CodexWrapperEx

CIHex.pmHex.pm DownloadsDocsLicense

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.1.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.145.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.Commands.Fork was removed. codex fork is an interactive TUI command with no non-interactive path -- piping a prompt to it fails with stdin is not a terminal -- so it cannot be driven from a library.

ExecResume is the closest available alternative, but it is not a fork: it resumes a session in place and adds to that session's history, where forking would branch a new session and leave the original untouched. There is currently no way to branch a session non-interactively.

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

EventFired around
[:codex_wrapper, :exec, :start | :stop | :exception]CodexWrapper.Exec.execute/2, CodexWrapper.ExecResume.execute/2
[:codex_wrapper, :stream, :start | :stop | :exception]CodexWrapper.Exec.stream/2, CodexWrapper.ExecResume.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

OptionTypeDescription
:binaryString.t()Path to codex binary (auto-discovered if omitted)
:working_dirString.t()Working directory for the subprocess
:env[{String.t(), String.t()}]Environment variables
:timeoutpos_integer()Command timeout in milliseconds
:verboseboolean()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"}
# 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

OptionTypeDescription
:modelString.t()Model name (e.g., "o3", "o4-mini")
:profileString.t()Named config profile (--profile)
:sandboxatom:read_only, :workspace_write, or :danger_full_access
:approval_policyatom:untrusted, :on_request, or :never
:full_autoboolean()Enable full-auto mode
:cdString.t()Working directory for codex subprocess
:skip_git_repo_checkboolean()Skip git repository check
:searchboolean()Enable live web search
:ephemeralboolean()Disable session persistence
:jsonboolean()Enable JSON (NDJSON) output
:output_schemaString.t()Path to output schema file
:output_last_messageString.t()Path to save last message
:strict_configboolean()Fail on unrecognized config.toml keys
:ignore_user_configboolean()Skip $CODEX_HOME/config.toml
:ignore_rulesboolean()Skip execpolicy .rules files
:dangerously_bypass_hook_trustboolean()Run hooks without persisted trust
:coloratom:always, :never, or :auto (Exec only)
:ossboolean()Use an open-source provider (Exec only)
:local_providerString.t()"lmstudio" or "ollama" (Exec only)

Modules

ModuleDescription
CodexWrapperConvenience API for exec, review, stream, and version
CodexWrapper.ConfigShared client configuration and binary discovery
CodexWrapper.ExecExec command builder with fluent API
CodexWrapper.ExecResumeSession resume/continue builder
CodexWrapper.ReviewCode review builder
CodexWrapper.ResultParsed command result (stdout, stderr, exit code)
CodexWrapper.JsonLineEventNDJSON streaming event parser
CodexWrapper.SessionMulti-turn session management
CodexWrapper.SessionServerGenServer wrapper for sessions
CodexWrapper.RetryExponential backoff retry
CodexWrapper.IExInteractive REPL helpers
CodexWrapper.Telemetry:telemetry span wrappers for exec paths
CodexWrapper.CommandBehaviour for CLI commands
CodexWrapper.RunnerBehaviour selecting how subprocesses execute and stream
CodexWrapper.Runner.PortDefault runner (/bin/sh Port with closed stdin)
CodexWrapper.Runner.ForcolaOptional leak-free runner via forcola
CodexWrapper.Commands.AuthAuthentication (login/logout/status)
CodexWrapper.Commands.FeaturesFeature flag management
CodexWrapper.Commands.McpMCP server CRUD
CodexWrapper.Commands.McpServerRun Codex as an MCP server
CodexWrapper.Commands.SandboxSandboxed command execution
CodexWrapper.Commands.ApplyApply diffs from task IDs
CodexWrapper.Commands.ArchiveArchive, unarchive, and delete saved sessions
CodexWrapper.Commands.CompletionShell completion script generation
CodexWrapper.Commands.DoctorInstall, config, auth, and runtime diagnostics
CodexWrapper.Commands.VersionCLI 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.