This guide builds an offline Codex worker first, then switches it to the real CLI.
1. Add dependencies
def deps do
[
{:ecto_sqlite3, "~> 0.17"},
{:oban, "~> 2.23"},
{:oban_codex, "~> 0.1"}
]
endmix deps.get
For an automated SQLite/Lite setup, use the installer instead:
mix igniter.install oban_codex
2. Configure Oban
With an existing Ecto repo:
config :my_app, Oban,
repo: MyApp.Repo,
queues: [codex: 2]Add it to the supervision tree:
children = [
MyApp.Repo,
{Oban, Application.fetch_env!(:my_app, Oban)}
]Generate and run the Oban migration:
mix ecto.gen.migration add_oban
defmodule MyApp.Repo.Migrations.AddOban do
use Ecto.Migration
def up, do: Oban.Migrations.up()
def down, do: Oban.Migrations.down(version: 1)
endmix ecto.migrate
3. Start with an offline worker
The injectable query function lets the entire Oban path run without a Codex binary or model call:
defmodule MyApp.SummaryWorker do
use ObanCodex.Worker,
queue: :codex,
max_attempts: 3,
args:
ObanCodex.Args.defaults(
sandbox: :read_only,
approval_policy: :never
),
query_fun: &__MODULE__.demo_query/2
require Logger
@impl ObanCodex.Worker
def handle_result(result, _job) do
Logger.info("Codex said: #{inspect(ObanCodex.text(result))}")
:ok
end
def demo_query(prompt, _opts) do
{:ok, ObanCodex.Testing.result("a summary of: #{prompt}")}
end
endEnqueue a job:
ObanCodex.Args.new(
prompt: "the oban_codex README",
meta: %{request_id: "demo-1"}
)
|> MyApp.SummaryWorker.new()
|> Oban.insert()The stored map uses string keys. meta is flattened into it, available to the
job and telemetry, but ignored by the Codex adapter.
4. Test the seam
use ExUnit.Case
import ObanCodex.Testing
test "reads a structured verdict" do
result = structured_result(%{"outcome" => "done"}, session_id: "thread-1")
assert ObanCodex.outcome(result) == "done"
assert ObanCodex.session_id(result) == "thread-1"
endFor worker tests, use Oban.Testing.perform_job/3 when possible so args pass
through the same JSON encode/decode boundary as production.
5. Switch to the real Codex CLI
Install and authenticate Codex on every worker node, then check it:
mix oban_codex doctor
Remove query_fun from the worker. Set a real working directory:
use ObanCodex.Worker,
queue: :codex,
max_attempts: 2,
args:
ObanCodex.Args.defaults(
working_dir: "/srv/checkouts/my_app",
sandbox: :read_only,
approval_policy: :never,
timeout: :timer.minutes(10)
)The default path builds CodexWrapper.Exec, forces JSONL, and returns the raw
%CodexWrapper.Result{}. Use ObanCodex.text/1, events/1, usage/1, and
session_id/1 to read it.
6. Structured output
Place a schema where every worker node can read it:
{
"type": "object",
"additionalProperties": false,
"required": ["outcome", "summary"],
"properties": {
"outcome": {
"type": "string",
"enum": ["done", "blocked"]
},
"summary": {
"type": "string"
}
}
}Then:
ObanCodex.Args.new(
prompt: "inspect the repository and report",
output_schema: "/srv/my_app/priv/report.schema.json",
sandbox: :read_only,
approval_policy: :never
)case ObanCodex.structured(result) do
%{"outcome" => "done", "summary" => summary} -> persist(summary)
%{"outcome" => "blocked"} -> {:cancel, :blocked}
nil -> {:error, :missing_structured_output}
end7. Production checks
- Pin security controls with
pinned_argsif enqueue input isn't fully trusted. - Keep secrets out of args; they are plaintext database data.
- Set a finite command timeout.
- Keep attempts low for workers that mutate files or external systems.
- Add Oban Lifeline and Pruner plugins.
- Provide one isolated checkout per concurrent mutating job.
- Consider
CodexWrapper.Runner.Forcolafor process-group termination. - Remember that session state is node-local and that
ephemeralsessions cannot be resumed.
Continue with Agent worker patterns or the experimental Agent lifecycle.