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

# Building a Slack bot

> Drive Cadenya agents from Slack with Bolt, route webhook events back into threads, and continue objectives inline.

This guide walks through building a Slack app that runs a Cadenya agent from a `/slash` command, streams the agent's progress back into the triggering thread, and continues the objective when the user replies. It's written against the [Slack Bolt SDK for JavaScript](https://slack.dev/bolt-js/concepts) and the [`@cadenya/cadenya`](https://www.npmjs.com/package/@cadenya/cadenya) Node SDK.

A complete, runnable implementation is available at **[cadenya/examples-slacktacular](https://github.com/cadenya/examples-slacktacular)**. Clone it if you want to skip the scaffolding and jump straight to modifying the interesting parts.

```mermaid theme={null}
flowchart LR
    Slack["Slack"]
    Bot["Your Bolt app"]
    Api["Cadenya API"]
    Hooks["Cadenya Webhooks"]

    Slack -->|slash commands / interactions / messages| Bot
    Bot -->|chat.postMessage / update| Slack
    Bot -->|objectives.create / continue| Api
    Hooks -->|objective events| Bot
```

## Why `externalId` is the keystone

The single design decision that makes a Slack integration tractable is round-tripping the Slack coordinates of the triggering message through the objective's `externalId`. Once you do that, every downstream flow (webhook event → thread reply, user reply → `objectives.continue`, follow-ups days later) collapses into a deterministic lookup instead of a database you have to maintain.

<Note>
  This guide uses `externalId` as a routing key for one specific integration.
  For the broader pattern (resolution syntax, parent-scoping rules, and
  everywhere else it shows up across the API), see [API Design → External IDs
  are first-class in
  paths](/docs/guides/api-design#external-ids-are-first-class-in-paths).
</Note>

Every Cadenya resource that supports `metadata` accepts an optional `externalId`, and objectives are retrievable by it using the `external_id:` prefix on [`objectives.retrieve`](/docs/api-reference/objectiveservice/get-an-objective-by-id):

```typescript theme={null}
const objective = await client.objectives.create({
  workspaceId: WORKSPACE_ID,
  agentId,
  systemPromptData: {},
  firstUserMessage: prompt,
  metadata: {
    externalId: `slack:${channelId}:${threadTs}`,
    labels: {
      slack_channel_id: channelId,
      slack_channel_name: channelName,
      slack_user_id: userId,
    },
  },
});

// Later, from any webhook, reply handler, or ad-hoc query:
const objective = await client.objectives.retrieve(
  `external_id:slack:${channelId}:${threadTs}`,
  { workspaceId: WORKSPACE_ID },
);
```

Why this matters:

* **No join table.** You don't need a Postgres table keyed by `(channel, thread_ts) → objective_id`. The mapping lives in Cadenya.
* **Stateless webhook handlers.** When Cadenya calls your webhook endpoint with an objective event, the payload already carries the `externalId`. Decode it, post to the right channel+thread, done.
* **Cheap follow-ups.** A user replying in a thread six hours later can land on the same objective via a single lookup, with no cache warmup and no migration.
* **Labels are your filters.** Keep `externalId` opaque (it's your routing key) and use [labels](/docs/guides/api-design#labels-for-everything-you-want-to-filter) for anything you'd want to search or group by: channel, user, team, environment.

<Tip>
  Pick an `externalId` scheme that's **reversible** and **collision-free for the
  lifetime of the objective**. `slack:C0123:1712932847.000100` is good:
  `thread_ts` is unique per-channel and never reassigned. The value can carry its
  own colons, since only the leading `external_id:` is the lookup prefix, so a
  structured key like this resolves cleanly. Encoding it once and decoding it in
  every handler removes an entire class of "which objective was that again?" bugs.
</Tip>

## Prerequisites

* A Slack workspace where you can install apps.
* A Cadenya workspace with at least one published [agent](/docs/guides/agents) that has `webhookEventsUrl` configured.
* The account-level webhook signing key, a `whsec_…` value. Find it on the **Webhooks** page of your account settings. [`account.rotateWebhookSigningKey`](/docs/api-reference/accountservice/rotates-the-webhook-signing-key-for-the-account) mints a new one and invalidates the old, so a rotation means updating your env var on cutover. The same key signs every webhook for every agent in the account.
* A public URL pointing at your local server (ngrok, Cloudflared, or a deployed endpoint). Slack and Cadenya both need to reach it.

## Scaffold the Bolt app

Slack's slash commands, interactivity callbacks, and event subscriptions all hit the same `/slack/events` endpoint. This setup uses `ExpressReceiver` so the same underlying Express app can also serve the Cadenya webhook route.

```typescript theme={null}
import { App, ExpressReceiver } from "@slack/bolt";
import Cadenya from "@cadenya/cadenya";

const cadenya = new Cadenya({
  apiKey: process.env.CADENYA_API_KEY!,
  webhookKey: process.env.CADENYA_WEBHOOK_KEY!, // the whsec_… signing key; the SDK verifies for you
});
const WORKSPACE_ID = process.env.CADENYA_WORKSPACE_ID!;

const receiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET!,
  endpoints: "/slack/events",
});

const app = new App({
  token: process.env.SLACK_BOT_TOKEN!,
  receiver,
});

// ... register handlers here ...

await app.start(Number(process.env.PORT ?? 3000));
```

The bot token scopes you need are:

* `commands`: for the slash command.
* `chat:write`, `chat:write.public`: to post and update messages.
* `channels:history`, `groups:history`: so `message` events fire for replies in channels the bot is not a member of.
* `reactions:write`: to acknowledge replies with an emoji.

Subscribe to the `message.channels` and `message.groups` bot events so thread replies stream in.

## Starting an objective from a slash command

The `/cadenya` slash command opens a modal; on submit, the app posts a "starting…" message to the channel, creates the objective, then updates the message in place with the result.

```typescript theme={null}
import { encodeCoords } from "./coords.js"; // see below

app.command("/cadenya", async ({ ack, body, client }) => {
  await ack();

  await client.views.open({
    trigger_id: body.trigger_id,
    view: await buildAgentPickerModal({
      cadenya,
      meta: {
        channelId: body.channel_id,
        channelName: body.channel_name,
        userId: body.user_id,
      },
    }),
  });
});

app.view("cadenya_start_objective", async ({ ack, view, client }) => {
  await ack();

  const meta = JSON.parse(view.private_metadata);
  const agentId =
    view.state.values.agent_block.agent_select.selected_option!.value;
  const prompt = view.state.values.prompt_block.prompt_input.value!;

  // 1. Post a "Starting…" placeholder so the user sees immediate feedback.
  const starting = await client.chat.postMessage({
    channel: meta.channelId,
    text: "Starting objective…",
  });

  // 2. Create the objective, encoding the Slack coordinates into externalId.
  const objective = await cadenya.objectives.create({
    workspaceId: WORKSPACE_ID,
    agentId,
    systemPromptData: {},
    firstUserMessage: prompt,
    metadata: {
      externalId: encodeCoords({
        channel: meta.channelId,
        threadTs: starting.ts!,
      }),
      labels: {
        slack_channel_id: meta.channelId,
        slack_channel_name: meta.channelName,
        slack_user_id: meta.userId,
      },
    },
  });

  // 3. Replace the placeholder with the real status.
  await client.chat.update({
    channel: meta.channelId,
    ts: starting.ts!,
    text: `Objective started: ${objective.metadata.id}`,
  });
});
```

<Note>
  The app posts the placeholder *first* so the message's `ts` (Slack's timestamp ID)
  can seed the `externalId`. That `ts` also becomes the `thread_ts` for every
  subsequent reply, webhook post, and user follow-up. Get this ordering wrong
  and everything downstream drifts.
</Note>

The agent-picker modal is a straight [`views.open`](https://api.slack.com/methods/views.open) with a static select populated by [`agents.list`](/docs/api-reference/agentservice/list-agents):

```typescript theme={null}
async function buildAgentPickerModal({ cadenya, meta }) {
  const agents = [];
  for await (const agent of cadenya.agents.list({ workspaceId: WORKSPACE_ID })) {
    agents.push({
      text: { type: "plain_text", text: agent.metadata.name.slice(0, 75) },
      value: agent.metadata.id,
    });
    if (agents.length >= 100) break; // Slack's static_select cap
  }

  return {
    type: "modal",
    callback_id: "cadenya_start_objective",
    private_metadata: JSON.stringify(meta),
    title: { type: "plain_text", text: "Run a Cadenya agent" },
    submit: { type: "plain_text", text: "Start" },
    blocks: [
      {
        type: "input",
        block_id: "agent_block",
        label: { type: "plain_text", text: "Agent" },
        element: {
          type: "static_select",
          action_id: "agent_select",
          options: agents,
        },
      },
      {
        type: "input",
        block_id: "prompt_block",
        label: { type: "plain_text", text: "What should it do?" },
        element: {
          type: "plain_text_input",
          action_id: "prompt_input",
          multiline: true,
        },
      },
    ],
  };
}
```

### Encoding Slack coordinates

Keep this helper trivial and reversible:

```typescript theme={null}
export function encodeCoords({
  channel,
  threadTs,
}: {
  channel: string;
  threadTs: string;
}) {
  return `slack:${channel}:${threadTs}`;
}

export function decodeCoords(externalId: string | undefined) {
  if (!externalId) return null;
  const [scheme, channel, threadTs] = externalId.split(":");
  if (scheme !== "slack" || !channel || !threadTs) return null;
  return { channel, threadTs };
}
```

## Receiving objective events via webhooks

When the agent emits an event (assistant message, tool call, approval request, error), Cadenya POSTs to your agent's `webhookEventsUrl` using the [Standard Webhooks](https://www.standardwebhooks.com/) signature format. Signatures are verified against the **account-level** signing key. One key covers every agent in the account, so store it in a single env var rather than per-agent. Mount the route on the same Express instance Bolt is already using:

```typescript theme={null}
import express from "express";

receiver.router.post(
  "/webhooks/cadenya",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;
    try {
      event = cadenya.webhooks.unwrap(req.body.toString(), { headers: req.headers });
    } catch (err) {
      res.status(401).send("bad signature");
      return;
    }

    res.status(200).end(); // ack immediately; dispatch async

    dispatchWebhook(event, app.client).catch((err) => {
      console.error({ err }, "webhook dispatch failed");
    });
  },
);
```

<Tip>
  [`webhooks.unwrap`](/docs/guides/webhooks) verifies the HMAC
  signature, freshness, and JSON shape in one call. Reach for
  [`webhooks.unsafeUnwrap`](/docs/guides/webhooks) only
  in local development when you're posting synthetic payloads and haven't wired
  a tunnel yet.
</Tip>

`dispatchWebhook` is where the `externalId` pays off. One lookup tells you where to post:

```typescript theme={null}
async function dispatchWebhook(event, slack) {
  const coords = decodeCoords(event.data.objective.externalId);
  if (!coords) return; // not a Slack-originated objective

  const { channel, threadTs } = coords;
  const objectiveEvent = event.data.objectiveEvent.data;

  switch (event.type) {
    case "objective_event.assistant_message":
      await slack.chat.postMessage({
        channel,
        thread_ts: threadTs,
        text: objectiveEvent.assistantMessage.content,
      });
      return;

    case "objective_event.tool_approval_requested":
      await slack.chat.postMessage({
        channel,
        thread_ts: threadTs,
        text: "Approval requested",
        blocks: approvalBlocks({
          toolCallId: objectiveEvent.toolApprovalRequested.toolCallId,
          objectiveId: event.data.objective.id,
        }),
      });
      return;

    case "objective_event.error":
      await slack.chat.postMessage({
        channel,
        thread_ts: threadTs,
        text: `:x: ${objectiveEvent.error.message}`,
      });
      return;

    // ...handle tool_called, tool_approved, tool_denied, sub_agent_spawned, etc.
  }
}
```

Every event type you receive is documented on the [`objectives.list_events`](/docs/api-reference/objectiveservice/list-objective-events) reference. The webhook payload uses the same shape.

### Wiring tool approvals

When you post approval blocks with stable `block_id`s and `value`s, the click handler becomes a straight passthrough to [`objectives.toolCalls.approve`](/docs/api-reference/objectiveservice/approve-a-tool-call) or [`objectives.toolCalls.deny`](/docs/api-reference/objectiveservice/deny-a-tool-call):

```typescript theme={null}
app.action("approve_tool_call", async ({ ack, body, client, action }) => {
  await ack();
  const { objectiveId, toolCallId } = JSON.parse((action as any).value);

  await cadenya.objectives.toolCalls.approve(objectiveId, toolCallId, { workspaceId: WORKSPACE_ID });

  // Update the original message so buttons are replaced with a status line.
  await client.chat.update({
    channel: body.channel!.id,
    ts: body.message!.ts,
    text: "✅ Approved",
    blocks: approvedBlocks(/* ... */),
  });
});
```

On approval, Cadenya fires `objective_event.tool_approved`; on denial, `objective_event.tool_denied`. Your webhook dispatcher should update the same message rather than posting a new one. Use a stable `block_id` (e.g., `approval:${toolCallId}`) and `chat.update` with `blocks` to swap them in place.

## Continuing an objective from a thread reply

When a user replies in a thread the bot already posted to, treat it as a follow-up to the objective. Subscribe to `message.channels` / `message.groups` and filter:

```typescript theme={null}
app.message(async ({ event, client }) => {
  if ("bot_id" in event && event.bot_id) return;
  if (event.subtype) return;
  if (!("thread_ts" in event) || !event.thread_ts) return;
  if (event.thread_ts === event.ts) return; // top-level message, not a reply

  const externalId = `external_id:${encodeCoords({
    channel: event.channel,
    threadTs: event.thread_ts,
  })}`;

  let objectiveId: string;
  try {
    const objective = await cadenya.objectives.retrieve(externalId, { workspaceId: WORKSPACE_ID });
    objectiveId = objective.metadata.id;
  } catch {
    return; // reply in an unrelated thread, ignore
  }

  await cadenya.objectives.continue(objectiveId, { workspaceId: WORKSPACE_ID, message: event.text });

  // Let the user know the message landed.
  await client.reactions.add({
    channel: event.channel,
    timestamp: event.ts,
    name: "eyes",
  });
});
```

Note the `external_id:` prefix on the retrieve call. That's the syntax that tells [`objectives.retrieve`](/docs/api-reference/objectiveservice/get-an-objective-by-id) to look up by `externalId` rather than internal ID. Without the prefix, you'd get a 404.

<Note>
  [`objectives.continue`](/docs/api-reference/objectiveservice/continue-an-objective) only succeeds once the objective
  reaches the **Waiting** state, where the agent has finished its turn and is
  awaiting input. A reply that lands while the agent is still running is
  rejected, so have the bot react accordingly (a "still working…" note, or a
  retry) rather than assume every reply sticks. A reply to a finalized, failed,
  or cancelled objective is rejected for good, so start a fresh objective there
  instead of retrying.
</Note>

## What to borrow from the example repo

The [example repository](https://github.com/cadenya/examples-slacktacular) contains production-shaped versions of everything above, plus the pieces that don't fit in a guide:

* **BlockKit renderers** for starting / assistant / tool-called / tool-approval / error messages with consistent block IDs.
* **Env validation** with [`zod`](https://zod.dev) so misconfigured secrets fail at boot.
* **Vitest coverage** for webhook HMAC, coord codec, and dispatch routing.
* **A `slack-manifest.yml`** you can paste into "Create app from manifest" to skip scope-picking.
* **CI** wired to typecheck and test on every push.

Clone it, swap the `webhookEventsUrl` and ngrok URL, and you have a working bot in a few minutes.

## Further reading

* [API design](/docs/guides/api-design): the broader patterns this guide leans on (external ids, list filters, snapshot isolation, webhooks).
* [Objectives guide](/docs/guides/objectives): lifecycle, events, tool calls, feedback.
* [Agents guide](/docs/guides/agents): build the agent and variations behind your bot.
* [Approving a tool](/docs/guides/callbacks/approving-a-tool): the same approval flow in plain TypeScript and Go.
* [Email updates from an objective](/docs/guides/callbacks/streaming-objective-events): react to objective events without the Slack plumbing.
* [`account.rotateWebhookSigningKey`](/docs/api-reference/accountservice/rotates-the-webhook-signing-key-for-the-account): rotate the account-level signing key used to verify all webhook deliveries.
* [`webhooks.unwrap`](/docs/guides/webhooks): signature verification contract.
* [Slack Bolt for JavaScript](https://slack.dev/bolt-js/concepts): the full reference for `App`, `ExpressReceiver`, and event types.
