apiari_claude_sdk/client.rs
1//! High-level client for spawning and interacting with Claude sessions.
2//!
3//! [`ClaudeClient`] is the main entry point. Configure it once, then call
4//! [`spawn`](ClaudeClient::spawn) to create a [`Session`] that communicates
5//! with a running `claude` subprocess over NDJSON.
6//!
7//! # Example
8//!
9//! ```rust,no_run
10//! # use apiari_claude_sdk::{ClaudeClient, SessionOptions};
11//! # async fn example() -> apiari_claude_sdk::error::Result<()> {
12//! let client = ClaudeClient::new();
13//! let opts = SessionOptions {
14//! model: Some("sonnet".into()),
15//! allowed_tools: vec!["Bash".into(), "Read".into(), "Edit".into()],
16//! ..Default::default()
17//! };
18//! let mut session = client.spawn(opts).await?;
19//!
20//! session.send_message("What files are in the current directory?").await?;
21//!
22//! while let Some(event) = session.next_event().await? {
23//! println!("{event:?}");
24//! }
25//! # Ok(())
26//! # }
27//! ```
28
29use crate::error::{Result, SdkError};
30use crate::session::SessionOptions;
31use crate::streaming::{AssembledEvent, StreamAssembler};
32use crate::tools::{ToolResult, ToolUse};
33use crate::transport::Transport;
34use crate::types::{InputMessage, Message};
35
36/// Builder / factory for Claude sessions.
37///
38/// Holds configuration that applies to every session spawned by this client,
39/// such as the path to the `claude` binary.
40#[derive(Debug, Clone)]
41pub struct ClaudeClient {
42 /// Path to the `claude` CLI binary.
43 pub cli_path: String,
44}
45
46impl Default for ClaudeClient {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl ClaudeClient {
53 /// Create a new client that will look for `claude` on `$PATH`.
54 pub fn new() -> Self {
55 Self {
56 cli_path: "claude".to_owned(),
57 }
58 }
59
60 /// Create a new client with a custom path to the Claude CLI binary.
61 pub fn with_cli_path(cli_path: impl Into<String>) -> Self {
62 Self {
63 cli_path: cli_path.into(),
64 }
65 }
66
67 /// Spawn a new Claude session with the given options.
68 ///
69 /// This starts the `claude` subprocess and returns a [`Session`] handle
70 /// for sending messages and receiving events.
71 ///
72 /// # Errors
73 ///
74 /// Returns [`SdkError::ProcessSpawn`] if the `claude` binary cannot be
75 /// found or started.
76 pub async fn spawn(&self, opts: SessionOptions) -> Result<Session> {
77 let args = opts.to_cli_args();
78 let transport = Transport::spawn(
79 &self.cli_path,
80 &args,
81 opts.working_dir.as_deref(),
82 &opts.env_vars,
83 )?;
84
85 Ok(Session {
86 transport,
87 assembler: StreamAssembler::new(),
88 finished: false,
89 })
90 }
91}
92
93/// A live session with a running `claude` subprocess.
94///
95/// Provides methods for sending messages, sending tool results, reading
96/// events, and interrupting the model.
97pub struct Session {
98 transport: Transport,
99 assembler: StreamAssembler,
100 finished: bool,
101}
102
103impl Session {
104 /// Send a text message to the model.
105 ///
106 /// This writes a user message to the subprocess's stdin in NDJSON format.
107 ///
108 /// # Errors
109 ///
110 /// Returns an error if the session is not connected or writing fails.
111 pub async fn send_message(&mut self, text: &str) -> Result<()> {
112 if self.finished {
113 return Err(SdkError::NotConnected);
114 }
115 let msg = InputMessage::user_text(text);
116 self.transport.send(&msg).await
117 }
118
119 /// Send a message with text and images.
120 ///
121 /// Images are `(media_type, base64_data)` tuples.
122 pub async fn send_message_with_images(
123 &mut self,
124 text: &str,
125 images: Vec<(String, String)>,
126 ) -> Result<()> {
127 if self.finished {
128 return Err(SdkError::NotConnected);
129 }
130 let msg = InputMessage::user_with_images(text, images);
131 self.transport.send(&msg).await
132 }
133
134 /// Send a tool result back to the model.
135 ///
136 /// After receiving a tool-use request via [`next_event`](Self::next_event),
137 /// execute the tool and send the result back with this method.
138 ///
139 /// # Errors
140 ///
141 /// Returns an error if the session is not connected or writing fails.
142 pub async fn send_tool_result(&mut self, result: &ToolResult) -> Result<()> {
143 if self.finished {
144 return Err(SdkError::NotConnected);
145 }
146 let msg = InputMessage::tool_result(&result.tool_use_id, &result.output, result.is_error);
147 self.transport.send(&msg).await
148 }
149
150 /// Get the next event from the stream.
151 ///
152 /// Returns `Ok(None)` when the session is complete (either the subprocess
153 /// exited or a `result` message was received).
154 ///
155 /// # Errors
156 ///
157 /// Returns an error on I/O failure, JSON parse failure, or if the
158 /// subprocess dies unexpectedly.
159 pub async fn next_event(&mut self) -> Result<Option<Event>> {
160 if self.finished {
161 return Ok(None);
162 }
163
164 loop {
165 let value = self.transport.recv().await?;
166
167 let Some(value) = value else {
168 // EOF — process exited.
169 self.finished = true;
170 return Ok(None);
171 };
172
173 // Try to parse as a typed Message.
174 let message: Message = match serde_json::from_value(value.clone()) {
175 Ok(m) => m,
176 Err(e) => {
177 // If we can't parse it, log and skip (forward compatibility).
178 tracing::warn!(
179 error = %e,
180 line = %value,
181 "skipping unrecognized message from claude stdout"
182 );
183 continue;
184 }
185 };
186
187 match message {
188 Message::System(sys) => {
189 return Ok(Some(Event::System(sys)));
190 }
191 Message::User(user) => {
192 return Ok(Some(Event::User(user)));
193 }
194 Message::Assistant(assistant) => {
195 // Extract tool-use requests for convenience.
196 let tool_uses = ToolUse::extract_from_content(&assistant.message.content);
197 return Ok(Some(Event::Assistant {
198 message: assistant,
199 tool_uses,
200 }));
201 }
202 Message::RateLimitEvent(event) => {
203 return Ok(Some(Event::RateLimit(event)));
204 }
205 Message::Result(result) => {
206 self.finished = true;
207 return Ok(Some(Event::Result(result)));
208 }
209 Message::StreamEvent(stream_event) => {
210 // Process through the assembler.
211 let assembled = self.assembler.process(&stream_event.event);
212 return Ok(Some(Event::Stream {
213 raw: stream_event,
214 assembled,
215 }));
216 }
217 }
218 }
219 }
220
221 /// Send an interrupt signal to the subprocess (SIGINT).
222 ///
223 /// This tells Claude to stop its current operation. The session remains
224 /// open and can continue to receive events and send new messages.
225 ///
226 /// # Errors
227 ///
228 /// Returns an error if the signal cannot be sent.
229 pub async fn interrupt(&mut self) -> Result<()> {
230 self.transport.interrupt()
231 }
232
233 /// Close stdin, signaling to the CLI that no more messages will be sent.
234 ///
235 /// After this call, [`send_message`](Self::send_message) and
236 /// [`send_tool_result`](Self::send_tool_result) will return
237 /// [`SdkError::NotConnected`]. The session remains open for reading
238 /// events via [`next_event`](Self::next_event) until the CLI process
239 /// finishes.
240 ///
241 /// This is useful when you have sent all your messages and want the CLI
242 /// to process them and emit its response. In `--input-format stream-json`
243 /// mode, the CLI may wait for EOF on stdin before finalizing.
244 pub fn close_stdin(&mut self) {
245 self.transport.close_stdin();
246 }
247
248 /// Close the session by closing stdin and waiting for the process to exit.
249 ///
250 /// Returns the exit code and any captured stderr output.
251 pub async fn close(mut self) -> Result<(Option<i32>, Option<String>)> {
252 self.transport.close_stdin();
253 self.transport.wait_with_stderr().await
254 }
255
256 /// Wait for the subprocess to exit and return any captured stderr.
257 ///
258 /// Unlike [`close`](Self::close), this does not consume the session.
259 /// Useful for retrieving stderr diagnostics after the session has finished.
260 pub async fn wait_for_stderr(&mut self) -> Result<Option<String>> {
261 let (_, stderr) = self.transport.wait_with_stderr().await?;
262 Ok(stderr)
263 }
264
265 /// Kill the subprocess immediately.
266 pub async fn kill(mut self) -> Result<()> {
267 self.transport.kill().await
268 }
269
270 /// Returns `true` if the session has received a result message or the
271 /// subprocess has exited.
272 pub fn is_finished(&self) -> bool {
273 self.finished
274 }
275}
276
277/// A high-level event from a Claude session.
278///
279/// This is the primary type that callers iterate over via
280/// [`Session::next_event`].
281#[derive(Debug, Clone)]
282pub enum Event {
283 /// A system metadata message (emitted once at session start).
284 System(crate::types::SystemMessage),
285
286 /// An echo of a user turn.
287 User(crate::types::UserMessage),
288
289 /// An assistant response turn.
290 ///
291 /// The `tool_uses` field is pre-extracted for convenience — it contains
292 /// the same tool-use blocks that appear in `message.content`, but as
293 /// [`ToolUse`] structs for easier pattern matching.
294 Assistant {
295 /// The full assistant message.
296 message: crate::types::AssistantMessage,
297 /// Convenience: tool-use requests extracted from the content blocks.
298 tool_uses: Vec<ToolUse>,
299 },
300
301 /// The final result message (session complete).
302 Result(crate::types::ResultMessage),
303
304 /// A raw streaming event with assembled content.
305 ///
306 /// Only emitted when `include_partial_messages` is set in [`SessionOptions`].
307 Stream {
308 /// The raw streaming event from the API.
309 raw: crate::types::StreamEvent,
310 /// Events assembled by the [`StreamAssembler`].
311 assembled: Vec<AssembledEvent>,
312 },
313
314 /// Rate limit status information.
315 RateLimit(crate::types::RateLimitEvent),
316}
317
318impl Event {
319 /// Returns `true` if this is a [`Event::Result`] (session complete).
320 pub fn is_result(&self) -> bool {
321 matches!(self, Event::Result(_))
322 }
323
324 /// Returns `true` if this is an [`Event::Assistant`] variant.
325 pub fn is_assistant(&self) -> bool {
326 matches!(self, Event::Assistant { .. })
327 }
328
329 /// If this is an [`Event::Assistant`] with tool-use requests, return them.
330 pub fn tool_uses(&self) -> Option<&[ToolUse]> {
331 match self {
332 Event::Assistant { tool_uses, .. } if !tool_uses.is_empty() => Some(tool_uses),
333 _ => None,
334 }
335 }
336
337 /// If this is a [`Event::Result`], return a reference to the result message.
338 pub fn as_result(&self) -> Option<&crate::types::ResultMessage> {
339 match self {
340 Event::Result(r) => Some(r),
341 _ => None,
342 }
343 }
344}