Codex is a supported agent. Cortex recognises
its User-Agent and parses its inference. agentop configure codex routes it through Cortex
and takes that out again.
One fact shapes it: Codex loads a dotenv file — $CODEX_HOME/.env when CODEX_HOME is set,
otherwise ~/.codex/.env — itself at process start,
confirmed against the real Codex CLI (writing a dead proxy address there and running bare
codex exec, with no agentop wrapper at all, broke both its websocket and its HTTPS
fallback transport). There is no settings JSON, so the routing goes into that file
directly. Every Codex process reads it once, when it starts — and that includes
Codex's own background server, which outlives the windows you type into; see
Codex's background app-server.
agentop configure codex enable # route Codex through Cortex
agentop configure codex status # what is set
agentop configure codex disable # take it out againWhat enable writes. Five variables, read from ~/.cortex/config.yaml so they always
match the running proxy:
| Variable | Value |
|---|---|
HTTP_PROXY HTTPS_PROXY http_proxy https_proxy |
Cortex's forward proxy URL |
SSL_CERT_FILE |
bundle.crt, the bridge CA plus the platform roots |
The CA goes in SSL_CERT_FILE, not Codex's own CODEX_CA_CERTIFICATE — which its HTTP
client would otherwise prefer, and which an earlier version of this page recommended. Codex's
dotenv loader discards every key whose name starts with CODEX_, on purpose, so that a .env
cannot reconfigure Codex itself. A CA written under that name is one Codex never reads.
SSL_CERT_FILE survives the filter and is the next name Codex checks. If some other tool
already has its own SSL_CERT_FILE in this file, enable refuses rather than overwrite it.
That mistake is worth knowing about because its symptom is success. With no CA it can
read, Codex rejects the bridge's forged certificate, Cortex falls back to tunnelling the
bytes opaquely, and the request still completes — Codex answers normally and nothing
reports an error. What is lost is the observation: Cortex sees an opaque tunnel instead of
the request. So "did Codex reply?" cannot tell a working CA from a broken one. The check
that can is whether the bridge terminated: ~/.cortex/proxy.log carries
tls-bridge passthrough … reason=client-hung-up lines for every connection it could not
terminate, and none when the CA is working.
enable scans the whole file for an existing definition of each of the five keys, not
only a block it wrote itself. Codex parses this file with
dotenvy, so KEY=VALUE, export KEY=VALUE, an
indented line and spaces around the = all count as definitions and all are seen; a
commented #KEY=VALUE does not. A key defined on more than one line is refused outright
rather than guessed at — dotenvy resolves a duplicate last-wins, so appending would
quietly beat an earlier definition, including a corporate proxy someone set deliberately.
For the same reason enable refuses to overwrite any value that is not Cortex's own,
naming it and asking you to remove it by hand first. The
only values it replaces are Cortex's own, such as a proxy at an older Cortex address. It
refuses outright when the TLS bridge is disabled or has no ca_dir, because Codex would
then have no CA to trust — there is no partial "proxy only" state. The first run copies the
original file to .env.bak and never overwrites that copy; everything else in the file,
including blank lines and comments, is left exactly as it was. A short comment marks the
newly-added lines the first time any are appended.
The record, and why it is always empty today. The first enable records what the five
variables held in ~/.cortex/codex-state.json. In practice that record contains nothing to
restore, and the reason is worth stating plainly rather than leaving as a surprise:
enable refuses whenever one of the five holds a value that is not Cortex's own, and
a value that is Cortex's own is recorded as absent (so disable removes it rather than
putting back a stale Cortex address). Those two rules are exact opposites, so every value
that reaches the record is stored as "was not set". The file, and disable's restore
branch, exist to keep Codex's state shape identical to claude-code's and OpenCode's —
#1289 is one pass over all three, and a
Codex-only design would have to be unpicked to get there.
What disable does. It removes the five variables, removes the
# Added by agentop configure codex enable marker if it is still exactly as written, and
deletes the record — so an enable / disable cycle is a round trip that leaves the file
as it found it. It would restore a recorded prior value if one existed, which per the
paragraph above does not happen today; if you hand-edit one of the five variables after
enable, disable simply removes it. The stricter "did this change since enable"
comparison is tracked by #1289.
What status says. It prints the five variables as the file currently holds them, a
warning if any is defined more than once, then enabled if every one matches what enable
would write right now, or not fully enabled (N of 5 set) otherwise. Last, whether Codex's
running background app-server uses Cortex — see below. It changes nothing.
enable and disable both show what they will change and ask before doing it. --yes
skips the question. If you decline, or there is no terminal to ask on, they change nothing
and exit 3. --env PATH points at a different dotenv file; --config PATH reads a
different Cortex config.
The file alone does not reach a Codex that is already running. Codex runs a
long-lived background server, its app-server daemon (codex app-server --managed-daemon),
and the codex you type into is only a screen that hands it each prompt: the daemon makes
the model calls. It reads the dotenv file when it starts and never again, and it is shared
by every Codex window, so it is often days old. A daemon started before enable keeps
calling OpenAI directly — the window's own startup checks and telemetry reach Cortex, its
prompts do not, and agentop shows Codex only as a small pending:codex@… bucket under
codex, with no tokens. disable has the mirror problem: the daemon keeps routing
through Cortex, and Codex stops working if Cortex is then stopped.
So enable and disable restart a running daemon, once, after writing the file, with
Codex's own codex app-server daemon restart. They say so before asking, naming its pid and
when it started, and --yes skips the question but not that line. Then they check the new
daemon's environment and say whether it uses Cortex — or, if its proxy is still not what the
file says, where else it could be coming from. With no daemon running there is nothing to
restart: the next codex starts one that reads the new file. An enable that finds nothing
to change does not restart; it says whether the running daemon uses Cortex, and how to
restart it if not.
The auto-updater is restarted too. An auto-updatable install runs a second background
process, codex app-server daemon pid-update-loop, which also reads the dotenv file only when
it starts. After installing an update it starts a new daemon, and that daemon inherits the
updater's environment, but codex app-server daemon restart leaves a running updater alone.
So enable and disable stop the updater first, and the restart starts a fresh one that has
read the new file. Comparing ps before and after, both processes change. Without this, a
disable came undone at the next Codex update, with the daemon back on Cortex's proxy. If the
updater will not stop, they name it and the command that replaces it.
What a restart does to Codex sessions, from Codex's source (0.163.0 and 0.164.0), not
measured here: the old daemon stops taking new turns, gives running ones its shutdown grace — 60
seconds by default, configurable up to 5 minutes — to finish, and saves its threads. The new daemon restores them and
continues a local turn the grace cut off, saying "Resuming interrupted work". An open
codex window shows that it is reconnecting and rejoins its thread on its own. So a restart
interrupts Codex rather than ending it, and enable or disable can take as long as that grace
when a turn is running.
If the restart fails, the file is still changed and the command still exits 0; it prints the error and the command to run yourself:
codex app-server daemon restartTo check the daemon by hand, its connections should be to 127.0.0.1:47600 rather than to
OpenAI:
lsof -nP -a -p "$(pgrep -f 'codex app-server --listen')" -iTCP -sTCP:ESTABLISHEDWithout agentop: open $CODEX_HOME/.env (or ~/.codex/.env) and remove the lines for the five variables above,
if they point at Cortex (a proxy on localhost:476…/127.0.0.1:476…, or a CA path under
~/.cortex/ca unless you moved its ca_dir). Leave any that point elsewhere — they are not
Cortex's. Then delete the record, and restart Codex's background server so it stops using
the proxy.
# Remove only lines matching these five keys, and only if they point at Cortex:
# HTTP_PROXY HTTPS_PROXY http_proxy https_proxy SSL_CERT_FILE
$EDITOR ~/.codex/.env
rm -f ~/.cortex/codex-state.json
codex app-server daemon restartCortex's TLS bridge decrypts Codex's HTTPS using certificates it forges from its own CA, so
Codex has to trust that CA. Codex's HTTP client (codex_http_client::custom_ca) checks
CODEX_CA_CERTIFICATE first, then SSL_CERT_FILE, then falls back to the platform's own
root store with neither set — but it only ever sees the process environment, and
Codex's dotenv loader strips every CODEX_-prefixed key before that. Hence
SSL_CERT_FILE; see Enable and revert above. Unlike the bundle
variables claude-code's and OpenCode's integrations set, Codex's custom CA is additive —
it is loaded on top of the platform roots rather than replacing them. bundle.crt (the
bridge CA plus the platform roots) is still the right value, it just is not load-bearing
for reaching hosts the bridge does not terminate.
Verified live with the inherited CA variables stripped from the environment
(env -u SSL_CERT_FILE -u CURL_CA_BUNDLE -u REQUESTS_CA_BUNDLE -u GIT_SSL_CAINFO), so that
the .env file was the only possible source of trust, and judged on whether the bridge
terminated rather than on whether Codex replied:
.env holds |
Bridge outcome |
|---|---|
SSL_CERT_FILE |
terminates — no tls-bridge passthrough lines |
CODEX_CA_CERTIFICATE |
passthrough; Codex rejected the forged cert |
| no CA variable at all | passthrough — identical to the row above |
That last pair is the whole reason this page labours the point: the CODEX_-prefixed name
is indistinguishable from writing nothing, and both still answer the prompt.
SSL_CERT_FILE in your shell, Codex will work regardless of what this file says — and a
test of this feature done in that environment proves nothing about the file. That is exactly
how the CODEX_CA_CERTIFICATE mistake survived its first review.
Its own agent. Codex sends four product tokens, all folded to the canonical agent name
codex, so its traffic gets its own row in agentop's agents pane instead of sharing
Other:
| User-Agent | Sent on |
|---|---|
codex_exec/<version> (<os>; <arch>) <term> — sometimes with a trailing (codex_exec; <version>) comment |
its inference (/backend-api/codex/responses) and most other backend calls |
codex-mcp-client/<version> |
its MCP client (/backend-api/ps/mcp) |
codex-tui/<version> (<os>; <arch>) <term> — sometimes with a trailing (codex-tui; <version>) comment |
the interactive TUI — i.e. plain codex — including its inference |
codex_cli_rs/<version> (<os>; <arch>) <term> |
anything sent before its app-server finishes initializing |
<term> is the TERM environment variable — the terminal type. It reads unknown under
codex exec, which runs without a TTY, and the terminal's own name under the interactive
TUI (xterm-256color on the captures these rows come from). Nothing reads this field:
Cortex matches on the product token before the first slash, which is why a varying TERM
does not split one agent into several rows.
The version is the field after the first slash in all four. The MCP client folds to the same
name deliberately: it is one agent's component, not a second agent. Codex's telemetry
exporter is not claimed — it sends OTel-OTLP-Exporter-Rust/<version> to
ab.chatgpt.com, which names a generic Rust crate rather than Codex, so it stays
unrecognised and shows under its raw label. Captured from Codex 0.160.1.
It groups into named sessions. Codex sends its session id in a header named
Session-Id — the bare name, not the X-Session-Id that OpenCode uses — and a laptop
install reads it. Captured on codex_exec/0.160.1: a UUID, constant across all nine
inference requests of one multi-turn session and different in the next.
Which requests carry it differs between the two front-ends, and it is worth knowing because it decides how many rows a run leaves:
Carries Session-Id |
Rows per run | |
|---|---|---|
codex exec |
its inference requests; the plugin-list and MCP chatter does not | one in agentop — the conversation under its real id. The chatter that arrived before anything named the session (15 events against 113, measured) is recorded in a pending:codex@<pid>.<start> bucket, which is linked to the session and shown inside it |
the TUI (plain codex) |
also its chatter — a TUI session was named with zero inference requests in it | one — everything lands in the named session, no pending bucket |
Either way the tokens and cost are in the named session. Note that once a session is
named, client_affinity files header-less requests from the same agent into it too, so a
row's presence there is not by itself proof that that request carried the header. Session-Id is read last in the
header precedence order, after the three dedicated names, because it is the least
namespaced of the four — and for the same reason it is read only on a loopback-only
install, exactly as X-Session-Id is. In a cluster an agent may send a bare
Session-Id for its own purposes, and filing its calls by it would take them away from
the session holding its user's intent.
~/.cortex/config.yaml may need one line added. The laptop config
writes session.id_headers out explicitly, and an explicit list is used exactly as
written rather than merged with the built-in default — so a Cortex installed before this
shipped keeps the old three names and will not read Codex's. Check with
grep -A5 id_headers ~/.cortex/config.yaml: if the list is there and has no
"Session-Id", add it as the last entry, or delete the whole id_headers: block to fall
back to the current default. A config that never had the block is already fine.
Before this, Codex's traffic landed in a per-process bucket (pending:codex@<pid>.<start>)
and stayed there — one bucket per process rather than per conversation, so two
conversations in one long-lived process merged and one spanning a restart split in two.
Typed inference. Codex speaks OpenAI's Responses API, which inference-parser reads
(model, messages, tools, completion, token counts) on any path ending in /responses —
/backend-api/codex/responses for the ChatGPT backend, /v1/responses for the public API.
Two things about that traffic are unlike the other dialects, and both are handled: the
request body arrives Content-Encoding: zstd, and the response is a real SSE stream
mislabelled Content-Type: application/json. Codex's tool calls arrive as
custom_tool_call items whose arguments are freeform text, not JSON — its exec tool
is one — so a consumer that parses ToolCalls[].Arguments as JSON must tolerate failure
for this dialect.
WebSocket, carried and parsed. Codex prefers a WebSocket to the same /responses
path — one socket for its whole session, with later turns sending only new input rather
than the whole conversation. Cortex carries it on both of Codex's endpoints — chatgpt.com
on a ChatGPT login and api.openai.com on an API key — and reads every turn on it: the
forward proxy completes the handshake, relays the frames unchanged, and runs each
response.create and the events answering it through the pipeline as if it were the HTTPS
request and its SSE stream. So a turn over the socket is parsed, priced and recorded
exactly as it is over HTTPS — model, messages, tools, completion, tokens, cost.
On the timeline a socket is a GET on the Responses path with tunnelReason: "websocket", open and close sharing a request id, the close carrying the socket's
duration and bytes. Its turns sit between the two as ordinary POST request and response
rows. Plugins act on each turn: one that rejects it keeps it from reaching chatgpt.com
and Codex is answered with the error event it reads for a server refusal; one that
rewrites the request body (a redaction script, context-guru) changes what is sent.
Codex's prewarm — a response.create with generate: false, sent before the first turn
with the prompt the turns build on — goes through the same request plugins, so a plugin
that refuses or rewrites content does so there too. It records no turn: it produces no
output.
A turn over the 32 MiB an HTTPS request body may be ends the socket with close code 1009, "message too big" — the WebSocket counterpart of the 413 HTTPS answers — before any of it reaches OpenAI, since no plugin would see it and no row would record it. An answer from OpenAI over 10 MiB ends the socket the same way; the frames before the limit have already reached Codex, since the relay forwards each frame before reading it.
Pressing Esc mid-answer closes the socket. Codex drops the turn's stream, which ends
the WebSocket, and opens a new one for the next prompt. The interrupted turn is recorded
with the text that had arrived, as statusCode 200 with error.kind: "turn_interrupted" — the way HTTPS records a stream the client leaves part way, with the
200 the origin was already answering — so /v1/usage does not count it as an error.
OpenAI sends no usage for a turn cut off this way, so it has no token count or cost: it
shows as priceable but unpriced, the same as a cancelled HTTPS stream.
A turn's status follows what the same turn would get over HTTPS. An open WebSocket
carries no HTTP status after its 101, so each turn row is given the one its HTTPS
equivalent would have: 200 for a completed turn; for a refusal the origin sends as an
error event, the event's own status, else the one its error code is answered with
(insufficient_quota and rate_limit_exceeded are 429, context_length_exceeded 400,
server_is_overloaded 503), else 500; 200 turn_interrupted when Codex ended the socket
mid-turn and 502 upstream_error when OpenAI did. Who ended it is read from the first
close frame either side sent, whose code and reason go into the row's message, and from
which side's connection dropped first when neither sent one.
Where Cortex cannot carry the socket it answers the handshake with 426 Upgrade
Required instead, which Codex reads as "this endpoint wants HTTP" and switches to HTTPS
at once, for the rest of its session. That happens for a connection the bridge did not
decrypt; a pipeline with a plugin that rewrites response bodies, since a live socket's
frames are on their way to Codex before a whole response exists; and a pipeline with a
plugin that sends requests elsewhere — inference-router, as agentop server use sets
up — since a turn rides a socket already connected to OpenAI, where over HTTPS each turn
is its own request and can be routed. A reload that adds either kind of plugin while a
socket is open ends that socket at its next turn with close code 1013, "try again later":
Codex reconnects, is answered 426, and goes on over HTTPS with the plugin applied. (An
HTTP/2 connection never reaches this far: Go's HTTP/2 server answers any request carrying
Upgrade with a 400 before Cortex sees it. Codex's client speaks HTTP/1.1.) The
timeline then shows a 426 with error.kind: "upgrade_declined", and Codex prints one
harmless failed to connect to websocket: HTTP error: 426 Upgrade Required line before
carrying on over HTTPS.
Before Cortex handled the socket at all, the proxy forwarded the handshake with Upgrade
stripped — it is a hop-by-hop header — and chatgpt.com answered the bare GET with
405. Codex treats a 405 as transient: it retried five times on a doubling backoff,
printing Reconnecting… n/5, before falling back. Measured on Codex 0.163.0, a one-word
codex exec turn took a median 11.0s that way, against ~3s with the socket carried
or declined. There is no supported Codex setting to skip the WebSocket attempt: the
built-in openai provider hardcodes supports_websockets = true, and Codex refuses a
config that overrides a built-in provider.
The behaviour is Cortex's setting, not Codex's, and the default is parse, so there is
nothing to configure. To change it, add the key to the listener: block that is already
in ~/.cortex/config.yaml:
listener:
# ...the lines already here...
responses_websockets: decline # parse (default) | decline | forwarddecline keeps Codex on HTTPS with no delay. forward hands the socket across the way
Cortex forwards every other WebSocket: it works, but its frames are opaque, so no turn on
it is parsed, priced or recorded — the timeline shows only the handshake and a 101 when
the socket ends. Then restart the proxy with agentop service restart. Unlike most of
that file, a listener: edit is not picked up live, and the restart briefly disconnects
every agent routed through Cortex.
The setting was a boolean, decline_responses_websockets, in v0.9.4. A config that still
sets it keeps working — true is decline and false is forward — and Cortex logs a
warning at startup naming the key to use instead. Setting both is refused.
Tokens always; cost on a laptop once the price list downloads, or wherever you
configure a rate. Token counts come from the Responses
API's usage block and aggregate alongside other agents (verified: a one-turn codex exec
reported 13,260 tokens under the codex series of GET /v1/usage?group=agent).
Cost on a laptop install: usually yes, and from a download rather than from the
binary. The bundled table carries first-party OpenAI list rates, so a GPT model prices
the same way a Claude one does — but the shipped table is pinned to a LiteLLM commit,
and a model added upstream after that commit is not in it. gpt-6-luna is one such, so
the compiled-in table alone does not price it. What closes the gap is the price-list
download: a local install fetches LiteLLM main at startup and hourly through the same
filter that generated the table, keeps it in ~/.cortex/price-list.json, and today's
upstream prices gpt-6-luna at $0.10 in / $0.50 out per million. So cost appears without
configuring anything, once that first download lands.
On a sidecar in a cluster, no. Sidecars make no such call and keep the compiled-in table, so a model newer than the pin reports priceable but unpriced there — named, never under-priced. See Finding traffic that is not priced.
Pin the rate yourself when you want it independent of a download — in a sidecar, with
bundled: false, or to assert a figure rather than inherit one. These are the first-party
OpenAI list prices for the model Codex uses, and a configured entry outranks both the
shipped table and the download:
# ~/.cortex/config.yaml
pricing:
endpoints:
- hosts: ["chatgpt.com"]
models:
"gpt-6-luna":
input_cost_per_million: 0.10
cache_write_cost_per_million: 0.125
cache_read_cost_per_million: 0.01
output_cost_per_million: 0.50agentop pricing or unpricedBy before copying the block — Codex
selects its own model and it changes between releases (gpt-6.1-sol has also been
observed).
Tool pruning is unsupported, for three independent reasons, any one of which is sufficient:
agentop tools scanbuilds theremovelist from Claude Code's transcripts (~/.claude/projects/*.jsonl) and proposes only Claude Code's hardcoded built-in tool names, so nothing builds a Codex list.--dir PATHrepoints the scan, but the parser still expects Claude Code'stool_useJSONL schema and the built-in filter is unconditional.tool-prune's default paths are/v1/chat/completions,/v1/completionsand/v1/messages, matched by suffix. Codex's/responsesis not among them, so the plugin recordsskip/path_not_inferenceand never runs on its inference.- Even with
pathsoverridden to include/responses,tool-prunereads the manifest from a top-leveltoolsarray, while Codex nests its manifest three levels down underinput[].tools[].tools[]. The plugin would skip withno_tool_manifest.
Worth knowing for anyone changing this: tool-prune and inference-parser keep
separate suffix lists, and only the parser's recognises /responses. The drift is
silent — the parser reads a Responses request that the pruner skips as "not inference".
Tested live with Codex 0.160.1 on macOS 26.3 (arm64), against the real ChatGPT backend, with Cortex built from the change that added this page. This was exercised:
enableon a file with unrelated content, preserving a comment, an API key line and another tool's variable;statusreportingenabled;disablerestoring the file and removing the record;- a bare
codex exec— noagentop execwrapper, no shell export — routing through Cortex and returning a normal response, confirming both the proxy routing and CA trust come from the dotenv file alone — re-run with the ambient CA variables stripped, and judged on the bridge terminating rather than on Codex replying (see CA trust); - each of the four proxy-variable spellings alone, with the other three absent: all
four independently routed Codex's traffic, which is why
enablewrites all four; - agent recognition for every User-Agent above, including the TUI's — captured from a real interactive session driven through the proxy over a pty, not inferred from Codex's source — and including the telemetry exporter staying unrecognised;
- the
codexseries inGET /v1/usage?group=agent, and the model showing as priceable but unpriced; - on Codex 0.163.0, the WebSocket transport: three
codex execturns each opened one socket through the bridge, with no handshake error and no fallback, and each turn on it was recorded with its model, a 13,188-token total and a cost — in about 3s, against a median 11.0s through the old 405 retries. Thedeclinemode was measured the same way over eight turns (one 426, then HTTPS), and a TUI turn driven over a pty declined and parsed over HTTPS undercodex-tui; - several turns on one socket: a
codex execprompt that ran two shell commands carried two turns on one WebSocket, each recorded with its model, tokens and a cost. The second turn's request held no messages, only the tool results, which is the incremental input described in #1515; - the TUI over a carried socket: a prompt typed into the real interactive TUI over
a pty opened a WebSocket under
codex-tui, and its turn was parsed with model, tokens and a cost, as was the TUI's own model check ongpt-5.6-luna; - Esc mid-answer in the TUI: the WebSocket closed within milliseconds, the interrupted turn was recorded with the 1,491 characters that had arrived, and the next prompt opened a new socket and was answered and parsed normally;
- an API-key login, on macOS: Codex opened the WebSocket to
api.openai.cominstead ofchatgpt.com, and a single-turn and a three-turn run were each parsed with model (gpt-6.1-sol), tokens and a cost; - Linux, on Codex 0.164.0 in an Ubuntu 26.10 arm64 VM with an API-key login: a single turn, three turns on one socket, and a TUI turn, each carried and parsed with tokens and a cost, and Codex's traffic attributed to its process and filed under its own session.
Not tested:
- Linux beyond the WebSocket. On Linux only the WebSocket transport above was
exercised;
agentop configure codex, the dotenv routing and the HTTPS fallback were tested on macOS only. - Codex's in-band interrupt. For some internal cases — steering a turn already
under way, or queued "mailbox" input, on models with
use_responses_liteset — Codex sendsresponse.interrupton the open socket and waits for the server'sresponse.interrupt.acceptedinstead of closing it. The relay forwards both unchanged and its tests cover the exchange in both orders, but it was not seen live: Esc does not use it. - Codex versions other than 0.160.1 through 0.164.0. 0.160.1 and 0.162.1 appeared in
one capture (an auto-update landed mid-session) and parse identically, and the
WebSocket transport was run on 0.163.0 and 0.164.0, which is some evidence the wire
shape is stable across releases rather than pinned to one build — but the
.envloading behaviour and theCODEX_filter were only read from 0.160.1's source. - The public
/v1/responsesAPI over HTTPS. An API-key login was exercised over the WebSocket above; over HTTPS — withresponses_websockets: decline, or a client that does not ask for the socket — it was not, and the non-streaming shape of that dialect is modelled from the published schema rather than from captured traffic. - A
function_calltool call. Codex's ownexectool is acustom_tool_call; thefunction_callshape is modelled from the documented schema. - Tool pruning, which is unsupported above rather than merely untested.
- Changing the routing restarts Codex's background server.
enableanddisablechange a file Codex's app-server daemon and auto-updater read only when they start, so they restart both when they are running, which interrupts running Codex turns for up to the daemon's shutdown grace (a minute by default). Changing the file any other way — by hand, or with an agentop older than this — does not reach a running daemon untilcodex app-server daemon restart. See Codex's background app-server. - Cost is blank where no list price reaches the model. Two cases, and neither is
Codex-specific: a sidecar keeps the compiled-in table and makes no download, so a
model newer than the pinned commit has no rate there; and
bundled: falseturns the shipped table and the download off together. On a laptop with defaults the rate arrives with the first hourly fetch. See What Cortex shows for it. - Chatty non-inference traffic. A single one-turn
codex execproduced ~100 recorded events, most of them not inference: repeated polling of/backend-api/ps/plugins/listand friends, plus an MCP handshake. The inference is a small fraction of a Codex session's rows. - Tool calls carry non-JSON arguments.
custom_tool_callarguments are freeform text, and the two plugins that forward them differ in what that costs.sparcsends the value on to its reflection service, whose own parsing falls back to the raw string on aJSONDecodeError, so it is tolerated.opaputs the value intoinput.inference.tool_calls[_].argumentsas a plain string, which is fine until a policy callsjson.unmarshalon it — that is the author's to handle, and nothing in Cortex handles it for them. CODEX_HOMEhas to be set whereagentopruns, not only where Codex runs. agentop reads it from its own process environment, the same way Codex does, so the two agree whenever both are launched from the same shell — the normal case. They come apart if you set it only for Codex, say in a wrapper script: agentop then writes~/.codex/.env, Codex reads$CODEX_HOME/.env, andstatusreportsenabledbecause it is reading the same wrong fileenablewrote. Nothing can detect that from outside — there is no signal distinguishing "CODEX_HOMEis not in use" from "it is in use somewhere agentop cannot see". Pass--env $CODEX_HOME/.envexplicitly if your setup is shaped that way.- Rows recorded before this release keep the raw User-Agent as their label and show
under
Otheruntil they age out.