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
Zand at most six fractional digits, for example2026-10-14T10:00:01.000000Z. Epoch numbers, a space instead ofT, 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(andparent_task_id) use letters, digits and. _ : -only, 1-100 characters;action_idthe same alphabet, 1-255.step_nameis 1-255 characters with no control characters. action_idon everystep.*, 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,propertiesandmetaare I-JSON (RFC 7493): noNaNorInfinity, 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 withouthashthe platform numbers the stored steps itself and ignores it. In a batch withhashit 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-Keyto 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
seqis the task's stored tail + 1 (1 for a new task) and the rest follow without gaps; - every
hashequals 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,--baselineor 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 withnone_configured; an unreachable platform with no cached policies exits withunavailable.
--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!"}
}
}'
/recordnests the Behavior under astepkey. That key names the whole Behavior, not its ordinal, which isseqinside it./evaluatenests it underintendedand requires a siblingcontextobject.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(ontask.startonly),timestamp,seq,kind,verb,step_name,input,output,properties, andmeta. Any other key returns HTTP 422 identifying the field the server rejected. An explicittimestampmust have a timezone offset; if omitted, the engine stamps the Behavior when it constructs it.The
timestampis the evaluation time. Time-based rules such asworking_hours_onlyread it, so stamp the behaviour when your agent forms the intent./evaluateanswers 422, naming both times, when it is more thanKV_MAX_CLOCK_SKEW_SECONDS(default 300) from the server's clock in either direction;/recordaccepts any recorded time.contexthas nonowfield, 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:
/healthreports"registration_blocked": trueand"registration_blocked_by": ["Agent must declare a valid risk classification", "Risk classification must be valid"], thecriticalpolicies that blocked it (the other five violated policies arehighormedium, which warn and do not block).- Every
/evaluateof astep.*answersblock(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_agentdoes not block: send the full description from the first example again and/healthreports"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(andstep.task_id) is the only task identity. Path state is kept pertask_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_taskare lost if the process stops (SIGTERMincluded). An abandoned task's buffer is evicted afterKV_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_taskcloses the task for good. It evicts the history for thattask_id, and the server refuses (409) any later write into it, so atask_idis 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/healthreports, and the server answers 409 to any otheragent_id, including one that called before the policies arrived. With a local source the server binds to the firstagent_idthat calls it, and answers 409 to any other. Run one server per agent; give each task its owntask_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¶
- kyvvu serve — CLI reference for the serve command
- Engine settings — all environment variables
- Architecture — why in-process evaluation is preferred