Skip to content

Chat bridges

A chat bridge makes an external chat product — Slack today — a first-class way into a Control Center workspace. Mention the bot and an agent wakes; reply in the thread and the conversation continues; type a slash command and a ticket is filed. Underneath, the same channels, transcripts, tickets and attribution the desktop app uses. Chat is a surface on the workspace, not a copy of it.

Slack is the only provider that ships. There is no Discord, Telegram, Teams, or Matrix implementation anywhere in the product. The feature is written provider-blind on purpose — adding a second product is one enum value, one adapter and one plugin — but today “chat” means Slack.

Two surfaces cover it and they belong to different people. Connecting a workspace to Slack and customizing the bot are admin actions under Settings → Workspace → General. Linking your own chat account to your Control Center identity is a member action under Settings → You → Profile & identity.

Three decisions shape the whole feature: which direction the connection runs, what authorizes a message that arrives from outside and where the product-specific knowledge is allowed to live.

The bridge never listens for the provider. Slack is reached over Socket Mode: the server asks Slack for a socket URL and dials out, then receives mentions, DMs and slash commands over that connection.

The consequence is the point. A laptop behind NAT, a home server with no domain, a machine that moves between networks — all of them can run the bridge with no inbound port, no tunnel, no TLS certificate and no public URL to rotate when it changes. There is no webhook endpoint to secure because there is no webhook endpoint. When Control Center composes the app definition it asserts Socket Mode and strips any request URL, because an app configured for both would be an app that half-works.

The cost is a connection to keep alive, which is the server’s job: reconnection is exponential backoff with jitter capped at 30 seconds, a provider-initiated socket rotation reconnects without escalating that backoff and a rejected credential is terminal — retrying a revoked token forever would only earn a rate limit, so the bridge stops and waits for new tokens while reporting the provider’s own error verbatim in settings.

A workspace with no chat app costs nothing at all: no socket, no timers, no traffic. And the whole thing starts after the server’s ready banner, because a chat app that is slow to answer must never delay boot.

Membership authorizes, not token possession

Section titled “Membership authorizes, not token possession”

The person who installed the app is not the person sending the message. So every inbound event resolves to a Principal before anything happens and it fails closed with an explanation:

  • An unlinked chat account is told how to link, in the thread it wrote in.
  • A linked account whose user is not a member of this workspace is told to ask an admin for an invite.
  • A member whose role is read-only (viewer, guest) is told their role cannot start work.

None of those proceed. A chat message is never attributed to somebody who did not send it — which is what keeps the audit trail, the per-principal rate limits and ticket reporters honest. See Multiplayer — identity, membership and presence for the membership model this leans on.

Linking is identity resolution, not an access grant. Membership is re-checked on every event, so a link belonging to someone who has since been removed resolves to a refusal rather than to their old privileges. Two paths establish a link: a short-lived single-use code the member types in chat, or an automatic match on the provider’s verified email — and the email path still requires that the matched user already be a member, so a stranger whose address happens to exist on the server links to nothing.

The codes are deliberately kept in memory, with a 15-minute time to live. Persisting one would create a durable credential-shaped row for something meant to evaporate and a server restart invalidating every outstanding code is the correct behavior, not a regression — the member presses “link” again.

A refusal is answered in the thread the message opened, as a real reply. It used to ride the provider’s ephemeral channel — quieter, but an ephemeral message anchored to a thread nobody has opened raises no unread, so a mention the bridge could not serve was indistinguishable from a bot that was down. A threaded reply badges the message it answers, reaches the one person who needs it and still keeps “you are not linked” out of the channel itself. Posting a message is also the one capability every provider has, so the explanation never depends on a feature the app may not carry.

A bridge that mirrors both ways will loop unless something tells the two directions apart. Every message the bridge brings in is stamped with metadata['chat'] — provider, team, conversation, thread, message and sender. The outbound mirror skips any message carrying that stamp.

That single rule is what makes bidirectional mirroring safe: a human message typed in Control Center is relayed out to the linked thread (prefixed with the author’s name), while a message that came from chat stops where it entered.

What crosses outward is deliberately narrow. The agent’s answer is relayed as ordinary chat text. A short thought can appear on the Thinking… row of the card. Tool output is a wall of diffs and stays in Control Center, which is also where a truncated reply points.

A card reports the work; the text is still the message

Section titled “A card reports the work; the text is still the message”

A relayed answer used to appear out of nowhere: nothing said the agent had started and a long turn looked like a bot that had stopped listening. So where the provider renders them, a turn also carries one task card, edited in place for as long as the turn runs and sitting above the answer it explains.

The card is one grouped plan, not a mashed paragraph. Slack concatenates details and output on the same task, so a row’s details are sent once (the thought on Thinking…) and output is never streamed. Setup is one row whose title is replaced (Working on it…, Cloning …). Reasoning adds a Thinking… row. Each tool is its own row (Read lib/main.dart). The answer is ordinary text under the card — Slack’s plan view reads it from a later markdown append, not from a chunk mixed into the tasks. Nothing else: no tool output and only a tool argument from a short allow-list, because an unknown tool’s arguments can be an entire file body and a row is one line.

The card also covers the wait before the turn. A first mention creates the conversation and creating it clones every repo the agent will work in — which on a real repository is minutes where the agent has not been asked anything yet and the thread looks like the bot ignored the request. So the card opens the moment the mention is accepted (Working on it…) and then that setup row updates in the same words the desktop shows — Cloning acme/widgets…, Setting up Reviewer…, Starting the agent… — then a Thinking… row as soon as the agent starts reasoning, then a row per tool. One card, one message, from “I heard you” through to the answer as ordinary text beneath. Setup is only reported where somebody in chat is waiting for it: a conversation being prepared in the app, or a stranded one resumed at server boot, provisions with no card at all.

It carries the only thing chat cannot give the reader: a View in Control Center link to the conversation behind the reply. Chat products accept http(s) in a link and nothing else, so the button points at a small page this server serves, which bounces the browser to the desktop app’s control-center:// link. That page reads nothing and resolves nothing — the ids in its URL are echoed into a deep link and nowhere else — and a server with no reachable public URL simply produces a card with no button rather than a broken one.

Cards are a capability like any other. A provider without them behaves exactly as before, text only and a provider that refuses one at call time keeps its stream: the reply always outranks the card that describes it. When streaming is off entirely, the finished card rides the single posted message instead. A filed ticket gets the same treatment — the /cc ticket confirmation is a completed card whose reason to exist is the link to the ticket just created.

Capabilities are negotiated, then remembered

Section titled “Capabilities are negotiated, then remembered”

Chat products differ in what they can render, so the bridge is written against the richest surface — a reply that grows live inside a thread, a transient “is thinking…” status, a titled thread, an ephemeral answer to a slash command — and degrades one capability at a time. A provider with none of it still works; replies simply arrive as one message.

Advertising a capability is not promising it. Slack’s streaming replies need a paid plan feature and Slack only refuses at call time. A refusal marked permanent is remembered for the whole life of the connection: the bridge stops asking and posts whole replies from then on and settings says so plainly, because “my replies stopped streaming” should be answerable without reading a log. Task cards are dropped the same way if Slack refuses one.

Every limit belongs to the provider rather than to the relay: the message ceiling, the stream-chunk ceiling and whether a stream needs a thread anchor are all declared by the adapter and a long reply is split on a paragraph, line, or word boundary rather than mid-word.

Everything product-shaped lives behind a port. The generic core knows about markdown, conversations, threads and members; it knows nothing about Slack envelopes, mrkdwn, or gateway intents. Three pieces make that work:

  • The adapter normalizes one product: its transport, its API calls, its text codec and its envelope quirks. Slack’s are instructive — a channel mention arrives twice (as app_mention and as message) and most message subtypes are chrome rather than new requests. Both are resolved in the adapter, so the core never learns that Slack double-delivers.
  • The descriptor is the provider’s public shape: which credentials it needs, what their formats are, which URLs help a user find them, what it can do and whether its app can be created or edited from here. The settings card, the connect dialog and argument validation are all generated from it — the client posts back a provider plus a map keyed by field id.
  • The plugin is the server-side composition: how credentials become a verified connection, how an adapter is built and how the provider-side app is managed.

So adding a product is one enum value, one adapter and one plugin. No new RPC op, no schema change, no client change — and the client stays honest, because a field it has never heard of still renders with the label the server supplied.

Two details keep the abstraction from leaking upward. The app-management steps a provider has no API for (generate a token, install the app, reinstall after a permission change) are returned as named steps with their own links, so the finish-setup screen shows them without knowing which provider it is. And every instruction the bridge writes names the provider and the command the app actually uses, so a renamed command or a second provider reads correctly with no new string.

A workspace’s chat tokens sit in that workspace’s own directory, beside its database — as plain JSON at <dataDir>/<workspaceId>/chat_credentials/<provider>.json, not in the database and deliberately not in the OS keychain. Two consequences are easy to lose later: deleting a workspace unlinks the directory and its tokens go with it and exporting or backing up a workspace copies workspace.db, so a workspace handed to somebody else does not carry live bot tokens inside it. The file inherits the server host’s filesystem trust boundary — anything with read access to the data directory can read a bot token, which is the same boundary the databases sit behind.

One file per provider, written atomically and owner-only: a revoked app is deleted by unlinking one file and a corrupt file costs one provider rather than all of them. Credentials travel in over the connect operation and never come back out — status reads answer with connection metadata only.

  • Structural app settings. The transport, scopes and event subscriptions are asserted, not exposed. An app that can be configured into silence from a settings screen is a support ticket, not a feature.
  • Interactive components. Buttons and modals are not wired; the surface is conversation plus slash commands.
  • A second source of truth. Chat is not a place where work is stored. The thread is a channel, the reply is a transcript, the ticket is a ticket — all of them in the workspace, reachable from every client.