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 and the @cadenya/cadenya Node SDK.
A complete, runnable implementation is available at 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
Section titled “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.
Every Cadenya resource that supports metadata accepts an optional externalId, and objectives are retrievable by it using the external_id: prefix on objectives.retrieve:
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
externalIdopaque (it’s your routing key) and use labels for anything you’d want to search or group by — channel, user, team, environment.
Prerequisites
Section titled “Prerequisites”- A Slack workspace where you can install apps.
- A Cadenya workspace with at least one published agent that has
webhookEventsUrlconfigured. - The account-level webhook signing key. Rotate or reveal it via
account.rotateWebhookSigningKey— 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
Section titled “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— somessageevents 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
Section titled “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}`, });});The agent-picker modal is a straight views.open with a static select populated by agents.list:
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
Section titled “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
Section titled “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 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"); }); },);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 reference — the webhook payload uses the same shape.
Wiring tool approvals
Section titled “Wiring tool approvals”When you post approval blocks with stable block_ids and values, the click handler becomes a straight passthrough to objectives.toolCalls.approve or objectives.toolCalls.deny:
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
Section titled “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 to look up by externalId rather than internal ID. Without the prefix, you’d get a 404.
What to borrow from the example repo
Section titled “What to borrow from the example repo”The example repository 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
zodso misconfigured secrets fail at boot. - Vitest coverage for webhook HMAC, coord codec, and dispatch routing.
- A
slack-manifest.ymlyou 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
Section titled “Further reading”- API Design — the broader patterns this guide leans on (external ids, list filters, snapshot isolation, webhooks).
- Objectives guide — lifecycle, events, tool calls, feedback.
- Agents guide — how to configure
webhookEventsUrl. account.rotateWebhookSigningKey— rotate the account-level signing key used to verify all webhook deliveries.webhooks.unwrap— signature verification contract.- Slack Bolt for JavaScript — the full reference for
App,ExpressReceiver, and event types.