A behaviour and supervision framework for long-running LLM agent processes, modeled as OTP state machines.
Each agent is a :gen_statem process wrapping a persistent LLM session.
Every interaction is a prompt-response turn, and the implementation decides
what happens between turns.
It is a GenServer but every call is a prompt.
GenAgent handles the mechanics of turns. Implementations handle the semantics of turns.
Installation
def deps do
[
{:gen_agent, "~> 0.1.0"},
# Plus at least one backend:
{:gen_agent_claude, "~> 0.1.0"},
{:gen_agent_codex, "~> 0.1.0"}
]
endQuick start
defmodule MyApp.Coder do
use GenAgent
defmodule State do
defstruct [:path, responses: []]
end
@impl true
def init_agent(opts) do
path = Keyword.fetch!(opts, :cwd)
backend_opts = [
cwd: path,
system_prompt: "You are a coding assistant."
]
{:ok, backend_opts, %State{path: path}}
end
@impl true
def handle_response(_ref, response, state) do
{:noreply, %{state | responses: state.responses ++ [response.text]}}
end
end
# Start the agent under the GenAgent supervision tree.
{:ok, _pid} = GenAgent.start_agent(MyApp.Coder,
name: "my-coder",
backend: GenAgent.Backends.Claude,
cwd: "/path/to/project"
)
# Synchronous prompt.
{:ok, response} = GenAgent.ask("my-coder", "What does lib/foo.ex do?")
IO.puts(response.text)
# Async prompt.
{:ok, ref} = GenAgent.tell("my-coder", "Add tests for lib/foo.ex")
{:ok, :completed, response} = GenAgent.poll("my-coder", ref)
# External event.
GenAgent.notify("my-coder", {:ci_failed, "test_auth"})
GenAgent.stop("my-coder")State model
An agent is a state machine with two states:
idle --- ask/tell/notify ---> processing
|
v
idle <--- handle_response --- processing (turn done)Self-chaining: handle_response/3 can return {:prompt, text, state}
to immediately dispatch another turn without a caller, useful for
multi-step work that the agent drives itself.
Halting: any callback can return {:halt, state} to go idle but freeze
the mailbox. A halted agent ignores queued prompts until resume/1 is
called.
Backends
Backends implement GenAgent.Backend and translate the LLM-specific wire
protocol into the normalized GenAgent.Event stream the state machine
consumes. Available backends (in sibling packages):
GenAgent.Backends.Claude(package:gen_agent_claude) -- wraps the AnthropicclaudeCLI viaClaudeWrapper.GenAgent.Backends.Codex(package:gen_agent_codex) -- wraps the OpenAIcodexCLI viaCodexWrapper.
A backend owns its session lifecycle, translates events, and carries any state it needs (session id, message history) in an opaque session term.
Callbacks
init_agent/1-- set up backend options and initial agent state.handle_response/3-- a turn completed, decide what to do next.handle_error/3(optional) -- a turn failed, decide what to do next.handle_event/2(optional) -- an external event arrived vianotify/2.handle_stream_event/2(optional) -- a backend event arrived mid-turn. Runs inside the prompt task, not the agent process.terminate_agent/2(optional) -- the agent is shutting down.
Lifecycle hooks (all optional):
pre_run/1-- one-time setup afterinit_agent, before the first turn.pre_turn/2-- before each dispatch. Can rewrite the prompt, skip, or halt.post_turn/3-- after each turn, post-decision. For state-mutating side effects.post_run/1-- on clean{:halt, state}from any callback. For completion side effects.
The use GenAgent macro provides default implementations of the optional
callbacks and lifecycle hooks.
Public API
start_agent/2-- start an agent under the supervision tree.ask/3-- synchronous prompt, blocks until the turn finishes.tell/3-- async prompt, returns a ref forpoll/3.poll/3-- check on a previously-issuedtell/3.notify/2-- push an external event intohandle_event/2.interrupt/1-- cancel an in-flight turn.resume/1-- unhalt an agent and drain its mailbox.status/2-- read the agent's current state.stop/1-- terminate the agent.whereis/1-- look up an agent's pid.
Data types
GenAgent.Event-- a normalized event emitted by a backend during a turn.GenAgent.Response-- the result of a completed turn delivered tohandle_response/3.
Telemetry
GenAgent emits telemetry events for observability:
[:gen_agent, :prompt, :start | :stop | :error][:gen_agent, :event, :received][:gen_agent, :state, :changed][:gen_agent, :mailbox, :queued][:gen_agent, :halted]
What GenAgent does not do
- It does not prescribe agent behavior (no retry logic, no summary format).
- It does not prescribe inter-agent communication (agents can
notify/2each other but the message format is up to you). - It does not manage persistence across restarts.
- It does not manage cost tracking or budgets.
See GenAgent.Backend for the backend behaviour, GenAgent.Event and
GenAgent.Response for the data types delivered to callbacks.
Summary
Types
Opaque term owned by the implementation module, carried across callbacks.
Return value of callbacks that may request a follow-up action.
Name under which an agent is registered in GenAgent.Registry.
Return value of pre_turn/2. The hook can pass the prompt through
(optionally rewritten), skip the turn, or halt the agent.
Reference returned for tell/2 requests.
Callbacks
A prompt->response turn failed. Optional. Decide what to do next.
An external event arrived via notify/2. Optional.
A prompt->response turn completed successfully. Decide what to do next.
A streaming event arrived mid-turn. Optional.
Initialize the agent. Return backend options and the initial agent state.
Clean-completion hook. Optional.
Per-turn post-dispatch hook. Optional.
One-time setup hook, fires after init_agent/1 and before the
first turn. Optional.
Per-turn pre-dispatch hook. Optional.
The agent is shutting down. Optional. Clean up resources.
Functions
Send a synchronous prompt to an agent.
Interrupt an in-flight turn.
Push an external event into the agent.
Check the status of a previously-issued tell/2 request.
Resume a halted agent.
Start an agent under the GenAgent supervision tree.
Read an agent's current status.
Stop an agent.
Send an asynchronous prompt to an agent.
Look up the pid of a registered agent, or nil if not found.
Types
@type agent_state() :: term()
Opaque term owned by the implementation module, carried across callbacks.
@type callback_return() :: {:noreply, agent_state()} | {:prompt, String.t(), agent_state()} | {:halt, agent_state()}
Return value of callbacks that may request a follow-up action.
@type name() :: term()
Name under which an agent is registered in GenAgent.Registry.
@type pre_turn_return() :: {:ok, prompt :: String.t(), agent_state()} | {:skip, agent_state()} | {:halt, agent_state()}
Return value of pre_turn/2. The hook can pass the prompt through
(optionally rewritten), skip the turn, or halt the agent.
@type request_ref() :: reference()
Reference returned for tell/2 requests.
Callbacks
@callback handle_error( request_ref :: reference(), reason :: term(), agent_state() ) :: callback_return()
A prompt->response turn failed. Optional. Decide what to do next.
Called when the turn could not complete successfully. Covers:
- The backend returned a synchronous
{:error, reason}fromGenAgent.Backend.prompt/2. - The event stream ended without a terminal
:resultor:errorevent. - The backend's event stream emitted a terminal
:errorevent. - The prompt task crashed (delivered as
{:task_crashed, reason}). - The watchdog fired (
:timeout). - The in-flight request was interrupted by
interrupt/1(:interrupted).
Returns the same value shape as handle_response/3, so the callback
can go idle, self-chain a follow-up prompt (useful for retry), or halt
the agent. The default implementation provided by use GenAgent is
{:noreply, state}.
@callback handle_event(event :: term(), agent_state()) :: callback_return()
An external event arrived via notify/2. Optional.
@callback handle_response( request_ref :: reference(), response :: GenAgent.Response.t(), agent_state() ) :: callback_return()
A prompt->response turn completed successfully. Decide what to do next.
@callback handle_stream_event(GenAgent.Event.t(), agent_state()) :: agent_state()
A streaming event arrived mid-turn. Optional.
Runs inside the task that is driving the prompt, not the agent process.
Returns the updated agent state, which is threaded through subsequent
stream events and then into handle_response/3.
@callback init_agent(opts :: keyword()) :: {:ok, backend_opts :: keyword(), agent_state()} | {:error, reason :: term()}
Initialize the agent. Return backend options and the initial agent state.
opts is the keyword list passed to start_agent/2 minus the reserved
keys consumed by GenAgent itself (:name, :backend, etc.).
@callback post_run(agent_state()) :: :ok
Clean-completion hook. Optional.
Fires when any callback (handle_response/3, handle_error/3,
handle_event/2, pre_turn/2, post_turn/3) returns
{:halt, state}. Runs before the agent is marked halted and before
the [:gen_agent, :halted] telemetry event is emitted.
Does NOT fire on crashes, stop/1, supervisor shutdown, or any
abnormal exit -- terminate_agent/2 covers those paths.
Use cases: create a PR, post a completion summary, mark a task done
in an external tracker. The semantic distinction from
terminate_agent/2 is "completion" vs "termination."
Crashes are caught: a warning is logged and the halt transition still completes normally. A failing last-chance hook does not keep a dead agent alive.
Default implementation: :ok.
@callback post_turn( outcome :: {:ok, GenAgent.Response.t()} | {:error, reason :: term()}, request_ref :: reference(), agent_state() ) :: {:ok, agent_state()}
Per-turn post-dispatch hook. Optional.
Fires after each turn, AFTER handle_response/3 or
handle_error/3 has returned its decision. The hook sees the
post-decision state. Runs regardless of which decision callback ran
or what it returned.
The outcome is {:ok, response} for a successful turn or
{:error, reason} for a failed one -- the same data delivered to
the decision callbacks. The hook cannot override the decision
callback's transition ({:noreply, ...}, {:prompt, ...},
{:halt, ...}); it only updates state.
Use cases: commit-per-turn (stateful side effect), persist a turn
record, update a per-turn metric that needs to live on agent state.
For pure observation, prefer telemetry handlers on
[:gen_agent, :prompt, :stop].
Crashes are caught: a warning is logged and the server continues with the transition the decision callback chose. The turn is not unwound.
Default implementation: {:ok, state}.
@callback pre_run(agent_state()) :: {:ok, agent_state()} | {:error, reason :: term()}
One-time setup hook, fires after init_agent/1 and before the
first turn. Optional.
Runs in the agent process, so it blocks the first turn until it
returns -- but does NOT block start_agent/2 from returning to the
caller. This is the right home for slow async setup that would
otherwise freeze the starter: cloning a repo, creating a worktree,
spinning up a sandbox, fetching secrets.
Return {:ok, state} to continue, or {:error, reason} to halt the
agent before any turn runs. On error, terminate_agent/2 is called
with {:pre_run_failed, reason}.
Crashes are wrapped: the agent halts with
{:pre_run_crashed, exception} and terminate_agent/2 is called
with that reason.
Default implementation: {:ok, state}.
@callback pre_turn(prompt :: String.t(), agent_state()) :: pre_turn_return()
Per-turn pre-dispatch hook. Optional.
Fires before each prompt is dispatched to the backend, inside the
agent process. Can observe, mutate state, rewrite the prompt (for
augmentation or templating), skip the turn with :skip, or halt the
agent entirely with :halt.
Use cases: prompt templating (inject context), rate limiting (sleep on a budget), gating (halt if an external signal says stop).
When the prompt is rewritten, [:gen_agent, :prompt, :start]
telemetry carries both the original and rewritten prompt plus a
rewritten: true flag so the transformation is traceable.
Crashes are caught: the turn is skipped, a warning is logged, and
the agent returns to :idle. Users who want strict crash semantics
can re-raise from inside a different callback.
Default implementation: {:ok, prompt, state}.
@callback terminate_agent(reason :: term(), agent_state()) :: term()
The agent is shutting down. Optional. Clean up resources.
Functions
@spec ask(name(), String.t(), timeout()) :: {:ok, GenAgent.Response.t()} | {:error, term()}
Send a synchronous prompt to an agent.
Blocks until the turn completes and returns {:ok, response} or
{:error, reason}. If the agent is currently processing another
prompt, the caller is queued transparently and unblocks when its
queued turn finishes.
The default timeout is :infinity. The agent's own watchdog is the
primary timeout mechanism -- callers generally should not need to set
their own. Supplying a shorter timeout here will raise on expiry
without affecting the agent.
@spec interrupt(name()) :: :ok
Interrupt an in-flight turn.
Kills the prompt task and delivers {:error, :interrupted} to the
waiting caller (if any). No-op if the agent is idle.
Asynchronous. Returns :ok immediately.
Push an external event into the agent.
The event is delivered to handle_event/2. If the callback
returns {:prompt, text, state} the prompt is dispatched (or
queued, if the agent is busy).
Asynchronous. Returns :ok immediately.
@spec poll(name(), request_ref(), timeout()) :: {:ok, :pending} | {:ok, :completed, GenAgent.Response.t()} | {:error, term()}
Check the status of a previously-issued tell/2 request.
Returns:
{:ok, :pending}if the request is queued or in-flight.{:ok, :completed, response}if the turn finished successfully.{:error, reason}if the turn failed.{:error, :not_found}if the ref is unknown (never issued, or pruned from the bounded result cache).
Only refs returned from tell/2 are pollable. Refs from ask/2 are
internal and reply directly to the caller.
@spec resume(name()) :: :ok
Resume a halted agent.
Clears the halted flag and re-drains the mailbox. No-op if the
agent is not halted.
Asynchronous. Returns :ok immediately.
@spec start_agent( module(), keyword() ) :: DynamicSupervisor.on_start_child()
Start an agent under the GenAgent supervision tree.
module is the implementation module (the one that use GenAgent).
opts must include:
:name-- the name the agent will register under inGenAgent.Registry.:backend-- the backend module implementingGenAgent.Backend.
Any other option is forwarded to init_agent/1. GenAgent-level
knobs (like :watchdog_ms) are recognized and stripped before
forwarding.
@spec status(name(), timeout()) :: %{ state: :idle | :processing, name: term(), queued: non_neg_integer(), current_request: request_ref() | nil, halted: boolean(), agent_state: term() }
Read an agent's current status.
@spec stop(name()) :: :ok | {:error, :not_found}
Stop an agent.
Terminates the agent process cleanly via its DynamicSupervisor.
Returns :ok or {:error, :not_found}.
@spec tell(name(), String.t(), timeout()) :: {:ok, request_ref()}
Send an asynchronous prompt to an agent.
Returns {:ok, ref} immediately. Use poll/2 to check on the
result. The same queueing semantics as ask/2 apply.
Look up the pid of a registered agent, or nil if not found.