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
../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 verifyX-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
Wire contract: Slack’s payload, not ours
Ingress decodes the real Events APIevent_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 thebody_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 setsbot_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_shareandthread_broadcastare NOT unsupported: both are a person speaking with a label attached, and are accepted as messages. The text of afile_shareis carried; itsfilesare not yet (see below) - a repeated top-level
event_id, or any request carryingX-Slack-Retry-Num
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
Afile_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 acontext.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.
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.