GenAgentClaude
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:
:binary-- path to theclaudeexecutable.:working_dir(deprecated alias:cwd) -- directory the CLI runs in.:env-- environment variables set on top of the inherited environment. See Environment and working directory.:debug-- passes--debugto the CLI.
Model and prompt:
:model-- model name or alias, for example"sonnet".:fallback_model-- model the CLI falls back to when the primary model is unavailable (--fallback-model).:system_prompt-- replaces the default system prompt.:system_prompt_file-- reads the system prompt from a file (--system-prompt-file), which avoids passing a large prompt in argv.:append_system_prompt-- text appended to the default system prompt.:effort--:low,:medium,:high,:xhigh, or:max.:max_turns-- cap on agentic turns per prompt.:max_budget_usd-- cap on spend per prompt.:json_schema-- JSON schema string for structured output.:agent-- name of a Claude Code agent to run as.:brief-- enables theSendUserMessagetool (--brief).
Tools and permissions:
:tools-- list of built-in tools the agent can use (--tools). Tools not in the list are not available to the agent. An empty list is not a restriction: the wrapper omits--toolsentirely for[], so the CLI's default tools stay available.:allowed_tools-- list of tools or tool patterns, such as"Bash(git log:*)", that run without a permission prompt (--allowed-tools). This does not remove other tools.:disallowed_tools-- list of tools or tool patterns the agent may not use (--disallowed-tools).:permission_mode,:dangerously_skip_permissions-- see Permissions.:add_dir-- directory or list of directories tools may access in addition to the working directory (--add-dir).
MCP:
:mcp_config-- path or list of paths to MCP server config files (--mcp-config).:strict_mcp_config--trueuses only the servers from:mcp_configand ignores MCP servers configured in Claude settings (--strict-mcp-config).
Settings isolation:
:setting_sources-- comma-separated string of the settings sources the CLI loads, from"user","project", and"local"(--setting-sources). An empty string loads none of them.:hermetic-- a preset over three flags.trueor:fullsets--setting-sources "", which drops user, project, and local settings (hooks, MCP servers, and permission rules from~/.claudeand the project's.claudedirectory).:projectsets--setting-sources user, which drops project and local settings and keeps the user's~/.claudesettings. Both scopes also set--strict-mcp-configand--exclude-dynamic-system-prompt-sections. An explicit:setting_sourcestakes precedence over the scope's value.:hermeticdoes not change authentication, the inherited environment, or where the CLI stores session transcripts.
Session:
:resume-- session ID to resume on the first turn. See Resuming after an agent restart.:session_id,:continue_session-- applied to the first turn only.
Streaming:
:include_partial_messages-- streams text deltas as they arrive. Enabled by default.
No effect on the default streamed turn:
:timeout--ClaudeWrapper.stream/2does not apply it. Each turn is bounded by a fixed idle deadline instead; see Cancellation and timeouts. It reaches a custom:stream_fn, which can read it.:verbose--ClaudeWrapper.stream/2always passes--verbose, which stream-json output requires.:output_format--ClaudeWrapper.stream/2always usesstream-json.
Backend-only:
:stream_fn-- a 2-arity function(prompt, opts) -> Enumerable.t()that replaces the default&ClaudeWrapper.stream/2. Intended for tests that want to stub out the subprocess.
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.
permission_mode: :plan-- the agent plans instead of editing project files. The quick start uses it because it only asks a question. Plan mode is a CLI permission mode, not a filesystem sandbox: in non-interactive use the CLI can still run shell commands (for example a test runner that writes build output), and it writes its plan file under~/.claude/plans. The CLI also ignores:modelin plan mode and uses its configured model.permission_mode: :accept_edits-- file edits are approved automatically. Use it only for agents that are meant to change files.permission_mode: :bypass_permissionsanddangerously_skip_permissions: true-- skip permission checks. Use them only in an environment you already trust the agent with.:default,:dont_ask, and:autoare also accepted and are passed to the CLI as given.
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:
ClaudeWrapper's runner ends the stream when the CLI writes no output line for 300,000 ms (5 minutes). The deadline resets on each line and is the same under the Port and Forcola runners. It is not configurable through backend options;:timeoutdoes not change it. The turn then fails with reason"stream_truncated". A tool call that runs longer than five minutes without the CLI emitting output can end the turn this way.- GenAgent's
:watchdog_ms(astart_agent/2option, default ten minutes) bounds the whole turn. A watchdog longer than five minutes does not extend the idle deadline.
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.
:text--%{text: String.t()}.:tool_use-- the raw block, for example%{"type" => "tool_use", "id" => "toolu_...", "name" => "Read", "input" => %{"file_path" => "lib/foo.ex"}}."input"is the tool's argument map. Other fields the CLI includes, such as"caller", are kept.:tool_result-- the raw block, for example%{"type" => "tool_result", "tool_use_id" => "toolu_...", "content" => "..."}."content"is a string or a list of content blocks."is_error"is present only when the CLI includes it.:usage--%{input_tokens: integer, output_tokens: integer}. A count the CLI omits is omitted. Cache token counts are not included.:result--%{text: String.t(), session_id: String.t(), cost_usd: number, duration_ms: integer, num_turns: integer, is_error: false, raw: map}. Fields the CLI omits are omitted, except:is_error.:rawis the string-keyed result event, sostructured_output(with:json_schema),stop_reason,permission_denials,duration_api_msand extended usage are read fromraw. When the result text is empty or absent,:textis omitted, soResponse.textis assembled from the turn's:textevents; a nonempty result text is always used as given. The original empty or missing value stays inraw. Tool inputs are never used as text: a plan passed toExitPlanModeis read from that:tool_useevent's"input". The fallback is covered by synthetic stream tests; the CLI output of a plan-mode turn that ends inExitPlanModewith an empty result has not been recorded.:errorfrom a failed"result"--%{reason: reason, data: raw}, whererawis the string-keyed result event andreasonis%{provider: :claude, subtype: String.t(), message: term, errors: [term], num_turns: integer, session_id: String.t(), cost_usd: number, usage: map}with absent fields omitted.:messageis the first of: a nonempty"result"string, the nonempty"errors"list joined with"; ", a nonempty"error"string, or:unknown.:errorskeeps the list. The recorded CLI 2.1.284 max-turns failure carries only"errors", so its:messageis that text. Other failure subtypes have not been recorded.:errorfrom an"error"event --%{reason: reason, data: raw}, wherereasonis the event's"error"or"message"field, or:unknown. When the CLI exits without a terminal result (idle deadline, non-zero exit, or spawn failure),reasonis"stream_truncated".
The translator emits nothing for:
- thinking content:
"thinking"blocks in assistant messages, andthinking_deltaandsignature_deltastream deltas - partial tool-input JSON (
input_json_delta) and other non-text stream deltas, message start and stop events, and content block start and stop events "system"events, including theinitevent (model, tools, MCP server status, and similar session metadata) and later subtypes such asstatus,thinking_tokens, and task progress. The backend still readssession_idfrom them for checkpointing."rate_limit_event"and any other unrecognized event type
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.