codex-wrapper
A type-safe Codex CLI wrapper for Rust.
Overview
codex-wrapper provides a builder-pattern interface for invoking the
Codex CLI programmatically. It follows
the same design philosophy as
claude-wrapper and
docker-wrapper: each CLI
subcommand is a builder struct that produces typed output.
Installation
Requires the codex CLI to be installed and available in PATH (or
configured via Codex::builder().binary()).
Quick Start
use ;
async
Two-Layer Builder Architecture
The Codex client holds shared configuration (binary path, environment,
timeout, retry policy). Command builders hold per-invocation options and call
execute(&codex).
Codex Client
Configure once, reuse across commands:
use ;
let codex = builder
.env
.timeout_secs
.retry
.build?;
Options:
binary()-- path tocodexbinary (auto-detected viaPATHby default)working_dir()-- working directory for commandsenv()/envs()-- environment variablesclear_env()-- clear inherited variables before applyingenv()/envs()timeout_secs()/timeout()-- command timeoutconfig()-- global config overrides (-c key=value)enable()/disable()-- global feature flagsretry()-- default retry policy
Child Environment Policy
Children inherit the wrapper process's environment by default for backwards
compatibility. Use clear_env() to construct the direct Codex child's
environment explicitly:
use Codex;
let codex = builder
.clear_env
.envs
.build?;
clear_env() is call-order independent, so explicit entries survive whether
they are added before or after it. config() and auth_status() use the same
effective environment as the child and do not fall back to ambient variables
for a cleared client. Debug output reports explicit variable names but never
their values.
This is direct-child environment control, not OS or same-user isolation. It does not prevent Codex from reading files, process metadata, sockets, or other resources available to its user and sandbox.
Command Builders
Each CLI subcommand is a separate builder:
| Command | CLI Equivalent | Description |
|---|---|---|
ExecCommand |
codex exec |
Run Codex non-interactively |
ExecResumeCommand |
codex exec resume |
Resume a non-interactive session |
ReviewCommand |
codex exec review |
Code review with git integration |
ResumeCommand |
codex resume |
Resume an interactive session |
ForkCommand |
codex fork |
Fork an interactive session |
LoginCommand |
codex login |
Authenticate |
LoginStatusCommand |
codex login status |
Check auth status |
LogoutCommand |
codex logout |
Remove credentials |
McpListCommand |
codex mcp list |
List MCP servers |
McpGetCommand |
codex mcp get |
Get MCP server details |
McpAddCommand |
codex mcp add |
Add stdio or HTTP MCP server |
McpRemoveCommand |
codex mcp remove |
Remove MCP server |
McpLoginCommand |
codex mcp login |
Auth to MCP server |
McpLogoutCommand |
codex mcp logout |
Deauth from MCP server |
McpServerCommand |
codex mcp-server |
Start Codex as MCP server |
SandboxCommand |
codex sandbox |
Run command in sandbox |
ApplyCommand |
codex apply |
Apply agent diff |
ArchiveCommand |
codex archive |
Archive a saved session |
DeleteCommand |
codex delete |
Permanently delete a session |
UnarchiveCommand |
codex unarchive |
Restore an archived session |
DoctorCommand |
codex doctor |
Diagnose local install health |
UpdateCommand |
codex update |
Update Codex to the latest version |
PluginAddCommand |
codex plugin add |
Install a plugin |
PluginListCommand |
codex plugin list |
List available plugins |
PluginRemoveCommand |
codex plugin remove |
Remove an installed plugin |
PluginMarketplaceAddCommand |
codex plugin marketplace add |
Add a marketplace source |
PluginMarketplaceListCommand |
codex plugin marketplace list |
List marketplace sources |
PluginMarketplaceUpgradeCommand |
codex plugin marketplace upgrade |
Refresh Git marketplaces |
PluginMarketplaceRemoveCommand |
codex plugin marketplace remove |
Remove a marketplace source |
CompletionCommand |
codex completion |
Generate shell completions |
FeaturesListCommand |
codex features list |
List feature flags |
FeaturesEnableCommand |
codex features enable |
Enable a feature |
FeaturesDisableCommand |
codex features disable |
Disable a feature |
VersionCommand |
codex --version |
Get CLI version |
RawCommand |
(any) | Escape hatch for arbitrary args |
ExecCommand
Full coverage of codex exec options:
use ;
let output = new
.model
.sandbox
.skip_git_repo_check
.ephemeral
.json
.execute
.await?;
| Method | CLI Flag | Description |
|---|---|---|
model() |
--model |
Model to use |
sandbox() |
--sandbox |
Sandbox policy |
strict_config() |
--strict-config |
Error on unknown config keys |
profile() |
--profile |
Config profile |
full_auto() |
--sandbox workspace-write |
Deprecated shim; sandbox() wins |
approval_policy() |
-c approval_policy= |
When the model asks for approval |
search() / search_mode() |
-c web_search= |
Web search mode |
cd() |
--cd |
Working directory |
skip_git_repo_check() |
--skip-git-repo-check |
Run outside git repo |
add_dir() |
--add-dir |
Additional writable dirs |
ignore_user_config() |
--ignore-user-config |
Ignore user-level config |
ignore_rules() |
--ignore-rules |
Ignore project rules files |
| (see Dangerous Operations) | --dangerously-bypass-hook-trust |
Skip the hook trust prompt |
ephemeral() |
--ephemeral |
Don't persist session |
output_schema() |
--output-schema |
JSON Schema for response |
color() |
--color |
Color output mode |
json() |
--json |
JSONL event output |
output_last_message() |
--output-last-message |
Write last message to file |
image() |
--image |
Attach image(s) |
config() |
-c |
Config override |
enable() / disable() |
--enable / --disable |
Feature flags |
oss() |
--oss |
Use local OSS provider |
local_provider() |
--local-provider |
Specify lmstudio/ollama |
approve_for_me() |
--approve-for-me |
Route approvals through automatic review (needs 0.147.0) |
prompt_via_stdin() |
(prompt becomes -) |
Send the prompt on stdin |
retry() |
(client-side) | Per-command retry policy |
Prompts on stdin
For prompts too large or awkward for argv, ExecCommand::from_stdin and
ExecResumeCommand::from_stdin send the prompt on the child's stdin and emit - in its argv:
use ;
let patch = read_to_string?;
let output = from_stdin
.execute
.await?;
let resumed = from_stdin
.session_id
.execute
.await?;
Retry does not apply to a stdin prompt. Any policy set on the command or the client is ignored for it: a second attempt would need to write the prompt into a pipe the first has already consumed, and retrying with an empty stdin would be worse than not retrying.
Typed Result
Use execute_json() for a typed QueryResult summarizing the run, assembled
from the JSONL event stream. This mirrors claude-wrapper's QueryResult so a
downstream abstraction can treat both wrappers uniformly:
use ExecCommand;
let result = new
.ephemeral
.execute_json
.await?;
println!;
println!;
println!;
QueryResult fields: result, session_id, thread_id, usage, and the
full events stream as an escape hatch.
Typed accessors on events
JsonLineEvent keeps every field in extra, with accessors for the ones worth naming:
session_id(), thread_id(), is_turn_completed(), is_turn_failed(), usage(),
agent_message_text(), role(), content_text(), item_type(), and command_execution().
There are no incremental text deltas to consume. Three captured runs, a one-word exec, a
four-sentence exec, and a review, each delivered the whole assistant message in a single
item.completed. The CLI emits no item.updated and no partial fields, and no flag changes
that. So this crate has no equivalent of claude-wrapper's PartialMessageEvent: streaming here
delivers whole events as they arrive, not partial sentences.
The CLI reports token counts, not money. There is no cost field to read. Converting tokens to dollars needs a per-model price table the CLI does not provide, so this crate does not guess at one.
JSONL Output Parsing
Use execute_json_lines() to parse the raw structured events from --json
mode. Available on both ExecCommand and ExecResumeCommand:
use ExecCommand;
let events = new
.ephemeral
.execute_json_lines
.await?;
for event in &events
Typed Accessors
JsonLineEvent provides convenience methods for common fields:
for event in &events
Available accessors: session_id(), thread_id(), is_turn_completed(),
is_turn_failed(), usage(), agent_message_text(), role(),
content_text().
The event vocabulary is thread.started, turn.started, turn.completed,
turn.failed, item.started, item.updated, item.completed. See the
schema notes in the types module docs for which parts of the payload layout
are verified against the CLI and which are assumed.
Streaming
Stream JSONL events via a callback as they arrive, instead of buffering all output:
use ;
let codex = builder.build?;
new
.ephemeral
.stream
.await?;
Also available on ExecResumeCommand::stream(). The child process's stderr
is drained concurrently; timeout handling mirrors the buffered exec path.
Multi-Turn Sessions
Session manages conversation state across turns automatically. The first
call dispatches via ExecCommand; subsequent calls use ExecResumeCommand
with the captured thread_id:
use Arc;
use ;
let codex = new;
let mut session = new;
let events = session.send.await?;
println!;
let events = session.send.await?;
println!;
You can also resume an existing session by thread ID:
let mut session = resume;
let events = session.send.await?;
The thread_id is preserved even on error paths, as long as at least one
event carried it.
Token usage
Each turn records a typed QueryResult, so cost accumulates across the
session:
session.send.await?;
session.send.await?;
println!;
if let Some = session.last_result
The CLI does not always report usage. total_tokens() sums what was reported,
so a total of 0 can mean either "nothing was used" or "nothing was
reported". turns_missing_usage() tells those apart:
if session.turns_missing_usage > 0
Streaming turns
stream() is the streaming equivalent of send(). Events reach the handler as
the CLI emits them, and the session still captures thread_id, history, and
cost, so a streaming turn and a buffered turn leave identical state:
session.stream.await?;
assert_eq!;
stream_execute() and stream_execute_resume() take a fully configured
command, mirroring execute() and execute_resume().
Code Review
use ReviewCommand;
// Review uncommitted changes
let output = new
.uncommitted
.model
.execute
.await?;
// Review against a base branch
let output = new
.base
.json
.execute
.await?;
// Typed result, same shape ExecCommand returns
let result = new
.uncommitted
.execute_json
.await?;
println!;
Review emits the same event vocabulary as codex exec, so execute_json() assembles the
review comments into QueryResult::result. One difference: a review's turn.completed
reports a usage object of all zeros, so result.usage carries no counts.
MCP Server Management
use ;
// List servers
let output = new.execute.await?;
// List as JSON
let servers = new.execute_json.await?;
// Add stdio server
stdio
.arg
.env
.execute
.await?;
// Add HTTP server
http
.bearer_token_env_var
.execute
.await?;
// Remove server
new.execute.await?;
Per-run MCP Servers
mcp add mutates persistent config, which is the wrong tool for a host running isolated
invocations: a cancelled run leaves residue and concurrent runs race. McpConfigBuilder scopes a
server set to one run instead:
use ;
let mcp = new
.server
.server;
let mut cmd = new;
for override_ in mcp.config_overrides
These are -c overrides, not a config file. codex has no --mcp-config flag; the only
config-bearing options are -c, --profile, and --ignore-user-config. Overrides suit the
purpose better anyway: nothing is written, nothing survives a cancelled run, and two runs cannot
collide. A contract check feeds the builder's real output to the CLI so the generated forms
cannot drift.
For the cases that do want a file, to_toml() and write_profile() produce a
$CODEX_HOME/<name>.config.toml that --profile layers. That one is persistent.
bearer_token_env_var() and env_http_header() record only environment-variable names, never
the secret values. required() makes an unavailable server fail the run instead of silently
continuing without a capability the caller expected.
Sandbox Execution
Run commands inside the Codex sandbox:
The platform is auto-detected (Seatbelt on macOS, and so on); as of
codex-cli 0.145.0 the old <macos|linux|windows> positional was removed.
use SandboxCommand;
let output = new
.arg
.execute
.await?;
Session Resume and Fork
use ;
// Resume the most recent interactive session
new
.last
.model
.execute
.await?;
// Fork a session to try a different approach
new
.session_id
.prompt
.execute
.await?;
Shell Completions
use ;
let output = new
.shell
.execute
.await?;
write?;
Feature Flags
use ;
// List all feature flags
new.execute.await?;
// Enable/disable features persistently
new.execute.await?;
new.execute.await?;
CLI Version
This wrapper is tested against a declared range of codex-cli versions. Both
ends of the range run the flag-contract check in CI, so the range reflects what
is actually verified rather than what is hoped.
use ;
// Report, do not fail. Warns via `tracing` when outside the range.
match codex.cli_version_status.await?
Most CLI releases break nothing, so this reports by default rather than refusing to run. When you do want a hard gate:
// Returns Error::UntestedCliVersion when outside the range.
let version = codex.ensure_tested_cli_version.await?;
This is a method rather than a builder option because build() is synchronous
and never spawns the binary.
Override the range with CodexBuilder::tested_cli_version_range(min, max) if
you have validated a different one yourself. check_version(&minimum) remains
available for a plain minimum-version gate.
Error Handling
Failed commands are classified at the error boundary, so a caller branches on the class instead of substring-matching stderr:
use ;
match new.execute.await
Classification reads stderr, because it has to: every failure observed on codex-cli 0.145.0
exits 1, so the exit code carries no information. Each signature comes from a captured failing
run. A failure matching none of them stays Error::CommandFailed with its output intact, so
classification never loses anything.
The classified failures are deterministic, so they are never retried even when their exit code is on a retry policy's list. Re-running gets the same rejection, and the CLI has already retried the auth case internally before it surfaces.
All commands return Result<T>, with errors typed via thiserror:
use ;
match new.execute.await
Dangerous Operations
--dangerously-bypass-approvals-and-sandbox turns off every approval prompt and the sandbox;
--dangerously-bypass-hook-trust lets configured hooks run unconfirmed. Neither is a plain
builder method, because a method name is not a barrier: it can be reached by autocomplete, by a
copied snippet, or by an agent editing a call site.
Both need two things that cannot happen by accident together:
use ;
use ;
// Errors unless CODEX_WRAPPER_ALLOW_DANGEROUS is set in the environment.
let allow = new?;
let output = new
.bypass_approvals_and_sandbox? // re-checks the variable here
.execute
.await?;
The second check is not redundant: a client built while the variable was set stops working the
moment it is unset, so permission reflects the environment when the bypass is applied rather than
whenever the client happened to be created. Without it, the call returns
Error::DangerousNotAllowed.
The variable name matches claude-wrapper's CLAUDE_WRAPPER_ALLOW_DANGEROUS.
Previewing the Command
Every builder can render the exact command line it will spawn, without spawning it:
use ;
let codex = builder.config.build?;
let cmd = new.ephemeral;
println!;
// codex -c 'model="gpt-5"' exec --ephemeral 'fix the failing tests'
Global args precede the subcommand, the same order the spawn uses, because the preview and both spawn paths share one assembly function. The output is quoted for a POSIX shell so it can be pasted, but no shell is involved at spawn time: args go to the process directly.
Reading config.toml
Behind the optional config feature, since it pulls in a TOML parser:
= { = "0.2", = ["config"] }
if let Some = codex.config?
This matters more than it used to: approval_policy and web_search moved from exec flags to
config keys in 0.145.0, so config is where some behavior is now decided.
Only the keys the wrapper has a reason to know about are typed (model, approval_policy,
sandbox_mode, web_search, [features], [projects] trust levels). Everything else stays in
raw, because modelling the whole file would mean tracking a schema that changes every release.
Profiles are files, not a table. --profile <name> layers $CODEX_HOME/<name>.config.toml
over the base config, so profiles lists those files. A [profiles] table is the legacy
mechanism the CLI no longer writes; it is reported separately as legacy_profiles rather than
mixed in.
A missing config.toml is Ok(None), not an error. A malformed one is Error::ConfigParse.
Session History
Read-side access to the logs the CLI writes under $CODEX_HOME/sessions:
use ;
for session in list?
Read-only; ArchiveCommand and DeleteCommand cover mutation through the CLI. Date filters are
cheap because the layout is sessions/<YYYY>/<MM>/<DD>/, so they narrow directories without
opening files. A cwd filter has to read each candidate's first line.
Two envelope generations. Modern files wrap every line as
{timestamp, type, payload}. Older ones have no envelope at all: the first line is the
metadata and later lines are bare records. Both were present on the machine this was written on,
205 files with 7 still carrying legacy lines, so a reader that assumed the current shape would
return nothing for part of a real history. SessionEntry::entry_type is None for a legacy
line, and every SessionMeta field is optional because older files record no cwd and no
cli_version.
Unknown entry types are kept rather than dropped, with the payload available as raw JSON.
Auth Pre-flight
Check which credential the CLI would use, synchronously, without spawning it:
use ;
let codex = builder.build?;
let status = codex.auth_status;
match &status.strategy
Useful for health endpoints and for failing fast instead of on an opaque non-zero exit. It answers
a different question from LoginStatusCommand, which asks the CLI whether a stored credential is
still valid; this asks which one the CLI would pick. Keep both.
Nothing here reads or returns a credential value. Environment variables are reported by name and stored credentials by mode.
The resolution was read off codex doctor on 0.145.0 rather than assumed, including the case
where both a stored login and an env key are present, which the CLI itself flags as "mixed auth
signals" and resolves in favour of the environment key.
Native Rollout Budgets
Bound one codex exec process with Codex's native rollout budget:
use ;
let budget = builder
.reminder_at_remaining_tokens
.sampling_token_weight
.prefill_token_weight
.build?;
let opening = new
.rollout_budget;
let resumed = new
.session_id
.prompt
.rollout_budget;
The limit is native rollout-budget units, not portable total tokens. Codex 0.145 and 0.146 always
count output tokens times sampling_token_weight plus non-cached input tokens times
prefill_token_weight. Starting with Codex 0.147, a provider-reported
codex_rollout_budget_units value takes precedence when available; the weighted formula remains
the fallback. Cached input is excluded from that fallback. Provider-reported units may be opaque,
so hosts must not assume that a CLI upgrade preserves identical meter semantics. Enforcement
happens after each completed response, so one response can overshoot the configured limit.
Subagents within that CLI execution share the same budget.
The wrapper emits the typed table after raw config and suppresses conflicting rollout_budget
feature toggles from both the command and its client. Codex applies feature toggles after every
config override regardless of argv order, so retaining one would silently disable or replace the
table. A separate CLI process, including a later resume, needs the config again. This is why both
exec builders expose the same method.
Post-turn Token Budgets
Cap what a session may consume. The ceiling is in tokens, not dollars, because the CLI reports token counts and no monetary cost:
use Arc;
use ;
let budget = builder
.max_tokens
.warn_at_tokens
.on_warning
.build;
let mut session = new.with_budget;
Each turn's reported usage is added to the budget, and a turn is refused with
Error::TokenBudgetExceeded once the ceiling is reached. The check runs before a turn starts, so
the ceiling can be overshot by at most the turn that crosses it: usage is only known once spent.
TokenBudget is Clone over shared state, so one budget can span several sessions.
Two things a budget cannot see, both worth knowing before relying on one:
- A
turn.completedwith nousageobject contributes nothing.turns_missing_usage()counts those separately, so an unmeasured turn is distinguishable from a genuinely cheap one. - A review reports usage as all zeros, so a session of reviews never advances the budget.
Treat the total as a floor on consumption rather than an exact measure.
Tracing
Every invocation is wrapped in a tracing span. Nothing is emitted unless the host installs a
subscriber, and there is no separate metrics abstraction: tracing is the seam, and hosts that
want metrics bridge it themselves.
| Span | Raised by |
|---|---|
codex.exec |
any buffered run, one per attempt |
codex.stream |
a streaming run, closing when the stream ends |
codex.retry |
a retried run, parent to each attempt's span |
Fields on open: subcommand, binary, working_dir. On close: outcome, duration_ms, and
exit_code where the process produced one.
outcome distinguishes four endings, including the two that are easy to miss:
outcome |
Meaning |
|---|---|
ok |
exited zero |
failed |
exited non-zero |
timeout |
hit the client's timeout |
cancelled |
explicit cancellation settled, or the future was dropped and the run abandoned |
The prompt is never recorded. It travels in argv, so recording the arguments would put it in
the host's logs; the same goes for the environment. Note that the existing debug! line does log
the full argv, prompt included, as a debugging aid at that level.
Cancellation
Use the cancellable exec methods when terminal settlement matters. They accept a future that
resolves when cancellation is requested. The wrapper sends SIGTERM to the Unix process group,
waits for termination_grace, sends SIGKILL, and awaits the direct child before returning
Error::Cancelled. Buffered client timeouts use the same cleanup path.
use ;
use Duration;
let codex = builder
.termination_grace
.build?;
// SIGTERM to the group, then five seconds, then SIGKILL.
// The direct child has been reaped when this future returns.
let result = new
.execute_json_cancellable
.await;
ExecCommand and ExecResumeCommand both provide execute_cancellable(),
execute_json_lines_cancellable(), and execute_json_cancellable(). Stdin prompts use the same
path. Retry does not apply because a cancelled attempt is a caller decision, not a transient
failure.
Dropping an ordinary command future remains an abrupt safety net. kill_on_drop stops the direct
child and a drop guard kills the Unix process group, but Drop cannot await reaping. A supervisor
that needs settlement should signal the cancellable method and keep polling it until it returns.
Groups are on by default, and can be turned off:
let codex = builder.process_group.build?;
Off, the child shares the parent's group, so a terminal Ctrl-C reaches the whole run directly and
a wrapper-side cancel reaches only the direct child. That is the right contract for a
terminal-attached host that shells out synchronously and treats the terminal as the supervisor;
the default suits a supervisor that cancels programmatically. claude-wrapper has the same
option under the same name.
Process groups are Unix-only. Elsewhere explicit cancellation kills and awaits the direct child, but the wrapper cannot make the same descendant-process guarantee. Reaping also needs the Tokio runtime to remain alive; the abrupt dropped-future fallback cannot settle during runtime teardown.
Retry Policy
Configure automatic retries for transient failures:
use ;
use Duration;
let policy = new
.max_attempts
.initial_backoff
.exponential
.retry_on_timeout
.retry_on_exit_codes;
// Set on the client (applies to all commands)
let codex = builder.retry.build?;
// Or override per-command
let output = new
.retry
.execute
.await?;
Escape Hatch: RawCommand
For subcommands or flags not yet covered by typed builders:
use RawCommand;
let output = new
.arg
.execute
.await?;
Cargo Features
| Feature | Default | Description |
|---|---|---|
json |
Yes | JSONL output parsing via serde_json -- enables execute_json_lines(), execute_json(), stream(), Session, QueryResult, JsonLineEvent and typed accessors |
config |
No | Read ~/.codex/config.toml via the toml crate -- enables codex.config() and the config module |
To disable default features:
[]
= { = "0.2", = false }
Examples
Runnable programs live in
crates/codex-wrapper/examples.
Each needs a working codex on PATH:
All but oneshot and health_check need the json feature, which is on by default. Each is
declared with its required-features in the manifest, so a reduced-feature build skips it
rather than failing.
Testing
CI and Release
GitHub Actions workflows handle CI (Linux, macOS, Windows), dependency
audits, changelog automation, and release-plz-driven crates.io releases.
License
MIT OR Apache-2.0