Skip to main content
loomcycle
§ story · bridges · v1.113+

Four chat surfaces, one operator token.

A customer asked us to front their agent on Microsoft Teams. We shipped that bridge in v1.113. Within a week the same customer asked for Slack. Then another asked for Telegram. Then Discord. Four surfaces, same agent, same operator token behind them.

The first version of the Teams bridge was a self-contained Node package. Writing the Slack one as a fork of that package would have given us two codebases to carry, two auth stories to keep in sync, and two places to patch when a new release of the loomcycle client landed. Writing the Discord and Telegram ones that way would have given us four of each. So we did not do that.

The short version. There is one shared Node package, @loomcycle/bridge-core. It handles identity, idempotency, per-user token minting, per-surface Markdown defusing, and the operator $cred: envelope. Four surface packages (bridge-teams, bridge-telegram, bridge-slack, bridge-discord) are thin adapters that parse their surface's webhook, hand the core what it needs, and send the agent's reply back through the surface's own send API. One operator token for the deployment; a short-lived per-user token minted for each caller; a group chat runs as a tenant-scoped identity so a conversation does not leak across users.

One operator token, four chat surfaces

The ask was always the same. The user wants to type at the agent from the chat tool they already live in. The operator wants one bearer token to configure, not one per surface. The agent is already there; the chat surface is the piece in between.

The old shape would have been four forks of the first bridge, drifting apart over time. The new shape is one core and four thin adapters. Each adapter is a few hundred lines of code. The core carries the parts a chat bridge gets wrong when it is written in a hurry.

What the core does

Five responsibilities, each with one reason to exist.

Identity. The operator token lives in one place and is held by the core at boot. It is never visible to the surface. When a message arrives from a specific user, the core mints a short-lived token scoped to that user and uses it for the agent call. When a message arrives from a group chat, it is served as a tenant-scoped identity instead (the per-user section below says more).

Idempotency. Chat platforms re-deliver webhooks when they think the first delivery failed. A doubled delivery does not spawn a second agent run. The core keys idempotency on the surface's own event id, which it already carries.

Token minting. Per-user tokens are minted with a TTL and a scope matched to what the surface needs. A Slack bridge does not need to start a batch; a Teams bridge does not need to peek at channels it was not invited to. The minted token carries exactly what the surface's reply path exercises.

Markdown defusing. Every chat platform turns some bytes in a message into live behaviour: a mention pings someone, a link grabs a preview card, an emoji trigger starts a workflow. An agent writing text can and will produce bytes that trip those behaviours, often by accident. The core defuses per surface before any send. Specifics vary; the Slack section below has the most to say.

Operator $cred: envelope. The surface credentials (Bot Framework app id and password for Teams, bot token for Discord, signing secret for Slack, bot token for Telegram) live under $cred: references in the bridge configuration file. The literal values stay in the operator's secret store. A configuration file checked into the operator's own repo carries references, not values.

What each surface adds

Teams

The first bridge, shipped in v1.113. It talks to Microsoft Bot Framework: inbound activities arrive over Bot Framework's own webhook, replies go back through Bot Framework's reply API. The operator holds a Bot Framework app id and app password; both live under $cred:. Interactive adaptive cards work (the agent can send a card and read back the user's input), which lets the bridge carry structured interactions and not just chat.

Telegram

Telegram sends webhooks with a secret header the bot sets at registration. A doubled secret header (two values arriving on one delivery) is refused end-to-end rather than reconciled, because the only reason both would appear is an attacker trying to confuse the verifier. A supergroup migration (Telegram's rename of a chat group) is reported with a named error so the operator notices rather than silently losing the old group binding. Link previews are off on reply sends and tg:// links in agent text are stripped before send, so an agent that produced a tg://resolve?domain=… link cannot summon a cross-chat navigation.

Slack

Slack earned the longest paragraph for a reason. Slack messages are Markdown-inert by construction. The mention defuser spells the word joiner as an escape, so a model that produces the characters that would form @channel or @everyone cannot cause a live summon of the whole channel or workspace. Link labels can never hide their target: the Slack renderer accepts <url|label> as a syntax, and a model with a flexible imagination can use that to hide a phishing target behind friendly label text, so the defuser rewrites every link into "label (url)" with the url visible. Interactive card presses are routed back only to Slack's own response_url for that press and never into the channel, so a card the agent sent to one user cannot be weaponised to post the result into a shared channel. Unfurl is off on every send, so a link to an untrusted site does not fetch a preview card through Slack's unfurler. Live mentions from agent text are refused: the agent cannot ping someone by writing their handle; a mention has to be sent as a structured block, which the bridge rejects by default.

Discord

Discord has two choices: a long-lived gateway WebSocket or an HTTP interactions endpoint. The bridge takes the HTTP interactions path. It does not hold a long-lived connection to Discord, which means an operator restarting the bridge is a one-second gap in service rather than a reconnect sequence. Link previews are off, pings from agent text are stripped, every link shows its real target the same way the Slack bridge handles labels.

The group-chat identity

A one-to-one chat is easy: the user is the user, mint a per-user token, serve the request. A group chat is where it gets interesting, because two users in the same group chat are two different callers but they share the same conversation thread.

The core serves a group chat as a tenant-scoped identity. On Teams this is a teams-conv-<hash> identity derived from the conversation id; the other surfaces have their own analogous shape (slack-conv-<hash>, discord-conv-<hash>, tg-conv-<hash>). The conversation thread runs as that identity. When a specific user sends a message, a per-user token is also minted and attached to that call, so the agent can tell who said what inside the conversation; the conversation identity carries the shared history, and the per-user token carries the attribution.

The operator token is never visible to the surface at any point in this. It stays in the bridge's own memory and signs the mint calls. If the bridge is compromised, the operator rotates the bridge's token and the four surfaces come back with a new one at the next boot.

What is next

A Zoom bridge would be the fifth and would follow the same shape. The user asked for one earlier; it is on the list. A WhatsApp Business bridge is the sixth; the shape fits.

A UI inside loomcycle to configure a bridge from the Web UI is on the roadmap. Today a bridge is configured by a YAML file the Node package reads at boot. That is fine for an operator who already lives in config files; it is a step a product-shaped operator should not have to take.

The bridges live outside the Go binary (they are Node packages), so an operator who wants a chat surface runs the binary plus one Node process per surface. bridge-core depends on @loomcycle/client and bumps on each loomcycle minor release. Slack and Teams interactive cards both exist; Discord cards do not yet.

The per-package READMEs are in the packages directory of the repo. If you were comparing loomcycle against a project that treats chat as a separate product, the comparison hub is the next read; the bridges are why the comparison columns do not need their own row for every chat tool.