Skip to main content

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}