Channel Adapters - Z-M-Huang/openhive GitHub Wiki
Channel Adapters
Channel adapters connect external messaging systems to OpenHive. Each adapter implements IChannelAdapter and handles its own inbound message flow. The ChannelRouter coordinates adapters and tracks which adapter owns each channel ID for targeted response routing.
Adapter Types
| Adapter | Transport | Channel ID Format | Use Case |
|---|---|---|---|
WsAdapter |
WebSocket (/ws) |
ws:{random-hex} |
Browser UIs, API clients, testing |
DiscordAdapter |
Discord.js | Discord snowflake (e.g., 123456789) |
Discord bot integration |
The WebSocket adapter is config-driven: set ws: { enabled: true } in channels.yaml to activate it.
WebSocket Protocol
The WebSocket adapter (/ws) uses a progressive response protocol with six message types:
Message Types
| Type | Direction | Purpose |
|---|---|---|
ack |
Server → Client | AI-generated acknowledgment (sent immediately) |
progress |
Server → Client | Intermediate streaming updates |
response |
Server → Client | Final response with full content |
notification |
Server → Client | Task completion notification (async) |
error |
Server → Client | Error message |
topic_list |
Server → Client | Active topics for the channel (see Conversation-Threading) |
Request/Response Flow
sequenceDiagram
participant Client
participant Server
Client->>Server: JSON {content: "..."}
Server->>Server: Spawn AI session
Server-->>Client: {type: "ack", ...}
Server-->>Client: {type: "progress", ...} (optional)
Server-->>Client: {type: "response", ...}
Message Format
All messages use JSON envelopes. Server-to-client messages include type, content, topic_id, and topic_name fields.
| Direction | Type | Key Fields |
|---|---|---|
| Client → Server | (none) | content (message text) |
| Server → Client | ack |
content (AI-generated acknowledgment), topic_id, topic_name |
| Server → Client | response |
content (final response), topic_id, topic_name |
| Server → Client | notification |
content (task completion), topic_id, topic_name |
| Server → Client | error |
error (error message), topic_id (null), topic_name (null) |
| Server → Client | topic_list |
topics (array of id, name, state objects) |
Clients send messages unchanged — no topic metadata required on the client side. Responses from different topics interleave on the wire; the client demultiplexes by topic_id.
Topic-Aware Routing
Each WebSocket connection supports multiple concurrent topics. When a message arrives, the server classifies it to a topic (see Conversation-Threading) and routes it to the appropriate per-topic session. Multiple topics can be processed in parallel on the same connection.
Same-topic serialization. Within a single topic, messages are still processed one at a time — a second message to the same topic while the first is processing will be queued. Parallelism is between different topics, not within a single topic.
Channel Router
The ChannelRouter sits between adapters and the message handler:
- Each adapter registers a message callback via
onMessage - When a message arrives, the router records
channelId → adapterin its#channelOwnersmap - The message passes through TrustGate for sender trust evaluation — the gate checks the sender's identity against the channel's trust policy and either allows the message to proceed or rejects it before any LLM processing occurs (see Architecture-Decisions#ADR-30)
- If allowed, the router forwards the message to
TopicClassifierand then to the application handler - Responses are sent back through the owning adapter via
sendResponse(channelId, content)
Sender ID per Adapter
Each adapter extracts a senderId that TrustGate uses for trust evaluation:
| Adapter | senderId | Source | Notes |
|---|---|---|---|
DiscordAdapter |
message.author.id |
Discord snowflake | Unique per Discord user (e.g., 291730823...). Evaluated against the channel's trust policy. |
WsAdapter |
X-Sender-Id header |
WebSocket upgrade request | Required for channels with a deny policy. Assertion-based — suitable for internal/trusted networks only. Not cryptographically verified. |
Notification Routing
Task completion notifications are routed to the originating channel:
- With
sourceChannelId+topicId: notification routed to the originating channel and topic. ThetopicIdallows the client to associate the notification with the correct conversation thread. - With
sourceChannelIdonly (notopicId): notification sent to the channel without topic context (legacy behavior, triggers without conversational context). - Without
sourceChannelId(null): expected for schedule triggers (non-notifying by default — see Triggers#Notification Routing & Policy). For keyword or message triggers, nullsourceChannelIdis logged as an error — these trigger types should always have an originating channel.
The sourceChannelId is threaded through the entire request chain: WS header X-Source-Channel → MCP handler → scopeQueue() → task DB options JSON → task-consumer → child session. For query_team (synchronous), the sourceChannelId is forwarded through the TeamQueryRunner to the child session.
Router Resilience
The ChannelRouter wraps each adapter.connect() call in a try-catch. If one adapter fails to connect (e.g., Discord timeout due to network issues), the router continues starting the remaining adapters. Failed adapters are removed from the active set so getConnectedCount() remains accurate. The server continues operating with the available adapters.
Discord Adapter
Topics are internal to OpenHive — the Discord adapter sends flat channel messages. No Discord threads are created. See Conversation-Threading#Channel Protocol.
The Discord adapter connects via Discord.js and supports:
- Watched channels: optional allowlist of channel IDs to listen on
- Message chunking: responses over 2000 characters are split into multiple messages
- Progress streaming: first assistant text as quick ack, subsequent updates as channel messages
Configure Discord in channels.yaml with a bot token and an optional list of watched channel IDs. See Team-Configuration for the full channels configuration.
Interaction Logging
All adapters contribute to the channel_interactions SQLite table via InteractionStore. Three types of events are logged:
| Event | Logged By | Fields |
|---|---|---|
| Inbound user message | channel-handler-factory.ts |
direction=inbound, channelId, userId, contentSnippet (2000 char cap) |
| Outbound main-agent reply | channel-handler-factory.ts |
direction=outbound, channelId, teamId=main, contentSnippet |
| Async task notification | task-consumer.ts |
direction=outbound, channelId (from sourceChannelId), teamId (child team), contentSnippet |
Intermediate WS frames (ack, progress) are not logged — only the final response.
The channel_interactions table also includes a trust_decision column that records the TrustGate verdict for each inbound message (allowed or denied). Denied messages are logged but never forwarded to TopicClassifier or the LLM.
Interactions are retained for 24 hours and cleaned up by a periodic interval in index.ts. They feed back into agent context via buildConversationHistorySection() in the system prompt, giving agents awareness of prior channel conversation.