Skip to main content

Slack connector

Slack is the first connector because it exercises the hardest real-world shape: multiple participants, threads, DMs, and agent replies in one shared workspace.

Setup

Use Automatic Slack setup to connect provisioning once per workspace, then create and install each agent through the wizard. Manual manifest and credential entry remains available. The broader design and API limits are in the research and rollout plan. Automatically created apps have a dedicated /api/slack/events/{id} route. It verifies pending Slack URL challenges against the saved app signing secret before installation. Active traffic must also match the recorded app and workspace, then passes into the existing connector ingress described below.

What the connector is responsible for

  • receive Slack events
  • validate and normalize inbound payloads
  • map Slack identities to Mesh actors inside the owning RuntimeAgent’s perspective
  • map Slack threads and DMs to perspective-scoped Mesh conversations
  • emit normalized inbound events
  • send outbound replies back to Slack

What it is not responsible for

  • model inference
  • persona logic
  • global memory policy
  • agent provisioning UX
  • telling one RuntimeAgent that another participant is also hosted by Mesh
Those belong in the runtime and versioned state layers. The perspective boundary is defined in ../perspective.md.

Which agent is this for?

Mesh hosts many agents, so an inbound Slack request has to be attributed before it can be handled. Nothing about the agent comes from process configuration. The routing key is the pair (api_app_id, team_id) from the request body: api_app_id identifies the Slack app, which is one agent’s Slack identity, and team_id identifies the workspace that app is installed in. That pair resolves to one agent connector row, which owns the bot token and signing secret for this installation. One agent can hold many of these rows — the same agent installed in several workspaces, each with its own credentials. Conversely two agents in the same workspace have different api_app_id values, so they never collide. Resolving the owning RuntimeAgent is a control-plane operation. After routing, the connector writes into that RuntimeAgent’s perspective. The fact that another Slack participant may also resolve to a RuntimeAgent elsewhere in the same Mesh deployment is not exposed to the current agent’s graph or prompt context.

Lookup happens before verification

Signature verification needs the signing secret, and the signing secret is stored per connector. So the order is: parse enough of the body to read the routing key, look up the connector, then verify X-Slack-Signature with that connector’s secret. There is nothing to verify with until the lookup has succeeded. A lookup miss and a signature mismatch both return the same opaque 401. An unauthenticated caller learns nothing about which apps or workspaces this install serves — including whether it has any agents at all.

Normalization model

The connector should produce a normalized event with:
  • actor identity
  • conversation key
  • parent message relationship
  • direction
  • body
  • body format
  • timestamp
It does not produce an Actor ontology such as human/agent/service. Slack’s external identity is the fact; what kind of entity sits behind it is not part of the conversation graph. That is the stable contract the rest of Mesh can consume.

Wire contract: Slack’s payload, not ours

Ingress decodes the real Events API event_callback. The mapping is:

Addressing a reply: two ids, two fields

Normalization produces two distinct identifiers, and they are not interchangeable: The channel id used to travel in ConversationTitle, which is how the reply path came to address chat.postMessage with a conversation key and fail with channel_not_found on every real send. slack.Send now rejects a ChannelID carrying the slack: conversation-key prefix, so a regression fails loudly on our side instead of being reported as a generic Slack error. ConversationTitle is left empty rather than backfilled: Slack message events carry no channel name, and fetching one is a conversations.info call we do not make yet. The prompt’s Conversation: line is therefore empty for now; feeding it real conversation history is tracked separately.

Sending a reply: two formats, one boundary

Inbound and outbound bodies are in different formats, and the body_format column is not decoration: The reply Slack receives is neither of those rows: it is the canonical Markdown translated into mrkdwn by internal/render/slack, and that translation happens inside turn.SendReply, at the send boundary, on a value that is never stored. So **bold** is what the graph holds and *bold* is what chat.postMessage gets. The two dialects share punctuation and mean different things by it, which is the bug this closes: Slack’s own docs call mrkdwn “inspired by markdown, but uses different rules”. Bold is one asterisk, strikethrough is one tilde, there are no headings and no tables, and &, <, > must be entity-encoded in literal text. Sending the model’s Markdown through untouched is how a reply reached Victor as a literal **Go**. The Slack connector satisfies this contract as slack.Outbound (slack.NewOutbound), which is a distinct type from Connector on purpose: Connector.Send is Slack’s API — Slack’s ids, a body already in Slack’s format — while Outbound.Send is Mesh’s send contract and owns both the addressing rules above and the format translation. See ../runtime/turn-pipeline.md “Render at send, persist canonical” for why the record stays canonical, and the internal/render/slack package doc for the full mapping table and why it is an AST walk rather than a regex.

Self-recognition, not bot classification

A RuntimeAgent is allowed privileged knowledge of its own connector identity. That is necessary for mention detection, reply-to-self addressing, and self-echo prevention. That privilege ends at self-recognition. A Slack message authored by another bot must not be dropped merely because Slack sets bot_id. From Lyra’s perspective, Daedalus, an externally hosted bot, a human, and any other participant are all Actors observed through Slack. The fact that Daedalus may also be hosted by Mesh is not a conversational distinction. Therefore the intended self-echo rule is:
Drop a bot-authored event only when its author is this RuntimeAgent’s own Slack identity.
Not:
Drop every event carrying bot_id.
Current implementation note: internal/app/slack_events.go still uses the broader bot_id != "" filter. That is an implementation gap against this contract and must be narrowed to self-identity before Mesh can support honest agent-to-agent interaction in the same Slack room.

Events we ack and drop

All of these return 200 — a non-2xx makes Slack retry, and “we chose not to act” is a successful delivery:
  • a message proven to be this RuntimeAgent’s own connector echo
  • event.type != "message": subscriptions can be broader than what we handle
  • unsupported message subtypes, until edits/deletes/etc. have normalized event semantics. file_share and thread_broadcast are NOT unsupported: both are a person speaking with a label attached, and are accepted as messages. The text of a file_share is carried; its files are not yet (see below)
  • a repeated top-level event_id, or any request carrying X-Slack-Retry-Num
Every drop is logged (slack event skipped, with the reason and the event’s identifiers, never its text). A refused statement — an unsupported subtype, a bot message with no author — logs at info; loop protection (echoes, retries, duplicates, unsubscribed event types) logs at debug. The reason also rides the 200 body and a slack.event.skipped span event, but the log line is what an operator has when a user reports that a message went unanswered. A file_share event’s files[] are carried as attachments — see Attachments below. These are the ignore class: dropped before persistence because they are not a new conversational observation this runtime currently supports. They are not the same as messages we see but are not addressed by, which must still be persisted as conversation context and must not start a turn. In a shared channel that second class is the overwhelming majority of traffic. See ../runtime/addressing.md. A blanket bot_id drop is specifically excluded from this list by the perspective principle.

Attachments

A file_share event’s files[] become connector-neutral attachments (event.Attachment: the file’s id, name, MIME type, size, and url_private) on the normalized message and are persisted as message_attachments rows with the event — metadata only, never bytes. Slack keeps the file. At turn time the images among the current message’s attachments are fetched with this install’s bot token and handed to the model as image content parts; every other file is named to the model and nothing more. The prompt lists each file with whether the model is looking at it and, when it is not, the one-phrase reason (not an image, too large, the model does not accept images, the fetch failed), so the agent says “I can’t see that” instead of describing a picture it never got. Earlier messages’ attachments appear in history by name only ([attached, not shown: "a.png"]). Scope: url_private answers only to an app with files:read in the file’s workspace. An install without it receives a 200 HTML sign-in page, which the connector refuses as “not the file” and the turn reports in text as a fetch failure — visible, and fixed by re-installing with the scope. The token goes only to Slack. url_private arrives inside a signed event, but it is still data: the connector sends the bot token only to https:// URLs on slack.com hosts and refuses everything else before a request is built. A crafted file_share naming another host must not be a token-exfiltration primitive. Fetches are bounded (8 MiB) and the bytes are discarded with the turn.

The 3-second ack budget

Slack redelivers any event it does not see a 2xx for within 3 seconds. A model call can exceed that on its own, so ingress acks first and runs the turn asynchronously on a context.Background()-derived context — the request context is cancelled the instant the handler returns. Shutdown drains those in-flight turns so an acked message still gets its reply. Idempotency is currently an in-process TTL set keyed on event_id. That is single-instance only: two replicas would each keep their own set and a retry routed to the other one would look new. See the comment in internal/app/slack_dedupe.go before scaling the deployment past one replica.

Replay and signature verification

X-Slack-Signature is compared with hmac.Equal (constant time, byte-exact), and X-Slack-Request-Timestamp must be within 5 minutes of our clock in either direction.

First implementation goal

Get one Slack message into one RuntimeAgent’s perspective, persist it, and send one reply back out. Then put a second RuntimeAgent in the same Slack room and prove that each sees the other only through Slack, with no hidden co-residency metadata crossing the perspective boundary. That proves the whole shape end to end without pretending the connector is the whole product.

Dashboard conversation names

The conversation list, channel filter, and conversation detail heading resolve channel names (#commongrid) and DM counterpart profile names through the connector’s optional ConversationNamer contract. Existing history is resolved on read, so no replay or migration is needed. Telegram uses the same contract with getChat, choosing a group title or the private chat’s full name/username. Stored titles and routing IDs remain fallbacks if metadata is unavailable. Slack requires channels:read, groups:read, im:read, and mpim:read in addition to the existing users:read permission. New Mesh manifests and OAuth installs request these scopes. Existing Slack apps must add these scopes and reinstall in the workspace before all channel/DM names can be resolved. History scopes alone do not grant metadata access. See conversations.info and Telegram getChat. Metadata lookup is best-effort, scoped to the agent’s matching connector install, and bounded to four seconds per dashboard request. An in-process bounded cache shares names across threads in a channel, refreshes successful names hourly, and retries failures after one minute while retaining the last known name. Provider outages, missing permissions, and disconnected installs do not hide history. Cache contents are repopulated after process restarts; names are not persisted as conversation titles and do not change routing or conversation identity.

Lifecycle reactions

slack.Reactor marks the triggering message (reactions.add / reactions.remove, which the app’s reactions:write scope covers):
  • Working: ⏳ (hourglass_flowing_sand) as soon as the turn is admitted.
  • Done: the hourglass is removed and replaced by an emoji for what the turn was about — 🐛 bug, 🔧 fix, 🗺️ map, 😂 joke, 🚀 shipped, 📝 writing, and so on. Two topics are deliberately not success marks: 🤷 when the reply says the work could not be completed, and ❓ when it needs the person’s input. 👍 when no topic is known, including a declined reply.
  • Failed: ❌ when the turn itself broke.
Done used to be a uniform ✅, which read as “finished and it worked” even when the reply said the agent could not do it. The topic comes from a fixed-choice classifier that runs while the reply is being sent: the agent’s decision model when it has one, otherwise its classifier text model (internal/app/reaction_topic.go, recorded as the completion_reaction task in decision_calls). The topics are a closed vocabulary (connector.ReactionTopic) that each connector maps to emoji its platform accepts; the Slack table is topicEmoji in internal/connector/slack/react.go. If Slack refuses a topic emoji, the reactor adds 👍 instead, so the hourglass is never left without a final mark.