Skip to main content

systemprompt_client/client/
mod.rs

1//! HTTP API client for a systemprompt.io server.
2//!
3//! [`SystempromptClient`] wraps a `reqwest::Client` with the API's base URL and
4//! an optional bearer [`JwtToken`], exposing typed calls for agents, contexts,
5//! tasks, artifacts, A2A message sends, and the admin read endpoints. A2A and
6//! artifact payloads cross the wire as raw JSON so this crate stays free of the
7//! agent-domain dependency; callers deserialize into the matching
8//! `systemprompt_models` types.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use crate::error::{ClientError, ClientResult};
14
15mod http;
16use chrono::Utc;
17use reqwest::Client;
18use std::time::Duration;
19use systemprompt_identifiers::{ContextId, JwtToken, TaskId};
20use systemprompt_models::a2a::{Task, methods};
21use systemprompt_models::admin::{AnalyticsData, LogEntry, UserInfo};
22use systemprompt_models::net::{
23    HTTP_AUTH_VERIFY_TIMEOUT, HTTP_DEFAULT_TIMEOUT, HTTP_HEALTH_CHECK_TIMEOUT,
24};
25use systemprompt_models::{
26    AgentCard, ApiPaths, CollectionResponse, CreateContextRequest, SingleResponse, UserContext,
27    UserContextWithStats,
28};
29
30#[derive(Debug, Clone)]
31pub struct SystempromptClient {
32    base_url: String,
33    token: Option<JwtToken>,
34    client: Client,
35}
36
37impl SystempromptClient {
38    pub fn new(base_url: &str) -> ClientResult<Self> {
39        let client = Client::builder().timeout(HTTP_DEFAULT_TIMEOUT).build()?;
40
41        Ok(Self {
42            base_url: base_url.trim_end_matches('/').to_owned(),
43            token: None,
44            client,
45        })
46    }
47
48    pub fn with_timeout(base_url: &str, timeout_secs: u64) -> ClientResult<Self> {
49        let client = Client::builder()
50            .timeout(Duration::from_secs(timeout_secs))
51            .build()?;
52
53        Ok(Self {
54            base_url: base_url.trim_end_matches('/').to_owned(),
55            token: None,
56            client,
57        })
58    }
59
60    #[must_use]
61    pub fn with_token(mut self, token: JwtToken) -> Self {
62        self.token = Some(token);
63        self
64    }
65
66    pub fn set_token(&mut self, token: JwtToken) {
67        self.token = Some(token);
68    }
69
70    #[must_use]
71    pub const fn token(&self) -> Option<&JwtToken> {
72        self.token.as_ref()
73    }
74
75    #[must_use]
76    pub fn base_url(&self) -> &str {
77        &self.base_url
78    }
79
80    pub async fn list_agents(&self) -> ClientResult<Vec<AgentCard>> {
81        let url = format!("{}{}", self.base_url, ApiPaths::AGENTS_REGISTRY);
82        let response: CollectionResponse<AgentCard> =
83            http::get(&self.client, &url, self.token.as_ref()).await?;
84        Ok(response.data)
85    }
86
87    pub async fn get_agent_card(&self, agent_name: &str) -> ClientResult<AgentCard> {
88        let url = format!(
89            "{}{}",
90            self.base_url,
91            ApiPaths::wellknown_agent_card_named(agent_name)
92        );
93        http::get(&self.client, &url, self.token.as_ref()).await
94    }
95
96    pub async fn list_contexts(&self) -> ClientResult<Vec<UserContextWithStats>> {
97        let url = format!(
98            "{}{}?sort=updated_at:desc",
99            self.base_url,
100            ApiPaths::CORE_CONTEXTS
101        );
102        let response: CollectionResponse<UserContextWithStats> =
103            http::get(&self.client, &url, self.token.as_ref()).await?;
104        Ok(response.data)
105    }
106
107    pub async fn get_context(&self, context_id: &ContextId) -> ClientResult<UserContext> {
108        let url = format!(
109            "{}{}/{}",
110            self.base_url,
111            ApiPaths::CORE_CONTEXTS,
112            context_id.as_ref()
113        );
114        let response: SingleResponse<UserContext> =
115            http::get(&self.client, &url, self.token.as_ref()).await?;
116        Ok(response.data)
117    }
118
119    pub async fn create_context(&self, name: Option<&str>) -> ClientResult<UserContext> {
120        let url = format!("{}{}", self.base_url, ApiPaths::CORE_CONTEXTS);
121        let request = CreateContextRequest {
122            name: name.map(String::from),
123        };
124        let response: SingleResponse<UserContext> =
125            http::post(&self.client, &url, &request, self.token.as_ref()).await?;
126        Ok(response.data)
127    }
128
129    pub async fn create_context_auto_name(&self) -> ClientResult<UserContext> {
130        let name = format!("Session {}", Utc::now().format("%Y-%m-%d %H:%M"));
131        self.create_context(Some(&name)).await
132    }
133
134    pub async fn fetch_or_create_context(&self) -> ClientResult<ContextId> {
135        let contexts = self.list_contexts().await?;
136        if let Some(ctx) = contexts.first() {
137            return Ok(ctx.context_id.clone());
138        }
139        let context = self.create_context_auto_name().await?;
140        Ok(context.context_id)
141    }
142
143    pub async fn update_context_name(
144        &self,
145        context_id: &ContextId,
146        name: &str,
147    ) -> ClientResult<()> {
148        let url = format!(
149            "{}{}/{}",
150            self.base_url,
151            ApiPaths::CORE_CONTEXTS,
152            context_id.as_str()
153        );
154        let body = serde_json::json!({ "name": name });
155        http::put(&self.client, &url, &body, self.token.as_ref()).await
156    }
157
158    pub async fn delete_context(&self, context_id: &ContextId) -> ClientResult<()> {
159        let url = format!(
160            "{}{}/{}",
161            self.base_url,
162            ApiPaths::CORE_CONTEXTS,
163            context_id.as_str()
164        );
165        http::delete(&self.client, &url, self.token.as_ref()).await
166    }
167
168    pub async fn list_tasks(&self, context_id: &ContextId) -> ClientResult<Vec<Task>> {
169        let url = format!(
170            "{}{}/{}/tasks",
171            self.base_url,
172            ApiPaths::CORE_CONTEXTS,
173            context_id.as_str()
174        );
175        http::get(&self.client, &url, self.token.as_ref()).await
176    }
177
178    pub async fn delete_task(&self, task_id: &TaskId) -> ClientResult<()> {
179        let url = format!(
180            "{}{}/{}",
181            self.base_url,
182            ApiPaths::CORE_TASKS,
183            task_id.as_str()
184        );
185        http::delete(&self.client, &url, self.token.as_ref()).await
186    }
187
188    // JSON: HTTP boundary. The shared client does not depend on the agent
189    // crate, so artifact rows are surfaced as raw JSON; callers that need
190    // typed access deserialize into `systemprompt_models::a2a::Artifact`.
191    pub async fn list_artifacts(
192        &self,
193        context_id: &ContextId,
194    ) -> ClientResult<Vec<serde_json::Value>> {
195        let url = format!(
196            "{}{}/{}/artifacts",
197            self.base_url,
198            ApiPaths::CORE_CONTEXTS,
199            context_id.as_str()
200        );
201        http::get(&self.client, &url, self.token.as_ref()).await
202    }
203
204    pub async fn check_health(&self) -> bool {
205        let url = format!("{}{}", self.base_url, ApiPaths::HEALTH);
206        self.client
207            .get(&url)
208            .timeout(HTTP_HEALTH_CHECK_TIMEOUT)
209            .send()
210            .await
211            .is_ok()
212    }
213
214    pub async fn verify_token(&self) -> ClientResult<bool> {
215        let url = format!("{}{}", self.base_url, ApiPaths::AUTH_ME);
216        let auth = self.auth_header()?;
217        let response = self
218            .client
219            .get(&url)
220            .timeout(HTTP_AUTH_VERIFY_TIMEOUT)
221            .header("Authorization", auth)
222            .send()
223            .await?;
224
225        Ok(response.status().is_success())
226    }
227
228    // JSON: A2A JSON-RPC 2.0 envelope. Both the inbound `message` and the
229    // returned response object are passed through as raw JSON so the shared
230    // client stays free of the agent-domain dependency.
231    pub async fn send_message(
232        &self,
233        agent_name: &str,
234        context_id: &ContextId,
235        message: serde_json::Value,
236    ) -> ClientResult<serde_json::Value> {
237        let url = format!("{}{}/{}/", self.base_url, ApiPaths::AGENTS_BASE, agent_name);
238        let request = serde_json::json!({
239            "jsonrpc": "2.0",
240            "method": methods::SEND_MESSAGE,
241            "params": { "message": message },
242            "id": context_id.as_ref()
243        });
244        http::post(&self.client, &url, &request, self.token.as_ref()).await
245    }
246
247    fn limited_url(&self, path: &str, limit: Option<u32>) -> String {
248        limit.map_or_else(
249            || format!("{}{}", self.base_url, path),
250            |l| format!("{}{}?limit={}", self.base_url, path, l),
251        )
252    }
253
254    pub async fn list_logs(&self, limit: Option<u32>) -> ClientResult<Vec<LogEntry>> {
255        let url = self.limited_url(ApiPaths::ADMIN_LOGS, limit);
256        http::get(&self.client, &url, self.token.as_ref()).await
257    }
258
259    pub async fn list_users(&self, limit: Option<u32>) -> ClientResult<Vec<UserInfo>> {
260        let url = self.limited_url(ApiPaths::ADMIN_USERS, limit);
261        http::get(&self.client, &url, self.token.as_ref()).await
262    }
263
264    pub async fn get_analytics(&self) -> ClientResult<AnalyticsData> {
265        let url = format!("{}{}", self.base_url, ApiPaths::ADMIN_ANALYTICS);
266        http::get(&self.client, &url, self.token.as_ref()).await
267    }
268
269    // JSON: HTTP boundary, see `list_artifacts`.
270    pub async fn list_all_artifacts(
271        &self,
272        limit: Option<u32>,
273    ) -> ClientResult<Vec<serde_json::Value>> {
274        let url = self.limited_url(ApiPaths::CORE_ARTIFACTS, limit);
275        http::get(&self.client, &url, self.token.as_ref()).await
276    }
277
278    fn auth_header(&self) -> ClientResult<String> {
279        self.token.as_ref().map_or_else(
280            || {
281                Err(ClientError::AuthError {
282                    message: "No token configured".to_owned(),
283                })
284            },
285            |token| Ok(format!("Bearer {}", token.as_str())),
286        )
287    }
288}