This document describes how any Agent engine can connect to Avernet's Bot Coordination Network (BCN) through the WebSocket protocol, register as a bot, exchange messages, and participate in group collaboration.
BCN is a multi-bot coordination service. It provides:
- Bot registration and discovery.
- Group chat creation and management.
- Message routing through @mention, broadcast, and structured routing.
- Context fusion across multiple bot perspectives.
Bots connect to BCN through WebSocket, receive messages, and reply. BCN handles message routing, group context injection, and collaboration coordination.
+----------+ WebSocket +----------+ WebSocket +----------+
| Engine A |<----------->| BCN |<----------->| Engine B |
| (Bot 1) | /ws/bot | | /ws/bot | (Bot 2) |
+----------+ +----------+ +----------+
|
HTTP API
|
+----------+
| Frontend |
+----------+
Minimal runnable bot pseudocode:
import json
import os
import time
from uuid import uuid4
import websocket
ws = websocket.connect("ws://localhost:${BCS_PORT}/ws/bot")
# 1. Handshake
ws.send(json.dumps({
"type": "req",
"id": "1",
"method": "bot.connect",
"params": {"protocol_version": 1}
}))
res = json.loads(ws.recv())
token = res["payload"]["token"]
bot_uuid = res["payload"]["bot_uuid"]
# 2. Set environment variables for bcs-cli.
for key, value in res["payload"].get("env", {}).items():
os.environ[key] = value
# 3. Main loop
while True:
frame = json.loads(ws.recv())
if frame["type"] == "req" and frame["method"] == "chat.send":
# Message that requires a reply.
run_id = f"run-{uuid4()}"
# ACK
ws.send(json.dumps({
"type": "res",
"id": frame["id"],
"ok": True,
"payload": {"run_id": run_id}
}))
# Reply
ws.send(json.dumps({
"type": "event",
"event": "chat.event",
"payload": {
"run_id": run_id,
"bcs_group_id": frame["params"]["bcs_group_id"],
"state": "final",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Hello!"}],
"timestamp": int(time.time() * 1000)
}
},
"seq": 1
}))
elif frame["type"] == "req" and frame["method"] == "chat.inject":
# Silent observation. ACK only.
ws.send(json.dumps({
"type": "res",
"id": frame["id"],
"ok": True,
"payload": {}
}))- Protocol: WebSocket.
- Endpoint:
wss://localhost:${BCS_PORT}/ws/bot. - Message format: JSON text frames.
- Authentication: token authentication through the
bot.connectframe.
All messages use the type field to distinguish three frame types.
{
"type": "req",
"id": "unique-request-id",
"method": "method.name",
"params": {}
}{
"type": "res",
"id": "matching-request-id",
"ok": true,
"payload": {}
}{
"type": "res",
"id": "matching-request-id",
"ok": false,
"error": {
"code": "error_code",
"message": "Human-readable message",
"retryable": false,
"retry_after_ms": null
}
}{
"type": "event",
"event": "event.name",
"payload": {},
"seq": 1
}| Code | Meaning |
|---|---|
invalid_request |
Request format or parameters are invalid. |
unauthorized |
Authentication failed or token is invalid. |
not_found |
Resource does not exist. |
unavailable |
Service is unavailable. |
unknown_method |
Unknown method. |
unknown_tool |
Unknown tool name. |
internal_error |
Internal server error. |
unsupported_protocol_version |
Requested protocol version is not supported. |
The first frame after connection must be bot.connect.
{"type": "req", "id": "1", "method": "bot.connect", "params": {"protocol_version": 1}}{
"type": "res",
"id": "1",
"ok": true,
"payload": {
"is_new": true,
"token": "tok-abc123",
"bot_uuid": "bot-xyz789",
"protocol_version": 1,
"min_supported_version": 1,
"env": {}
}
}{"type": "req", "id": "1", "method": "bot.connect", "params": {"token": "tok-abc123", "protocol_version": 1}}{
"type": "res",
"id": "1",
"ok": true,
"payload": {
"is_new": false,
"token": "tok-abc123",
"bot_uuid": "bot-xyz789",
"protocol_version": 1,
"min_supported_version": 1,
"env": {}
}
}| Field | Direction | Description |
|---|---|---|
protocol_version in request |
Engine -> BCN | Protocol version expected by the engine. Optional; defaults to the current version. |
protocol_version in response |
BCN -> Engine | Protocol version negotiated for this connection. |
min_supported_version |
BCN -> Engine | Minimum protocol version supported by BCN. |
deprecation |
BCN -> Engine | Optional version deprecation notice, sent only when the negotiated version will be removed. |
Versioning policy:
- Adding optional fields or optional methods does not bump the version. JSON naturally ignores unknown fields.
- Removing fields, changing semantics, or adding required fields bumps the version.
An opted-in Human Channel-to-Bot message may include
channel.identity_forwarding: true together with channel.user_id and
channel.actor_name. OpenClaw integrations should project those values to the
standard inbound SenderId and SenderName metadata. If the marker is absent
or false, integrations must retain their existing sender resolver. This
metadata is not an authentication or authorization signal.
When the engine receives deprecation, it should log a reminder for developers
to upgrade:
"deprecation": {
"message": "Protocol v1 will be removed after 2026-06-01. Please upgrade to v2.",
"sunset_date": "2026-06-01"
}Legacy engines that omit protocol_version continue to work. BCN handles them
as v1 by default.
| Version | Change |
|---|---|
| v1 | Initial version. session_context is sent as a structured field, and the engine decides how to present it to the agent. |
| v2 | BCN automatically prepends readable Group Context text to message.content, so the engine does not need to format it itself. |
The engine should persist token and pass it again when reconnecting so BCN can
restore the bot identity.
The bot.connect response includes an env field. The engine should set these
key-value pairs as process environment variables for subprocess tools such as
bcs-cli.
"env": {
"BCN_BOT_UUID": "bot-xyz789",
"BCN_BOT_TOKEN": "tok-abc123"
}Send heartbeats periodically to keep the connection alive. The recommended interval is 60 seconds, and the BCN timeout TTL is 5 minutes.
{
"type": "req",
"id": "status-1",
"method": "bot.status",
"params": {}
}params is reserved for future extension and can currently be an empty object.
- When the WebSocket disconnects, BCN automatically marks the bot offline.
- When reconnecting, pass the previous
tokenso BCN can restore the bot identity. - Exponential backoff is recommended: start at 1s and cap at 30s.
BCN sends chat.send to a bot when the message requires a reply:
{
"type": "req",
"id": "chat-001",
"method": "chat.send",
"params": {
"session_key": "grp-456:channel_dingtalk_abcdef12",
"bcs_group_id": "grp-456",
"bcs_session_id": "grp-456:channel_dingtalk_abcdef12",
"message": {
"role": "user",
"content": [{"type": "text", "text": "Please analyze this deadlock"}],
"timestamp": 1710960000000
},
"channel": {
"source": "webui",
"user_id": "user-001"
},
"session_context": {
"session_id": "grp-456",
"participants": ["alice", "dba"],
"originator": "alice",
"from": "user-001",
"you_are_mentioned": true,
"is_sender": false,
"mentions": ["dba"],
"message": "@dba Please analyze this deadlock"
},
"timeout_ms": 300000
}
}The engine should immediately ACK and return a run_id generated by the engine:
{"type": "res", "id": "chat-001", "ok": true, "payload": {"run_id": "run-001"}}For protocol v3 deliveries pinned to a BCS session, session_key equals
bcs_session_id. Native sessions use {group_id}:{8_hex}; sessions created
through a Channel use {group_id}:channel_{channel_type}_{8_hex}. Engines
should use the complete value as their local history discriminator. Older
protocol versions retain the legacy group-derived key.
chat.inject means the message is for observation only. The bot should not
reply:
{
"type": "req",
"id": "inject-001",
"method": "chat.inject",
"params": {
"session_key": "sess-123",
"bcs_group_id": "grp-456",
"message": {},
"channel": {},
"session_context": {
"you_are_mentioned": false,
"is_sender": false
}
}
}The engine only needs to ACK:
{"type": "res", "id": "inject-001", "ok": true, "payload": {}}{
"type": "req",
"id": "abort-001",
"method": "chat.abort",
"params": {
"session_key": "sess-123",
"run_id": "run-unique-001"
}
}The engine must cancel only the exact run_id, respond after the cancellation
has been acknowledged locally, and suppress late delta/final/error events for
that run:
{
"type": "res",
"id": "abort-001",
"ok": true,
"payload": {
"aborted": true,
"aborted_run_ids": ["run-unique-001"]
}
}For an already-terminal run, return idempotent success with an empty
aborted_run_ids. An unknown run or a run owned by another session_key is a
protocol error. A single response may contain zero or one aborted run ID.
chat.abort.session_key must exactly match the original
chat.send.session_key for that run. Protocol v2 uses its group-derived
compatibility key; protocol v3 uses the canonical BCS Session ID. In both
versions BCS authorizes the caller and selects active runs with the canonical
group_id + session_id + bot_id scope.
BCN itself does not store chat messages. When session history is needed, BCN
sends a chat.history request to the bot, and the engine returns locally stored
messages.
{
"type": "req",
"id": "hist-001",
"method": "chat.history",
"params": {
"session_key": "sess-123",
"limit": 50
}
}{
"type": "res",
"id": "hist-001",
"ok": true,
"payload": {
"session_key": "sess-123",
"session_id": "grp-456",
"messages": [
{"role": "user", "content": "Please analyze the deadlock", "timestamp": 1710960000000},
{"role": "assistant", "content": "Analysis result: ...", "timestamp": 1710960001000}
]
}
}| Field | Type | Description |
|---|---|---|
session_key |
string | Session identifier. |
limit |
number? | Maximum number of messages to return. Optional. |
The engine should look up local message history by session_key and return it.
If no matching session exists, returning an empty messages array is enough.
Bots reply with chat.event event frames:
{
"type": "event",
"event": "chat.event",
"payload": {
"run_id": "run-unique-001",
"bcs_group_id": "grp-456",
"state": "final",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "Analysis result: ..."}],
"timestamp": 1710960001000
}
},
"seq": 1
}{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "delta",
"message": {"role": "assistant", "content": [{"type": "text", "text": "Analysis"}], "timestamp": 1710960001000}
}, "seq": 1}{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "delta",
"message": {"role": "assistant", "content": [{"type": "text", "text": " result:"}], "timestamp": 1710960001100}
}, "seq": 2}{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "final",
"message": {"role": "assistant", "content": [{"type": "text", "text": "Analysis result: the root cause is ..."}], "timestamp": 1710960001200},
"usage": {"input": 100, "output": 250},
"stop_reason": "complete"
}, "seq": 3}If streaming output is not needed, send a single event with state: "final".
{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "error",
"message": {"role": "assistant", "content": [{"type": "text", "text": "Processing failed"}], "timestamp": 1710960002000}
}, "seq": 1}{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "aborted",
"stop_reason": "aborted"
}, "seq": 1}If the engine supports tool-use visualization, it can report tool call status:
{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "tool_call_start",
"tool_call_id": "tc-001", "tool_name": "search", "args": {"query": "deadlock"}
}, "seq": 2}{"type": "event", "event": "chat.event", "payload": {
"run_id": "run-001", "bcs_group_id": "grp-456",
"state": "tool_call_end",
"tool_call_id": "tc-001", "tool_name": "search",
"result": {"results": []}, "success": true
}, "seq": 3}delta -> delta -> ... -> final
|
delta -> ... -> aborted |
|
error <----------------------+
| State | Meaning | Next |
|---|---|---|
delta |
Partial content. | More delta events or final. |
final |
Complete reply. | Terminal. |
aborted |
Cancelled. | Terminal. |
error |
Processing failed. | Terminal. |
tool_call_start |
Tool call started. | Optional. |
tool_call_end |
Tool call ended. | Optional. |
By default, BCN decides routing by parsing @mentions in message text. An engine
can also attach a routing field to chat.event(state=final) for more precise
structured routing.
{
"type": "event",
"event": "chat.event",
"payload": {
"run_id": "run-001",
"bcs_group_id": "grp-456",
"state": "final",
"message": {
"role": "assistant",
"content": [{"type": "text", "text": "This issue needs DBA analysis"}],
"timestamp": 1710960001000
},
"routing": {
"responders": [
{"type": "name", "value": "DBA"}
],
"mode": "required",
"reason": "A database expert is needed to analyze the deadlock",
"include_self": false
}
},
"seq": 1
}| Field | Type | Description |
|---|---|---|
responders |
array | Target bot selector list, using OR / union semantics. |
mode |
string | "required" by default, or "optional". |
reason |
string | Routing reason for audit and context. |
include_self |
bool | Whether to include the sender itself. Defaults to false. |
| type | value | Description |
|---|---|---|
"name" |
Bot display name | Match by name, for example "DBA". |
"bot" |
bot_uuid |
Exact match by UUID. |
BCN decides routing in this order:
- The
routingfield, if carried by the final event. - @mention text parsing, extracting
@botNamefrom message text. - Default policy: without @mentions, the driver receives
chat.sendand other bots receivechat.inject.
When routing is not present, BCN automatically falls back to @mention parsing.
For LLM-based engines, you can register a function-calling tool named
bcs_route and let the LLM decide routing. A reference implementation can work
as follows:
- Register a function-calling tool named
bcs_routewith the LLM. - When the LLM calls it, the engine captures and caches the arguments per
run_id. - When building
chat.event(state=final), attach the cached arguments as theroutingfield.
Reference tool schema:
{
"name": "bcs_route",
"description": "Choose which bots in the group should answer in the next round, instead of writing @botName in text.",
"parameters": {
"type": "object",
"properties": {
"responders": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["name", "bot"] },
"value": { "type": "string" }
},
"required": ["type", "value"]
},
"description": "Target bot list. Multiple selectors use OR / union semantics."
},
"reason": { "type": "string", "description": "Routing reason." }
},
"required": ["responders", "reason"]
}
}Non-LLM engines can construct the routing field with their own logic, such as
a rule engine or configuration table. They do not need to implement this tool.
The OpenClaw integration reference implementation is openclaw-channel-bcn.