ClaudeWrapper

CI Hex.pm Docs License

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:

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.

Installation

def deps do
  [
    {:claude_wrapper, "~> 0.15.3"}
  ]
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     # spend for the call

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)

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.

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"))

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: kind}} -> {:failed, kind}
end

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.

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

Leak-free execution (opt-in)

By default a timeout, halted stream, closed session, or BEAM death closes the Erlang port or shuts down a Task, which closes the pipes but sends no signal to the OS process, so claude and every stdio MCP server it spawned can keep running (see #185). Add forcola and select its implementations to run every invocation under a process-group kill (SIGTERM then SIGKILL on timeout/halt/close/BEAM-death):

# mix.exs:  {:forcola, ">= 0.4.0 and < 0.7.0"}
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. The duplex adapter uses Forcola's bounded pull delivery with separate stderr. 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.

Retry, telemetry, budget

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.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

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.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

License

MIT. See the LICENSE file in the source repo for the full text.