Files
waggle-os/docs/backend-map/sections/03a-api-chat-agents.md
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

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 Tauri run_agent_query command. 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, done carries usage: { prompt_tokens, completion_tokens, total_tokens } (all 0) and toolsUsed: [] — a different usage shape than the agent-loop done (inputTokens/outputTokens). The frontend should tolerate both.

What happens inside one chat turn (server-side, in order)

  1. Validate body, scan for injection, RBAC + path guards (all pre-hijack).
  2. reply.hijack(), write SSE headers, wire AbortController to client disconnect (request.raw.on('close')).
  3. Resolve model with fallback chain: explicit → workspace → config default → claude-sonnet-4-6; apply budget-model and smart-routing (routeMessage) overrides.
  4. Load/create session history (RAM cache sessionHistories, else loadSessionMessages from disk); push user message; persistMessage to .jsonl.
  5. Create a per-session Orchestrator scoped to the workspace mind (sessionManager.getOrCreate), else fall back to the shared singleton.
  6. Probe LiteLLM availability → choose agent-loop, echo mode, or slash-command path.
  7. Slash command? → run via commandRegistry.execute (works even in echo mode). Result either streams as tokens + done, or — if prefixed AGENT_LOOP_REROUTE:: — falls through to the agent loop with a rewritten message.
  8. Agent-loop path: auto-recall memory (auto_recall tool events), GEPA expand (first message only), ambiguity guard, build system prompt (persona + profile + skills + workspace-now + behavioral spec), filter tools by persona/availability, register the pre:tool confirmation hook, compress context, then call runAgentLoop with stream: true and onToken/onToolUse/onToolResult callbacks that emit the SSE events above.
  9. Credential-pool key rotation + model fallback on retryable errors.
  10. Post-processing: cost tracking, trace finalize, auto-save memory, skill distillation, KG entity extraction, correction detection, regulated-persona disclaimers, grounding hedge notes.
  11. Push assistant message to history, persistMessage, emit done.
  12. finally: unregister the pre:tool hook, finalize any pending trace as abandoned, 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 requestId you POST back is the exact requestId from the approval_required SSE event. Echo sourceWorkspaceId back 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 command400. 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 omits runWorkflow and spawnAgent (those need the full agent loop), so workflow commands like /research, /plan, /spawn return their "not available in this context" fallback. To run those, send the command through POST /api/chat instead — chat detects the slash command and, when the LLM is available, can reroute it through the agent loop via the AGENT_LOOP_REROUTE:: prefix.
  • There is no GET /api/commands listing endpoint. The registry has list()/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 runRetrievalAgentLoopdistinct 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 by persistMessage, read by loadSessionMessages.
  • 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 intelligent compressConversation (LLM-summarize the middle); otherwise the simple applyContextWindow sliding 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)