Skip to main content

recursive/http/
mod.rs

1//! HTTP API server for the Recursive agent.
2//!
3//! Provides a lightweight axum-based HTTP server that exposes the agent's
4//! tool registry as a read-only JSON endpoint, a health check, a POST /run
5//! endpoint that executes the agent with a given goal, session management
6//! endpoints for multi-turn conversations, and SSE streaming of agent events.
7
8mod auth;
9mod handlers;
10mod rate_limit;
11
12pub use auth::{AuthConfig, JwtConfig};
13pub use handlers::map_agent_event;
14pub use rate_limit::RateLimiter;
15
16use auth::{auth_config_from_env, auth_middleware};
17use handlers::{
18    agui_run, create_session, delete_session, fork_session, get_session, health, list_sessions,
19    list_slash_commands, list_tools, metrics_handler, openapi_spec, patch_session, run_agent,
20    send_session_message, session_clear_goal, session_events, session_interrupt,
21    session_plan_confirm, session_plan_reject, session_set_goal,
22};
23use rate_limit::{metrics_middleware, rate_limit_middleware, rate_limiter_from_env};
24
25use axum::{
26    routing::{get, post},
27    Router,
28};
29use std::collections::HashMap;
30use std::sync::atomic::AtomicU64;
31use std::sync::Arc;
32use tokio::sync::{broadcast, RwLock};
33
34use crate::config::Config;
35use crate::llm::LlmProvider;
36use crate::runtime::AgentRuntime;
37use crate::tools::plan_mode::PlanApprovalGate;
38use crate::tools::ToolRegistry;
39
40// ── Metrics ────────────────────────────────────────────────────────────────
41
42/// Prometheus-compatible metrics collector using lock-free atomic counters.
43#[derive(Default)]
44pub struct Metrics {
45    pub requests_total: AtomicU64,
46    pub requests_active: AtomicU64,
47    pub agent_runs_total: AtomicU64,
48    pub agent_runs_success: AtomicU64,
49    pub agent_runs_failed: AtomicU64,
50    pub tokens_prompt_total: AtomicU64,
51    pub tokens_completion_total: AtomicU64,
52    pub agent_steps_total: AtomicU64,
53}
54
55// ── Session types ──────────────────────────────────────────────────────────
56
57/// Internal session state (not directly serialized to clients).
58///
59/// The [`AgentRuntime`] owns the transcript; this struct adds HTTP-layer
60/// metadata (id, created_at) and the broadcast channel for SSE clients.
61pub struct SessionState {
62    pub id: String,
63    pub created_at: String,
64    /// Optional human-readable title, settable via `PATCH /sessions/:id`.
65    pub title: Option<String>,
66    /// Runtime is wrapped in a per-session Mutex so concurrent HTTP requests
67    /// for the same session are serialized without blocking the global lock.
68    pub runtime: Arc<tokio::sync::Mutex<AgentRuntime>>,
69    /// Shared gate for plan-mode approval. Stored here so HTTP handlers can
70    /// approve/reject without taking the runtime Mutex (which may be held
71    /// by a running agent turn).
72    pub plan_approval_gate: Arc<PlanApprovalGate>,
73    /// Goal-170: cancellation token for the currently running agent turn.
74    /// `POST /sessions/:id/interrupt` cancels this token, which causes
75    /// the kernel to exit with `FinishReason::Cancelled` at the next step
76    /// boundary.  Replaced with a fresh token at the start of every turn.
77    pub interrupt_token: Arc<tokio::sync::Mutex<Option<tokio_util::sync::CancellationToken>>>,
78}
79
80/// Serialized session info for list/detail endpoints.
81#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
82pub struct SessionInfo {
83    pub id: String,
84    pub created_at: String,
85    pub message_count: usize,
86    /// Optional human-readable title, set via `PATCH /sessions/:id`.
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub title: Option<String>,
89}
90
91/// Request body for `POST /sessions`.
92#[derive(serde::Deserialize, Debug)]
93pub struct CreateSessionRequest {
94    pub system_prompt: Option<String>,
95}
96
97/// Response body for `POST /sessions`.
98#[derive(serde::Serialize, Debug)]
99pub struct CreateSessionResponse {
100    pub id: String,
101    pub created_at: String,
102}
103
104/// Request body for `POST /sessions/:id/messages`.
105#[derive(serde::Deserialize, Debug)]
106pub struct SessionMessageRequest {
107    pub content: String,
108}
109
110/// Response body for `POST /sessions/:id/messages`.
111#[derive(serde::Serialize, Debug)]
112pub struct SessionMessageResponse {
113    pub role: String,
114    pub content: String,
115}
116
117/// Detail response for `GET /sessions/:id`.
118#[derive(serde::Serialize, Debug)]
119pub struct SessionDetailResponse {
120    pub id: String,
121    pub created_at: String,
122    /// Optional human-readable title (set via `PATCH /sessions/:id`).
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub title: Option<String>,
125    pub messages: Vec<serde_json::Value>,
126    /// Goal-167: current task list as maintained by `todo_write` calls.
127    pub todos: Vec<crate::tools::todo::TodoItem>,
128    /// Current session lifecycle state: `"idle"` | `"plan_pending_approval"`.
129    pub status: String,
130    /// Non-null when `status` is `"plan_pending_approval"`.
131    pub pending_plan: Option<String>,
132    /// Goal-168: active goal state, or `null` when no goal is set.
133    pub goal: Option<crate::runtime::GoalState>,
134    /// First user message in the session (for quick display).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub first_prompt: Option<String>,
137    /// Most recent user message in the session.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub last_prompt: Option<String>,
140}
141
142// ── Goal-168: goal endpoint types ────────────────────────────────────────
143
144/// Request body for `POST /sessions/:id/goal`.
145#[derive(serde::Deserialize, Debug)]
146pub struct SetGoalRequest {
147    /// The completion condition (free-form text).
148    pub condition: String,
149    /// Hard cap on autonomous turns. Defaults to 20.
150    pub max_turns: Option<u32>,
151}
152
153/// Response body for goal mutation endpoints.
154#[derive(serde::Serialize, Debug)]
155pub struct GoalResponse {
156    pub status: String,
157}
158
159// ── Goal-169: slash commands endpoint types ───────────────────────────────
160
161/// One slash command entry in `GET /slash-commands`.
162#[derive(Clone, serde::Serialize, Debug)]
163pub struct SlashCommandInfo {
164    pub name: String,
165    pub description: String,
166    pub source: String,
167    #[serde(skip_serializing_if = "Vec::is_empty")]
168    pub aliases: Vec<String>,
169    #[serde(skip_serializing_if = "String::is_empty")]
170    pub argument_hint: String,
171}
172
173// ── SSE event types ──────────────────────────────────────────────────────
174
175/// A single block of message content (mirrors Claude Agent SDK's
176/// `TextBlock` / `ToolUseBlock`). Emitted as part of [`SseEvent::Message`]
177/// so SDK clients can iterate `for block in msg.content` without doing a
178/// second round-trip to the session detail endpoint.
179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
180#[serde(tag = "type", rename_all = "snake_case")]
181pub enum SseContentBlock {
182    /// A run of plain text from the assistant.
183    Text { text: String },
184    /// A request from the assistant to call a tool.
185    ToolUse {
186        id: String,
187        name: String,
188        input: serde_json::Value,
189    },
190}
191
192/// Server-Sent Event payload emitted during an agent session run.
193#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
194#[serde(tag = "type", rename_all = "snake_case")]
195pub enum SseEvent {
196    /// A full role-tagged message, with content broken into typed blocks.
197    /// Modeled on Claude Agent SDK's `AssistantMessage` / `UserMessage`
198    /// streaming shape so the TS / Python SDKs can yield typed messages
199    /// without falling back to the session detail endpoint.
200    Message {
201        role: String,
202        content: Vec<SseContentBlock>,
203    },
204    /// A partial text delta during streaming. Concatenate `text` deltas
205    /// keyed by `step` to reconstruct the eventual `Message::Text` block.
206    PartialMessage { text: String, step: usize },
207    /// A tool is being called.
208    ToolCall { name: String, step: usize },
209    /// A tool call completed.
210    ToolResult { name: String, success: bool },
211    /// The agent run completed.
212    Done {
213        finish_reason: String,
214        total_steps: usize,
215    },
216    /// An error occurred.
217    Error { message: String },
218    /// Agent proposed a plan and is waiting for human review.
219    PlanProposed { plan: String },
220    /// Goal-168: judge found condition not yet met; loop continues.
221    GoalContinuing { reason: String, turns: u32 },
222    /// Goal-168: judge confirmed condition met.
223    GoalAchieved { condition: String, turns: u32 },
224    /// SDK Phase B: a tool call just completed; elapsed_ms is wall-clock time
225    /// from when the ToolCall event was received to when ToolResult arrived.
226    /// Emitted in addition to (and after) the `tool_result` event.
227    ToolProgress {
228        tool_use_id: String,
229        tool_name: String,
230        elapsed_ms: u64,
231    },
232}
233
234// ── App state ──────────────────────────────────────────────────────────────
235
236/// Shared application state for the HTTP server.
237#[derive(Clone)]
238pub struct AppState {
239    pub tools: Vec<ToolInfo>,
240    /// Live tool registry used to construct per-request and per-session
241    /// AgentRuntimes. Without this, runtimes get an empty registry and
242    /// every tool_call from the LLM resolves to "tool not found".
243    pub tool_registry: ToolRegistry,
244    pub config: Config,
245    pub provider: Arc<dyn LlmProvider>,
246    /// Session state keyed by session ID.
247    pub sessions: Arc<RwLock<HashMap<String, SessionState>>>,
248    /// Per-session SSE broadcast channels.
249    pub event_channels: Arc<RwLock<HashMap<String, broadcast::Sender<SseEvent>>>>,
250    pub metrics: Arc<Metrics>,
251    /// Goal-169: registered slash commands (built-in + skill-backed).
252    /// Pre-built at startup for cheap `GET /slash-commands` responses.
253    pub slash_commands: Arc<Vec<SlashCommandInfo>>,
254}
255
256/// Serializable tool info for the `/tools` endpoint.
257#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)]
258pub struct ToolInfo {
259    pub name: String,
260    pub description: String,
261    pub parameters: serde_json::Value,
262}
263
264/// Request body for `POST /run`.
265#[derive(serde::Deserialize, Debug)]
266pub struct RunRequest {
267    pub goal: String,
268    pub max_steps: Option<u32>,
269    pub system_prompt: Option<String>,
270}
271
272/// Successful response from `POST /run`.
273#[derive(serde::Serialize, Debug)]
274pub struct RunResponse {
275    pub status: String,
276    pub finish_reason: String,
277    pub messages: Vec<serde_json::Value>,
278    pub usage: UsageInfo,
279}
280
281/// Token/step usage information.
282#[derive(serde::Serialize, Debug)]
283pub struct UsageInfo {
284    pub total_steps: u32,
285    pub total_tokens: u64,
286}
287
288/// Error response body.
289#[derive(serde::Serialize, Debug)]
290pub struct ErrorResponse {
291    pub status: String,
292    pub error: String,
293}
294
295/// Query parameters for `GET /sessions`.
296#[derive(serde::Deserialize, Debug, Default)]
297pub struct ListSessionsQuery {
298    /// Maximum number of sessions to return (default: all).
299    pub limit: Option<usize>,
300    /// Number of sessions to skip before returning results (default: 0).
301    pub offset: Option<usize>,
302}
303
304// ── Router builders ────────────────────────────────────────────────────────
305
306/// Build the axum [`Router`] with all API routes.
307///
308/// Routes:
309/// - `GET /health` — returns `"ok"` (200)
310/// - `GET /tools` — returns JSON array of [`ToolInfo`]
311/// - `POST /run` — runs the agent with a goal and returns the outcome
312/// - `POST /sessions` — create a new session
313/// - `GET /sessions` — list all sessions
314/// - `GET /sessions/:id` — get session detail with messages
315/// - `POST /sessions/:id/messages` — send a message in a session
316/// - `DELETE /sessions/:id` — remove a session
317/// - `GET /sessions/:id/events` — SSE stream of agent events for a session
318/// - `GET /openapi.json` — returns the OpenAPI 3.0.3 specification
319///
320/// Auth is sourced from the `RECURSIVE_HTTP_AUTH_KEYS` env var. For tests
321/// that need a deterministic auth state (no env-var races across parallel
322/// test threads), use [`build_router_with_auth`] instead.
323pub fn build_router(state: AppState) -> Router {
324    build_router_with_auth(state, auth_config_from_env())
325}
326
327/// Build the HTTP router with an explicit `AuthConfig`.
328///
329/// Tests use this to inject a known auth state without touching
330/// process-global env vars. Production code paths use [`build_router`].
331///
332/// The rate limiter is sourced from env (`rate_limiter_from_env`).
333/// Tests that need deterministic rate-limit behavior should use
334/// [`build_router_with_auth_and_rate_limit`] instead.
335pub fn build_router_with_auth(state: AppState, auth: AuthConfig) -> Router {
336    build_router_with_auth_and_rate_limit(state, auth, rate_limiter_from_env())
337}
338
339/// Build the HTTP router with an explicit `AuthConfig` AND an explicit
340/// `RateLimiter`.
341///
342/// This is the lowest-level constructor — both of the layered
343/// middleware states are caller-supplied. Production code paths
344/// invoke [`build_router`] (env-driven on both); tests that need
345/// race-free rate-limit assertions use this directly.
346pub fn build_router_with_auth_and_rate_limit(
347    state: AppState,
348    auth: AuthConfig,
349    limiter: RateLimiter,
350) -> Router {
351    Router::new()
352        .route("/health", get(health))
353        .route("/tools", get(list_tools))
354        .route("/run", post(run_agent))
355        .route("/sessions", post(create_session))
356        .route("/sessions", get(list_sessions))
357        .route("/sessions/{id}", get(get_session))
358        .route("/sessions/{id}", axum::routing::delete(delete_session))
359        .route("/sessions/{id}", axum::routing::patch(patch_session))
360        .route("/sessions/{id}/messages", post(send_session_message))
361        .route("/sessions/{id}/events", get(session_events))
362        .route("/sessions/{id}/plan/confirm", post(session_plan_confirm))
363        .route("/sessions/{id}/plan/reject", post(session_plan_reject))
364        // Goal-168: goal loop endpoints.
365        .route("/sessions/{id}/goal", post(session_set_goal))
366        .route(
367            "/sessions/{id}/goal",
368            axum::routing::delete(session_clear_goal),
369        )
370        // Goal-170: interrupt the current agent turn.
371        .route("/sessions/{id}/interrupt", post(session_interrupt))
372        // SDK Phase C: fork a session.
373        .route("/sessions/{id}/fork", post(fork_session))
374        // Goal-169: slash commands listing.
375        .route("/slash-commands", get(list_slash_commands))
376        .route("/agui", post(agui_run))
377        .route("/openapi.json", get(openapi_spec))
378        .route("/metrics", get(metrics_handler))
379        .layer(axum::middleware::from_fn_with_state(
380            state.metrics.clone(),
381            metrics_middleware,
382        ))
383        .layer(axum::middleware::from_fn_with_state(
384            limiter,
385            rate_limit_middleware,
386        ))
387        .layer(axum::middleware::from_fn_with_state(auth, auth_middleware))
388        .with_state(Arc::new(state))
389}
390
391/// Build a static OpenAPI 3.0.3 specification describing all API endpoints.
392pub fn build_openapi_spec() -> serde_json::Value {
393    serde_json::json!({
394        "openapi": "3.0.3",
395        "info": {
396            "title": "Recursive Agent API",
397            "version": "0.4.0",
398            "description": "HTTP API for the Recursive coding agent"
399        },
400        "paths": {
401            "/health": {
402                "get": {
403                    "summary": "Health check",
404                    "description": "Returns 'ok' if the server is running.",
405                    "responses": {
406                        "200": {
407                            "description": "Server is healthy",
408                            "content": {
409                                "text/plain": {
410                                    "schema": { "type": "string", "example": "ok" }
411                                }
412                            }
413                        }
414                    }
415                }
416            },
417            "/tools": {
418                "get": {
419                    "summary": "List registered tools",
420                    "description": "Returns the JSON array of tools available to the agent.",
421                    "responses": {
422                        "200": {
423                            "description": "Array of tool descriptors",
424                            "content": {
425                                "application/json": {
426                                    "schema": {
427                                        "type": "array",
428                                        "items": { "$ref": "#/components/schemas/ToolInfo" }
429                                    }
430                                }
431                            }
432                        }
433                    }
434                }
435            },
436            "/run": {
437                "post": {
438                    "summary": "Run the agent",
439                    "description": "Execute the agent with a goal and return the outcome.",
440                    "requestBody": {
441                        "required": true,
442                        "content": {
443                            "application/json": {
444                                "schema": { "$ref": "#/components/schemas/RunRequest" }
445                            }
446                        }
447                    },
448                    "responses": {
449                        "200": {
450                            "description": "Agent completed successfully",
451                            "content": {
452                                "application/json": {
453                                    "schema": { "$ref": "#/components/schemas/RunResponse" }
454                                }
455                            }
456                        },
457                        "400": {
458                            "description": "Invalid request (e.g. empty goal)",
459                            "content": {
460                                "application/json": {
461                                    "schema": { "$ref": "#/components/schemas/ErrorResponse" }
462                                }
463                            }
464                        },
465                        "422": { "description": "Request body failed deserialization" },
466                        "500": {
467                            "description": "Internal server error",
468                            "content": {
469                                "application/json": {
470                                    "schema": { "$ref": "#/components/schemas/ErrorResponse" }
471                                }
472                            }
473                        }
474                    }
475                }
476            },
477            "/sessions": {
478                "get": {
479                    "summary": "List sessions",
480                    "description": "Returns all active sessions.",
481                    "responses": {
482                        "200": {
483                            "description": "Array of session info objects",
484                            "content": {
485                                "application/json": {
486                                    "schema": {
487                                        "type": "array",
488                                        "items": { "$ref": "#/components/schemas/SessionInfo" }
489                                    }
490                                }
491                            }
492                        }
493                    }
494                },
495                "post": {
496                    "summary": "Create a session",
497                    "description": "Create a new multi-turn conversation session.",
498                    "requestBody": {
499                        "required": true,
500                        "content": {
501                            "application/json": {
502                                "schema": { "$ref": "#/components/schemas/CreateSessionRequest" }
503                            }
504                        }
505                    },
506                    "responses": {
507                        "201": {
508                            "description": "Session created",
509                            "content": {
510                                "application/json": {
511                                    "schema": { "$ref": "#/components/schemas/CreateSessionResponse" }
512                                }
513                            }
514                        }
515                    }
516                }
517            },
518            "/sessions/{id}": {
519                "get": {
520                    "summary": "Get session detail",
521                    "description": "Returns session metadata and full message transcript.",
522                    "parameters": [{
523                        "name": "id",
524                        "in": "path",
525                        "required": true,
526                        "schema": { "type": "string" }
527                    }],
528                    "responses": {
529                        "200": {
530                            "description": "Session detail with messages",
531                            "content": {
532                                "application/json": {
533                                    "schema": { "$ref": "#/components/schemas/SessionDetailResponse" }
534                                }
535                            }
536                        },
537                        "404": { "description": "Session not found" }
538                    }
539                },
540                "delete": {
541                    "summary": "Delete a session",
542                    "description": "Remove a session and its transcript.",
543                    "parameters": [{
544                        "name": "id",
545                        "in": "path",
546                        "required": true,
547                        "schema": { "type": "string" }
548                    }],
549                    "responses": {
550                        "204": { "description": "Session deleted" },
551                        "404": { "description": "Session not found" }
552                    }
553                }
554            },
555            "/sessions/{id}/messages": {
556                "post": {
557                    "summary": "Send a message",
558                    "description": "Send a user message in a session and get the assistant response.",
559                    "parameters": [{
560                        "name": "id",
561                        "in": "path",
562                        "required": true,
563                        "schema": { "type": "string" }
564                    }],
565                    "requestBody": {
566                        "required": true,
567                        "content": {
568                            "application/json": {
569                                "schema": { "$ref": "#/components/schemas/SessionMessageRequest" }
570                            }
571                        }
572                    },
573                    "responses": {
574                        "200": {
575                            "description": "Assistant response",
576                            "content": {
577                                "application/json": {
578                                    "schema": { "$ref": "#/components/schemas/SessionMessageResponse" }
579                                }
580                            }
581                        },
582                        "404": {
583                            "description": "Session not found",
584                            "content": {
585                                "application/json": {
586                                    "schema": { "$ref": "#/components/schemas/ErrorResponse" }
587                                }
588                            }
589                        },
590                        "500": {
591                            "description": "Internal server error",
592                            "content": {
593                                "application/json": {
594                                    "schema": { "$ref": "#/components/schemas/ErrorResponse" }
595                                }
596                            }
597                        }
598                    }
599                }
600            },
601            "/sessions/{id}/events": {
602                "get": {
603                    "summary": "Subscribe to session events",
604                    "description": "SSE stream of real-time agent events for a session.",
605                    "parameters": [{
606                        "name": "id",
607                        "in": "path",
608                        "required": true,
609                        "schema": { "type": "string" }
610                    }],
611                    "responses": {
612                        "200": {
613                            "description": "SSE event stream",
614                            "content": {
615                                "text/event-stream": {
616                                    "schema": { "type": "string" }
617                                }
618                            }
619                        },
620                        "404": { "description": "Session not found" }
621                    }
622                }
623            },
624            "/agui": {
625                "post": {
626                    "summary": "Run an AG-UI agent",
627                    "description": "Drive a recursive agent run via the AG-UI protocol \
628                        (https://docs.ag-ui.com). Body is an AG-UI RunAgentInput; the \
629                        response is an SSE stream of AG-UI events (RunStarted, \
630                        TextMessageStart/Content/End, ToolCall*, RunFinished, ...).",
631                    "requestBody": {
632                        "required": true,
633                        "content": {
634                            "application/json": {
635                                "schema": { "type": "object" },
636                                "description": "AG-UI RunAgentInput payload"
637                            }
638                        }
639                    },
640                    "responses": {
641                        "200": {
642                            "description": "AG-UI SSE event stream",
643                            "content": {
644                                "text/event-stream": {
645                                    "schema": { "type": "string" }
646                                }
647                            }
648                        },
649                        "400": { "description": "Invalid AG-UI RunAgentInput" }
650                    }
651                }
652            },
653            "/openapi.json": {
654                "get": {
655                    "summary": "OpenAPI specification",
656                    "description": "Returns this OpenAPI 3.0.3 spec as JSON.",
657                    "responses": {
658                        "200": {
659                            "description": "OpenAPI spec document",
660                            "content": {
661                                "application/json": {
662                                    "schema": { "type": "object" }
663                                }
664                            }
665                        }
666                    }
667                }
668            }
669        },
670        "components": {
671            "schemas": {
672                "ToolInfo": {
673                    "type": "object",
674                    "properties": {
675                        "name": { "type": "string" },
676                        "description": { "type": "string" },
677                        "parameters": { "type": "object" }
678                    },
679                    "required": ["name", "description", "parameters"]
680                },
681                "RunRequest": {
682                    "type": "object",
683                    "properties": {
684                        "goal": { "type": "string" },
685                        "max_steps": { "type": "integer", "nullable": true },
686                        "system_prompt": { "type": "string", "nullable": true }
687                    },
688                    "required": ["goal"]
689                },
690                "RunResponse": {
691                    "type": "object",
692                    "properties": {
693                        "status": { "type": "string" },
694                        "finish_reason": { "type": "string" },
695                        "messages": { "type": "array", "items": { "type": "object" } },
696                        "usage": { "$ref": "#/components/schemas/UsageInfo" }
697                    },
698                    "required": ["status", "finish_reason", "messages", "usage"]
699                },
700                "UsageInfo": {
701                    "type": "object",
702                    "properties": {
703                        "total_steps": { "type": "integer" },
704                        "total_tokens": { "type": "integer" }
705                    },
706                    "required": ["total_steps", "total_tokens"]
707                },
708                "ErrorResponse": {
709                    "type": "object",
710                    "properties": {
711                        "status": { "type": "string" },
712                        "error": { "type": "string" }
713                    },
714                    "required": ["status", "error"]
715                },
716                "CreateSessionRequest": {
717                    "type": "object",
718                    "properties": {
719                        "system_prompt": { "type": "string", "nullable": true }
720                    }
721                },
722                "CreateSessionResponse": {
723                    "type": "object",
724                    "properties": {
725                        "id": { "type": "string" },
726                        "created_at": { "type": "string" }
727                    },
728                    "required": ["id", "created_at"]
729                },
730                "SessionInfo": {
731                    "type": "object",
732                    "properties": {
733                        "id": { "type": "string" },
734                        "created_at": { "type": "string" },
735                        "message_count": { "type": "integer" }
736                    },
737                    "required": ["id", "created_at", "message_count"]
738                },
739                "SessionDetailResponse": {
740                    "type": "object",
741                    "properties": {
742                        "id": { "type": "string" },
743                        "created_at": { "type": "string" },
744                        "messages": { "type": "array", "items": { "type": "object" } }
745                    },
746                    "required": ["id", "created_at", "messages"]
747                },
748                "SessionMessageRequest": {
749                    "type": "object",
750                    "properties": {
751                        "content": { "type": "string" }
752                    },
753                    "required": ["content"]
754                },
755                "SessionMessageResponse": {
756                    "type": "object",
757                    "properties": {
758                        "role": { "type": "string" },
759                        "content": { "type": "string" }
760                    },
761                    "required": ["role", "content"]
762                }
763            }
764        }
765    })
766}