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 timing key, 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.

ValueWhat it meansStatus
openai.chatThe OpenAI chat-completions request/response shapeFully decomposed. Most gateways and client libraries (litellm and friends) normalize everything to this shape — if that is what you send, declare it
anthropic.messagesThe Anthropic messages shapeFully decomposed
openai.responsesThe OpenAI Responses API shapeAccepted by the wire; decomposition is not implemented yet — see below
canonicalYou hold no provider body at allFully 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/request is a messages list. It is not {"text": "..."}. The list is what carries the conversation, the tool results being fed back, and the system prompt (as messages[0]).
  • usage.input_tokens is the total input, cache included, and the two cache counters are subsets of it. If your source reports nothing, omit usage entirely 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[] and modifications.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.