/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
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.metadata accepts an optional externalId, and objectives are retrievable by it using the external_id: prefix on objectives.retrieve:
- 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
externalIdopaque (it’s your routing key) and use labels for anything you’d want to search or group by: channel, user, team, environment.
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, a
whsec_…value. Find it on the Webhooks page of your account settings.account.rotateWebhookSigningKeymints 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.
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 is not a member of.reactions:write: to acknowledge replies with an emoji.
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.
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.views.open with a static select populated by agents.list:
Encoding Slack coordinates
Keep this helper trivial and reversible:Receiving objective events via webhooks
When the agent emits an event (assistant message, tool call, approval request, error), Cadenya POSTs to your agent’swebhookEventsUrl 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:
dispatchWebhook is where the externalId pays off. One lookup tells you where to post:
objectives.list_events reference. The webhook payload uses the same shape.
Wiring tool approvals
When you post approval blocks with stableblock_ids and values, the click handler becomes a straight passthrough to objectives.toolCalls.approve or objectives.toolCalls.deny:
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 tomessage.channels / message.groups and filter:
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.
objectives.continue 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.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.
webhookEventsUrl and ngrok URL, and you have a working bot in a few minutes.
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: build the agent and variations behind your bot.
- Approving a tool: the same approval flow in plain TypeScript and Go.
- Email updates from an objective: react to objective events without the Slack plumbing.
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.