Phase 2 of the Flynn roadmap. Adds a WebSocket gateway server that starts alongside the Telegram bot, providing real-time API access to the agent, sessions, and tools. Protocol: JSON-RPC-like (request/response/event) over WebSocket. 8 methods: agent.send, agent.cancel, sessions.list, sessions.history, sessions.create, tools.list, tools.invoke, system.health. Auth: Bearer token + Tailscale identity header support. Session bridge: per-connection agent instances with shared model router. New files: src/gateway/ (protocol, router, server, auth, session-bridge, handlers for agent/sessions/tools/system). 57 new tests (181 total), typecheck clean.
6.0 KiB
Phase 2: WebSocket Gateway — Implementation Plan
Goal
Add a WebSocket gateway to the Flynn daemon that allows real-time communication from web clients (and eventually any WS client). The gateway wraps existing daemon components (session manager, agent, tool registry) — no refactoring of existing code.
Approach
Incremental, additive. Existing Telegram bot and TUI continue working unchanged. The gateway is a new module that starts alongside them.
Architecture
src/gateway/
├── protocol.ts # JSON-RPC message types + event types
├── server.ts # WebSocket server (ws library), connection lifecycle
├── router.ts # Method routing → handler dispatch
├── auth.ts # Token auth + Tailscale identity headers
├── session-bridge.ts # Maps WS client connections to agent sessions
└── handlers/
├── agent.ts # agent.send (streaming), agent.cancel, agent.status
├── sessions.ts # sessions.list, sessions.history, sessions.create
├── tools.ts # tools.list, tools.invoke
└── system.ts # system.health, system.info
Protocol
JSON-RPC-like over WebSocket (not full JSON-RPC 2.0 — simpler):
// Client → Server
interface GatewayRequest {
id: number; // Client-assigned request ID
method: string; // e.g. "agent.send"
params: object; // Method-specific parameters
}
// Server → Client (success)
interface GatewayResponse {
id: number; // Matches request ID
result: object; // Method-specific result
}
// Server → Client (error)
interface GatewayError {
id: number; // Matches request ID
error: {
code: number; // Error code (negative = protocol, positive = app)
message: string; // Human-readable description
};
}
// Server → Client (streaming event, multiple per request)
interface GatewayEvent {
id: number; // Matches originating request ID
event: string; // Event type name
data: object; // Event-specific payload
}
Event Types (for agent.send streaming)
| Event | Data | When |
|---|---|---|
content |
{ text: string } |
Text chunk from model |
tool_start |
{ tool: string, args: object } |
Tool execution beginning |
tool_end |
{ tool: string, result: { success, output, error? } } |
Tool execution complete |
done |
{ content: string, usage: { inputTokens, outputTokens } } |
Final response |
error |
{ code: number, message: string } |
Error during processing |
Error Codes
| Code | Meaning |
|---|---|
| -1 | Parse error (invalid JSON) |
| -2 | Invalid request (missing id/method) |
| -3 | Method not found |
| -4 | Authentication required |
| -5 | Authentication failed |
| 1 | Session not found |
| 2 | Tool not found |
| 3 | Agent busy (already processing) |
| 4 | Request cancelled |
Methods
agent.send
Send a message to the agent and receive streaming response.
Params: { message: string, sessionId?: string }
Events: content, tool_start, tool_end, done, error
Response: (none — final state sent as "done" event)
agent.cancel
Cancel the currently running agent request.
Params: { sessionId?: string }
Response: { cancelled: boolean }
sessions.list
List all active sessions.
Params: {}
Response: { sessions: [{ id: string, messageCount: number }] }
sessions.history
Get message history for a session.
Params: { sessionId: string, limit?: number, offset?: number }
Response: { messages: Message[], total: number }
sessions.create
Create a new session.
Params: { sessionId?: string }
Response: { sessionId: string }
tools.list
List all registered tools.
Params: {}
Response: { tools: [{ name, description, inputSchema }] }
tools.invoke
Directly invoke a tool (bypasses agent).
Params: { tool: string, args: object }
Response: { success: boolean, output: string, error?: string }
system.health
Health check.
Params: {}
Response: { status: "ok", uptime: number, version: string, sessions: number, tools: number }
Session Bridge
Each WebSocket connection is associated with a session:
- Client connects → assigned default session ID
ws:{connectionId} - Client can specify
sessionIdinagent.sendto use a named session - Sessions are created on demand via SessionManager
- Multiple WS clients can share a session (e.g. multiple browser tabs)
- Disconnection does NOT destroy the session (persistence via SQLite)
Auth (Phase 2b)
Two auth modes (checked in order):
- Token auth:
Authorization: Bearer <token>header on WS upgrade, or{ method: "auth", params: { token: "..." } }as first message - Tailscale identity:
Tailscale-User-Loginheader set by Tailscale Funnel/proxy
Config addition:
server:
port: 18800
auth:
token: "optional-static-token" # If set, required for all WS connections
tailscale_identity: true # Trust Tailscale-User-Login header
No auth initially (Phase 2a) — gateway only listens on localhost.
Implementation Order
protocol.ts— Types only, no runtime coderouter.ts— Method dispatch (pure function, easy to test)session-bridge.ts— Client-to-session mappinghandlers/system.ts— Simplest handler, proves the patternhandlers/sessions.ts— Session listing/historyhandlers/tools.ts— Tool listing/invocationhandlers/agent.ts— The main handler (streaming, tool events)server.ts— WebSocket server, ties everything together- Wire into
daemon/index.ts - Tests throughout
Dependencies
Need ws package:
pnpm add ws
pnpm add -D @types/ws
Test Strategy
- Unit tests for protocol message validation
- Unit tests for router dispatch
- Unit tests for each handler (mock session manager, agent, tool registry)
- Integration test: WS client → server → handler → response
- Test streaming events for agent.send
Plan Version: 1.0 Created: 2026-02-05 Parent: docs/plans/2026-02-05-openclaw-parity-design.md Phase 2