> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Slack

# 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](/slack-automatic-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](/slack-turnkey-onboarding-research).

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`](/perspective).

## 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:

| Slack field | Mesh field | Notes |
| - | - | - |
| `event.thread_ts` (else `event.ts`) | `ExternalThreadID` | Slack omits `thread_ts` on a top-level message, where the message *is* the thread root. |
| `event.ts` | `ExternalMessageID` | Kept verbatim as an opaque string. `chat.postMessage` wants this value back as `thread_ts`; parsing it as a float loses trailing zeros. |
| `event.ts` | `ReceivedAt` | `<seconds>.<microseconds>` since epoch, parsed to UTC. |
| `event.channel` | `ExternalChannelID` | Message events send a string id; some other event types send an object, which also decodes. |
| `event.user` | `ExternalActorID` | Connector-scoped participant identity; no human/agent/service classification is inferred. |
| `event.text` | `Body` | Recorded as `mrkdwn`, which is what Slack actually sends. |
| *(none)* | `ActorDisplayName` | Message events carry no display name. Populating it needs a `users.info` lookup. |
| top-level `team_id` | *(routing key, then validated)* | Paired with `api_app_id` to look up the agent connector. Once resolved, the namespace comes from the stored connector, not from the payload — a request never chooses its own conversation namespace. |

### Addressing a reply: two ids, two fields

Normalization produces two distinct identifiers, and they are not
interchangeable:

| Field | Example | What it is for |
| - | - | - |
| `ConversationKey` | `slack:T000000TEST:1712345678.000100` | Mesh's synthetic conversation identity inside one RuntimeAgent perspective. Attribution and graph keys only. Slack cannot route it. |
| `ChannelExternalID` | `C000000TEST` | Slack's own channel address. This is the only value `chat.postMessage` accepts as `channel`. |
| `ConversationTitle` | *(empty for Slack today)* | Human-readable display text. Nothing routes on it. |

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:

| Direction | `body_format` | What the bytes are |
| - | - | - |
| inbound | `mrkdwn` | What Slack sent us, verbatim. |
| outbound | `markdown` | Mesh's canonical form — the model's words as persisted. |

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`](/runtime/turn-pipeline) "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](#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`](/runtime/addressing).

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](https://docs.slack.dev/reference/methods/conversations.info/)
and [Telegram getChat](https://core.telegram.org/bots/api#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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.