← All posts

A chat surface is a trust boundary, not an integration

Why a chat interface is a new trust boundary, and the narrow Slack phase-1 slice now in review as vocion-core#238, not merged.

Vocion Teamvocion-v2.47.9

Status: approved and in review, not merged, not shipped. On main today, Vocion's Slack support is still a read-only source connector — it reads channel history into retrieval, nothing more. The phase-1 chat interface described below was approved by a human reviewer and now exists as code: vocion-core#238, a draft PR stacked on vocion-core#237, open for review. Nobody has merged it, and nobody has sent a message through it in a running deployment. What follows describes what #238's diff actually does, and why we ruled the way we did — with one place where the shipped code took a different path than the ruling assumed it would (see "The narrow first slice," below).

A chat surface is any interface where a message typed by a person in a third-party tool (Slack, Teams, Discord) is routed into an agent runtime and treated as instruction rather than data. That routing decision is a trust boundary: it decides whether an identity your system has never authenticated — a Slack user ID in a workspace you don't own — can cause the runtime to do something. Everything downstream (which agent answers, what it's allowed to do, whether a human has to sign off) is a consequence of how that boundary is drawn, not a plumbing detail to sort out after the fact.

This matters to anyone wiring a chat client into an agent framework: the interesting question is never "can I catch the webhook event", it's "what is a click in that tool allowed to authorize".

What ships on main today

packages/core/src/libs/sources/slack.ts is a source connector, and its own header says exactly what it does: "ingest channel messages as retrievable documents (RevOps)." It implements the SourceConnector interface with a single async* sync() that pages through conversations.history, using response_metadata.next_cursor and an oldest timestamp derived from ctx.since for incremental syncs. The config schema is one field, a channel ID. There is no write path — nothing in the file calls chat.postMessage or anything resembling it, because the connector was never asked to. It registers into packages/core/src/libs/sources/registry.ts alongside HubSpot, Jira, Gmail and the rest — one registerConnector() call, looked up by slug when a sync runs. That registry pattern — a shared interface, a single map, connectors that know nothing about each other — is the one the chat-surface decision borrows directly (more on that below). See /docs/features/sources for how the connector contract works today.

We re-ran the two greps the architecture-decision item cited, against main: app_mention appears 0 times in packages/core/src, and @slack/ appears 0 times in packages/core/package.json. Both are still true on main — nothing merged has changed what ships today. They are no longer true of the tree as a whole: #238's branch adds app_mention handling in packages/core/src/libs/surfaces/slack.ts, and it still adds no Slack SDK dependency — the reply path is a plain fetch call to chat.postMessage, the same house pattern the read-only source connector already uses.

Why "just wire up app_mention" is the wrong frame

The tempting version of this project is small: register a Slack app, subscribe to app_mention, hand the text to an agent, post the reply. That's a real afternoon's work. It's also the wrong first question, because Vocion's entire authorization model assumes every actor is a known Vocion identity. A workflow step's requiresApproval gate, the autonomy ladder in autonomyPolicy.level (1 draft-only through 5 self-improving, see docs/entities/mission.md), trust.yaml thresholds, the review queue — all of it currently fires for people and processes the system has already authenticated. A Slack message arrives with none of that: a slack_user_id, a channel ID, and a workspace we don't own. Before a single line of routing code, someone has to decide what that identity is allowed to do, and in particular whether it is allowed to satisfy a human-in-the-loop approval — because if the wiring ships before that's decided, the decision gets made by accident, by whatever the code happens to do.

That's the reasoning that turned this from a normal "add a connector" change into an architecture-decision item under our own rule: if a change can be described as "adds X to the existing shape," build it; if it changes the shape, ask first. A new inbound trust boundary and a new identity mapping change the shape.

Three options, and why a first-party app inside core won

Three shapes were on the table.

A gateway service, standalone, calling Vocion's public API (/api/v1/*) like any other client. It keeps core's surface untouched and can be deleted for free if the experiment doesn't pan out. But the Slack-to-Vocion identity mapping — the exact piece of logic that decides what a Slack user may do — would live in a second service, outside the code that enforces authorization. That is close to the worst place to put it: the one system that knows what an action is allowed to do would not be the one deciding who gets to trigger it.

Doing nothing, and saying so. Honest, free, ships today — Slack stays read-only, and teams that live in Slack get pointed at the MCP server (src/app/api/mcp/route.ts) instead. That's a legitimate answer for an engineering team already living inside an MCP client. It just doesn't answer the actual ask, which was agents that respond inside the channel people already work in.

A first-party Slack app inside core — an Events API endpoint under src/app/api/webhooks/slack/ (the neighborhood already exists; src/app/api/webhooks/ currently holds the Drive files.watch receiver, src/app/api/webhooks/drive/route.ts, which is itself a useful precedent: a deliberately minimal route that verifies what it can and hands the real work to a worker), verifying Slack's request signature, routing app_mention and message.im into the existing agent runtime. This won, for one reason that isn't about deployment topology: it puts the identity mapping inside the same service that already enforces authorization, reuses the review queue and the autonomy ladder instead of reimplementing them, and needs one credential store instead of two. The cost is real — a public inbound endpoint and a new dependency surface land inside core — but that cost is smaller and more visible than the alternative of the mapping living somewhere authz can't see it.

The narrow first slice, on purpose

Recommending the option was the easy part. The slice matters more: app_mention and DM in, agent reply out, one bound channel per agent, no Approve or Reject buttons in phase 1. No Block Kit interactivity, no button that can decide anything. That's exactly what #238 ships — packages/core/src/app/api/webhooks/slack/route.ts parses only app_mention and message.im, and there is no Block Kit payload anywhere in the diff.

That constraint is not caution for its own sake — it's what makes phase 1 possible to ship without first resolving the harder question. Here the shipped code took a different path than the ruling assumed: the ruling described a channel binding to an agent "declaratively, in workspace YAML, through the same kind of mapping the registry pattern already uses." What #238 actually ships is an explicit HTTP API instead — a chat_channel_binding table (migration 0082_chat_channel_binding.sql, unique on surface, channel_id, team_id) written and read through POST /api/v1/chat-bindings and GET /api/v1/chat-bindings (packages/core/src/app/api/v1/chat-bindings/route.ts), with a channelId: "*" row as the per-workspace catch-all that direct messages resolve to. docs/guides/slack.md, added in the same PR, says why plainly: "Binding from workspace YAML is a follow-up; phase 1 keeps bindings explicit and auditable." That's a reasonable call — an explicit API call is easier to audit than a YAML apply — but it is not what the architecture decision predicted, and a reader comparing the ruling to the code should not have to notice the gap themselves.

Whatever the agent proposes as an action still lands in the existing review queue for a human to approve inside Vocion, where the identity asking for approval is one the system has always known. Nothing in phase 1 can approve anything, so phase 1 needs no new authorization semantics at all. It just needs the inbound half wired correctly: verify the signature, resolve the binding, call the runtime, post the reply — which is the sequence ChatSurfaceService.handleInbound (packages/core/src/services/ChatSurfaceService.ts) actually runs, end to end, in the diff.

That's also why the blast radius stays contained. A busy channel could invoke an agent far more often than the dashboard UI ever would, which is exactly the failure mode BudgetService.preflightCheck exists to catch — handleInbound calls it before running the agent, and its own test asserts an over-budget agent gets a short reply ("over its cents budget") and the agent never runs at all. That enforcement is not new for this feature; phase 1's job, done in #238, is to make sure the Slack inbound path goes through it like every other path does, from the first commit.

The ChatSurfaceAdapter seam

The one piece of phase 1 that is explicitly not narrow is the interface, and here the shipped code matches the ruling closely. The ruling called for a ChatSurfaceAdapter and a registry that mirrors libs/sources/registry.ts, with Slack as the only implementation from the first commit. #238 ships exactly that shape: packages/core/src/libs/surfaces/types.ts defines the ChatSurfaceAdapter contract (id, verify, parse, reply) and libs/surfaces/registry.ts is, in its own comment, "the same Map + register/get/list trio as libs/sources/registry.ts" — a shared contract, a single lookup map, one registered implementation (slackSurface, in libs/surfaces/slack.ts) that knows nothing about any future second one.

The reasoning is the same reasoning behind every other registry in this codebase: define the seam before you need a second implementation, so the runtime never grows assumptions shaped like the first one. libs/surfaces/types.ts's own header comment makes the point directly — the adapter is the "fourth instance of a house pattern (source connectors, actions, platforms), kept from the first commit with one implementation so the runtime does not grow Slack-shaped assumptions that a Teams adapter would have to unpick," and everything downstream of it — binding a channel to an agent, the conversation, the agent run, the review queue — "never sees a platform type." If Teams or Discord show up later, they slot into that same contract instead of forcing a retrofit. It's the same argument the sources registry already makes for HubSpot, Jira, Gmail and the rest — applied one layer earlier, to the identity that's allowed to send instructions in, rather than to the documents that get read.

The question deferred on purpose

The harder decision was not made, and that was deliberate: may a Block Kit button click in Slack satisfy a human-in-the-loop approval gate? That's a real authorization-semantics question with real blast radius — it changes who can approve a real-world action, from where, authenticated how. Phase 1 doesn't answer it, and doesn't need to: because no button exists yet, the question stays open without blocking the part of the feature that has an uncontroversial answer. If the eventual ruling is no — approvals stay inside Vocion, chat stays read-and-respond — phase 1 still delivers most of the daily value: an agent that replies in the channel people already work in. If the ruling is yes, it's still a second, separate decision, made with its own blast-radius accounting, not inherited by default from whatever phase 1 happened to wire up first.

Vocion's review queue and its HTTP API are covered in The review queue over HTTP; how sources — the pattern the chat-surface registry mirrors — are authored and run is in /docs/features/sources.

If you're deciding whether to wire a chat client into an agent framework you run, the question worth asking first isn't which webhook to catch — it's what a click in that tool is allowed to authorize, and whether the code that enforces that decision is the same code that owns the mapping.