Skip to content

REST API / Local Engine

What you'll learn: How to integrate non-Python agents with Kyvvu using the local HTTP server (kyvvu serve).


Platform batch logging

Agents that submit logs directly to POST /api/v1/logs/batch must follow these rules. A batch that breaks any of them is refused as a whole and none of its steps is stored.

  • Timestamp on every step, as an RFC 3339 string with an explicit offset or Z and at most six fractional digits, for example 2026-10-14T10:00:01.000000Z. Epoch numbers, a space instead of T, and a missing offset are refused with HTTP 422. The timestamp records when the agent emitted the step, even if the batch is sent later.
  • Identifiers: task_id (and parent_task_id) use letters, digits and . _ : - only, 1-100 characters; action_id the same alphabet, 1-255. step_name is 1-255 characters with no control characters.
  • action_id on every step.*, unique within the task. Task boundaries (task.*) may omit it. A repeat inside one batch is refused with 422; one already stored for the task with 409 (code: duplicate_action_id).
  • Values in input, output, properties and meta are I-JSON (RFC 7493): no NaN or Infinity, integers within ±(2^53 − 1), string keys only, no lone surrogate characters.
  • seq, when you send it, is your own ordinal for the step and must be strictly increasing within the batch. In a batch without hash the platform numbers the stored steps itself and ignores it. In a batch with hash it is authoritative; see "The hash chain" below.
  • Delivery: a task may be sent in several batches of at most 1000 steps. The platform stores a batch before it answers 201. Send an Idempotency-Key to make a resend safe; reusing a key for a different batch gets 409.
  • Wire version: send X-Kyvvu-Wire-Version: 1 (or nothing). Another value is refused with 400 (code: unsupported_wire_version).
  • Client name (optional): send X-Kyvvu-Client: <name>/<version> to have the dashboard show your integration by name; see below.

What the dashboard shows for a REST client

The platform records, on each agent, the client that last spoke for it, and the Agents page shows it. A REST integration has no policy engine to report, so it never needs to send the X-Kyvvu-Engine-Version, X-Kyvvu-SDK-Version or X-Kyvvu-Policy-Schema headers the Python clients send (Client and platform compatibility).

What you send on registration and batches Recorded as Shown as Flagged Needs update
no X-Kyvvu-* header rest REST integration never
X-Kyvvu-Client: support-bot/1.4 support-bot/1.4 support-bot/1.4 never

X-Kyvvu-Client takes letters, digits and . + _ / -, up to 128 characters; a value outside that is ignored rather than refused.

Only a policy fetch (GET /api/v1/policies?agent_key=...) without the headers is recorded as legacy and flagged: that is what a Python engine too old to announce itself does. A REST client that fetches policies should therefore send X-Kyvvu-Client.

A complete request

Two calls, both authenticated with an agent API key (KvKey-..., created under Workspace → API Keys) in an Authorization: Bearer header. Set KV_API_URL to your platform URL; it defaults to https://platform.kyvvu.com. A personal access token for the CLI is refused with 401.

1. Get the agent's platform id. The batch's agent_id is the identifier the platform assigned to the agent (agent_ followed by 16 hex characters), not your own agent_key. Sending the agent key is refused with 400 "Agent 'support-agent' not found, not owned by this API key, or inactive.". Registering the agent returns the id:

curl -s -X POST "${KV_API_URL:-https://platform.kyvvu.com}/api/v1/agents" \
  -H "Authorization: Bearer $KV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent_key": "support-agent",
    "name": "Support agent",
    "purpose": "Answers customer support questions from the FAQ and the CRM",
    "declared_tools": ["read_faq", "read_crm"],
    "risk_classification": "minimal",
    "environment": "development"
  }'

The response (201) is the agent record; read agent_id from it. These fields are excerpted from the full response:

{
  "agent_id": "agent_3f9c2a7b1d4e6f80",
  "agent_key": "support-agent",
  "registration_status": "created"
}

Registration is idempotent per agent_key within the workspace: sending it again answers registration_status: "updated" with the same agent_id, so an agent can register at every start and read its id from the answer. Other places it appears: kv.agent_id after kv.register_agent() in the Python client, the AGENT_ID column of kyvvu list-agents, and the agent's page on the dashboard.

2. Send the task. Steps are in the order they happened; every step.* has an action_id, and task.* boundaries do not need one:

curl -s -X POST "${KV_API_URL:-https://platform.kyvvu.com}/api/v1/logs/batch" \
  -H "Authorization: Bearer $KV_API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Kyvvu-Wire-Version: 1" \
  -H "Idempotency-Key: task-7f3e-batch-1" \
  -d '{
    "agent_id": "agent_3f9c2a7b1d4e6f80",
    "task_id": "task-7f3e",
    "steps": [
      {"kind": "task.start", "step_name": "task.start",
       "timestamp": "2026-10-14T10:00:00.000000Z"},
      {"kind": "step.resource", "verb": "GET", "step_name": "read_faq",
       "action_id": "act-1", "input": {"query": "refund policy"},
       "output": {"answer": "Refunds within 30 days."},
       "timestamp": "2026-10-14T10:00:01.000000Z"},
      {"kind": "step.model", "step_name": "draft_reply", "action_id": "act-2",
       "properties": {"model": "gpt-4o"}, "input": {"prompt": "Draft a reply"},
       "output": {"response": "You can get a refund within 30 days."},
       "timestamp": "2026-10-14T10:00:02.000000Z"},
      {"kind": "task.end", "step_name": "task.end",
       "timestamp": "2026-10-14T10:00:03.000000Z"}
    ]
  }'

Response (201), sent after the steps are stored:

{"task_id": "task-7f3e", "steps_logged": 4, "hash_tail": "1f0bc4871392e38130d4defa6aeff75e5ff0d7e9850a6fe2e80bad48b238db50"}

hash_tail is the hash of the last stored entry of the task's chain (see below); it is not a signed receipt. Resending the same body with the same Idempotency-Key answers the same response and stores nothing twice. step.model takes no verb; the valid pairs list what each kind takes.

The hash chain: who computes it

Every stored step carries a hash that links it to the previous step of the same agent's task (chain v1). The hash is

import hashlib
import json


def chain_hash(agent_id, task_id, seq, input_data, previous_hash=None):
    payload = {
        "agent_id": agent_id,
        "task_id": task_id,
        "seq": seq,
        "input_data": input_data or {},
    }
    if previous_hash:
        payload["previous_hash"] = previous_hash
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    return hashlib.sha256(encoded).hexdigest()

with input_data the step's input ({} when it has none), and previous_hash the previous step's hash, left out for the task's first step. It does not cover kind, verb, timestamp, output, properties or meta, and anyone who can write the stored rows can recompute it: it shows that rows still agree with their hashes, not who wrote them. Signatures and a wider hash are planned for the next format.

The Kyvvu engine, the Python client and kyvvu-claude compute the chain themselves (kyvvu_engine.chain_entry_hash) and send seq and hash on every step. The platform then checks, inside the same locked transaction that stores the batch:

  • the first step's seq is the task's stored tail + 1 (1 for a new task) and the rest follow without gaps;
  • every hash equals its own recomputation, chained from the stored tail.

A batch that fails either check is refused whole with HTTP 422, and nothing is stored. detail.code is seq_mismatch (with index, seq and expected_seq) or hash_mismatch (with index, seq and expected_hash), naming the first bad step. The hash_tail of a 201 then equals the client's own chain head. A resend under the same Idempotency-Key is answered from the stored receipt before anything is checked. hash is on every step of a batch or on none; mixing them, or sending hash without seq, is a 422.

A REST client does not have to compute anything. Leave hash (and seq) out, as in the example above, and the platform numbers and hashes the steps itself, exactly as it always has.

kyvvu serve (local engine)

For agents written in JavaScript, Go, Rust, or any language, kyvvu serve runs the engine as a local HTTP server. Your agent makes HTTP calls to evaluate steps — same engine, same sub-millisecond policies, no Python needed in your agent code.

Start the server

uv tool install kyvvu-cli

(or pip install kyvvu-cli). kyvvu serve is provided by kyvvu-cli, which includes the engine server and its web dependencies.

The server needs a policy source, and there are two kinds. Only one of them needs an account.

Local — no credentials, no network: run one alternative at a time.

kyvvu serve --baseline                       # the frozen 15-policy bundled baseline
kyvvu serve --policy-file my-manifest.yaml   # your own manifest (repeatable)

Platform — fetches this agent's current policy set:

export KV_API_URL=https://platform.kyvvu.com
export KV_API_KEY=KvKey-...
export KV_AGENT_KEY=my-agent
kyvvu serve --host 127.0.0.1 --port 8080

With the platform as the source, send the platform agent_id, the one POST /api/v1/agents returned for this agent_key (agent_ followed by 16 hex characters; see A complete request), in every agent_id and context.agent_id below, not a name of your own and not the agent_key. The platform's policies for the agent carry that id, and a policy naming an agent applies only to that agent. The server learns the id when it fetches the policies, prints it at start (Agent id:) and reports it as agent_id in /health:

kyvvu serve — policy evaluation server
  API:       https://platform.kyvvu.com
  Agent:     my-agent
  Agent id:  agent_3f9c2a7b1d4e6f80 (send this as agent_id)
  Endpoints: http://127.0.0.1:8080/evaluate, /record, /end_task, /register_agent, /health

Any other agent_id is refused with HTTP 409 on every endpoint, with a detail naming both ids, for example This runner governs agent_id 'agent_3f9c2a7b1d4e6f80' and received 'my-agent'. The platform serves the policies for agent_key='my-agent' under agent_id='agent_3f9c2a7b1d4e6f80', .... With --baseline or --policy-file the policies name no agent, so any agent_id applies; the calls below run unchanged against either source.

A kyvvu serve with neither does not start: the runner refuses a configuration naming no policy source, and the server exits 1 with a message naming what is missing. That is deliberate — the complaint lands where the configuration is supplied rather than on the first governed step.

When the server refuses to start, and when it starts ungoverned

Three things stop kyvvu serve at start, and all exit 1:

  • No policy source at all (no --policy-file, --baseline or credentials).
  • A policy set that is invalid. A manifest that is missing, does not parse, names an unknown rule_type, has params that do not match the rule, a duplicate policy name or a bad regex is refused whole.
  • Zero policies in effect. A valid manifest with policies: [] exits with none_configured; an unreachable platform with no cached policies exits with unavailable.

--allow-ungoverned explicitly permits starting with zero policies. In that mode, /health answers HTTP 200 with "governed": false and load_state set to none_configured or unavailable; /evaluate allows every step. Check the governed field if readiness depends on enforcement:

curl -s http://127.0.0.1:8080/health | python3 -c "import sys, json; h = json.load(sys.stdin); sys.exit(0 if h['governed'] else 1)"

An enforcing set stays governed if a later fetch fails; /health then reports "governed": true and "load_state": "stale_cache".

Endpoints

Method Path Purpose
GET /health Liveness probe — returns policy status.
POST /evaluate Preflight evaluation of an intended behaviour.
POST /record Record a completed step into task history.
POST /end_task Close a task — evict history and flush logs.
POST /register_agent Evaluate agent-registration policies.

Health check

These examples show startup snapshots. Instance IDs, timestamps and remaining TTL vary between processes and over time.

curl http://127.0.0.1:8080/health

Against the platform (source: api):

{
  "loaded": true,
  "last_success": "2026-10-16T18:38:59.480041Z",
  "last_attempt": "2026-10-16T18:38:59.480041Z",
  "policy_count": 15,
  "stale": false,
  "source": "api",
  "last_fetch_at": "2026-10-16T18:38:59.480041Z",
  "last_fetch_succeeded": true,
  "instance_id": "898dc674-6b88-4ac2-837f-fb9a94b6e4c8",
  "ttl_remaining_seconds": 300.0,
  "load_state": "enforcing",
  "policies_offered": 15,
  "registration_blocked_by": [],
  "stale_cause": null,
  "client_upgrade": null,
  "agent_id": "agent_3f9c2a7b1d4e6f80",
  "applicable_policy_count": 15,
  "registration_blocked": false,
  "governed": true
}

From kyvvu serve --baseline, as printed by a real server:

{
  "loaded": true,
  "last_success": null,
  "last_attempt": "2026-10-16T18:38:59.480041Z",
  "policy_count": 15,
  "stale": false,
  "source": "local",
  "last_fetch_at": null,
  "last_fetch_succeeded": false,
  "instance_id": "898dc674-6b88-4ac2-837f-fb9a94b6e4c8",
  "ttl_remaining_seconds": 300.0,
  "load_state": "enforcing",
  "policies_offered": 15,
  "registration_blocked_by": [],
  "stale_cause": null,
  "client_upgrade": null,
  "agent_id": null,
  "applicable_policy_count": null,
  "registration_blocked": false,
  "governed": true
}

From a kyvvu serve whose platform requires engine 9.0.0 and refused this client as too old (426 client_upgrade_required, see compatibility), still enforcing the set in its policy cache (KV_POLICY_CACHE_PATH):

{
  "loaded": true,
  "last_success": null,
  "last_attempt": "2026-10-16T18:38:59.480041Z",
  "policy_count": 15,
  "stale": false,
  "source": "disk_cache",
  "last_fetch_at": null,
  "last_fetch_succeeded": false,
  "instance_id": "898dc674-6b88-4ac2-837f-fb9a94b6e4c8",
  "ttl_remaining_seconds": 300.0,
  "load_state": "stale_cache",
  "policies_offered": 15,
  "registration_blocked_by": [],
  "stale_cause": "client_upgrade_required",
  "client_upgrade": {
    "code": "client_upgrade_required",
    "detail": "This platform requires kyvvu-engine 9.0.0 or later.",
    "min_engine_version": "9.0.0",
    "min_policy_schema": 1,
    "upgrade": "uv tool upgrade kyvvu-cli"
  },
  "agent_id": "agent_3f9c2a7b1d4e6f80",
  "applicable_policy_count": 15,
  "registration_blocked": false,
  "governed": true
}

For a local source nothing is fetched, so last_fetch_at is null and last_fetch_succeeded is false (and so is last_success): that is the absence of a fetch, not a failed one. Read source and load_state instead; they are the fields that say whether the server is governing.

The fields that decide what the server does:

Field Meaning
governed true when at least one policy is in effect (load_state is enforcing or stale_cache). The readiness check for a port that requires enforcement.
load_state Why the server is or is not governing; see the table below.
registration_blocked true while the agent's last registration evaluation was blocked. Every step.* is then answered block; see Register an agent.
registration_blocked_by The names of the critical registration policies that blocked it; empty when it is not blocked.
stale_cause Why a stale_cache set is stale: fetch_failed, set_refused, client_upgrade_required, cache_expired, local_source_unreadable or caller_marked. null when the set is not stale.
client_upgrade When stale_cause is client_upgrade_required, what the platform said: code, detail, min_engine_version, min_policy_schema and the upgrade command to run. null otherwise.
agent_id The agent this server governs. With the platform as the source, the platform agent_id its policies were served for, known from the first fetch: the agent_id to send, and the only one accepted. With a local source, the agent_id of the first call, and null before it.
applicable_policy_count How many of the policy_count loaded policies apply to agent_id (those naming no agent, plus those naming it). Equal to policy_count when the ids match; null while agent_id is.

Read load_state, not policy_count. A count of zero cannot tell two different situations apart, and both allow every step:

load_state Meaning Governed?
enforcing Policies loaded and in effect yes
stale_cache Serving a cache past its TTL after a failed fetch yes
none_configured The source has no policies — nothing assigned no
unavailable The source could not be reached no

There is no partial state. A policy set is loaded whole or refused whole: a policy with an unknown rule_type, params that do not match its rule, a duplicate name, a bad regex or the retired scope: key raises PolicyLoadError at load, so kyvvu serve exits 1 instead of starting with fewer policies than the file names. policies_offered and policy_count therefore agree; a background refresh that is refused keeps the last good set and reports stale_cache.

Request bodies

Every body is JSON. The objects are strict, context included: a key the schema does not define returns HTTP 422 naming it, and so does a value that is not one of the allowed enum values.

A behaviour (intended in /evaluate, step in /record). Its kind field identifies the action kind; kind and verb must be one of the valid pairs:

Field Required Meaning
agent_id yes The agent's identifier: letters, digits and . _ : -, at most 255 characters. When the platform is the policy source (KV_API_URL + KV_API_KEY + KV_AGENT_KEY), send the platform agent_id returned at registration (agent_…, also agent_id in /health): policies served by the platform carry that id, and any other agent_id is refused with 409. With --baseline or a local policy file, any name works.
task_id yes The identity of the task. Path history is kept per task_id; see below. Letters, digits and . _ : -, 1-100 characters.
action_id on /record of a step.* Identifies one action. Unique per (agent_id, task_id): the only reuse allowed is the /record of a step under the action_id its /evaluate returned. Optional on /evaluate (the server generates one and returns it); omit it on task.* boundaries. A duplicate is a 422. Letters, digits and . _ : -, 1-255 characters.
kind yes One of the 12 action kinds.
step_name yes The tool or action, e.g. send_email. Rules such as the allowlist read it. 1-255 characters, no control characters (U+0000-U+001F, U+007F-U+009F).
verb per action kind GET, POST, PATCH or DELETE; omit it (or send null) for task.*, step.model, step.exec, step.gate and step.unknown.
input, output, properties, meta, timestamp, seq no properties is what policies read through paths; output belongs in /record, where it is the result of the step. meta keys starting with kyvvu. are reserved for Kyvvu and are a 422.

An identifier outside these rules is a 422 naming the field and the rule, for example task_id 'run/42': use letters, digits and . _ : - only (1-100 characters); /end_task and context apply the same rules. See identifier rules.

context (an EvalContext; agent_id is its only required field; required by /evaluate and /register_agent, not used by /record):

Field Default Meaning
agent_id required The agent the evaluation is for.
environment development (optional) One of development, qa, staging, production; anything else is a 422. Used by policies that differ per environment. The default is development, not production (the Python client defaults to production), so send it explicitly.
risk_classification none (optional) The agent's EU AI Act class: minimal, limited, high, unacceptable or unclassified; anything else is a 422. Omit it and a policy limited to a class (such as risk_classification: high) applies, so an unclassified agent is treated as possibly high-risk.
agent_allowed_tools none The agent's declared tool names. The bundled baseline's "Tool calls must be in the declared allowlist" reads it and blocks every step.resource, step.exec and step.credential while it is empty, so a baseline server needs it on every call.
agent_name, agent_purpose, agent_owner, user_id, manifests, project_root none (all optional) Metadata some rules read.
task_id none (optional) Accepted but ignored: only intended.task_id identifies the task.

context is strict. An unknown key is an HTTP 422 naming it, for example agent_allowed_tool (a misspelling of agent_allowed_tools) returns extra_forbidden with "loc":["body","context","agent_allowed_tool"]. Previously, such a key was silently dropped, so the tool allowlist saw no tools and the rule failed open. A bad environment (dev) or risk_classification (super) is a 422 of type literal_error listing the valid values. kind and verb are validated the same way.

What a port to TypeScript, Go or another language needs to know

The seq field of a behaviour is server-assigned. Whatever you send in seq is currently ignored; do not rely on that, as a later version may honour or reject a supplied value. /record assigns it and returns it in its response. It is strictly increasing per (agent_id, task_id) and may have gaps; do not assume it starts at a particular value, and the order it gives concurrent /record calls of one task is unspecified. /evaluate neither reads nor returns it. Leave it out. A behaviour that still carries the retired ordinal field step is rejected with HTTP 422 naming seq.

The server has no authentication. Anyone who can reach the port can evaluate, record and end tasks, and can reset another caller's task history with /end_task. It binds to 127.0.0.1 by default, which is the intended use: a sidecar next to the agent. --host 0.0.0.0 exposes it to every network the machine is on. Put it behind something that authenticates (a service-mesh policy, a private network, a reverse proxy with a credential). The API key you give kyvvu serve is used to fetch policies; it does not protect the endpoints.

Concurrent requests for the same task_id are safe for the engine's state, not for your ordering. The task history is guarded by one lock, so every /record gets a unique, strictly increasing seq. The routes are plain def handlers, so the server runs them on a thread pool and these calls really do overlap. What the engine cannot do is order them for you: an /evaluate that overlaps a /record of the same task may or may not see that step, and a history rule ("no message after a PII read") judges only what had been recorded when it read. Serialise /record then /evaluate per task in your agent, as the Python client does. Different task_ids do not interact.

A task is written by the process that opened it. Open a task by recording its task.start (see the integration pattern); the server answers 409 to any other /evaluate or /record for a task it has not seen open, to a second task.start, to /end_task for a task that was never opened, and to any write into a task it has ended or evicted. In 1.0 a task cannot be continued by another server, or after a restart; how a task may be continued elsewhere is not yet defined.

risk_score is the severity of the worst violated policy, on a 0 to 1 scale, not a probability and not a sum. The weights are low 0.25, medium 0.5, high 0.75, critical 1.0, and with no violation it is 0.0. The action follows from it: 0.0 is allow, anything between 0 and 1 is warn, and 1.0 is block. So blocked: true means at least one critical policy was violated. Branch on action or blocked, and use risk_score only for display.

/health for an empty source. With --allow-ungoverned, a manifest with policies: [] answers HTTP 200 with governed: false, loaded: false and load_state: "none_configured" (the instance id and times vary):

{
  "loaded": false,
  "last_success": null,
  "last_attempt": "2026-10-16T18:38:59.480041Z",
  "policy_count": 0,
  "stale": false,
  "source": "local",
  "last_fetch_at": null,
  "last_fetch_succeeded": false,
  "instance_id": "898dc674-6b88-4ac2-837f-fb9a94b6e4c8",
  "ttl_remaining_seconds": 300.0,
  "load_state": "none_configured",
  "policies_offered": 0,
  "registration_blocked_by": [],
  "stale_cause": null,
  "client_upgrade": null,
  "agent_id": null,
  "applicable_policy_count": null,
  "registration_blocked": false,
  "governed": false
}

Use governed as the readiness check when the port requires policies in effect.

A task, call by call

The calls below are one task, in order, and run as written against kyvvu serve --baseline on the default port, and against a server whose source is the platform when agent_3f9c2a7b1d4e6f80 is the agent_id it reports (tests execute them both ways). Against the platform, put your own agent's agent_id in their place. Every /evaluate and /record for a task is refused with 409 until its task.start is recorded, so start there.

Open the task

curl -X POST http://127.0.0.1:8080/record \
  -H "Content-Type: application/json" \
  -d '{
    "step": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "kind": "task.start",
      "step_name": "task.start"
    }
  }'

Response: {"seq": 1, "task_id": "task-abc"}. A task.start takes no action_id and no verb. You may /evaluate it first, as the integration pattern does; that is optional, and the only /evaluate the server accepts for a task that is not yet open.

Evaluate a step

curl -X POST http://127.0.0.1:8080/evaluate \
  -H "Content-Type: application/json" \
  -d '{
    "intended": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "action_id": "act-1",
      "kind": "step.model",
      "step_name": "chat_gpt-4o",
      "input": {"user_message": "Hello"}
    },
    "context": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "environment": "production",
      "risk_classification": "limited",
      "agent_allowed_tools": ["chat_gpt-4o"]
    }
  }'

Response, from kyvvu serve --baseline (abridged: the real policies array has one entry for each of the seven policies evaluated per step; policy_id and manifest_id are null when the policies come from a local file or the bundled baseline):

{
  "action": "allow",
  "risk_score": 0.0,
  "policies": [
    {
      "policy_id": null,
      "name": "AI-generated content must be marked before delivery",
      "rule_type": "not",
      "severity": "high",
      "violated": false,
      "violation_details": null,
      "manifest_id": null,
      "manifest_name": "EU AI Act - Minimal / Limited Risk Compliance"
    }
  ],
  "action_id": "act-1",
  "blocked": false,
  "recorded_seq": null
}

action_id is optional on /evaluate: leave it out and the server generates one and returns it. Either way, the /record of this step sends it back. recorded_seq is set only on a blocked step, which the server records itself.

The three outcomes:

action blocked HTTP What to do
allow false 200 Run the step.
warn false 200 Run the step. A policy of severity high, medium or low was violated; the violated entries in policies say which. Log it, surface it, or act on it: the server does nothing more. It also raises a Python UserWarning in its own process.
block true 200 Do not run the step. A critical policy was violated.

The server always returns HTTP 200 for a policy decision. Read action (or blocked), never the status code. A 422 means the request was malformed; it is not a verdict.

Record the completed step

curl -X POST http://127.0.0.1:8080/record \
  -H "Content-Type: application/json" \
  -d '{
    "step": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "action_id": "act-1",
      "kind": "step.model",
      "step_name": "chat_gpt-4o",
      "input": {"user_message": "Hello"},
      "output": {"response": "Hi there!"}
    }
  }'

/record nests the Behavior under a step key. That key names the whole Behavior, not its ordinal, which is seq inside it. /evaluate nests it under intended and requires a sibling context object.

Behavior objects are strict. A Behavior in a request body accepts only the fields the schema defines: agent_id, task_id, action_id, aag_version (on task.start only), timestamp, seq, kind, verb, step_name, input, output, properties, and meta. Any other key returns HTTP 422 identifying the field the server rejected. An explicit timestamp must have a timezone offset; if omitted, the engine stamps the Behavior when it constructs it.

The timestamp is the evaluation time. Time-based rules such as working_hours_only read it, so stamp the behaviour when your agent forms the intent. /evaluate answers 422, naming both times, when it is more than KV_MAX_CLOCK_SKEW_SECONDS (default 300) from the server's clock in either direction; /record accepts any recorded time. context has no now field, and sending one is a 422.

Response: {"seq": 2, "task_id": "task-abc"} (seq is strictly increasing per task and may have gaps; it is not a count). Only recorded steps enter the task's history. /evaluate alone does not: a path rule such as "no message after a PII read" sees the read only if you /record it.

A step the baseline warns about

The baseline allows the model call itself: its content-marking policy (EU AI Act Article 50(2)) asks for a step.gate between model output and its delivery. Delivering that output with no gate in between is a warn:

curl -X POST http://127.0.0.1:8080/evaluate \
  -H "Content-Type: application/json" \
  -d '{
    "intended": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "action_id": "act-2",
      "kind": "step.message",
      "verb": "POST",
      "step_name": "send_reply",
      "input": {"text": "Hi there!"}
    },
    "context": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "environment": "production",
      "risk_classification": "limited",
      "agent_allowed_tools": ["chat_gpt-4o"]
    }
  }'

The response is "action": "warn", "risk_score": 0.75, with two entries violated: true: "AI-generated content must be marked before delivery" and "Chatbot disclosure gate required before outbound messages". A warning does not stop the step, so run it and record it:

curl -X POST http://127.0.0.1:8080/record \
  -H "Content-Type: application/json" \
  -d '{
    "step": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "action_id": "act-2",
      "kind": "step.message",
      "verb": "POST",
      "step_name": "send_reply",
      "input": {"text": "Hi there!"},
      "output": {"delivered": true}
    }
  }'

End the task

Record the closing task.end (or task.error), then call /end_task:

curl -X POST http://127.0.0.1:8080/record \
  -H "Content-Type: application/json" \
  -d '{
    "step": {
      "agent_id": "agent_3f9c2a7b1d4e6f80",
      "task_id": "task-abc",
      "kind": "task.end",
      "step_name": "task.end"
    }
  }'

curl -X POST http://127.0.0.1:8080/end_task \
  -H "Content-Type: application/json" \
  -d '{"task_id": "task-abc", "agent_id": "agent_3f9c2a7b1d4e6f80"}'

agent_id is required; another agent's id is a 409. Response: {"status": "ok", "task_id": "task-abc"}. A task_id the server never saw opened, or has already ended, is a 409; so is any /evaluate or /record into task-abc from now on, so the calls above run once per server. Any intent still open is recorded as abandoned before the task closes.

Register an agent (evaluate registration policies)

/register_agent runs the agent_registration policies against an agent description. It registers nothing with the platform: the platform is where agents are registered, and this endpoint tells you what the registration policies decide. It is not a dry run, though: the server holds the result, and a blocked registration blocks the agent's steps (below). The body is the agent record in agent_data plus a context:

curl -X POST http://127.0.0.1:8080/register_agent \
  -H "Content-Type: application/json" \
  -d '{
    "agent_data": {
      "name": "Support agent",
      "purpose": "Answers customer support questions from the FAQ and the CRM",
      "declared_tools": ["read_faq", "read_crm"],
      "risk_classification": "minimal",
      "owner_id": "you@example.com"
    },
    "context": {"agent_id": "agent_3f9c2a7b1d4e6f80", "environment": "development", "risk_classification": "minimal"}
  }'

The response has the same shape as /evaluate. From kyvvu serve --baseline this one is {"action": "allow", "risk_score": 0.0, ..., "blocked": false} with the eight registration policies, all violated: false. The keys of agent_data are what the registration rules read through field (see Field and property paths).

Leave out everything but name and the baseline answers block (blocked: true, risk 1.0) and lists seven violated policies, among them the missing purpose, tool allowlist, risk classification and owner:

curl -X POST http://127.0.0.1:8080/register_agent \
  -H "Content-Type: application/json" \
  -d '{
    "agent_data": {"name": "Support agent"},
    "context": {"agent_id": "agent_3f9c2a7b1d4e6f80", "environment": "development", "risk_classification": "minimal"}
  }'

A blocked registration blocks the agent until a registration passes. This is deliberate, and fail-closed: an agent whose registration a critical policy refused is not allowed to act. After the call above:

  • /health reports "registration_blocked": true and "registration_blocked_by": ["Agent must declare a valid risk classification", "Risk classification must be valid"], the critical policies that blocked it (the other five violated policies are high or medium, which warn and do not block).
  • Every /evaluate of a step.* answers block (risk 1.0), whatever the step, with those registration policies as the violated entries, and the server records the blocked step like any other block. Task boundaries are not refused, so a task can still be opened and closed.
  • It clears only when a later /register_agent does not block: send the full description from the first example again and /health reports "registration_blocked": false, and steps are judged by the step policies again.

The server holds one registration, the last one evaluated (one server, one agent). When the server fetches its policies from the platform and the registration policies change (a manifest assigned to the running agent), it re-evaluates the held registration on its own, and the same state is set or cleared then. A restart forgets it.

Errors

A request that does not match a schema is HTTP 422 with a detail list; the msg says what to change. For a kind and verb pair that does not exist, it names the verbs the action kind takes:

{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body",
        "intended"
      ],
      "msg": "Value error, Invalid behavior combination: kind='step.model', verb='GET'. step.model takes no verb (omit it)."
    }
  ]
}

A missing context on /evaluate is {"detail":[{"type":"missing","loc":["body","context"],"msg":"Field required", ...}]}, and a missing intended.task_id is the same with "loc":["body","intended","task_id"]. A misspelt kind (step.mesage) is a 422 of type enum listing the twelve valid values.

Task identity and state

  • intended.task_id (and step.task_id) is the only task identity. Path state is kept per task_id. Use a fresh, unique id per task (a UUID); two tasks that share an id share their history.
  • State lives in the server's memory. The history that path rules read (the PII read, the approval gate, the step counts) is held by the process. After a restart it is empty, so a task that was midway through "PII read, then email" is allowed to send the email, because the server no longer knows about the read. Restarting the server mid-task weakens enforcement; run one long-lived server, and treat a restart as the end of every open task.
  • Steps are flushed to the trace when the task ends. Steps that were recorded but never ended with /end_task are lost if the process stops (SIGTERM included). An abandoned task's buffer is evicted after KV_TASK_MAX_AGE_SECONDS (default one hour) and flushed then, if the process is still running. Blocks are reported as incidents when they happen, so they are not lost.
  • /end_task closes the task for good. It evicts the history for that task_id, and the server refuses (409) any later write into it, so a task_id is never reused. The server remembers a bounded number of ended ids; generate a fresh one per task regardless.
  • One server, one agent. With the platform as the source, the agent is the one the platform serves the policies for, whose agent_id /health reports, and the server answers 409 to any other agent_id, including one that called before the policies arrived. With a local source the server binds to the first agent_id that calls it, and answers 409 to any other. Run one server per agent; give each task its own task_id.

Integration pattern

This runnable Bash, curl and jq example uses a canned operation result. It executes an allowed step, skips a blocked code-execution step, and closes the task. Replace execute_step with your integration's operation.

The typical flow for a non-Python agent:

# Demo: record an allowed operation and skip a blocked operation.
base_url=${KV_SERVE_URL:-http://127.0.0.1:8080}
agent_id=agent_3f9c2a7b1d4e6f80
task_id=task-pattern
context=$(jq -nc --arg agent "$agent_id" '{agent_id:$agent, environment:"development", risk_classification:"limited", agent_allowed_tools:["read_faq","run_script"]}')
request() {
  curl --fail --silent --show-error -X POST "$base_url$1" \
    -H 'Content-Type: application/json' -d "$2"
}
boundary() {
  step=$(jq -nc --arg agent "$agent_id" --arg task "$task_id" --arg kind "$1" \
    '{agent_id:$agent, task_id:$task, kind:$kind, step_name:$kind}')
  request /evaluate "$(jq -nc --argjson step "$step" --argjson context "$context" '{intended:$step, context:$context}')" >/dev/null
  request /record "$(jq -nc --argjson step "$step" '{step:$step}')" >/dev/null
}
execute_step() {
  printf 'executed %s\n' "$1" >&2
  printf 'demo output'
}
submit() {
  step=$(jq -nc --arg agent "$agent_id" --arg task "$task_id" --arg name "$1" --arg kind "$2" \
    '{agent_id:$agent, task_id:$task, step_name:$name, kind:$kind, input:{query:"demo"}}')
  if [ "$2" = step.resource ]; then step=$(jq '. + {verb:"GET"}' <<<"$step"); fi
  verdict=$(request /evaluate "$(jq -nc --argjson step "$step" --argjson context "$context" '{intended:$step, context:$context}')")
  if jq -e '.blocked' <<<"$verdict" >/dev/null; then
    printf 'blocked %s\n' "$1" >&2
    return
  fi
  output=$(execute_step "$1")
  completed=$(jq --argjson verdict "$verdict" --arg output "$output" \
    '. + {action_id:$verdict.action_id, output:{result:$output}}' <<<"$step")
  request /record "$(jq -nc --argjson step "$completed" '{step:$step}')" >/dev/null
}
boundary task.start
submit read_faq step.resource
submit run_script step.exec
boundary task.end
request /end_task "$(jq -nc --arg agent "$agent_id" --arg task "$task_id" '{agent_id:$agent, task_id:$task}')"

The first write into a task must be its task.start; anything else is a 409.


Next steps