CodexWrapperEx

CI Hex.pm Hex.pm Downloads Docs License

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.5.3"}
]
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.

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"])

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()

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

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()
# cost and turn count
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 loads the matching [profiles.<name>] section of config.toml:

Exec.new("Fix the tests")
|> Exec.profile("fast")
|> Exec.execute(config)

It is available on Exec only, and as a :profile option on CodexWrapper.exec/2 and CodexWrapper.Session.new/2. The Codex CLI accepts --profile on codex exec but rejects it on codex exec resume and codex exec review, so ExecResume and Review have no profile/2, and a session's :profile applies to its first turn only.

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 Exec only. codex exec resume and codex exec review reject --color, --oss, and --local-provider, verified against codex-cli 0.145.0.

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.

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
)

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)

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)

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):

Stop metadata adds:

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()}] Environment variables
:timeout pos_integer() Command timeout in milliseconds
:verbose boolean() Enable verbose output

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 with stdin closed and bounds it with a BEAM Task timeout. On timeout the BEAM task is killed, but no signal reaches the codex process group, so codex and any stdio MCP servers it spawned can survive, reparented to init.

The optional forcola dependency provides CodexWrapper.Runner.Forcola, which routes runs through a Rust shim that puts codex in its own process group and kills the whole group (SIGTERM then SIGKILL) on timeout, on close, or when the BEAM dies, so codex and its MCP servers are reaped together. forcola is POSIX-only (macOS and Linux) and ships precompiled shim binaries, so no Rust toolchain is required.

# mix.exs
{:forcola, "~> 0.3.5"}
# 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:

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.

The two runners enforce a command's :timeout differently for a stream. Runner.Port treats it as an idle bound (the wait for the next line, what the streaming paths have always used, defaulting to 300_000 when :timeout is nil). Runner.Forcola treats it as forcola's whole-run bound, falling back to :forcola_default_timeout_ms the same way the synchronous path does.

A non-zero exit ends a stream without raising on either runner -- the Enumerable simply finishes. Use execute/2 when the exit code matters.

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
:model String.t() Model name (e.g., "o3", "o4-mini")
:profile String.t() Named config profile (--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() Enable live web search
: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 (Exec only)
:oss boolean() Use an open-source provider (Exec only)
:local_provider String.t() "lmstudio" or "ollama" (Exec only)

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 closed stdin)
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.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.