oban_codex

Hex.pmDocumentation

Run OpenAI Codex turns as durable Oban jobs.

codex_wrapper owns the Codex CLI process. Oban owns persistence, concurrency, retries, scheduling, uniqueness, and recovery. oban_codex is the seam between them, plus the same higher-level conveniences offered by oban_claude: validated args, result helpers, worker callbacks, testing fixtures, CLI tools, an Igniter installer, and an opt-in long-lived Agent lifecycle.

Install

def deps do
[
{:oban, "~> 2.23"},
{:oban_codex, "~> 0.1"}
]
end

Requirements:

Check a worker node before enabling its queue:

mix oban_codex doctor

Quick start

defmodule MyApp.CodexJob do
use ObanCodex.Worker, queue: :codex, max_attempts: 3
@impl ObanCodex.Worker
def handle_result(result, _job) do
Logger.info("Codex: #{ObanCodex.text(result)}")
:ok
end
end
ObanCodex.Args.new(
prompt: "summarize this repository",
working_dir: "/path/to/repo",
sandbox: :read_only,
approval_policy: :never
)
|> MyApp.CodexJob.new()
|> Oban.insert()

ObanCodex.Args.new/1 accepts atom keys and native Elixir values, validates them, and returns the string-keyed JSON map Oban stores. A raw string map is also accepted as the low-level escape hatch.

Compatibility with oban_claude

The packages intentionally share the same architecture and naming. Application code that only depends on the Oban-facing seam should feel familiar.

Surfaceoban_claudeoban_codex
stateless enginerun/2run/2
worker macrodefaults, pinned args, classifier, result/error hookssame
args buildernew/1, defaults/1, flat metasame
result helpersstructured output, session, costtext, events, structured output, session, usage; cost unavailable
testingresult/error/query fixtures and sequencessame concepts, encoded as real Codex JSONL
task CLIrun, doctor, argssame
installerSQLite/Lite Igniter scaffoldsame
Agent lifecycleInstance, Job, Supervisor, Ticksame state machine and facade

Provider semantics intentionally differ:

ConcernClaudeCodex
permissionspermission_mode, tool allow/denysandbox, approval_policy, explicit dangerous bypass flags
structured outputinline JSON Schemapath to a JSON Schema file
resumeClaude resume flagsCodex thread id through session_id; resume is an alias
isolationCLI worktree optionprovide a dedicated checkout/working directory outside this package
config sealingClaude hermetic scopesignore_user_config, ignore_rules, strict_config, explicit overrides
spend/turn railswrapper reports cost and rail stopsCodex JSONL reports token usage, not price or turn-budget errors

Those differences are deliberate rather than papered over with misleading fields.

Worker configuration

Worker :args are defaults under the stored job args. :pinned_args are guardrails over the job:

defmodule MyApp.ReadOnlyReview do
use ObanCodex.Worker,
queue: :review,
max_attempts: 2,
args:
ObanCodex.Args.defaults(
model: "gpt-5",
working_dir: "/srv/checkouts/project"
),
pinned_args:
ObanCodex.Args.defaults(
sandbox: :read_only,
approval_policy: :never,
ignore_user_config: true
)
end

Precedence is:

pinned_args > stored job args > args defaults

Both worker maps must be string-keyed; the builders are the easiest way to ensure that. A malformed stored job cancels as {:cancel, {:invalid_args, message}} instead of retrying the same input.

Do not put environment variables or secrets in job args. :env is deliberately absent because Oban stores args in plaintext. Resolve secrets on the worker node or inject a custom :query_fun.

Result model

The successful payload is the underlying %CodexWrapper.Result{}:

%CodexWrapper.Result{
stdout: "... JSONL ...",
stderr: "",
exit_code: 0,
success: true
}

oban_codex forces --json and provides stable readers over those events:

ObanCodex.events(result) # [%CodexWrapper.JsonLineEvent{}, ...]
ObanCodex.text(result) # final agent message
ObanCodex.structured(result) # decoded object/array, or nil
ObanCodex.outcome(result) # conventional structured["outcome"]
ObanCodex.session_id(result) # thread_id from thread.started
ObanCodex.usage(result) # token usage from the final turn.completed
ObanCodex.cost_usd(result) # nil; Codex doesn't report price

A non-zero CLI exit is still a %CodexWrapper.Result{success: false}. A failure before a result exists (timeout, spawn, signal, I/O) is normalized as an %ObanCodex.Error{}.

Structured output

Codex takes a JSON Schema file path, not inline schema text:

ObanCodex.Args.new(
prompt: "triage issue 87",
output_schema: "/srv/my_app/priv/triage.schema.json",
sandbox: :read_only,
approval_policy: :never
)

The path must exist on the node executing the job. It is reapplied to resumed turns. A handler can propose/dispose with the decoded object:

@impl ObanCodex.Worker
def handle_result(result, _job) do
case ObanCodex.structured(result) do
%{"outcome" => "fix", "summary" => summary} ->
MyApp.Implement.new(%{"prompt" => summary}) |> Oban.insert()
:ok
%{"outcome" => "wontfix"} ->
{:cancel, :wontfix}
_ ->
:ok
end
end

Classification and error hooks

The default classifier is intentionally conservative:

OutcomeOban verdict
successful result:ok
exit 126/127{:cancel, :command_unavailable}
other non-zero exit{:error, {:command_failed, exit_code}}
timeout{:error, :timeout}
deterministic normalized config/auth/spawn fault{:cancel, kind}
other normalized execution fault{:error, kind}
off-contract raw error{:cancel, term}

Codex doesn't currently expose a stable typed envelope for errors printed by the CLI, so arbitrary non-zero exits are bounded retries. Override :classifier when your deployment can classify an exit more precisely:

defmodule MyApp.CodexJob do
use ObanCodex.Worker,
queue: :codex,
classifier: &__MODULE__.classify/1
def classify({:ok, %CodexWrapper.Result{success: false} = result}) do
if result.stdout =~ "login required" do
{{:cancel, :auth}, result}
else
ObanCodex.Outcome.classify({:ok, result})
end
end
def classify(outcome), do: ObanCodex.Outcome.classify(outcome)
end

A classifier returns {oban_return, payload}, not a flat verdict. handle_error/3 receives every non-:ok verdict with its payload and job, and may preserve or replace the verdict.

Sessions

Every persisted run emits a thread id:

thread_id = ObanCodex.session_id(result)
ObanCodex.Args.new(
prompt: "continue with the tests",
session_id: thread_id
)

resume: thread_id is accepted as a compatibility alias. Do not set both. ephemeral: true is for one-shot jobs and cannot be combined with either resume key.

Session files are node-local. Pin a conversational chain to compatible worker nodes or use shared Codex state storage if your deployment provides it.

Experimental Agent lifecycle

The opt-in Agent layer is in sync with oban_claude:

Add the supervisor:

children = [
{ObanCodex.Agent.Supervisor, []}
]

Then start and prompt a long-lived conversational state machine:

{:ok, _pid} =
ObanCodex.Agent.start_agent("triage-7",
args:
ObanCodex.Args.defaults(
working_dir: "/srv/checkouts/project",
output_schema: "/srv/my_app/priv/agent.schema.json",
sandbox: :read_only,
approval_policy: :never
),
approved_args: %{
"sandbox" => "workspace_write",
"approval_policy" => "never"
}
)
:processing = ObanCodex.Agent.submit_prompt("triage-7", "triage new issues")
{:ok, status} = ObanCodex.Agent.status("triage-7")

The state machine threads session_id across ordinary Oban jobs and supports idle, running, waiting-for-user, awaiting-permission, and paused states. cost_usd remains in its info map for cross-package shape compatibility, but it stays 0.0 unless a custom error payload reports cost.

See Agent lifecycle.

Testing without Codex

import ObanCodex.Testing
assert {:ok, result} =
ObanCodex.run(%{"prompt" => "x"}, query_fun: respond("done"))
assert ObanCodex.text(result) == "done"
query = sequence([error(:timeout), structured_result(%{"outcome" => "done"})])

Fixtures are real %CodexWrapper.Result{} structs containing production-shaped JSONL, so helper behavior is exercised rather than mocked around.

CLI and installer

# real, queueless Codex turn
mix oban_codex run "summarize the repo" \
--working-dir . --sandbox read_only --approval-policy never
# dry-run the validated stored args
mix oban_codex args "summarize the repo" --sandbox read_only --json
# preflight binary, auth, config, and runtime health
mix oban_codex doctor

For a runnable SQLite/Lite scaffold:

mix igniter.install oban_codex

The generated worker is offline by default via query_fun, so the first boot doesn't start a model turn.

Telemetry

Each turn emits:

Run measurements include native duration and cost_usd: 0.0. Metadata can contain prompts and raw output; redact before exporting it.

Fleet safety

More patterns are in Agent worker patterns.

License

MIT