Skip to content

Latest commit

 

History

History
521 lines (451 loc) · 33.2 KB

codex.md

File metadata and controls

521 lines (451 loc) · 33.2 KB

Codex

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.

Enable and revert

agentop configure codex enable    # route Codex through Cortex
agentop configure codex status    # what is set
agentop configure codex disable   # take it out again

What 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.

Codex's background app-server

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 restart

To 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:ESTABLISHED

Undoing it by hand

Without 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 restart

CA trust

Cortex'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.

⚠️ If you have already trusted the bridge CA in your OS keychain, or exported 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.

What Cortex shows for it

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.

⚠️ An existing ~/.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 | forward

decline 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.50

⚠️ Read the resulting number as an estimate, not a bill. Those are per-token API list prices, while Codex on a ChatGPT subscription bills a flat monthly fee — so the figure is "what this would have cost via the API", which is useful for comparison and wrong if read as spend. The same caveat applies to Claude Code on a Pro/Max subscription, which Cortex prices today without saying so; this is a subscription-vs-API distinction Cortex does not currently model for any agent, not something peculiar to Codex. Check the model name against agentop 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:

  1. agentop tools scan builds the remove list 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 PATH repoints the scan, but the parser still expects Claude Code's tool_use JSONL schema and the built-in filter is unconditional.
  2. tool-prune's default paths are /v1/chat/completions, /v1/completions and /v1/messages, matched by suffix. Codex's /responses is not among them, so the plugin records skip / path_not_inference and never runs on its inference.
  3. Even with paths overridden to include /responses, tool-prune reads the manifest from a top-level tools array, while Codex nests its manifest three levels down under input[].tools[].tools[]. The plugin would skip with no_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".

Verified depth

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:

  • enable on a file with unrelated content, preserving a comment, an API key line and another tool's variable; status reporting enabled; disable restoring the file and removing the record;
  • a bare codex exec — no agentop exec wrapper, 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 enable writes 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 codex series in GET /v1/usage?group=agent, and the model showing as priceable but unpriced;
  • on Codex 0.163.0, the WebSocket transport: three codex exec turns 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. The decline mode 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 under codex-tui;
  • several turns on one socket: a codex exec prompt 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 on gpt-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.com instead of chatgpt.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_lite set — Codex sends response.interrupt on the open socket and waits for the server's response.interrupt.accepted instead 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 .env loading behaviour and the CODEX_ filter were only read from 0.160.1's source.
  • The public /v1/responses API over HTTPS. An API-key login was exercised over the WebSocket above; over HTTPS — with responses_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_call tool call. Codex's own exec tool is a custom_tool_call; the function_call shape is modelled from the documented schema.
  • Tool pruning, which is unsupported above rather than merely untested.

Known issues

  • Changing the routing restarts Codex's background server. enable and disable change 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 until codex 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: false turns 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 exec produced ~100 recorded events, most of them not inference: repeated polling of /backend-api/ps/plugins/list and 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_call arguments are freeform text, and the two plugins that forward them differ in what that costs. sparc sends the value on to its reflection service, whose own parsing falls back to the raw string on a JSONDecodeError, so it is tolerated. opa puts the value into input.inference.tool_calls[_].arguments as a plain string, which is fine until a policy calls json.unmarshal on it — that is the author's to handle, and nothing in Cortex handles it for them.
  • CODEX_HOME has to be set where agentop runs, 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, and status reports enabled because it is reading the same wrong file enable wrote. Nothing can detect that from outside — there is no signal distinguishing "CODEX_HOME is not in use" from "it is in use somewhere agentop cannot see". Pass --env $CODEX_HOME/.env explicitly if your setup is shaped that way.
  • Rows recorded before this release keep the raw User-Agent as their label and show under Other until they age out.