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; every other key is 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 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, and every key added since v1.0 arrived this way.
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 optional fields
Send each when you hold the fact; omit it when you do not. Every one is an attribution or diagnostic signal — a runtime never derives trust, policy or authorization from any of them, and each is a CLAIM the sender makes about itself:
| Field | Since | Description |
|---|---|---|
integration | 1.0 | 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 | 1.0 | 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 | 1.0 | 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 |
obligation_results | 1.2 | What the enforcement point DID about the obligations it was given on the previous step. Self-declared: an enforcement point that called no scanner and reports clean is indistinguishable from one that called and was told clean, so it is never an input to authorization |
redaction | 1.4 | What an agent-side plugin masked on the host before this request left it — which rules fired, which placeholders it minted. A success record, not a finding; a secret found anyway is a miss with a name |
initiator | 1.5 | WHO started the work this step belongs to, when the producer knows and the body does not say: "scheduled" (a scheduler; no person present) or "spawned" (another agent session). ⚠️ A string, never an enum — an unknown value is IGNORED, never rejected. There is deliberately no human value: nothing can prove a person was there |
llm_endpoint | 1.6 | WHERE the agent pointed the model request — host[:port], no scheme or path. It answers where did the agent dial, not who finally served it, which is what makes an unknown relay host legible as one. A value that is not a host is ignored, never rejected |
transport | 1.8 | WHERE THE TIME WENT on the way here — durations only, each measured inside ONE clock, plus the clock offset as a diagnostic. ⚠️ The object is tolerant where the envelope is strict: unknown keys INSIDE it are stripped, so a producer one version ahead keeps validating |
⚠️ Optional is load-bearing, in both directions. additionalProperties: false
rejects UNKNOWN keys, not absent ones — so an additive optional key keeps every
integration in the field validating and lets the two ends roll forward
independently. Making one required would 400 the entire installed base, turning a
diagnostic into an outage.
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 is not on the wire. The runtime closes turns itself — a
new user instruction in a later request closes the previous turn, the raw
body's own
finish_reasonrevealsmax_tokens, a block is the runtime's own act, and an idle timeout closes what nothing else did. A producer never announces a turn boundary.
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 only coordinate on the wire, because it is the only 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 is deliberately NOT on the wire
A producer sends what only a producer can know. Everything a runtime can derive for itself is absent by design — sending it would be a second, weaker source of truth that can disagree with the first.
| Not on the wire | Where the job lives |
|---|---|
| a protocol version | the runtime adapts to the body it receives; producers never version-gate |
session_id / turn / step | derived server-side, always — see the layer model |
| a parent-session pointer | sessions are flat on the wire; the runtime infers the tree |
| a timestamp | the runtime's receive time. A producer clock cannot carry ordering: nothing bounds how wrong it is and the receiving end cannot audit it |
| a turn-close event | runtime-side turn closing (instruction boundary, finish_reason, idle timeout) |
an event_id | assigned at ingress and returned on the Verdict (below) |
⚠️ The one apparent exception is integration, the reporter's build id: the
heartbeat carries it for liveness, and it is
ALSO an optional event field for per-event triage —
because the heartbeat goes quiet exactly when a bad rollout is what you are
trying to name.
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.