Current section

Files

Jump to
claude_sdk lib claude_sdk.ex
Raw

lib/claude_sdk.ex

defmodule ClaudeSDK do
@moduledoc """
Elixir SDK for the Claude Code CLI.
Wraps the Claude Code CLI as a subprocess, communicating via stdin/stdout
using newline-delimited JSON. Provides a streaming interface for sending
prompts and receiving typed message structs.
## Basic Usage
ClaudeSDK.query("What is 2+2?")
|> Enum.each(&IO.inspect/1)
## With Options
ClaudeSDK.query("Explain this code", %ClaudeSDK.Types.Options{
model: "claude-sonnet-4-6",
max_turns: 3,
permission_mode: :bypass_permissions
})
|> Enum.each(fn
%ClaudeSDK.Types.AssistantMessage{} = msg -> handle_assistant(msg)
%ClaudeSDK.Types.ResultMessage{} = msg -> handle_result(msg)
_ -> :ok
end)
## Permission Callbacks
ClaudeSDK.query("Read my files", %ClaudeSDK.Types.Options{
can_use_tool: fn tool_name, _input ->
if tool_name in ["Read", "Glob"], do: :allow, else: {:deny, "Not permitted"}
end
})
|> Enum.each(&IO.inspect/1)
## In-Process MCP Servers
server = ClaudeSDK.create_mcp_server("my-tools", "1.0", [
%ClaudeSDK.MCP.Tool{
name: "greet",
description: "Say hello",
input_schema: %{"type" => "object", "properties" => %{"name" => %{"type" => "string"}}},
handler: fn args -> {:ok, "Hello, \#{args["name"]}!"} end
}
])
ClaudeSDK.query("Use the greet tool", %ClaudeSDK.Types.Options{mcp_servers: [server]})
|> Enum.each(&IO.inspect/1)
## See Also
- `ClaudeSDK.Client` — stateful multi-turn conversations with session persistence
- `ClaudeSDK.Types.Options` — all available configuration options
- `ClaudeSDK.MCP.Tool` — defining custom MCP tools
"""
require Logger
alias ClaudeSDK.ControlRouter
alias ClaudeSDK.Internal
alias ClaudeSDK.MessageParser
alias ClaudeSDK.Types.Options
@doc """
Send a prompt to the Claude CLI and return a stream of typed messages.
The stream yields message structs (AssistantMessage, SystemMessage, etc.)
and terminates when a ResultMessage is received or the subprocess exits.
Control requests (e.g. permission checks, MCP messages) are intercepted
and handled automatically when callbacks are configured.
## Parameters
- `prompt` — the user message string
- `opts` — `%Options{}` struct or keyword list of options
## Returns
An `Enumerable.t()` of parsed message structs.
"""
@spec query(String.t(), Options.t() | keyword()) :: Enumerable.t()
def query(prompt, opts \\ %Options{})
def query(prompt, opts) when is_list(opts) do
valid_keys = Map.keys(%Options{}) -- [:__struct__]
invalid_keys = Keyword.keys(opts) -- valid_keys
if invalid_keys != [] do
raise ArgumentError,
"unknown options #{inspect(invalid_keys)}. Valid options: #{inspect(valid_keys)}"
end
query(prompt, struct(Options, opts))
end
def query(prompt, %Options{} = opts) do
case Options.validate(opts) do
:ok ->
Stream.resource(
fn -> start_subprocess(prompt, opts) end,
&receive_messages/1,
&cleanup/1
)
{:error, reason} ->
raise ArgumentError, reason
end
end
@doc """
Create an in-process MCP server configuration.
Returns a server config map that can be passed in `Options.mcp_servers`.
"""
@spec create_mcp_server(String.t(), String.t(), [ClaudeSDK.MCP.Tool.t()]) :: map()
def create_mcp_server(name, version, tools) do
ClaudeSDK.MCP.Server.create(name, version, tools)
end
@doc """
List available sessions. See `ClaudeSDK.Sessions.list_sessions/1`.
"""
@spec list_sessions(keyword()) :: [ClaudeSDK.Sessions.session_info()]
defdelegate list_sessions(opts \\ []), to: ClaudeSDK.Sessions
@doc """
Get info for a single session. See `ClaudeSDK.Sessions.get_session_info/2`.
"""
@spec get_session_info(String.t(), keyword()) :: ClaudeSDK.Sessions.session_info() | nil
defdelegate get_session_info(session_id, opts \\ []), to: ClaudeSDK.Sessions
@doc """
Get conversation messages for a session. See `ClaudeSDK.Sessions.get_session_messages/2`.
"""
@spec get_session_messages(String.t(), keyword()) :: [ClaudeSDK.Sessions.session_message()]
defdelegate get_session_messages(session_id, opts \\ []), to: ClaudeSDK.Sessions
@doc """
Rename a session. See `ClaudeSDK.Sessions.rename_session/3`.
"""
@spec rename_session(String.t(), String.t(), keyword()) :: :ok | {:error, term()}
defdelegate rename_session(session_id, title, opts \\ []), to: ClaudeSDK.Sessions
@doc """
Tag a session. See `ClaudeSDK.Sessions.tag_session/3`.
"""
@spec tag_session(String.t(), String.t() | nil, keyword()) :: :ok | {:error, term()}
defdelegate tag_session(session_id, tag, opts \\ []), to: ClaudeSDK.Sessions
@doc false
defdelegate generate_request_id, to: ClaudeSDK.Internal
# Stream.resource start_fun: spawn subprocess and send initialization + prompt
defp start_subprocess(prompt, opts) do
control_handlers = Internal.build_control_handlers(opts)
transport_mod = opts.transport_module
{:ok, pid} =
case transport_mod.start(caller: self(), options: opts) do
{:ok, pid} ->
{:ok, pid}
{:error, %ClaudeSDK.CLINotFoundError{} = e} ->
raise e
{:error, reason} ->
raise ClaudeSDK.TransportError, reason: reason
end
transport_mod.send_message(pid, Internal.build_init_request(opts))
init_timeout = opts.init_timeout_ms
case Internal.wait_for_init_response(init_timeout) do
{:ok, _server_info, buffered_messages} ->
# Replay messages received during init so they appear in the stream
for msg <- buffered_messages, do: send(self(), {:claude_message, msg})
:ok
{:error, {:cli_exited, {:error, %ClaudeSDK.ProcessExitError{} = e}}} ->
raise e
{:error, {:cli_exited, reason}} ->
raise ClaudeSDK.TransportError,
reason: reason,
message: "CLI exited during initialization: #{inspect(reason)}"
{:error, :init_timeout} ->
Internal.safe_stop_subprocess(pid)
raise ClaudeSDK.TimeoutError, timeout_ms: init_timeout
end
# Send the user prompt
user_message = %{
type: "user",
session_id: opts.session_id,
message: %{role: "user", content: prompt},
parent_tool_use_id: nil
}
transport_mod.send_message(pid, user_message)
message_timeout = opts.message_timeout_ms
%{
subprocess: pid,
transport_module: transport_mod,
control_handlers: control_handlers,
message_timeout: message_timeout,
started_at: System.monotonic_time(:millisecond)
}
end
# Stream.resource next_fun: receive and parse messages
defp receive_messages({:halt, state}), do: {:halt, state}
defp receive_messages(:halt), do: {:halt, :done}
defp receive_messages(
%{
subprocess: pid,
transport_module: transport_mod,
control_handlers: handlers,
message_timeout: message_timeout
} =
state
) do
receive do
{:claude_message, %{"type" => "control_request"} = raw} ->
case ControlRouter.dispatch(raw, handlers) do
{:handled, response} ->
transport_mod.send_message(pid, response)
{[], state}
{:handled_with_interrupt, response} ->
transport_mod.send_message(pid, response)
interrupt_msg = %{
type: "control_request",
request_id: Internal.generate_request_id(),
request: %{subtype: "interrupt"}
}
transport_mod.send_message(pid, interrupt_msg)
{:halt, state}
{:unhandled, _} ->
case MessageParser.parse(raw) do
{:ok, msg} ->
{[msg], state}
{:error, reason} ->
Logger.warning("Failed to parse control_request message: #{inspect(reason)}")
{[], state}
end
end
{:claude_message, raw} ->
case MessageParser.parse(raw) do
{:ok, %ClaudeSDK.Types.ResultMessage{} = msg} ->
{[msg], {:halt, state}}
{:ok, msg} ->
{[msg], state}
{:error, reason} ->
Logger.warning("Failed to parse message: #{inspect(reason)}")
{[], state}
end
{:claude_exit, _reason} ->
{:halt, :done}
after
message_timeout ->
timeout_seconds = div(message_timeout, 1000)
elapsed_ms = System.monotonic_time(:millisecond) - state.started_at
timeout_result = %ClaudeSDK.Types.ResultMessage{
subtype: "error",
is_error: true,
result: "Message receive timeout after #{timeout_seconds}s",
duration_ms: elapsed_ms,
duration_api_ms: nil,
num_turns: nil,
session_id: nil
}
{[timeout_result], :halt}
end
end
# Stream.resource after_fun: clean up subprocess
defp cleanup(:done), do: :ok
defp cleanup(%{subprocess: pid}) do
Internal.safe_stop_subprocess(pid)
end
defp cleanup(_), do: :ok
end