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

# Slack

> Ship a Slack agent on Dairo: register a Slack app, hand out install links, receive mentions and DMs as messages, and reply with one send call.

Give your product a Slack presence. Your customers install your Slack app into
their own workspaces, their @mentions and DMs arrive as messages in your Dairo
mailbox, and your agent replies with the same send call it uses for email.
Dairo carries the messages; every reply is written by your code.

## How it works

Slack follows the same receive-and-reply model as [Telegram](/channels/telegram),
with an OAuth install flow instead of a pasted bot token:

1. Register a Slack app for your account — Dairo can mint one for you, or you
   import one you already own.
2. Mint an install URL and put it behind the "Add to Slack" button in your
   product.
3. A customer approves the install; their workspace binds to your account as a
   `channel: "slack"` inbox.
4. @mentions of the bot and DMs to it land in your mailbox and fire the
   `message.received` webhook; your agent replies with `POST /v1/messages`.

There is no built-in responder anywhere in this path. Dairo stores what
arrives and delivers what you send — nothing goes out until your code calls
the send endpoint, and a send that names no conversation is rejected rather
than guessed.

## Register your Slack app

`POST /v1/slack/apps` registers the app whose name and icon your customers
see, in one of two ways:

* **Managed** — pass `origin: "managed"` and a Slack app-configuration token
  from [your Slack app dashboard](https://api.slack.com/apps). Dairo creates
  the app for you and wires up its event delivery. The token is used once and
  never stored.
* **Bring your own** — pass `origin: "byo"` with an existing app's `appId`,
  `clientId`, `clientSecret`, and `signingSecret`. The secrets are encrypted at
  rest and never returned by any read.

<CodeGroup>
  ```bash title="Managed" theme={null}
  curl -X POST https://api.dairo.app/v1/slack/apps \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "origin": "managed",
      "name": "Acme Assistant",
      "configToken": "xoxe..."
    }'
  ```

  ```bash title="Bring your own" theme={null}
  curl -X POST https://api.dairo.app/v1/slack/apps \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "origin": "byo",
      "appId": "A012ABCD0A0",
      "clientId": "1234567890.1234567890123",
      "clientSecret": "...",
      "signingSecret": "..."
    }'
  ```
</CodeGroup>

The response is the app's public identity — never its secrets. An app already
registered to a different Dairo account is refused with a `409`.

If you prefer to create the app in Slack's UI yourself,
`GET /v1/slack/apps/manifest-template` returns a manifest to paste into
Slack's "Create app from manifest" flow; import the resulting credentials with
`origin: "byo"` afterward.

### Ambient channel reading

`contextMode` on the create call decides what the app listens to:

* `"mentions"` (default) — @mentions of the bot and DMs to it, nothing else.
* `"ambient"` — additionally subscribes to the channel message stream.

An ambient app carries an `ambientMode`, settable with
`PATCH /v1/slack/apps/{appId}` (its only mutable field):

| `ambientMode`     | Behavior                                                                                                              |
| ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| `"off"` (default) | Channel chatter that does not @mention the bot is discarded.                                                          |
| `"store"`         | That chatter is stored in the mailbox (with `isMention: false`) for later reading, without firing `message.received`. |

@mentions and DMs always notify as usual; `ambientMode` only governs the
non-mention channel stream. Setting `"store"` on a `"mentions"` app is a
`400`.

## Mint the install URL

`POST /v1/slack/oauth/start` returns a signed
`https://slack.com/oauth/v2/authorize` URL. Embed it as your "Add to Slack"
button — the signed `state` inside it ties the resulting workspace binding to
your account.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/slack/oauth/start \
    -H "Authorization: Bearer $DAIRO_API_KEY"
  ```

  ```ts title="TypeScript" theme={null}
  const { url } = await dairo.slack.oauthStart();
  // Render <a href={url}>Add to Slack</a> in your product.
  ```
</CodeGroup>

```json theme={null}
{ "url": "https://slack.com/oauth/v2/authorize?client_id=...&scope=...&state=..." }
```

`oauth/start` resolves your account's single Slack app. With zero or more than
one app registered it returns a `400` naming the explicit form,
`POST /v1/slack/apps/{appId}/install-url`, which mints the same URL for a
specific app.

<Note>
  The `state` in the URL is signed, single-use, and expires after 10 minutes.
  Mint a fresh URL per click rather than caching one — a stale link fails the
  install.
</Note>

## What an install creates

When a customer approves the install, their workspace binds to your account as
one `channel: "slack"` inbox. From that moment the workspace's traffic to your
bot is yours to receive and reply to.

* **One workspace, one inbox per app.** The same owner reinstalling reconnects
  the existing inbox in place — fresh credentials, same inbox id.
* A workspace already bound to a different Dairo account through the same app
  is refused; a binding belongs to exactly one account.
* If the workspace later removes the app, the inbox is marked disconnected.
  Sends to it return a definitive `422` (`slack_disconnected`) until the app
  is reinstalled — never a retryable error, because no retry can succeed.

## Receive @mentions and DMs

Every @mention of the bot and every DM to it lands in the mailbox as a message
with `channel: "slack"` and fires the [`message.received`](/webhooks/webhooks)
webhook — the same event email and Telegram fire, so your agent reacts instead
of polling.

Webhook payloads are metadata-first: the event carries the `messageId`, and
you fetch the full message for the Slack coordinates. `GET /v1/messages/{messageId}`
returns:

```json title="An @mention, fetched by id and trimmed" theme={null}
{
  "message": {
    "object": "message",
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "channel": "slack",
    "direction": "inbound",
    "inboxId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "from": { "address": "slack:U456DEF", "name": null },
    "to": ["slack-a012abcd0a0-t123abc@slack.dairo.local"],
    "textBody": "@U789GHI can you resend last month's invoice?",
    "channelMetadata": {
      "teamId": "T123ABC",
      "channelId": "C0LAN2Q65",
      "ts": "1720598400.001200",
      "threadTs": null,
      "slackUserId": "U456DEF",
      "eventId": "Ev07ABCXYZ",
      "isMention": true,
      "channelType": "channel"
    },
    "receivedAt": "2026-07-10T09:20:00Z"
  }
}
```

Everything your agent needs to reply is in `channelMetadata`:

| Field         | What it is                                                                       |
| ------------- | -------------------------------------------------------------------------------- |
| `teamId`      | The Slack workspace the message came from — `T…`.                                |
| `channelId`   | The conversation to reply into — `C…` public, `D…` DM, `G…` private.             |
| `ts`          | The message's own timestamp; the thread root when a reply threads on it.         |
| `threadTs`    | The thread the message belongs to, when it arrived inside one; otherwise `null`. |
| `slackUserId` | Who sent it — `U…`. Also surfaced as `from.address` (`slack:U456DEF`).           |
| `eventId`     | Slack's delivery id, for your own dedup if you want it.                          |
| `isMention`   | `true` for an @mention in a channel; `false` for a DM.                           |
| `channelType` | `"dm"`, `"channel"`, or `"private_channel"` — who can see the conversation.      |

Slack ids are case-sensitive. Store and echo them back exactly as received —
a lowercased id targets the wrong conversation, or none.

<Warning>
  Branch on `channelType` before answering. `"dm"` is a private 1:1 —
  account-specific details are fine. `"channel"` and `"private_channel"` are
  rooms where other people are watching: do not disclose account data, balances,
  or anything you would not post publicly. Answer briefly, or offer to continue
  in a DM. Dairo gives you the fact; the judgment is your agent's.
</Warning>

## Reply

Reply with the same `POST /v1/messages` call you use on every channel, pointed
at the Slack inbox. One Slack-specific rule: you must address the conversation
explicitly. A Slack inbox is a whole workspace of conversations, so there is
deliberately no "reply to whoever spoke last" default — that would race one
user's answer into another user's DM.

Put one of these targets in `to`:

| `to` value                     | Where it posts                                                                                                          |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| `slack:{channelId}`            | Into that conversation, unthreaded.                                                                                     |
| `slack:{channelId}:{threadTs}` | Into that conversation, in the given thread. Recommended for replies.                                                   |
| `slack:{userId}`               | Into that user's latest conversation with the bot — a channel mention is answered in its thread, a DM is answered flat. |

To keep a channel reply attached to the question, echo back
`slack:{channelId}:{threadTs ?? ts}` from the inbound message's
`channelMetadata`.

<CodeGroup>
  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "inboxId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "to": ["slack:C0LAN2Q65:1720598400.001200"],
      "text": "On it — sending last month'\''s invoice now."
    }'
  ```

  ```ts title="TypeScript" theme={null}
  await dairo.messages.send({
    inboxId: "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    to: ["slack:C0LAN2Q65:1720598400.001200"],
    text: "On it — sending last month's invoice now.",
  });
  ```
</CodeGroup>

The response comes back with `status: "sent"` and `channel: "slack"`, and the
send shows up in `GET /v1/messages` like any other outbound message.

Reply errors are definitive, never guesses:

* No `slack:` target in `to` — `400`, `slack_target_required`.
* `slack:{userId}` for a user who has never messaged the bot — `422`; address
  the `channelId` explicitly instead.
* The workspace uninstalled the app — `422`, `slack_disconnected`.

## From webhook to reply

The whole loop: a `message.received` event arrives, your agent decides what to
say, and the reply lands in the right conversation with the right privacy.
Signature verification is elided here — wire it up as in
[Build a webhook receiver](/examples/webhook-receiver).

```ts title="Node.js" theme={null}
import { Dairo } from "dairo";

const dairo = new Dairo({ apiKey: process.env.DAIRO_API_KEY });

// Called after you have verified the signature and deduped by event id.
async function onDairoEvent(event: { type: string; data: { messageId: string } }) {
  if (event.type !== "message.received") return;

  // 1. Fetch the full message to get the Slack coordinates.
  const { message } = await dairo.messages.get(event.data.messageId);
  if (message.channel !== "slack") return;

  const meta = message.channelMetadata; // { channelId, ts, threadTs, channelType, ... }

  // 2. This is where your agent lives — Dairo has no opinion on the answer.
  //    Use channelType to decide how private the reply can be.
  const inPublic = meta.channelType !== "dm";
  const answer = await yourAgent.reply(message.textBody, { discreet: inPublic });

  // 3. Reply into the same conversation: in-thread in a channel, flat in a DM.
  //    Slack ids are case-sensitive — echo them verbatim.
  const target =
    meta.channelType === "dm"
      ? `slack:${meta.channelId}`
      : `slack:${meta.channelId}:${meta.threadTs ?? meta.ts}`;

  await dairo.messages.send({
    inboxId: message.inboxId,
    to: [target],
    text: answer,
  });
}
```

## Behavior and limits

* **Text only, for now.** Attachments and scheduled sends (`sendAt`) return a
  `400` on the Slack channel. An `html` body is flattened to plain text —
  Slack renders its own formatting, not HTML.
* **No notification injection.** Relayed content cannot trigger a
  `<!channel>`-style mass notification: Slack control sequences in the body
  are neutralized at delivery and render as literal text.
* **No bot loops.** Messages authored by bots — including your bot's own
  replies echoing back — are never ingested, so two bots cannot ping-pong.
* **Threads map to threads.** Replies inside a Slack thread share one Dairo
  `threadId`, so a conversation reads as one thread in the mailbox.
* **Scopes.** Reading rides `messages:read` and sending rides `messages:send`.
  Everything under `/v1/slack/*` — apps, manifest template, install URLs —
  rides `inboxes:write`, the same scope as creating an inbox.
* **Quota.** Each Slack send counts as one message against your monthly
  message quota; a send over quota returns a `429` before anything posts.

## Related

* [Channels](/concepts/channels) — the one-inbox, one-message model Slack
  plugs into.
* [Messages & threads](/receiving/messages-and-threads) — read a mixed-channel
  mailbox as structured messages.
* [Build a support inbox agent](/examples/support-inbox-agent) — a worked
  agent over the unified inbox.
