The layer model

This is the protocol's foundational concept. OGR models agent traffic the way the layered network model models packets — and it is built the way a firewall is: an integration sees one event at a time, the way a firewall sees one IP packet, and the runtime reassembles everything above it and reads everything below it out of the payload.

The model has two axes — the same two a firewall has: entities (the parties, which persist) and a traffic stack (the activity, every unit an episode with a beginning and an end).

The traffic stack

#OGR layerOne unit is
L6Sessionone conversation
L5Turnone instruction → quiescence
L4Stepone model call: request + response, paired by step_id
L3Eventone GuardEvent, half a step — the only layer on the wire
L2Callone tool call the model asked for
L1Execone real execution on a machine — named by the model, not carried by the contract

Read downward it is containment — a session holds turns, a turn holds steps, a step holds exactly two events, a response event holds zero or more calls, a call resolves to at most one exec. Read upward it is an observability ladder: the higher the layer, the more reconstruction stands between the wire and the answer.

And the same six layers in the vocabularies you already have — the network stack, tracing spans, and the three agent SDKs:

#OGRNetworkOTel GenAIOpenAI Agents SDKClaude Agent SDKLangGraph
L6Sessionsession tableconversation.idSession idsession_idthread
L5Turnflowinvoke_agentone Runner.run()one query()one invoke()
L4Steptransportchat spangeneration_span ⚠️one round trip ⚠️model node
L3Eventthe packetspan start / endspan start / endAssistantMessagearound invoke()
L2Calllinkexecute_toolfunction_spantool_useToolNode
L1Execphysicalthe host commandthe tool fn
Agenthostagent.idAgentagent · subagentthe graph

⚠️ Both agent SDKs call an L4 step a "turn" — one loop iteration, which is what max_turns counts. An OGR turn is the instruction episode above it. The detailed mapping, with what to send as session_hint and why a hook can refuse where a span cannot, is further down.

The event is the packet

Like a packet, a GuardEvent is a headerkind (step/request | step/response), step_id, and the identity four-tuple agent_id · agent_type · agent_workspace · agent_user (OGR's answer to the firewall's 5-tuple; the fifth coordinate, the tenant, comes from the API key and never from the payload) — plus a payload: the raw provider body.

That is all the wire carries. No session ids, no turn or step numbers, no lifecycle marks: coordinates a sender could declare are coordinates a sender could get wrong, so every layer above the event is derived server-side — a firewall does not ask packets which connection they belong to.

Above the packet: reassembly

  • A step's two events are paired by the producer-minted step_id — fragment reassembly. The halves arrive independently and sometimes out of order; each is judged at its own moment: the request before the model sees it, the response before the agent acts on it.
  • A turn opens at a user instruction and is closed by the runtime: the next instruction, the body's own finish_reason, or an idle timeout — a flow table's FIN / RST / timeout.
  • A session is chained from what requests already carry: each request holds the whole conversation, so its prefix fingerprints link it to its predecessor — plus the producer's own optional session_hint when it has one.

Below the packet: parsed, then inferred

  • A call is parsed from the response payload; its result arrives inside the next step's request and is paired back by the provider's call id. The call belongs to the step that issued it — and it is the unit enforcement names: a verdict's findings[].path says which call offended, and an integration may refuse only that one.
  • An exec is what actually ran. No integration observes this layer: a gateway sees what was asked (the call) and what came back (the result), never what happened in between — and the wire deliberately carries no exec kinds. The layer is in the model because the gap between what a call claims and what an exec does — a tool named get_weather that actually deletes files — is precisely what agent security is about, and a model without the layer cannot even name that blind spot.

A text-only step has no calls and no execs — empty lower layers are normal, like a bare ACK carrying no application data.

The entity axis

EntityNetwork analogueOn the wire
Tenantthe administrative boundarythe API key (never the payload)
Workspacesecurity zone — one zone, one policy setagent_workspace
Agenthost / endpointagent_id (+ agent_type, agent_user)

An agent is an endpoint, not a layer. Every stack unit is an episode; an agent persists with zero traffic — sessions belong to it the way TCP connections belong to a host. It is addressed by the identity four-tuple every event header carries, and discovered from traffic the way hosts are inventoried from packets.

Your harness already has words for this

Most agents are instrumented, or at least built on an SDK, before they are guarded — so the traffic already has names. Two families of them: the tracing vocabulary (OpenTelemetry's GenAI semantic conventions, and dialects such as OpenInference) and each SDK's own — the OpenAI Agents SDK, the Claude Agent SDK, LangGraph. They describe the same traffic these six layers describe. Here is the correspondence, exactly.

One word, two meanings: a tracing span is a timed operation in a trace; a verdict's modifications.spans are character offset ranges in a text. Below, "span" means the first.

The map

#OGRNetwork (OSI / TCP-IP)OTel GenAIOpenAI Agents SDKClaude Agent SDKLangGraph
L6Session — one conversationno OSI layer — the firewall's session table, idle aginggen_ai.conversation.id (no span)Session / SQLiteSession id; a trace's group_idthe session — session_id, resume, forkthe threadthread_id + checkpointer
L5Turn — one instruction → quiescenceno OSI layer — a flow's FIN / RST / timeoutinvoke_agent spanone Runner.run() — one traceone query() prompt, up to its ResultMessageone invoke() / stream() on the graph
L4Step — one model calltransport (OSI L4)the inference span, chat {model}generation_span / response_spantheir "turn"one loop round trip — their "turn" (max_turns)one model-node execution (before_modelafter_model)
L3Event — half a step, the wire unitnetwork (OSI L3) — the packetthat span's start / endthat span's start / endAssistantMessage out; tool results ride the next UserMessagethe two moments around the chat model's invoke()
L2Call — one tool calldata link (OSI L2)execute_tool spanfunction_spana tool_use block; PreToolUse is its gatea ToolNode call; wrap_tool_call is its gate
L1Exec — one real executionphysical (OSI L1)what Bash / Edit actually did on the hostwhat the tool function actually did
Agent (entity, off the stack)host / endpointgen_ai.agent.id / .namethe Agent object (agent_span); a handoff switches itthe agent, and each subagentthe compiled graph
Workspace · Tenantsecurity zone · administrative boundary(deployment.environment.name)

The numbers line up through L4 on purpose. Exec/call/event/step sit on physical/link/network/transport, and the packet is L3 in both columns. Above transport the columns part: networking has only "application", because network applications share no structure — agent traffic is a dialogue with stable structure, so turn and session are this domain's own L5 and L6, not OSI's session and presentation layers (the two practice discarded).

⚠️ "Turn" means this stack's STEP in two of the three SDKs. In both the OpenAI Agents SDK and the Claude Agent SDK a turn is one iteration of the agent loop — one model call plus the tool runs it triggers — and that is what max_turns counts. An OGR turn is the user-instruction episode that contains those iterations: one Runner.run(), one query() prompt, one graph invoke(). Same word, one layer apart. (The OpenAI Agents SDK documentation uses both senses: max_turns counts loop iterations, while "a single logical turn in a chat conversation" is one Runner.run() — an OGR turn.)

Tracing spans

Three differences that are not vocabulary. They are why OGR does not simply consume spans:

  1. A span is an interval; a GuardEvent is a half. A span is written when its operation ends — after the model has answered, after the tool has run. OGR's two moments are the ones where something is still held and can still be refused: before the request reaches the model, and after the response arrives but before the agent acts on it. A span cannot block, so a step is two events rather than one record. (An SDK hook can refuse — see the sections below. A span never can.)
  2. A span declares its coordinates; a GuardEvent declares one. trace_id, span_id and parent_span_id are producer-authored — and a producer that can declare a flow can get the flow wrong. The wire keeps step_id alone, because pairing the two halves of one model call under concurrency is the single fact a runtime cannot derive. Session, turn and step numbering are derived server-side.
  3. Telemetry is best-effort and sampled; enforcement is neither. Dropping spans is normal operation; a dropped guard event is an unjudged model call. The same asymmetry governs content: message capture is opt-in for a tracer (gen_ai.input.messages / gen_ai.output.messages), mandatory here, because the content is the event.

A fourth difference is shape. A trace is an open-ended tree — any framework nests whatever spans it likes — while this stack is six fixed layers, so the same traffic from two different harnesses lands on the same coordinates.

If your harness already emits spans, three mappings are directly useful when you write the integration:

  • Mint step_id from the inference span's span_id. One span covers both halves of the step — exactly the pairing rule — and it makes every guard row joinable to the trace it came from.
  • Send gen_ai.conversation.id as session_hint.
  • Send gen_ai.agent.id / gen_ai.agent.name / user.id as the identity four-tuple's agent_id / agent_type / agent_user, and name your instrumentation in integration the way an OTel instrumentation scope names itself.

What does not carry over: exporting spans to a collector is not an integration. The evaluate call is synchronous and sits in the byte path — see Instrument your agent.

In OpenInference, span kind AGENT ≈ turn, LLM ≈ step, TOOL ≈ call, session.id ≈ session, and user.idagent_user; its GUARDRAIL kind is where an OGR evaluate call itself would appear, if you traced it.

OpenAI Agents SDK

  • Session — a Session (SQLiteSession("user_123")) is the conversation the runner prepends before a run and appends to after it. Send its id as session_hint; a trace's group_id, which links the traces of one chat thread, carries the same fact and is equally good.
  • Turn — one Runner.run() / run_sync() / run_streamed(), which the SDK also wraps in one trace. Its RunResult is the turn's outcome.
  • Step — one iteration of the runner's loop. The generation_span (chat completions) or response_span (Responses API) is 1:1 with the model call: mint step_id from its id. A custom Model / ModelProvider is the natural enforcement point, because it holds the request before it is sent and the response before the runner acts on the tool calls.
  • Call — a function_span. A guardrail_span is where the evaluate call appears if you trace it — though note the SDK's own input/output guardrails run beside the model call, while an OGR decision sits in it.
  • Handoffhandoff_span has no layer, because a handoff is movement on the entity axis, not a unit of traffic: the steps after it carry a different agent_id inside the same turn and the same session. agent_span is that agent's slice of the run — and an agent is an endpoint, not a layer.

Claude Agent SDK

  • Session — the SDK's own session: session_id off the init SystemMessage or the ResultMessage, resume to return to it, fork to branch it. Send it as session_hint. This SDK produces both of the cases the hint exists for: compaction (compact_boundary) rewrites the history, so the conversation prefix a runtime chains on vanishes mid-conversation, while fork does the opposite — two live sessions sharing a long identical prefix. Content alone re-attaches the first wrongly and merges the second. The hint settles both.
  • Turn — one query() prompt, up to its ResultMessage (with ClaudeSDKClient, one client.query() call).
  • Step — one round trip of the loop, which this SDK calls a turn. The AssistantMessage is the response half — text, thinking and tool_use blocks together, one generation, one event — and the UserMessage carrying tool results belongs to the next request half. That is exactly the rule the protocol states: a call's result is judged in the following step/request.
  • Call — a tool_use block, gated by the PreToolUse hook. That hook is an enforcement point in the OGR sense: it can reject a call and hand the model a rejection instead. It is where the Claude Code plugin sits.
  • Exec — what Bash, Write or Edit actually did on the host. Named by the model, not carried by the contract.
  • Subagents — a subagent runs its own conversation with fresh context and returns only its final response to the parent, as a tool result. Its traffic is its own session: give it its own session_hint, and keep the same agent_id unless you want it inventoried as a separate agent.

LangGraph

  • Session — the thread: configurable.thread_id, persisted by a checkpointer. That id is the session_hint; the checkpointer is the local analogue of the runtime's session table.
  • Turn — one invoke() / stream() on the compiled graph for that thread.
  • Step — one execution of the model node: create_agent's before_modelafter_model window, or the chat model's invoke() inside the prebuilt ReAct agent. Not a super-step — that is graph-execution granularity: nodes running in parallel share one, and a node that calls no model produces no events at all. This stack observes the model plane, not the graph.
  • Call — a ToolNode execution, gated by wrap_tool_call.
  • StateAgentState.messages (the add_messages reducer) is the conversation the request payload already carries; nothing about state needs sending separately.
  • Interruptinterrupt() / HumanInTheLoopMiddleware is the structural twin of a block: the graph pauses before a consequential call. The difference is who answers — a person there, a policy decision point here.
  • The LangGraph integration wraps the chat model for exactly this reason: the model node holds both refusable moments, so every graph built on that model is covered with no per-node work.

What none of them have

Everything above the agent. An SDK models one process; workspace (one security zone, one policy set) and tenant (the administrative boundary, carried by the API key and never by the payload) exist because what gets governed is a fleet, not a run. The nearest neighbor a tracing vocabulary offers is the resource attribute deployment.environment.name, and it is not a policy boundary.

Why six layers — and why not OSI's seven

OGR follows the pragmatic TCP/IP cut, not OSI's seven: a layer earns its place with its own unit, its own mechanism, and its own question. Above transport, networking has only "application", because network applications share no structure — but agent traffic is a dialogue with stable structure, so turn and session are this domain's own layers, defined here rather than mapped onto OSI's vestigial session/presentation layers. And the agent stays off the stack for the same reason a host is not a protocol layer.

The firewall vocabulary carries over with the method:

Firewall / networkOGR
packetevent
5-tuplethe identity four-tuple + the API key's tenant
fragment reassemblystep_id pairing
session table, idle agingserver-side session state, idle timeout
stateful inspectionsession / turn derivation
deep packet inspectiondetection over the payload's texts
pass / dropverdict allow / block
security zoneworkspace — one zone, one policy set

Normative text

The layer model is normative since OGR v1.0: specification/overview.md § The layer model.