The GuardEvent object
A GuardEvent is the unit an integration point submits to the runtime — one
HALF of a step (one model call), observed at the moment the integration can
still refuse it. It is the request body of
POST /v1/evaluate.
Eight required fields, three optional. v1.0 keeps every knob a producer could choose to skip off the wire: what a runtime can derive is not on the wire at all (coordinates, timestamps, protocol versioning), and what only the producer can know is mandatory — with the empty string as the explicit "I have nothing to assert". An integration is an API key, eight required fields, and one endpoint. The three optional fields exist so the two ends of a deployment can roll forward independently — making any of them mandatory would reject every build already in the field.
Normative schema:
schema/guard-event.schema.json
(mirrored at /schema/1.0/guard-event.schema.json).
The object is closed: additionalProperties: false.
The eight required fields
| Field | Type | Description |
|---|---|---|
kind | enum | step/request | step/response — see Kinds |
step_id | string | Producer-minted opaque id binding one model call's two events — see step_id |
agent_id | string | WHICH agent — unique within the organization. "" = derived from the API key |
agent_type | string | What KIND of agent — a harness/product label, never an identity. "" = unlabeled |
agent_workspace | string | The named GROUP of agents this one belongs to — one workspace, one policy set. "" = the key's workspace |
agent_user | string | Who is USING the agent this session. "" = every session is one user |
llm_protocol | enum | openai.chat | openai.responses | anthropic.messages | canonical — see llm_protocol |
payload | object | The raw provider body, forwarded untouched — see Payloads |
The three optional fields
Send each when you hold the fact; omit it when you do not. All three are attribution/diagnostic signals — a runtime never derives trust, policy or authorization from them:
| Field | Type | Description |
|---|---|---|
integration | string | Who reported this event — the reporter's own "name/version" (e.g. "ogr-higress/3.5.0"). The heartbeat carries the liveness copy; this is the per-event triage copy |
connection | string | The reporter's own opaque downstream-flow id (e.g. "<instance>#<connection ordinal>"), stable for the life of one client connection. The one session signal a client cannot strip; a corroborated last-resort grouping signal only |
session_hint | string | The producer's own opaque name for the conversation this step belongs to. A harness that knows its session says so, and the runtime prefers it to prefix-chain inference |
Kinds
An agent's loop runs in steps — one model call each. An event is one HALF of a step:
kind | Emitted | payload |
|---|---|---|
step/request | BEFORE the model call — holding what is about to be sent | the untouched provider request body |
step/response | AFTER the model answers whole, BEFORE the agent acts on it | the untouched provider response body (stream-reassembled if streamed) |
Design rules the vocabulary enforces:
- One event is one step half — never less. A step's prose, its reasoning
and ALL of its tool calls are one
step/response; the fed-back tool results and the user's new words are onestep/request. There is no kind left to shatter a step into fragments, because splitting a generation destroys the semantics a judge needs most: that the prose and the actions came from the same prompt. - Tool results are judged in the next request. A call's result travels in
the following
step/request(that is where the wire puts it); the runtime pairs it with its call by the provider's tool-call id. No third content kind exists. - Turn lifecycle left the wire in v0.8.
turn/endis gone: the runtime closes turns itself — a new user instruction in a later request closes the previous turn, the raw body's ownfinish_reasonrevealsmax_tokens, a block is the runtime's own act, and an idle timeout closes what nothing else did.
Forward the raw body
An integration that holds a provider request/response does not decompose
anything — it sends the body it holds. The RUNTIME normalizes: the new user
words, the tool outcomes being fed back, the model's prose, its reasoning,
every tool call it asks for, and the declared tool inventory (whose
definitions are themselves an attack surface — description injection,
rug-pulls — judged from the tools array where they already travel). The
system prompt needs no special handling — it is messages[0] of the body
being forwarded, exactly as the provider sees it.
The wire is deliberately stateless and repetitive — every step/request
carries the full conversation, exactly as the provider protocol does. The
runtime deduplicates at ingress; the network cost is accepted in exchange
for an integration that needs no state and no session affinity.
llm_protocol
Which protocol the payload speaks: openai.chat | openai.responses |
anthropic.messages | canonical. Required — the producer knows what it is
sending and says so; a runtime may still verify against the body shape and
reject a mismatch. An agent built on a normalizing client library (litellm
and most gateways normalize everything to the OpenAI chat shape) states the
shape it actually sends: openai.chat.
Canonical payloads
llm_protocol: "canonical" is for the integration that does NOT hold a
provider body: a harness with its own internal message format, or a stream
judged after reassembly where no single raw body ever existed. The shape:
// step/request
{ "messages": [ /* the full conversation being sent */ ],
"tools": [ /* declared tool schemas — include when changed or first seen */ ] }
// step/response
{ "text": "...", "reasoning": "...",
"tool_calls": [ { "id": "call_abc", "name": "bash", "arguments": { ... } } ],
"model": "...",
"usage": { "input_tokens": 0, "cache_read_tokens": 0, "cache_write_tokens": 0,
"output_tokens": 0, "reasoning_tokens": 0 },
"timing": { "started_at": "...", "first_token_at": "...", "completed_at": "..." } }
usage and timing on step/response
Two per-step facts only the integration can supply, powering per-step cost and latency analytics downstream:
timing—{started_at, first_token_at?, completed_at}, wall-clock facts the byte path observes. On a CANONICAL payload it is the ordinarytimingfield; on a RAW provider body the integration may add it as a top-leveltimingkey — inserted into the body's own bytes, never via a re-serialization, so span offsets keep indexing the strings as transported.usage— a raw body carries the provider's own accounting and needs nothing added. A canonical (stream-reassembled) payload should carry the canonical counters transcribed from the stream, and must omit the field rather than report zeros when the provider reported nothing — absence is the honest value.
step_id: the one coordinate
A producer-minted opaque id binding the step/request and step/response
of ONE model call. A fresh random id per call (a UUID is fine); never
reused.
This is the single coordinate v0.8 kept, because it is the single fact a
runtime cannot derive: an agent running model calls concurrently (parallel
tool use, fan-out subagents) interleaves its requests and responses, and
arrival order stops pairing them. A step_id is a local variable in the
loop, not session state.
Everything above it is DERIVED, always: sessions by conversation-prefix chaining (a harness that compacts its context is re-attached by the runtime at the compaction point), turns by instruction boundaries and idle timeout, step numbering by arrival.
Identity: the four-tuple
All four fields are required on every event; the empty string is the explicit "no assertion", never an error:
| Field | Empty means | Description |
|---|---|---|
agent_id | derived from the API key (identity floor) | WHICH agent this is — unique within the organization; the key the inventory and policy resolution hang off. Example: "invoice-bot" |
agent_type | unlabeled | What KIND of agent — the harness or product name ("langgraph", "claude-code", "my-harness"). A label, not an identity |
agent_workspace | the API key's workspace | The named GROUP of agents this one belongs to — one workspace, one policy set. Example: "finance-agents" |
agent_user | every session is one user | Who is USING the agent this session — changes per session or per request. Example: "u-8232" |
Behind a gateway that authenticates its callers with per-caller credentials,
the authenticated caller id is the natural agent_id; agent_workspace is
an agent grouping the operator maintains (e.g. a consumer-group header) —
never a human org chart, never a tenant.
The API key is the identity floor
The four-tuple degrades gracefully. An integration sending four empty
strings is still fully attributable: the runtime derives agent_id from the
API key (one key, one default agent), places the agent in the key's
workspace, and treats every session as the same single user. Each field an
integration fills refines that picture; none is a precondition for coverage.
Requiring the fields while allowing them empty is deliberate: every
integrator answers the identity question explicitly instead of falling into
the floor by omission.
One agent_id, one agent
agent_id names the agent; agent_type merely describes it. When events
share an agent_id but disagree on agent_type — one credential driving
several harnesses at once — the runtime keeps them as ONE agent (the id is
the identity) and surfaces the disagreement as a shadow agent signal:
several agents hiding behind one identity is a usage error worth an
operator's attention, not a reason to split the inventory.
Owner and user are attributes, not boundaries
Identity and placement — agent_id and agent_workspace — decide where an
event lands and which policy set judges it. Owner and user describe: who
is accountable for the agent, who a session serves. A runtime never lets
either select configuration.
Every identity field is a claim, bounded by the channel: resolved only
within the tenant the API key proves (agent_workspace names a workspace
inside that tenant, never the tenant itself).
What v0.8 removed, and where each job went
| Removed | The job moved to |
|---|---|
ogr_version | the runtime adapts to the body it receives; producers never version-gate |
session_id / turn / step | derived server-side, always |
parent_session_id | gone with declared coordinates; sessions are flat on the wire |
timestamp | the runtime's receive time |
integration (build id) | the heartbeat for liveness — and it RETURNED to the event as an optional field for per-event triage: the heartbeat goes quiet exactly when a bad rollout is what you are naming |
kind turn/end | runtime-side turn closing (instruction boundary, finish_reason, idle timeout) |
There is no event_id on the request. Identifiers are the runtime's
job: it assigns every accepted event a unique, time-ordered event_id at
ingress and returns it on the Verdict.
A client that wants to reference an event uses the returned id; it never
mints one.
Example — one complete event
{
"kind": "step/response",
"step_id": "8c2f1a0e77b04d5b",
"agent_id": "invoice-bot",
"agent_type": "my-harness",
"agent_workspace": "finance-agents",
"agent_user": "u-8232",
"llm_protocol": "openai.chat",
"payload": {
"id": "chatcmpl-9x",
"model": "gpt-5",
"choices": [ { "index": 0, "finish_reason": "tool_calls", "message": {
"role": "assistant", "content": "Cloning the repo now.",
"tool_calls": [ { "id": "call_1", "type": "function", "function": {
"name": "bash", "arguments": "{\"command\": \"git clone https://github.com/acme/app\"}" } } ] } } ],
"usage": { "prompt_tokens": 8120, "completion_tokens": 64 },
"timing": { "started_at": "2026-08-15T09:30:01Z",
"first_token_at": "2026-08-15T09:30:01.4Z",
"completed_at": "2026-08-15T09:30:02.1Z" }
}
}
The payload is the provider's response body as transported (plus the
integration-inserted timing); the runtime does all decomposition. A
gateway's event looks identical — it fills the four-tuple from its own
authenticated caller instead of from config.