ClaudeWrapper
Drive the Claude Code CLI from Elixir: typed results and errors, streaming, tool-permission callbacks, and long-lived sessions, over the same claude binary you already run in a terminal.
claude_wrapper owns exactly the seam between Elixir and the claude process: spawning it, framing its NDJSON, and turning its output into typed %ClaudeWrapper.Result{} / %ClaudeWrapper.Error{}. It takes no position on what you do with a result. It offers two ways to drive claude, and you pick by the lifecycle of your host:
- One-shot (
ClaudeWrapper.query/2,Query,Session): a fresh subprocess per turn, simple request/response. The fit formixtasks, escripts, batch jobs, and anything that runs and exits. - Long-lived (
ClaudeWrapper.DuplexSession): aGenServerholding oneclaudesubprocess open across a whole conversation: streams partial tokens, interrupts mid-turn, and routes tool-permission prompts back to your code. The fit for chat UIs, agent runtimes, and Phoenix-backed interfaces.
The long-lived mode speaks the same duplex protocol the official @anthropic-ai/claude-agent-sdk uses internally and that the @agentclientprotocol/claude-agent-acp bridge relies on for IDE integrations like Zed's agent panel, so an OTP host can use claude the way an IDE backend does.
Native CLI compatibility
The emitted query/resident and command argv are checked against Claude Code
2.1.220 and 2.1.309 in pinned PR CI. Run mix claude.contract with
CLAUDE_CONTRACT_BIN selecting an executable and optionally
CLAUDE_CONTRACT_VERSION requiring its exact reported version. The task uses
only native version/help and missing-operand parsing; its command construction
uses a task-local fake executable and never the selected provider binary.
Flags newer than a pinned version are checked from the version they are tagged
with in the task, so an older pinned CLI does not report them as missing.
These are verified compatibility points, not a bisected minimum or a promise of
full native semantics. Existing caller-selected CliVersion windows remain unchanged.
Controlled fake binaries verify the checker can reject missing flags and commands.
Auth.setup_token/2 now rejects token-from-stdin setup with :unsupported_command
before spawning. The CLI's top-level setup-token generates a credential; it
cannot configure a supplied token through stdin.
Installation
def deps do
[
{:claude_wrapper, "~> 0.20.0"}
]
end
Requires the claude CLI installed and on your PATH (or set CLAUDE_CLI to its path). Run claude doctor (or, from Elixir, ClaudeWrapper.doctor/0) before your first real call.
Quick start
{:ok, result} = ClaudeWrapper.query("Explain this error: ...")
result.result # the text
result.cost_usd # the session's running total (a resumed session includes earlier turns)
Everything else is a variation on this: more control over the flags (Query), continuity across turns (Session / DuplexSession), streaming, or reading Claude Code's own on-disk state.
Driving claude
One-shot query and stream
For short-lived consumers, query/2 runs a fresh subprocess and returns the full result; stream/2 yields %StreamEvent{}s as they arrive. Both accept the same options (see Building the call).
{:ok, result} =
ClaudeWrapper.query("Fix the bug in lib/foo.ex",
model: "sonnet",
working_dir: "/path/to/project",
max_turns: 5,
permission_mode: :bypass_permissions
)
ClaudeWrapper.stream("Implement the feature in issue #42", working_dir: ".")
|> Stream.each(fn event -> IO.inspect(event.type) end)
|> Stream.run()
Multi-turn without a process (Session)
ClaudeWrapper.Session threads --resume <session_id> across one-shot calls, so you get multi-turn continuity without holding a subprocess open: a struct-passing API, ideal outside an OTP host or when turns are far apart in wall time.
session = ClaudeWrapper.Session.new(config, model: "sonnet")
{:ok, session, r1} = ClaudeWrapper.Session.send(session, "What files are here?")
{:ok, session, r2} = ClaudeWrapper.Session.send(session, "Add tests for lib/foo.ex")
ClaudeWrapper.SessionServer wraps this in a supervised GenServer when you want a process around the per-call flow but not live token streaming.
Long-lived sessions (DuplexSession)
Holds one claude subprocess open across many turns; subscribers see assistant messages, partial token deltas, and tool-call results live.
config = ClaudeWrapper.Config.new(working_dir: ".")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config)
:ok = ClaudeWrapper.DuplexSession.subscribe(pid)
# Resolves when the CLI emits its `result` event; the inbox streamed the turn.
{:ok, result} = ClaudeWrapper.DuplexSession.send(pid, "Explain this codebase.")
# Inbox: {:claude, {:system_init, id}} | {:assistant, _} | {:stream_event, _}
# | {:user, _} (tool results) | {:result, %ClaudeWrapper.Result{}}
ClaudeWrapper.DuplexSession.interrupt(pid) # cancel an in-flight turn cleanly
ClaudeWrapper.DuplexSession.close(pid) # end the session
Configure it with a Query. Pass a %Query{} for the session's spawn-time knobs (model, system prompt, permission mode, tool lists, mcp config, ...); its prompt and transport flags are ignored (the session owns stream-json and takes prompts per turn).
query = ClaudeWrapper.Query.new("") |> ClaudeWrapper.Query.model("sonnet") |> ClaudeWrapper.Query.allowed_tool("Read")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config, query: query)
Permission callback. When the CLI wants a tool, it routes the request through your :on_permission callback. Answer synchronously, or return :defer and answer later via respond_to_permission/3 (for human-in-the-loop UIs).
on_permission = fn tool_name, _input ->
if tool_name == "Bash", do: {:deny, "no shell here"}, else: :allow
end
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config, on_permission: on_permission)
Control requests. control/3 sends any stream-json control request and returns the CLI's response; :on_control_request answers inbound ones other than can_use_tool (defer with :defer and reply later through respond_to_control/3). Without a handler, inbound requests are answered with an error rather than left waiting.
{:ok, %{"model" => _}} = ClaudeWrapper.DuplexSession.control(pid, %{subtype: "set_model", model: "sonnet"})
{:ok, %{"mcpServers" => _}} = ClaudeWrapper.DuplexSession.control(pid, %{subtype: "mcp_status"})
Typed helpers cover the requests checked against the CLI: set_model/2, set_permission_mode/2, set_max_thinking_tokens/2, mcp_status/1, mcp_toggle/3, mcp_reconnect/2, mcp_set_servers/2, stop_task/2, rewind_files/3, get_settings/1 and apply_flag_settings/2.
In-process MCP tools. :sdk_mcp_servers serves MCP from inside the BEAM over the session's own stdio: no port, no subprocess, no config file. A server is any JSON-RPC handler function; DuplexSession.SdkMcp.tool_server/2 builds one from a list of tools. :initialize sends the protocol's initialize request (with hooks or other fields) and initialize_response/2 returns the CLI's answer.
alias ClaudeWrapper.DuplexSession.SdkMcp
lookup = %{
name: "lookup_user",
description: "Look up a user by email",
input_schema: %{"type" => "object", "properties" => %{"email" => %{"type" => "string"}}},
call: fn %{"email" => email} -> {:ok, MyApp.Users.describe(email)} end
}
{:ok, pid} =
ClaudeWrapper.DuplexSession.start_link(
config: config,
sdk_mcp_servers: %{"app" => SdkMcp.tool_server("app", [lookup])},
on_permission: fn _tool, _input -> :allow end
)
# The model sees the tool as mcp__app__lookup_user.
Supervise it. Each session owns one Port; pair it with a DynamicSupervisor for per-conversation isolation, named registration, and OTP restart semantics. See ClaudeWrapper.DuplexSession for the full API and message vocabulary.
REPL helpers
For interactive exploration, two IEx helper modules stream tokens to stdout:
iex> import ClaudeWrapper.DuplexIEx # one long-lived session in the process dictionary
iex> start(working_dir: ".")
iex> say("Explain the README briefly.") # ...streams live...
iex> import ClaudeWrapper.IEx # per-call one-shot mode
iex> chat("explain this codebase", working_dir: ".")
iex> cost()
Building the call
ClaudeWrapper.Query is the builder for the full CLI flag surface; Query.apply_opts/2 takes a keyword list of any setter, and query/2 / stream/2 / Session.send/3 all delegate to it, so the same options work everywhere. An unknown option name, a mis-shaped value (for example disallowed_tools: :bash), or conflicting session selectors (resume with continue_session, fork_session without either, session_id with either unless fork_session) raise ArgumentError before anything is spawned. Before 0.19 they were dropped silently.
One-shot and streaming queries on the built-in runners send the prompt to the CLI on stdin, so prompt size is limited by the CLI rather than by execve, and the prompt does not appear in the process table (prompt_input: :argv restores the old form; a custom runner still gets it in argv). An argument list over the platform limit returns {:error, %Error{kind: :argument_list_too_long}} before anything is spawned.
alias ClaudeWrapper.{Config, Query}
Query.new("Fix the tests")
|> Query.model("sonnet")
|> Query.max_turns(10)
|> Query.permission_mode(:bypass_permissions)
|> Query.allowed_tool("Read")
|> Query.execute(Config.new(working_dir: "/path/to/project"))
Print flags added for Claude Code 2.1.31x
Each has a setter and a matching apply_opts/2 key:
| Setter / option | Emits |
|---|---|
restricted/1, restricted: true |
--restricted |
permission_prompts/2, :host | :none |
--permission-prompts host|none |
system_prompt_snapshot/2, :on | :off |
--system-prompt-snapshot on|off |
autocompact/2, :auto or a positive integer token count |
--autocompact auto|<tokens> |
allow_dangerously_skip_permissions/1 |
--allow-dangerously-skip-permissions |
chrome/2, true | false |
--chrome / --no-chrome |
forward_subagent_text/1 |
--forward-subagent-text |
append_subagent_system_prompt/2 |
--append-subagent-system-prompt <text> |
append_subagent_system_prompt_file/2 |
--append-subagent-system-prompt-file <path> |
advisor/2 |
--advisor <value> |
prompt_suggestions/2, true | false |
--prompt-suggestions / --prompt-suggestions false |
permission_mode(:manual) |
--permission-mode manual |
extra_args/2, a list of strings |
each string as one argv token |
- Unset means no flag.
chromeandprompt_suggestionsdefault tonil, which emits nothing and leaves the CLI default.prompt_suggestions(true)(orprompt_suggestions/1) keeps the bare flag;falseemits--prompt-suggestions false. Inapply_opts/2,falseis passed through for these two, unlike the other boolean options. - Validation. An unknown
permission_prompts,system_prompt_snapshotorpermission_modeatom, and anautocompactvalue other than:autoor a positive integer, raiseArgumentErrorwhen the arguments are built. The CLI validates the token range itself (2.1.316 help gives 100k to 1M). - System-prompt snapshot.
onreuses the system prompt recorded for the conversation until compaction (the CLI default, except under--bareoutside cloud and background sessions);offrenders it fresh. To change the system prompt of a resumedSessionturn, pass the new prompt withsystem_prompt_snapshot: :off. - Forwarding subagent text needs print mode with
--output-format stream-json.Query.stream/2and aDuplexSessionstarted withquery:always use stream-json;execute/2uses JSON output and the CLI may reject the flag there. - Bypass.
allow_dangerously_skip_permissions/1makes bypass a mode that can be switched to later; it does not turn bypass on.--dangerously-skip-permissionsstays behindClaudeWrapper.DangerousClient. extra_argsaccepts only a list of strings, keeps their order and boundaries, and places them after everyQueryflag and before the--prompt separator, so the prompt still goes on stdin or argv as configured. A"--"element is rejected. The arguments are not checked against the CLI.:defaultpermission mode still emitsdefaultfor compatibility.
Version provenance: the issue (#328) reports these flags on Claude Code 2.1.305, 2.1.311 and 2.1.312. They were checked against the help output of an installed 2.1.316, and the three hidden value flags (--append-subagent-system-prompt, --append-subagent-system-prompt-file, --advisor) by their missing-operand errors on 2.1.316. mix claude.contract checks flag names only, not values or combinations.
Out of scope: these launch flags are not on Query (use extra_args/2 if needed): --ax-screen-reader, --bg/--background, --cloud, --desktop, --environment, --ide, --remote-control, --remote-control-session-name-prefix, --teleport.
Supporting builders: ClaudeWrapper.Prompt (composable prompts with deferred file/git expansion), ClaudeWrapper.McpConfig (programmatic .mcp.json), and ClaudeWrapper.ToolPattern (typed, validated tool specs).
ClaudeWrapper.McpConfig.new()
|> ClaudeWrapper.McpConfig.add_stdio("my-server", "npx", ["-y", "my-mcp-server"], env: %{"API_KEY" => "sk-..."})
|> ClaudeWrapper.McpConfig.write!(".mcp.json")
Operational concerns
Error handling
Every operational failure is {:error, %ClaudeWrapper.Error{}}, a raisable exception you match on by :kind, with details in :reason / :exit_code / :stdout / :stderr:
case ClaudeWrapper.query("...", max_turns: 1) do
{:ok, result} -> result
{:error, %ClaudeWrapper.Error{kind: :max_turns_exceeded}} -> :hit_limit
{:error, %ClaudeWrapper.Error{kind: :api_error, reason: %{category: :rate_limit}}} -> :back_off
{:error, %ClaudeWrapper.Error{kind: kind}} -> {:failed, kind}
end
A turn that ends on an API error (HTTP 429, 529, 5xx, a refused connection) is {:error, %Error{kind: :api_error}} with reason: %{status:, category:, num_turns:, session_id:, ...}; a 401 is :auth with reason :invalid_credentials. Before 0.19 these came back as {:ok, %Result{is_error: true}}.
For streaming calls, timeout: ms bounds both the total run and each gap
between output frames. idle_timeout_ms: ms bounds the gaps separately, so a
long run can still detect a stalled CLI early:
ClaudeWrapper.stream(prompt, timeout: 1_800_000, idle_timeout_ms: 120_000).
A stream that ends before its terminal result emits a stream_truncated error
event. Without either, a per-frame idle safety deadline (300 s) still applies. Select the Forcola runner below when timeout
cleanup must terminate the CLI process group.
Each stdout line is bounded by the config's max_line_bytes (default 1 MiB);
a longer line stops the CLI and ends the stream with a line_limit error
event. With the Forcola runner the stream reads on demand, so a slow consumer
holds the CLI back. The default Runner.Port cannot: an Erlang port has no
read-side flow control, so a slow consumer's mailbox holds every line the CLI
has written.
The CLI's own rail-stop caps are typed, recoverable errors, distinct from a genuine failure. :max_turns_exceeded (--max-turns) and :max_budget_exceeded (--max-budget-usd, separate from the client-side :budget_exceeded of ClaudeWrapper.Budget) each carry reason: %{cap:, cost_usd:, num_turns:, session_id:}, so a capped run can be resumed:
{:error, %ClaudeWrapper.Error{kind: :max_budget_exceeded, reason: %{session_id: sid}}} = ...
# resume with Query.resume(sid) / Session
Running from a mix release
When the VM runs from a mix release (RELEASE_ROOT is set), every spawn path removes the release's boot variables (RELEASE_*, including RELEASE_COOKIE, plus BINDIR and ROOTDIR) and its bin and erts-<vsn>/bin directories from claude's environment, so a project's own elixir or mix that the agent runs does not boot against the release (#310). Config.env is applied after the scrub, so a scrubbed name can still be set explicitly; Config.new(release_env: :inherit) turns the scrub off. ClaudeWrapper.ChildEnv.release_scrub/1 returns the entries for callers that build an environment themselves. Unsetting RELEASE_COOKIE is not an isolation boundary: the cookie is also in $RELEASE_ROOT/releases/COOKIE.
Leak-free execution (opt-in)
On the default Port paths, a timeout, halted stream, closed session, owner death or BEAM death stops claude itself: a small /bin/sh supervisor (one-shot and streaming) or a lifeline port (DuplexSession) sends SIGINT, then SIGTERM 2 s later, then SIGKILL 1 s after that (#303). A closed duplex session first gets 1 s to exit on its own stdin EOF. Only the claude pid is signalled, so processes it started (stdio MCP servers, tool commands) are stopped only if claude stops them, and a pid recycled in the moment between claude's exit and a signal is a residual risk. ClaudeWrapper.Config.exec/2, used by the Commands.* subcommand wrappers, still sends no signal on timeout. Add forcola and select its implementations to run every invocation under a process-group kill (SIGINT, then SIGTERM 2 s later, then SIGKILL on timeout/halt/close/BEAM-death; #307), which also reaches the CLI's descendants in its group. An interrupted claude exits 0, so a zero exit status after a cancelled run does not mean success:
# mix.exs: {:forcola, "~> 0.12"}
config :claude_wrapper,
runner: ClaudeWrapper.Runner.Forcola, # one-shot + streaming
duplex_adapter: ClaudeWrapper.DuplexSession.Adapter.Forcola # DuplexSession
Both are opt-in and additive (they compile only when forcola is present); POSIX-only.
A single config can also select its own runner without touching application
config: Config.new(runner: ClaudeWrapper.Runner.Forcola) (see
Per-config runners).
The duplex adapter uses Forcola's bounded pull delivery with separate stderr. The default Adapter.Port also keeps claude's stderr out of the VM's stderr (a bounded tail comes back from DuplexSession.shutdown/1 and the {:transport_terminal, _} event) and bounds each pending line (:max_line_bytes, default 1 MiB); :max_output_bytes is opt-in there.
Set per-session bounds with adapter_opts: [max_line_bytes: ..., max_output_bytes: ...];
the defaults are 1 MiB per line and 64 MiB over the session. A bounded stderr
tail is returned separately from provider NDJSON by DuplexSession.shutdown/1,
alongside Forcola's terminal status, cleanup confirmation, and output evidence.
For owner-death evidence, pass terminal_recipient: supervisor_pid in
adapter_opts so an independent process receives Forcola's terminal record.
Observing a one-shot session identity
With Runner.Forcola, Query.execute/3 can announce the native session as
soon as stdout reports system/init, while retaining synchronous Result/Error
completion and the configured whole-run timeout:
reference = make_ref()
ClaudeWrapper.Query.execute(query, config,
session_observer: {observer_pid, reference})
The local observer process handles
{reference, %ClaudeWrapper.SessionObservation{session_id: session_id}} while
the call runs. The execution caller sends this message before returning, so
its later terminal reply to the same observer cannot overtake it.
Only the first valid, nonblank session ID is announced. Malformed, duplicate and conflicting later init events are ignored; stderr cannot supply an ID or terminal result. A dead observer is harmless and no caller callback runs. An observation is not proof of success: the call may still time out or fail. The caller owns persistence and must reject observations from stale attempts. The function waits for transport completion even after a result event.
Invalid observer options return :invalid_session_observer; a runner without
observed execution returns :observation_unsupported, both before spawning.
ClaudeWrapper.query/2 accepts the same :session_observer execution option
alongside its existing config and query options. Calls without that option,
and Query.execute/2, retain ordinary execution. Observed
failure diagnostics keep stderr separate, with newline-normalized stdout;
clean completion returns the usual parsed Result. Forcola's existing
24-hour bound applies when Config.timeout is nil.
Per-config runners and runner options
Config carries an optional :runner and :runner_options, so two configs
for the same provider (different binary, working directory, endpoint,
credentials, MCP files or CLAUDE_CONFIG_DIR) can run side by side. Each
field falls back to config :claude_wrapper, runner: ... / runner_options: ...
only when it is absent; an explicit runner_options: [] disables configured
defaults. Nothing in the library writes application or OS environment state.
ClaudeWrapper.query/2 accepts both as config options.
config =
ClaudeWrapper.Config.new(
binary: "/opt/claude-a/bin/claude",
working_dir: "/srv/a",
env: [{"CLAUDE_CONFIG_DIR", "/srv/a/.claude"}],
timeout: 600_000,
runner: ClaudeWrapper.Runner.Forcola,
runner_options: [idle_timeout_ms: 120_000, max_output_bytes: 16_777_216]
)
Runner options are honored only by evidenced execution (below), one-shot or
streamed: stream/2 with runner options or an :execution_observer yields
frames as they arrive and sends one %ExecutionEvidence{} when the stream
ends, including on an early halt (Query.stream/3). It needs a runner with
run_evidenced/5; otherwise it yields one terminal evidence_unsupported
error event. Paths that cannot honor them reject non-empty options before
spawning: Command.run/3 and raw/2 return :invalid_runner_options, and
ClaudeWrapper.Config.exec/2 (used by the Commands.* wrappers) raises
ArgumentError. DuplexSession.start_link/1 rejects non-empty resolved options
before opening any adapter, returning :invalid_runner_options with reason
{:unsupported_mode, :duplex}. Pass runner_options: [] on those calls to opt
out of an application default.
Evidenced one-shot execution
Query.execute/3 with execution_observer: {pid, reference} (or any call
whose config resolves non-empty runner options) runs through
c:ClaudeWrapper.Runner.run_evidenced/5 and sends exactly one
{reference, %ClaudeWrapper.ExecutionEvidence{}} before returning, on success
or failure. A SessionObservation for the same call is sent earlier, as soon
as the native ID appears. The record keeps separate facts: raw child exit
status/signal, native termination, the bound that cancelled the run, output
truncation, cleanup confirmation and its scope, guard close, retained partial
stdout/stderr, the observed session ID, and the last terminal result found
in retained stdout (kept even when the call fails). A terminal result, a
session ID or a zero exit alone never means clean completion; missing or
malformed native facts are :unknown. A process-group cleanup confirmation
does not cover descendants that escaped the group.
Runner raises, throws and catchable exits return a typed :io error with
reason: {:runner_failed, kind, reason} and still deliver one terminal record.
This fallback retains the first native ID and up to 1 MiB of observed stdout
with reconstructed newlines; unreported status, cleanup and stderr remain
unknown or empty. Collector helper failures retain bounded received stdout and
stderr. A collector observer failure returns :io with
{:observer_failed, kind, reason} after draining the native run, preserving
its terminal facts. Uncatchable execution caller death cannot deliver a record.
Runner.Forcola implements it around one Forcola.run/2 with an owned
bounded collector and accepts :max_output_bytes (default 64 MiB), :idle_timeout_ms,
:max_line_bytes, :env_mode, :interrupt_signal / :interrupt_grace_ms
(default SIGINT with a 2 s grace), :kill_grace_ms and :shim_path. It requires Forcola ~> 0.12 and a
matching shim; an incompatible shim is rejected before SPAWN. An unsupported
Forcola result contract returns :evidence_unsupported before spawning.
Other runners are rejected with
:evidence_unsupported before they are invoked; there is no fallback to the
Port runner.
Async one-shot runs (Run)
ClaudeWrapper.Run.start/3 runs the same evidenced execution in its own
process and returns a handle instead of blocking:
{:ok, run} = ClaudeWrapper.Run.start(query, config, events_to: self())
%{status: :running, session_id: sid, frames: n} = ClaudeWrapper.Run.snapshot(run)
case ClaudeWrapper.Run.await(run, 300_000) do
{:ok, %ClaudeWrapper.Result{} = result, %ClaudeWrapper.ExecutionEvidence{}} -> result
{:error, %ClaudeWrapper.Error{} = error, %ClaudeWrapper.ExecutionEvidence{}} -> error
:timeout -> ClaudeWrapper.Run.cancel(run)
end
start/3appliesQuery.execute/3's preflight (options, observers, runner support, runner options viac:ClaudeWrapper.Runner.check_evidenced/2, spawn target) and returns those rejections before anything is spawned.- Each parsed stdout event is sent to
:events_toas{run.ref, {:frame, %StreamEvent{}}}in stream order, then{run.ref, {:done, status}}. Delivery is unacknowledged; when the recipient's queue holds:max_queued_frames(default 1,000) or more messages, frames are dropped and counted insnapshot/1. snapshot/1is answered from the run process's state and never waits on the child.await/2returns the outcome with the run's single evidence record; its timeout returns:timeoutand leaves the run going.cancel/2is idempotent and returns{:error, %Error{kind: :cancelled}, evidence}withevidence.reason == :cancelled. WithRunner.Forcolathe run is stopped by closing its Forcola port, so the shim runs its owner-death sequence; Forcola sends no report after that, so cleanup is:unknown.- If the process that called
start/3exits, the run is cancelled.
It needs a runner with run_evidenced/5 (currently Runner.Forcola).
Retry, telemetry, budget
-
ClaudeWrapper.Retry: exponential backoff around aQuery. A retry re-sends the prompt as a new CLI process, and the tool effects of a failed attempt (file edits, commits, pushes, shell commands) are not undone and can run again. So the default retries only API errors that ended the run before any tool ran (:api_errorwith category:rate_limit,:overloaded,:server_erroror:connection). Timeouts, non-zero exits and other failures are retried only when a caller:retry_onopts in; even then a timeout is retried only after the runner confirmed the attempt exited (Runner.Forcola), and a query with a pinned--session-idis never retried.ClaudeWrapper.Retry.execute(query, config, max_retries: 3, base_delay_ms: 1_000) -
ClaudeWrapper.Telemetry::telemetry.span/3-shaped events (:start/:stop/:exception) around every exec path, so one handler observes the whole lifecycle.:stopmetadata carries:cost_usd(the session's running total),:exit_code,:duration,:usage,:is_errorand:subtype; session and duplex turn stops add:turn_cost_usd. Further events coverDuplexSessionpermission requests and decisions ([:claude_wrapper, :duplex, :permission, :request | :decision], with the decision, its source and the time to answer), interrupts ([:claude_wrapper, :duplex, :interrupt, :start | :stop], with the outcome), and every observedrate_limit_event([:claude_wrapper, :rate_limit]). Metadata never carries tool input, prompts or result text.Event Emitted by [:claude_wrapper, :exec, _]query/2/Query.execute/2[:claude_wrapper, :stream, _]stream/2/Query.stream/2[:claude_wrapper, :session, :turn, _]Session.send/3[:claude_wrapper, :duplex, :session, _]DuplexSessionprocess lifetime[:claude_wrapper, :duplex, :turn, _]DuplexSession.send/3 -
ClaudeWrapper.Budget: a client-side USD budget tracker for multi-turn loops.
Testing
Drive a DuplexSession against an in-process double (no network, no claude) with ClaudeWrapper.Test and ClaudeWrapper.DuplexSession.Adapter.Test.
Reading ~/.claude state
Beyond driving claude, the read-side modules introspect Claude Code's on-disk state, useful for dashboards, session pickers, and agent tooling. All parse liberally and return typed structs:
{:ok, history} = ClaudeWrapper.History.home()
{:ok, sessions} = ClaudeWrapper.History.sessions_for_path(history, File.cwd!())
{:ok, settings} = ClaudeWrapper.Settings.load(project_root: File.cwd!())
History, Settings, Agents, Skills, Jobs, and Worktrees cover session transcripts, the settings layers, agent-definition files, skills, background jobs, and git worktrees.
CLI subcommands & the raw escape hatch
Typed wrappers for the claude subcommands live under ClaudeWrapper.Commands.* (auth, mcp, plugin, marketplace, agents, doctor, version, …):
{:ok, plugins} = ClaudeWrapper.Commands.Plugin.list(config)
{:ok, _} = ClaudeWrapper.Commands.Marketplace.add(config, "https://github.com/org/marketplace")
For anything not yet wrapped, ClaudeWrapper.raw(["config", "list"]) runs an arbitrary subcommand through the configured runner.
Bundled binary (opt-in)
Instead of a PATH install, claude_wrapper can resolve, install, and version-floor a claude binary under its own priv/bin/:
config = ClaudeWrapper.Config.new(binary: :bundled) # pure resolution; no network
mix claude_wrapper.install # install/update the bundled binary
mix claude_wrapper.uninstall # remove it
mix claude_wrapper.path # print its path and install state
The PATH / CLAUDE_CLI default is unchanged for everyone else. See ClaudeWrapper.Bundled.
Module reference
Long-lived sessions (the headline feature)
| Module | Description |
|---|---|
ClaudeWrapper.DuplexSession |
Long-lived stream-json session over a single claude subprocess |
ClaudeWrapper.DuplexIEx |
REPL helpers for DuplexSession |
ClaudeWrapper.Conversation |
Turn-history/cost bookkeeping over a DuplexSession |
One-shot / per-call
| Module | Description |
|---|---|
ClaudeWrapper |
Convenience API (query/2, stream/2, version/0, auth_status/0, doctor/0, raw/2) |
ClaudeWrapper.Query |
Query builder + execute/stream |
ClaudeWrapper.Run |
Async handle for one evidenced one-shot run: snapshot, await, cancel |
ClaudeWrapper.Session |
Multi-turn continuity over per-call subprocesses |
ClaudeWrapper.SessionServer |
Supervised wrapper for Session |
ClaudeWrapper.IEx |
REPL helpers for one-shot/per-call mode |
Prompt building & structured output
| Module | Description |
|---|---|
ClaudeWrapper.Prompt |
Composable prompt builder with deferred file/git expansion |
ClaudeWrapper.Stream |
Lazy Stream combinators over a DuplexSession turn |
ClaudeWrapper.Structured |
Typed structured-output tasks; decode/2 for results already in hand (e.g. a DuplexSession turn) |
Subprocess execution & transport
| Module | Description |
|---|---|
ClaudeWrapper.Runner |
Behaviour selecting how one-shot/streaming subprocesses run |
ClaudeWrapper.Runner.Port |
Default: System.cmd + /bin/sh streaming port |
ClaudeWrapper.Runner.Forcola |
Opt-in leak-free runner (process-group kill); needs forcola |
ClaudeWrapper.DuplexSession.Adapter |
Transport seam for DuplexSession |
ClaudeWrapper.DuplexSession.Adapter.Port |
Default adapter: real claude subprocess over a Port |
ClaudeWrapper.DuplexSession.Adapter.Forcola |
Opt-in leak-free adapter; needs forcola |
ClaudeWrapper.DuplexSession.Adapter.Test |
Controllable in-process test double |
Shared infrastructure
| Module | Description |
|---|---|
ClaudeWrapper.Config |
Shared client config (binary, working_dir, env, timeout) |
ClaudeWrapper.Result |
Parsed result struct |
ClaudeWrapper.Error |
Canonical error exception (match on :kind) |
ClaudeWrapper.ExecutionEvidence |
Terminal evidence record of an evidenced one-shot execution |
ClaudeWrapper.StreamEvent |
NDJSON streaming event (partial_message/1) |
ClaudeWrapper.McpConfig |
.mcp.json builder |
ClaudeWrapper.Retry |
Exponential backoff retry |
ClaudeWrapper.Telemetry |
:telemetry spans for exec/stream/session/duplex, plus permission, interrupt and rate-limit events |
ClaudeWrapper.Budget |
Client-side USD budget tracker |
ClaudeWrapper.ToolPattern |
Typed, validated tool-spec builder |
ClaudeWrapper.CliVersion |
Parse/compare the CLI version |
ClaudeWrapper.DangerousClient |
Env-gated --dangerously-skip-permissions |
ClaudeWrapper.Auth |
Env auth detection + failure classification |
ClaudeWrapper.Test |
Drive a DuplexSession against an in-process double (network-free) |
ClaudeWrapper.Bundled |
Opt-in bundled-binary resolution |
Reading ~/.claude state
| Module | Description |
|---|---|
ClaudeWrapper.History |
Session JSONL transcripts (projects/sessions/entries) |
ClaudeWrapper.Settings |
The four settings.json layers |
ClaudeWrapper.Agents |
Read/write agent definition files |
ClaudeWrapper.Skills |
Read ~/.claude/skills |
ClaudeWrapper.Jobs |
Read background-job state |
ClaudeWrapper.Worktrees |
git worktree introspection |
CLI subcommand wrappers (ClaudeWrapper.Commands.*)
| Module | Description |
|---|---|
Commands.Auth |
Auth management (login modes, status, setup-token) |
Commands.Agents |
List active background agent sessions (agents --json) |
Commands.Mcp |
MCP server management (add/add-json/add-from-desktop/list/get/remove/serve) |
Commands.Plugin |
Plugin install/enable/disable/update/validate/tag/details/prune |
Commands.Marketplace |
Marketplace add/remove/list/update |
Commands.AutoMode |
auto-mode config/defaults/critique |
Commands.Ultrareview |
ultrareview (cloud multi-agent code review) |
Commands.Install / Commands.Update |
claude install / claude update |
Commands.Project |
claude project purge |
Commands.Doctor / Commands.Version |
CLI health check / version |
License
MIT. See the LICENSE file in the source repo for the full text.