Skip to main content
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

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.
Every Cadenya resource that supports metadata accepts an optional externalId, and objectives are retrievable by it using the external_id: prefix on objectives.retrieve:
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 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. 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.

Prerequisites

  • 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, a whsec_… value. Find it on the Webhooks page of your account settings. account.rotateWebhookSigningKey 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.
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.
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.
The agent-picker modal is a straight 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’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:
webhooks.unwrap verifies the HMAC signature, freshness, and JSON shape in one call. Reach for webhooks.unsafeUnwrap 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 you where to post:
Every event type you receive is documented on the objectives.list_events reference. The webhook payload uses the same shape.

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:
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:
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.
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 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 have a working bot in a few minutes.

Further reading