A facade over long-lived agent processes whose turns run as Oban jobs.
Experimental
The agent layer is the stateful floor above the stateless ObanClaude
seam. It is opt-in (nothing runs unless ObanClaude.Agent.Supervisor is
in your tree) and experimental: the API may change between minor
releases while it carries this marker. See the
Agent lifecycle guide.
One agent is one ObanClaude.Agent.Instance (:gen_statem) registered
under a caller-chosen id. A prompt does not block on claude: it enqueues an
ObanClaude.Worker job and the state machine parks in :running until the
worker reports back through job_finished/3. All interaction goes through
this module; nothing here messages a process that is not running.
{:ok, _pid} = ObanClaude.Agent.start_agent("triage-7",
args: ObanClaude.Args.defaults(model: "sonnet"))
:processing = ObanClaude.Agent.submit_prompt("triage-7", "triage the new issues")
{:ok, :running} = ObanClaude.Agent.status("triage-7")
{:ok, {:awaiting_permission, %{id: id}}} =
ObanClaude.Agent.await("triage-7", [:idle, :awaiting_permission, :waiting_for_user])
:processing = ObanClaude.Agent.approve_action("triage-7", id)Action ids are opaque strings: compare them for equality only, and do not parse them or assume a format. They are unique across VM restarts.
Requires ObanClaude.Agent.Supervisor in the host supervision tree.
Summary
Types
The caller-chosen agent identity, unique per running agent.
Why a deferred pause request was not accepted.
A deferred pause and the turn that first requested it.
Why the host requested a pause at the current turn's safe boundary.
What status/1 returns: a bare state atom, except the two gated states,
which atomically carry what they are gated on (the action map holds the
:id that approve_action/2 / reject_action/3 take). :offline when the
agent is not running.
Functions
Block until the agent settles into one of states (a state atom or list of
them), returning the full status/0 it landed on -- so a gated state
arrives with its payload
The fire-and-forget form of submit_prompt/3: never blocks the caller.
Asynchronously force the agent into :paused lockdown, from any state. Drops any pending action or question.
Synchronously force the agent into :paused and acknowledge retained pause provenance.
Fork one named conversation arc into another and run prompt on the fork.
The agent's event log, oldest first. Works in every state. Result entries
are {:result, structured_output_map} for --json-schema turns and
{:result, text} otherwise.
The agent's bookkeeping in one map. :session_id remains the legacy default
arc handle; :session_arcs, :active_arc_id, and :continuation expose the
named-arc state and the current or most recent fresh/resume decision. Also
includes :state, :turns, accumulated :cost_usd, the applied opaque
:config_revision, any pending gate, the active :deferred_pause latch,
and the applied :pause_context while the agent is paused.
The return path for workers: report a finished turn back to its agent.
The retry half of the return path: report a failed-but-retryable attempt.
Register one executing job attempt and return its local session observer.
All running agents as {agent_id, status} pairs, straight off the registry
(no process is messaged). Order is unspecified. The fleet-inventory read for
dashboards and supervisors.
Synchronously latch a pause for the current logical turn.
Synchronously move an agent toward a host-requested safe boundary.
Reject the pending action and record the denial.
Release a :paused agent back to :idle.
Spawn a new agent under the dynamic supervisor.
Read the agent's lifecycle status from registry metadata, without messaging the process: one atomic read of the state and, in the gated states, the pending action or question it is gated on -- so there is no torn status-then-payload sequence.
Cleanly stop a running agent (:transient, so it is not restarted).
Send a prompt to a running agent. Synchronous: replies :processing once the
turn's Oban job is enqueued.
Types
@type agent_id() :: term()
The caller-chosen agent identity, unique per running agent.
@type pause_after_turn_error() :: :agent_not_running | :agent_id_mismatch | :malformed_identity | :foreign_generation | :retired_turn | :stale_turn | {:invalid_state, atom()}
Why a deferred pause request was not accepted.
@type pause_latch() :: %{ cause: :pause_after_turn | :quiesce, reason: pause_reason(), source_generation: String.t(), source_turn_id: String.t(), owner_generation: String.t(), owner_turn_id: String.t(), owner_arc_id: String.t(), owner_correlation_id: String.t() | nil }
A deferred pause and the turn that first requested it.
@type pause_reason() :: term()
Why the host requested a pause at the current turn's safe boundary.
@type status() :: :idle | :running | :paused | :offline | {:awaiting_permission, %{id: String.t(), description: String.t()}} | {:waiting_for_user, String.t() | nil}
What status/1 returns: a bare state atom, except the two gated states,
which atomically carry what they are gated on (the action map holds the
:id that approve_action/2 / reject_action/3 take). :offline when the
agent is not running.
Functions
Approve the pending action by id (read it off status/1 or await/3).
The continuation turn resumes the claude session with the approved action as
its prompt, and carries the agent's :approved_args (e.g. a
permission_mode elevation) merged over the default args -- so approval
actually unlocks the tools the action needs, on that turn only.
Options
:args-- a string-keyed map of claude args merged over:approved_argsfor THIS continuation only. The caller knows what was approved, so it can size the elevation to it: an approval to comment on a pull request needs no shell, whatever the agent's standing:approved_argssay. Nothing is remembered; if the approved turn fails and re-gates, the re-approval supplies its own. Non-string keys are refused with{:error, {:invalid_args, keys}}and the action stays pending.
@spec await(agent_id(), atom() | [atom()], timeout :: pos_integer()) :: {:ok, status()} | {:error, :timeout}
Block until the agent settles into one of states (a state atom or list of
them), returning the full status/0 it landed on -- so a gated state
arrives with its payload:
case ObanClaude.Agent.await("live", [:idle, :awaiting_permission], 300_000) do
{:ok, {:awaiting_permission, %{id: id}}} -> ObanClaude.Agent.approve_action("live", id)
{:ok, :idle} -> :done
endPolls the registry (25ms), so it never messages the agent. :offline is
awaitable (e.g. after stop_agent/1). Returns {:error, :timeout} when the
deadline passes first.
The fire-and-forget form of submit_prompt/3: never blocks the caller.
Returns :ok as soon as the cast is sent -- there is no :processing
acknowledgment and no error reply on a failed enqueue (that lands in
history/1 as {:enqueue_failed, reason}). State handling matches
submit_prompt/3 -- queued in :running / :awaiting_permission, the
answer in :waiting_for_user -- except :paused, where the prompt is
dropped (recorded as {:dropped_prompt, text}) since lockdown has no caller
to refuse. Use this from callers that must not block on a busy agent (a
LiveView event handler, a scheduler); use submit_prompt/3 when you want
backpressure and the enqueue acknowledgment. Takes the same options.
@spec emergency_pause(agent_id()) :: :ok | {:error, :agent_not_running}
Asynchronously force the agent into :paused lockdown, from any state. Drops any pending action or question.
Synchronously force the agent into :paused and acknowledge retained pause provenance.
@spec fork_arc(agent_id(), String.t(), String.t(), String.t(), keyword()) :: :processing | {:error, term()}
Fork one named conversation arc into another and run prompt on the fork.
Claude supports this through --fork-session. The source arc must already
have a retained or host-seeded session. The fork's returned session is stored
under target_arc_id; the source remains unchanged.
The agent's event log, oldest first. Works in every state. Result entries
are {:result, structured_output_map} for --json-schema turns and
{:result, text} otherwise.
The agent's bookkeeping in one map. :session_id remains the legacy default
arc handle; :session_arcs, :active_arc_id, and :continuation expose the
named-arc state and the current or most recent fresh/resume decision. Also
includes :state, :turns, accumulated :cost_usd, the applied opaque
:config_revision, any pending gate, the active :deferred_pause latch,
and the applied :pause_context while the agent is paused.
@spec job_finished( agent_id(), {:ok, ClaudeWrapper.Result.t()} | {:error, term(), term()}, map() ) :: :ok | {:error, :agent_not_running | :turn_identity_required}
The return path for workers: report a finished turn back to its agent.
ObanClaude.Agent.Job calls this from handle_result/2 /
handle_error/3 with the complete metadata captured by the job. Payload
shapes: {:ok, %ClaudeWrapper.Result{}} on success, or
{:error, oban_return, payload} for a failed turn. Fire-and-forget: if the
agent is gone the outcome is dropped.
@spec job_retrying(agent_id(), map(), map()) :: :ok | {:error, :agent_not_running | :turn_identity_required}
The retry half of the return path: report a failed-but-retryable attempt.
ObanClaude.Agent.Job's error callback calls this when Oban will re-run
the job (an {:error, _} with attempts remaining, or a {:snooze, _}). The
machine stays in :running -- the logical turn is still in flight -- records
{:retrying, retry} in history, and re-arms the :job_timeout watchdog to
cover the retry's backoff plus execution. retry is
%{attempt:, max_attempts:, verdict:}. Fire-and-forget, like
job_finished/3.
@spec job_started(agent_id(), Oban.Job.t()) :: {:ok, {pid(), reference()}} | {:error, term()}
Register one executing job attempt and return its local session observer.
The owning Agent validates the persisted job id, generation, logical turn, arc, attempt and snooze counter. The returned PID/reference stays local to this execution and must never be persisted in job args or metadata.
All running agents as {agent_id, status} pairs, straight off the registry
(no process is messaged). Order is unspecified. The fleet-inventory read for
dashboards and supervisors.
@spec pause_after_turn(agent_id(), pause_reason(), map()) :: :ok | {:error, pause_after_turn_error()}
Synchronously latch a pause for the current logical turn.
captured_meta is the exact job metadata emitted when that turn was
enqueued. The instance validates its agent id, generation, and turn id
atomically before accepting the latch. Matching repeats are idempotent, even
after the turn reaches a gate or the paused state. The first accepted reason
wins; repeats do not replace it. Invalid metadata returns the precise
pause_after_turn_error/0 without changing the agent.
A normal terminal outcome and a watchdog timeout land directly in
:paused, before postponed prompts can run. An ask_user or
request_permission directive remains gated; answering or approving grants
one continuation while retaining the latch. Explicit resume_agent/1 and
emergency_pause/1 clear it.
@spec quiesce(agent_id(), pause_reason()) :: :paused | :armed | :already_paused | :draining | {:error, term()}
Synchronously move an agent toward a host-requested safe boundary.
An idle agent pauses immediately. A running agent finishes its current turn; an agent parked on a question or permission keeps that gate and allows its continuation. In both cases the agent pauses at the next terminal boundary, and rejecting a pending permission pauses immediately. An existing latch is retained, so the first reason wins.
Returns :paused when the boundary was immediate, :armed when a deferred
pause is active, or :already_paused when the agent was already paused at a
safe boundary. :draining means the lifecycle is paused but a turn retained
by an emergency pause has not reported its terminal outcome yet; the caller
must not replace the process and should retry quiesce/2 after the turn
drains. Unlike pause_after_turn/3, this host operation needs no captured
turn identity because the state machine selects its current boundary
atomically.
Reject the pending action and record the denial.
The agent returns to :idle, unless a pause_after_turn/3 latch is active;
then rejection applies the latch and returns the agent to :paused.
Release a :paused agent back to :idle.
Spawn a new agent under the dynamic supervisor.
Starting an id that is already running returns the existing pid, so the call
is idempotent. See ObanClaude.Agent.Instance for the config keys.
Read the agent's lifecycle status from registry metadata, without messaging the process: one atomic read of the state and, in the gated states, the pending action or question it is gated on -- so there is no torn status-then-payload sequence.
{:ok, :running} = status("live")
{:ok, {:awaiting_permission, %{id: id, description: d}}} = status("live")
{:ok, {:waiting_for_user, question}} = status("live")An unknown or stopped agent reads as {:ok, :offline}. Registry cleanup
after a process death is asynchronous, so :offline after stop_agent/1
is eventually-consistent -- await(id, :offline) if you need to block on it.
@spec stop_agent(agent_id()) :: :ok | {:error, :agent_not_running}
Cleanly stop a running agent (:transient, so it is not restarted).
Send a prompt to a running agent. Synchronous: replies :processing once the
turn's Oban job is enqueued.
In :running and :awaiting_permission the call blocks (the machine
postpones it) until the turn finishes / the gate clears; in
:waiting_for_user the prompt is the answer to the pending question and
resumes the claude session.
Options (shared with cast_prompt/3):
:session--:resume(default) continues the agent's claude session;:freshstarts a new one for this turn (the resume handle is cleared at delivery time, so it composes with queued prompts);:fresh_fallbackrecords that the host deliberately started fresh after a failed resume.:arc_id-- an opaque non-empty string naming the provider conversation; defaults to"default". Arcs isolate their session handles while the agent remains single-turn-at-a-time.:resume_session_id-- an exact host-retained handle for:resumewhen this arc has been evicted from the bounded cache. It is admitted at turn delivery, not call time. A different live handle for the arc is refused rather than overwritten. Do not use for a fresh turn or a fork.:origin--:operator(default) or:tick. A:tickprompt is a scheduled delivery: in:waiting_for_userit queues behind the pending question instead of being consumed as the answer.ObanClaude.Agent.Ticksets this; operators rarely should.:correlation_id-- an optional opaque application-owned string carried through postponed delivery, Oban job metadata, and Agent lifecycle telemetry. The wrapper preserves it but does not interpret it.