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

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)

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.

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

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

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

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.McpMCP server management
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.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.