ClaudeWrapper

CIHex.pmDocs

Elixir wrapper for the Claude Code CLI.

claude_wrapper gives you two ways to drive claude from Elixir:

  1. DuplexSession -- a GenServer that holds oneclaude subprocess open for the lifetime of a conversation, streams partial tokens as they arrive, lets you interrupt mid-turn, and routes tool-permission prompts back to your code. This is the right fit for chat UIs, agent runtimes, Phoenix-backed interfaces, and any long-running OTP process.
  2. One-shot Query -- a single subprocess per turn, simple request/response. The right fit for mix tasks, escripts, batch jobs, and anything else that runs and exits.

The duplex mode is the same 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. We surface it here so an OTP host can use claude the same way an IDE backend would.

Installation

def deps do
[
{:claude_wrapper, "~> 0.8"}
]
end

Requires the claude CLI to be installed and on your PATH (or set CLAUDE_CLI to point at it).

Bundled binary (opt-in)

claude_wrapper can also resolve, install, and version-pin a claude binary under its own priv/bin/, so an app can depend on claude_wrapper and get a known CLI version without a separate PATH install. This is opt-in; the PATH/CLAUDE_CLI default above is unchanged for everyone else.

config = ClaudeWrapper.Config.new(binary: :bundled)

ClaudeWrapper.Config.new/1 resolution is pure -- it does not touch the network. Install the pinned binary explicitly:

mix claude_wrapper.install # install/update the pinned bundled binary
mix claude_wrapper.uninstall # remove it
mix claude_wrapper.path # print its path and install state

See ClaudeWrapper.Bundled for details.

DuplexSession (long-lived chat-style sessions)

Holds one claude subprocess open across many turns. Subscribers see assistant messages, partial token deltas, and tool-call results as they arrive.

config = ClaudeWrapper.Config.new(working_dir: ".")
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config)
# Subscribe to live events.
:ok = ClaudeWrapper.DuplexSession.subscribe(pid)
# Send a turn; this resolves when the CLI emits its `result` event.
{:ok, result} = ClaudeWrapper.DuplexSession.send(pid, "Explain this codebase.")
# Inbox now contains:
# {:claude, {:system_init, "abc-123"}}
# {:claude, {:assistant, %{...}}}
# {:claude, {:stream_event, %{...}}} -- partial token deltas
# {:claude, {:user, %{...}}} -- tool results
# {:claude, {:result, %ClaudeWrapper.Result{}}}
# Cancel an in-flight turn cleanly (no SIGKILL):
ClaudeWrapper.DuplexSession.interrupt(pid)
# Or close the whole session:
ClaudeWrapper.DuplexSession.close(pid)

Configuring the session with a Query

Pass a %ClaudeWrapper.Query{} to configure the session's spawn-time knobs (model, system prompt, permission mode, tool allow/deny lists, mcp config, effort, turn/budget caps, session continuity, ...) with the same setters and apply_opts/2 as the one-shot path. The query's prompt and transport-format flags are ignored: the session owns its stream-json transport and takes prompts per turn via send/3.

query =
ClaudeWrapper.Query.new("")
|> ClaudeWrapper.Query.model("sonnet")
|> ClaudeWrapper.Query.allowed_tool("Read")
|> ClaudeWrapper.Query.max_turns(20)
{:ok, pid} = ClaudeWrapper.DuplexSession.start_link(config: config, query: query)

:extra_args still works as an escape hatch for flags not yet on Query, and is applied after the query's flags.

Permission callback

When the CLI wants to run a tool, it routes the prompt back through your :on_permission callback. The callback can answer synchronously or defer to a UI:

on_permission = fn tool_name, _input ->
case tool_name do
"Bash" -> {:deny, "no shell tools in this session"}
_ -> :allow
end
end
{:ok, pid} =
ClaudeWrapper.DuplexSession.start_link(
config: config,
on_permission: on_permission
)

For human-in-the-loop UIs, return :defer from the callback and answer later via respond_to_permission/3.

Pairing with a DynamicSupervisor

Each session owns one Port. Pair them with a DynamicSupervisor for per-conversation isolation, named registration, and standard OTP restart semantics:

{:ok, _} =
DynamicSupervisor.start_child(
MyApp.SessionsSupervisor,
{ClaudeWrapper.DuplexSession, [config: config, name: {:via, Registry, ...}]}
)

See ClaudeWrapper.DuplexSession for the full API and message vocabulary.

DuplexIEx (REPL helpers for the duplex session)

For interactive use, the DuplexIEx helpers store one session in the IEx process dictionary and stream tokens to stdout as they arrive:

iex> import ClaudeWrapper.DuplexIEx
iex> start(working_dir: ".")
Claude session started.
iex> say("Explain the README briefly.")
...streams text live...
($0.0123, 1 turn)
:ok
iex> say("Now suggest a one-line tagline.")
...streams text live...
:ok
iex> close()
Session closed.

One-shot queries

For short-lived consumers (mix tasks, escripts, batch jobs, anything that does one thing and exits), the simpler request/response surface spawns a fresh subprocess per turn:

{:ok, result} = ClaudeWrapper.query("Explain this error: ...")
{: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
)

Streaming events from a one-shot query:

ClaudeWrapper.stream("Implement the feature in issue #42",
working_dir: "/path/to/project"
)
|> Stream.each(fn event -> IO.inspect(event.type) end)
|> Stream.run()

For a per-call REPL, use ClaudeWrapper.IEx:

iex> import ClaudeWrapper.IEx
iex> chat("explain this codebase", working_dir: ".")
iex> say("now add tests for the retry module")
iex> cost()
iex> reset()

When to use Session vs DuplexSession

ClaudeWrapper.Session threads --resume <session_id> across one- shot calls so you get multi-turn continuity without holding a subprocess open. Use it when:

session = ClaudeWrapper.Session.new(config, model: "sonnet")
{:ok, session, result} = ClaudeWrapper.Session.send(session, "What files are here?")
{:ok, session, result} = ClaudeWrapper.Session.send(session, "Add tests for lib/foo.ex")

When in doubt: a long-running host (Phoenix server, agent runtime, chat UI backend) wants DuplexSession; everything else wants Query or Session.

Query builder

For full control over flags, build a Query directly:

alias ClaudeWrapper.{Config, Query}
config = Config.new(working_dir: "/path/to/project")
Query.new("Fix the tests")
|> Query.model("sonnet")
|> Query.max_turns(10)
|> Query.permission_mode(:bypass_permissions)
|> Query.allowed_tool("Read")
|> Query.allowed_tool("Write")
|> Query.execute(config)

Query.apply_opts/2 accepts a keyword list version of any of these setters; ClaudeWrapper.query/2, ClaudeWrapper.stream/2, and Session.send/3 all delegate to it, so you can pass any of those opts uniformly.

Less common setters: Query.name/2 tags the query for CLI-side tracing, Query.plugin_url/2 loads a plugin from a URL for the duration of the call, and Query.safe_mode/1 enables the CLI's safe-mode guardrails:

Query.new("Review this PR")
|> Query.name("pr-review")
|> Query.plugin_url("https://example.com/my-plugin.zip")
|> Query.safe_mode()
|> Query.execute(config)

Multi-agent coordination

Multi-agent coordination has moved to a separate package, agent_workshop. It is backend-agnostic and can drive Claude, Codex, or any agent that implements its Backend behaviour. Use it alongside claude_wrapper:

def deps do
[
{:claude_wrapper, "~> 0.8"},
{:agent_workshop, "~> 0.1"}
]
end

Telemetry

ClaudeWrapper emits :telemetry events around its core exec paths so downstream applications can observe query/session/stream lifecycle with a single handler. Events use the :telemetry.span/3 shape with :start, :stop, and :exception suffixes:

EventEmitted by
[:claude_wrapper, :exec, _]Query.execute/2 (one-shot query)
[:claude_wrapper, :stream, _]Query.stream/2 (NDJSON streaming)
[:claude_wrapper, :session, :turn, _]Session.send/3 (single turn)

Stop metadata adds :cost_usd, :exit_code, and the usual :duration. Subscribe with:

:telemetry.attach_many(
"claude-wrapper-observer",
[
[:claude_wrapper, :exec, :stop],
[:claude_wrapper, :stream, :stop],
[:claude_wrapper, :session, :turn, :stop]
],
fn event, measurements, metadata, _config ->
IO.inspect({event, measurements.duration, metadata})
end,
nil
)

See ClaudeWrapper.Telemetry for the full event reference.

SessionServer (supervised one-shot sessions)

For OTP applications that want a supervised process around the per-call Session flow:

{:ok, pid} =
ClaudeWrapper.SessionServer.start_link(
config: config,
query_opts: [model: "sonnet", max_turns: 5]
)
{:ok, result} = ClaudeWrapper.SessionServer.send_message(pid, "Fix the tests")
ClaudeWrapper.SessionServer.total_cost(pid)

SessionServer wraps Session (one subprocess per turn). For chat- UI-style flows where partial-token streaming matters, prefer DuplexSession instead.

MCP config builder

Build .mcp.json files programmatically:

ClaudeWrapper.McpConfig.new()
|> ClaudeWrapper.McpConfig.add_stdio("my-server", "npx", ["-y", "my-mcp-server"],
env: %{"API_KEY" => "sk-..."}
)
|> ClaudeWrapper.McpConfig.add_sse("remote", "https://example.com/mcp")
|> ClaudeWrapper.McpConfig.write!(".mcp.json")

Retry with backoff

ClaudeWrapper.Retry.execute(query, config,
max_retries: 3,
base_delay_ms: 1_000,
max_delay_ms: 30_000
)

Plugin and marketplace management

alias ClaudeWrapper.Commands.{Plugin, Marketplace}
{:ok, plugins} = Plugin.list(config)
{:ok, _} = Plugin.install(config, "my-plugin", scope: :project)
{:ok, marketplaces} = Marketplace.list(config)
{:ok, _} = Marketplace.add(config, "https://github.com/org/marketplace")

Raw CLI escape hatch

For subcommands not yet wrapped:

ClaudeWrapper.raw(["config", "list"])

Reading Claude Code state

Beyond driving claude, the read-side modules introspect Claude Code's on-disk state under ~/.claude -- useful for dashboards, session pickers, and agent tooling:

# Session transcripts (~/.claude/projects/<slug>/*.jsonl)
{:ok, history} = ClaudeWrapper.History.home()
{:ok, sessions} = ClaudeWrapper.History.sessions_for_path(history, File.cwd!())
{:ok, log} = ClaudeWrapper.History.read_session(history, hd(sessions).session_id)
# Settings layers and agent definition files
{:ok, settings} = ClaudeWrapper.Settings.load(project_root: File.cwd!())
{:ok, agents_root} = ClaudeWrapper.Agents.home()
{:ok, agents} = ClaudeWrapper.Agents.list(agents_root)

History, Settings, Agents, Skills, Jobs, and Worktrees parse liberally and return typed structs.

Error handling

Every operational failure is {:error, %ClaudeWrapper.Error{}} -- a raisable exception. Match on :kind; details live in :reason and the :exit_code / :stdout / :stderr fields:

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

The CLI's own rail-stop caps are surfaced as 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 a :reason map of %{cap:, cost_usd:, num_turns:, session_id:} so a capped run can be resumed:

case ClaudeWrapper.query("...", max_budget_usd: 5.0) do
{:ok, result} ->
result
{:error, %ClaudeWrapper.Error{kind: :max_budget_exceeded, reason: %{session_id: sid}}} ->
{:resume, sid}
end

Leak-free execution (optional)

By default a timeout, a halted stream, a closed session, or BEAM death closes the Erlang port or shuts down a Task. That closes the pipes but sends no signal to the OS process, so claude -- and every stdio MCP server it spawned -- can keep running after the caller was told the turn failed (see #185).

Add forcola and select its implementations to run every claude invocation under a process-group kill: on timeout, halt, close, or BEAM death the whole group (the CLI and its MCP servers) is terminated (SIGTERM, then SIGKILL). forcola is POSIX-only; without it the default Port paths are used unchanged.

# mix.exs
{:forcola, "~> 0.3"}
# config/config.exs
config :claude_wrapper,
runner: ClaudeWrapper.Runner.Forcola, # one-shot + streaming
duplex_adapter: ClaudeWrapper.DuplexSession.Adapter.Forcola # DuplexSession

Both are opt-in and additive: ClaudeWrapper.Runner.Forcola and ClaudeWrapper.DuplexSession.Adapter.Forcola compile only when forcola is present. A DuplexSession can also select the adapter per session with adapter: ClaudeWrapper.DuplexSession.Adapter.Forcola.

Modules

Long-lived sessions (the headline feature)

ModuleDescription
ClaudeWrapper.DuplexSessionLong-lived stream-json session over a single claude subprocess
ClaudeWrapper.DuplexIExREPL helpers for DuplexSession
ClaudeWrapper.ConversationTurn-history/cost bookkeeping over a DuplexSession

One-shot / per-call

ModuleDescription
ClaudeWrapperConvenience API (query/2, stream/2)
ClaudeWrapper.QueryQuery builder + execute/stream
ClaudeWrapper.SessionMulti-turn continuity over per-call subprocesses
ClaudeWrapper.SessionServerSupervised wrapper for Session
ClaudeWrapper.IExREPL helpers for one-shot/per-call mode

Prompt building & structured output

ModuleDescription
ClaudeWrapper.PromptComposable prompt builder with deferred file/git expansion
ClaudeWrapper.StreamLazy Stream combinators over a DuplexSession turn
ClaudeWrapper.StructuredTyped structured-output tasks

Subprocess execution (one-shot / streaming)

ModuleDescription
ClaudeWrapper.RunnerBehaviour selecting how one-shot/streaming subprocesses run
ClaudeWrapper.Runner.PortDefault: System.cmd + /bin/sh streaming port
ClaudeWrapper.Runner.ForcolaOpt-in leak-free runner (process-group kill); needs forcola

DuplexSession transport adapter

ModuleDescription
ClaudeWrapper.DuplexSession.AdapterTransport seam for DuplexSession
ClaudeWrapper.DuplexSession.Adapter.PortDefault adapter: real claude subprocess over a Port
ClaudeWrapper.DuplexSession.Adapter.ForcolaOpt-in leak-free adapter (process-group kill); needs forcola
ClaudeWrapper.DuplexSession.Adapter.TestControllable in-process test double

Shared infrastructure

ModuleDescription
ClaudeWrapper.ConfigShared client config (binary, working_dir, env, timeout)
ClaudeWrapper.ResultParsed result struct
ClaudeWrapper.ErrorCanonical error exception (match on :kind)
ClaudeWrapper.StreamEventNDJSON streaming event (partial_message/1)
ClaudeWrapper.McpConfig.mcp.json builder
ClaudeWrapper.RetryExponential backoff retry
ClaudeWrapper.Telemetry:telemetry spans for exec/stream/session
ClaudeWrapper.BudgetClient-side USD budget tracker
ClaudeWrapper.ToolPatternTyped, validated tool-spec builder
ClaudeWrapper.CliVersionParse/compare the CLI version
ClaudeWrapper.DangerousClientEnv-gated --dangerously-skip-permissions
ClaudeWrapper.AuthEnv auth detection + failure classification
ClaudeWrapper.TestDrive a DuplexSession against an in-process double (network-free)
ClaudeWrapper.BundledOpt-in bundled-binary resolution for the claude CLI

Reading ~/.claude state

ModuleDescription
ClaudeWrapper.HistorySession JSONL transcripts (projects/sessions/entries)
ClaudeWrapper.SettingsThe four settings.json layers
ClaudeWrapper.AgentsRead/write agent definition files
ClaudeWrapper.SkillsRead ~/.claude/skills
ClaudeWrapper.JobsRead background-job state
ClaudeWrapper.Worktreesgit worktree introspection

CLI subcommand wrappers

ModuleDescription
ClaudeWrapper.Commands.AuthAuth management (login modes, status, setup-token)
ClaudeWrapper.Commands.AgentsList configured agents
ClaudeWrapper.Commands.McpMCP server management (add/list/get/remove/serve/login/logout)
ClaudeWrapper.Commands.PluginPlugin install/enable/disable/update/tag/details/prune
ClaudeWrapper.Commands.MarketplaceMarketplace add/remove/list/update
ClaudeWrapper.Commands.AutoModeauto-mode config/defaults/critique
ClaudeWrapper.Commands.Ultrareviewultrareview (cloud multi-agent code review)
ClaudeWrapper.Commands.Installclaude install
ClaudeWrapper.Commands.Updateclaude update
ClaudeWrapper.Commands.Projectclaude project purge
ClaudeWrapper.Commands.DoctorCLI health check
ClaudeWrapper.Commands.VersionCLI version

License

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