GenAgent

CI Hex.pm Docs

A behaviour and supervision framework for long-running LLM agent processes, modeled as OTP state machines.

This repository contains six independently versioned Hex packages. Install only the packages an application needs; core has no dependency on a CLI wrapper or HTTP client.

Package Source Purpose
gen_agent . Agent behaviour, lifecycle, and supervision
gen_agent_claude integrations/claude Claude CLI backend
gen_agent_codex integrations/codex Codex CLI backend
gen_agent_anthropic integrations/anthropic Anthropic HTTP backend
gen_agent_openai integrations/openai OpenAI HTTP backend
gen_agent_ensemble extensions/ensemble Multi-agent strategies

The claude_wrapper and codex_wrapper repositories remain independent.

If you are deciding between calling a wrapper directly, running a GenAgent, or enqueuing an Oban job, see Choosing a layer.

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.8.0"} # x-release-please-version
  ]
end

Add at least one backend using its own installation guide: Claude, Codex, Anthropic, or OpenAI.

Quick start

Define an implementation module by using the GenAgent behaviour:

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 supervision tree and interact with it by name:

{: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")
GenAgent.poll("my-coder", ref)
#=> {:ok, :pending} while the turn runs, then {:ok, :completed, response}

# Or have the outcome sent to you as a message.
{:ok, ref} = GenAgent.tell_with_completion("my-coder", "Run the tests")

receive do
  {:gen_agent, :completion, "my-coder", ^ref, {:ok, response}} -> IO.puts(response.text)
  {:gen_agent, :completion, "my-coder", ^ref, {:error, reason}} -> IO.inspect(reason)
end

# Push an external event into handle_event/2.
GenAgent.notify("my-coder", {:ci_failed, "test_auth"})

GenAgent.stop("my-coder")

One interface for Claude and Codex

Use the same callback module and GenAgent API for both providers. Select the backend when starting each agent:

gen_agent owns the agent process, prompt tasks, callbacks, and queue. gen_agent_claude and gen_agent_codex implement the backend contract, translate provider events, and retain each provider's session identifier. Those backends call claude_wrapper and codex_wrapper, respectively; the wrappers build CLI arguments, parse output, and select a runner for the external process. Add both backend packages to one application when agents need both CLIs. Select a backend per agent, then use the same GenAgent.ask/3, tell/3, poll/3, and interruption APIs for either.

defmodule MyApp.Assistant do
  use GenAgent

  @impl true
  def init_agent(opts) do
    {:ok, [cwd: Keyword.fetch!(opts, :cwd)], %{}}
  end

  @impl true
  def handle_response(_ref, _response, state), do: {:noreply, state}
end

for {name, backend} <- [
      {"claude", GenAgent.Backends.Claude},
      {"codex", GenAgent.Backends.Codex}
    ] do
  {:ok, _pid} = GenAgent.start_agent(MyApp.Assistant,
    name: name, backend: backend, cwd: "/path/to/project"
  )
end

{:ok, response} = GenAgent.ask("claude", "Explain this project")
{:ok, ref} = GenAgent.tell("codex", "Explain this project")
GenAgent.poll("codex", ref) # {:ok, :pending} while queued or running

Either agent supports ask, tell, and poll; the interaction style is independent of the provider. Install both backend packages and configure their CLIs before starting them. Backend-specific options and capabilities can differ: return the appropriate options from init_agent/1 when adding model settings, instructions, or permissions. Each agent keeps its own provider session.

For a runnable OTP application that starts Claude and Codex behind one entry point, see the local GenAgent app example.

State model

An agent is a state machine with two states:

idle --- ask/tell ---------------------------> processing
idle --- notify -> handle_event/2 -> {:prompt, ..} -> processing
idle --- notify -> handle_event/2 -> {:noreply, ..} -> idle (no turn)
                                  |
                                  v
idle <--- handle_response --- processing (turn done)

A notification only starts a turn when handle_event/2 returns {:prompt, text, state}; {:noreply, state} leaves the agent idle.

Halting does not terminate the agent. It remains registered with its final state and backend session. An external owner can read GenAgent.status(name) after completion, persist the state it needs, then call GenAgent.stop(name) to release resources. Use stop/2 with the owning supervisor for a caller-owned agent. Calling stop/1 or stop/2 for that same agent from one of its callbacks, including handle_stream_event/2 in the prompt task, returns {:error, :self_stop} because waiting for its exit would block cleanup. If final state and cached results are not needed, a post_run/1 callback can spawn a separate process to stop a global agent:

def post_run(_state) do
  name = GenAgent.current_name()
  spawn(fn -> GenAgent.stop(name) end)
  :ok
end

An ordinary ask/3 sent to a halted agent waits until resume/1, and its watchdog does not bound that wait. tell/3 and tell_with_completion/4 accept requests while halted, but poll/3 reports {:ok, :pending} until they run. Check status/2 or runtime_snapshot/2 to distinguish a halt from an active turn. Callers that cannot resume the agent can use tell_with_completion/5 with on_halt: :fail.

Core callbacks

Only init_agent/1 and handle_response/3 are required. use GenAgent supplies defaults for the rest, except handle_info/2, which stays undefined so unexpected messages are logged and discarded. A "decision" is {:noreply, state}, {:prompt, text, state} (text must be a binary), or {:halt, state}.

Callback Runs in Valid returns Use it for
init_agent/1 agent {:ok, backend_opts, state} or {:error, reason} Backend options and initial state
handle_response/3 agent decision A turn succeeded: record it, chain, or halt
handle_error/3 agent decision A turn failed: stay idle, retry, or halt
handle_event/2 agent decision Events from notify/2 and notify_ack/3
handle_info/2 agent decision Timers, monitors, and other OTP messages
handle_stream_event/2 prompt task next state (any term) Mid-turn backend events
terminate_agent/2 agent any term (ignored) Cleanup on shutdown

pre_run/1, pre_turn/2, post_turn/3, and post_run/1 are covered under Lifecycle hooks. The GenAgent moduledoc has the full callback contract table, including invalid returns and logging.

Errors. handle_error/3 receives backend errors, a terminal :error event, a stream that ends without a terminal event, a prompt-task crash ({:task_crashed, reason}), :timeout, :interrupted, and :task_supervisor_unavailable. It also receives overload or pre_turn/2 rejections of generated prompts. It returns a decision, so {:prompt, text, state} retries; there is no built-in retry cap. A raise or invalid return from handle_error/3, handle_event/2, or handle_info/2 is logged and keeps the previous state; the same failure in handle_response/3 stops the agent.

Events. An event handled while idle runs immediately. During a turn it is buffered and delivered in arrival order after the decision callback and post_turn/3, using the resulting state. See the keyless log-triage example for an OTP Logger handler that forwards crash reports and batches deferred notifications into agent turns.

Stream events. handle_stream_event/2 runs in the prompt task. Its state starts as the agent state at dispatch and is threaded through the stream inside that task; the agent process does not see intermediate values. The final value is merged only when the stream finishes normally, which includes a terminal :error event or EOF without a terminal event. A task crash, interruption, or watchdog kill discards it, and the agent continues from its pre-stream state. A crash inside the callback is a task crash.

Terminate. terminate_agent/2 runs in the agent process on shutdown for cleanup; its return value is ignored. It does not run for an untrappable kill.

Request refs and acknowledgments

interrupt_request/3 cancels the active turn only if ref is still the current request, so it cannot hit a successor turn. It returns {:ok, :accepted}, {:error, :not_current}, {:error, :idle}, or {:error, :not_found}. Use cancel_request/3 for a queued tell.

try do
  with {:ok, ref} <- GenAgent.tell("my-coder", "Run the tests", 5_000) do
    interruption =
      case GenAgent.interrupt_request("my-coder", ref, 5_000) do
        {:ok, :accepted} = result -> result
        {:error, reason} when reason in [:not_current, :idle] ->
          GenAgent.cancel_request("my-coder", ref, 5_000)
        {:error, reason} -> {:error, reason}
      end

    %{ref: ref, interruption: interruption}
  end
catch
  :exit, reason -> {:error, {:agent_call_failed, reason}}
end

{:ok, :accepted} reports the agent's decision and BEAM task cancellation, not that a provider process has stopped. {:error, :not_current} can also mean the request has finished or is queued. :idle includes a tell queued while halted, so both results fall back to cancel_request/3, which may itself return {:error, :current} or {:error, :already_finished}. The example returns the ref and admission result without waiting for completion. Read the eventual outcome with GenAgent.poll/3, or use tell_with_completion/4 when you want ref-tagged completion messages. Each call has a finite timeout, and the catch handles timeout or agent-death exits. A call timeout does not prove rejection; check agent state before retrying.

notify_ack/3 waits for admission, not for model work. While a turn is running, :ok means the event was retained for delivery after the turn.

case GenAgent.notify_ack("my-coder", {:ci_failed, "test_auth"}, 5_000) do
  :ok -> :accepted
  {:error, {:overloaded, info}} -> {:rejected, info}
  {:error, :not_found} -> :no_such_agent
  {:error, :draining} -> :shutting_down
end

The acknowledgment is in-memory only. A prompt that handle_event/2 later generates can still be rejected, and handle_error/3 receives that overload. notify/2 is a best-effort cast that returns :ok regardless.

Lifecycle hooks

In addition to the core callbacks, v0.2 adds four optional lifecycle hooks for fine-grained control over what happens around each turn and around the agent's full run:

Hook When it fires Typical use
pre_run/1 Once, after init_agent/1, before the first turn Slow async setup: clone a repo, create a worktree, fetch secrets
pre_turn/2 Before each prompt dispatch Prompt augmentation, rate limiting, :skip/:halt as a gate
post_turn/3 After each turn, post-decision State-mutating side effects: commit per turn, record usage
post_run/1 On clean {:halt, state} from a decision callback or pre_turn/2, or an external halt/1 Completion actions: open a PR, post a summary

All four are optional with default no-op implementations. The guiding principle is telemetry first, callbacks for state mutation -- observational use cases (log tokens, emit metrics) should use the existing telemetry events; callbacks exist specifically for hooks that need to mutate agent_state or block the next transition.

See the Workspace pattern guide for a complete example exercising all four hooks in sequence around a git workspace.

Backends

GenAgent ships with a GenAgent.Backend behaviour and no built-in backend. Pick one of the sibling packages or write your own:

Backend Package Transport
Claude (Anthropic) gen_agent_claude claude CLI via claude_wrapper
Codex (OpenAI) gen_agent_codex codex CLI via codex_wrapper
Anthropic HTTP gen_agent_anthropic direct HTTP API via req
OpenAI HTTP gen_agent_openai Responses API via req

A backend owns its session lifecycle, translates the LLM-specific event stream into the normalized GenAgent.Event values the state machine consumes, and carries any state it needs (session id, message history) in an opaque session term.

The contract has three required callbacks (start_session/1, prompt/2, terminate_session/1). prompt/3 and checkpoint_session/2 let CLI backends acknowledge a session ID during an active turn, so a later failure or interruption does not discard it. update_session/2 and resume_session/2 and reset_session/1 remain optional. See GenAgent.Backend for details.

The four bundled adapters normalize turn failures as GenAgent.Backend.Error when this core version is present. Its :kind, :retryable?, :status, and :retry_after fields support shared retry policies; :raw retains the provider's original reason. A retry is always the application's decision.

See the Backends guide for backend implementation rules and a capability and option comparison. For the tradeoffs between wrappers, GenAgent, and Oban workers, see Choosing a layer.

Public API

Function What it does
start_agent/2 Start an agent under the supervision tree.
current_name/0 Read the registered name from an agent callback.
ask/3 Synchronous prompt. Blocks until the turn finishes.
tell/3 Async prompt. Returns a ref for poll/3.
tell_with_completion/4 Async prompt with a request-scoped completion message.
poll/3 Check on a previously-issued tell/3.
notify/2 Push an external event into handle_event/2.
notify_ack/3 Wait for an in-memory notification admission result.
interrupt/1 Cancel an in-flight turn.
interrupt_request/3 Acknowledge cancellation only if the active request ref matches.
cancel_request/3 Remove a queued tell by its exact request ref.
halt/1 Pause dispatch after the active turn, without stopping the agent.
resume/1 Unhalt an agent and drain its mailbox.
reset_session/2 Clear supported backend conversation context between turns while retaining agent state.
status/2 Read the agent's current state.
runtime_snapshot/2 Read bounded runtime metadata and pending-input counts.
drain/2 Refuse new work, finish the active turn, and wait for the agent to exit.
stop/1 Terminate the agent.
child_spec/2 Build an agent child spec for a caller-owned supervisor.
stop/2 Terminate an agent under a caller-owned supervisor.
whereis/1 Look up an agent's pid.

Names resolve through a Registry, so callers address agents by name (any term). Agents use restart: :temporary: a crashed or stopped agent must be started explicitly, and its previous state is not restored. Callbacks can use GenAgent.current_name() in both the agent process and the prompt task; no duplicate agent_name option is needed.

runtime_snapshot/2 reports the coordinator's current phase, halted, pending-halt and draining flag, queued prompt and buffered notification counts, pending self-chain, and active request ref, origin and elapsed/watchdog time. It contains no prompt, callback state, backend session or event payload. It is a point-in-time observation, not durable state or permission to dispatch. The older status/2 API remains available; its agent_state is the server's latest retained state, not a live read of an in-flight task.

Use GenAgent.drain(name, timeout) during an orderly shutdown. It rejects new asks, tells, and acknowledged notifications with {:error, :draining}; queued asks and completion recipients receive the same error. The active turn finishes its callbacks before the agent and backend session terminate. drain/2 returns :ok only after that cleanup and process exit. The default timeout is :infinity; a caller timeout does not cancel a drain already accepted by the agent. stop/1 remains immediate.

Request completion messages

Use tell_with_completion/4 when a caller needs the exact request ref and an asynchronous terminal outcome:

{:ok, ref} = GenAgent.tell_with_completion("my-coder", "Run the tests")

receive do
  {:gen_agent, :completion, "my-coder", ^ref, {:ok, response}} ->
    IO.puts(response.text)

  {:gen_agent, :completion, "my-coder", ^ref, {:error, reason}} ->
    IO.inspect(reason)
end

The optional third argument selects a recipient pid; it defaults to the caller. The agent registers that recipient when it accepts the request, so a fast response or pre_turn/2 skip can send the completion before the call returns. Match on the ref, which works with interrupt_request/3 while active or cancel_request/3 while queued. Pending-queue overload returns an error without a ref or completion message. Accepted queued requests deliver after their turn, or deliver {:error, :cancelled} when cancelled before dispatch. Backend errors, gate skip/halt/invalid, interruption, and watchdog expiry deliver error outcomes. For started turns, the message is sent after turn decision and post_turn/3 callbacks. It is sent at most once for each accepted request.

Delivery does not depend on the bounded poll-result cache. It is an in-memory BEAM send, not durable delivery: a dead recipient loses its message, and agent death before a terminal outcome leaves the request uncertain. Monitor the agent if its death matters; a replacement under the same name uses new refs. A crashing decision callback stops the agent before completion. Neither a completion nor an agent monitor proves that an external provider process has settled.

Pending input bounds

Each agent admits at most 1,000 pending prompts and 1,000 deferred notifications by default, with a separate 1,048,576-byte payload cap for each queue. Configure :max_pending_prompts, :max_pending_prompt_bytes, :max_pending_notifications, and :max_pending_notification_bytes in start_agent/2 or child_spec/2. Values must be non-negative integers; zero disables the corresponding pending queue. Byte use is the sum of :erlang.external_size/1 for queued prompt strings or event terms. The active prompt and events handled immediately while idle are outside these pending caps.

ask/3 and tell/3 return {:error, {:overloaded, info}} before acceptance when a pending prompt does not fit. A rejected tell/3 returns no pollable ref. info identifies the queue, count or byte limit, current count and bytes, incoming bytes, and configured maxima. notify_ack/3 returns :ok after the event is handled or retained, or the same typed overload error if admission fails. It acknowledges in-memory processing, not durable delivery. Existing notify/2 is a best-effort cast: its immediate :ok does not mean the event fit. Use the rejection telemetry event to observe cast overloads.

Deferred event callbacks may generate prompts when a turn finishes. Those prompts use the bounded pending-prompt queue; on overload, handle_error/3 receives the reason. Response follow-ups and error retries use one reserved slot outside the prompt count cap, but must fit the prompt byte cap. Error retries of ask and tell turns retain their original caller and ref; response follow-ups are independent turns. A halted agent retains admitted work until resume/1; interruption or completion releases queue capacity as work is drained. These limits bound accepted internal storage, not arbitrary messages already waiting in the BEAM process mailbox before admission is processed. Stream-output capture and external provider cancellation have their own contracts.

Event capture bounds

Each turn retains at most 1,000 normalized events and 1,048,576 bytes of event terms by default. Override these positive-integer limits with :max_events_per_turn and :max_event_bytes_per_turn in start_agent/2 or child_spec/2. Byte usage is the sum of :erlang.external_size/1 for retained events, not total process memory.

The default event_retention: :compact delivers every normalized event to handle_stream_event/2 and continues to the terminal event. If the log limit is reached, Response.events holds the unchanged prefix and Response.event_coverage reports the observed, retained, and omitted counts plus the first omission reason. Response.terminal keeps the full terminal event separately; Response.text, usage, and session ID are built from the full stream. Check coverage before using Response.events as a complete record. Full response text, terminal payloads, callback state, and provider decoder buffers can still grow beyond the event-log budget.

Set event_retention: :lossless to require the previous complete-log contract. An event that exceeds either limit then ends the turn with {:error, {:event_capture_overflow, diagnostics}}; the rejected event is not delivered to handle_stream_event/2. The stream enumerable is halted, but settlement of provider subprocesses or remote work remains the backend's responsibility.

Supervision

The package starts a fixed supervision tree on application boot:

GenAgent.Supervisor
  GenAgent.Registry          (Registry, keys: :unique)
  GenAgent.TaskSupervisor    (Task.Supervisor)
  GenAgent.AgentSupervisor   (DynamicSupervisor)
    <your agents under here>

To own agent and prompt-task lifetimes in your application, start a Task.Supervisor before a DynamicSupervisor in your supervision tree:

children = [
  {Task.Supervisor, name: MyApp.AgentTasks},
  {DynamicSupervisor, name: MyApp.Agents, strategy: :one_for_one}
]

{:ok, _owner} = Supervisor.start_link(children, strategy: :rest_for_one)

spec =
  GenAgent.child_spec(MyAgent,
    name: "worker-1",
    backend: MyBackend,
    task_supervisor: MyApp.AgentTasks
  )

{:ok, _agent} = DynamicSupervisor.start_child(MyApp.Agents, spec)
{:ok, response} = GenAgent.ask("worker-1", "Hello")
:ok = GenAgent.stop("worker-1", MyApp.Agents)

For an agent that starts with your application, a module that uses GenAgent can also be a static supervisor child:

children = [
  {Task.Supervisor, name: MyApp.AgentTasks},
  {MyApp.Watcher,
   name: "watcher",
   backend: MyBackend,
   task_supervisor: MyApp.AgentTasks}
]

{:ok, owner} = Supervisor.start_link(children, strategy: :rest_for_one)
{:ok, response} = GenAgent.ask("watcher", "Hello")
:ok = GenAgent.stop("watcher", owner)

use GenAgent provides an overridable child_spec/1 for this tuple form. It delegates to GenAgent.child_spec/2, so the options and validation are the same. The child ID is its registered name. A static agent also has restart: :temporary: after a stop or crash, the supervisor removes it and does not restart it. Start a new agent explicitly if needed; its previous state is not restored.

child_spec/2 requires an explicit, running task supervisor; it never silently uses the global one. Both globally and caller-owned agents use GenAgent.Registry, so names must be unique across them and normal name-based calls work for either. stop/1 targets only the global agent supervisor; pass the owning supervisor to stop/2. The agent child is temporary and is never automatically replayed after a crash. With :rest_for_one, failure of the task supervisor also stops the agent supervisor. On ordinary shutdown, the agent supervisor stops first so its agents can cancel in-flight tasks while their task supervisor is still running. An application must reconstruct state and consumed work from its own durable records; supervision alone does not provide recovery. The Chaos Lab example is a keyless, executable check of the supervision contract.

Each prompt turn runs as a Task under its selected Task.Supervisor. A crashed task delivers :DOWN to the owning agent, which turns it into an {:error, {:task_crashed, reason}} response for the caller -- it does not take down the agent process. If the selected task supervisor is unavailable before a task can start, the request fails with :task_supervisor_unavailable, reaches handle_error/3, and leaves the agent idle. A callback's immediate retry is discarded in this case; a later request can run after the supervisor returns. When this happens during a caller-owned retry, the original ask or poll receives the final :task_supervisor_unavailable error.

The prompt task belongs to its agent: it is stopped when the agent exits, including abrupt exits that bypass termination callbacks. Interruption and the watchdog also stop the active prompt task. Stopping a BEAM task does not establish that a provider's subprocess or remote request has stopped; external cancellation and resource cleanup belong to the backend and its transport.

Patterns

Eleven common topologies are documented as ex_doc guides shipped with the package. Each guide is a complete worked example you can read, copy, and adapt -- they are not installed as public API modules:

Start with the patterns overview for a "choose your pattern" decision tree.

Telemetry

GenAgent emits telemetry events for observability:

[:gen_agent, :turn, :start]      # content-free, %{agent, ref, origin}
[:gen_agent, :turn, :stop]       # content-free, duration_ms
[:gen_agent, :turn, :error]      # content-free, duration_ms, reason_kind
[:gen_agent, :turn, :rejected]   # declined before dispatch; no matching start
[:gen_agent, :prompt, :start]    # %{agent, ref}
[:gen_agent, :prompt, :stop]     # %{agent, ref, duration}
[:gen_agent, :prompt, :error]    # %{agent, ref, reason}
[:gen_agent, :event, :received]  # %{agent, event}
[:gen_agent, :state, :changed]   # %{agent, from, to}
[:gen_agent, :mailbox, :queued]  # %{agent, depth}
[:gen_agent, :input, :rejected]  # %{agent, reason: {:overloaded, info}}
[:gen_agent, :halted]            # %{agent}
[:gen_agent, :terminated]        # %{agent, reason}; callback-driven exits only

Enough to build a communication graph, track latency, alert on stuck agents. Attach handlers with :telemetry.attach/4. Use the :turn events for ordinary metrics: older prompt/event events can carry prompts, raw errors, or agent state. GenAgent.Telemetry documents measurements, units, ordering, and data handling.

What GenAgent does not do

Testing

mix test
mix format --check-formatted
mix credo --strict
mix dialyzer

These commands check the root package. Run scripts/quality.sh to validate all six packages; see the contributor guide for setup and release conventions.

GenAgent.Backends.Mock ships with the core package so applications can test their own agent callbacks without a provider account or CLI. Return scripts: from init_agent/1, one script per turn:

def init_agent(opts) do
  events = [GenAgent.Event.new(:result, %{text: "done"})]
  {:ok, [scripts: [events]], %{observer: Keyword.fetch!(opts, :observer)}}
end

{:ok, _pid} = GenAgent.start_agent(MyAgent,
  name: "test-worker",
  backend: GenAgent.Backends.Mock,
  observer: self()
)

assert {:ok, response} = GenAgent.ask("test-worker", "work")
assert response.text == "done"
assert GenAgent.Backends.Mock.history("test-worker") == ["work"]

Use GenAgent.Backends.Mock.gate/2 when a test needs to inspect an active turn or queue before releasing it. See the module documentation for the release message and supported script shapes.

License

MIT. See LICENSE.