The Codex CLI¶
The second of the two places where your code does not own the loop.
CodexCliCognition hands the whole agent loop to a locally installed codex
binary. The CLI decides how many turns to take, which of its own tools to call,
and when it is finished. Your application starts it and reads what comes back.
Is this page for you?
Reach for it when you want Codex's coding ability inside an agentkit run
— its shell, its apply_patch, its OS-level sandbox — without managing API
keys or reimplementing any of it.
Read The Claude CLI first if you have not, because the two cognitions are deliberately parallel and that page makes the shared argument once. This page is mostly about where they differ.
The shape of it¶
from pathlib import Path
from agentkit.agents import Agent
from agentkit.models.providers.cli.codex import CodexCliCognition
agent = Agent(
name="local",
prompt="You are a concise assistant.",
cognition=CodexCliCognition(
model="gpt-5-codex",
working_dir=Path("/tmp/sandbox"),
sandbox="read-only", # what the session may DO
ask_for_approval="never", # ...and whether it may pause to ask a human
),
)
Auth is entirely the CLI's problem: whatever CODEX_API_KEY, OPENAI_API_KEY
or $CODEX_HOME login the CLI would find on its own is what this uses.
agentkit never touches an API key here.
Everything a caller relies on is the same as the Claude cognition: exactly one
terminal final event on every path, the same AgentResult, the same
stop-reason taxonomy, the same spawn= seam for tests, the same evals keys
where the same fact exists (session_id, cli_duration_ms,
cli_return_code).
The four places they differ¶
The two cognitions are parallel wherever the binaries are. Where they are not, papering over it would mean a field that silently does nothing — so each difference is a visible difference in the API.
ClaudeCliCognition |
CodexCliCognition |
|
|---|---|---|
| containment | tools= — a tool allow-list |
sandbox= — an OS sandbox |
| system prompt | --append-system-prompt |
no flag; prepended, or a replaced instructions file |
| cost | reported by the CLI | computed from a price table |
| in-flight budget cap | --max-budget-usd |
none |
| a session | one held process, many turns | one thread, resumed per turn |
| middleware escape hatch | hook_settings |
none — Codex has no pre-tool hook |
Containment is a sandbox, not a tool list¶
Claude Code restricts what a session has. Codex gives every session the same
small toolbox — shell, apply_patch, update_plan, and web_search when
you ask for it — and restricts what those tools may do:
sandbox="read-only"(the CLI's own default forcodex exec) — can read the workspace, cannot write, no network.sandbox="workspace-write"— can also write inside the workspace and anyadd_dirs. Still no network unless you passnetwork_access=True.sandbox="danger-full-access"— no containment at all.
ask_for_approval is the other half and is not a substitute for it. In a
service it should be "never" — there is nobody at the terminal to answer —
which makes sandbox the only thing standing between the model and the
machine.
AgentKit's default native_tool_policy="warn" adds a narrower CLI
configuration on top of the read-only sandbox: it disables Codex shell tools,
unified exec, apps, plugins, skill dependency installation, and web search.
When AgentKit supplies host MCP tools, their explicit server and tool grants
remain available. This reduces distractions from ungoverned native tools; it
does not prove complete isolation, which Codex does not currently expose as a
host-enforced tool allow-list. Use native_tool_policy="deny" when dispatch
must be refused unless only host-governed tools can be used. "allow" leaves
the Codex native toolbox available.
This is a stronger guarantee than a tool list, on one axis
A tool allow-list is enforced by the CLI deciding not to offer a tool. A
sandbox is enforced by the operating system. The Codex cognition has no way
to make your middleware apply to shell (see below) — and the thing that
does contain shell does not depend on the model's cooperation.
There is no system-prompt flag¶
Codex has no --append-system-prompt. So agent.prompt reaches the model one
of two ways, and both are stated rather than guessed at:
system_prompt_mode="prepend"(the default) puts it at the top of the first user message, above the task, separated by a rule. Codex's own base instructions — tool guidance, the sandbox explanation, theapply_patchformat — stay in place.system_prompt_mode="replace"writes it to a file passed as-c experimental_instructions_file=…, which REPLACES those base instructions the wayclaude --system-promptreplaces Claude Code's. The config key is marked experimental by the CLI, so this is the mode that will break first on an upgrade — deliberate, since the alternative is silently not replacing anything.
In a session, the prepended prompt goes out on the first turn only. Sending it again on turn three would put three copies in the transcript and read to the model as escalating emphasis.
The CLI does not report cost¶
Claude Code's result payload carries total_cost_usd. Codex's turn.completed
carries token counts and nothing else. So Usage.cost_usd is computed:
from agentkit.models.providers.cli.codex import CodexCliCognition
cognition = CodexCliCognition(
model="gpt-5-codex",
pricing=lambda model, usage: usage.input_tokens / 1e6 * 1.25
+ usage.output_tokens / 1e6 * 10.0,
)
Without a pricing= the framework's own best-effort table is used, and it
preserves cost_usd=None with CostSource.UNPRICED when its model or accounting facts cannot be priced.
Every result carries a CostSource provenance value (PRICE_TABLE, HOST_PRICING or UNPRICED) so callers can interpret the
field as a billed number.
One more consequence, and it is the one to watch: there is no
--max-budget-usd. An exhausted budget refuses to spawn (with the resumable
budget_exhausted stop reason) and what a run cost is charged afterwards, but
nothing hands the CLI a ceiling it can stop itself against mid-flight. A run
that starts with a cent of headroom can spend five dollars.
A session is a resumed thread¶
codex exec is one-shot. Its continuation seam is codex exec resume <id>, so
CodexCliSession spawns per turn and threads the conversation through the id it
learned from turn one:
import asyncio
from agentkit.models.providers.cli.codex import CodexCliCognition
async def main() -> None:
cognition = CodexCliCognition(model="gpt-5-codex")
async with cognition.session() as chat:
async for _ in chat.turn("Summarise README.md"):
...
async for _ in chat.turn("Now list the risks you skipped"):
...
print(chat.session_id, chat.turns_taken)
asyncio.run(main())
That costs a CLI warm-up per turn, where the Claude session pays it once. It buys three things the held-process design cannot have:
- A cancelled turn does not end the conversation. The thread is on disk; the
next
turn()resumes it.ClaudeCliSessionhas to declare itself over, because its process was the conversation. - Per-turn structured output works.
--output-schemais chosen at spawn and every turn is its own spawn, so anoutput=-carrying agent may be passed to any turn. The Claude session has to refuse it. - A failed turn costs one turn.
ephemeral=True is refused by session(): nothing is written, so there is
nothing to resume.
The thing to understand first: the chain does not apply¶
Same edge as the Claude page, and here it is sharper, because there is no hook
to fall back on. When the CLI runs shell or apply_patch, the call never
passes through your Invoker, and none of this runs:
| middleware | what stops applying |
|---|---|
egress |
default-deny URL checking |
guard |
every input guard |
audit |
one record per tool call. There are no records |
memoize |
idempotency and single-flight |
Guardrail.check_url |
SSRF and allowlist |
So the cognition warns, once per instance, naming the middlewares that will not apply and the sandbox that is actually in force. Two ways to close the gap and they are not equivalent:
- Serve your own tools over MCP (below). Those calls come back through
agentkit, so the chain applies to them.
shellis still outside it. - Contain
shellwith the sandbox.sandbox="read-only"means anegressguard has nothing to guard: there is no network.
There is deliberately no codex_hook_settings. Codex's extension points are
notify (which fires after the fact and cannot refuse) and execpolicy .rules
files (static patterns, not a Python chain against a live ctx). A function
that generated one of those and called it middleware coverage would be the
silent non-application this whole area exists to avoid.
Capabilities: the Rule-of-Two applies here too¶
RunPolicy refuses a tool set that reaches private data, untrusted
content and egress at once. A Codex session's tags come off the sandbox rather
than a tool table:
from agentkit.agents.control.safety import RunPolicy
from agentkit.models.providers.cli.codex import CodexCliCognition
for kwargs in [{"sandbox": "read-only"}, {"sandbox": "read-only", "web_search": True}]:
cognition = CodexCliCognition(**kwargs)
print(kwargs, "->", cognition.caps, RunPolicy().check([cognition]).allowed)
{'sandbox': 'read-only'} -> ('private_data',) True
{'sandbox': 'read-only', 'web_search': True} -> ('egress', 'private_data', 'untrusted_content') False
The second row is the one to pause on. A read-only sandbox with web search on is the full trifecta — it reads like the safest configuration Codex has, because the agent cannot write anything, and it combines private-data access, untrusted content and egress in one run.
Three more notes on the tagging:
- Read-only is about writes, not about reading. Every mode carries
private_data; a read-only agent is not blind. network_access=Trueaddsegresstoworkspace-write, because that is exactly what it turns on.- MCP tools contribute nothing here, because their caps live on the server's
own tool definitions. Pass those tools' agentkit-side objects alongside the
cognition:
RunPolicy(mode="deny").check([cognition, *tools]).
Giving Codex your own tools¶
serve_registry already advertises an agentkit ToolRegistry over MCP with its
schemas, requires_approval flags and caps intact. What it writes is Claude
Code's --mcp-config document; Codex reads MCP servers out of config.toml.
as_codex_mcp is that translation:
from agentkit.models.providers.cli.codex import CodexCliCognition
from agentkit.models.providers.cli.codex import as_codex_mcp
from agentkit.integrations.mcp import serve_registry
async def wire(registry, ctx):
spec = serve_registry(registry, name="engine", ctx=ctx)
async with spec:
cognition = CodexCliCognition(model="gpt-5-codex", **as_codex_mcp(spec))
return cognition
spec.codex_kwargs() is the same thing as a method, next to cli_kwargs().
One thing there is not a rename. Codex has no header field for a bearer token —
it has bearer_token_env_var, which names an environment variable the CLI
reads at connect time. So as_codex_mcp returns an env= entry alongside
mcp_servers=, and the token travels in the child's environment rather than in
the argv, which is world-readable in ps output on most systems.
Unlike cli_kwargs, there is no builtin_tools= switch. Codex has no tool
allow-list, so "only OUR tools" is not something a flag can say — every session
has shell. What contains it is sandbox=.
Testing it¶
FakeCodexCli sits at the spawn seam, so a test still exercises
the real parsing, the real token split, the real cost computation and the real
meter charge:
from agentkit.models.providers.cli.codex import CodexCliCognition
from agentkit.core.testing import FakeCodexCli, codex_turn
cli = FakeCodexCli.script(codex_turn(text="done", usage=(1200, 1000, 40)))
cognition = CodexCliCognition(spawn=cli)
print(type(cognition).__name__, cli.spawns)
codex_turn builds one complete, well-formed turn — thread.started,
turn.started, the items, turn.completed — because the order carries meaning
the cognition depends on and hand-writing it in every test is where a test ends
up asserting against a stream the binary would never emit.
FakeCodexCli.answering("one", "two") is the shorthand for a multi-turn
session, where a spawn is a turn.
What bites people¶
sandbox=Noneis not "no sandbox", it is the CLI's default — read-only forcodex exec, plus whatever the operator'sconfig.tomlsays. Set it explicitly in a service: "whatever the machine happens to be configured for" is not a containment decision.ask_for_approval="never"alone is not containment. It removes the human and leaves the sandbox as the only control. Set both.- A read-only sandbox with
web_search=Trueis the lethal trifecta, and it is the most innocuous-looking configuration Codex has. usage.cost_usdis an estimate when priced andNonefor an unpriced model. Checkevals["cost_source"], and passpricing=if the number matters.- The budget is not an in-flight ceiling. It refuses before and charges after; nothing stops a run mid-flight.
- Codex refuses to run outside a git repository. A scratch-directory
workspace needs
skip_git_repo_check=True. - The middleware chain does not apply to
shell, and there is no hook that can make it. The sandbox is the control.
Related¶
- The Claude CLI — the sibling cognition, and the shared argument for why any of this is shaped the way it is.
- Integrations (MCP) —
serve_registry, whichas_codex_mcppoints at Codex. - Agents — the cognitions alongside each other, and
RunPolicy. - Middlewares — the chain that does not reach native tools.
- Testing — the CLI doubles in full.