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.
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.
def deps do
[
{:claude_wrapper, "~> 0.19.0"}
]
endRequires 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.
{: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.
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()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.
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 sessionConfigure 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.
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()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"))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")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}
endA 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. A stream that ends before its terminal result emits a
stream_truncated error event. Without an explicit timeout, a per-frame idle
safety deadline 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) / SessionWhen 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.
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 (SIGTERM then SIGKILL on timeout/halt/close/BEAM-death), which also reaches the CLI's descendants in its group:
# mix.exs: {:forcola, "~> 0.7.0"}
config :claude_wrapper,
runner: ClaudeWrapper.Runner.Forcola, # one-shot + streaming
duplex_adapter: ClaudeWrapper.DuplexSession.Adapter.Forcola # DuplexSessionBoth 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.
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.
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 one-shot execution (below).
Paths that cannot honor them reject non-empty options before spawning:
Command.run/3 and raw/2 return :invalid_runner_options, stream/2 yields
one terminal runner_options_unsupported error event, 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.
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,
:kill_grace_ms and :shim_path. It requires Forcola ~> 0.7.0 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.
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)
endstart/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 0.7 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).
-
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; session and duplex turn stops add:turn_cost_usd.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.
Drive a DuplexSession against an in-process double (no network, no claude) with ClaudeWrapper.Test and ClaudeWrapper.DuplexSession.Adapter.Test.
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.
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.
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 networkmix claude_wrapper.install # install/update the bundled binary
mix claude_wrapper.uninstall # remove it
mix claude_wrapper.path # print its path and install stateThe PATH / CLAUDE_CLI default is unchanged for everyone else. See ClaudeWrapper.Bundled.
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 |
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 |
MIT. See the LICENSE file in the source repo for the full text.