How to govern a coding-agent fleet with Talon
This guide shows how to govern an orchestrated coding-agent fleet — Claude Code subagents and Codex executors fanning out across two providers — through one Talon gateway, with per-session budgets, per-subagent attribution, and signed evidence. It is also the canonical reference for the neutral orchestration-metadata contract. Allow about 20 minutes.
For single-tool setup without orchestration, start with the OpenClaw guide — the gateway mechanics (vault, agent identity, policy) are identical.
The scenario
A platform team runs an orchestrator that plans a change, then fans out:
- Claude Code subagents (planner, generator, reviewer) call Anthropic's Messages API.
- Codex executors call OpenAI's Responses API.
- One logical coding session spans both providers and a tree of subagents.
Terminology: the roles are labels, not Talon concepts
Every role name in this guide — orchestrator, planner, generator, reviewer, executor, judge — is an illustration, not a vocabulary Talon knows:
- An orchestrator is simply whatever client code coordinates the work (a lead agent session, a script, a CI job) and passes one session id to everything it spawns. Talon never sees a role called "orchestrator" — it sees a Talon agent identity (which key authenticated), a session id, and per-request subagent labels.
X-Talon-Agent-ID/X-Talon-Parent-Agent-ID/X-Talon-Clientvalues are free-form strings you choose. Talon does not validate them against any known set, assigns them no semantics, and never uses them in policy — it records them as attribution (provenance: client_asserted) and rolls costs up by them. Name your subagents whatever your fleet calls them.- The one enforced vocabulary is
X-Talon-Stage: exactlygeneration(producing candidate work),judge(evaluating or selecting between candidates), orcommit(finalizing the chosen result). Any other value is silently dropped at ingestion — the request proceeds, no stage is recorded. - Talon agent ≠ tenant ≠ subagent id. A Talon agent is a traffic identity defined by one
agent.talon.yaml, authenticated by its vault-bound agent key (e.g. one agent per tool); a tenant is the owning account, derived from the agent (key → agent → tenant_id); a subagent id is the client-asserted runtime label above. Budgets and policy bind to Talon agents and tenants — never to subagent ids. - A wire family (
api_family) is the provider's HTTP API shape —anthropic(Messages API) oropenai(Chat Completions / Responses) — which determines how Talon parses, redacts, and prices traffic on that route.
In practice: with Claude Code and Codex CLI, the vendor adapters pick up whatever session/subagent headers the tools emit — nothing to invent. With a custom orchestrator, you choose the labels and set the generic headers yourself; if a tool version doesn't emit identity headers, set the generic ones from the process that launches it.
The platform team wants three things:
- Budgets — a runaway session stops burning money, no matter which provider route it uses.
- Attribution — every request traceable to a session, a subagent, and its parent.
- Signed evidence — one queryable, integrity-checkable record of what happened.
How Talon sees it
Talon sits between the fleet and both providers as a gateway. Every request carries orchestration metadata in HTTP headers; Talon validates it, records it in signed evidence, and rolls it up:
| The fleet does | Talon records |
|---|---|
| Orchestrator picks a session id and passes it to every subagent | One session grouping all requests with that id, across both provider routes (TestGatewayOrchestration_CrossProviderAndIsolation) |
| Each subagent identifies itself and its parent | agent_id / parent_agent_id per request, rolled up per subagent in the session summary |
| Spend accumulates on both wires | One session cost total; max_session_cost denies on either route (TestSessionBudget_CrossProviderDeny) |
| A client asserts identity | Evidence with provenance: "client_asserted" — attribution, not authentication (see Limitations) |
Prerequisites
- Talon installed (
go install github.com/dativo-io/talon/cmd/talon@latestorcurl -sL https://install.gettalon.dev | sh). macOS linker errors:CC=/usr/bin/clang go install ...@latest. - Claude Code, Codex CLI, or any orchestrator/client that can set HTTP headers on its LLM requests.
- Real Anthropic and OpenAI API keys (for governed traffic; the demo needs none).
Steps
1. Generate the coding-agents pack
mkdir talon-coding && cd talon-coding
talon init --pack coding-agents --name coding-gateway
This creates agent.talon.yaml, agents/codex/agent.talon.yaml, talon.config.yaml, and pricing/models.yaml pre-configured for coding traffic (source of truth: internal/pack/templates/coding-agents/; the pricing table is a copy of the embedded default). The defaults are deliberate:
- Two agents, one per tool —
claude-code(the primaryagent.talon.yaml, Anthropic route) andcodex(agents/codex/agent.talon.yaml, OpenAI route), each its own AI use case with its own vault-bound agent key,metadata.team: coding,policies.allowed_providers, and its own budgets. Budgets and audit attribute per tool. To serve both from onetalon serve, setagents_dir: "."(#267, shipped; the pack ships it commented out) — discovery matches the exact filenameagent.talon.yamland fails closed on duplicateagent.name. Withoutagents_dir,talon serveruns the single defaultagent.talon.yaml. - Always enforced, deliberately permissive defaults — there is no shadow posture (#442). The pack keeps traffic flowing by choice of rule action (input PII
warn, response PIIwarn) and reserves real denials for the provider allowlist and the budgets. Tighten rule by rule after reading the evidence. response_pii_action: warn(organization baseline) — streams normally: the SSE stream reaches the developer as the provider emits it and is scanned after delivery; response PII is recorded in signed evidence as a post-delivery observation, not prevented (see LIMITATIONS.md §7 and response PII actions and streaming).redact/blockare preventive and buffer the whole stream before release. Input-side scanning (input_scan: true→ warn) still applies, and a per-agent downgrade toallowis deliberately not expressible.- Long timeouts (
request_timeout: 600s) — the 120s default hard-cuts long coding generations (#230). - Credential recognizers — high-precision patterns for PEM private keys, AWS
AKIA...ids, GitHub tokens, andsk-ant-/sk-proj-LLM keys. Talon is not a secret scanner; run gitleaks/trufflehog in pre-commit for repository hygiene — these cover prompt/response traffic only. session_limits.max_cost: 10.00per agent — the per-session soft cap (see step 4).
Identity topology choice (this determines what "one session" means — see session semantics):
| Topology | Effect | When |
|---|---|---|
| One agent per tool (pack default) | Per-tool budgets and attribution; a session id asserted under both agents is two sessions | Independent tools, per-team chargeback |
One agent for the whole orchestrator, allowed on both providers (policies.allowed_providers) | One session spans both provider routes; subagent attribution via subagent ids | Orchestrated fan-out with a single fleet budget (this is what the demo does) |
2. Store both provider keys and start the gateway
export TALON_SECRETS_KEY=$(openssl rand -hex 32) # keep it; same value at serve time
talon secrets set anthropic-api-key "sk-ant-..."
talon secrets set openai-api-key "sk-..."
# Mint each tool's Talon agent key (bound via agent.key.secret_name)
talon secrets set claude-code-talon-key "$(openssl rand -hex 24)"
talon secrets set codex-talon-key "$(openssl rand -hex 24)"
talon serve --gateway
Each tool authenticates with its agent key; Talon resolves the key to the agent, derives the tenant, and injects the real provider key upstream. For the Anthropic provider family, vault-stored keys are the only upstream auth mode — upstream_auth_mode: client_bearer is rejected at config load. This also means subscription/OAuth billing cannot be governed: pointing only ANTHROPIC_BASE_URL at Talon sends Claude Code's OAuth token, which Talon rejects. See LIMITATIONS.md §7.
Point each tool's base URL at the gateway (http://localhost:8080/v1/proxy/anthropic for the Anthropic SDK; http://localhost:8080/v1/proxy/openai/v1 for OpenAI-SDK/Codex clients, which append their own path — the trailing /v1 matters, #235) with its agent key as the bearer token. Per-tool walk-throughs: Claude Code, Codex CLI.
3. Send orchestration metadata — the neutral contract
This section is the canonical reference for the contract (implementation: internal/gateway/orchmeta.go, #194/#192).
Generic headers (work on every provider route)
| Header | Meaning |
|---|---|
X-Talon-Session-ID | Groups requests into one coding session |
X-Talon-Agent-ID | The subagent making this request |
X-Talon-Parent-Agent-ID | The subagent that spawned it |
X-Talon-Client | Client label recorded in evidence (e.g. my-orchestrator) |
Any client that can set headers participates fully — no Talon change needed. Example fan-out (orchestrator sets the same session id on both wires):
# Claude Code subagent → Anthropic route ($CLAUDE_CODE_KEY = the value minted
# into claude-code-talon-key)
curl -s -X POST http://localhost:8080/v1/proxy/anthropic/v1/messages \
-H "Authorization: Bearer $CLAUDE_CODE_KEY" \
-H "X-Talon-Session-ID: sess-feature-4711" \
-H "X-Talon-Agent-ID: generator" \
-H "content-type: application/json" -d '{...}'
# Codex executor → OpenAI route, child of generator
curl -s -X POST http://localhost:8080/v1/proxy/openai/v1/responses \
-H "Authorization: Bearer $CODEX_KEY" \
-H "X-Talon-Session-ID: sess-feature-4711" \
-H "X-Talon-Agent-ID: executor" \
-H "X-Talon-Parent-Agent-ID: generator" \
-H "content-type: application/json" -d '{...}'
Vendor adapters are data, not code
Clients that already emit their own headers are mapped onto the same neutral contract by an adapter table entry (vendorAdapters in internal/gateway/orchmeta.go) — never a code branch in the request path. Two adapters ship today:
| Client | Session header | Agent header | Parent header |
|---|---|---|---|
claude-code | X-Claude-Code-Session-Id | X-Claude-Code-Agent-Id | X-Claude-Code-Parent-Agent-Id |
codex | Session-Id | X-Openai-Subagent | — (none; use the generic header for parent attribution) |
The first adapter with any populated header wins. Onboarding a new client is exactly one of: (a) the client sends the generic X-Talon-* headers — zero Talon changes — or (b) one new entry in the adapter table.
Precedence and provenance
- Precedence per field: generic > vendor > synthetic. If both
X-Talon-Session-IDand a vendor session header are present with different values, the generic value is recorded and the vendor value is ignored — per field, so a generic session id can coexist with a vendor-asserted agent id (TestResolveOrchestration_Precedence). session_sourcerecords how the session id was obtained:client_asserted(generic header),vendor_asserted(adapter header), orsynthetic(no client assertion; Talon derivessess_<correlation-id>).provenanceis alwaysclient_asserted. Orchestration identity distinguishes subagents within an already-authenticated agent; it does not authenticate them. It is exactly as trustworthy as the workload that presented the agent key.- Identity is never a policy input. Budgets bind to the Talon agent and the agent-scoped session tuple, never to subagent
agent_idlabels (TestPolicyInputParity_WithAssertedSession). Acting on client-asserted identity waits for workload attestation (#149). - Per-agent opt-out:
agent.accept_client_metadata: falsein the agent file ignores asserted subagent/parent/client identity and vendor session headers (the genericX-Talon-Session-IDstill resolves the session id). Default istrue; the flag gates recording only (TestGatewayOrchestration_FlagOff). Requests without any asserted session id get a syntheticsess_<correlation-id>in evidence either way — synthetic ids never create session-store state or budgets. - Optional
X-Talon-Stagetags a request's pipeline stage; onlygeneration,judge,commitare accepted — anything else is dropped at ingestion (TestNormalizeStage).
Header hygiene
Every orchestration value must be RFC 7230 token charset and at most 128 bytes. Violations are rejected with HTTP 400 — never truncated — so hostile client-asserted strings never reach signed evidence or operator dashboards (TestResolveOrchestration_Hygiene, TestGatewayOrchestration_HygieneRejectedAtGateway).
Session semantics: what "one session" means
One session id groups requests across providers, scoped per (tenant, agent). Two different agents asserting the same session id get separate sessions and separate session budgets — a session id is not a cross-tenant or cross-agent join key (TestSessionBudget_AgentAndTenantIsolation). Synthetic session ids are evidence-only and never create session state or budget rows (TestSessionBudget_SyntheticSessionsCreateNoRows).
4. Set budgets
Per agent, in its agent.talon.yaml — the agent's one override over the organization baseline:
policies:
session_limits:
max_cost: 10.00 # soft cap per coding session
cost_limits:
daily: 50.00
monthly: 500.00
How the session cap (session_limits.max_cost) behaves — precisely:
- A new request is denied once accrued session spend + the pre-request estimate exceeds the limit. The denial is HTTP 403 with a provider-native error body (
session_budget_exceeded: session spend 6.00 + estimate 1.00 exceeds limit 5.00), so clients surface it like any provider error. One documented exception: errors emitted before routing (e.g. a request for an unknown provider prefix) use the OpenAI error shape, since no provider — and therefore no wire family — was resolved yet (#195). - Spend accumulates per session, not per provider — the same session is denied on the other provider's route too (
TestSessionBudget_CrossProviderDeny). - It is a soft cap: one in-flight request whose real cost exceeds the estimate can overshoot, and N concurrent first requests are bounded only by N × per-request cost. Atomic reservation is #144 (
TestSessionBudget_SoftCapOvershoot,TestSessionBudget_ConcurrentBurstBound). - Session denies carry a structured evidence detail (limit, spent, estimate) — populated only for session-budget denies, not other reasons (
TestSessionBudgetDetail_OnlyOnSessionDeny). - A budget deny is preventive: the provider is never dispatched (
TestSessionBudget_ExceededDeny_ZeroDispatchasserts zero upstream calls). There is no observe-and-forward posture (#442). - If the session store fails, the budget check fails open and the gap is annotated in signed evidence (
session_budget_unavailable,TestSessionBudget_FailOpenAnnotated).
5. Watch it: audit, costs, dashboard
All session surfaces read the same signed evidence through one aggregation (evidence.BuildSessionSummary) — the CLI, the export, and the dashboard cannot disagree.
# Per-session summary + the session's records (per-subagent rollup)
talon audit list --session sess-feature-4711
# Verify HMAC integrity of every record in the session
talon audit verify --session sess-feature-4711
# Export only this session's records (for hand-off). Cache-aware cost columns
# (cache_read_tokens, cache_write_tokens, pricing_basis) are included.
talon audit export --session sess-feature-4711
# Per-session cost rollup, machine-readable
talon costs --session sess-feature-4711 --json
Scoping: without --tenant/--agent these session commands are unscoped (they show the whole session, whichever tenant owns it — local CLI access implies DB access anyway); pass --tenant and/or --agent to filter. Note talon costs' calendar rollups (no --session) default to tenant default — the session forms do not.
The dashboard (talon serve) has a Coding Sessions (orchestration) panel showing active sessions with per-subagent attribution and denials_by_reason — budget trips show up as session_budget_exceeded counts.
6. Run the offline demo
The whole scenario — one session, two providers, subagent tree, PII warn, session budget trip, signed evidence — runs offline in about 30 seconds:
make coding-agents-demo # from the repo root
# or: cd examples/coding-agents-demo && docker compose up -d --build && ./demo.sh all
The mock provider speaks both the Anthropic Messages wire and the OpenAI Responses wire (including SSE with cache-token usage), so no real API key exists anywhere in the stack. The same sequence is smoke-run in CI: go test -tags=integration ./tests/integration -run TestCodingAgentsDemo_EndToEnd. See examples/coding-agents-demo/README.md.
Failure mode → defense mapping
| Failure mode | Talon defense | Control / backing |
|---|---|---|
| Runaway spend inside one coding session | Per-session soft cap; new requests denied 403 with provider-native session_budget_exceeded | max_session_cost; TestSessionBudget_SoftCapOvershoot |
| Session shifts spend to the other provider's route | Session spend accumulates cross-provider; denied on both routes | TestSessionBudget_CrossProviderDeny |
| Two teams collide on the same session id | Sessions scoped per (tenant, agent) — separate sessions, separate budgets | TestSessionBudget_AgentAndTenantIsolation |
| Hostile header value aimed at evidence or dashboards | Hygiene gate: token charset, ≤128 bytes, reject 400 (never truncate) | TestGatewayOrchestration_HygieneRejectedAtGateway |
| Junk stage strings bloating session state | Fixed stage set (generation/judge/commit); others dropped at ingestion | TestNormalizeStage |
| Subagent forges its identity | Attribution, not authentication: provenance: client_asserted; identity never a policy input; budgets bind to the Talon agent | TestPolicyInputParity_WithAssertedSession; LIMITATIONS.md §7 |
| Session store outage | Budget check fails open; gap annotated in signed evidence (session_budget_unavailable) | TestSessionBudget_FailOpenAnnotated |
| Budget denials disrupting rollout | Start with a generous session_limits.max_cost, read talon audit list --session, then lower it; denials are always real and dispatch nothing | session_limits.max_cost; TestSessionBudget_ExceededDeny_ZeroDispatch |
| Credentials pasted into prompts | High-precision recognizers (PEM, AKIA..., GitHub tokens, sk-ant-/sk-proj-) evidenced via pii_action: warn | pack agent.talon.yaml custom_recognizers |
| Evidence tampering | HMAC verification over every record in the session | talon audit verify --session <id> |
What Talon does not see (limitations)
LIMITATIONS.md §7 is the honest boundary statement for this whole scenario. In one breath:
- Local tool execution is invisible — file edits, shell commands, and local tool runs never transit the gateway; evidence shows the model's tool_use intentions, not local execution.
- Client-asserted identity is attribution, not authentication — attestation is #149.
- Subscription/OAuth billing cannot be governed — governed operation requires the agent-key + vault-injected-key model.
- Session budgets are soft caps — atomic reservation is #144.
- Response PII
warnis observation after delivery, not prevention — it cannot recall what the developer already saw;redact/blockprevent, and therefore buffer whole streams. - Cache pricing falls back to the input rate when a pricing entry lacks cache rates.
Summary
| Before | After |
|---|---|
| Orchestrator → two providers directly | Orchestrator → Talon → both providers |
| Spend per API key, unattributed | Per-session soft caps + per-agent daily/monthly caps |
| No idea which subagent did what | Per-subagent attribution in one session view |
| Logs you hope are intact | Signed evidence; talon audit verify --session |
You're done
Your fleet's traffic now flows through one gateway: every request carries session and subagent attribution, session budgets deny runaway spend on either provider route, and one aggregation feeds the CLI, the export, and the dashboard.
Next steps:
| I want to… | Doc |
|---|---|
| See the whole scenario run offline | Coding-agents demo (make coding-agents-demo) |
| Cap daily spend per team | Cost governance by agent |
| Hand evidence to an auditor | Compliance export runbook |
| Copy-paste policy recipes | Policy cookbook |
| Know exactly where the boundary is | LIMITATIONS.md §7 |