20 KiB
03a · Chat / Agent-Execution / Session API
Purpose
This is the contract for the conversational core of Waggle OS: how the frontend submits a chat turn, how tokens and tool events stream back over Server-Sent Events (SSE), how tool-execution approvals are negotiated mid-stream, how sessions are created/listed/renamed/deleted/exported, and how slash commands are run. Every endpoint here lives in the local Fastify sidecar (packages/server/src/local/routes/), mounted at base path /api. If you are rebuilding the frontend, this file is your source of truth for these flows — the names, paths, and JSON shapes below are quoted verbatim from the code.
Two distinct execution paths.
POST /api/chat(conversational, multi-turn message history,runAgentLoop) is the one the chat UI uses.POST /api/agent/run(one-shot structured retrieval, shape-driven,runRetrievalAgentLoop) is a separate research path backing a Taurirun_agent_querycommand. Both stream SSE but with different event names. Do not conflate them.
1. The chat turn — POST /api/chat (SSE)
File: packages/server/src/local/routes/chat.ts (1708 LOC — the largest route in the codebase).
This is not a JSON request/response endpoint. The server validates the body, then calls reply.hijack() and writes a raw text/event-stream. All validation and auth happen BEFORE the hijack — once hijacked, reply.status() is a silent no-op, so any 400/403 you get back is a normal JSON error; anything after that is SSE.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
message |
string |
yes | The user's turn. Max length WAGGLE_MAX_MESSAGE_LENGTH env (default 50000 chars) → else 400 MESSAGE_TOO_LONG. |
workspace |
string |
no | Workspace ID. workspaceId is accepted as a synonym (P0-4 backwards-compat). Defaults to 'default'. |
workspaceId |
string |
no | Alias for workspace. |
model |
string |
no | Model override. Falls back to workspace model → config default → 'claude-sonnet-4-6'. |
session |
string |
no | Session ID. Defaults to workspace, else 'default'. Determines the .jsonl file written. |
workspacePath |
string |
no | Explicit working dir. Path-traversal guarded: must resolve inside dataDir, else 400 PATH_TRAVERSAL. |
persona |
string |
no | Per-window persona override (takes precedence over workspace default for THIS request only). |
autonomy |
{ level: 'normal' | 'trusted' | 'yolo', expiresAt?: number } |
no | Relaxes the tool-confirmation gate. Expired (expiresAt < Date.now()) falls back to 'normal'. |
Pre-stream rejections (regular JSON, HTTP error codes)
| Condition | Status | Body |
|---|---|---|
Missing message |
400 | { error: 'message is required' } |
| Message too long | 400 | { error: 'Message too long (...)', code: 'MESSAGE_TOO_LONG' } |
| Injection score ≥ 0.7 | 400 | { error: 'Message blocked by security scanner', code: 'INJECTION_DETECTED' } (flags NOT leaked) |
| Viewer in team workspace | 403 | { error: 'Viewers cannot send messages...', code: 'VIEWER_READ_ONLY' } |
workspacePath escapes dataDir |
400 | { error: 'Invalid workspace path', code: 'PATH_TRAVERSAL' } |
Unsafe workspace/session segment |
400 | thrown by assertSafeSegment → Fastify default { statusCode: 400 } |
SSE response headers
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
Access-Control-Allow-Origin: <validated origin>
Each event is written as event: <name>\ndata: <json>\n\n.
SSE event catalogue (/api/chat)
These are the exact event names emitted via sendEvent(event, data). The frontend MUST handle all of them.
event: |
data shape |
Meaning |
|---|---|---|
token |
{ content: string } |
One streamed chunk of the assistant's text. Concatenate in order. |
step |
{ content: string } |
Human-readable progress line ("Recalling relevant memories...", "✔ tool approved", budget/compression notices, model-switch notes). |
tool |
{ name: string, input: object } |
Agent is invoking a tool. Pair with the step line from describeToolUse. |
tool_result |
{ name, result: string, duration?: number, isError: boolean } |
Tool finished. isError true when result starts with Error:/Error . |
file_created |
{ filePath: string, fileAction: 'write' | 'edit' | 'generate' } |
Emitted after write_file / edit_file / generate_docx succeed. |
approval_required |
{ requestId, toolName, input, sourceWorkspaceId, ...trustMeta } |
Blocking. Agent is paused; client must POST to /api/approval/:requestId (see §4). For install_capability, trustMeta adds riskLevel, approvalClass, trustSource, assessmentMode, explanation, permissions. |
gepa_choices |
{ original, expanded, clarifyingQuestions: string[], intent } |
GEPA optimizer expanded a vague first message; offers ask-first clarification choices. |
model_switch |
{ model, reason, primary } |
Active model changed mid-turn (budget cap, retry/fallback, credential exhaustion). |
notification |
{ type: 'workflow_captured', title, message, pattern } |
Auto-skill capture suggested a repeatable workflow. |
done |
see below | Terminal success. Full final content + usage + cost. |
error |
{ message: string } |
Terminal failure. User-friendly message only (raw traces/context never leaked). |
done data:
{
"content": "<final assistant text, may include appended disclaimers/notes>",
"usage": { "inputTokens": 0, "outputTokens": 0 }, // AgentResponse.usage
"toolsUsed": ["save_memory", "..."],
"model": "claude-sonnet-4-6",
"cost": 0.001234, // present only when usage known; rounded to 1e-6
"tokens": { "input": 0, "output": 0 } // present only when cost present
}
Note: in echo mode and command-only paths,
donecarriesusage: { prompt_tokens, completion_tokens, total_tokens }(all 0) andtoolsUsed: []— a different usage shape than the agent-loopdone(inputTokens/outputTokens). The frontend should tolerate both.
What happens inside one chat turn (server-side, in order)
- Validate body, scan for injection, RBAC + path guards (all pre-hijack).
reply.hijack(), write SSE headers, wireAbortControllerto client disconnect (request.raw.on('close')).- Resolve model with fallback chain: explicit → workspace → config default →
claude-sonnet-4-6; apply budget-model and smart-routing (routeMessage) overrides. - Load/create session history (RAM cache
sessionHistories, elseloadSessionMessagesfrom disk); push user message;persistMessageto.jsonl. - Create a per-session
Orchestratorscoped to the workspace mind (sessionManager.getOrCreate), else fall back to the shared singleton. - Probe LiteLLM availability → choose agent-loop, echo mode, or slash-command path.
- Slash command? → run via
commandRegistry.execute(works even in echo mode). Result either streams astokens +done, or — if prefixedAGENT_LOOP_REROUTE::— falls through to the agent loop with a rewritten message. - Agent-loop path: auto-recall memory (
auto_recalltool events), GEPA expand (first message only), ambiguity guard, build system prompt (persona + profile + skills + workspace-now + behavioral spec), filter tools by persona/availability, register thepre:toolconfirmation hook, compress context, then callrunAgentLoopwithstream: trueandonToken/onToolUse/onToolResultcallbacks that emit the SSE events above. - Credential-pool key rotation + model fallback on retryable errors.
- Post-processing: cost tracking, trace finalize, auto-save memory, skill distillation, KG entity extraction, correction detection, regulated-persona disclaimers, grounding hedge notes.
- Push assistant message to history,
persistMessage, emitdone. finally: unregister thepre:toolhook, finalize any pending trace asabandoned,raw.end().
DELETE /api/chat/history
Clears a session's in-RAM state. Querystring ?session=<id> (default 'default'). Evicts the session from sessionHistories, systemPromptCache, compressionSummaries, and sessionToolSequences. Returns { ok: true, cleared: <sessionId> }. Does not delete the on-disk .jsonl — use DELETE /api/sessions/:sessionId for that.
2. Conversation history — GET /api/history
File: agent.ts. Loads a session's messages, RAM-first then disk (dataDir/workspaces/<workspace>/sessions/<session>.jsonl).
Querystring: session? (defaults to workspace then 'default'), workspace? (default 'default').
Response:
{
"sessionId": "default",
"messages": [
{ "id": "hist-0", "role": "user", "content": "...", "timestamp": "ISO-8601" }
],
"count": 1
}
3. Agent status / cost / model — agent.ts
| Method | Path | Purpose | Response |
|---|---|---|---|
| GET | /api/agent/status |
Agent + cost snapshot | { running: true, model, tokensUsed, estimatedCost, turns, usage } |
| GET | /api/agent/cost |
Detailed cost breakdown | { summary: <formatted string>, ...stats } |
| POST | /api/agent/cost/reset |
(No-op) cost reset | { ok: true, message: 'Cost tracking resets on server restart' } |
| GET | /api/agent/model |
Current model | { model } |
| PUT | /api/agent/model |
Switch model | body { model } → { ok: true, model }; missing model → 400 { error } |
| GET | /api/agents/active |
Sub-agent orchestrator state | { workers: [...], active: [...] } (empty arrays when no workflow running) |
4. Tool-execution approvals — approval.ts
When the agent wants to run a gated tool, the chat SSE stream emits approval_required and pauses (awaiting a Promise registered in pendingApprovals). The frontend resolves it via a separate HTTP call. Auto-denies after 5 minutes (fail-safe).
| Method | Path | Body / Params | Purpose |
|---|---|---|---|
| POST | /api/approval/:requestId |
{ approved: boolean, always?: boolean, reason?: string, sourceWorkspaceId?: string|null } |
Approve/deny the paused tool. always: true persists a grant so future identical (tool + target) calls auto-pass. 404 if no such pending request. Returns { ok: true, requestId, approved, always }. |
| GET | /api/approval/pending |
— | List paused approvals (for reconnect/recovery): { pending: [{ requestId, toolName, input, timestamp }], count }. |
| GET | /api/approval/grants |
— | List persistent "always allow" grants: { grants, count }. |
| DELETE | /api/approval/grants/:id |
— | Revoke one grant. 404 if not found. |
| POST | /api/approval/grants/clear |
— | Wipe all grants. { ok: true }. |
The
requestIdyou POST back is the exactrequestIdfrom theapproval_requiredSSE event. EchosourceWorkspaceIdback verbatim so the grant is scoped correctly.
5. Slash commands — commands.ts + command-registry.ts
| Method | Path | Body | Purpose |
|---|---|---|---|
| POST | /api/commands/execute |
{ command: string, workspaceId?: string } |
Run a slash command out-of-band (not through chat SSE). Missing command → 400. Returns { result: string, command: string }. |
- A command is any input matching
/^\/\w/(commandRegistry.isCommand). Parsed as/<name> <args>; aliases supported. - This route wires a subset of
CommandContext:searchMemory,getWorkspaceState,listSkills. It omitsrunWorkflowandspawnAgent(those need the full agent loop), so workflow commands like/research,/plan,/spawnreturn their "not available in this context" fallback. To run those, send the command throughPOST /api/chatinstead — chat detects the slash command and, when the LLM is available, can reroute it through the agent loop via theAGENT_LOOP_REROUTE::prefix. - There is no
GET /api/commandslisting endpoint. The registry haslist()/search()in code but they are not exposed over HTTP, so the frontend cannot fetch the command catalogue from the server — it must hardcode/derive autocomplete client-side.
6. Sessions CRUD — sessions.ts + session-utils.ts
Sessions are JSONL files on disk: dataDir/workspaces/<workspaceId>/sessions/<sessionId>.jsonl. Line 0 is a { type: "meta", title, summary?, created, distilled?, outcome? } record; subsequent lines are { role, content, timestamp }. All path segments pass assertSafeSegment.
| Method | Path | Params / Query / Body | Purpose & response |
|---|---|---|---|
| GET | /api/workspaces/:workspaceId/sessions |
query ?hideEmpty=true |
List sessions (sorted by lastActive desc). Returns SessionInfo[]. Unknown workspace → []. |
| GET | /api/workspaces/:workspaceId/sessions/search |
query ?q=<≥2 chars>&limit=<≤50> |
Full-text search across session content/summary. <2 chars → 400. Unknown ws → 404. Returns SessionSearchResult[]. |
| GET | /api/workspaces/:workspaceId/sessions/:sessionId/export |
— | Export one session as Markdown (Content-Type: text/markdown). 404 if missing. |
| GET | /api/workspaces/:workspaceId/sessions/:sessionId/timeline |
— | Tool-event timeline (TimelineEvent[], heuristically reconstructed from assistant content patterns; spawn_agent nests children). 404 if missing. |
| POST | /api/workspaces/:workspaceId/sessions |
body { title? } |
Create a session (writes meta line). 404 if workspace missing. 201 + SessionInfo. |
| PATCH | /api/sessions/:sessionId |
body { title }, query ?workspace= |
Rename. Searches all workspaces if workspace omitted. Missing title → 400; not found → 404. Returns { id, title }. |
| DELETE | /api/sessions/:sessionId |
query ?workspace= |
Delete the .jsonl. Searches all workspaces if omitted. 404 if not found. Returns { deleted: true }. |
| GET | /api/sessions/:sessionId/summary |
query ?workspace= |
Structured post-session summary (counts user/assistant/tool/memory/doc). 404 if not found. |
SessionInfo (list/create shape)
{ id: string; title: string; summary: string | null; messageCount: number; lastActive: string; created: string }
/api/sessions/:sessionId/summary shape
{
"sessionId", "title", "messageCount",
"userMessages", "assistantMessages",
"toolsUsed", "memoriesSaved", "documentsCreated",
"summary", "lastActive", "created"
}
Other types from session-utils.ts the frontend may render
SessionSearchResult:{ sessionId, title, summary, matchCount, snippets: [{ text, role }], lastActive }TimelineEvent:{ id, timestamp, toolName, status: 'success'|'error', durationMs, inputPreview, outputPreview, fullInput, fullOutput, children? }ThreadInfo:{ title, lastActive, freshness: 'fresh'|'aging'|'stale', messageCount, sessionId }(fresh<2d, aging<7d, else stale — timestamp-based, NOT importance)ProgressItem/OpenQuestion/SessionOutcome/DistillableSession— heuristic extractions used by/catchup-style features (not directly exposed as REST here).
Summaries are lazily generated (
generateSessionSummary) for sessions with ≥4 messages and persisted back into the meta line — no LLM needed.
7. Agent groups — agent-groups.ts
Multi-agent group configs stored in dataDir/agent-groups.json. A group has a strategy (parallel | sequential | coordinator) and ordered members.
| Method | Path | Body | Purpose |
|---|---|---|---|
| GET | /api/agent-groups |
— | List all groups (AgentGroup[]). |
| POST | /api/agent-groups |
{ name, description?, strategy, members: AgentGroupMember[] } |
Create. Missing name → 400. 201 + group. |
| PATCH | /api/agent-groups/:id |
partial of the above | Update fields. 404 if not found. |
| DELETE | /api/agent-groups/:id |
— | Delete. 404 if not found. Returns { deleted: true }. |
| POST | /api/agent-groups/:id/run |
{ task, teamId? } |
Placeholder — does NOT execute. Returns a queued stub: { jobId, groupId, groupName, strategy, memberCount, task, status: 'queued' }. Missing task → 400. |
AgentGroup: { id, name, description?, strategy, members, createdAt }.
AgentGroupMember: { agentId, roleInGroup: 'lead'|'worker'|string, executionOrder }.
8. One-shot structured retrieval — POST /api/agent/run (SSE)
File: agent-run.ts. Backs the Tauri run_agent_query command. Shape-aware research flow via runRetrievalAgentLoop — distinct from /api/chat. Requires multiMind.personal + an embedding provider, else 503.
Request body (AgentRunBody)
| Field | Type | Notes |
|---|---|---|
question |
string |
required; non-string → 400 { error: 'question is required' }. |
shape |
string? |
Prompt-shape name. Validated against listShapes(); unknown → warn + fall back to model-default (NOT a 400). |
model |
string? |
Default 'claude-sonnet-4-6'. |
persona |
string? |
Default 'general-purpose'. |
workspace / workspaceId |
string? |
Target mind; 'personal' or absent → personal mind. |
maxSteps |
number? |
Default 5. |
maxRetrievalsPerStep |
number? |
Default 8. |
SSE events (/api/agent/run) — different from /api/chat
event: |
data |
|---|---|
started |
{ shape, shapeRequested, shapeRecognized, model } |
progress |
per-step AgentRunProgressEvent (from the loop's onProgress) |
finalized |
{ rawResponse, normalizedResponse, promptShapeName, stepsTaken, retrievalCalls, loopExhausted, totalTokensIn, totalTokensOut, totalCostUsd, totalLatencyMs } |
error |
{ error: string } |
done |
{ ok: true } (always last) |
9. Persistence & context model (mental model)
- Session file = JSONL, append-only. Meta line first, then
{ role, content, timestamp }per message. Written bypersistMessage, read byloadSessionMessages. - History cache =
server.agentState.sessionHistories: Map<sessionId, {role,content}[]>— RAM mirror, lazily hydrated from disk. - Context window =
MAX_CONTEXT_MESSAGES = 50. When a budget model exists, chat uses intelligentcompressConversation(LLM-summarize the middle); otherwise the simpleapplyContextWindowsliding window with a prepended[Context summary — N earlier messages compressed]system message. - Governance (
chat-governance.ts): for team workspaces, blocked-tool policies are fetched directly from the team server (5-min cache), no HTTP loopback.
10. Flow diagram — submitting a chat turn with a gated tool
sequenceDiagram
participant UI as Frontend
participant Chat as POST /api/chat (SSE)
participant Loop as runAgentLoop
participant Appr as POST /api/approval/:id
participant Disk as session .jsonl
UI->>Chat: { message, workspace, session, model?, persona?, autonomy? }
Note over Chat: validate · injection scan · RBAC · path guard (pre-hijack)
alt rejected
Chat-->>UI: 400/403 JSON error
else accepted
Chat->>Disk: persist user message
Chat-->>UI: event: step "Recalling relevant memories..."
Chat-->>UI: event: tool / tool_result (auto_recall)
Chat->>Loop: stream=true, onToken/onToolUse/onToolResult
Loop-->>UI: event: token (xN, assistant text)
Loop-->>UI: event: tool { name, input }
Note over Loop: gated tool hits pre:tool hook
Loop-->>UI: event: approval_required { requestId, toolName, input }
UI->>Appr: { approved: true, always? }
Appr-->>Loop: resolve(true) (or auto-deny after 5 min)
Loop-->>UI: event: tool_result { name, result, isError }
Loop-->>UI: event: token (xN, more text)
Chat->>Disk: persist assistant message
Chat-->>UI: event: done { content, usage, toolsUsed, model, cost? }
end
Note over Chat: on failure → event: error { message } (+ raw turn still persisted)