Every other connection guide routes Claude Code to a backend. This one runs the opposite
direction: point the OpenAI Codex CLI at shunt and let shunt load-balance it across a pool of
ChatGPT/Codex OAuth accounts. shunt relays the Codex CLI’s OpenAI Responses traffic verbatim —
no Anthropic translation — so the CLI talks the same wire protocol to shunt that it would talk
directly to chatgpt.com.
This is the Inbound Codex Endpoint in practice; that page is the behavior spec (failover table, exhaustion semantics, reload rules). The steps below are the end-to-end connection walkthrough. Codex CLI config keys quoted here come from OpenAI’s configuration reference and authentication docs.
1. Enable the endpoint on shunt
The inbound endpoint is opt-in. Add the table and point it at a chatgpt_oauth provider (the
default provider name is codex):
# shunt config.toml
[server.codex_endpoint]
provider = "codex" # must be a chatgpt_oauth provider; "codex" is the built-in defaultcodex is a built-in chatgpt_oauth provider, so no [providers.codex] block is required to enable
the endpoint. To point it at a differently-named provider, declare one as a [providers.<name>]
table (see the configuration reference) and set
provider = "<name>" above.
shunt check # validates the endpoint's provider exists + is chatgpt_oauth
shunt runWhen [server.codex_endpoint] is absent, none of the routes are registered and shunt’s default
HTTP surface is unchanged. When present, startup validation rejects an unknown provider or one
whose auth isn’t chatgpt_oauth — the endpoint injects the operator’s Codex bearer, so only a
chatgpt_oauth provider qualifies. See the
configuration reference for every key.
2. Point the Codex CLI at shunt
The Codex CLI appends /responses to whatever base URL it uses (Codex speaks the OpenAI Responses
wire protocol — wire_api = "responses" is the only value it supports), so shunt registers three
routes and any client shape below lands on one:
Codex CLI ~/.codex/config.toml |
shunt route it hits |
|---|---|
custom provider base_url = ".../v1" |
POST /v1/responses |
openai_base_url = ".../v1" |
POST /v1/responses |
chatgpt_base_url = ".../backend-api/codex" |
POST /backend-api/codex/responses |
Recommended — a custom model provider. With requires_openai_auth = false it is unauthenticated
from the CLI’s view, so it needs no local codex login at all — shunt supplies the account — and
it supports either way of presenting the shunt token (step 3):
# ~/.codex/config.toml
model_provider = "shunt" # select it as the active provider
model = "gpt-5.6-sol" # an entitled slug — see step 5
[model_providers.shunt]
name = "shunt"
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses" # the only supported value; also the default
requires_openai_auth = false # shunt handles auth; the CLI needs no ChatGPT/API login hereSimpler — override a base URL (no provider block):
# ~/.codex/config.toml — pick one
openai_base_url = "http://127.0.0.1:3001/v1" # + OPENAI_API_KEY = your shunt token
chatgpt_base_url = "http://127.0.0.1:3001/backend-api/codex" # loopback only (ChatGPT login mode)openai_base_url + OPENAI_API_KEY=<shunt-token> is the LiteLLM/llmgateway idiom — the CLI sends
the token as Authorization: Bearer, which shunt accepts (step 3), so this shape works even with
[server.auth]. chatgpt_base_url keeps the CLI in ChatGPT-login mode and sends its own ChatGPT
token, which shunt can’t accept as a client token — use it only on an ungated (loopback) shunt.
Both run the CLI in its own OpenAI/ChatGPT auth mode, so both need a local codex login; the custom
provider above avoids even that.
The CLI’s own local ~/.codex/auth.json login is irrelevant to which account answers — every
request draws an account from shunt’s pool. A loopback base_url may stay plain http://; use
https:// for anything remote. Do not set supports_websockets = true on the shunt provider —
this endpoint is HTTP/SSE-only (see below).
3. Present the shunt client token (when [server.auth] is set)
If shunt has [server.auth] configured — recommended for anything beyond
loopback — present the shunt client token one of two ways; shunt accepts either.
A. As an OpenAI-style Bearer key — the LiteLLM/llmgateway idiom. Set the shunt token as the API
key and the CLI sends it as Authorization: Bearer <shunt-token>:
# ~/.codex/config.toml — built-in openai provider (no provider block needed).
# Set the base URL here, NOT via the OPENAI_BASE_URL env var (see the caution in step 2).
openai_base_url = "http://127.0.0.1:3001/v1"# then present only the token via env — the CLI sends it as Authorization: Bearer
export OPENAI_API_KEY="<shunt-token>"# or a custom provider — env_key becomes the Bearer
[model_providers.shunt]
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"
env_key = "SHUNT_TOKEN" # reads $SHUNT_TOKEN, sent as Authorization: BearerB. As the x-shunt-token header (or whatever [server.auth].header names). Only a custom
provider can attach a header; keep the secret out of config.toml with env_http_headers:
[model_providers.shunt]
name = "shunt"
base_url = "http://127.0.0.1:3001/v1"
wire_api = "responses"
requires_openai_auth = false
env_http_headers = { "x-shunt-token" = "SHUNT_TOKEN" } # reads $SHUNT_TOKENexport SHUNT_TOKEN="<token>"(Or hardcode http_headers = { "x-shunt-token" = "<token>" }.) Either way the token is checked
against [server.auth]’s list; a missing or wrong token returns 401 authentication_error. Without
[server.auth] at all the endpoint is open to anyone who can reach it — fine for loopback, not for a
shared gateway. The client’s bearer/header is used only to authenticate to shunt — it is stripped
and never forwarded to the Codex backend.
4. Provision the account pool on shunt
The endpoint reuses the Codex Multi-Account pool unchanged — provision accounts on shunt’s host, not the CLI’s:
codex login # sign in to a ChatGPT account (browser flow)
shunt login codex --name main # capture it into shunt's store
# Each additional pool account needs its own login + capture (a separate ChatGPT
# account): `codex login` then `shunt login codex --name backup`, and so on.# shunt config.toml
[[providers.codex.accounts]]
name = "main"
[[providers.codex.accounts]]
name = "backup" # each declared name must be provisioned above; an unprovisioned
# account is skipped on failover (its credential fails to resolve)Selection is session-sticky: the Codex CLI’s own session-id request header keys the account,
so one conversation stays on one account for as long as it stays healthy, then fails over (429 →
rotate, 401 → refresh + retry, 5xx → cool down + rotate). A successful pooled response carries an
x-shunt-account: <name> header. On a shared gateway with [server.auth]
enabled, the sticky key is scoped per authenticated client, so different clients that happen to send
the same session-id use distinct sticky keys — preventing cross-client sticky-key collisions. (The
keys are distinct; two distinct keys can still hash to the same account.)
With no [[providers.codex.accounts]] configured and an empty store, the endpoint falls back to
the single default ~/.codex/auth.json credential — no pool, no failover — so one Codex login works
the moment [server.codex_endpoint] is set.
5. Pick an entitled model
6. Verify
A raw Responses request against any of the three routes should relay verbatim and (when pooled)
return an x-shunt-account header:
curl -N -i -X POST http://127.0.0.1:3001/v1/responses \
-H "content-type: application/json" \
-H "x-shunt-token: <token>" \
-d '{"model":"gpt-5.6-sol","input":"say hi","stream":true}'- A
200withcontent-type: text/event-streamand anx-shunt-account:header ⇒ the pool served the request and the SSE is relayed unchanged. - A
401 authentication_error⇒ missing/invalidx-shunt-token(step 3). - A
400naming the model ⇒ the account isn’t entitled to that slug (step 5).
Then run the real Codex CLI a turn — it round-trips through the pool with no code change on the CLI
side beyond the base URL. See Inbound Codex Endpoint for how this
differs from the /v1/messages path and HTTP Endpoints for the registered
routes.
HTTP/SSE only
Even when the target provider has websocket = true, this endpoint always uses the HTTP transport,
so leave supports_websockets off (its default) on the Codex CLI’s shunt provider. The experimental
Codex WebSocket v2 transport
is out of scope for this endpoint and tracked as a follow-up.