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