Gateway Architecture
The Gateway Hub is the central WebSocket entry point for all clients — web UI, TUI, mobile, IDE extensions, and channel adapters. Every message, event, and command flows through a single authenticated connection with unified security, routing, and observability.
The web app holds one gateway connection per browser tab (web/lib/gateway.ts), shared by the chat page, the permission prompts, the recommended-models panel and the documents page. The legacy /ws and /ws/permissions sockets were retired (coworking S0d); the only other sockets are the browser extension’s /ws/browser-bridge and voice (/voice, /voice/media/:provider).
Architecture
Section titled “Architecture” ┌────────────────────────────────────┐ Web UI ─────────────►│ │ TUI (local) ────────►│ GATEWAY HUB │ Mobile ─────────────►│ │ IDE/ACP ────────────►│ - ConnectionManager (auth, budgets)│──► Root agent Telegram adapter ───►│ - GatewayEventBus (pub/sub) │──► Agent Workers Slack adapter ──────►│ - CommandRegistry (9 commands) │──► Tool Execution Teams webhook ──────►│ - FeedbackManager (emoji/stall) │──► Permission Mgr WhatsApp webhook ───►│ - PresenceTracker (idle timeouts) │ │ - RateLimiter (per-connection) │ └────────────────────────────────────┘Connection Lifecycle
Section titled “Connection Lifecycle”CONNECTING → AUTHENTICATING → ACTIVE → DRAINING → CLOSED │ ├─ Idle timeout ├─ Rate limit exceeded ├─ Auth token expired └─ Server shutdown (graceful drain)Authentication
Section titled “Authentication”Clients connect to ws://host:port/gateway and must send an auth message within 5 seconds.
| Client Type | Auth Method | Trust Level |
|---|---|---|
| Web UI | session_token (a 60 s ticket from GET /api/auth/ws-ticket, or the stored bearer) | user |
| TUI / editor | session_token (the CLI login in ~/.octipus/session.json) | user |
| Mobile/IDE | session_token or api_key | user |
| Automation | api_key (a personal API token, octi_…) | user |
| Live-artifact SDK (embed page) | artifact_token ({ artifactId, token }, minted per page view) | user; may only ping and (un)subscribe artifact:<id> of that one artifact |
There are two methods for people, session_token and api_key, plus
artifact_token for live-artifact embeds, and one trust level for people:
user. Trust never widens what a connection may see or touch:
- Events reach a connection only when they belong to its user.
- Every ownership check compares user ids — joining a session,
/history,/proposals, answering a permission request or approval,agent.stop. An admin’s connection gets no further than anyone else’s. - Admin-only commands (
/reload-extensions,/mcp reconnect) readusers.is_adminfrom the database when they run. - An admin answers someone else’s permission request or approval only through
the audited REST routes
POST /api/admin/permission-requests/:id/resolveandPOST /api/admin/approvals/:id/resolve(each takes areason).
The machine token (local, ~/.octipus/local-token) and the hmac adapter
method were removed: the first reached every user’s sessions from loopback,
the second was never wired. The TUI signs in with the CLI login instead (see
the TUI guide).
Client address
Section titled “Client address”The address used for the pre-auth cap and the audit log is the socket’s peer
address. X-Forwarded-For / X-Real-IP are believed only when that peer is
listed in security.trustedProxies (TRUSTED_PROXIES); see
CONFIGURATION.md. REST rate limits, login
lockouts and the audit log use the same rule (src/security/client-ip.ts).
Connection Budgets
Section titled “Connection Budgets”gateway.maxConnectionsPerUser(default 20) signed-in connections per user — one per browser tab, plus terminals. The one over the cap getsauth_errorToo many connections; the web shows “Too many open tabs” with a Retry and tries again every 30 s, the TUI keeps its login and says so.- Max 20 connections per address that have not authenticated yet. There is no per-address cap on signed-in connections: behind a reverse proxy every client shares one address, and that cap would become an install-wide one.
gateway.maxFrameBytes(default 262144, 256 KiB) is the socket’smaxPayload(read at server start): a bigger client frame closes the connection with 1009.auth_okcarries that same startup value asmaxFrameBytes(not a later config change the socket does not enforce), so a client can refuse such a frame itself (the web chat page and the TUI do). Files do not travel in frames: the web and the TUI upload them withPOST /sessions/:id/attachmentsand sendfileRefs; inlinechat.sendattachmentsare held to 256 KiB each.
Protocol
Section titled “Protocol”Client → Gateway
Section titled “Client → Gateway”| Message Type | Description |
|---|---|
auth | Authentication handshake (must be first message) |
chat.send | Send a chat message to the root agent |
chat.interject | Side-channel question sent while root agent is running (non-blocking) |
chat.steer | Inject a message into the running root agent turn to change course mid-flight |
command | Execute a gateway command (e.g., /persona, /status) |
subscribe | Subscribe to event patterns over your own events (e.g., agent.*) and/or resources (e.g., artifact:<id>), each access-checked |
unsubscribe | Remove event patterns and/or resources |
permission.respond | Approve/deny a permission request |
approval.respond | Approve/deny a pipeline approval (the requester only) |
agent.stop | Stop one of your own running agents |
voice.set | { sessionId, on } — put one of your sessions into (or out of) voice mode for this connection: the propose-then-confirm gate applies, and its lifecycle is narrated to this connection as voice.speak. on:false counts only for the session this connection turned on; the session leaves voice mode when no connection of the user holds it there any more (also on close) |
replay | { sessionId, afterEventId? } — the events of one of your own sessions after afterEventId, for a reconnecting client (answered with replay; without afterEventId the answer is gap: true with no events) |
ping | Heartbeat |
chat.send takes an optional workspaceId (one you own): a session the
server has not seen yet is created there. Without it, the connection’s
workspace (?workspace=, else your default) is used; an existing session
keeps its own.
Gateway → Client
Section titled “Gateway → Client”| Message Type | Description |
|---|---|
auth_ok | Authentication successful (includes capabilities) |
auth_error | Authentication failed |
event | Gateway event (agent lifecycle, chat response, etc.) |
command.result | Result of a command execution |
error | Error message (rate limit, validation, etc.) |
pong | Heartbeat response with server time |
subscribed | The resources of a subscribe that passed the access check (a refused one answers error FORBIDDEN) |
events_dropped | Notification that events were dropped from the replay buffer |
permission.pending | { requests, approvals } — your open permission requests and root-agent approvals. Sent after every subscribe with patterns, and again after a permission.respond that found the request already answered. Authoritative: the client replaces its list |
replay | { sessionId, events, gap } — answer to replay; gap: true when afterEventId is missing or no longer buffered and the client must reload from REST |
Pending snapshot
Section titled “Pending snapshot”A tab opened after a prompt was raised still shows it: on subscribe the hub
reads the user’s open permission requests and root-agent approvals and sends
permission.pending. Live permission.request, permission.resolved,
agent.approval_required and approval.resolved events raised while the
snapshot is read are held (ConnectionContext.hydrationQueue) and sent after
it, minus a request already in the snapshot — so a resolution during
hydration cannot resurrect a request, and no request arrives twice.
Web frame mapping
Section titled “Web frame mapping”What the web consumed on the retired /ws and /ws/permissions, and where it
comes from now:
| Legacy frame | Gateway |
|---|---|
connected | auth_ok |
agent_event (incl. thought/text_delta) | chat.delta, agent.action, agent.event (the worker’s own kind in payload.agentEvent) |
turn_event (status_update, approval_required, worker_*, team_*, pipeline_event), chat_response | rootAgent.status, agent.approval_required, agent.spawned / agent.completed, team.*, pipeline.event, chat.response |
chat_error | chat.error (to every tab of the user; the sender also gets error CHAT_ERROR) |
permission_request | permission.request, permission.resolved |
pending_requests | permission.pending |
response_recorded, approval_resolved | permission.resolved, approval.resolved (every tab; a refused answer is error APPROVAL_NOT_FOUND / APPROVAL_EXPIRED / PERMISSION_ERROR to the sender) |
swarm_event | swarm.* |
document_event | document.enqueued / processing / completed / failed (stamped with the uploader) |
model_install_progress | model.install_progress (stamped with who started the install) |
steer_result | chat.message { injected: true } (source: steer:<connectionId>), or the chat.send fallback when nothing is running |
speak | voice.speak |
in-app proactive delivery (webchat:<you>) | chat.message { proactive: true } |
client chat, steer, approval_response, permission_response, voice | chat.send, chat.steer, approval.respond, permission.respond, voice.set |
Event Bus
Section titled “Event Bus”The GatewayEventBus is a typed pub/sub system that replaces scattered EventEmitter patterns:
- Pattern matching: Subscribe to
agent.*,swarm.*,chat.message, or*(all events) - Replay buffer: Last 200 events per session for reconnection (
swarm.*events included;voice.speakis never kept). At mostgateway.replayMaxSessionssessions (default 500) keep one — the least recently active is dropped first — and a session’s buffer goes when it is deleted, archived or purged with its space (session-lifecycle.ts). A client reads it withreplay, for its own existing sessions only. - Error isolation: One handler throwing doesn’t break other handlers
- Per-user delivery: every event names its user (
GatewayEvent.userIdis required) and goes to that user’s connections only — trust level and admin rights widen nothing. The only user-less types are listed inGLOBAL_EVENT_TYPES(protocol.ts) with their reason, and none of them reach a client by user.publishEvent(event, only)can narrow delivery to some of the user’s connections (voice.speakgoes to the voice-mode connection only), never widen it. - User-less types: resource events (artifact, room and similar) go only to a resource through
publishToResource; two more stay on the internal bus:audit(connection-manager signals; the pre-auth ones have no user yet) andextension.notify. Host extensions are files the operator installs (~/.octipus/extensions,<cwd>/.octipus/extensions), so there is no installing user to stamp.extension.notifytherefore stays user-less and on the internal bus (other extensions read it); it never reaches a client. This differs from the coworking spec (§4.1 L4), which planned to stamp it with the extension’s installing user. - Resource delivery:
hub.publishToResource(resource, message)sends to connections whoseConnectionContext.resourcesholds the resource, outside the bus and the user rule. A connection joins a resource only through asubscribethatcanSubscribeToResource(resource-access.ts) accepts:artifact:<id>for the owner of the artifact’s workspace, or for anartifact_tokenconnection of that artifact;chat:inboxfor any user connection (the web chat page holds it while open: an in-app delivery towebchat:<you>counts as delivered only when one of your connections does). Live-artifact events (artifact.data_updated,artifact.version_updated,artifact.source_error) travel this way.
Event Families
Section titled “Event Families”| Family | Events | Emitter |
|---|---|---|
chat.* | chat.message, chat.response, chat.delta, chat.error | Root agent, message handler, WebChatChannel (in-app delivery) |
agent.* | agent.spawned, agent.completed, agent.failed, agent.stopped, agent.status, agent.event, agent.action, agent.iteration, agent.blocked | AgentManager / Executor |
swarm.* | swarm.node_spawned, swarm.node_completed, swarm.budget_warning, swarm.call_graph_cycle_blocked, swarm.narration | SwarmSpawner / AgentWorker / SwarmCallGraph |
permission.* | permission.request, permission.resolved | PermissionManager |
approval.* | agent.approval_required, approval.resolved | ApprovalManager (root agent) |
document.* | document.enqueued, document.processing, document.completed, document.failed | Document queue |
model.* | model.install_progress | hwfit installer |
voice.* | voice.speak | Narrator (event bridge) |
tool.* | tool.invoked, tool.result | ToolExecutor |
Additional event families exist for worker lifecycle, pipelines, sessions, and extensions. See src/core/gateway/protocol.ts for the complete list of GatewayEventType definitions.
swarm.node_spawned payload includes rootSessionId, nodeId, parentNodeId, kind, depth, topicPath, role, model, budgets, and a taskBriefPreview (first 200 chars). The web UI composes the live swarm tree from these events and falls back to GET /api/swarm/nodes for rehydration.
Event Bridge
Section titled “Event Bridge”The connectEventBridge() function subscribes to:
- Root agent events → mapped to
chat.response,agent.spawned,agent.completed,approval.resolved, etc.; each is also narrated (voice.speak) to the connections that put its session into voice mode - Agent manager events → mapped to
agent.*andchat.delta, with the agent’s session - Permission requests and resolutions →
permission.request,permission.resolved - The document queue →
document.*; local model installs →model.install_progress
Every source is imported when the bridge connects; one that cannot load fails startup.
Rate Limiting
Section titled “Rate Limiting”Sliding window rate limiter per connection per action type:
| Action | User Limit |
|---|---|
chat.send | 30/min |
command | 60/min |
subscribe | 30/min |
default (chat.steer, permission.respond, replay, voice.set, …) | 60/min |
Room and document frames (coworking S2/S3) will get their own buckets.
Commands
Section titled “Commands”Built-in commands available via the gateway protocol:
| Command | Aliases | Description |
|---|---|---|
/help | /h, /? | List available commands |
/status | /s | Show session status and running agents |
/persona | Configure the root agent persona | |
/abort | /stop, /cancel | Cancel your running agents |
/clear | /cls | Clear conversation display |
/compact | Compact session context | |
/cost | Show cumulative token usage and cost for this session | |
/diff | Show git diff for workspace changes | |
/changes | Review git changes — list, or a file diff | |
/reload-extensions | /reload | Re-discover and reload user extensions |
/persona | Configure the root agent persona | |
/version | /v | Show Octipus version and build info |
Feedback System
Section titled “Feedback System”The FeedbackManager maps agent lifecycle events to emoji reactions:
| Agent State | Emoji | Debounce |
|---|---|---|
| Classifying | 🤔 | 700ms |
| Agent started | 🧠 | 700ms |
| Tool use | 🔧/📖/💻/🔍 | 700ms |
| Completed | ✅ | Immediate |
| Error | ❌ | Immediate |
| Soft stall (10s) | 😐 | — |
| Hard stall (30s) | 😬 | — |
Channel Adapters
Section titled “Channel Adapters”Channel adapters connect to the gateway using the adapter protocol:
BaseChannel (abstract, src/channels/interface.ts) ├── TelegramChannel (wraps Grammy) ├── SlackChannel (wraps Bolt, Socket Mode) ├── TeamsChannel ├── WhatsAppChannel └── WebChatChannelAdapters are reached through the UnifiedMessageInterface (getUMI()), which
owns registration and routing — see the channel-adapter guide.
Adapter communication
Section titled “Adapter communication”Channel adapters call the UnifiedMessageInterface methods and receive shared
backend events. They do not send a separate channel.message / channel.send
wire protocol. The actual gateway frames are defined in
src/core/gateway/protocol.ts; use that schema when implementing a client.
API Endpoints
Section titled “API Endpoints”| Endpoint | Description | Auth |
|---|---|---|
GET /api/gateway/status | Hub status (connections, events) | User |
GET /api/gateway/connections | Active connection list | Admin |
GET /api/gateway/events/stats | Event bus metrics | User |
GET /api/gateway/adapters | Channel adapter status | User |
src/core/gateway/├── protocol.ts # Typed protocol schemas (Zod) + helpers├── connection-manager.ts # Auth, budgets, connection lifecycle├── event-bus.ts # Central pub/sub with replay buffer├── rate-limiter.ts # Sliding window per-connection limiter├── cli-session.ts # The CLI login (~/.octipus/session.json) terminal clients use├── presence.ts # Who's connected, idle timeouts├── commands.ts # Command registry + 9 built-in commands├── feedback.ts # Emoji reactions + stall detection├── message-handler.ts # Routes messages to root agent/permissions├── event-bridge.ts # Bridges root agent events to event bus├── connection-manager.ts # Per-connection state, budgets, subscriptions├── message-handler.ts # Dispatches inbound client messages├── presence.ts # Presence tracking├── rate-limiter.ts # Per-connection rate limits├── steering.ts # chat.steer / chat.interject handling├── hub.ts # GatewayHub singleton (wires everything)└── index.ts # Public exports
src/channels/├── interface.ts # BaseChannel + UnifiedMessageInterface (getUMI)├── discovery.ts # Channel discovery / enablement├── linking.ts # Account-linking codes├── telegram/ slack/ teams/ whatsapp/ webchat/
src/api/├── gateway-ws.ts # /gateway WebSocket endpoint└── routes/gateway.ts # REST API for dashboard
web/lib/├── gateway.ts # The tab's one connection (auth, subscribe, replay, reconnect)└── gateway-context.tsx # GatewayProvider, useGateway / useGatewayMessages, "Too many open tabs"
src/tui-pi/ # pi-tui terminal clientsrc/tui-editor/ # pi-tui code editor