How to govern Codex CLI with Talon
This guide shows how to route Codex CLI's OpenAI traffic through Talon so every request is audited, attributed per session and subagent, and policy-checked. Allow about 15 minutes.
One boundary up front, because it decides whether this guide applies to you: Talon can only govern Codex when Codex authenticates with an API key, not a ChatGPT subscription login. Codex sends subscription OAuth tokens when requires_openai_auth = true is set on a provider — Talon rejects those as unknown agent keys. Governed operation is always Talon agent key in, vault-stored provider key out. See LIMITATIONS.md §7.
Two wire facts shape everything below (verified 2026-07 against the openai/codex source; see the pin notes in internal/gateway/testdata/conformance/responses/README.md): Codex speaks the OpenAI Responses API only (wire_api = "responses"; the "chat" value was removed in Feb 2026), and Codex always streams — token usage arrives only inside the terminal response.completed SSE event, which Talon parses into signed cost evidence (extractUsageFromJSONPayload in internal/gateway/forward.go, pinned by TestExtractUsage_ResponsesCompleted in internal/gateway/cache_pricing_test.go, including input_tokens_details.cached_tokens).
Prerequisites
- Talon installed (
go install github.com/dativo-io/talon/cmd/talon@latestorcurl -sL https://install.gettalon.dev | sh). On macOS, ifgo installfails with a linker error (e.g.unsupported tapi file type), useCC=/usr/bin/clang go install ...@latestormake installfrom a clone. - Codex CLI installed
- A real OpenAI API key (
sk-...) with billing set up — subscription-only accounts cannot be governed (see above)
Steps
1. Install Talon and generate the coding-agents config
mkdir talon-coding && cd talon-coding
talon init --pack coding-agents
This creates three files (source of truth: internal/pack/templates/coding-agents/):
agents/codex.talon.yaml— thecodexagent: Codex CLI's Talon traffic identity (agent.key.secret_name: codex-talon-key) plus its policy override with coding-tuned defaults —session_limits.max_cost: 10.00,cost_limits.daily: 50.00/monthly: 500.00,input_scan: true(input PII actionwarn),allowed_providers: ["openai"],metadata.team: coding— and high-precision credential recognizers (PEM private-key blocks, AWSAKIA...key IDs, GitHubghp_/github_pat_tokens, Anthropic/OpenAIsk-ant-.../sk-proj-...keys) so leaked credentials in prompt traffic land in evidence.agent.talon.yaml— theclaude-codeagent for Claude Code (the pack's primary agent; see the Claude Code guide).talon.config.yaml— gateway config with the OpenAI provider, the organization baseline (organization_policy.defaults:pii_action: warn,response_pii_action: allow), shadow mode, and a raisedrequest_timeout: 600s(the response-header wait follows it by default).
One agent, or the whole fleet (#267, shipped): this guide provisions only the codex agent, so talon serve runs it single-file via TALON_DEFAULT_POLICY=agents/codex.talon.yaml (step 2). To govern Codex and Claude Code from one talon serve, set agents_dir in talon.config.yaml — discovery loads every agent.talon.yaml under it, each served with its own key, policy, and routing; provision both agents' keys first, and give the codex agent its own agents/codex/agent.talon.yaml (the pack writes it flat as agents/codex.talon.yaml). See the Claude Code guide for the second agent.
These defaults are deliberate — see Why the pack defaults look like this below before changing them.
2. Set the vault key, store the real OpenAI key, mint the agent key
Talon encrypts secrets with TALON_SECRETS_KEY. Use the same value when storing the secrets and when starting the server, or the gateway returns "Service configuration error" / "cipher: message authentication failed".
# 1. Set the vault encryption key once; keep it for the next steps (save it somewhere safe)
export TALON_SECRETS_KEY=$(openssl rand -hex 32)
# 2. Store your real OpenAI key (secret name must match the config's secret_name)
talon secrets set openai-api-key "sk-your-openai-key"
# 3. Mint the codex agent's traffic key (bound via agent.key.secret_name);
# keep $CODEX_KEY — Codex will present it to the gateway
CODEX_KEY="$(openssl rand -hex 24)"
talon secrets set codex-talon-key "$CODEX_KEY"
# 4. Start Talon with the gateway — same shell so TALON_SECRETS_KEY is still set,
# with the codex agent file active (see step 1)
export TALON_DEFAULT_POLICY=agents/codex.talon.yaml
talon serve --gateway
Codex presents its agent key; Talon resolves it to the codex agent (and its derived tenant) and injects the vault-stored provider key upstream. Your real sk-... key never reaches developer machines.
Three different keys (do not confuse):
| Key | Purpose | Where it lives |
|---|---|---|
| TALON_SECRETS_KEY | Encrypts/decrypts the vault. Must be the same for talon secrets set and talon serve. | Environment. Set before the steps above. |
Agent key ($CODEX_KEY) | Workload identity Codex sends to Talon as its "OpenAI API key"; resolves to the codex agent and its derived tenant. Not used for encryption. | Vault secret codex-talon-key (referenced by agent.key.secret_name), and in Codex's environment via the provider's env_key (step 4). Rotate with talon secrets set codex-talon-key <new> + restart — one active key, never two. |
Provider key (sk-...) | Talon's upstream credential. | Vault secret openai-api-key only. |
3. Confirm the gateway is running
Leave talon serve --gateway running. Optional: test the Responses route with curl:
curl -s -X POST http://localhost:8080/v1/proxy/openai/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CODEX_KEY" \
-d '{"model":"gpt-5.3-codex","input":"Say hi"}'
You should get a JSON response, not "Invalid or missing agent key" (unknown key) or "Service configuration error" (vault key mismatch).
The Responses wire path — streaming SSE with tool calls, client_metadata/prompt_cache_key passthrough, full-transcript resend, store semantics — is covered by conformance fixtures in internal/gateway/conformance_responses_test.go and internal/gateway/testdata/conformance/responses/. The fixtures assert SSE streams reach the client byte-identical including the terminal response.completed event — Codex treats a stream that never ends with response.completed as an error and retries.
4. Point Codex CLI at the gateway
Edit ~/.codex/config.toml and add a Talon provider plus a profile that uses it:
[model_providers.talon]
name = "Talon gateway"
base_url = "http://localhost:8080/v1/proxy/openai/v1"
wire_api = "responses"
env_key = "TALON_CODEX_KEY"
[profiles.talon]
model_provider = "talon"
model = "gpt-5.3-codex"
Then run Codex with the agent key in the environment variable named by env_key:
export TALON_CODEX_KEY=$CODEX_KEY # the value minted into codex-talon-key
codex --profile talon
(Or set model_provider = "talon" at the top level of config.toml to make it the default instead of a profile.)
Important:
- The trailing
/v1inbase_urlis required. Codex buildsPOST {base_url}/responses, and Talon's Responses handling matches the/v1/responsespath (isResponsesAPIPathininternal/gateway/responses_api.go). Without it the joined path misses both Talon's Responses handling and OpenAI's endpoint — you get 404 on every request. Note: the pack wizard's printed next-steps currently omit the/v1(#235); use the value shown here. - Never set
requires_openai_auth = trueon the Talon provider. That makes Codex send its ChatGPT-subscription OAuth token, which Talon rejects as an unknown agent key. Subscription billing cannot be governed; this is a hard boundary, not a configuration gap (LIMITATIONS.md §7). - The value in
TALON_CODEX_KEYis the Talon agent key, not your real OpenAI key. Talon resolves thecodexagent by this key and injects the vault-stored key upstream — Codex never sees yoursk-...key. - The Codex IDE extension reads the same
~/.codex/config.toml, includingmodel_providers, so this configuration governs it too (verified 2026-07 against theopenai/codexsource; re-verify on Codex majors per the recapture notes ininternal/gateway/testdata/conformance/responses/README.md).
Codex's native identity headers (session-id for the session, x-openai-subagent for the subagent) are recognized automatically by the built-in codex vendor adapter (vendorAdapters in internal/gateway/orchmeta.go, pinned by the "codex vendor adapter" case in TestResolveOrchestration_Precedence) and recorded in signed evidence as orchestration metadata. Codex's client_metadata body object and prompt_cache_key are forwarded untouched — the client_metadata_passthrough conformance fixture asserts no transform drops them.
Two honest caveats: header values are attribution, not authentication — they are recorded with provenance: "client_asserted" and are never a policy input (budgets bind to the Talon agent and the agent-scoped session, never to subagent labels; backed by TestPolicyInputParity_WithAssertedSession). And header values are validated at ingestion: longer than 128 bytes or outside the HTTP token charset means the request is rejected, not truncated (internal/gateway/orchmeta.go).
Optional — cross-tool session continuity: to group Codex work into an orchestrator-chosen session (e.g. spanning Claude Code and Codex), inject the generic header from an environment variable:
[model_providers.talon]
# ... as above ...
env_http_headers = { "X-Talon-Session-ID" = "TALON_SESSION_ID" }
When TALON_SESSION_ID is set in Codex's environment, every request carries the generic session header, and generic beats the vendor session-id on conflict (TestResolveOrchestration_Precedence). See Governing coding agents for the full contract.
5. Verify
Run any prompt in Codex, then check the audit trail:
talon audit list
# Traffic for this agent only:
talon audit list --agent codex
Session-level views (all built on the same aggregation, evidence.BuildSessionSummary):
# Per-session summary + per-subagent rollup + the session's records
talon audit list --session <session-id>
# Per-session cost rollup, machine-readable
talon costs --session <session-id> --json
# Verify the HMAC signature of every record in a session
talon audit verify --session <session-id>
The dashboard (talon serve HTTP UI) shows the same data in the Coding Sessions panel.
Because Codex always streams, cost figures depend on Talon parsing the usage block out of the terminal response.completed event — that parsing is shipped (internal/gateway/forward.go, TestExtractUsage_ResponsesCompleted); without it streamed cost would be estimate-only. One pricing caveat from LIMITATIONS.md §7: a pricing entry without cache rates bills cache tokens at the full input rate (pricing_basis: "cache_fallback_input_rate") — keep the pricing table current.
Troubleshooting
- 404 on every request — The
base_urlis missing the trailing/v1. Codex appends/responses, so.../v1/proxy/openaibecomes.../v1/proxy/openai/responses, which matches neither Talon's/v1/responseshandling nor OpenAI's endpoint. Setbase_url = "http://localhost:8080/v1/proxy/openai/v1"(#235). - Authentication errors on every request — Either
TALON_CODEX_KEYis unset in the shell running Codex (theenv_keymechanism sends nothing), or the provider hasrequires_openai_auth = trueand Codex is sending subscription OAuth, which Talon rejects. Removerequires_openai_auth, export the agent key, restart Codex. LIMITATIONS.md §7. gateway_secret_get_failed/ "cipher: message authentication failed" / "Service configuration error" — Vault decryption failed. Use the sameTALON_SECRETS_KEYfortalon secrets setandtalon serve. If you lost it, set a new one and re-runtalon secrets set openai-api-key "sk-...".- Long generations are cut off mid-response — Your
request_timeoutis too low. The pack sets600s; the server-wide default of120shard-cuts long coding generations. The response-header wait defaults torequest_timeout(tunable viaresponse_header_timeout), so slow non-streaming prompts get the same budget. Also note Codex itself aborts a stream that has been idle for ~300 seconds and retries; Talon cannot extend that client-side limit. - Streaming shows nothing for a long time, then everything at once — A
response_pii_actionother thanallowbuffers the entire SSE stream before release; time-to-first-token becomes total generation time. Set the organization baseline back toresponse_pii_action: allow(LIMITATIONS.md §7). - A Responses client (not Codex) fails with "Item with id 'rs_...' not found. Items are not persisted when store is set to false" — That client relies on
previous_response_idcontinuity while sendingstore: falseor nostorefield. Codex does not — it resends the full transcript each turn (full_transcript_resendconformance fixture). For clients that do, setresponses_store_mode: force_if_absenton the provider (step 6). talon audit list --sessionfinds nothing — The session ID must match what Codex sent in itssession-idheader. Check a recent record withtalon audit list --agent codexand read the session ID from its orchestration metadata.
6. store semantics — respect Codex's retention decision
Codex sends store: false on every request (except Azure) and resends the full transcript each turn. store: false is a retention decision: the provider is being asked not to persist the conversation. Talon's handling is configured per provider:
gateway:
providers:
openai:
base_url: "https://api.openai.com"
secret_name: "openai-api-key"
responses_store_mode: preserve # default
| Mode | Behaviour | Use when |
|---|---|---|
preserve (default) | Forwards the client's store field untouched; explicit store: false is honored, an absent field stays absent | Codex, and any client that manages its own transcript |
force_if_absent | Injects store: true only when the client sent no store field; explicit store: false is still honored | Clients that need previous_response_id continuity (e.g. OpenClaw) |
force_true | Reverses an explicit store: false — and records that in signed evidence (gateway_annotations: ["responses_store_overridden"]) | Only if you knowingly accept that the provider then retains data the client asked not to store |
All three modes are pinned end-to-end by TestConformanceResponses_StoreModes in internal/gateway/conformance_responses_test.go. For Codex, leave the default alone: preserve honors Codex's store: false, and Codex never uses previous_response_id over HTTP, so it loses nothing.
7. Session budgets
The pack sets a per-session spending cap in the codex agent file:
policies:
session_limits:
max_cost: 10.00
This is a soft cap: a new request is denied once accrued session spend plus the pre-request estimate exceeds the limit — HTTP 403 with a provider-native error body (deny reason session_budget_exceeded), and the evidence record carries a structured session_budget detail (limit, spent, estimate).
Honest semantics, stated plainly (LIMITATIONS.md §7): one in-flight request whose real cost exceeds its estimate can overshoot the cap, and N concurrent first requests are bounded only by N × per-request cost. Atomic reservation is tracked as #144. The behavior is pinned by TestSessionBudget_SoftCapOvershoot, TestSessionBudget_ConcurrentBurstBound, and the rest of TestSessionBudget_* in internal/gateway/session_budget_test.go.
Session budgets stack with the agent's daily/monthly caps (policies.cost_limits); session spend accumulates across provider routes for one agent (TestSessionBudget_CrossProviderDeny), and sessions from one agent never affect another agent's budget (TestSessionBudget_AgentAndTenantIsolation).
8. Roll out enforcement
The generated config starts in mode: "shadow": nothing is blocked, and every request that would have been denied is recorded as a shadow violation in signed evidence (TestSessionBudget_ShadowMode covers the session-budget case). Run in shadow until the dashboards look right, then:
# Review what would have been blocked
talon enforce report
# Flip to enforce mode
talon enforce enable
talon enforce status shows the current mode; talon enforce disable drops back to shadow.
Why the pack defaults look like this
| Default | Reason |
|---|---|
response_pii_action: allow (organization baseline) | Any other value (warn/redact/block) buffers the entire SSE stream before releasing it — time-to-first-token becomes total generation time, and Codex always streams. Input-side scanning (warn) still applies, and a per-agent downgrade to allow is deliberately not expressible. LIMITATIONS.md §7. |
Input PII action warn (input_scan: true, no redact), not redact | Redaction mangles code. warn records findings in evidence and lets the request flow. |
request_timeout: 600s | The 120s default hard-cuts long coding generations. The response-header wait defaults to request_timeout, so slow non-streaming prompts are covered too. Codex additionally aborts idle streams at ~300s on its side. |
responses_store_mode: preserve | Codex's store: false is an explicit retention decision; the gateway must not silently reverse it (TestConformanceResponses_StoreModes). |
session_limits.max_cost as a soft cap | Denies the next request over the limit rather than killing in-flight streams; see step 7 for the overshoot bounds. |
| Credential recognizers, not a secret scanner | The agent.talon.yaml recognizers cover high-precision formats only (PEM, AKIA..., ghp_..., sk-ant-.../sk-proj-...) in prompt/response traffic. Talon is not a repository secret scanner — keep gitleaks/trufflehog in pre-commit. |
What Talon does not see
Talon governs model API traffic. Codex's local tool executions — file edits, shell commands, anything it does on the developer's machine — never transit the gateway. Evidence shows the model's tool-call intentions, not local execution results. Client-asserted session/subagent identity is attribution within an already-authenticated agent, not authentication of subagents. Both boundaries are documented in LIMITATIONS.md §7.
Failure mode → defense mapping
| Failure mode | Talon defense | Config / control |
|---|---|---|
Developer routes Codex via Talon with requires_openai_auth = true (subscription OAuth) | Gateway rejects the OAuth token as an unknown agent key — ungoverned traffic cannot masquerade as governed | Agent-key auth via env_key; LIMITATIONS.md §7 |
| Real OpenAI key spread across developer laptops | Vault-stored key injected upstream; clients only ever hold the agent key | talon secrets set openai-api-key, providers.openai.secret_name |
Gateway silently reverses Codex's store: false retention decision | Default preserve forwards it untouched; a force_true override is recorded in signed evidence (responses_store_overridden) | responses_store_mode (TestConformanceResponses_StoreModes) |
Gateway transforms mangle Codex body shapes (client_metadata, prompt_cache_key, full-transcript resend) or the SSE stream | Conformance fixtures assert unknown fields forwarded untouched and streams delivered byte-identical incl. the terminal response.completed | TestConformanceResponses_Fixtures, internal/gateway/testdata/conformance/responses/ |
| Streamed spend invisible (Codex always streams) | Usage parsed from the terminal response.completed event into signed cost evidence, incl. cached tokens | internal/gateway/forward.go (TestExtractUsage_ResponsesCompleted) |
| Agent loop burns budget inside one session | Soft session cap denies the next request with session_budget_exceeded + structured session_budget evidence | session_limits.max_cost (TestSessionBudget_*) |
| Slow-burn overspend across sessions | Per-agent daily/monthly caps | policies.cost_limits.daily / .monthly |
Credentials pasted into prompts (AWS keys, GitHub tokens, PEM blocks, sk-...) | Input-side scan with high-precision recognizers; findings recorded in signed evidence | agent.talon.yaml custom_recognizers, input warn |
| Long generation hard-cut mid-stream | Raised coding timeouts | request_timeout: 600s (header wait follows it by default) |
| Subagent forges its identity | Attribution, not authentication: provenance: "client_asserted", never a policy input; budgets bind to the Talon agent | TestPolicyInputParity_WithAssertedSession; LIMITATIONS.md §7 |
| Hostile/oversized orchestration header values | Validated at ingestion: 128-byte cap, HTTP token charset, rejected not truncated | internal/gateway/orchmeta.go |
| Enforcement flipped on blind | Shadow mode records would-have-denied violations first | mode: "shadow", talon enforce report / enable |
| Evidence tampering | HMAC-signed evidence chain, verifiable per session | talon audit verify --session <id> |
Summary
| Before | After |
|---|---|
| Codex CLI → OpenAI directly | Codex CLI → Talon → OpenAI |
| Real API key on every laptop | Key in Talon's encrypted vault only |
| No central audit | Every request in talon audit, attributed per session and subagent |
| No cost controls | Session, daily, and monthly caps per agent |
No proof store: false survived the middlebox | Retention decision preserved by default; any override signed into evidence |
You're done
Codex CLI now sends all OpenAI traffic through Talon. Talon logs every request into signed evidence, attributes sessions and subagents, parses streamed usage into cost evidence, preserves Codex's store: false retention decision, scans inputs for leaked credentials, and enforces (or shadow-records) session and agent budgets.
Next steps:
| I want to… | Doc |
|---|---|
| Govern Claude Code through the same gateway | How to govern Claude Code with Talon |
| Run an orchestrated fleet across both providers | Governing coding agents |
| Cap spend per team or application | How to cap daily spend per team or application |
| Export evidence for auditors | How to export evidence for auditors |
| Understand the hard boundaries | LIMITATIONS.md §7 |
Conformance fixtures: #200 / PR #224.