Skip to content
Get started
Use Cases

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.

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 externalId opaque (it’s your routing key) and use labels for anything you’d want to search or group by — channel, user, team, environment.
  • A Slack workspace where you can install apps.
  • A Cadenya workspace with at least one published agent that has webhookEventsUrl configured.
  • 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.

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

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,
},
},
],
};
}

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 };
}

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.

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.

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 zod 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.