Chat bridge
The bridge is a small daemon that:
- Listens on a Unix socket at the configured transport’s
socket_path. - Forwards messages from ctrlrelay to a chat channel.
- Streams replies back from that channel and delivers them to the socket client that asked.
It speaks Telegram or Mattermost. Pipelines never know which: they talk to the socket, and the chat app is the bridge’s business. Switching is a one-line config change and no pipeline code moves.
| Telegram | Mattermost | |
|---|---|---|
| Needs | a bot from BotFather, your chat id | a self-hosted server, a bot account, a channel id |
| Outbound | Bot API sendMessage |
POST /api/v4/posts |
| Inbound | getUpdates long-poll |
WebSocket posted events |
| “answer this one” | the reply gesture | reply in the question’s thread |
| Tappable choices | reply keyboard | rendered as a numbered list you type |
The one operator-facing difference worth knowing. When several questions are outstanding, the bridge can only route your answer if it knows which question you mean. On Telegram that is Telegram’s reply; on Mattermost it is replying inside the question’s thread. A loose message in the channel is only routable when exactly one question is waiting — otherwise the bridge refuses and tells you what is outstanding, rather than guessing.
Pipelines use it as the human-in-the-loop channel: when Claude writes a
BLOCKED_NEEDS_INPUT checkpoint, the dev pipeline calls transport.ask(question),
which travels socket → bridge → Telegram → user → Telegram → bridge → socket
and returns as a string back into the resume call.
The bridge is implemented in
src/ctrlrelay/bridge/.
Prerequisites
- A Telegram account.
- A registered bot (next section).
- Your numeric chat ID (next section).
- The bridge socket directory must exist and be writable. Default is
~/.ctrlrelay/.
1 — Create a bot via BotFather
- Open Telegram and message
@BotFather. - Send
/newbot. - Choose a display name (e.g.
ctrlrelay orchestrator). - Choose a unique username ending in
bot(e.g.myorg_devsync_bot). - BotFather replies with an HTTP API token that looks like
123456:ABCdef-.... Save this — it’s your bot token.
2 — Get your chat ID
- Open the chat with your new bot and send any message (e.g.
hello). Telegram won’t deliver bot messages until the chat exists. -
Hit the
getUpdatesendpoint with your token:curl "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates" | jq - Find the numeric
message.chat.idfield. That’s your chat ID. For private chats it’s a positive integer; for groups it’s negative.
If you’d rather use a Telegram group, add the bot to the group and use the group’s chat ID instead.
3 — Configure ctrlrelay
Set the bot token in your environment (the bridge reads it from the env var
named in transport.telegram.bot_token_env):
export CTRLRELAY_TELEGRAM_TOKEN="123456:ABCdef-your-real-token"
Update config/orchestrator.yaml:
transport:
type: "telegram"
telegram:
bot_token_env: "CTRLRELAY_TELEGRAM_TOKEN"
chat_id: 987654321 # your numeric chat ID
socket_path: "~/.ctrlrelay/ctrlrelay.sock"
Validate:
ctrlrelay config validate
4 — Start the bridge
Foreground (handy when wiring up for the first time — Ctrl+C to stop):
ctrlrelay bridge start
Background (writes a PID file alongside the socket):
ctrlrelay bridge start --daemon
Check it’s alive:
ctrlrelay bridge status
Stop it:
ctrlrelay bridge stop
Mattermost instead of Telegram
Steps 1–2 above are Telegram-specific. For Mattermost, do this instead and then rejoin at step 4.
M1 — Enable bot accounts
System Console → Integrations → Bot Accounts → Enable Bot Account Creation = true.
On a self-hosted install this is off by default. Note that editing
config.json on the server may not be enough — if the instance has no config
watcher the value will not take effect until systemctl restart mattermost,
and the live value is what matters. Read it back before believing it:
curl -s "https://<your-server>/api/v4/config/client?format=old" \
| jq .EnableBotAccountCreation
EnableUserAccessTokens is not required. That setting governs user
personal access tokens; bot account tokens are managed separately. Leaving it
off avoids letting every user mint long-lived full-access tokens.
M2 — Create the bot and its token
- Integrations → Bot Accounts → Add Bot Account.
- Username
ctrlrelay, role Member. Leavepost:allandpost:channelsoff — the bot will be added to one channel, and a token with no reach beyond it is a smaller problem if it leaks. - Create New Token and copy it immediately. Mattermost shows it once.
M3 — A channel, and put the bot in it
Create a channel for orchestrator questions, then invite the bot:
/invite @ctrlrelay
This is not optional, and the reason is easy to miss. A bot without
post:all cannot post where it is not a member — and it does not receive the
reply events for such a channel either. Forget this and questions fail to post
and answers would never arrive. ctrlrelay bridge start checks membership
before it binds its socket and refuses to start until the bot is invited.
The bot must also be on the team. If /invite complains, add it via the
team’s Invite People first.
M4 — Find the channel id
The id, not the name: a name is only unique within a team and can be renamed under you, while the id is stable. From the channel’s View Info, or:
curl -s -H "Authorization: Bearer $CTRLRELAY_MATTERMOST_TOKEN" \
"https://<your-server>/api/v4/teams/name/<team>/channels/name/<channel>" \
| jq -r .id
M5 — Configure
export CTRLRELAY_MATTERMOST_TOKEN="your-bot-token"
transport:
type: "mattermost"
mattermost:
url: "https://chat.example.com" # scheme required
bot_token_env: "CTRLRELAY_MATTERMOST_TOKEN"
channel_id: "emzhur1hwpyc38ehkfm5ppym8y"
socket_path: "~/.ctrlrelay/ctrlrelay.sock"
ask_timeout_seconds: 900
Then ctrlrelay config validate, and continue from step 4 above —
ctrlrelay bridge start is the same command for either transport.
Editions. Everything here works on free self-hosted Mattermost. The REST API, the WebSocket, bot accounts and bot tokens are core features, not licensed ones; the paid tiers cover SSO/SAML/LDAP, compliance export and high availability, none of which the bridge touches.
5 — Send a test message
Once the bridge is running and reachable on its socket, send a one-off message through it:
ctrlrelay bridge test --message "hello from ctrlrelay"
You should see the message appear in your Telegram chat almost immediately. If you don’t, see Troubleshooting.
How it integrates with pipelines
When you run ctrlrelay poller start (or run dev) with transport.type:
telegram configured, the pipeline auto-connects to the bridge socket if it
exists. Messages it sends:
🔔 New issue #123 in your-org/your-app: ...— when the poller picks up an issue.⏸️ Blocked on #123: ...— Claude wrote aBLOCKED_NEEDS_INPUTcheckpoint; the next reply you send becomes the answer.✅ PR ready: ...— pipeline finished green.❌ Failed on #123: ...— pipeline failed.
For the full BLOCKED → answer → resume mechanics, see Feedback loop.
Protocol
The bridge speaks newline-delimited JSON over the Unix socket. Defined in
src/ctrlrelay/bridge/protocol.py.
op |
Direction | Purpose |
|---|---|---|
send |
client → bridge | Fire-and-forget message into Telegram. |
ask |
client → bridge | Question that expects a reply. Optional options[] renders as a Telegram keyboard. |
ack |
bridge → client | Acknowledges receipt of send/ask. |
answer |
bridge → client | Reply text from the Telegram user, returned to the original ask caller. |
ping / pong |
both | Liveness check. |
error |
bridge → client | Error envelope (error and message fields). |
You generally don’t need to speak the protocol directly — use
ctrlrelay.transports.SocketTransport from Python or the bridge CLI commands.
Troubleshooting
“Bridge not running” when calling bridge test — start the bridge first with
ctrlrelay bridge start --daemon. Confirm with bridge status.
No reply arrives in Telegram — check the bot token: curl
https://api.telegram.org/bot<TOKEN>/getMe should return your bot. If it returns
401, the token is wrong or the bot was deleted.
Replies don’t reach the pipeline — make sure you’re replying in the same
chat as chat_id in your config. If you’re using a group chat, replying via
Telegram’s “reply” gesture (long-press → Reply) helps the bridge match your
answer to the right pending question.
PID file exists on start — a previous run died without cleaning up. Run
ctrlrelay bridge stop to clear the stale PID, then start again.
Rate limits — Telegram caps individual chats at ~20 messages/minute. The
bridge does not implement client-side rate limiting; if you saturate the chat
you’ll see HTTP 429 in the bridge logs and the affected send/ask calls
will fail. Slow your pipelines down or split notifications across chats.
Bridge crashes when network is offline — the bridge requires Telegram API
access. If the network is down at startup, the long-poll task will fail and the
process exits. Restart the bridge once connectivity is restored. (When run under
launchd / systemd with KeepAlive/Restart=always, this is automatic.)
Socket exists but no process — if bridge status reports “socket exists but
no running process”, remove the orphan socket file (rm
~/.ctrlrelay/ctrlrelay.sock) and restart.
Sequence: BLOCKED question round-trip
pipeline bridge chat app user
│ │ │ │
│── ask("Which?") ────>│ │ │
│ │─ post question ─────>│ │
│ │ │── push ──────>│
│ │ │ │
│ │ │<── reply ─────│
│ │<─ reply + post id ───│ │
│<── answer("the b") ──│ │ │
The bridge remembers the id of every question it posts, so the reply’s
“this is what I am answering” — Telegram’s reply_to_message_id, Mattermost’s
thread root_id — names the session exactly. That is also how an answer
arriving after the pipeline stopped waiting still drives a resume, via the
pending_resumes table.
The pipeline’s transport.ask() call blocks (with the configured timeout) until
the bridge returns the answer. The pipeline then resumes the Claude session via
claude --resume <session_id> with a prompt of the form “User answered: …”.