GenAgentClaude

CI Hex.pm Docs

The package source and new issues live in genagent/gen_agent/integrations/claude. The former repository retains historical releases and discussions.

Claude backend for GenAgent, built on top of claude_wrapper.

Provides GenAgent.Backends.Claude, which wraps the claude CLI and translates its stream-json output into the normalized GenAgent.Event values the state machine consumes.

Prerequisites

The claude CLI must be installed and on your PATH (or set CLAUDE_CLI to point at it). See the Claude Code docs for install instructions.

Installation

def deps do
  [
    {:gen_agent, "~> 0.3.0"},
    {:gen_agent_claude, "~> 0.1.0"}
  ]
end

Quick 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 = [
      working_dir: path,
      system_prompt: "You are a coding assistant.",
      permission_mode: :plan
    ]

    {: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

{:ok, _pid} = GenAgent.start_agent(MyApp.Coder,
  name: "my-coder",
  backend: GenAgent.Backends.Claude,
  cwd: "/path/to/project"
)

{:ok, response} = GenAgent.ask("my-coder", "What does lib/foo.ex do?")
IO.puts(response.text)

Session continuation

Claude CLI tracks multi-turn state through a session_id. The backend checkpoints it from a raw system or terminal event as soon as the CLI provides it, then threads it through --resume on subsequent turns, including after a failed or interrupted turn. No caller code is required. If the CLI fails before providing an ID, the next turn starts without --resume.

The conversation behind a session ID is not stored by a server. The CLI writes it as a local transcript file under ~/.claude/projects/<slug derived from the working directory>/<session_id>.jsonl in the home directory of the user that runs the CLI. A saved ID can be resumed when the CLI runs as the same user, on the same host, with the same working directory, and the transcript file still exists. This backend rejects no_session_persistence: true for this reason (see Backend options).

Claude emits text deltas and tool results as normalized events. A long or tool-heavy turn can exceed GenAgent's default retained-log budget of 1,000 events and 1,048,576 bytes. With the default event_retention: :compact, the turn continues and the backend receives the terminal session ID; response.event_coverage tells you if response.events is incomplete. Use event_retention: :lossless when your application requires every event in response.events; that mode fails the turn on overflow. The limits and retention mode are GenAgent start_agent/2 options, not Claude backend options. See the root README's event capture section for the contract.

# Turn 1: fresh conversation
{:ok, r1} = GenAgent.ask("my-coder", "Remember the number 42")
# Turn 2: same thread, same agent
{:ok, r2} = GenAgent.ask("my-coder", "What number did I ask you to remember?")
# r2.text == "42"

Resuming after an agent restart

GenAgent does not call resume_session/2; every agent start calls start_session/1 with the options returned from init_agent/1. To continue a prior conversation after the agent process restarts, save response.session_id and pass it back as :resume in those options, with the same working directory as before:

defmodule MyApp.Reviewer do
  use GenAgent

  @impl true
  def init_agent(opts) do
    cwd = Keyword.fetch!(opts, :cwd)

    backend_opts =
      case MyApp.SessionStore.get("reviewer") do
        nil -> [working_dir: cwd]
        saved_id -> [working_dir: cwd, resume: saved_id]
      end

    {:ok, backend_opts, %{}}
  end

  @impl true
  def handle_response(_ref, response, state) do
    if response.session_id, do: MyApp.SessionStore.put("reviewer", response.session_id)
    {:noreply, state}
  end
end

MyApp.SessionStore stands for whatever storage the application uses. The first turn after the restart runs with --resume <saved_id>. Once the CLI reports a session ID, the backend uses that ID for later turns in place of the one you supplied. Resume needs the CLI's saved transcript for that session, which lives under the user's home directory, so the CLI must run as the same user on the same machine; if the transcript is missing, the turn returns an error. Keep :cwd the same across restarts: the backend forwards :resume unchanged, and the CLI's own documentation describes how it finds a session's transcript.

Backend options

start_session/1 accepts ClaudeWrapper.stream/2 options with one exception: no_session_persistence: true returns {:error, {:unsupported_option, :no_session_persistence}}, because later turns resume the CLI session. If :session_id or :continue_session is supplied for the first turn, the backend omits it once --resume is used.

Options are passed to ClaudeWrapper.stream/2 unchanged, except that :cwd is renamed to :working_dir and :include_partial_messages defaults to true. start_session/1 (and resume_session/2) validates the options before any turn runs: an unsupported key returns {:error, {:unknown_option, key}}, and a malformed value (a bad :permission_mode or :effort, a non-binary :json_schema, a non-list :allowed_tools, :disallowed_tools, or :tools, and so on) returns {:error, {:invalid_option, key, value}}. A nil value is treated as unset. The lists below cover common options; the backend also accepts the other options handled by ClaudeWrapper.Query.apply_opts/2 in its supported wrapper version.

Process:

Model and prompt:

Tools and permissions:

MCP:

Settings isolation:

Session:

Streaming:

No effect on the default streamed turn:

Backend-only:

Read-only agent

:tools limits the tools the agent has. :allowed_tools lets the listed tools run without a permission prompt. Setting both gives an agent that can read the project and cannot edit files or run shell commands:

@impl true
def init_agent(opts) do
  backend_opts = [
    working_dir: Keyword.fetch!(opts, :cwd),
    system_prompt: "You review code. Do not modify files.",
    tools: ["Read", "Grep", "Glob"],
    allowed_tools: ["Read", "Grep", "Glob"],
    hermetic: :project
  ]

  {:ok, backend_opts, %{}}
end

hermetic: :project keeps the project's .claude settings, hooks, and MCP servers out of the run. Use hermetic: true to also drop the user's ~/.claude settings.

Permissions

The backend adds no permission flag unless you set one. With no :permission_mode and no :dangerously_skip_permissions, the claude CLI applies its own configuration: the host's Claude settings files and its default permission mode. The backend does not choose a posture for you, so what the agent can do depends on that host configuration.

Environment and working directory

The subprocess inherits the BEAM's full environment and current directory. :env overrides individual variables on top of the inherited environment; it does not replace or sanitize the rest. Set :cwd (or :working_dir) to run the CLI in a specific directory instead of the BEAM's.

Cancellation and timeouts

Two independent bounds apply to a turn:

On interrupt, watchdog, and stop, GenAgent cancels its prompt task. With the default Port runner this closes the pipes but does not guarantee that the CLI and the MCP servers it spawned have exited. The same applies when the idle deadline ends the stream. To terminate the whole process group, add forcola and select its runner:

# mix.exs
{:forcola, "~> 0.4.0"}

# config/config.exs
config :claude_wrapper, runner: ClaudeWrapper.Runner.Forcola

See ClaudeWrapper.Runner for details.

See GenAgent.Backends.Claude for the full module docs.

Event translation

Claude CLI's stream-json output is translated into GenAgent.Event values by GenAgent.Backends.Claude.EventTranslator:

Claude event GenAgent event
"system" filtered
"assistant" :text and :tool_use from ordered content blocks
"user" :tool_result from tool-result content blocks
"stream_event" with text delta immediate :text; completed assistant text is deduplicated
"content_block_delta" :text (from delta text)
"tool_use" :tool_use
"tool_result" :tool_result
successful "result" :usage + terminal :result
failed "result" :usage + terminal :error with subtype, message, cost, session and usage evidence
"error" terminal :error
anything else filtered

Token counts from data["usage"] are pulled out into a separate :usage event so GenAgent.Response.usage is populated. Failed results reach handle_error/3 and return {:error, reason} from ask/3 or poll/3. They are not retried automatically. Tool calls are emitted from completed assistant blocks so full input is retained; partial tool-input JSON is not emitted on its own.

Event data

event.data for each kind the translator emits. :text, :usage, :result, and :error carry atom-keyed maps. :tool_use and :tool_result carry the CLI's content block unchanged, with string keys, so one turn's events mix both key types.

The translator emits nothing for:

Testing

# Unit tests only (default, no CLI invocation)
mix test

# Run live tests that actually call the claude CLI
mix test --only live

Live tests are tagged :live so they do not run by default. They burn real tokens -- keep them cheap.

License

MIT. See LICENSE.