CodexWrapperEx
Elixir wrapper for the Codex CLI.
Provides a typed interface for executing prompts, streaming responses, running code reviews, managing multi-turn sessions, and configuring MCP servers -- all from Elixir.
Installation
def deps do
[
{:codex_wrapper, "~> 0.10.0"}
]
end
Requires the codex CLI to be installed and on your PATH (or set CODEX_CLI
to point at it).
Codex CLI compatibility
Tested against codex-cli 0.149.0 and 0.164.0. Both are pinned in PR CI.
The Codex CLI moves quickly and has removed flags between releases, so a
wrapper release is only known to match the version recorded here. Subcommands
this wrapper does not cover -- including the experimental app-server,
remote-control, cloud, and exec-server -- are reachable through
CodexWrapper.raw/1:
CodexWrapper.raw(["some", "new", "subcommand"])
The native contract checks help and closed-enum config rejection without authentication
or model inference. Set CODEX_CONTRACT_BIN to select an explicit executable and
CODEX_CONTRACT_VERSION to require its reported version. Controlled fake binaries
test that the checker can reject drift; they do not replace these native checks.
Keeping the version current is part of the release checklist: bump the line
above whenever the wrapper is verified against a newer CLI. The weekly
CLI contract workflow installs the latest @openai/codex and runs
mix codex.contract, opening a cli-drift issue when the wrapper emits a
flag the CLI no longer accepts.
Quick start
# One-shot exec
{:ok, result} = CodexWrapper.exec("Explain this error: ...")
IO.puts(result.stdout)
# With options
{:ok, result} = CodexWrapper.exec("Fix the bug in lib/foo.ex",
model: "o3",
working_dir: "/path/to/project",
full_auto: true
)
# Streaming
CodexWrapper.stream("Implement the feature described in issue #42",
working_dir: "/path/to/project"
)
|> Stream.each(fn event -> IO.inspect(event.event_type) end)
|> Stream.run()
Prompt transport
Exec, ExecResume, Review and ExecFork send prompts on stdin by default.
This also applies to convenience calls and initial or resumed session turns,
for both execute and stream. The argument list contains - instead of the
prompt, so prompt size does not consume the operating system's argv budget
and the prompt is absent from process arguments.
Each invocation creates a 0600 file in a private 0700 directory. The launcher opens the file as stdin and unlinks it before codex runs. The wrapper also cleans up files after startup failures, timeouts and stream halts. Streams create their file when enumerated.
Select the previous argv form when needed:
CodexWrapper.exec("Explain this error", prompt_input: :argv)
Exec.new("Explain this error")
|> Exec.prompt_input(:argv)
|> Exec.execute(config)
All four builders support prompt_input/2. Session defaults and per-turn
options accept :prompt_input too. A prompt of exactly - is always sent as
literal text on stdin, even in argv mode. An omitted optional prompt remains
omitted.
The built-in runners reject oversized argument lists or environment strings
before starting codex, returning {:error, {:argument_list_too_long, details}}.
Streams emit %CodexWrapper.StreamError{} with that reason. The size check
includes inherited environment, launcher overhead and Linux per-string
limits. Limits are cached for the VM's lifetime. The check reserves 4 KiB below
the aggregate limit reported by getconf, discovered through PATH with
/usr/bin/getconf as a fallback. If discovery fails, the aggregate defaults are
2 MiB on Linux and 1 MiB on macOS (32 KiB on other platforms). Argv mode still exposes
prompt text in the process table and is subject to these limits.
Custom runners receive prompt bytes as opts[:stdin]; they must deliver all
bytes and then EOF, or callers must select prompt_input: :argv. The built-in
runners preserve their termination behavior: Port and Forcola both send INT,
TERM and KILL, on one-shot runs and streams.
Code review
# Review uncommitted changes
{:ok, result} = CodexWrapper.review(uncommitted: true)
# Review against a base branch
{:ok, result} = CodexWrapper.review(base: "main", model: "o4-mini")
# Review a specific commit
{:ok, result} = CodexWrapper.review(commit: "abc123", title: "PR title")
Multi-turn sessions
config = CodexWrapper.Config.new(working_dir: "/path/to/project")
session = CodexWrapper.Session.new(config, model: "o3")
{:ok, session, result} = CodexWrapper.Session.send(session, "What files are here?")
{:ok, session, result} = CodexWrapper.Session.send(session, "Add tests for lib/foo.ex")
CodexWrapper.Session.turn_count(session)
#=> 2
turn = CodexWrapper.Session.last_turn(session)
# Also available as session.last_turn
turn.status
#=> :completed
turn.final_message
#=> "Added tests for lib/foo.ex"
CodexWrapper.Session.total_usage(session)
#=> %{input_tokens: 120, cached_input_tokens: 100, cache_write_input_tokens: nil,
# output_tokens: 7, reasoning_output_tokens: nil}
send/3 keeps its raw %Result{} return value and result history. The updated
session exposes the typed JSON turn as session.last_turn or
Session.last_turn/1. SessionServer.last_turn/1 and
SessionServer.total_usage/1 provide the same access through a session server.
Usage totals sum reported nonnegative integers from recorded turns. A field
is nil if it was never reported; the whole total is nil if no recorded
turn included usage. Nonzero exits leave the session unchanged and do not
contribute to usage totals. Each recorded send updates a running total;
reading it takes constant time and does not parse the retained stdout again.
Session options are the exec options in the table under
Exec options and apply to every turn. Resumed turns run
codex exec resume: :profile, :cd, :add_dir, :color, :oss,
:local_provider and :approve_for_me are emitted before resume
(see Exec-level options),
and :sandbox, :approval_policy and :search as -c sandbox_mode="...",
-c approval_policy="..." and -c web_search="...". :worktree and
:thread_source apply to the first turn only: codex exec resume
rejects --worktree, and --thread-source classifies a newly created
thread.
Typed turn results
CodexWrapper.Turn.from_result/1 projects an existing --json result without
starting a process. %Result{} continues to hold the raw stdout, stderr and
exit code.
config = CodexWrapper.Config.new()
exec = CodexWrapper.Exec.new("Summarize the changes") |> CodexWrapper.Exec.json()
{:ok, result} = CodexWrapper.Exec.execute(exec, config)
turn = CodexWrapper.Turn.from_result(result)
turn.status # :completed, :failed or :no_terminal
turn.final_message # last completed agent message from the last turn, or nil
turn.thread_id # thread.started identity, or nil
turn.usage # five token counters, or nil
turn.error # native error data, or nil
turn.events # parsed events, including unrecognized fields
The last turn.completed or turn.failed sets the native turn outcome. A
top-level error fails the turn if no terminal event follows it. A nonzero
process exit makes the projection :failed, including after a completion
event. CodexWrapper.exec/2 and CodexWrapper.review/1 still return
{:ok, %Result{success: false}} for a nonzero exit; runner errors return
{:error, reason}.
Status, error, and terminal evidence cover the entire event stream. A new
turn.started resets only the final message and usage; it does not erase an
earlier reported outcome. For example, a completed turn followed by a new
start still has status: :completed, but its message and usage are nil
until the new turn supplies them. See CodexWrapper.Turn for each field's scope.
Usage has the keys :input_tokens, :cached_input_tokens,
:cache_write_input_tokens, :output_tokens and :reasoning_output_tokens.
Only nonnegative integers are retained; missing or invalid counters are
nil. Original values and any extra fields remain in turn.events.
IEx REPL
Use Codex conversationally from IEx:
iex> import CodexWrapper.IEx
iex> chat("explain this codebase", working_dir: ".", model: "o3")
# => prints response
iex> say("now add tests for the retry module")
# => continues the conversation
iex> cost()
# turn count; the Codex CLI reports no cost
iex> history()
# prints full conversation
iex> session_id()
# "abc-123" -- save this to resume later
iex> reset()
# start fresh
Exec builder
For full control, use the Exec struct directly:
alias CodexWrapper.{Config, Exec}
config = Config.new(working_dir: "/path/to/project")
Exec.new("Fix the tests")
|> Exec.model("o3")
|> Exec.sandbox(:workspace_write)
|> Exec.approval_policy(:never)
|> Exec.search()
|> Exec.execute(config)
approval_policy/2 takes :untrusted, :on_request, or :never, and
emits -c approval_policy="<value>". The Codex CLI removed the
--ask-for-approval flag from exec in 0.14x; the config key is the
supported equivalent. :on_failure was dropped along with the flag and
now raises.
search/1 enables live web search and search/2 selects a mode
(:cached, :indexed, :live, :disabled):
Exec.new("What changed in OTP 27?")
|> Exec.search(:cached)
|> Exec.execute(config)
Both emit -c web_search="<mode>". The Codex CLI removed the --search
flag from exec in 0.14x; the config key is the supported equivalent,
and :live is what --search used to mean.
profile/2 selects a named config profile, emitting --profile <name>.
The CLI layers $CODEX_HOME/<name>.config.toml on top of the base
config.toml:
Exec.new("Fix the tests")
|> Exec.profile("fast")
|> Exec.execute(config)
ExecResume, ExecFork and Review have profile/2 too, and
CodexWrapper.exec/2, CodexWrapper.review/1 and
CodexWrapper.Session.new/2 take a :profile option. On the
subcommands it is emitted before the subcommand
(codex exec --profile <name> resume ...); see
Exec-level options.
How the CLI resolves a profile (checked on codex-cli 0.149.0, 0.164.0 and 0.165.0):
- The profile is the file
$CODEX_HOME/<name>.config.toml, not a[profiles.<name>]table. Aconfig.tomlthat still has[profiles.<name>]orprofile = "<name>"fails to load when--profile <name>is given, with an error asking to move the settings into<name>.config.toml. - A missing profile file is not an error: the run continues with the base config. A misspelled profile name therefore drops whatever sandbox or approval settings the file would have carried, without notice.
- With
ignore_user_config/1the profile file is not read either (0.165.0), so the run gets codex defaults plus the-coverrides.
Writable directories and git commits
add_dir/2 emits --add-dir <dir>, which makes the directory writable
alongside the workspace. It widens the sandbox; it is not a read-only
context hint.
Under the workspace-write sandbox the workspace's own git metadata stays
read-only, so git add and git commit run by the agent fail with
Unable to create '.../index.lock': Operation not permitted. Measured on
codex-cli 0.165.0 (codex sandbox and app-server command/exec, outside
/tmp, which workspace-write leaves writable):
| Workspace | Extra writable roots | Commit |
|---|---|---|
| plain checkout | none | fails |
| plain checkout | <repo>/.git |
succeeds |
| linked worktree | none | fails |
| linked worktree | git rev-parse --git-dir only |
fails (objects live in the common dir) |
| linked worktree | git rev-parse --git-common-dir only |
fails (the worktree index lives in the git dir) |
| linked worktree | both | succeeds |
To let the agent commit, add the directories git reports:
git -C <workspace> rev-parse --path-format=absolute --git-dir
git -C <workspace> rev-parse --path-format=absolute --git-common-dir
Exec.new("Fix the test and commit")
|> Exec.sandbox(:workspace_write)
|> Exec.add_dir(git_dir)
|> Exec.add_dir(git_common_dir)
Write access to .git also lets the sandboxed agent change hooks and
config that git runs later, outside the sandbox. Committing outside codex
after reviewing the diff stays the conservative option.
Typed config fields
These builders emit -c config fields with values checked before codex
starts. They are on Exec, ExecResume, ExecFork and Review, and are
also options on CodexWrapper.exec/2, review/1 and Session:
| Builder | Config field | Values |
|---|---|---|
reasoning_effort/2 |
model_reasoning_effort |
any non-empty string, e.g. "high" |
reasoning_summary/2 |
model_reasoning_summary |
:auto, :concise, :detailed, :none |
verbosity/2 |
model_verbosity |
:low, :medium, :high |
approvals_reviewer/2 |
approvals_reviewer |
:user, :auto_review, :guardian_subagent |
rollout_budget/2 |
features.rollout_budget |
limit_tokens: and reminder_at_remaining_tokens: (required), sampling_token_weight:, prefill_token_weight: |
Exec.new("review the migration")
|> Exec.reasoning_effort("high")
|> Exec.reasoning_summary(:concise)
|> Exec.verbosity(:low)
|> Exec.rollout_budget(limit_tokens: 100_000, reminder_at_remaining_tokens: [50_000, 10_000])
# -c model_reasoning_effort="high" -c model_reasoning_summary="concise" -c model_verbosity="low"
# -c features.rollout_budget={enabled=true,limit_tokens=100000,reminder_at_remaining_tokens=[50000,10000]}
An invalid value raises ArgumentError. The typed fields are emitted after
any config/2 entries, and codex applies repeated -c last-wins, so a
typed builder wins over a config/2 entry for the same key, as
approval_policy/2 and search/1,2 already do.
Config isolation
By default the CLI loads $CODEX_HOME/config.toml, so a programmatic run
silently inherits whatever the developer has configured. Two options take
that away:
Exec.new("Summarize the diff")
|> Exec.ignore_user_config()
|> Exec.strict_config()
|> Exec.config(~s(model="o3"))
|> Exec.execute(config)
ignore_user_config/1 skips the config file entirely (auth still
resolves through CODEX_HOME). strict_config/1 makes the run fail on a
config key this Codex version does not recognize, rather than ignoring
it. Together they give a run whose configuration is exactly what the
caller passed.
ignore_rules/1 is the same idea for execpolicy .rules files.
All three are available on Exec, ExecResume, and Review.
Hook trust
dangerously_bypass_hook_trust/1 runs enabled hooks without requiring
persisted hook trust. Hook trust is what stops a repository from running
commands the user never approved, so this belongs only in automation that
already vets where its hooks come from. It is separate from
dangerously_bypass_approvals_and_sandbox/1; setting one does not set the
other. Available on Exec, ExecResume, and Review.
Color and local providers
color/2 (:always, :never, :auto), oss/1, and local_provider/2
("lmstudio" or "ollama") are on all four builders; the subcommands
emit them before the subcommand.
Exec-level options on resume, fork and review
codex exec reads some options before a subcommand and applies them to
exec resume, exec fork and exec review, while rejecting them after
it. ExecResume, ExecFork and Review emit these between exec and
the subcommand: profile/2, add_dir/2, cd/2, oss/1,
local_provider/2, color/2 and approve_for_me/1.
ExecResume.new()
|> ExecResume.session_id(id)
|> ExecResume.prompt("continue")
|> ExecResume.profile("ci")
|> ExecResume.add_dir("/repo/.git")
|> ExecResume.approval_policy(:never)
# codex exec --profile ci --add-dir /repo/.git resume -c approval_policy="never" -- <id> -
After the subcommand, all three take approval_policy/2, search/1,2
(as -c fields) and thread_source/2; ExecResume and ExecFork take
cyber_access_program/2, which codex exec review does not support.
worktree/1 is on ExecFork only: codex-cli 0.165.0 lists --worktree
in the resume and review help but rejects it there ("cannot resume an
existing session; use codex exec fork --worktree", "not supported for
code review").
Review builder
alias CodexWrapper.{Config, Review}
config = Config.new(working_dir: "/path/to/project")
Review.new()
|> Review.base("main")
|> Review.model("o4-mini")
|> Review.sandbox(:workspace_write)
|> Review.execute(config)
output_schema/2 points codex exec review at a JSON Schema file via
--output-schema, the same way Exec.output_schema/2 does, so a review
can return structured output:
Review.new()
|> Review.uncommitted()
|> Review.output_schema("/path/to/schema.json")
|> Review.execute(config)
full_auto/1 is still available on Exec, ExecResume and Review,
but it is deprecated upstream: it now selects the workspace-write
sandbox rather than emitting the deprecated --full-auto flag. An
explicit sandbox/2 call wins over it.
sandbox/2 works on all three builders but emits different arguments.
codex exec takes --sandbox <mode>; codex exec resume and codex exec review reject that flag with unexpected argument, so on
ExecResume and Review the builder emits the equivalent config
override -c sandbox_mode="<mode>" instead. The public API and the
three accepted modes are the same either way.
Structured output
CodexWrapper.Structured.run/4 takes a JSON Schema as a map, writes it to a
private temporary --output-schema file for the life of the process, runs
the command with --json, and decodes the final agent message. It accepts
Exec, ExecResume, ExecFork and Review commands.
schema = %{
"type" => "object",
"properties" => %{"name" => %{"type" => "string"}},
"required" => ["name"],
"additionalProperties" => false
}
{:ok, %{"name" => name}, %{result: _result, events: _events}} =
CodexWrapper.Exec.new("Extract the name from: Hi, I'm Ada Lovelace.")
|> CodexWrapper.Structured.run(schema, config)
Codex enforces the schema; the wrapper checks only that the final message is
a JSON object or array. Pass parse: fn value -> {:ok, ...} | {:error, ...} end
for a caller-side domain check. Failed turns, non-zero exits, missing terminal
events, missing or invalid messages and parser failures are distinct tagged
errors that keep the result and events. Structured.decode/2 decodes a
%Result{} from a run that used an explicit schema path.
Final message from plain-text runs
CodexWrapper.LastMessage.run/3 reads the final agent message independently
of merged stdout and stderr, keeping the original process result:
{:ok, text, result} =
CodexWrapper.Exec.new("Summarize the diff in one paragraph")
|> CodexWrapper.LastMessage.run(CodexWrapper.Config.new())
Works with Exec, ExecResume, ExecFork and Review. The wrapper creates
a private temporary file and removes it after execution, including failures
and raises. An explicit output_last_message/2 path remains the caller's.
Missing or empty output on exit zero returns {:error, {:no_last_message, result}};
a non-zero exit returns {:error, {:process_failed, text, result}}.
Session resumption
Resume a previous session:
alias CodexWrapper.{Config, ExecResume}
config = Config.new()
# Resume a session
ExecResume.new()
|> ExecResume.session_id("previous-session-id")
|> ExecResume.prompt("Continue where we left off")
|> ExecResume.execute(config)
# Resume the most recent session
ExecResume.new()
|> ExecResume.last()
|> ExecResume.execute(config)
Forking
CodexWrapper.ExecFork wraps codex exec fork <SESSION_ID> [PROMPT]. It
copies a session's history into a new session and runs the optional
prompt there. The source session is not modified and can still be
resumed under its original ID.
alias CodexWrapper.{Config, ExecFork}
config = Config.new()
{:ok, fork} =
ExecFork.new("source-session-id")
|> ExecFork.prompt("Try the other approach")
|> ExecFork.fork(config)
fork.session_id # the new session, from the CLI's thread.started event
fork.source_session_id # "source-session-id", unchanged
fork/2 forces --json and returns {:error, {:exit, code, result}} on a
non-zero exit, for example when the source session does not exist.
execute/2, execute_json/2, and stream/2 behave as they do on
ExecResume. The session ID is validated before the CLI is spawned:
empty IDs, IDs with surrounding whitespace or control characters, and
IDs starting with - return {:error, {:invalid_session_id, value}}.
codex exec fork accepts the codex exec resume flags without --last
and --all, plus --output-schema. It rejects --sandbox, so
sandbox/2 emits -c sandbox_mode="<mode>" here as well. Verified
against codex-cli 0.149.0.
A CLI without exec fork returns {:error, {:unsupported, :exec_fork}}.
ExecFork.supported?/1 checks ahead of time by running
codex exec fork --help. ExecFork.stream/2 has no error tuple to
return, so on such a CLI enumerating the stream raises
CodexWrapper.UnsupportedError with capability: :exec_fork. The check
only runs when the stream ends without output.
codex fork, without exec, only runs as an interactive TUI.
CodexWrapper.Commands.Fork wrapped it and was removed.
Retry with backoff
alias CodexWrapper.{Config, Exec, Retry}
config = Config.new()
exec = Exec.new("Fix the flaky test")
Retry.execute(exec, config,
max_retries: 3,
base_delay_ms: 1_000,
max_delay_ms: 30_000
)
:retry_on also receives {:ok, %Result{success: false}} for a run that
exited nonzero. The default does not retry those, since a retry re-sends the
prompt and can repeat the failed attempt's tool commands. The wrapper does
not classify rate-limit or capacity failures: codex-cli 0.165.0 reports a
429, 503 or 529 only in its own message text, not as a structured field. See
the CodexWrapper.Retry docs for a predicate that matches that text.
SessionServer (GenServer)
For OTP applications that need a supervised, process-based session:
{:ok, pid} = CodexWrapper.SessionServer.start_link(
config: config,
exec_opts: [model: "o3"]
)
{:ok, result} = CodexWrapper.SessionServer.send_message(pid, "Fix the tests")
CodexWrapper.SessionServer.turn_count(pid)
Works with supervision trees:
children = [
{CodexWrapper.SessionServer,
name: :my_agent, config: config, exec_opts: [model: "o3"]}
]
MCP server management
alias CodexWrapper.Commands.Mcp
config = CodexWrapper.Config.new()
# List MCP servers
{:ok, servers} = Mcp.list(config, json: true)
# Add a stdio transport server
{:ok, _} = Mcp.add(config, "my-server", :stdio,
command: "npx",
args: ["-y", "my-mcp-server"],
env: %{"API_KEY" => "sk-..."}
)
# Add an HTTP transport server
{:ok, _} = Mcp.add(config, "remote", :http,
url: "https://example.com/mcp",
bearer_token_env_var: "MY_TOKEN"
)
# Add a server that authenticates over OAuth
{:ok, _} = Mcp.add(config, "oauth-server", :http,
url: "https://example.com/mcp",
oauth_client_id: "codex-cli",
oauth_resource: "https://example.com"
)
# Get server details
{:ok, info} = Mcp.get(config, "my-server", json: true)
# Remove a server
{:ok, _} = Mcp.remove(config, "my-server")
MCP OAuth
login/3 runs the OAuth flow for a configured server and logout/2 clears
its credentials.
{:ok, _} = Mcp.login(config, "oauth-server")
{:ok, _} = Mcp.login(config, "oauth-server", scopes: ["read", "write"])
{:ok, _} = Mcp.logout(config, "oauth-server")
Running Codex as an MCP server
alias CodexWrapper.Commands.McpServer
{:ok, output} = McpServer.start(config,
config: ["key=value"],
enable: ["feature-name"]
)
Authentication
alias CodexWrapper.Commands.Auth
{:ok, _} = Auth.login(config)
{:ok, _} = Auth.login(config, with_api_key: true)
{:ok, _} = Auth.login(config, with_access_token: true)
{:ok, _} = Auth.status(config)
{:ok, _} = Auth.logout(config)
Feature flags
alias CodexWrapper.Commands.Features
{:ok, list} = Features.list(config)
{:ok, _} = Features.enable(config, "my-feature")
{:ok, _} = Features.disable(config, "my-feature")
Sandbox execution
Run commands inside Codex's sandbox:
alias CodexWrapper.Commands.Sandbox
Sandbox.new("python3")
|> Sandbox.args(["script.py", "--flag"])
|> Sandbox.execute(config)
Sandbox state and permission profiles:
Sandbox.new("pytest")
|> Sandbox.permission_profile("ci")
|> Sandbox.sandbox_state_readable_root("/usr/share")
|> Sandbox.sandbox_state_disable_network()
|> Sandbox.cd("/src")
|> Sandbox.execute(config)
Sandbox.new/1 replaces the old Sandbox.new(platform, command). The
Codex CLI dropped the platform subcommand and infers the platform from
the host, so passing an atom now raises with a migration message.
Diagnostics
codex doctor checks the local install, config, auth, and runtime health.
It works as a preflight before a run:
alias CodexWrapper.Commands.Doctor
{:ok, report} = Doctor.new() |> Doctor.summary() |> Doctor.execute(config)
execute/2 returns the human-readable report as a string. For the
machine-readable form, execute_json/2 forces --json and decodes it:
{:ok, report} = Doctor.new() |> Doctor.execute_json(config)
report["overallStatus"]
#=> "warning"
report["checks"]["auth.credentials"]["status"]
#=> "ok"
codex doctor exits 0 even when checks report warnings or failures, so a
successful call is not by itself a clean bill of health. Read
"overallStatus" rather than relying on the exit code.
Builders: json/1, summary/1, all/1, no_color/1, ascii/1.
Shell completions
alias CodexWrapper.Commands.Completion
{:ok, script} = Completion.generate(config, :zsh)
Plugins
CodexWrapper.Commands.Plugin wraps codex plugin and
codex plugin marketplace. Every call passes --json and returns the
decoded document.
alias CodexWrapper.Commands.Plugin
{:ok, %{"installed" => installed, "available" => available}} =
Plugin.list(config, available: true)
{:ok, _} = Plugin.add(config, "sample@debug")
{:ok, _} = Plugin.remove(config, "sample", marketplace: "debug")
{:ok, _} = Plugin.marketplace_add(config, "https://example.com/plugins.git", ref: "v1")
{:ok, %{"marketplaces" => _}} = Plugin.marketplace_list(config)
{:ok, _} = Plugin.marketplace_upgrade(config)
{:ok, _} = Plugin.marketplace_remove(config, "debug")
add, remove and the marketplace changes edit $CODEX_HOME/config.toml
and the plugin cache. A non-zero exit returns {:error, {:exit, code, output}}.
Queueing a message
CodexWrapper.Commands.Queue.add/4 wraps codex queue, which queues a
message for an existing session, named by id or exact session name:
{:ok, _output} = CodexWrapper.Commands.Queue.add(config, thread_id, "also run the linter")
Codex looks the thread up in its local thread store, or on the app server
given with remote: "ws://...". The message travels in argv, so it is
visible in the process table.
Session lifecycle
codex archive, codex unarchive, and codex delete operate on a saved
session, named by id (UUID) or session name.
alias CodexWrapper.Commands.Archive
{:ok, _} = Archive.archive(config, "abc-123")
{:ok, _} = Archive.unarchive(config, "abc-123")
delete permanently destroys the session, and unarchive cannot bring
it back. So it requires an explicit confirmation; without it the CLI is
never invoked.
Archive.delete(config, "abc-123")
# => {:error, :confirmation_required}
Archive.delete(config, "abc-123", confirm: true)
# => {:ok, ""}
The same three are available on a %Session{}, using its session id:
{:ok, _} = CodexWrapper.Session.archive(session)
{:ok, _} = CodexWrapper.Session.unarchive(session)
{:ok, _} = CodexWrapper.Session.delete(session, confirm: true)
Session history
CodexWrapper.History reads the rollout files codex writes for each
persisted session under $CODEX_HOME (default ~/.codex):
sessions/YYYY/MM/DD/rollout-<timestamp>-<id>.jsonl, the same with
.jsonl.zst when codex has compressed it, and both forms under
archived_sessions/. It only reads; the commands above change sessions.
alias CodexWrapper.History
{:ok, history} = History.home()
# Newest first; archived: true for archived only, :all for both
{:ok, sessions} = History.list_sessions(history, archived: :all, limit: 20)
{:ok, log} = History.read_session(history, hd(sessions).id)
log.meta["cwd"]
Enum.take(log.entries, 5)
# [%{timestamp: "...", type: "session_meta", payload: %{...}, ordinal: 0}, ...]
Listing reads file names and sizes only. read_session/2 resolves the id
when it is called, because codex compresses and archives rollouts, and
returns entries as a lazy stream so very large rollouts are read with
bounded memory. Compressed files need OTP 28 or later (:zstd); on OTP 27
they return {:error, {:unsupported, :zstd}}. session_index.jsonl,
thread_history_1.sqlite and the app-server thread view are not covered.
They return {:error, :no_session} if no turn has run yet, since the
session id only exists once the CLI has created it.
Applying diffs
alias CodexWrapper.Commands.Apply
{:ok, _} = Apply.execute(config, "task-id-from-codex")
Raw CLI escape hatch
For subcommands not yet wrapped:
CodexWrapper.raw(["some", "new", "subcommand"])
Telemetry
CodexWrapper emits :telemetry span events around its core exec paths so
host applications can observe durations and metadata without
re-implementing instrumentation. Each operation produces a matched
:start/:stop pair, or a :start/:exception pair if it raises.
Synchronous calls use :telemetry.span/3; lazy streams follow the same
event shape over their full consumption lifecycle.
Events
| Event | Fired around |
|---|---|
[:codex_wrapper, :exec, :start | :stop | :exception] |
CodexWrapper.Exec.execute/2, CodexWrapper.ExecResume.execute/2, CodexWrapper.ExecFork.execute/2 |
[:codex_wrapper, :stream, :start | :stop | :exception] |
CodexWrapper.Exec.stream/2, CodexWrapper.ExecResume.stream/2, CodexWrapper.ExecFork.stream/2, CodexWrapper.Review.stream/2 |
[:codex_wrapper, :review, :start | :stop | :exception] |
CodexWrapper.Review.execute/2 |
[:codex_wrapper, :session, :turn, :start | :stop | :exception] |
each synchronous CodexWrapper.Session.send/3 turn (Exec or Resume) |
execute_json/2 delegates to its corresponding execute/2 path, so it
emits the same exec or review event rather than an additional
JSON-specific event.
Stream :start fires when the returned enumerable is first reduced, not
when it is constructed. :stop fires after the producer is exhausted or
the consumer halts early, after producer cleanup has run.
Metadata
Start metadata (all events):
:command-- atom identifying the command path (:exec,:exec_resume,:exec_fork,:review,:session_exec, or:session_resume):session_id-- session identifier when present (for:exec_fork, the source session being forked):sandbox_mode-- sandbox mode atom (:read_only,:workspace_write,:danger_full_access) ornil:approval_policy-- approval policy atom (:untrusted,:on_request,:never) ornil
Stop metadata adds:
:exit_code-- non-negative integer from the subprocess when a synchronous call resolves to a%CodexWrapper.Result{}. Streaming events omit it because the Runner stream contract does not expose the subprocess exit code.
Exception metadata adds the standard :kind, :reason, and
:stacktrace fields.
Attaching a handler
:telemetry.attach_many(
"codex-wrapper-logger",
[
[:codex_wrapper, :exec, :stop],
[:codex_wrapper, :stream, :stop],
[:codex_wrapper, :review, :stop],
[:codex_wrapper, :session, :turn, :stop]
],
fn event, measurements, metadata, _config ->
require Logger
Logger.info("#{inspect(event)} duration=#{measurements.duration} meta=#{inspect(metadata)}")
end,
nil
)
Configuration
Config options
| Option | Type | Description |
|---|---|---|
:binary |
String.t() |
Path to codex binary (auto-discovered if omitted) |
:working_dir |
String.t() |
Working directory for the subprocess |
:env |
[{String.t(), String.t() | nil | false}] |
Environment variables for the child; nil or false removes an inherited variable |
:release_env |
:scrub | :inherit |
Scrub the host release's boot environment by default when RELEASE_ROOT is set; use :inherit to opt out |
:timeout |
pos_integer() |
Whole-command timeout in milliseconds; defaults to no whole-run bound for Port streams |
:idle_timeout_ms |
pos_integer() | nil |
Maximum gap between output frames in a stream; defaults to 300,000 ms, or set nil to disable |
:runner |
module or shorthand | Per-config runner; snapshots application default when absent |
:runner_options |
keyword | Immutable one-shot transport requirements; explicit [] overrides application defaults |
:verbose |
boolean() |
Compatibility option: only false is supported; true raises before CLI execution |
Removing inherited variables
{name, nil} and {name, false} in :env remove a variable the BEAM
would otherwise pass to codex, on every runner path. When a name
appears more than once, the last entry wins. CodexWrapper.Config.new/1 raises
ArgumentError on an empty name, a name containing =, a NUL byte, or
a value that is not a string, nil or false.
Runner.Port passes the unset to Port.open/2. Runner.Forcola
one-shot calls replace the child environment with the computed one
(env_mode: :replace). Forcola.Stream does not take env_mode, so a
Forcola stream runs /usr/bin/env -u NAME ... in front of codex.
Running from a release
By default, when the parent VM has RELEASE_ROOT set, each child process
starts without inherited RELEASE_*, BINDIR, or ROOTDIR variables. The
wrapper also removes complete PATH entries equal to the parent's BINDIR,
ROOTDIR/bin, RELEASE_ROOT/bin, or either root's direct erts-<version>/bin.
Other PATH entries retain their order, including duplicates and empty entries.
This lets commands such as mix and elixir find the project's runtime instead
of booting against the host release. The project's runtime must be available on
the remaining PATH.
The default applies to execute and stream calls on both runners, sessions,
command modules, and raw/2. The parent environment is read when preparing
the spawn and is never modified. Outside a release, nothing is scrubbed.
Config.env entries apply after the scrub, so an explicit value can restore
a removed name. An explicit PATH still has the parent's release entries
removed; {"PATH", nil} or {"PATH", false} keeps PATH unset. With
runner_options: [env_mode: :replace], no inherited PATH is added.
# Default behavior inside a release
config = CodexWrapper.Config.new(release_env: :scrub)
# Opt out for a command that deliberately needs the host release environment
config = CodexWrapper.Config.new(release_env: :inherit)
CodexWrapper.exec("inspect the release", release_env: :inherit)
# Obtain unset tuples and a cleaned PATH for a custom environment builder
scrub = CodexWrapper.ChildEnv.release_scrub(System.get_env())
Removing RELEASE_COOKIE reduces accidental propagation. It does not prevent
a child running as the same OS user from reading the release's cookie file.
Observing native thread identity before completion
The opt-in :session_observer execution option announces the first valid
stdout thread.started ID while a one-shot call is still running:
reference = make_ref()
CodexWrapper.exec("continue the work",
session_observer: {observer_pid, reference},
working_dir: "/path/to/project", timeout: 300_000)
The local observer receives
{reference, %CodexWrapper.SessionObservation{session_id: id, source: :thread_started}}.
The execution caller sends this message before returning, so its later terminal
reply to the same observer cannot overtake the observation. Callers own
persistence and must reject references from stale execution attempts. An
observed identity is not proof of completion; the call can still fail or time out.
The same option works with Exec.execute/3, ExecResume.execute/3,
ExecFork.execute/3, ExecFork.fork/3, their execute_json/3 variants and
CodexWrapper.exec_json/2. It requires Runner.Forcola and Forcola in the
supported dependency range (~> 0.12). Invalid observer options and runners
without observed execution fail before spawning. A dead observer is harmless;
no caller callback runs and no silent Port fallback occurs.
Observed commands force --json. Their Result preserves every stdout and
stderr byte in separate fields, including final output without a newline.
Legacy execute/2 and convenience calls without an observer retain merged
output. Exit codes and process success semantics stay the same. Fork identity
comes only from valid stdout thread.started, while stderr remains available
for failure diagnostics.
Malformed, blank, duplicate and conflicting later announcements are ignored.
Identity framing is limited to 1 MiB per line; an oversized line is skipped
until the next newline without truncating the final raw result. The whole-run
deadline, including Forcola's configured default for a nil timeout, stays in
force. Completion waits for transport termination even after turn.completed.
The execution caller owns and cleans up its helper; owner death closes the
Forcola port and triggers process-group cleanup.
Evidenced one-shot execution and isolated configuration
CodexWrapper.Config.new/1 snapshots the application runner and runner options only when
those fields are absent. Explicit runner_options: [] overrides defaults;
concurrent calls can select different binaries, directories, environment,
models, CLI config/MCP overrides and transport requirements without changing
application or OS state. All one-shot commands, including raw, version,
completion and command convenience functions, honor the config's runner.
Directly constructed structs with nil runner fields retain legacy default lookup.
Forcola's legacy one-shot default timeout is captured by CodexWrapper.Config.new/1 too.
The formerly System.cmd commands (auth login/logout/status, apply, completion,
features, MCP, mcp-server and version) now use this runner routing and its timeout
semantics. With the default Port runner they run through /bin/sh, with stdin
from /dev/null and the SIGINT/TERM/KILL watchdog; a missing binary returns
{:error, {:exit, 127, output}} instead of raising. Port has no whole-run bound
unless config :timeout is set. Forcola uses the snapshotted
:forcola_default_timeout_ms (300,000 ms unless configured) when no explicit
limit is set, then cancels the child and returns {:error, {:timeout, milliseconds}}.
Interactive login, device authorization and long-running mcp-server commands
therefore need an explicit config :timeout sufficient for the intended wait or
server lifetime under Forcola. This routing does not supply interactive stdin
or a duplex MCP connection.
Add execution_observer to Exec.execute/3, ExecResume.execute/3,
ExecFork.execute/3, ExecFork.fork/3, Review.execute/3, JSON variants,
or the exec/review convenience functions. Supply a session_observer too
when early native identity is needed:
ref = make_ref()
config = CodexWrapper.Config.new(
binary: "/opt/codex", working_dir: "/work/project",
env: [{"CODEX_HOME", "/work/config-a"}, {"OPENAI_API_KEY", "caller-supplied"}],
runner: CodexWrapper.Runner.Forcola,
timeout: 60_000,
runner_options: [shim_path: "/opt/forcola_shim", env_mode: :replace,
idle_timeout_ms: 10_000, max_output_bytes: 1_048_576,
interrupt_signal: :int, interrupt_grace_ms: 1_000, kill_grace_ms: 500])
CodexWrapper.Exec.execute(CodexWrapper.Exec.new("inspect the project"), config,
session_observer: {self(), ref}, execution_observer: {self(), ref})
# {ref, %CodexWrapper.SessionObservation{...}} (if announced)
# {ref, %CodexWrapper.ExecutionObservation{...}} (exactly once before return)
The evidenced adapter requires Forcola ~> 0.12, which supplies the run/2
evidence contract and matching shim. A Forcola without that contract is
rejected before evidenced execution. The example requires the matching trusted
shim; fake tests do not establish native behavior.
A terminal observation preserves bounded stdout/stderr prefixes (including on
failure), the last parsed turn.completed / turn.failed object, the legacy
outcome, raw child exit/signal, protocol/transport termination, cancellation
reason, truncation, cleanup confirmation/scope, and guard close independently.
Missing facts stay unknown. A session ID, JSON result, exit zero, or exhausted
enumerable does not establish clean completion. Process-group confirmation
says nothing about escaped descendants (Forcola #93 / Codex #119).
Without an explicit limit, evidenced execution uses a finite wall bound: Forcola
uses the snapshotted :forcola_default_timeout_ms (300,000 ms unless configured);
other runners use 300,000 ms. Idle defaults to 300,000 ms, with 1 MiB combined
stdout/stderr. :timeout_ms supplies a per-config
runner default; :timeout takes precedence. Idle/output limits and interrupt
grace must be positive 32-bit integers; kill grace may be zero. Interrupt
signal/grace must be supplied together and only :int is accepted. Replacement
environment clears inherited variables even with env: []; use an absolute
binary and supply every variable the target requires. Explicit shim paths must
be absolute; installation, matching version and lifetime belong to the caller.
Custom runners opt in with run_evidenced/5 and may provide
validate_evidence/0 for capability preflight. validate_options/1 is required
for nonempty runner options. Unsupported requirements are errors before spawn,
with no Port fallback. A valid execution observer receives a terminal fact
on preflight failure too, without a fabricated child exit. Invalid execution
targets cannot receive a fact. The owned bounded collector forwards identity
from the caller, contains private raw messages, monitors caller/worker death,
and closes before the terminal fact. Forcola remains the native process owner;
there is no competing cancellation loop. Existing session-only observation
and legacy return shapes remain available.
The new controls are one-shot only; streams reject nonempty runner_options
and execution_observer before spawning. An explicitly selected per-config runner
(including a non-nil runner in a direct struct) must implement stream_lines/4;
otherwise stream consumption raises ArgumentError before spawning, with no Port
fallback. Implicit application/default runner selection retains the legacy Port
fallback with a warning when the streaming callback is unavailable.
The default mix test command includes the required contract suite and resolves
the matching shim from the installed Forcola dependency. To run it alone:
mix test test/codex_wrapper/runner/forcola_evidence_contract_test.exs
An optional FORCOLA_SHIM_PATH=/absolute/path/to/matching/forcola_shim test
override selects a trusted executable built for the same Forcola version.
This suite reports a missing contract or shim as a failure and is not excluded
by the legacy :forcola availability tag. Fixtures run without Codex credentials
and clean their temporary files; native process cleanup is asserted separately.
Runners: process-group cleanup with forcola
Every codex subprocess -- the synchronous commands
(CodexWrapper.exec/2, Exec.execute/2, ExecResume.execute/2,
Review.execute/2) and the NDJSON streaming paths (Exec.stream/2,
ExecResume.stream/2, Review.stream/2, Session.stream/3) -- routes
through a runner module. The default, CodexWrapper.Runner.Port, runs codex under a
/bin/sh wrapper, supplies the prompt on stdin, and bounds synchronous execution
with a BEAM Task timeout. Commands without prompt input receive /dev/null.
When the port closes -- a timeout, a stream idle timeout or early halt, the
owning process dying, or the BEAM dying -- the wrapper's watchdog signals
the codex process: SIGINT first, SIGTERM two seconds later, SIGKILL one
second after that, stopping once the process is gone. Codex installs its own
SIGINT handler and uses it to stop tool children it placed in other process
groups. The shell starts codex as a background job, which begins with
SIGINT ignored, so a program without such a handler is stopped by the
SIGTERM step. The caller is not held up by this schedule.
This is not a process-tree guarantee. Only the codex process is signalled,
so a child that Codex does not stop itself, a daemonized process, or a stdio
MCP server can survive. A process that writes to the closed port can also die
of a broken pipe before its SIGINT handler finishes. Signals are sent by pid,
so a pid reused between the target's exit and the watchdog's cancellation
could be signalled by mistake; the window is kept short, not removed. The
schedule needs an executable /bin/sleep to escalate; without one only
SIGINT is sent.
The optional forcola dependency provides
CodexWrapper.Runner.Forcola, which routes runs through a Rust shim that
puts codex in its own process group and signals the whole group on
timeout, on close, or when the BEAM dies. One-shot runs send SIGINT first,
SIGTERM after interrupt_grace_ms (default 2,000), then SIGKILL after
kill_grace_ms; set interrupt_signal: :int and interrupt_grace_ms in
runner_options to change the grace. The SIGINT step matters because codex
puts each tool command and stdio MCP server in its own process group: on
SIGINT codex stops the command it is running, while on SIGTERM alone it
exits and leaves that command behind.
Streaming runs through forcola follow the same schedule with the default
2,000 ms grace when a stream is halted, times out, or its owner or the BEAM
dies. Neither runner stops descendants that leave codex's process
group or ignore these signals. On Linux, forcola's cgroup option is the
backstop; on macOS there is none. forcola is POSIX-only (macOS and Linux)
and ships precompiled shim binaries, so no Rust toolchain is required.
# mix.exs
{:forcola, "~> 0.12"}
# config/config.exs
config :codex_wrapper, runner: CodexWrapper.Runner.Forcola
:forcola and :task work as shorthand for the two built-in runners.
:forcola falls back to the default runner when the dependency is
absent, so it is safe to set unconditionally:
config :codex_wrapper, runner: :forcola
CodexWrapper.Runner.Forcola compiles only when forcola is present, so
the dependency stays optional and the default path is unchanged.
forcola requires a finite timeout, so when a command's :timeout is
nil the run falls back to :forcola_default_timeout_ms (default
300_000) instead of running unbounded for synchronous commands:
config :codex_wrapper, runner: :forcola, forcola_default_timeout_ms: 120_000
Streaming through a runner
The streaming paths go through the same selection, so :forcola gets
their process group killed too -- on an early Enum.take/2, on a
timeout, or when the BEAM dies.
Both runners use :timeout as the whole-run deadline and
:idle_timeout_ms as the independent gap between output frames. A
Port stream has no whole-run deadline when :timeout is nil; a
Forcola stream uses :forcola_default_stream_timeout_ms (default
3,600,000 ms) instead. The existing :forcola_default_timeout_ms
setting also applies to streams unless the stream-specific setting is
provided. If either
bound expires, the stream emits a final %CodexWrapper.StreamError{}
with {:timeout, ms} or {:idle_timeout, ms} in its reason field.
Direct Runner.stream_lines/4 consumers receive the corresponding
{:error, reason} item before the stream ends.
A run that ends badly ends its stream with one final
%CodexWrapper.StreamError{} after its last line, on either runner and
without raising: reason is {:exit_status, code} for a nonzero exit
(127 for a missing executable on Runner.Port), {:spawn, reason} when
the process could not be started, or {:signal, signal} when
Runner.Forcola reports death by signal. A zero exit adds nothing.
A custom runner only has to implement run/4; stream_lines/4 is
optional, and a runner without it falls back to Runner.Port for
streams.
Exec options
| Option | Type | Description |
|---|---|---|
:prompt_input |
:stdin or :argv |
Prompt transport (default :stdin) |
:model |
String.t() |
Model name (e.g., "o3", "o4-mini") |
:profile |
String.t() |
Config profile: layers $CODEX_HOME/<name>.config.toml (--profile) |
:sandbox |
atom | :read_only, :workspace_write, or :danger_full_access |
:approval_policy |
atom | :untrusted, :on_request, or :never |
:full_auto |
boolean() |
Enable full-auto mode |
:cd |
String.t() |
Working directory for codex subprocess |
:skip_git_repo_check |
boolean() |
Skip git repository check |
:search |
boolean() or atom |
true for live search, or :cached, :indexed, :live, :disabled |
:ephemeral |
boolean() |
Disable session persistence |
:json |
boolean() |
Enable JSON (NDJSON) output |
:output_schema |
String.t() |
Path to output schema file |
:output_last_message |
String.t() |
Path to save last message |
:strict_config |
boolean() |
Fail on unrecognized config.toml keys |
:ignore_user_config |
boolean() |
Skip $CODEX_HOME/config.toml |
:ignore_rules |
boolean() |
Skip execpolicy .rules files |
:dangerously_bypass_hook_trust |
boolean() |
Run hooks without persisted trust |
:color |
atom | :always, :never, or :auto |
:oss |
boolean() |
Use an open-source provider |
:local_provider |
String.t() |
"lmstudio" or "ollama" |
:add_dir |
String.t() or list |
Make an extra directory writable (--add-dir) |
:image |
String.t() or list |
Image to attach (Exec and resumed turns) |
:config |
String.t() or list |
key=value config override (-c) |
:enable / :disable |
String.t() or list |
Feature flag to enable or disable |
:approve_for_me |
boolean() |
Route approval requests through automatic review; on Exec not with :sandbox, :full_auto or the bypass flag |
:worktree |
boolean() |
Run in a new codex-managed git worktree; not with :ephemeral; codex-cli 0.157.1+ (first session turn only) |
:thread_source |
String.t() |
Source classification for a new thread; codex-cli 0.157.1+ (first session turn only) |
:cyber_access_program |
atom | :standard, :daybreak_blue or :daybreak_red; OpenAI provider only; codex-cli 0.162.0+ |
CodexWrapper.exec/2, exec_json/2, stream/2, review/1 and
CodexWrapper.Session raise ArgumentError on an option not listed for
them, and on a value of the wrong type. review/1 takes the review target
options listed in its docs plus every option in this table except
:image, :worktree and :cyber_access_program.
Modules
| Module | Description |
|---|---|
CodexWrapper |
Convenience API for exec, review, stream, and version |
CodexWrapper.Config |
Shared client configuration and binary discovery |
CodexWrapper.Exec |
Exec command builder with fluent API |
CodexWrapper.ExecResume |
Session resume/continue builder |
CodexWrapper.ExecFork |
Non-interactive session fork builder |
CodexWrapper.UnsupportedError |
Raised by a stream when the CLI lacks a capability |
CodexWrapper.Review |
Code review builder |
CodexWrapper.Result |
Parsed command result (stdout, stderr, exit code) |
CodexWrapper.JsonLineEvent |
NDJSON streaming event parser |
CodexWrapper.Session |
Multi-turn session management |
CodexWrapper.SessionServer |
GenServer wrapper for sessions |
CodexWrapper.Retry |
Exponential backoff retry |
CodexWrapper.IEx |
Interactive REPL helpers |
CodexWrapper.Telemetry |
:telemetry span wrappers for exec paths |
CodexWrapper.Command |
Behaviour for CLI commands |
CodexWrapper.Runner |
Behaviour selecting how subprocesses execute and stream |
CodexWrapper.Runner.Port |
Default runner (/bin/sh Port with prompt stdin and INT/TERM/KILL cleanup of codex) |
CodexWrapper.Runner.Forcola |
Optional leak-free runner via forcola |
CodexWrapper.Commands.Auth |
Authentication (login/logout/status) |
CodexWrapper.Commands.Features |
Feature flag management |
CodexWrapper.Commands.Mcp |
MCP server CRUD |
CodexWrapper.Commands.McpServer |
Run Codex as an MCP server |
CodexWrapper.Commands.Sandbox |
Sandboxed command execution |
CodexWrapper.Commands.Apply |
Apply diffs from task IDs |
CodexWrapper.Commands.Archive |
Archive, unarchive, and delete saved sessions |
CodexWrapper.Commands.Plugin |
Plugins and plugin marketplaces |
CodexWrapper.Commands.Queue |
Queue a message for an existing session |
CodexWrapper.History |
Read session rollout files, plain and .jsonl.zst |
CodexWrapper.Commands.Completion |
Shell completion script generation |
CodexWrapper.Commands.Doctor |
Install, config, auth, and runtime diagnostics |
CodexWrapper.Commands.Version |
CLI version |
Testing
The default suite does not invoke an authenticated Codex session:
mix test
Opt-in integration tests exercise the installed Codex CLI, including its
live JSON event shape, session/thread identifier, feature-list format,
and streaming Port path. They require codex on PATH and an active
login:
mix test --include integration
# or only the live checks
mix test --only integration
The separate CLI contract check is authentication-free and verifies that the flags and config keys emitted by the wrapper are still accepted:
mix codex.contract
License
MIT -- see LICENSE.