Bridge Protocol Specification#
Version: 1.0 Status: Implemented (M5) Code:
internal/channel/bridge/
The Bridge Protocol lets external messaging adapters — written in any language — connect to opendray at runtime via WebSocket. This means new platforms (WeChat, DingTalk, Discord variants, custom chat backends) can ship as standalone adapter scripts without recompiling the Go binary.
The opendray side ("server") is the bridge channel kind. Each bridge
channel row in the database holds one adapter slot — name, shared
token, optional capability allow-list. The adapter ("client") opens a
WebSocket, presents the token, declares its capabilities, and is then
treated as a regular Channel implementation by the Hub.
1. Provisioning#
In the admin UI (Channels → New channel → kind=bridge):
- Set a name (
wechat,discord-custom, ...). - Copy the auto-generated token (or paste your own).
- Optionally tick which capabilities the adapter is allowed to claim (empty = accept whatever it declares).
The created row is an empty slot. The bridge starts listening for an
adapter connection immediately if enabled=true.
2. Connection#
Endpoint#
[object Promise]The endpoint is public (not behind admin bearer auth); the per-bridge token is the only authentication.
Authentication#
The token must be supplied via one of:
| Method | Example |
|---|---|
| Query parameter | ?token=YOUR_TOKEN |
| Header | X-Bridge-Token: YOUR_TOKEN |
| Header | Authorization: Bearer YOUR_TOKEN |
Inside register |
{"type":"register", "token":"..."} |
If the token does not match any bridge channel row, the server replies
with a register_ack{ok:false, error:"invalid token"} and closes the
connection.
Lifecycle#
[object Promise]Frames are JSON objects, one per WebSocket text frame. Maximum payload size is 256KB.
3. Adapter → opendray frames#
register (required first frame)#
[object Promise]| Field | Type | Required | Description |
|---|---|---|---|
type |
string | yes | "register" |
token |
string | (yes) | Bridge token. Optional if supplied via header/query. |
platform |
string | yes | Adapter identity (wechat, discord-custom, ...). |
capabilities |
string[] | yes | One or more of: text, card, buttons, image, file, typing, update_message, reply_to_message. The bridge filters this against its accept_capabilities allow-list (when set). |
metadata |
object | no | Free-form, used only for logs. |
message#
User sent a text message.
[object Promise]| Field | Type | Required | Description |
|---|---|---|---|
session_key |
string | rec. | Stable triple {platform}:{conversation}:{user} (the adapter chooses the format). |
conversation_id |
string | rec. | Logical chat identifier. Stored on channel_messages.conversation_id. |
user_id |
string | no | Platform user ID. |
user_name |
string | no | Display name. Wins over user_id for Author. |
text |
string | yes | Message body. |
reply_ctx |
any | yes | Opaque routing handle. opendray echoes it back on every outbound frame so the adapter can deliver replies to the right thread. Often a string pointing at the platform's message id. |
card_action#
User clicked a button on a card opendray previously sent.
[object Promise]The action is whatever was set as ButtonOption.Value on the original
card. Built-in cards produce values like cmd:/resume <sid> (slash
command) or nav:/sessions/<sid> (UI navigation hint).
opendray's Hub recognises cmd:/... actions — including those wrapped
in act: — and dispatches them through the slash-command registry.
ping#
Application-level keepalive. The server replies with {"type":"pong"}.
WebSocket-level ping/pong is also sent automatically by the server every
~54s — adapters need only respond to Ping control frames per RFC 6455
(most WebSocket libraries do this transparently).
4. opendray → adapter frames#
All outbound frames echo the originating reply_ctx so the adapter can
route replies. session_key mirrors the inbound format.
register_ack#
[object Promise]On failure: {"type":"register_ack","ok":false,"error":"invalid token"}.
send#
Plain text outbound.
[object Promise]send_card#
Structured card with markdown + buttons. Only sent when the adapter
declared card on register.
The element shape currently mirrors the Go channel.CardElement types
verbatim (CardMarkdown → {Content}, CardActions → {Buttons},
CardListItem → {Text, Button}, CardSelect → {Placeholder, Options, ...}, CardNote → {Text}). Adapters typically translate
the union into their native renderer.
send_buttons#
Text plus an inline button row, when the adapter does not implement full cards.
[object Promise]update_message#
Edit a previously-sent message in place.
[object Promise]The preview_handle is whatever the adapter put in
channel_messages.metadata.preview_handle (or returned via a future
message_ack frame). Today opendray simply uses the upstream platform
message id when known.
send_image, send_file#
[object Promise]send_file follows the same shape with a file object that may also
carry filename.
start_typing, stop_typing#
[object Promise]Sent when the agent begins a long-running turn; the matching
stop_typing follows when work completes.
pong#
Reply to an adapter ping.
5. Capabilities#
| Capability | Required to receive |
|---|---|
text |
always (implicit) |
card |
send_card |
buttons |
send_buttons |
update_message |
update_message |
typing |
start_typing / stop_typing |
image |
send_image |
file |
send_file |
reply_to_message |
(informational; set if adapter honours reply_ctx for thread-level replies) |
When the Hub tries a capability the adapter did not claim, the server
returns channel.ErrNotSupported to the caller, and the Hub
automatically falls back to a plain send frame with text rendered
from Card.RenderText().
6. Reconnection#
The bridge channel keeps its broker registration even after the WS
connection closes — adapters may freely reconnect with the same token.
A new register frame replaces any prior connection. Outbound frames
attempted while no adapter is attached return ErrNotSupported.
7. Reference adapter (Python pseudocode)#
[object Promise]Sending an inbound message:
[object Promise]