Slack Workspace
OpenClaw plugin with Slack workspace tools the bundled Slack channel plugin lacks: scheduling, search, structured messages, canvases, bookmarks, channel management, Slack Lists, files, and Workflow Builder triggers.
Install
openclaw plugins install clawhub:@stevekinney/openclaw-slack-workspaceSlack Workspace
An OpenClaw plugin that gives the agent Slack workspace tools the bundled Slack channel plugin doesn't have: scheduling, search, structured messages, canvases, bookmarks, public-channel management, Slack Lists, file uploads, and more. It registers its tools and hooks with definePluginEntry. The channel plugin still handles conversation itself (replies, reactions, pins, and file handling inside a chat turn), so nothing here duplicates it.
Tools
Every tool uses the bot token except slack_search and slack_search_context, which need the user token; slack_identity, which checks whichever token you ask about; and slack_workflow_trigger_run, which uses no Slack token at all. The scopes listed are the ones Slack's docs require for the methods each tool calls. openclaw slack-workspace doctor checks them against what your tokens actually hold (see Scope doctor).
Tools marked † take an optional channelId. Inside a Slack conversation, leaving it out targets the current channel. Outside one (cron jobs, automations, other chat platforms), it's required.
Identity and search
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_identity | Report who a token authenticates as and which scopes it holds | bot or user (tokenKind) | none |
slack_search | Keyword search over messages or files as the authorizing user, including their private channels and DMs. Points the agent to slack_search_context for questions | user | search:read |
slack_search_context | Real-time Search (assistant.search.context): messages ranked by meaning, each with its permalink and surrounding messages; optionally files, channels and users. Unrestricted message searches fall back to search.messages (mode: "legacy") when unavailable; requests with conversation/content filters, time bounds, or a cursor fail without legacy search | user | search:read.public (plus search:read.private, .im, .mpim, .files, .users for wider coverage) |
Messages and Block Kit
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_post_table † | Post rows and columns as a native table | bot | chat:write |
slack_post_plan † | Post a checklist or progress card | bot | chat:write |
slack_post_chart † | Post a native chart | bot | chat:write |
slack_post_rich_text † | Post prose with real lists, quotes, and code blocks | bot | chat:write |
slack_blocks_send † | Post raw Block Kit blocks | bot | chat:write |
slack_blocks_update † | Replace an existing message's blocks by ts | bot | chat:write |
slack_interaction_prepare † | Prepare a bounded modal or external-select interaction | bot | none |
slack_message_get † | Find messages by the metadata stamped on them and return their ts | bot | channels:history, metadata.message:read |
slack_message_delete † | Delete a message this bot posted (approval-gated) | bot | channels:history, chat:write |
slack_post_ephemeral † | Send one user a message only they can see | bot | chat:write |
slack_work_object_post † | Post a native Work Object card for an external issue, task, or doc | bot | chat:write |
slack_post_table, slack_post_plan, slack_post_chart, and slack_post_rich_text also take updateTs to rewrite a message they posted earlier.
After posting or updating, those four tools and slack_blocks_send/slack_blocks_update call chat.getPermalink and return the message's permalink, so the agent can link to what it posted. If the lookup fails, the post still succeeds: the result just omits permalink and a warning is logged.
slack_message_delete wraps chat.delete so the agent can clean up its own scratch or obsolete cards. Before deleting, it reads the message back with conversations.history, or conversations.replies when you pass the thread parent's threadTs, and compares its author with the bot's auth.test identity. If another user or app posted the message, or the message can't be found, the tool refuses the call and never reaches chat.delete. Deleting is irreversible, so every call also waits for a human's approval.
slack_work_object_post posts a Work Object card for an item that lives in another system, like an issue, incident, or doc. It calls chat.postMessage with entity metadata and no app_unfurl_url, because it's a proactive post rather than a reply to a link unfurl. The card shows title, displayId, displayType, status, and a link to url. entityType picks Slack's entity schema: task (the default), incident, file, or content_item. Only task and incident have a status field, so the tool refuses a status on the others before calling Slack. Clicking the card opens only a static placeholder. Filling in the details pane means handling Slack's entity_details_requested event, and only the bundled channel plugin receives events. Slack renders Work Objects only after someone turns on Work Object Previews in the app's settings. If Slack rejects the entity metadata, the tool posts an equivalent plain Block Kit card, with a linked title and a context line, and returns mode: "fallback" with Slack's error as reason. Errors that a plain post would hit too, like channel_not_found or missing_scope, are thrown as usual. The result is { mode, channelId, ts, permalink?, reason? }.
Scheduling and reminders
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_schedule_message | Schedule a message up to 120 days out | bot | chat:write |
slack_schedule_reschedule | Replace a pending scheduled message's text or time | bot | chat:write |
slack_remind | Remind a person (by DM) or a channel at a set time | bot | im:write, chat:write |
slack_scheduled_list | List pending scheduled messages | bot | none |
slack_scheduled_cancel | Cancel a pending scheduled message | bot | chat:write |
Canvases
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_canvas_create | Create a canvas from Markdown or a built-in template | bot | canvases:write |
slack_canvas_edit | Append, prepend, insert, replace, or delete canvas content, or rename the canvas | bot | canvases:write |
slack_canvas_sections | Find section IDs to edit relative to | bot | canvases:read |
slack_canvas_list | Find canvases the bot can see | bot | files:read |
slack_canvas_access_set | Share a canvas with channels or users | bot | canvases:write |
slack_canvas_access_delete | Revoke canvas access | bot | canvases:write |
slack_canvas_delete | Delete a canvas | bot | canvases:write |
slack_canvas_channel_get_or_create | Get or create a channel's tab canvas | bot | channels:read, canvases:write |
slack_canvas_status_update | Rewrite one section under a heading, for scheduled status runs | bot | channels:read, canvases:read, canvases:write |
slack_canvas_from_thread | Turn a thread into a canvas | bot | channels:history, canvases:write |
Bookmarks
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_bookmark_list | List a channel's bookmarks | bot | bookmarks:read |
slack_bookmark_add | Add a link bookmark | bot | bookmarks:write |
slack_bookmark_edit | Change a bookmark's title, link, or emoji | bot | bookmarks:write |
slack_bookmark_remove | Remove a bookmark | bot | bookmarks:write |
Public channels
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_channel_list | Find channels (public, plus private ones the bot is in) by name, topic, or purpose, each with isMember (read-only) | bot | channels:read (plus groups:read for private channels) |
slack_channel_members | List a channel's member user IDs (read-only) | bot | channels:read (groups:read for private channels) |
slack_channel_create | Create a public channel, or a private one with isPrivate: true | bot | channels:manage (groups:write for private) |
slack_channel_archive | Archive a channel | bot | channels:manage (groups:write for private) |
slack_channel_unarchive | Unarchive a channel (the undo for archive) | bot | channels:manage (groups:write for private) |
slack_channel_rename | Rename a channel | bot | channels:manage (groups:write for private) |
slack_channel_set_topic | Set a channel's topic | bot | channels:write.topic (groups:write.topic for private) |
slack_channel_set_purpose | Set a channel's purpose | bot | channels:manage (groups:write for private) |
slack_channel_invite | Invite users to a channel | bot | channels:write.invites (groups:write.invites for private) |
slack_channel_join | Join a public channel as the bot | bot | channels:read, channels:join |
slack_channel_leave | Leave a public channel | bot | channels:read, channels:manage |
slack_channel_kickoff | Create a project channel (public or private) with topic, purpose, invites, canvas, and bookmark in one call | bot | channels:manage, channels:write.topic, channels:write.invites, canvases:write, bookmarks:write (plus the groups:write* twins for private) |
Slack Lists
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_list_create | Create a List with typed columns | bot | lists:write |
slack_list_update | Rename a List, or change its description or todo mode | bot | lists:write |
slack_list_schema | Read a List's column and option IDs | bot | lists:read |
slack_list_item_create | Add a row, by column name | bot | lists:read, lists:write |
slack_list_item_update | Change cells, by column name | bot | lists:read, lists:write |
slack_list_item_delete | Delete a row | bot | lists:write |
slack_list_items_delete_multiple | Delete several rows | bot | lists:write |
slack_list_items_list | Read a List's rows | bot | lists:read |
slack_list_item_info | Read one row and its subtasks | bot | lists:read |
slack_list_export | Export a List as a CSV or JSON file and return its download URL | bot | lists:read |
slack_list_access_set | Share a List with channels or users | bot | lists:write |
slack_list_access_delete | Revoke List access | bot | lists:write |
slack_list_from_thread | Turn a thread's action items into List rows | bot | channels:history, lists:read, lists:write |
Files
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_file_upload | Upload a generated file, shared to a channel or kept private | bot | files:write |
slack_remote_file_add | Register an external document as a Slack file | bot | remote_files:write |
slack_remote_file_update | Update a remote file's details | bot | remote_files:write |
slack_remote_file_remove | Remove a remote file from Slack | bot | remote_files:write |
slack_remote_file_share | Share a remote file into channels | bot | remote_files:share |
slack_remote_file_info | Look up one remote file | bot | remote_files:read |
slack_remote_file_list | List remote files, optionally in one channel | bot | remote_files:read |
Assistant threads, user groups, and workflows
| Tool | What it does | Token | Scopes |
|---|---|---|---|
slack_assistant_set_title | Rename an Agent View or Assistant View thread (agents.sessions.rename, falling back to assistant.threads.setTitle) | bot | chat:write (assistant:write for the fallback) |
slack_assistant_suggest_prompts | Show up to four suggested prompts, at the top of the Messages tab in Agent View | bot | assistant:write |
slack_usergroup_list | List user groups (read-only) | bot | usergroups:read |
slack_usergroup_members | List a user group's members (read-only) | bot | usergroups:read |
slack_workflow_trigger_run | Start a Workflow Builder workflow through a configured webhook trigger | none | none |
Tools that read a channel's history (slack_message_get, slack_message_delete, slack_canvas_from_thread, slack_list_from_thread) need the history scope for the conversation's type: channels:history for public channels, groups:history for private ones, and im:history or mpim:history for DMs and group DMs.
Install
You need OpenClaw 2026.10.5 or later and Node 24.22.1 (or a supported version declared in package.json), with the Slack channel plugin already connected to a Slack app. This plugin uses the same app and the same bot token, so anything it posts comes from the bot people already talk to. Ordinary workspace tools work on stock OpenClaw with routing disabled. Session routing additionally requires the companion host contract described in the routing guide; installing this package does not install or patch OpenClaw. See the release notes for compatibility details.
-
Install the plugin from ClawHub:
bash openclaw plugins install clawhub:@stevekinney/openclaw-slack-workspace -
In your Slack app's settings (api.slack.com/apps), add the scopes listed under Slack scopes, then reinstall the app to your workspace. For
slack_searchandslack_search_context, also add the user-token scopes and copy the app'sxoxp-user token. -
Point the plugin at your tokens, as shown under Configuration, then reload it:
bash openclaw plugins reload slack-workspace -
Confirm that the tokens hold every scope the tools need:
bash openclaw slack-workspace doctor
Configuration
Configure the plugin under plugins.entries.slack-workspace.config in your OpenClaw config:
{
plugins: {
entries: {
"slack-workspace": {
config: {
botToken: { source: "env", id: "SLACK_BOT_TOKEN" },
userToken: { source: "env", id: "SLACK_USER_TOKEN" },
workflowTriggers: {
standup: { source: "env", id: "STANDUP_TRIGGER_URL" },
},
autoJoin: true,
autoJoinDeny: ["C0123ABCD"],
routing: {
enabled: true,
accountId: "default",
directMessages: {
defaultTarget: { agentId: "main", sessionKey: "agent:main:slack" },
rules: [],
},
},
},
},
},
},
}
| Key | Type | What it's for |
|---|---|---|
botToken | string or SecretRef | The xoxb- bot token. Every tool except slack_search, slack_search_context and slack_workflow_trigger_run uses it, and slack_identity uses it by default. Falls back to SLACK_BOT_TOKEN. |
userToken | string or SecretRef | The xoxp- user token. Only slack_search and slack_search_context need it (and slack_identity with tokenKind: "user"), because Slack's search.* methods reject bot tokens and assistant.search.context needs an action_token with a bot token. Falls back to SLACK_USER_TOKEN. Leave it out if you don't need search. |
workflowTriggers | map of name to string or SecretRef | Workflow Builder webhook trigger URLs for slack_workflow_trigger_run. See Workflow triggers. |
autoJoin | boolean, default true | Join a public channel and retry once when a bot-token call fails with not_in_channel. See Channel membership. |
autoJoinDeny | array of channel IDs | Channels the bot never auto-joins. |
routing | object | Opt-in reply routing and interaction ownership. enabled defaults to false; accountId defaults to "default". |
When routing is enabled, semanticReplies.enabled defaults to true, with a windowSeconds of 900 and maximumCandidates of 5. Retention defaults to messageDays: 30, maximumMessages: 10000, interactionHours: 24, and maximumInteractions: 2000. directMessages.defaultTarget is optional; rules match accountId, workspaceId, and userId, with an optional channelId. A channel-specific rule wins over a user rule, followed by the default target. All targets are pinned to their concrete sessionId; if that session is archived, reset, replaced, or deleted, the target becomes inactive instead of following a reused session key.
Session liveness uses the SDK's asynchronous exact-session reader with latest-read consistency. It does not require privileged Gateway RPC access. Semantic routing uses the agent's Decision model first. To authorize the Utility fallback, configure plugins.entries.slack-workspace.llm with allowAgentIdOverride: true, allowModelOverride: true, and allowedModels plus allowedCompletionModels containing the configured Utility model's exact provider/model reference. Keep those allowlists aligned with the Utility setting.
Routing requires the companion OpenClaw host routing ABI version 1. Installing the released SDK alone is insufficient—the host must expose the inbound route contract and native interactive handler. Enabling routing without that companion host change fails plugin registration with an actionable error. Leave routing disabled to use the released host.
Keep the companion in the image build rather than applying it only to a running container. The Railway template pins this repository’s routing patch by immutable commit and SHA-256, applies it to the release source, and runs routing regressions before producing an image. An incompatible upgrade must fail the build until the patch is rebased and verified. Select the image’s matched Slack channel with openclaw plugins install slack; its persisted bundled path follows subsequent images instead of retaining a separately installed channel archive. See the routing verification and upgrade record.
Each secret can be a plain string or a SecretRef ({ source, provider?, id }). Use a SecretRef. botToken, userToken, and every workflowTriggers entry are declared in configContracts.secretInputs, so the host resolves the reference before the plugin sees it. If a SecretRef reaches the plugin unresolved, the tool fails and tells you to run openclaw secrets reload. A token whose prefix doesn't match its slot, say an xoxp- token in botToken, logs a warning but still runs.
Slack scopes
The Slack app needs these scopes, split by token:
Bot token (xoxb-): assistant:write, bookmarks:read, bookmarks:write, canvases:read, canvases:write, channels:history, channels:join, channels:manage, channels:read, channels:write.invites, channels:write.topic, chat:write, files:read, files:write, groups:write, groups:write.invites, groups:write.topic, im:write, lists:read, lists:write, metadata.message:read, remote_files:read, remote_files:share, remote_files:write, usergroups:read. Add groups:history, im:history, and mpim:history to read threads outside public channels.
User token (xoxp-): search:read, search:read.public. Add search:read.private, search:read.im, search:read.mpim, search:read.files and search:read.users to let slack_search_context cover private channels, DMs, files and people.
The groups:write* scopes let the channel lifecycle tools act on private channels. Without them, those tools still work on public channels, and a private-channel call fails with an error naming the missing scope. Without channels:join, auto-join and slack_channel_join fail with an error naming the scope.
The source of truth is TOOL_SCOPES in src/doctor.ts. Run openclaw slack-workspace doctor after changing the Slack app to confirm the reinstall picked up every scope.
Skills
The plugin ships three agent skills in skills/, which teach the agent when and how to reach for the tools:
skills/slack-block-kit: picking betweenslack_post_table,slack_post_plan,slack_post_chart,slack_post_rich_text, and rawslack_blocks_send, with a Block Kit reference inreferences/block-kit.md.skills/slack-canvas: writing canvases, the built-in templates, and canvas Markdown.skills/slack-lists: designing a List's columns and writing rows.
Approvals
A before_tool_call hook asks a human to approve destructive or wide-reach calls before they reach Slack: slack_canvas_edit with operation: "replace" or "delete", slack_canvas_delete, slack_bookmark_remove, slack_scheduled_cancel, slack_channel_archive, slack_channel_unarchive, slack_list_item_delete, slack_list_items_delete_multiple, slack_remote_file_remove, slack_message_delete, every slack_channel_kickoff, and slack_canvas_access_set or slack_list_access_set when they share with any channel or grant owner. Revoking a wide share doesn't un-show what people already saw, so those wait for approval, while read or write grants to individual users go straight through. Reviewers get allow-once or deny only; the plugin doesn't persist trust, so it never offers allow-always. The thread-to-canvas and thread-to-list tools enforce the same approval policy before any Slack call, including when sharing an existing object or creating an object shared with the source channel. The rules live in src/approvals.ts. When you add an irreversible or disruptive tool, register its rule there in the same change. slack_canvas_status_update also requires approval because rewriting its status section replaces existing content.
Archive, unarchive, and rename also require an explicit confirm: true argument. The schema has no default for it, and the tool refuses the call before contacting Slack without it. That guard still holds in cron jobs and other automation where no one is around to approve.
Real-time Search
slack_search_context is the tool for questions like "what did we decide about X?". It calls assistant.search.context with the user token, which needs no action_token (only bot-token calls do). Each message hit comes back with its permalink and, by default, the messages just before and after it, so the agent can read the decision in context. Pass contentTypes to also search files, channels or users, and channelTypes to narrow the conversations searched (default: all four types). Results are turn-local: the tool description tells the agent to answer from them and not store them. Slack rate-limits the method to roughly 10 calls a minute per user; short Retry-After waits are retried, longer ones fail with a clear error rather than falling back.
When the call fails with missing_scope, not_allowed_token_type, unknown_method, feature_not_enabled, or assistant_search_context_disabled, the tool runs the same query through search.messages instead and returns mode: "legacy", a one-line reason, and a doctorHint. Legacy results are messages only, without context messages. slack_search stays for exact keyword and modifier queries, files, and numbered paging.
Canvas discovery
slack_canvas_list finds canvases you don't already have an ID for. Slack stores canvases as files, so the tool calls files.list with types=canvas, the lookup Slack's canvas docs recommend. It uses the bot token and the files:read scope the app already holds, not a search scope. The bot sees only canvases it created or that were shared somewhere it can read. files.list pages by page and count instead of a cursor, so the result carries page, pages, total, and hasMore.
Channel discovery
slack_channel_list finds channels the agent doesn't already have an ID for. It calls conversations.list for every public channel in the workspace plus the private channels the bot is in, and leaves archived channels out unless you pass excludeArchived: false. Pass query to keep only channels whose name, topic, or purpose contains that text, ignoring case. The filter runs on each page the tool fetches, so a narrow query can come back with few results and hasMore: true. Each channel carries isMember, so the agent can decide what to join with slack_channel_join. If the bot token lacks groups:read, Slack refuses the private-channel half of the request. The tool then lists public channels only and returns privateIncluded: false.
slack_channel_members returns a channel's member user IDs from conversations.members. Both tools follow Slack's cursor for up to maxPages pages (default 10). If hasMore is still true, call again with the returned cursor. Neither waits for approval, because both are read-only.
Channel lifecycle
slack_channel_archive, slack_channel_unarchive, slack_channel_rename, slack_channel_set_topic, slack_channel_set_purpose, and slack_channel_invite work on public channels and on private channels the bot is a member of. slack_channel_create makes a public channel unless you pass isPrivate: true. Private channels need the groups:write, groups:write.topic, and groups:write.invites twins of channels:manage, channels:write.topic, and channels:write.invites. On an install without them, Slack answers a private-channel call with missing_scope, and the tool turns that into an error naming the scope and pointing to openclaw slack-workspace doctor. A channel_not_found usually means a bad ID or a private channel the bot isn't in; invite the bot first. Archive and unarchive keep their confirm: true guard and approval on private channels too. Rename keeps its confirm: true guard but does not require a second asynchronous approval. The approval hook sees only the call's arguments, so its prompt names the channel ID rather than saying whether the channel is private.
slack_channel_kickoff stands up a project room in one call: it creates a channel, public by default or private with isPrivate: true, then runs whichever of these you pass: set the topic, set the purpose, invite users, create a canvas shared to the channel, and add a link bookmark. It's composed from the same calls as the single-purpose tools. Once the channel exists, a failed step doesn't stop the rest. Each step lands in steps with its error, and complete is false if any of them failed. If the create call fails, the tool throws, because nothing else can run without a channel. Every kickoff waits for a human's approval, and the prompt says whether the new channel is public or private.
Channel membership
slack_channel_join and slack_channel_leave add and remove the bot from a public channel. Neither waits for approval: both are visible and easy to reverse. slack_channel_leave refuses private channels, because the bot can't rejoin one without a member's invite.
Most tools don't need an explicit join. When a bot-token call fails with not_in_channel, the client looks the channel up with conversations.info, joins it with conversations.join if it's public, and retries the original call once. The tool's result then carries autoJoined: true, so the agent knows it's now a member. The client never joins private channels, DMs, or group DMs; those fail with an error asking for an /invite @OpenClaw. Archived channels fail with an error that says so. A failed join or a failed retry is never retried again. Set autoJoin: false in the plugin config to turn this off, or list channel IDs in autoJoinDeny to keep the bot out of specific channels.
conversations.join needs the channels:join bot scope. If your Slack app doesn't have it, a join fails with an error naming the scope. Keep in mind what membership does on the host side: with the channel plugin's groupPolicy: "open", every channel the bot joins becomes one where the agent answers @-mentions, and the channel plugin posts an introduction on join unless channels.slack.joinIntro is false.
Lists
slack_list_create wraps slackLists.create with the bot token and the lists:write scope. Pass a schema of typed columns (key, name, type, optional primary, and Slack's column options passed through verbatim), or copy an existing list's columns with copyFromListId, adding includeCopiedListRecords to copy its items too. Slack rejects a call that sets both schema and copyFromListId, so the tool refuses it before calling Slack. todoMode adds Slack's completed, assignee, and due-date columns. Slack has no method to add, remove, or retype columns after creation, so the schema has to cover every column up front. The result carries the new listId and each column's id, which later item writes need.
slack_list_update wraps slackLists.update with the lists:write scope. It changes a list's name, description (Slack mrkdwn, sent as a rich_text block with real mentions), and todoMode in place, so the listId and every row ID stay valid. It sends only the fields you pass, refuses a call that passes none, and returns which fields it updated. It doesn't wait for approval, since each change can be undone with another update. Columns stay out of reach: Slack doesn't let them change after creation.
slack_list_schema reads an existing list's columns with the lists:read scope. It calls slackLists.items.list with include_list and a one-item limit, since that response carries the parent list's schema and slackLists.items.info would need an item ID first. Each column comes back with its id, key, name, and type, and select and multi-select columns add options pairing each option ID with its label. Item writes need those IDs rather than the names people see in Slack.
slack_list_item_create and slack_list_item_update take column names, so callers don't have to call slack_list_schema first. Both read the schema once per call, resolve each name (or column ID or key) and each select label to Slack's IDs, and shape every value for its column type: text becomes a rich_text block; user, channel, email, phone, date (YYYY-MM-DD), and select values become arrays; number and rating stay bare numbers; checkbox stays a bare boolean. Columns of other types, such as attachments, are refused before anything reaches Slack. slack_list_item_create passes parentItemId through to add a subtask. slack_list_item_update sends all its cells in one slackLists.items.update call, and the cells can span several items. slack_list_item_delete and slack_list_items_delete_multiple wrap slackLists.items.delete and .deleteMultiple, and both wait for approval.
slack_list_items_list and slack_list_item_info are the read side, with the lists:read scope. slack_list_items_list wraps slackLists.items.list and walks Slack's cursor pages with the shared pagination helper, up to ten pages per call; when hasMore is still true, pass the returned cursor back to continue. Set archived: true to list archived items instead of active ones. slack_list_item_info wraps slackLists.items.info and returns one item plus its subtasks. Both return each item's id, parentItemId for subtasks, who created it and when, and its fields: each cell's columnId, key, plain text where Slack sends one, and Slack's value as-is. Cells come back keyed by column ID, so pair them with slack_list_schema to get column names.
slack_list_export exports a whole list as a file, with the lists:read scope. Slack builds the file asynchronously, so the tool starts a job with slackLists.download.start and then polls slackLists.download.get every two seconds. It polls at most ten times and stops before a poll would land past 30 seconds. format is csv (the default) or json, and includeArchived adds archived rows. includeThreads and includeAttachments only work with JSON, so the tool refuses them on a CSV export before calling Slack, and it repeats them on every poll, as Slack requires. When the file is ready, the result has ready: true and a downloadUrl on files.slack.com. If time runs out, the result has ready: false and the jobId; call again with that jobId (and the same format options) to keep waiting without starting a second export. A job that reports a failed status throws. Starting an export changes nothing in the list, so the tool is marked read-only and doesn't wait for approval. The download URL is a private Slack file link, and its lifetime hasn't been confirmed against a live workspace yet.
slack_list_access_set and slack_list_access_delete wrap slackLists.access.set and .delete with the lists:write scope, and take the same arguments as the canvas access tools: channelIds or userIds (never both in one call), and for set, an accessLevel of read, write, or owner. Only users can be owners, so the tool refuses owner with channelIds before calling Slack. Calling set again for the same target changes its level. Channel shares and owner grants wait for approval; read and write grants to users don't.
slack_list_from_thread turns a thread's action items into List rows. It reads the thread with the same conversations.replies helper as slack_canvas_from_thread, then picks out open checklist entries (- [ ] …), lines starting TODO: or Action item:, and bullets under an "Action items:" or "Next steps:" heading. The first mention in an item becomes its assignee. An agent that has read the thread itself can pass items (each a task with an optional assignee and dueDate) and skip the pattern matching. Without listId, the tool creates a todo_mode list with one primary Task column and shares it with the thread's channel (or the channelIds/userIds given). With listId, it reads that list's schema and appends rows, putting the task in the primary column (or first text column) and the assignee and due date in the to-do columns when the list has them. Slack has no batch item create, so each row is its own slackLists.items.create call. If a row or the share fails, the result still carries the listId, the rows already added, and an itemError or shareError. The tool needs lists:write, lists:read for an existing list, and the history scope for the thread's channel type.
File uploads
slack_file_upload pushes a file the agent generated, like a report, a CSV, or a chart image, to Slack outside a normal chat turn. files.upload is retired, so the tool runs Slack's three-step external upload. First, files.getUploadURLExternal reserves a URL for the file's byte length. Then the tool POSTs the raw bytes to that pre-signed URL, without the token. (Slack's docs specify POST for this step, not PUT.) Last, files.completeUploadExternal finishes the upload and shares the file. With channelId, the file lands in that channel, or in the threadTs thread, with an optional initialComment. Without channelId, the file stays private to the bot: it is uploaded but shared nowhere. Text goes in content as-is. Binary files like PNGs go in as base64 with contentEncoding: "base64". filename sets the extension Slack uses to infer the file type, and it defaults to the title. The result is { fileId, permalink, channelId, ts }. ts is the share message's timestamp, and it's null when the file is private or Slack hasn't reported the share yet. The tool needs the files:write bot scope.
Remote files
slack_remote_file_add registers an external document, like a Linear issue or a generated report link, as a native Slack file object with a preview card, so it shows up in Slack as a file instead of a bare hyperlink. It wraps files.remote.add and takes an externalId (your own stable, unique ID for the document), externalUrl, and title, plus optional filetype and indexableFileContents, the plain text Slack indexes for search. slack_remote_file_update changes those fields in place, and slack_remote_file_remove takes the file out of Slack and out of every channel it was shared to. The external document itself is untouched, but the removal waits for approval. slack_remote_file_share posts the file into one or more channels. Adding doesn't share, so call share afterward. Update, remove, and share find the file by exactly one of fileId or externalId. Add, update, and remove need the remote_files:write scope; share needs remote_files:share. Every remote-file tool uses the bot token, because files.remote.add rejects user tokens. These scopes are separate from files:read and files:write, which cover uploaded bytes like slack_file_upload. The result is the file's { fileId, externalId, externalUrl, title, permalink }, and share adds the channelIds.
slack_remote_file_info and slack_remote_file_list find remote files again in a later turn, with the remote_files:read scope. slack_remote_file_info wraps files.remote.info and takes exactly one of fileId or externalId. slack_remote_file_list wraps files.remote.list and walks Slack's cursor pages with the shared pagination helper, up to ten pages per call; when hasMore is still true, pass the returned cursor back to continue. Pass channelId to list only the files shared into that channel. Both return files in the same { fileId, externalId, externalUrl, title, permalink } shape as the write tools.
Scheduled messages
slack_schedule_message wraps chat.scheduleMessage with the bot token and the chat:write scope. Slack allows 30 messages per 5-minute window per channel, counted by when they're set to post, not when you schedule them. Past that, Slack fails the call with restricted_too_many, and the plugin's error hint names the cap so the agent knows to spread the post times out. Both scheduling tools say so in their descriptions, too.
Slack has no method to edit a scheduled message, so slack_schedule_reschedule does the delete-and-recreate in one call. It takes the original's channelId and scheduledMessageId, plus the replacement's full text, postAt, and optional blocks and threadTs. Nothing carries over from the original. The tool checks postAt before it calls Slack, then schedules the replacement first and cancels the original second. That order means a refused replacement, say from the channel cap, leaves the original pending. If the original can't be cancelled, maybe because it already posted, the tool withdraws the replacement and reports the error, so you never end up with two copies. In the rare case that the withdrawal fails too, the error names both IDs. The result is { channelId, scheduledMessageId, replacedScheduledMessageId, postAt, postAtIso }, and the replacement has a new ID. It isn't gated by an approval the way slack_scheduled_cancel is, because it always leaves one message pending.
Reminders
slack_remind is the plugin's reminder tool. Slack's docs for reminders.add and reminders.list say the reminder methods began retiring in March 2023 and "have become degraded or useless," so the tool doesn't touch them. Instead, it schedules an ordinary message. Pass userId to remind a person: the tool calls conversations.open to find or open the bot's DM with them, then schedules the reminder there with chat.scheduleMessage. Pass channelId instead to post the reminder in a channel, which skips the DM step. Set exactly one of the two. when takes the same formats and limits as slack_schedule_message's postAt: an ISO-8601 datetime with an explicit offset, or Unix seconds, in the future and at most 120 days out. The tool checks when before it calls Slack. It uses the bot token with the im:write and chat:write scopes. The result is { channelId, userId?, scheduledMessageId, postAt, postAtIso }, and because a reminder is just a scheduled message, slack_scheduled_list shows it and slack_scheduled_cancel cancels it. Each reminder fires once. Scheduled messages never carry Slack message metadata—Slack accepts the request but will not post scheduled messages that include metadata. For a recurring one, set up an openclaw automations job that calls the tool.
Ephemeral messages
slack_post_ephemeral wraps chat.postEphemeral to send one user a private nudge inside a shared channel or thread, so nobody else sees it. It takes channelId, userId, text, and optional blocks and threadTs, and it uses the bot token with the chat:write scope. The user has to be a member of the channel. The result is { channelId, userId, ephemeralTs }, and it deliberately has no ts field: chat.update can't target an ephemeral message, so ephemeralTs is for reference only and won't work with slack_blocks_update or updateTs. Ephemeral messages don't persist, either. They vanish when the user reloads Slack.
Assistant thread titles
slack_assistant_set_title renames the title Slack shows for an Agent View or Assistant View thread, so the agent can swap the default title for something descriptive once it knows what the conversation is about. It takes channelId, threadTs (the assistant thread's root), and title (up to 200 characters), and it uses the bot token. It only works on those Slack-managed assistant threads, not ordinary channel or DM threads. The bundled Slack channel plugin never renames sessions itself, so this tool doesn't compete with core.
Slack deprecates assistant_view in February 2027 (see Migrating to agent messaging), so the tool calls the Agent Sessions API first: agents.sessions.rename, which needs chat:write. When that call fails with unknown_method or missing_scope, it calls the legacy assistant.threads.setTitle (assistant:write) instead. Other errors, such as session_not_found, are returned as-is. The result is { channelId, threadTs, title, api }, where api is "agents.sessions" or "assistant.threads" for the fallback.
slack_assistant_suggest_prompts wraps assistant.threads.setSuggestedPrompts to show up to four tappable follow-up prompts for an Agent View or Assistant View conversation. In Agent View, Slack now shows them at the top of the Messages tab rather than under a particular thread. It takes channelId, threadTs, and prompts, an array of one to four { title, message } objects, and it uses the bot token with the assistant:write scope. Each call replaces the prompts already shown. The result is { channelId, threadTs, prompts }. This tool is a stopgap: the bundled channel plugin only makes a threadless setSuggestedPrompts call to detect Agent View and has no per-thread action yet (openclaw/openclaw#50481). Retire it, or merge it into core, once core ships the equivalent.
Workflow triggers
slack_workflow_trigger_run starts a Workflow Builder workflow from a conversation. Slack has no Web API method that starts a Workflow Builder workflow from a bot token, so the tool uses a webhook trigger instead. Create one in Workflow Builder, which gives you a stable https://hooks.slack.com/triggers/... URL, and add it under a name in the plugin config's workflowTriggers map. Each entry is a string or a SecretRef, and each one is declared in configContracts.secretInputs (workflowTriggers.*). The URL is the credential: anyone holding it can start the workflow, so treat it like a token. The tool takes name and an optional payload object, whose keys are the trigger's variables as defined in Workflow Builder. It POSTs payload as JSON, without a Slack token, so it needs no OAuth scope. An unknown name fails before any request and lists the configured names. The result is { name, status, response }, where response is Slack's body, parsed as JSON when it is JSON. A non-2xx status fails with the status and the start of the body. Neither the error nor the log line ever includes the URL.
Workflow Builder → OpenClaw bridge
docs/workflow-builder-gateway-bridge.md covers the opposite direction: a Workflow Builder workflow that hands a prompt to an OpenClaw agent through the Gateway's POST /hooks/agent endpoint. OpenClaw posts the agent's reply straight to a Slack channel. It needs no plugin code and no new scopes, but it does need hooks.enabled, a public Gateway origin (gateway.publicOrigin), and a small custom-step Slack app, because Workflow Builder has no built-in outbound HTTP step. The recipe hasn't been run live yet.
Inbound webhooks
docs/inbound-webhooks.md maps the ways an external system can reach OpenClaw or Slack to when to use each. The options are OpenClaw's Gateway hooks (/hooks/wake, /hooks/agent with direct Slack delivery, and mapped /hooks/<name>), the bundled Webhooks plugin for TaskFlow state (which never starts an agent), and Slack's own incoming-webhook URL (which bypasses OpenClaw entirely). None of them needs code in this plugin, which is why it doesn't run an HTTP listener of its own.
Scope doctor
openclaw slack-workspace doctor calls the read-only auth.test once per configured token and compares the scopes Slack actually granted against what each tool needs. Missing scopes are listed with the tools they break. This catches a Slack app whose manifest gained scopes that were never reinstalled. Pass --json for machine-readable output. The command exits non-zero on any gap, unavailable token, or rejected token. The per-tool requirements live in TOOL_SCOPES in src/doctor.ts. When you add a tool, add its entry there; a test fails if one is missing.
Scheduled digests
To turn a recurring automation's N messages into one card that gets edited each run, have it look up its card with slack_message_get and post with updateTs. The steps and an example prompt are under "Scheduled-digest recipe" in skills/slack-block-kit/SKILL.md.
Routing and interactions
Routing is opt-in and runs after the Slack channel has authenticated the sender and isolated the account, workspace, and conversation. The resolver checks an exact reply origin first, then a known thread origin, then bounded semantic candidates, and finally direct-message rules/defaults. A stale explicit origin never gets semantic fallback; it can fall through to a configured direct-message target or ordinary routing. The default direct-message target applies only to routing.accountId and the workspace authenticated by the configured bot token. An unmatched account or workspace keeps ordinary host routing; explicit rules remain scoped to their named account, workspace, user, and optional channel. If workspace authentication fails, the default target is not used. Native approvals remain owned by the host approval path.
Preparing an interaction requires an active Slack turn with a host-provided sender and conversation. Explicit userId and channelId must match that trusted context; supplied IDs cannot authorize preparation from cron jobs or other channels.
Use slack_interaction_prepare when a model needs a button to open a modal or an external select to return bounded options. For a modal, pass a Slack view and attach the returned actionId to the button. For options, pass up to 100 { text: { type: "plain_text", text }, value } entries and attach the returned actionId to the external select. The result is { actionId, reference, expiresAt }. The native host handler opens the modal or answers options synchronously, validates the actor and conversation, and routes submissions to the originating live session. It never exposes trigger IDs, response URLs, or session IDs to the model. Expired controls receive an ephemeral explanation and never fall through to another session.
Proactive scheduled routing uses an opaque footer marker in the message blocks because Slack refuses to post scheduled messages that contain metadata. The marker consumes one block, so routed scheduled messages allow at most 49 content blocks. Keep the marker intact when constructing scheduled messages.
Not built on purpose
These gaps are deliberate decisions, not oversights. The reasoning for each lives under "Explicitly out of scope" in ROADMAP.md; read it before proposing any of them.
- Legacy message
attachments. Slack deprecated them in favor of Block Kit for new development. No tool takes anattachmentsparameter, and none should. - Anything the bundled Slack channel plugin already owns. That covers pins, reactions, the emoji list, member info, in-turn file upload and download, opening a conversation for ordinary chat, assistant thread and agent session status (
assistant.threads.setStatus,agents.sessions.setStatus), and App Home (views.publish). A secondviews.publishwould race the channel plugin's own Home view. - Per-message personas (
chat:write.customize) and dedicatedchat:write.publicorusers:writefeatures. They're out of scope for this plugin. - Link unfurling and Work Objects (
chat.unfurl). They need alink_sharedevent that this app doesn't subscribe to, relayed from the channel plugin's Socket Mode connection, plus a registered unfurl domain. None of those exist. - Workflow Steps. Slack retired legacy steps from apps (
workflow.steps:execute) in September 2024. Modern custom steps (functions.completeSuccess) need Socket Mode handlers, and the channel plugin owns this app's only connection. - A general
views.opentool. Modal creation is available throughslack_interaction_prepare, which prepares a native button callback and keeps the short-lived trigger inside the host interactive handler. It does not expose a trigger ID to the model or replace native approval controls. - Trigger management (
workflows.triggers.*). Those methods only target workflows in this app's own manifest, never Workflow Builder ones. Useslack_workflow_trigger_runinstead. - Workflow templates and
mcp:connecttools. No Web API method uses these scopes. - List discovery. No Slack method lists the Lists in a workspace. You need the
list_idalready.
Development
Build
npm install
npm run plugin:build
npm run plugin:validate
npm test
Manifest
openclaw.plugin.json is hand-authored. npm run plugin:build refreshes only the fields OpenClaw derives from the entry (id, name, description, configSchema, contracts.tools) and keeps the rest. Everything else (configContracts, skills, cliCommands, toolMetadata) is maintained by hand; when you add a tool, add its toolMetadata entry to both the manifest and src/tool-metadata.ts, which a test compares against it.
Output shaping
Every tool declares an outputSchema. When a tool returns data from Slack, the default is to curate it: map Slack's object down to the few fields the agent acts on, rename them to camelCase, and set additionalProperties: false. Curate whenever the raw object is large or noisy, or carries fields the agent has no use for, like ranks, audit user and team IDs, or icon URLs. slack_search, slack_scheduled_list, slack_bookmark_list, slack_bookmark_add, slack_bookmark_edit, slack_canvas_sections, slack_canvas_list, and slack_usergroup_list all work this way. Pass a Slack value through untouched only when it's small and every field is useful, or when its structure is the point, like search's paging object. When you do, say so in the schema description. The rule also lives next to the shared schemas in src/schemas.ts.
CI
.github/workflows/ci.yml runs on every push and pull request. It builds, checks that openclaw.plugin.json matches openclaw plugins build output, validates the plugin, and runs the tests. To run the same checks locally before you commit:
npm run ci
If the metadata check fails, run npm run plugin:build and commit the updated manifest.
Release
dist/ isn't committed, so publish a locally built tarball rather than the unbuilt GitHub repository. npm pack rebuilds dist/ through prepack. Use Node 24.22.1 from .node-version; the artifact checks also pin that runtime inside their Docker hosts.
Release from a clean, reviewed main. Update the version in package.json, package-lock.json, and openclaw.plugin.json, and add the compatibility and feature changes to CHANGELOG.md. Keep the package peer dependency and openclaw.compat requirements aligned with the tested minimum host. Then run the quality gates and pack once:
release_state=$(mktemp -d)
OPENCLAW_HOME="$release_state" OPENCLAW_STATE_DIR="$release_state/state" OPENCLAW_CONFIG_PATH="$release_state/configuration.json" npm run ci
release_directory=$(mktemp -d)
npm pack --pack-destination "$release_directory"
release_artifact="$release_directory/stevekinney-openclaw-slack-workspace-$(node -p "require('./package.json').version").tgz"
npm run test:artifact -- --artifact "$release_artifact"
npm run test:artifact -- --artifact "$release_artifact" --image ghcr.io/openclaw/openclaw:2026.10.5@sha256:f40d1e66fd14d8c5b49c832f4100f83b53fdc01f5108f7db11de38fd0ee6dbe5
: "${SLACK_COMPANION_IMAGE:?Set the reviewed companion-patched OpenClaw image}"
npm run test:artifact -- --artifact "$release_artifact" --image "$SLACK_COMPANION_IMAGE" --companion
These checks install the exact tarball through OpenClaw's supported npm-pack: installer. Each host uses disposable state, a clean environment, and fake tokens. Network access is blocked during runtime checks and Slack responses are mocked. The stock checks verify ordinary tools with routing disabled and the actionable error for unsupported routing activation. The companion check requires the real host contract and exercises ownership persistence, exact-session replies, interactive routing, and archive fallback using real SDK session and keyed-store APIs. It never substitutes a mock runtime for a missing companion. Reports retain the tarball path and SHA-256. Failures print command diagnostics; native failures also retain their isolated fixture for diagnosis. A host command that cannot settle within five minutes fails the check.
The companion image must be built from the reviewed host source and patch described in the routing guide. It must already exist or be pullable by Docker. The test wraps that image with the pinned Node binary; it does not patch an arbitrary stock host. CI runs the stock checks on the pinned SDK and minimum supported host. Run the companion check as a release gate whenever the plugin, SDK, or companion changes. Artifact checks establish package/runtime behavior; live Slack transport and production readiness need their own deployment verification.
After all gates pass, authenticate the current ClawHub CLI as the stevekinney package owner and dry-run the same tarball. Inspect the package version, compatibility, file list, and source attribution before uploading:
clawhub login
clawhub whoami
release_commit=$(git rev-parse HEAD)
clawhub package publish "$release_artifact" --family code-plugin --source-repo stevekinney/openclaw-plugin-slack-workspace --source-commit "$release_commit" --dry-run
clawhub package publish "$release_artifact" --family code-plugin --source-repo stevekinney/openclaw-plugin-slack-workspace --source-commit "$release_commit" --wait
Wait for publication and security checks to finish, verify the published version, then tag and push the release: git tag v<version> && git push origin v<version>. Publishing the plugin does not install the companion OpenClaw runtime. Leave routing disabled on hosts without that contract.
Use the isolated OPENCLAW_HOME, OPENCLAW_STATE_DIR, and OPENCLAW_CONFIG_PATH prefix above for CLI-driven quality gates. None of these steps need your real state, configuration, tokens, or a running gateway.
License
MIT. See LICENSE.
