codex-wrapper 0.4.1

A type-safe Codex CLI wrapper for Rust
Documentation

codex-wrapper

A type-safe Codex CLI wrapper for Rust.

Crates.io Documentation CI License

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

cargo add codex-wrapper

Requires the codex CLI to be installed and available in PATH (or configured via Codex::builder().binary()).

Quick Start

use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};

#[tokio::main]
async fn main() -> codex_wrapper::Result<()> {
    let codex = Codex::builder().build()?;
    let output = ExecCommand::new("explain this error")
        .model("o3")
        .sandbox(SandboxMode::WorkspaceWrite)
        .ephemeral()
        .execute(&codex)
        .await?;
    println!("{}", output.stdout);
    Ok(())
}

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 codex_wrapper::{Codex, RetryPolicy};

let codex = Codex::builder()
    .env("OPENAI_API_KEY", "sk-...")
    .timeout_secs(300)
    .retry(RetryPolicy::new().max_attempts(3).exponential())
    .build()?;

Options:

  • binary() -- path to codex binary (auto-detected via PATH by default)
  • working_dir() -- working directory for commands
  • env() / envs() -- environment variables
  • clear_env() -- clear inherited variables before applying env() / envs()
  • timeout_secs() / timeout() -- command timeout
  • config() -- global config overrides (-c key=value)
  • enable() / disable() -- global feature flags
  • retry() -- 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_wrapper::Codex;

let codex = Codex::builder()
    .clear_env()
    .envs([
        ("PATH", "/usr/local/bin:/usr/bin:/bin"),
        ("CODEX_HOME", "/srv/codex/agent-home"),
    ])
    .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 codex_wrapper::{ExecCommand, SandboxMode};

let output = ExecCommand::new("fix the failing tests")
    .model("o3")
    .sandbox(SandboxMode::WorkspaceWrite)
    .skip_git_repo_check()
    .ephemeral()
    .json()
    .execute(&codex)
    .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 codex_wrapper::{CodexCommand, ExecCommand, ExecResumeCommand};

let patch = std::fs::read_to_string("huge.patch")?;
let output = ExecCommand::from_stdin(format!("Review this patch:\n{patch}"))
    .execute(&codex)
    .await?;

let resumed = ExecResumeCommand::from_stdin("Now summarize the risky parts")
    .session_id("thread-id")
    .execute(&codex)
    .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 codex_wrapper::ExecCommand;

let result = ExecCommand::new("what is 2+2?")
    .ephemeral()
    .execute_json(&codex)
    .await?;

println!("{}", result.result);
println!("thread: {:?}", result.thread_id);
println!("tokens: {:?}", result.usage.and_then(|u| u.total()));

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 codex_wrapper::ExecCommand;

let events = ExecCommand::new("what is 2+2?")
    .ephemeral()
    .execute_json_lines(&codex)
    .await?;

for event in &events {
    println!("{}: {:?}", event.event_type, event.extra);
}

Typed Accessors

JsonLineEvent provides convenience methods for common fields:

for event in &events {
    if let Some(id) = event.thread_id() {
        println!("thread: {id}");
    }
    if event.is_turn_completed() {
        println!("tokens: {:?}", event.usage().and_then(|u| u.total()));
    }
    if let Some(text) = event.agent_message_text() {
        println!("assistant: {text}");
    }
}

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 codex_wrapper::{Codex, ExecCommand, JsonLineEvent};

let codex = Codex::builder().build()?;

ExecCommand::new("explain this codebase")
    .ephemeral()
    .stream(&codex, |event: JsonLineEvent| {
        println!("{}: {:?}", event.event_type, event.extra);
    })
    .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 std::sync::Arc;
use codex_wrapper::{Codex, Session};

let codex = Arc::new(Codex::builder().build()?);
let mut session = Session::new(codex);

let events = session.send("create a hello world program").await?;
println!("turn 1: {} events", events.len());

let events = session.send("now add error handling").await?;
println!("turn 2: {} events, thread_id={:?}", events.len(), session.id());

You can also resume an existing session by thread ID:

let mut session = Session::resume(codex, "thread_abc123");
let events = session.send("continue where we left off").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("first").await?;
session.send("second").await?;

println!("{} turns, {} tokens", session.total_turns(), session.total_tokens());

if let Some(result) = session.last_result() {
    println!("last turn: {:?}", result.usage);
}

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 {
    eprintln!("token total is an undercount: {} turns reported none",
              session.turns_missing_usage());
}

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("summarize this repo", |event| {
    println!("{}", event.event_type);
}).await?;

assert_eq!(session.total_turns(), 1);

stream_execute() and stream_execute_resume() take a fully configured command, mirroring execute() and execute_resume().

Code Review

use codex_wrapper::ReviewCommand;

// Review uncommitted changes
let output = ReviewCommand::new()
    .uncommitted()
    .model("o3")
    .execute(&codex)
    .await?;

// Review against a base branch
let output = ReviewCommand::new()
    .base("main")
    .json()
    .execute(&codex)
    .await?;

// Typed result, same shape ExecCommand returns
let result = ReviewCommand::new()
    .uncommitted()
    .execute_json(&codex)
    .await?;
println!("{}", result.result);

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 codex_wrapper::{McpListCommand, McpAddCommand, McpRemoveCommand};

// List servers
let output = McpListCommand::new().execute(&codex).await?;

// List as JSON
let servers = McpListCommand::new().execute_json(&codex).await?;

// Add stdio server
McpAddCommand::stdio("my-tool", "npx")
    .arg("my-mcp-server")
    .env("API_KEY", "secret")
    .execute(&codex)
    .await?;

// Add HTTP server
McpAddCommand::http("sentry", "https://mcp.sentry.dev/mcp")
    .bearer_token_env_var("SENTRY_TOKEN")
    .execute(&codex)
    .await?;

// Remove server
McpRemoveCommand::new("old-server").execute(&codex).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 codex_wrapper::{ExecCommand, McpConfigBuilder, McpServerConfig};

let mcp = McpConfigBuilder::new()
    .server("files", McpServerConfig::stdio("npx").arg("-y").arg("server"))
    .server("docs", McpServerConfig::http("https://example.com/mcp")
        .env_http_header("X-Identity", "IDENTITY_TOKEN")
        .required());

let mut cmd = ExecCommand::new("summarize the docs");
for override_ in mcp.config_overrides() {
    cmd = cmd.config(override_);
}

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 codex_wrapper::SandboxCommand;

let output = SandboxCommand::new("ls")
    .arg("-la")
    .execute(&codex)
    .await?;

Session Resume and Fork

use codex_wrapper::{ResumeCommand, ForkCommand};

// Resume the most recent interactive session
ResumeCommand::new()
    .last()
    .model("o3")
    .execute(&codex)
    .await?;

// Fork a session to try a different approach
ForkCommand::new()
    .session_id("abc-123")
    .prompt("try a different approach")
    .execute(&codex)
    .await?;

Shell Completions

use codex_wrapper::{CompletionCommand, Shell};

let output = CompletionCommand::new()
    .shell(Shell::Zsh)
    .execute(&codex)
    .await?;
std::fs::write("_codex", &output.stdout)?;

Feature Flags

use codex_wrapper::{FeaturesListCommand, FeaturesEnableCommand, FeaturesDisableCommand};

// List all feature flags
FeaturesListCommand::new().execute(&codex).await?;

// Enable/disable features persistently
FeaturesEnableCommand::new("web-search").execute(&codex).await?;
FeaturesDisableCommand::new("web-search").execute(&codex).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 codex_wrapper::{CliVersionStatus, TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX};

// Report, do not fail. Warns via `tracing` when outside the range.
match codex.cli_version_status().await? {
    CliVersionStatus::Tested => {}
    CliVersionStatus::NewerUntested { found, tested_max } => {
        eprintln!("codex {found} is newer than tested ({tested_max})");
    }
    CliVersionStatus::OlderThanMinimum { found, minimum } => {
        eprintln!("codex {found} is older than tested ({minimum})");
    }
}

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 codex_wrapper::{ExecCommand, CodexCommand, Error, FailureKind};

match ExecCommand::new("test").execute(&codex).await {
    Ok(output) => println!("{}", output.stdout),
    Err(e) => match e.failure_kind() {
        Some(FailureKind::Auth) => eprintln!("re-authenticate and retry"),
        Some(FailureKind::NotTrustedDirectory) => eprintln!("run in a git repo, or skip the check"),
        Some(FailureKind::Config) => eprintln!("fix the config: {e}"),
        Some(FailureKind::SessionNotFound) => eprintln!("start a new session"),
        _ => eprintln!("{e}"),
    },
}

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 codex_wrapper::{ExecCommand, Error};

match ExecCommand::new("test").execute(&codex).await {
    Ok(output) => println!("{}", output.stdout),
    Err(Error::CommandFailed { stderr, exit_code, .. }) => {
        eprintln!("failed (exit {}): {}", exit_code, stderr);
    }
    Err(Error::Timeout { .. }) => eprintln!("timed out"),
    Err(Error::NotFound) => eprintln!("codex binary not in PATH"),
    Err(e) => eprintln!("{e}"),
}

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 codex_wrapper::{CodexCommand, ExecCommand};
use codex_wrapper::dangerous::{Dangerous, DangerousClient};

// Errors unless CODEX_WRAPPER_ALLOW_DANGEROUS is set in the environment.
let allow = DangerousClient::new()?;

let output = ExecCommand::new("rewrite everything")
    .bypass_approvals_and_sandbox(&allow)?   // re-checks the variable here
    .execute(&codex)
    .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 codex_wrapper::{Codex, CodexCommand, ExecCommand};

let codex = Codex::builder().config("model=\"gpt-5\"").build()?;
let cmd = ExecCommand::new("fix the failing tests").ephemeral();

println!("{}", cmd.to_command_string(&codex));
// 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:

codex-wrapper = { version = "0.2", features = ["config"] }
if let Some(config) = codex.config()? {
    println!("model:    {:?}", config.model);
    println!("profiles: {:?}", config.profiles);
    println!("anything else: {:?}", config.raw.get("personality"));
}

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 codex_wrapper::history::{self, SessionQuery};

for session in history::list(&SessionQuery::new().after(2026, 8, 1))? {
    let log = history::read(&session.path)?;
    println!("{} in {:?}", session.id, log.meta.and_then(|m| m.cwd));
}

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 codex_wrapper::{Codex, AuthStrategy};

let codex = Codex::builder().build()?;
let status = codex.auth_status();

match &status.strategy {
    AuthStrategy::None => eprintln!("no credentials; run `codex login`"),
    AuthStrategy::Mixed { .. } => eprintln!("stored login and env key both set; the env key wins"),
    other => println!("will authenticate via {other:?}"),
}

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 codex_wrapper::{ExecCommand, ExecResumeCommand, RolloutBudgetConfig};

let budget = RolloutBudgetConfig::builder(200_000)
    .reminder_at_remaining_tokens([100_000, 25_000])
    .sampling_token_weight(1.0)
    .prefill_token_weight(0.25)
    .build()?;

let opening = ExecCommand::new("implement the change")
    .rollout_budget(budget.clone());
let resumed = ExecResumeCommand::new()
    .session_id("thread-id")
    .prompt("continue")
    .rollout_budget(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 std::sync::Arc;
use codex_wrapper::{Codex, Session, TokenBudget};

let budget = TokenBudget::builder()
    .max_tokens(200_000)
    .warn_at_tokens(150_000)
    .on_warning(|total| eprintln!("at {total} tokens"))
    .build();

let mut session = Session::new(Arc::new(codex)).with_budget(budget.clone());

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.completed with no usage object 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 codex_wrapper::{Codex, ExecCommand};
use std::time::Duration;

let codex = Codex::builder()
    .termination_grace(Duration::from_secs(5))
    .build()?;

// SIGTERM to the group, then five seconds, then SIGKILL.
// The direct child has been reaped when this future returns.
let result = ExecCommand::new("long task")
    .execute_json_cancellable(&codex, async { shutdown.await })
    .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 = Codex::builder().process_group(false).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 codex_wrapper::{Codex, ExecCommand, RetryPolicy};
use std::time::Duration;

let policy = RetryPolicy::new()
    .max_attempts(5)
    .initial_backoff(Duration::from_secs(2))
    .exponential()
    .retry_on_timeout(true)
    .retry_on_exit_codes([1, 2]);

// Set on the client (applies to all commands)
let codex = Codex::builder().retry(policy).build()?;

// Or override per-command
let output = ExecCommand::new("flaky task")
    .retry(RetryPolicy::new().max_attempts(10))
    .execute(&codex)
    .await?;

Escape Hatch: RawCommand

For subcommands or flags not yet covered by typed builders:

use codex_wrapper::RawCommand;

let output = RawCommand::new("cloud")
    .arg("--json")
    .execute(&codex)
    .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:

[dependencies]
codex-wrapper = { version = "0.2", default-features = false }

Examples

Runnable programs live in crates/codex-wrapper/examples. Each needs a working codex on PATH:

cargo run --example oneshot        # one prompt, raw output
cargo run --example json_output    # JSONL events and the typed QueryResult
cargo run --example stream_exec    # events delivered as they arrive
cargo run --example session        # multi-turn via exec resume
cargo run --example review         # code review, text and typed
cargo run --example mcp_servers    # add / list / inspect / remove MCP servers
cargo run --example health_check   # installed CLI vs the tested version range

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

cargo test --lib --all-features           # Unit tests (no CLI required)
cargo test --test integration -- --ignored # Integration tests (requires codex in PATH)

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