Subagent turns: what reaches the gateway, per harness¶
Status: findings / design note. Not shipped behaviour. This page records observations or a proposed design. Nothing here is a product capability unless a linked release note says so.
Editions: OSS, Cloud, Enterprise. Unless stated otherwise, everything on this page ships in OSS.
When a coding harness runs a subagent, does the subagent's model traffic look different from its parent's by the time it reaches Preloop? This page records what was observed, harness by harness, and proposes how a parent session id would be captured where one is derivable.
It is a findings page, not a design. Nothing here has been implemented.
Why it matters¶
Preloop derives a per-run session id from the request the agent sends:
X-Preloop-Session-Id first, then a vendor header gated on the credential's
runtime principal type, then a body level conversation id, then nothing
(backend/preloop/services/agent_session_headers.py, precedence in the module
docstring, reading in native_session_id_from_headers). None of that has a
notion of a parent. Without one, an agent to agent note addressed to "the agent
that ran that subagent" has no row to land on, and a scope model that means
"my own children" has nothing to key on.
Method¶
Each harness was pointed at a local endpoint on 127.0.0.1 that speaks just
enough of the relevant wire protocol to complete a turn, and that writes every
inbound request line, header and body to a file. The endpoint answers the first
turn with a tool call that makes the harness spawn a subagent, and answers
everything after that with a short text reply, so one run produces a parent
turn, the subagent's turns, and the parent's follow up turn.
No traffic left the machine and no Preloop instance was involved: the point is
what the harness puts on the wire, which is the same whether the endpoint is a
fake or the gateway. Runs used a scratch HOME, a scratch working directory
and a placeholder api key. Identifiers below are from those throwaway runs;
install scoped identifiers are redacted.
Observed on 2026-09-15.
Summary¶
| Harness | Version | Subagent turn distinguishable | Parent derivable | From |
|---|---|---|---|---|
| Claude Code | 2.1.268 | Yes | Yes | X-Claude-Code-Agent-Id present only on subagent turns, alongside the parent's X-Claude-Code-Session-Id |
| OpenCode | 1.18.31 | Yes | Yes | X-Parent-Session-Id, sent next to the child's own X-Session-Id |
| Codex CLI | 0.154.0 | Partly, unconfirmed | Probably, unconfirmed | agent_name inside X-Codex-Turn-Metadata (a canonical task path); the in process spawn could not be exercised, see below |
| Gemini CLI | 0.35.3 | No | No | Subagent turns are identical to parent turns apart from content-length; no conversation id on the wire at all |
| Claude Desktop, Cursor, Windsurf, VS Code, OpenClaw, Hermes, Aider | not probed | No | No | No native session header is read for these today, so turns are already source keyed |
Claude Code 2.1.268¶
Scenario: one -p run whose first turn answers with an Agent tool call
(the subagent tool is named Agent in this version, not Task), twice in one
run, then a second run where the subagent itself takes three turns.
Raw, one run with two subagents (user-agent: claude-cli/2.1.268 (external, sdk-cli),
path POST /v1/messages?beta=true):
1 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b 21 tools
2 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b x-claude-code-agent-id: a1e37403a36fc420c 13 tools
3 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b 21 tools
4 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b x-claude-code-agent-id: a48fe9b1db3fc6abc 13 tools
5 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b 21 tools
6 x-claude-code-session-id: ebd4605d-7099-4c54-bd01-747f7a720e1b 21 tools
A full header diff of a parent turn against a subagent turn in the same run
differs in exactly one header: x-claude-code-agent-id, absent on the parent
and present on the child. Everything else, including anthropic-beta,
user-agent, x-app and the session header, is identical.
The second run confirmed the id is per subagent and stable for its lifetime:
one subagent took three turns (a tool call, its result, then its answer) and
sent x-claude-code-agent-id: a79dad809a7048810 on all three, while the
parent's turns in between carried no such header.
The body level path is no help: metadata.user_id carries the JSON string
{"device_id": "<redacted>", "account_uuid": "", "session_id": "<the same
session uuid>"} on parent and subagent turns alike, so the fallback Preloop
already reads (_session_id_from_anthropic_metadata in
backend/preloop/services/openai_gateway.py) sees the parent's identity for
both.
Note also that the subagent's own tool list still contains Agent, so a
subagent can spawn its own subagent. The wire carries one flat agent id and no
path, so depth beyond one level is not reconstructible from headers.
Verdict: yes, distinguishable, and the parent is exactly the session the
X-Claude-Code-Session-Id already names.
OpenCode 1.18.31¶
Scenario: one opencode run whose first turn answers with a task tool call.
Raw (user-agent: opencode/1.18.31 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14,
path POST /v1/chat/completions):
1 x-session-id: ses_f599e0060ffe4gLnO1q69ZcNFw x-session-affinity: ses_f599e0060ffe4gLnO1q69ZcNFw
2 x-session-id: ses_f599e0060ffe4gLnO1q69ZcNFw x-session-affinity: ses_f599e0060ffe4gLnO1q69ZcNFw
3 x-session-id: ses_f599dfca1ffe933RP1BLey87Mr x-session-affinity: ses_f599dfca1ffe933RP1BLey87Mr
x-parent-session-id: ses_f599e0060ffe4gLnO1q69ZcNFw
4 x-session-id: ses_f599e0060ffe4gLnO1q69ZcNFw x-session-affinity: ses_f599e0060ffe4gLnO1q69ZcNFw
The subagent turn (3) is a first class session with its own id, and the parent
id is sent explicitly. OpenCode's own log line for the spawn shows the same
relationship internally: created id=ses_f599dfca... parentID=ses_f599e006...
agent=general mode=subagent. x-parent-session-id is absent on root turns.
Verdict: yes, distinguishable, with the parent named directly.
Codex CLI 0.154.0¶
Codex sends far more identity than it is currently read for. Every request
carries Session-Id, Thread-Id and X-Codex-Window-Id, plus a JSON blob in
X-Codex-Turn-Metadata:
session-id: 01a0a666-eae6-74a3-83cc-4ec2ffe68a86
thread-id: 01a0a666-eae6-74a3-83cc-4ec2ffe68a86
x-codex-window-id: 01a0a666-eae6-74a3-83cc-4ec2ffe68a86:0
x-codex-turn-metadata: {"installation_id": "<redacted>",
"session_id": "01a0a666-eae6-...", "thread_id": "01a0a666-eae6-...",
"agent_name": "/root", "turn_id": "01a0a666-eb03-...",
"window_id": "01a0a666-eae6-...:0", "window_number": 0,
"context_window_id": "01a0a664-...", "request_kind": "turn",
"root_turn_id": "01a0a666-eb03-...", "thread_source": "user", ...}
agent_name is the interesting one. The tool the model uses to spawn a
subagent is spawn_agent, in a namespace tool (multi_agent_v1, or
collaboration when the multi_agent_v2 feature is enabled), and its own
description states the naming rule: "If your current task is /root/task1 and
you spawn_agent with task_name task_3 the agent will have canonical task name
/root/task1/task_3". So the top level agent reports agent_name: "/root",
and a subagent should report a path under it.
That last step could not be confirmed. codex exec advertises the spawn tools
in the request but its tool router rejects the call with
error=unsupported call: spawn_agent (tried bare, dotted and double
underscore namespaced forms, with and without the multi_agent_v2 feature
flag), so the handler is not registered in the non interactive entry point of
this build. No subagent turn could be produced, and therefore no subagent
agent_name was observed.
What was observed is the other kind of nesting: a Codex run that shells out to
another codex exec. The nested process is a clean slate:
1 session-id: 01a0a666-eae6-74a3-83cc-4ec2ffe68a86 agent_name: /root thread_source: user (outer)
2 session-id: 01a0a666-eb41-7b80-ae61-4b847ea65ab6 agent_name: /root thread_source: user (nested process)
3 session-id: 01a0a666-eae6-74a3-83cc-4ec2ffe68a86 agent_name: /root thread_source: user (outer, after)
New session uuid, new thread uuid, new turn and root turn ids, still /root,
and nothing that points at the caller. Only installation_id is shared, and
that is per install, not per parent.
Verdict: partly, unconfirmed. In process subagents are almost certainly
distinguishable through agent_name, and the parent is the path prefix, but
that needs one live capture from an entry point that registers the spawn
handler before anything is built on it. Process level nesting (codex exec
inside codex exec) is not distinguishable: to us it is a second,
unrelated session.
Gemini CLI 0.35.3¶
Gemini CLI has in process subagents: the request advertises generalist and
codebase_investigator as tools, and answering with a generalist function
call makes the CLI run it ([LocalAgentExecutor] Skipping subagent tool
'codebase_investigator' for agent 'generalist' to prevent recursion).
The subagent's turns reach the endpoint with headers identical to the
parent's apart from content-length. A full diff of parent turn against
subagent turn differs only in
content-length:
POST /v1beta/models/<model>:streamGenerateContent?alt=sse
user-agent: GeminiCLI/0.35.3/<model> (darwin; arm64; terminal)
x-goog-api-client: google-genai-sdk/1.30.0 gl-node/<version>
x-gemini-api-privileged-user-id: <redacted, stable across separate runs>
x-goog-api-key: <credential>
There is no session id of any kind, on the header or in the body: the request
body carries only contents, systemInstruction, tools and
generationConfig. The only stable identifier,
x-gemini-api-privileged-user-id, was identical across two separate CLI
processes, so it is an install identity, the same trap as Codex's
installation_id and Claude Code's device_id. Keying on it would collapse
every conversation on the machine into one row, which is the bug
agent_session_headers.py exists to avoid.
Verdict: no. Not only is the subagent turn indistinguishable, the parent turns are not separable from each other either. Gemini CLI sessions are source keyed today and this changes nothing.
The rest of the enrolled products¶
Claude Desktop, Cursor, Windsurf, VS Code with Copilot, OpenClaw, Hermes and
Aider were not probed. For the note story the answer is the same for all of
them and does not depend on a probe: _NATIVE_SESSION_HEADERS has entries for
Codex and OpenCode only, Claude Code is handled by its own header on the
Anthropic ingress, and nothing else sends a per conversation id we read. Their
traffic is source keyed, so there is no per conversation row for a parent to
point at, let alone a parent. Aider has no subagent concept at all.
If one of these later grows a subagent feature, it needs its own entry in this table before it can participate.
Proposed mechanism for a parent session id¶
Per harness, the source of the parent link:
| Harness | Child session key | Parent session id from | Confidence |
|---|---|---|---|
| Claude Code | <X-Claude-Code-Session-Id>:<X-Claude-Code-Agent-Id> |
the session row keyed on X-Claude-Code-Session-Id alone |
observed |
| OpenCode | X-Session-Id (unchanged) |
X-Parent-Session-Id |
observed |
| Codex | <Session-Id>:<slug(agent_name)> when agent_name is not /root |
the row for the parent path prefix, root being Session-Id itself |
inferred, needs one capture |
| Gemini CLI | not derivable | not derivable | observed |
| Everything else | not derivable | not derivable | by construction |
Three properties make the composite key workable:
- The existing normaliser accepts it. Client session ids are validated against
^[A-Za-z0-9_.:\-]+$with a 200 character cap (backend/preloop/services/openai_gateway.py), so<uuid>:<agent-id>andses_...pass unchanged. Codex'sagent_namedoes not: it contains/, so it would have to be slugified before it is used as part of a key, and that slugification must be injective enough not to merge two sibling agents. - The parent id is always a value we would have keyed a row on anyway, so
parent_session_idcan resolve to a realruntime_sessionrow rather than storing a free text id nobody can join to. Where the parent's own row does not exist yet (a subagent's request arriving before the parent's next turn) the resolution should create or look up the parent by its source key exactly as a parent turn would, which keeps the two paths from racing into two rows. - The new headers get the same gate as the existing ones.
X-Parent-Session-Idis a generic name that any proxy could stamp, so it must be read only when the credential's runtime principal type says OpenCode, and the Claude Code agent id only when it says Claude Code, exactly as the module docstring inagent_session_headers.pyargues. The gate fails closed to today's behaviour.
Where it would land: a nullable self referencing parent_session_id on
runtime_session (backend/preloop/models/models/runtime_session.py), set
once at session creation and never updated, plus whatever the console needs to
render it. That is a schema change for the capture child to make, not this
page.
Two limits worth stating up front:
- Depth. Claude Code gives one flat agent id with no path, so a subagent of a
subagent looks exactly like a subagent of the root. The honest reading of
parent_session_idfor Claude Code is "the harness conversation this turn belongs to", which is correct at depth one and a safe approximation deeper. OpenCode's header names the immediate parent and is correct at any depth. - Process nesting. An agent that launches another CLI process is invisible as lineage on every harness probed. Anything that needs to follow that link has to get it from execution lineage, not from session identity.
Fallback when the parent cannot be derived¶
For Gemini CLI and every unmapped product there is no parent and often no per conversation row at all. The scope model in the policy child (#637) should therefore treat lineage as optional evidence, never a precondition:
- A missing
parent_session_idmeans "lineage unknown", which must resolve to outside my subtree, not "same subtree". The default scope, an agent may note its own children, then denies rather than widens on harnesses that tell us nothing. - Where the delegation epic has execution lineage, that is the authoritative parent link and it works on every harness, because Preloop creates those records itself. Session lineage is an addition for interactive harness runs that have no execution behind them, not a replacement.
- A wider scope stays an explicit per account grant, off by default, so an operator on a harness with no lineage can still get agent to agent notes by opting in rather than by us guessing a hierarchy.
Concretely: the scope check should be a function of (author identity, target,
grant, optional lineage), and its behaviour with lineage set to None must be
in the tests from the first commit, because for Gemini CLI that is
the only shape it will ever see.
Recommendation on the capture child¶
Worth doing, narrowed:
- Do OpenCode and Claude Code now. Both are observed, both are one header,
and together they cover the two harnesses where subagent use is routine.
The work is small: a parent aware read in
agent_session_headers.py, a nullable column, and the resolution rule above. - Do not do Codex in the same change. The mechanism is credible but
unconfirmed, and
agent_nameneeds slugification and a sibling collision rule that should be designed against real values, not against a tool description. One capture from an entry point that registers the spawn handler settles it. - Do not block the note write path or the scope model on any of it. With the fallback above, phase 1 ships without lineage and gains precision when the capture lands.
The thing that would change this recommendation is a decision to key notes on execution lineage only. In that case session level parents are decoration, and this becomes a console nicety rather than part of the addressing model.