--- title: Building a Slack bot | cadenya description: 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. ## 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. 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](/guides/api-design#external-ids-are-first-class-in-paths/index.md). Every Cadenya resource that supports `metadata` accepts an optional `externalId`, and objectives are retrievable by it using the `external_id:` prefix on [`objectives.retrieve`](/api/resources/objectives/methods/retrieve/index.md): ``` const objective = await client.objectives.create({ agentId, data: { initialMessage: 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}`, ); ``` 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 — no cache warmup, no migration. - **Labels are your filters.** Keep `externalId` opaque (it’s your routing key) and use [labels](/api/resources/objectives/methods/create/index.md) for anything you’d want to search or group by — channel, user, team, environment. 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. Encoding it once and decoding it in every handler removes an entire class of “which objective was that again?” bugs. ## Prerequisites - A Slack workspace where you can install apps. - A Cadenya workspace with at least one published [agent](/guides/agents/index.md) that has `webhookEventsUrl` configured. - The account-level webhook signing key. Rotate or reveal it via [`account.rotateWebhookSigningKey`](/api/resources/account/methods/rotate_webhook_signing_key/index.md) — 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. We use `ExpressReceiver` so the same underlying Express app can also serve the Cadenya webhook route. ``` import { App, ExpressReceiver } from "@slack/bolt"; import Cadenya from "@cadenya/cadenya"; const cadenya = new Cadenya({ apiKey: process.env.CADENYA_API_KEY! }); 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 isn’t explicitly 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, we post a “starting…” message to the channel, create the objective, then update the message in place with the result. ``` 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({ agentId, data: { initialMessage: 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}`, }); }); ``` We post 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. The agent-picker modal is a straight [`views.open`](https://api.slack.com/methods/views.open) with a static select populated by [`agents.list`](/api/resources/agents/methods/list/index.md): ``` async function buildAgentPickerModal({ cadenya, meta }) { const agents = []; for await (const agent of cadenya.agents.list()) { 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: ``` 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: ``` 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, req.headers, { secret: process.env.CADENYA_WEBHOOK_SECRET!, }); } 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"); }); }, ); ``` [`webhooks.unwrap`](/api/resources/webhooks/methods/unwrap/index.md) verifies the HMAC signature, freshness, and JSON shape in one call. Reach for [`webhooks.unsafeUnwrap`](/api/resources/webhooks/methods/unsafe_unwrap/index.md) only in local development when you’re posting synthetic payloads and haven’t wired a tunnel yet. `dispatchWebhook` is where the `externalId` pays off — one lookup tells us exactly where to post: ``` 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_objective_created, etc. } } ``` Every event type you’ll receive is documented on the [`objectives.list_events`](/api/resources/objectives/methods/list_events/index.md) 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`](/api/resources/objectives/subresources/tool_calls/methods/approve/index.md) or [`objectives.toolCalls.deny`](/api/resources/objectives/subresources/tool_calls/methods/deny/index.md): ``` 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, { approverProfileId: body.user.id, // or look up your internal 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: ``` 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); objectiveId = objective.metadata.id; } catch { return; // reply in an unrelated thread — ignore } await cadenya.objectives.continue(objectiveId, { 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`](/api/resources/objectives/methods/retrieve/index.md) to look up by `externalId` rather than internal ID. Without the prefix, you’d get a 404. [`objectives.continue`](/api/resources/objectives/methods/continue/index.md) will reject with a 409 if the objective is still running. Pass `enqueue: true` if you want the message queued and delivered when the agent’s next turn starts — usually the right behavior for chat interfaces where users expect their message to “stick.” ## 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’ll have a working bot in a few minutes. ## Further reading - [API Design](/guides/api-design/index.md) — the broader patterns this guide leans on (external ids, list filters, snapshot isolation, webhooks). - [Objectives guide](/guides/objectives/index.md) — lifecycle, events, tool calls, feedback. - [Agents guide](/guides/agents/index.md) — how to configure `webhookEventsUrl`. - [`account.rotateWebhookSigningKey`](/api/resources/account/methods/rotate_webhook_signing_key/index.md) — rotate the account-level signing key used to verify all webhook deliveries. - [`webhooks.unwrap`](/api/resources/webhooks/methods/unwrap/index.md) — signature verification contract. - [Slack Bolt for JavaScript](https://slack.dev/bolt-js/concepts) — the full reference for `App`, `ExpressReceiver`, and event types.