Run a Claude Code job (via claude_wrapper)
on an Oban queue.
oban_claude is the thin seam between Oban and claude:
run/2is the engine: a string-keyed args map in, a{oban_return, result}tuple out. It callsClaudeWrapper.query/2and maps the typed%ClaudeWrapper.Result{}/%ClaudeWrapper.Error{}onto Oban's return values via a classifier (seeObanClaude.Outcome).ObanClaude.Workeris the drop-in worker (use ObanClaude.Worker) with a singleObanClaude.Worker.handle_result/2override point.
It owns no state and runs no daemon, and takes no position on what consumes a
result. Whether the agent effects its own writes (full-auto) or returns
structured data for a downstream effector is encoded in the job's args
(prompt + permission mode), never here, so oban_claude supports both.
Example
Build the job's args with ObanClaude.Args.new/1 (atom keys, validated) rather
than a hand-written string map:
defmodule MyApp.ClaudeJob do
use ObanClaude.Worker, queue: :claude, max_attempts: 3
end
ObanClaude.Args.new(prompt: "summarize the repo", working_dir: "/path/to/repo")
|> MyApp.ClaudeJob.new()
|> Oban.insert()The raw string-keyed map is still accepted as the low-level escape hatch:
%{"prompt" => "summarize the repo", "working_dir" => "/path/to/repo"}
|> MyApp.ClaudeJob.new()
|> Oban.insert()Telemetry
run/2 emits the following events via :telemetry:
[:oban_claude, :run, :stop]
Emitted after a successful ClaudeWrapper.query/2 call. This includes
is_error: true results, which are returned without raising.
- Measurements:
:duration-- wall time of the query in native time units (convert withSystem.convert_time_unit/3):cost_usd-- the reported cost in USD from the result (0.0when the result carries no cost)
- Metadata:
[:oban_claude, :run, :exception]
Emitted when the query returns {:error, _} -- both a typed
%ClaudeWrapper.Error{} and an off-contract error term (which the classifier
cancels, so it still surfaces here).
- Measurements:
:duration-- wall time of the query in native time units:cost_usd-- the run's spend when the error carries one (a rail-stop:max_budget_exceeded/:max_turns_exceededreason map does;0.0otherwise, including every off-contract term). Summing:cost_usdacross both events gives a complete spend total.
- Metadata:
:error-- the%ClaudeWrapper.Error{}struct, or the raw error term on the off-contract path:args-- the string-keyed args map passed torun/2:job-- as above
Data handling
:args (prompt, system prompt, :meta), :result, and :error (which can
embed raw CLI stdout/stderr) all ride on these events. Redact before shipping
telemetry to a log aggregator.
Summary
Types
Maps a wrapper_outcome/0 onto the {oban_return, payload} envelope. The
first element MUST be a valid oban_return/0; run/2 raises otherwise. A
common mistake is a flat {:cancel, reason} -- that is a bare verdict, not
the {verdict, payload} envelope the ObanClaude.Worker path unwraps.
An Oban.Worker.perform/1 return value. :snooze accepts Oban's full
Oban.Period.t/0 -- a pos_integer of seconds or a {n, unit} tuple. The
default classifier (ObanClaude.Outcome) never returns :snooze (it would
bypass max_attempts); a :classifier override may.
The claude entrypoint: (prompt, query_opts) -> t:wrapper_outcome/0.
What ClaudeWrapper.query/2 (or a custom :query_fun) returns: a typed
%Result{}, or a typed %Error{} (an arbitrary term() only off the
documented contract).
Functions
Read the run's cost in USD from a run's payload, or nil if unavailable.
Read the "outcome" string from a result's structured_output, when a
--json-schema run produced one. Returns nil otherwise.
Run a claude job from a string-keyed args map.
Read the claude session id from a run's payload, or nil if there is none.
Return the full schema-validated structured_output from a result (a
--json-schema run), or nil when the run produced none.
Types
@type classifier() :: (wrapper_outcome() -> {oban_return(), term()})
Maps a wrapper_outcome/0 onto the {oban_return, payload} envelope. The
first element MUST be a valid oban_return/0; run/2 raises otherwise. A
common mistake is a flat {:cancel, reason} -- that is a bare verdict, not
the {verdict, payload} envelope the ObanClaude.Worker path unwraps.
@type oban_return() :: :ok | {:ok, term()} | {:error, term()} | {:cancel, term()} | {:snooze, Oban.Period.t()}
An Oban.Worker.perform/1 return value. :snooze accepts Oban's full
Oban.Period.t/0 -- a pos_integer of seconds or a {n, unit} tuple. The
default classifier (ObanClaude.Outcome) never returns :snooze (it would
bypass max_attempts); a :classifier override may.
@type query_fun() :: (String.t(), keyword() -> wrapper_outcome())
The claude entrypoint: (prompt, query_opts) -> t:wrapper_outcome/0.
@type wrapper_outcome() :: {:ok, ClaudeWrapper.Result.t()} | {:error, ClaudeWrapper.Error.t() | term()}
What ClaudeWrapper.query/2 (or a custom :query_fun) returns: a typed
%Result{}, or a typed %Error{} (an arbitrary term() only off the
documented contract).
Functions
@spec cost_usd(ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()) :: float() | nil
Read the run's cost in USD from a run's payload, or nil if unavailable.
Like session_id/1, it hides the success-vs-rail-stop shape split:
result.cost_usd on a %Result{}, error.reason.cost_usd on a rail-stop
%Error{}. Use it in ObanClaude.Worker.handle_error/3 to record spend
before enqueuing a resume job.
@spec outcome(ClaudeWrapper.Result.t()) :: String.t() | nil
Read the "outcome" string from a result's structured_output, when a
--json-schema run produced one. Returns nil otherwise.
Useful inside ObanClaude.Worker.handle_result/2 to branch on a typed
outcome (e.g. "done" vs "blocked").
@spec run(ObanClaude.Args.t(), classifier: classifier(), query_fun: query_fun(), job: Oban.Job.t() ) :: {oban_return(), ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()}
Run a claude job from a string-keyed args map.
Returns {oban_return, %Result{} | %Error{}} so a caller can both act on the
Oban verdict and inspect the underlying run (cost, session id, structured
output). :prompt is required; a curated subset of ClaudeWrapper.query/2
options (the keys in the ObanClaude.Args options table) is forwarded, and any
other key in the map is silently ignored.
Options:
:classifier-- aclassifier/0: a 1-arity fun mapping the claude call's outcome onto the{oban_return, payload}envelope. The first element is the Oban verdict;run/2raisesArgumentErrorif it is not a validoban_return/0(guarding the flat-{:cancel, reason}mistake that Oban would otherwise silently treat as success). Defaults to&ObanClaude.Outcome.classify/1.:query_fun-- aquery_fun/0, the claude entrypoint. Defaults to&ClaudeWrapper.query/2. Override to stub claude in tests, or to route through a different wrapper entrypoint.:job-- theOban.Jobthis run belongs to. Its identity (id,queue,worker,attempt,meta) rides along in both telemetry events':jobmetadata for cost attribution.ObanClaude.Workerpasses it automatically; barerun/2callers may omit it (:jobis thennil).
@spec session_id(ClaudeWrapper.Result.t() | ClaudeWrapper.Error.t() | term()) :: String.t() | nil
Read the claude session id from a run's payload, or nil if there is none.
The handle lives in two different places depending on the outcome, and this
reader hides that split so callers never touch claude_wrapper internals:
- on a success (or
is_error%Result{}), it isresult.session_id; - on a rail-stop
%Error{}(:max_turns_exceeded/:max_budget_exceeded), it iserror.reason.session_id.
Use it inside ObanClaude.Worker.handle_error/3 to enqueue a resume: job
after a rail stop. Any other error shape (an atom reason, a missing field)
yields nil.
@spec structured(ClaudeWrapper.Result.t()) :: map() | list() | nil
Return the full schema-validated structured_output from a result (a
--json-schema run), or nil when the run produced none.
Use inside ObanClaude.Worker.handle_result/2 to branch on a typed result
object. outcome/1 is the convenience for the common "outcome" key.