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/2. 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)Requires ObanClaude.Agent.Supervisor in the host supervision tree.
Summary
Types
The caller-chosen agent identity, unique per running agent.
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.
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: :state, :session_id, :turns,
accumulated :cost_usd, and any :pending_action / :pending_question.
A call into the process (unlike status/1); works in every state.
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.
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.
Reject the pending action: the denial is recorded and the agent returns to :idle.
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 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.
@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.
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: :state, :session_id, :turns,
accumulated :cost_usd, and any :pending_action / :pending_question.
A call into the process (unlike status/1); works in every state.
@spec job_finished( agent_id(), {:ok, ClaudeWrapper.Result.t()} | {:error, term(), term()} ) :: :ok | {:error, :agent_not_running}
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 agent_id it read off the job's meta. 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.
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/2.
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.
Reject the pending action: the denial is recorded and the agent returns to :idle.
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). Use:freshto stop a long-lived agent's context from growing without bound.: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.