Bidirectional client for Claude Code with hooks support.
This GenServer maintains a persistent connection to the Claude CLI process, handles control protocol messages, and invokes hook callbacks.
The Client enables:
- Bidirectional streaming communication
- Runtime hook callback invocation
- Control protocol request/response handling
- Message queueing and delivery
Usage
# Define hook callbacks
def check_bash(input, _tool_use_id, _context) do
if dangerous?(input), do: Output.deny("Blocked"), else: Output.allow()
end
# Configure options with hooks
options = %Options{
allowed_tools: ["Bash", "Write"],
hooks: %{
pre_tool_use: [
Matcher.new("Bash", [&check_bash/3])
]
}
}
# Start client
{:ok, pid} = Client.start_link(options)
# Send query
Client.send_message(pid, "Run: echo 'Hello'")
# Receive messages
stream = Client.stream_messages(pid)
Enum.each(stream, &IO.inspect/1)
# Stop client
Client.stop(pid)With Streaming
{:ok, pid} = Client.start_link(options)
# Start listening in separate process
task = Task.async(fn ->
Client.stream_messages(pid)
|> Enum.take_while(&(&1.type != :result))
|> Enum.to_list()
end)
# Send message
Client.send_message(pid, "Write a function")
# Wait for completion
messages = Task.await(task, :infinity)
Summary
Functions
Waits until the initialize request has been sent to the transport.
Waits for the client to finish initialization.
Returns a specification to start this module under a supervisor.
Gets the currently active agent.
Gets the list of available agent names.
Gets current MCP server connection status.
Retrieves the currently active model name.
Returns the server initialization info provided by the CLI.
Sends an interrupt control request to the CLI.
Sends a request in streaming mode, injecting session_id when missing.
Collects messages until a result frame is received.
Streams messages until a result frame is received.
Rewinds tracked files to their state at a specific user message.
Sends a message to Claude.
Switches to a different agent configuration.
Requests a runtime model switch.
Sets the permission mode at runtime.
Starts the client GenServer.
Stops the client.
Returns a stream of messages from Claude.
Subscribes to the client's message stream and returns a subscription reference.
Types
@type state() :: %ClaudeAgentSDK.Client{ accumulated_text: String.t(), active_subscriber: reference() | nil, buffer: String.t(), control_request_timeout_ms: pos_integer(), hook_callback_timeouts: %{required(String.t()) => pos_integer()}, init_request_id: String.t() | nil, init_timeout: {reference(), pos_integer()} | nil, init_waiters: [GenServer.from()], initialized: boolean(), initialized_waiters: [GenServer.from()], options: ClaudeAgentSDK.Options.t(), pending_callbacks: %{ required(String.t()) => %{ pid: pid(), monitor_ref: reference(), signal: ClaudeAgentSDK.AbortSignal.t(), type: :hook | :permission } }, pending_inbound: :queue.queue(), pending_inbound_dropped: non_neg_integer(), pending_inbound_size: non_neg_integer(), pending_permission_change: {GenServer.from(), reference()} | nil, pending_requests: %{required(String.t()) => {GenServer.from(), reference()}}, permission_bridge: :ets.tid() | nil, port: port() | nil, registry: ClaudeAgentSDK.Hooks.Registry.t(), sdk_mcp_servers: %{required(String.t()) => pid()}, server_info: map() | nil, session_id: term(), stream_buffer_limit: non_neg_integer(), stream_stop_reason: String.t() | nil, subscriber_monitors: %{required(reference()) => reference()}, subscriber_queue: [{reference(), String.t()}], subscribers: %{required(reference()) => pid()}, transport: pid() | nil, transport_module: module() | nil, transport_opts: keyword() }
Client state.
Fields:
port- Port to Claude CLI processoptions- Configuration optionsregistry- Hook callback registryhook_callback_timeouts- Map of callback_id => timeout_mssubscribers- Map of ref => pid for streaming subscriptionssubscriber_monitors- Map of ref => monitor_ref for subscriber lifecycle cleanuppending_requests- Map of request_id => {from, ref}pending_callbacks- Map of request_id => %{pid, monitor_ref, signal, type} for in-flight control callbacksinitialized- Whether initialization handshake completedbuffer- Incomplete JSON buffersdk_mcp_servers- Map of server_name => registry_pid for SDK MCP serversaccumulated_text- Buffer for partial text (streaming, v0.6.0)active_subscriber- Current streaming consumer reference (v0.6.0)subscriber_queue- Pending message queue (v0.6.0)init_waiters- Callers waiting for initialize request sendinitialized_waiters- Callers waiting for initialize completionpending_inbound- Buffered inbound events/messages before first subscriberpending_inbound_size- Number of buffered inbound entries before first subscriberpending_inbound_dropped- Dropped inbound entries due to buffer limitstream_buffer_limit- Max buffered inbound entries before first subscribercontrol_request_timeout_ms- Per-client timeout for control requests in milliseconds
Functions
@spec await_init_sent(pid(), pos_integer() | nil) :: {:ok, String.t()} | {:error, term()}
Waits until the initialize request has been sent to the transport.
Returns {:ok, request_id} once the initialize request is sent, or
{:error, reason} if the client is not alive or times out.
@spec await_initialized(pid(), pos_integer() | nil) :: :ok | {:error, term()}
Waits for the client to finish initialization.
Returns :ok once the initialize handshake completes, or {:error, reason}
if the client is not alive or times out.
Returns a specification to start this module under a supervisor.
See Supervisor.
Gets the currently active agent.
Parameters
client- The client PID
Returns
{:ok, agent_name} or {:error, reason}
Examples
{:ok, :coder} = Client.get_agent(client)
Gets the list of available agent names.
Parameters
client- The client PID
Returns
{:ok, [agent_name]} or {:error, reason}
Examples
{:ok, [:coder, :researcher]} = Client.get_available_agents(client)
Gets current MCP server connection status.
Returns a map with "mcpServers" key containing a list of server status
objects, each with:
"name"- Server name"status"- Connection status:"connected","pending","failed","needs-auth","disabled"
Parameters
client- The client PID
Returns
{:ok, map()} or {:error, reason}
Examples
{:ok, status} = Client.get_mcp_status(client)
for server <- status["mcpServers"] do
IO.puts("#{server["name"]}: #{server["status"]}")
end
Retrieves the currently active model name.
Returns the server initialization info provided by the CLI.
Sends an interrupt control request to the CLI.
@spec query(pid(), String.t() | Enumerable.t(), String.t()) :: :ok | {:error, term()}
Sends a request in streaming mode, injecting session_id when missing.
Matches Python SDK behavior:
- String prompts are wrapped as a
"user"message withparent_tool_use_id: nil - Map prompts (or enumerables of maps) get
session_idinjected if absent
@spec receive_response(pid()) :: {:ok, [ClaudeAgentSDK.Message.t()]} | {:error, term()}
Collects messages until a result frame is received.
Useful for workflows that only care about a single response and want to avoid managing streaming state manually.
@spec receive_response_stream(pid()) :: Enumerable.t(ClaudeAgentSDK.Message.t())
Streams messages until a result frame is received.
This provides a streaming equivalent of receive_response/1.
Rewinds tracked files to their state at a specific user message.
Requires Options.enable_file_checkpointing to be enabled when starting the client.
Sends a message to Claude.
In streaming mode, this queues the message for sending.
Parameters
client- Client PIDmessage- Message string or map
Returns
:ok or {:error, reason}
Examples
Client.send_message(pid, "Write a hello world function")
Switches to a different agent configuration.
Parameters
client- The client PIDagent_name- The name of the agent to switch to (atom)
Returns
:ok or {:error, reason}
Examples
Client.set_agent(client, :researcher)
Requests a runtime model switch.
Returns :ok when the CLI confirms the change or {:error, reason}
when validation fails or the CLI rejects the request.
@spec set_permission_mode(pid(), ClaudeAgentSDK.Permission.permission_mode()) :: :ok | {:error, :invalid_permission_mode}
Sets the permission mode at runtime.
Changes how tool permissions are handled for subsequent tool uses.
Parameters
client- Client PIDmode- Permission mode atom (:default,:accept_edits,:plan,:bypass_permissions,:delegate,:dont_ask)
Returns
:ok- Successfully changed mode{:error, :invalid_permission_mode}- Invalid mode provided
Examples
Client.set_permission_mode(pid, :plan)
Client.set_permission_mode(pid, :accept_edits)
Client.set_permission_mode(pid, :bypass_permissions)
Client.set_permission_mode(pid, :delegate)
@spec start_link( ClaudeAgentSDK.Options.t(), keyword() ) :: GenServer.on_start()
Starts the client GenServer.
Validates hooks configuration, starts Claude CLI process, and performs initialization handshake.
Parameters
options- ClaudeAgentSDK.Options struct with hooks configurationopts- Optional runtime overrides (e.g.:transport,:transport_opts,:control_request_timeout_ms)
Returns
{:ok, pid}- Successfully started{:error, reason}- Failed to start
Examples
options = %Options{
hooks: %{
pre_tool_use: [Matcher.new("Bash", [&my_hook/3])]
}
}
{:ok, pid} = Client.start_link(options)
@spec stop(pid()) :: :ok
Stops the client.
Terminates the CLI process and cleans up resources.
Parameters
client- Client PID
Returns
:ok
Examples
Client.stop(pid)
@spec stream_messages(pid()) :: Enumerable.t(ClaudeAgentSDK.Message.t())
Returns a stream of messages from Claude.
Subscribes to the client and yields messages as they arrive.
Parameters
client- Client PID
Returns
Enumerable stream of Message structs
Examples
Client.stream_messages(pid)
|> Stream.filter(&(&1.type == :assistant))
|> Enum.to_list()
Subscribes to the client's message stream and returns a subscription reference.