FAQ: protocols and payloads
The questions integrators ask first, in the order they hit them. The normative answers live in the GuardEvent object and POST /v1/evaluate; this page is the short form.
I call several different models. Is the payload different for each one?
Yes — and that is the design, not a problem you have to solve.
payload is the provider body you already hold, forwarded untouched. An
OpenAI chat body and an Anthropic messages body look nothing alike, and
neither is normalized by you: you say which one it is with llm_protocol,
and the runtime does the decomposition — the new user words, the tool
outcomes being fed back, the model's prose, its reasoning, every tool call it
asked for, the declared tool inventory.
Everything outside payload is identical for every model and every
provider: kind, step_id, the identity four-tuple, and the three optional
fields. So "we added a second model" is a one-line change (a different
llm_protocol value), never a second integration.
Two consequences worth knowing up front:
- Do not decompose the body yourself. Sending a hand-extracted
{"text": "..."}throws away the tool calls, the tool results and the conversation — exactly the material the action-side detectors read. - Do not re-serialize it either. Findings and redaction spans carry
offsets into the bytes you sent; a re-encoded body shifts them. The one
legal addition is a top-level
timingkey, spliced in — no provider protocol defines one.
Which values does llm_protocol accept?
Four, and it is a closed enum — an unlisted string is rejected with 400 invalid_event.
| Value | What it means | Status |
|---|---|---|
openai.chat | The OpenAI chat-completions request/response shape | Fully decomposed. Most gateways and client libraries (litellm and friends) normalize everything to this shape — if that is what you send, declare it |
anthropic.messages | The Anthropic messages shape | Fully decomposed |
openai.responses | The OpenAI Responses API shape | Accepted by the wire; decomposition is not implemented yet — see below |
canonical | You hold no provider body at all | Fully supported — see below |
The value is your claim about the bytes. A runtime may verify it against the body and fall back to sniffing the shape, so a wrong claim degrades to weaker detection — never to a misparse.
openai.responses, today
The value is reserved in the wire and events declaring it are accepted and
recorded, but the reference runtime does not yet split that shape into its
parts. In practice a step/request is then judged only as a truncated dump
of the body, and a step/response yields no judged text at all — the event
is stored, the detection is not what you want.
Until it lands, send the Responses API traffic as
canonical — you are
converting a body you already hold into a message list, and you get full
coverage.
My protocol is not one of those four. What do I send?
llm_protocol: "canonical", with the payload in the canonical shape. This is
the answer for a harness with its own internal message format, an in-house
gateway, or a stream you judge after reassembling it — anything where no
single raw provider body exists.
// step/request — a message list, oldest first.
// The full conversation, exactly as a provider protocol carries it.
{ "messages": [ {"role": "system", "content": "…"},
{"role": "user", "content": "…"},
{"role": "assistant", "content": "…"},
{"role": "tool", "tool_call_id": "call_1", "content": "…"} ],
"tools": [ {"name": "bash", "description": "…", "schema": { /* JSON Schema */ }} ],
"timing": { "received_at": "2026-08-20T09:30:00.900Z" } }
// step/response
{ "text": "…",
"reasoning": "…",
"tool_calls": [ {"id": "call_1", "name": "bash", "arguments": {"command": "…"}} ],
"model": "…",
"usage": { "input_tokens": 8120, "cache_read_tokens": 0, "cache_write_tokens": 0,
"output_tokens": 64, "reasoning_tokens": 0 },
"timing": { "started_at": "…", "first_token_at": "…", "completed_at": "…" } }
Three rules that catch people out:
- A canonical
step/requestis amessageslist. It is not{"text": "..."}. The list is what carries the conversation, the tool results being fed back, and the system prompt (asmessages[0]). usage.input_tokensis the total input, cache included, and the two cache counters are subsets of it. If your source reports nothing, omitusageentirely rather than sending zeros — an integration holds no tokenizer, and absence is the honest value.- Your paths are your own. Because there is no provider body to translate
against, the paths in
findings[]andmodifications.spans[]name your canonical payload directly (payload.messages.1.content,payload.tool_calls.0.arguments.command), so spans apply in place with no mapping step.
Do I need different code per provider?
No. One function, called twice per model call, with the body you already
have — see
the minimal integration
and a complete exchange.
llm_protocol is a parameter, not a code path.
Can I add my own fields?
Inside payload, whatever the provider body contains is yours — the runtime
reads what it recognizes and carries the rest. Plus the one addition the
contract defines: a top-level timing.
Outside payload, no. The event envelope is closed (additionalProperties: false), so an unknown key is a 400 rather than a field quietly ignored.
That is what lets both ends roll forward independently: new optional fields
are additive, and absent ones are never an error.
Why did my event get a 400?
Almost always an extra top-level key. Fields that existed in pre-1.0 drafts —
timestamp, session_id, turn, step, ogr_version, agent_owner — are
gone from the wire: coordinates and timestamps are derived by the runtime, so
sending them is refused loudly instead of being silently ignored.
The response names the offending field, which is the whole migration guide:
{"error": "invalid_event",
"details": [{"code": "unrecognized_keys", "keys": ["timestamp"], "path": [],
"message": "Unrecognized key: \"timestamp\""}]}
The other common cause is an identity field left out. All four of
agent_id / agent_type / agent_workspace / agent_user are required,
with "" as the explicit "nothing to assert" — required-but-empty is
deliberate, so every integrator answers the identity question rather than
falling into the API-key floor by omission.
How do I judge a streamed response?
Once, whole, after the stream ends — never chunk by chunk. Withhold the
stream's final ~200 characters from the client, reassemble the complete
response, submit it as the step's one step/response, then release the tail
on allow or cut the stream on block. If no single raw body ever existed,
send the canonical shape with the counters transcribed from the stream. See
the quickstart.
Do I really send the whole conversation every time?
Yes — the wire is deliberately stateless and repetitive, exactly as the provider protocols are. A runtime is expected to deduplicate at ingress, and it reassembles sessions and turns from the history itself (re-attaching a conversation across context compaction, which is why it wants the messages rather than your session bookkeeping). The network cost buys an integration that needs no state and no session affinity.
If your harness already knows which conversation a call belongs to, send
session_hint — an opaque id of your own naming, stable across the calls of
one conversation. It is a grouping hint used for attribution only: never
authorization, never policy selection.