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, with an OAuth install flow instead of a pasted bot token:

  1. Register a Slack app for your account: Dairo creates it for you from a Slack app-configuration token (apps made outside Dairo cannot be connected).
  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. Pass a Slack app-configuration token from your Slack app dashboard: Dairo creates the app for you and wires up its event delivery. The token is used once and never stored, and the app's secrets are encrypted at rest and never returned by any read.

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..."
  }'

The response is the app's public identity — never its secrets. Connecting an app you created yourself in Slack is not supported: Slack offers no way to prove that the account importing an app owns it, so every app is created by Dairo.

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

ambientModeBehavior
"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.

curl -X POST https://api.dairo.app/v1/slack/oauth/start \
  -H "Authorization: Bearer $DAIRO_API_KEY"
{ "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.

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.

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 (code validation_failed, with a message that starts with 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 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:

An @mention, fetched by id and trimmed
{
  "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:

FieldWhat it is
teamIdThe Slack workspace the message came from — T….
channelIdThe conversation to reply into — C… public, D… DM, G… private.
tsThe message's own timestamp; the thread root when a reply threads on it.
threadTsThe thread the message belongs to, when it arrived inside one; otherwise null.
slackUserIdWho sent it — U…. Also surfaced as from.address (slack:U456DEF).
eventIdSlack's delivery id, for your own dedup if you want it.
isMentiontrue 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.

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.

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 exactly one of these targets in to — a second recipient, or any cc or bcc, is a 400 (code invalid_request, with a message that starts with slack_single_recipient), because a Slack send posts one message into one conversation:

to valueWhere 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.

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."
  }'

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 (code invalid_request, with a message that starts with 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, or the app was deleted — 422 (code validation_failed, with a message that starts with 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.

Node.js
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, scheduled sends (sendAt), inline buttons, and Telegram-only payloads return a 400 on the Slack channel. An html body is flattened to plain text — Slack renders its own formatting, not HTML.
  • Plan limit. Each workspace binding counts against your plan's inbox limit; an install over the limit is refused, and the install callback reports plan_limit_reached.
  • 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 and 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.

On this page