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.
How do these layers map to my SDK's vocabulary — spans, turns, threads?
One to one at the layer that matters. The full table, for OpenTelemetry's GenAI conventions and for the OpenAI Agents SDK, the Claude Agent SDK and LangGraph, is in the layer model. The short form:
| Your word | OGR |
|---|---|
gen_ai.conversation.id · SQLiteSession id · session_id · thread_id | session (L6) — send it as session_hint |
invoke_agent span · one Runner.run() · one query() prompt · one graph invoke() | turn (L5) |
chat {model} span · generation_span · one loop round trip · one model-node execution | step (L4) — exactly 1:1 |
that span's start / end · AssistantMessage and the next UserMessage | the two events (L3) |
execute_tool span · function_span · a tool_use block · a ToolNode call | call (L2) |
Two things worth knowing before you wire it up:
- ⚠️ "Turn" means our step in both the OpenAI Agents SDK and the Claude
Agent SDK. There, a turn is one iteration of the agent loop — one model
call plus the tool runs it triggers — and that is what
max_turnscounts. Our turn is the user-instruction episode that contains those iterations. Same word, one layer apart. - Mint
step_idfrom the inference span'sspan_idif you have one. That span covers both halves of the step, which is exactly the pairing rule, and it makes every guard row joinable to the trace it came from.
What does not carry over is the delivery model. A span is written when the
operation ends and may be sampled away; evaluate is called while the
request or the response is still held, synchronously, on every model call —
because a span cannot block and an unsampled step is an unjudged one. Tracing
tells you what happened; this decides whether it happens. (An SDK hook —
PreToolUse, wrap_tool_call — is a different story: those can refuse, which
is why the plugins in this repo sit there.)