Hex.pm Documentation

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:

  • Elixir ~> 1.20
  • a configured Oban instance
  • codex_wrapper ~> 0.4
  • the codex CLI installed and authenticated for real runs

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:

  • [:oban_codex, :run, :start]
  • [:oban_codex, :run, :stop] for any completed CLI result
  • [:oban_codex, :run, :exception] for a pre-result execution failure
  • [:oban_codex, :agent, :transition] for Agent state changes

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

Fleet safety

  • Keep max_attempts low for mutating work; a retry is another model turn and may repeat writes.
  • Pin sandbox, approval_policy, dangerous bypass flags, and working_dir with :pinned_args when enqueue input is semi-trusted.
  • Set a finite timeout. Configure CodexWrapper.Runner.Forcola when you need the whole OS process group killed on timeout.
  • Use Oban.Plugins.Lifeline for abandoned jobs and Oban.Plugins.Pruner for plaintext job-arg retention.
  • Provide isolated checkouts yourself for concurrent mutating jobs. Codex has no oban_claude-style worktree option in this seam.
  • Never enable dangerously_bypass_approvals_and_sandbox unless the worker is already protected by an external sandbox.

More patterns are in Agent worker patterns.

License

MIT