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

FieldTypeDescription
kindenumstep/request | step/response — see Kinds
step_idstringProducer-minted opaque id binding one model call's two events — see step_id
agent_idstringWHICH agent — unique within the organization. "" = derived from the API key
agent_typestringWhat KIND of agent — a harness/product label, never an identity. "" = unlabeled
agent_workspacestringThe named GROUP of agents this one belongs to — one workspace, one policy set. "" = the key's workspace
agent_userstringWho is USING the agent this session. "" = every session is one user
llm_protocolenumopenai.chat | openai.responses | anthropic.messages | canonical — see llm_protocol
payloadobjectThe 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:

FieldTypeDescription
integrationstringWho 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
connectionstringThe 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_hintstringThe 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:

kindEmittedpayload
step/requestBEFORE the model call — holding what is about to be sentthe untouched provider request body
step/responseAFTER the model answers whole, BEFORE the agent acts on itthe 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 one step/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/end is gone: the runtime closes turns itself — a new user instruction in a later request closes the previous turn, the raw body's own finish_reason reveals max_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 ordinary timing field; on a RAW provider body the integration may add it as a top-level timing key — 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:

FieldEmpty meansDescription
agent_idderived 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_typeunlabeledWhat KIND of agent — the harness or product name ("langgraph", "claude-code", "my-harness"). A label, not an identity
agent_workspacethe API key's workspaceThe named GROUP of agents this one belongs to — one workspace, one policy set. Example: "finance-agents"
agent_userevery session is one userWho 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

RemovedThe job moved to
ogr_versionthe runtime adapts to the body it receives; producers never version-gate
session_id / turn / stepderived server-side, always
parent_session_idgone with declared coordinates; sessions are flat on the wire
timestampthe 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/endruntime-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.