@cuylabs/agent-channel-slack
v4.0.0
Published
Slack channel adapter for @cuylabs/agent-core — bridges agent-core turn sources to Slack via @slack/bolt
Maintainers
Readme
@cuylabs/agent-channel-slack
Slack channel integration for @cuylabs/agent-core.
This package now supports two real deployment modes:
- Direct Slack mode using
@slack/bolt - M365-backed Slack mode using Azure Bot Service +
@microsoft/agents-hosting
That split is intentional. Some teams want the native Slack surface and no Azure dependency. Others want Slack to share the same Azure Bot registration and hosting model they already use for Teams.
The direct mode is built on the official Slack TypeScript SDKs:
@slack/boltfor inbound requests, event routing, and OAuth install flows@slack/web-apifor outbound Slack API calls, progressive response updates, and nativechatStreamstreaming
Choose a Mode
| Mode | Primary API | Transport | Best when |
| ----------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Direct Slack | mountSlackAgentApp() / mountSlackAgent() / createSlackChannelAdapter() | @slack/bolt | You want the native Slack app model and direct Slack credentials |
| M365-backed Slack | mountM365SlackAgent() / createM365SlackChannelAdapter() | Azure Bot Service Slack channel -> M365 CloudAdapter | You already run on the M365 stack and want Slack to share that hosting/auth model |
Stack Position
Direct Slack
Slack
-> @slack/bolt
-> @cuylabs/agent-channel-slack
-> @cuylabs/agent-coreM365-backed Slack
Slack
-> Azure Bot Service Slack channel
-> @microsoft/agents-hosting
-> @cuylabs/agent-channel-slack
-> @cuylabs/agent-channel-m365
-> @cuylabs/agent-coreInstall
Direct Slack mode
pnpm add @cuylabs/agent-channel-slack @slack/bolt @slack/web-api expressM365-backed Slack mode
pnpm add @cuylabs/agent-channel-slack @cuylabs/agent-channel-m365 @microsoft/agents-hosting @microsoft/agents-activity expressPrefer the mode-specific subpaths for new code:
import { mountSlackAgent } from "@cuylabs/agent-channel-slack/direct";
import { mountM365SlackAgent } from "@cuylabs/agent-channel-slack/m365";The root export is shared-only. It exposes transport-neutral Slack types,
parsers, formatting, session helpers, ambient turn context, and event-bridge
utilities. Import transport APIs from /direct or /m365 so optional peer
dependencies stay isolated.
Express Compatibility
This package is authored against express + @types/express@5, but some upstream SDKs in the Slack/M365 hosting path still expose public APIs typed against older Express declarations.
That mismatch is a TypeScript boundary problem, not a runtime problem. The runtime objects are compatible, but the static types are not always structurally assignable across Express 4 and Express 5 declarations.
The package follows a deliberate policy:
- keep our package surface on modern Express 5 types
- localize third-party type skew to small compat boundaries
- avoid leaking upstream Express version assumptions through our public API
In practice, that is why the M365 path uses a dedicated compat shim in @cuylabs/agent-channel-m365/express-compat, and why the direct Slack Bolt boundary keeps its Express interop casts internal instead of spreading them through user-facing APIs.
Quick Start
Direct Slack mode
import { createAgent } from "@cuylabs/agent-core";
import { openai } from "@ai-sdk/openai";
import { mountSlackAgent } from "@cuylabs/agent-channel-slack/direct";
const agent = createAgent({
model: openai("gpt-4o-mini"),
});
await mountSlackAgent(agent, {
path: "/slack/events",
});Required environment variables:
OPENAI_API_KEY=...
SLACK_BOT_TOKEN=xoxb-...
SLACK_SIGNING_SECRET=...
PORT=3000Important: path is the exact Slack Events API callback path, not a mount prefix. If you keep the default, configure Slack to call https://<your-host>/slack/events.
Direct Slack mode now has three auth shapes:
- single-workspace: one bot token for one workspace
- oauth: Bolt-managed install/callback flow with an installation store
- authorize: custom Bolt
authorizecallback for fully external token resolution
The direct-mode API is intentionally split into two layers:
mountSlackAgent(...)- one-call host + adapter setup
createSlackBoltApp(...)- lower-level Bolt
App/ExpressReceiverbuilder for advanced hosting and OAuth control
- lower-level Bolt
For local OAuth demos, the package also exposes createInMemorySlackInstallationStore(). Use a persistent store in production.
M365-backed Slack mode
import { createAgent } from "@cuylabs/agent-core";
import { openai } from "@ai-sdk/openai";
import { mountM365SlackAgent } from "@cuylabs/agent-channel-slack/m365";
const agent = createAgent({
model: openai("gpt-4o-mini"),
});
await mountM365SlackAgent(agent, {
path: "/api/messages",
});Required environment variables:
OPENAI_API_KEY=...
MicrosoftAppId=...
MicrosoftAppPassword=...
MicrosoftAppTenantId=...
PORT=3978This mode also requires Azure Bot Service to be configured with the Slack channel connector so Slack events arrive as Bot Framework activities at /api/messages.
What the Package Gives You
- direct Slack transport via Bolt
- Bolt Assistant lifecycle wired to an
AgentTurnSource(setStatus,setSuggestedPrompts,setTitle,chatStream, feedback buttons) - M365-backed Slack transport via
CloudAdapter - Slack-aware session mapping and thread-aware defaults
- Slack activity parsing for both the direct and M365-backed paths
- shared ambient turn context via
currentSlackTurnContext() - progressive Slack response updates using
chat.update - native Slack streaming using
WebClient.chatStream - markdown-to-mrkdwn response formatting
- typed helpers for Azure Bot Service Slack
channelData
Core APIs
Direct mode
createSlackBoltApp(...)createSlackChannelAdapter(...)— directapp.message/app.eventadaptermountSlackAgent(...)— same, with Express scaffoldingcreateSlackAssistantBridge(...)— Bolt Assistant middleware bridgemountSlackAssistantAgent(...)— assistant pane only, ExpressmountSlackAssistantAgentSocket(...)— assistant pane only, Socket Mode (recommended for local dev)mountSlackAgentApp(...)— unified: assistant pane +app_mention+ DMs through one mount, all usingchatStreammountSlackAgentAppSocket(...)— unified Assistant pane +app_mention+ DMs over Socket ModecreateSlackInteractiveController(...)— Slack-native approval / human-input Block Kit controllercreateSlackMcpServerConfig({ userToken })— Slack hosted MCP server descriptor consumable by@cuylabs/agent-core/mcpcreateSlackFeedbackBlock(...)/registerSlackFeedbackAction(...)currentSlackTurnContext()— exposesauth(bot/user tokens, team/enterprise IDs),threadContext, andassistantutilities (setStatus,setSuggestedPrompts,setTitle) inside tools and middlewareparseSlackMessageActivity(...)parseSlackMentionActivity(...)parseSlackMessageActivityFromMessageEvent(...)
M365-backed mode
createM365SlackChannelAdapter(...)mountM365SlackAgent(...)currentM365SlackTurnContext()parseSlackChannelData(...)parseSlackActivity(...)
currentM365SlackTurnContext() is the M365-path alias for the same Slack ambient context model. Use whichever name is clearer in your codebase.
Slack Assistant (recommended for new direct apps)
Slack's Assistant pane (introduced with Bolt's app.assistant(...) middleware) is the modern surface for AI agents in Slack. The package ships an opinionated bridge that wires the full lifecycle to an AgentTurnSource from @cuylabs/agent-core:
| Lifecycle event | Bridge behavior |
| -------------------------------- | ----------------------------------------------------------------------------------------------------- |
| assistant_thread_started | Optional say(initialReply) + setSuggestedPrompts(...) + saveThreadContext() |
| assistant_thread_context_changed | saveThreadContext() |
| message (user prompt in thread) | setStatus("Thinking...") -> client.chatStream(...) -> agent events bridged into chunks -> stop({ blocks: feedbackBlock }) |
| Block action agent_feedback | parses verdict, calls onFeedback, posts ephemeral acknowledgement |
AgentEvent mapping inside userMessage:
| AgentEvent | Slack effect |
| --------------------------- | ---------------------------------------------------------------------------------- |
| text-delta | streamer.append({ markdown_text }) after markdown-to-mrkdwn formatting |
| tool-start / tool-error | setStatus(...) + task_update chunk when taskDisplayMode: "plan" |
| tool-result | task_update chunk with status: "complete" when taskDisplayMode: "plan" |
| reasoning-start | setStatus("Thinking...") when showReasoning: true |
| complete | streamer.stop({ blocks: [feedbackBlock] }) |
| error | error chunk appended + streamer.stop({ blocks }); original error reaches the host's logger |
Quick start
import { createAgent } from "@cuylabs/agent-core";
import { openai } from "@ai-sdk/openai";
import { mountSlackAssistantAgent } from "@cuylabs/agent-channel-slack/direct";
const agent = createAgent({ model: openai("gpt-4o-mini") });
await mountSlackAssistantAgent({
source: agent, // any AgentTurnSource
taskDisplayMode: "timeline", // or "plan" for the task list UI
timeoutMs: 120_000,
getSuggestedPrompts: () => ({
title: "Try one of these:",
prompts: [
{ title: "Roll some dice", message: "Roll 2d20" },
{ title: "Summarize", message: "Summarize this thread" },
],
}),
onFeedback: async ({ verdict, sessionId }) => {
metrics.increment(`feedback.${verdict}`, { sessionId });
},
});Advanced wiring
For finer control, build the bridge yourself and pair it with createSlackBoltApp (single-workspace, OAuth, or custom authorize modes):
import { createSlackBoltApp, createSlackAssistantBridge } from "@cuylabs/agent-channel-slack/direct";
const { boltApp, app, receiver } = await createSlackBoltApp({ ... });
const bridge = createSlackAssistantBridge({
source: agent,
taskDisplayMode: "plan",
threadContextStore: myDurableStore, // optional Postgres-backed store
showReasoning: true,
feedback: {
actionId: "agent_feedback",
onFeedback: async ({ verdict, channelId, messageTs }) => { ... },
positiveAck: "Thanks for the feedback!",
negativeAck: false, // skip the ephemeral
},
});
bridge.install(boltApp); // registers app.assistant(...) + feedback action
app.use(receiver.router);createSlackAssistantBridge returns { assistant, install(app), feedbackActionId? }. Hosts that prefer manual wiring can call app.assistant(bridge.assistant) and register their own block-action handler instead.
Use mountSlackAgentApp(...) when you want one direct Slack app to handle the Assistant pane, DMs, and app_mention events together. Its prepareTurn and resolveSession hooks apply to every surface, so session routing, system prompts, and app-specific context stay consistent between assistant and channel turns.
For local development or internal deployments that prefer Socket Mode, use the same unified surface over Slack's websocket ingress:
import { mountSlackAgentAppSocket } from "@cuylabs/agent-channel-slack/direct";
await mountSlackAgentAppSocket({
source: agent,
appToken: process.env.SLACK_APP_TOKEN,
botToken: process.env.SLACK_BOT_TOKEN,
start: true,
});Socket Mode only changes inbound delivery. Outbound streaming still uses Slack
Web API calls (chatStream or chat.update), so the response behavior matches
the HTTP Events API mount.
Slack-Native Approval and Human Input
createSlackInteractiveController(...) provides the Slack side of
agent-core's approval and human-input primitives:
import { createAgent } from "@cuylabs/agent-core";
import {
createSlackInteractiveController,
mountSlackAgentAppSocket,
} from "@cuylabs/agent-channel-slack/direct";
const interactive = createSlackInteractiveController({
// Defaults to process memory. Use a durable store when actions may resolve
// after a restart or in another worker.
store: myDurableInteractiveStore,
requestTimeoutMs: 5 * 60 * 1000,
authorize: async (record, actor) =>
actor.userId === record.target?.userId ||
(await acl.canApprove(actor.userId, record.request)),
// Optional fan-out when another runtime owns the pending request.
onResolve: async (requestId, resolution) => {
await runtime.respondToInputRequest(
requestId,
resolution.kind === "approval"
? {
kind: "approval",
action: resolution.action,
feedback: resolution.feedback,
rememberScope: resolution.rememberScope,
}
: {
kind: "human",
response: resolution.response,
},
);
},
});
const agent = createAgent({
model,
approval: {
defaultAction: "ask",
onRequest: interactive.approval.onRequest,
},
humanInput: {
onRequest: interactive.humanInput.onRequest,
},
});
await mountSlackAgentAppSocket({
source: agent,
interactive,
});When the agent emits an approval or human-input request, the Slack bridge posts
a Block Kit prompt in the originating thread and keeps the turn alive until the
Slack action resolves it. By default only the original requester can approve or
answer. Provide authorize for admin/delegated approval policy. For server-backed or durable
runtimes, provide onResolve to call your runtime's input-resolution API and a
durable store for pending request metadata; direct in-process turns still need
the originating process to remain alive while they wait.
Call interactive.cancelAll("shutting down") during graceful shutdown to reject
pending in-process waits and remove pending store records created by that
controller.
If you install more than one interactive controller on the same Bolt app, pass a
stable namespace or explicit actionIds so Slack block-action IDs do not
collide:
const interactive = createSlackInteractiveController({
namespace: "dory_prod",
});Direct Slack Behavior
Direct mode is the more Slack-native path:
- request verification comes from the Slack signing secret
- the adapter consumes Bolt events directly
- progressive updates use the Slack Web API
- direct messages and @mentions are handled by default
- plain channel-message ingestion is opt-in with
respondToChannelMessages: true - Bolt auth can run in single-workspace, OAuth, or custom-authorize mode
Direct Slack Auth Modes
Single workspace
Use botToken or SLACK_BOT_TOKEN.
OAuth multi-workspace
Use createSlackBoltApp() or mountSlackAgent() with:
auth: { mode: "oauth", ... }- a real installation store
clientId,clientSecretstateSecretor a custom state store
The package includes createInMemorySlackInstallationStore() for demos and local development, but not for production durability.
Custom authorize
Use auth: { mode: "authorize", authorize } when token lookup already lives in your own auth/runtime layer and Bolt should call back into that resolver.
The default session strategy is "thread-aware":
| Strategy | Session ID | Best for |
| -------------------- | --------------------------------------------------------------------------------------------------- | ------------------------------------- |
| "thread-aware" | channelId:threadTs in threads, channelId in DMs, channelId:messageTs for new channel messages | Natural Slack conversation grouping |
| "channel-id" | channelId | One shared session per channel |
| "user-per-channel" | channelId:userId | Per-user isolation in shared channels |
| "user-per-thread" | channelId:threadTs:userId in threads/channels, channelId:userId in DMs | Per-user isolation inside threads |
| "custom" | caller-defined | Shared external session stores |
The default streaming mode is "progressive":
- post a placeholder
- update it as text accumulates
- surface tool or reasoning status before the main response arrives
If you prefer one final reply, use streamingMode: "accumulate".
For Slack-native streaming, use streamingMode: "chat-stream". This uses
Slack's chat.startStream, chat.appendStream, and chat.stopStream APIs
through @slack/web-api:
await mountSlackAgent(agent, {
streamingMode: "chat-stream",
chatStreamBufferSize: 256,
});Model output is converted from common markdown to Slack mrkdwn by default.
Disable it with formatChatMarkdown: false, or provide formatMessageText
for full control.
M365-Backed Slack Behavior
The M365-backed path is not just the direct Slack adapter behind a different host. It is a different transport:
- Slack events are normalized through Azure Bot Service
activity.channelId === "slack"- Slack-native payload details are embedded in
activity.channelData - auth is Entra/JWT through
CloudAdapter
Assistant pane limitation. Azure Bot Service's Slack channel does not currently relay
assistant_thread_started/assistant_thread_context_changedevents, and it does not surface Slack's assistant-pane utilities (assistant.threads.setStatus,setSuggestedPrompts,setTitle). The modern Slack Assistant UX (status pane, suggested prompts,chatStreamwithtask_display_mode, feedback blocks) is therefore only available on the direct path viamountSlackAssistantAgentormountSlackAgentApp. Plain DM andapp_mentionflows work fine on the M365-backed path.
Interactive limitation. Slack-native approval and human-input rendering uses Block Kit actions and modal submissions. Azure Bot Service's Slack connector does not preserve those direct Slack action/view callbacks as the direct Bolt path does, so
createSlackInteractiveController(...)is direct Slack only.
This mode is useful when:
- your org already deploys bots through the M365 hosting stack
- you want one hosting/auth story across Teams and Slack
- you accept that the Slack surface is mediated through Bot Framework activities
The Slack package adds the missing Slack-specific layer on top of agent-channel-m365 by parsing channelData.SlackMessage into SlackActivityInfo.
M365-backed Slack keeps the base M365 conversation session behavior by default. To make ABS Slack thread-aware, provide the Slack-specific resolver:
import {
createM365SlackChannelAdapter,
resolveM365SlackThreadSessionId,
} from "@cuylabs/agent-channel-slack/m365";
createM365SlackChannelAdapter({
source,
resolveSession: ({ slack }) => resolveM365SlackThreadSessionId(slack),
});Ambient Turn Context
Use the ambient context helpers inside tools or middleware:
import { currentSlackTurnContext } from "@cuylabs/agent-channel-slack/direct";
const ctx = currentSlackTurnContext();
if (ctx) {
ctx.slackActivity.channelId;
ctx.slackActivity.channelType;
ctx.user.userId;
ctx.sessionId;
}When you are on the M365-backed path, currentM365SlackTurnContext() returns the same Slack ambient shape.
Handling Tokens
Direct Slack turns can expose ctx.auth.botToken and, when user scopes are granted, ctx.auth.userToken. Treat both as secrets. Do not put the auth bag in logs, trace attributes, telemetry baggage, tool results, message history, or model-visible prompts. Pass only the token you need to trusted Slack API clients or to createSlackMcpServerConfig({ userToken }).
Docs
Examples
- examples/README.md
- examples/01-direct-slack.ts
- examples/02-direct-slack-oauth.ts
- examples/03-m365-slack.ts
- examples/04-slack-interactive.ts
Relationship to Other Channel Packages
| Package | Purpose |
| ------------------------------ | -------------------------------------------------- |
| @cuylabs/agent-channel-m365 | Generic M365 Activity Protocol bridge |
| @cuylabs/agent-channel-teams | Teams-native layer above the M365 bridge |
| @cuylabs/agent-channel-slack | Slack-native direct mode plus Slack-over-M365 mode |
If you need Slack only, use the direct mode. If you need Slack and Teams to share the same Microsoft-hosted ingress and auth story, use the M365-backed mode.
