The agent layer is the stateful floor above the stateless ObanClaude seam:
one agent = one :gen_statem process whose conversational turns run as
ordinary ObanClaude.Worker jobs. The process never blocks on claude -- a
prompt enqueues a job and the machine parks until the worker's callbacks
report back. Provider sessions live in bounded, host-named conversation arcs,
so one agent can keep an operator conversation separate from scheduled and
task-specific work while remaining single-turn-at-a-time.
It is opt-in: nothing runs unless ObanClaude.Agent.Supervisor is in your
tree, and the core seam (ObanClaude.run/2, ObanClaude.Worker) is
untouched by it.
Experimental
The agent API may still change between minor releases while it carries this marker.
Quickstart
Add the supervisor after your Oban instance, and run the two queues:
children = [
MyApp.Repo,
{Oban, repo: MyApp.Repo, queues: [agents: 2, ticks: 1], ...},
ObanClaude.Agent.Supervisor
]Then, from anywhere:
{:ok, _pid} = ObanClaude.Agent.start_agent("a1", args: %{"model" => "haiku"})
:processing = ObanClaude.Agent.submit_prompt("a1", "reply with just: hi")
{:ok, :idle} = ObanClaude.Agent.await("a1", :idle, 120_000)
{:ok, log} = ObanClaude.Agent.history("a1")Every config key has a default; :args is any string-keyed claude args map
(build it with ObanClaude.Args.defaults/1). See ObanClaude.Agent.Instance
for the full config table.
The lifecycle
| State | Meaning | Prompts arriving here |
|---|---|---|
:idle | ready | start a turn |
:running | an Oban job is in flight | postponed until the turn ends |
:waiting_for_user | the last turn asked a question | consumed as the answer (except origin: :tick, which queues) |
:awaiting_permission | the last turn requested approval | postponed until the gate clears |
:paused | lockdown via emergency_pause/1, pause_after_turn/3, or quiesce/2 | refused (calls) or dropped-and-recorded (casts) |
A :state_timeout watchdog (:job_timeout, default 60s) guards :running
against a turn that never reports back. Every state change atomically updates
the registry -- ObanClaude.Agent.status/1 reads the state and the gated
payload (action or question) in one messageless read -- and emits
[:oban_claude, :agent, :transition] telemetry.
Directives: how a turn routes the machine
The machine routes on the turn's structured output. Give the agent a
json_schema arg with a "directive" field and tell it (via
append_system_prompt) when to use each value:
"ask_user"+"question"->:waiting_for_user"request_permission"+"action"->:awaiting_permission- anything else ->
:idle
No schema means every turn returns to :idle -- fine for plain
conversational agents.
Approvals actually unlock things
Conversational approval is only meaningful if it changes what the turn may
do. The :approved_args config (e.g. %{"permission_mode" => "accept_edits"},
or a "worktree" for isolated edits) merges over the agent's args for
approve continuations only -- normal turns run with whatever standing
permissions you configured, which can be none at all.
Approved work is also not allowed to silently die: if the approved turn
fails (a rail stop, a crash verdict) or hits the watchdog, the machine
re-gates -- back to :awaiting_permission with the same description and a
fresh action id, with {:approval_incomplete, reason} in history. Because
the failed turn's session id was captured, a re-approval resumes the
interrupted work rather than starting over.
Pause at a safe turn boundary
Hosts that enforce a spend, token, or policy rail often learn that an agent
must pause while its turn is still running. Calling emergency_pause/1 at that
point is too blunt: it deliberately drops action and question gates, and a
later asynchronous pause can race a postponed prompt through :idle.
pause_after_turn/3 synchronously installs a correlated latch inside the state
machine before the worker callback can finish the turn:
:telemetry.attach_many(
"daily-spend-rail",
[[:oban_claude, :run, :stop], [:oban_claude, :run, :exception]],
fn _event, _measurements, %{job: %{meta: %{"agent_id" => id} = job_meta}}, _config ->
case ObanClaude.Agent.pause_after_turn(id, :daily_spend, job_meta) do
:ok -> :ok
{:error, reason} -> Logger.warning("safe-boundary pause refused: #{inspect(reason)}")
end
end,
nil
)The telemetry handler must call pause_after_turn/3 inline. The worker sends
its terminal callback only after telemetry handlers return, which gives the
latch deterministic ordering. Starting another process for the call gives up
that guarantee.
Treat a returned error as a visible, fail-closed race outcome. Do not raise in the handler: telemetry detaches handlers that crash.
Pass the exact job metadata captured at enqueue time. The machine validates the
agent id, instance generation, and logical turn id atomically. A foreign,
stale, retired, or malformed identity returns a typed error and changes
nothing. Repeating the accepted source or current/latest owner identity is
idempotent; the first pause reason wins. A matching completed turn remains
correlatable while it is parked in :waiting_for_user or
:awaiting_permission, so a host that learns about the rail after the gate
opens can still install the latch without erasing the gate. A terminal turn
that already reached :idle remains retired and cannot be latched afterward.
Ordinary completion, terminal failure, and watchdog expiry now land directly
in :paused, before postponed prompts can run. A permission request or user
question remains gated. Approving or answering grants one continuation while
the latch stays active; that continuation pauses when it finishes, or remains
gated if it requests another action or question. Rejecting a latched permission
gate records the denial and goes directly to :paused.
Retries remain part of the same logical turn and keep the latch. An explicit
resume_agent/1 clears it. emergency_pause/1 remains immediate and clears the
latch together with all gate scopes.
For lifecycle changes that do not originate in turn telemetry, use
quiesce/2. It chooses the boundary atomically inside the state machine, so a
host does not need a racy status/1 read followed by a pause:
case ObanClaude.Agent.quiesce("caretaker", {:config_changed, revision}) do
:paused -> restart_with_new_config()
:armed -> wait_until_paused_then_restart()
:already_paused -> restart_with_new_config()
:draining -> wait_for_turn_then_retry_quiesce()
endAn idle agent pauses immediately. A running agent finishes its turn. A gated
agent retains its question or permission and allows the continuation before
pausing; rejecting a pending permission pauses immediately. Existing latches
are left in place, so the first reason wins. :draining is not a replacement
boundary: an emergency-paused turn still owns physical work, and the host must
retry after its terminal callback retires that turn. quiesce/2 emits the same
pause_reason and pause_action transition fields with cause: :quiesce.
An optional config_revision on start_agent/2 identifies the immutable host
configuration applied to that provider process. It is an opaque non-empty
string of at most 256 bytes. The provider does not compare or compute it:
ObanClaude.Agent.start_agent("caretaker",
args: %{"model" => "sonnet"},
config_revision: "routine-42:7"
)The applied revision appears in info/1, every job's metadata, and Agent
transition and turn-completion telemetry. It is omitted from metadata when no
revision was supplied. The host can compare the observed revision with its
desired revision, call quiesce/2, stop the paused process, and start a new
one. The provider never mutates a running config in place.
Prompts: sync, async, and options
submit_prompt/3is a call: it blocks until the machine accepts the prompt (microseconds in:idle; the whole in-flight turn if postponed) and replies:processing. Backpressure for scripts.cast_prompt/3is fire-and-forget: never blocks the caller. For LiveView handlers, schedulers, anything that must not wait.
Both take options: arc_id: "issue-651" selects an opaque conversation arc;
omission uses the backward-compatible "default" arc. session: :fresh
clears only the selected arc at delivery time. session: :fresh_fallback
records that the host deliberately recovered from a failed resume.
origin: :tick marks a scheduled delivery that must never be consumed as the
answer to a pending question.
Conversation arcs
Seed known provider handles when starting or restoring the process:
{:ok, _pid} =
ObanClaude.Agent.start_agent("caretaker",
session_arcs: %{
"operator" => persisted_operator_session,
"issue-651" => persisted_issue_session
},
max_session_arcs: 32
)
:processing =
ObanClaude.Agent.submit_prompt("caretaker", "continue the issue",
arc_id: "issue-651"
)Each job's metadata identifies arc_id, the input session_id, and its
continuation_decision / continuation_reason. info/1 returns
session_arcs, active_arc_id, and the current or most recent
continuation. A terminal resume classified as :session_not_found,
:invalid_session, :unknown_session, or :session_rejected produces a
typed outcome: :session_rejected. The host can then reconstruct a durable
handoff and deliberately submit session: :fresh_fallback; no local
transcript is selected implicitly.
Claude can branch a retained session without changing its source arc:
:processing =
ObanClaude.Agent.fork_arc(
"caretaker",
"issue-651",
"issue-651-experiment",
"try the alternate design"
)The returned child session is stored under the target arc. Least-recently
used inactive handles are evicted when max_session_arcs is reached. Arc
persistence and rotation policy remain the host application's responsibility.
If the host still has a compatible exact handle after eviction, it can supply
it with the turn so admission and enqueue happen in one state-machine event:
ObanClaude.Agent.submit_prompt("caretaker", "operator work",
arc_id: "operator",
resume_session_id: persisted_operator_session
)The handle is used only when that arc is absent from the live cache. A
different live handle returns {:error, {:session_conflict, arc_id}} without
enqueuing; the host must reconcile its durable record. This does not verify
that an evicted provider transcript still exists. A confirmed native rejection
still requires the host's explicit recovery policy. Keep gate-bound answers on
the arc that opened the gate.
Retries are one logical turn
ObanClaude.Agent.Job routes terminal-aware: {:cancel, _} or a final
{:error, _} reports job_finished/3; a retryable failure or {:snooze, _}
reports job_retrying/3, which keeps the machine in :running and re-arms
the watchdog. The default worker stays max_attempts: 1 (every retry is a
paid call); opt in with a three-line delegating worker -- see
ObanClaude.Agent.Job.
Every job carries an opaque instance generation and logical turn id in its
metadata. The instance checks both inside the state machine before changing
state, session, approval, counters, or watchdogs. Late outcomes, duplicate
callbacks, and callbacks from an earlier same-id process are retained only as
bounded diagnostics. Custom workers that delegate their result and error
callbacks to ObanClaude.Agent.Job inherit this behavior automatically.
Scheduling: a crontab entry is an agent
ObanClaude.Agent.Tick adapts Oban.Plugins.Cron (which schedules at the
worker layer) to the machine (which owns turn enqueueing): a beat delivers a
prompt through the facade, never a job behind the machine's back.
{Oban.Plugins.Cron,
crontab: [
{"0 9 * * *", ObanClaude.Agent.Tick,
args: %{
"agent_id" => "standup",
"arc_id" => "daily-sweep",
"prompt" => "Summarize overnight CI failures.",
"session" => "fresh",
"if_offline" => "start",
"start" => %{
"args" => %{"model" => "sonnet"},
"config_revision" => "routine-42:7"
}
}}
]}Policies per tick: if_busy ("skip" default / "queue"), if_offline
("skip" default / "start" -- the crontab becomes the agent's spec, so
restarts self-heal at the next beat), session ("resume" default /
"fresh", the routine-friendly choice). Ticks run on their own queue
(:ticks): on a queue shared with agent turns a tick serializes behind the
very turn it should observe as busy, and skip-policy can never fire.
A host can serialize Tick delivery with its own configuration boundary by
setting config :oban_claude, tick_admission: MyApp.TickAdmission. The module
must export admit/4. It receives :claude, the agent id, the optional
"start.config_revision", and a zero-arity function containing the complete
status check, offline start, and prompt cast. It must either invoke and return
that function's result or return another valid Oban.Worker result. Without
this configuration, Tick delivery is unchanged.
Observing a fleet
status/1-- one atomic registry read: the state, plus the pending action/question in the gated states. Never messages the process.await/3-- block until the agent settles into given states; returns the full status payload. Registry-polling.list/0-- every running agent as{id, status}, off the registry.info/1-- appliedconfig_revision, turn count, accumulated cost, defaultsession_id, all retainedsession_arcs, current/recent continuation, pending gates, and the currentdeferred_pauselatch. While paused,pause_contextretains the cause, reason, action, and any correlated turn identity that applied the pause (a call; in-process, so the host seeds persisted arcs after restart).history/1-- the bounded event log (:max_history, default 500). A safe boundary latch records{:pause_after_turn, reason}when accepted and{:paused_after_turn, reason}when it is applied. A host boundary records{:quiesce, reason}when armed or{:quiesced, reason}when an idle agent pauses immediately.- Telemetry:
[:oban_claude, :agent, :transition]with%{agent_id, from, to}, and[:oban_claude, :agent, :turn_completed]with the arc, session, continuation decision, and typed outcome. Pass an opaquecorrelation_idtosubmit_prompt/3orcast_prompt/3to carry an application request identity through postponed delivery, job metadata, turn transitions, approval continuations, and completion. Turn events also expose the wrapper-ownedagent_generationandagent_turn_id; when set,config_revisionappears on every transition and completion. Deferred pause transitions addcause(:pause_after_turnfor a correlated latch,:quiescefor a host boundary,:emergency_pausefor the immediate brake),pause_reason, andpause_action(:continued,:applied, or:cleared). Gate transitions also addgate_outcome(:opened,:approved,:answered,:incomplete, or:rejected) and the relevantaction_idorquestion.
Testing without a queue or claude
The :enqueue_fun config replaces the Oban insert. Tests capture the metadata
passed to that function and return it through job_finished/3, so they drive
the whole machine with no DB and no claude while preserving turn ownership:
test_pid = self()
{:ok, _} =
ObanClaude.Agent.start_agent("t1",
enqueue_fun: fn args, meta ->
send(test_pid, {:enqueued, args, meta})
{:ok, :queued}
end
)
:processing = ObanClaude.Agent.submit_prompt("t1", "go")
assert_receive {:enqueued, %{"prompt" => "go"}, %{"agent_id" => "t1"} = meta}
:ok =
ObanClaude.Agent.job_finished(
"t1",
{:ok, ObanClaude.Testing.result("done")},
meta
)
{:ok, :idle} = ObanClaude.Agent.await("t1", :idle, 1_000)Build payloads with ObanClaude.Testing (result/1, structured_result/2
for directives, error/2 for failures).
What it deliberately does not do
The machine owns lifecycle, not operations. Durable spend ledgers and budgets, persisted journals/todos, gate records that survive restarts, notifications, dashboards, and MCP surfaces for agents-driving-agents are all application concerns -- they compose cleanly on top of the telemetry and the facade (each was built and live-proven in a demo app during this layer's incubation), and they carry dependencies this library should not.
Early native session observations
Early observations require a runner implementing the wrapper's
run_observed/5 contract, such as its Forcola runner. The default Port runner
continues using legacy one-shot execution and retains terminal session handles.
Execution ownership and terminal fencing apply with either runner.
The default worker registers an execution before validating its arguments or
calling the wrapper. execution_started identifies an accepted worker attempt;
it does not claim that the CLI spawned. Invalid arguments therefore complete
the same accepted attempt with the existing cancellation behavior. The
Agent checks the job id, process generation, logical turn, arc, attempt and
Oban snooze counter, then supplies a local PID/reference observer. That
observer never enters persisted job arguments or metadata. Custom query_fun/2
functions retain their existing keyword contract; they can still complete
through the normal worker callbacks without producing early observations.
The owning Agent emits [:oban_claude, :agent, :execution_started] before
[:oban_claude, :agent, :session_observed]. Both events and the corresponding
turn_completed event carry agent_id, agent_generation, agent_turn_id,
arc_id, job_id, job_attempt, job_snoozed, and execution_state: :started.
Configuration revision, correlation and continuation decision metadata remain
available. Session observations add session_id and source (:system_init).
These events are emitted synchronously by the owning Agent, in acceptance
order. A host can persist the started tuple and accept only matching later
events without calling back into the Agent from a telemetry handler.
ObanClaude.Agent.info/1 exposes the registered execution tuple in continuation and
sets its session_id only after that execution's accepted observation. A
first accepted handle wins; duplicate or conflicting observations and old
references are ignored. Retrying closes the old observer and a new attempt
must advance the attempt plus snooze watermark. Late terminal callbacks are
also fenced by the captured job tuple and worker process.
Observation is evidence that the CLI created or resumed a session, not that
work completed. Failure, cancellation, pause and watchdog expiry retain the
observed handle. An explicit typed session rejection clears the rejected arc;
completion includes rejected_arc_id and rejected_session_id. A rejected
fork source cannot erase an unrelated target, and only an observed new child
handle can replace the target on an interrupted fork.
A failed enqueue or a watchdog timeout before any worker registered emits
execution_state: :not_started with no job tuple.
Legacy/custom callbacks without registered execution omit all new execution
fields. Tick admission, approval gates and deliberate fresh-session requests
are unchanged. Durable storage, configuration compatibility and restart
recovery remain the host application's responsibility.