Send a Slack message from a workflow
Post a Slack message when a workflow action fires, from a Sanity Function generated from the workflow, without posting twice when the effect runs again.
Early access
Workflows is in early access, built in public. Read How early access works before you rely on it.
This guide builds an approval workflow that posts one Slack message when someone approves. An effect is a step the workflow hands off to code outside the engine. Here, the approve action queues a slack-notify effect. A Sanity Function generated from the workflow runs the effect’s handler. The handler posts the message and records the time it posted as the effect’s sentAt output.
You need:
- Node.js 22.18 or later, which runs TypeScript files directly.
- A Sanity project and dataset, and an API token with the Developer role or higher on that project.
- A Slack workspace where you can create apps.
- Optional: Run an external step with an effect explains effects and handlers, and Generate Functions from your workflow definitions explains the generated Functions.
Get a Slack incoming webhook
An incoming webhook is a URL that posts messages to one Slack channel. Slack issues it to a Slack app, as Sending messages using incoming webhooks describes:
- Go to api.slack.com/apps, click Create New App, and choose From scratch. Name the app and pick your workspace. To create the app From a manifest instead, use the manifest below: the app needs a bot user and the
incoming-webhookbot scope. - Open Incoming Webhooks in the app settings and switch Activate Incoming Webhooks on.
- Click Add New Webhook to Workspace, pick the channel the messages go to, and click Allow.
- Copy the webhook URL. It starts with
https://hooks.slack.com/services/. You put it in.envasSLACK_WEBHOOK_URLin a later step.
display_information:
name: Workflows notifications
features:
bot_user:
display_name: Workflows
oauth_config:
scopes:
bot:
- incoming-webhookThe webhook URL is the only Slack credential this guide uses. You don’t need the bot token, any app-level token, or any other scope. Anyone with the URL can post to the channel, so keep it out of version control.
Create the project
Create a directory with the six files below. The generator and setup add the rest.
package.json installs the Workflows packages at one version and runs setup.ts with the values from .env:
{
"name": "example-slack-effect",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"setup": "node --env-file=.env setup.ts",
"workflows": "node --env-file=.env node_modules/@sanity/workflow-cli/bin/run.js",
"sanity": "node --env-file=.env node_modules/sanity/bin/sanity"
},
"dependencies": {
"@sanity/blueprints": "^0.32.0",
"@sanity/client": "^8.11.0",
"@sanity/functions": "^1.7.3",
"@sanity/workflow-blueprint": "^0.38.0",
"@sanity/workflow-engine": "^0.38.0"
},
"devDependencies": {
"@sanity/workflow-cli": "^0.38.0",
"@types/node": "^26.6.5",
"sanity": "^6.21.0",
"typescript": "^6.0.3"
}
}Copy .env.example to .env. You fill it in when you deploy:
# An API token with the Developer role (or higher) on the project in sanity.workflow.ts
SANITY_AUTH_TOKEN=
# A Slack incoming webhook URL: https://api.slack.com/messaging/webhooks
SLACK_WEBHOOK_URL=workflow.ts defines the workflow. Its review stage has one approve action, which declares the slack-notify effect. The effect binds the workflow’s title field as its title param and declares one output, sentAt:
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const approval = defineWorkflow({
name: 'approval',
title: 'Approval',
initialStage: 'review',
fields: [{type: 'string', name: 'title', title: 'Title', initialValue: {type: 'input'}}],
stages: [
{
name: 'review',
title: 'Review',
activities: [
{
name: 'review',
title: 'Review',
actions: [
{
name: 'approve',
title: 'Approve',
status: 'done',
effects: [
{
name: 'slack-notify',
bindings: {title: '$fields.title'},
outputs: [{type: 'datetime', name: 'sentAt', title: 'Sent at'}],
},
],
},
],
},
],
transitions: [{name: 'approved', to: 'approved'}],
},
{name: 'approved', title: 'Approved'},
],
})effect-handlers/slack-notify.ts is the effect handler. The generator looks in effect-handlers/ for one handler per effect and connects it to the generated Function. If a handler file already exists, the generator leaves it unchanged:
import type {EffectHandler} from '@sanity/workflow-engine'
export const slackNotify: EffectHandler = async (params) => {
const webhookUrl = process.env.SLACK_WEBHOOK_URL
if (!webhookUrl) throw new Error('SLACK_WEBHOOK_URL is not set')
// You might want to handle failure here, for example a message that was already sent.
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: `Approved: ${String(params.title)}`}),
})
if (!response.ok) {
throw new Error(`Slack responded ${response.status}: ${await response.text()}`)
}
return {outputs: {sentAt: new Date().toISOString()}}
}sanity.workflow.ts binds the definition to a deployment named slack. Set workflowResource.id to your project ID and dataset, joined by a dot, such as abc123de.production:
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {approval} from './workflow.ts'
// Every generated function bundles this file, so it must not read the environment.
export const slack = {
name: 'slack',
partition: 'slack-example',
expectedMinReaderModel: 11,
workflowResource: {type: 'dataset', id: '<projectId>.<dataset>'},
definitions: [approval],
} satisfies WorkflowDeploymentInput
export default defineWorkflowConfig({deployments: [slack]})setup.ts runs the deploy steps in order:
import {spawnSync} from 'node:child_process'
import {existsSync, readFileSync} from 'node:fs'
import {datasetResourceParts} from '@sanity/workflow-engine'
import {slack} from './sanity.workflow.ts'
const webhookUrl = process.env.SLACK_WEBHOOK_URL
if (!webhookUrl) throw new Error('Set SLACK_WEBHOOK_URL in .env')
const {projectId} = datasetResourceParts(slack.workflowResource.id)
// Reports a failure without the arguments, because one of them is the webhook URL.
function npx(...args: string[]): void {
const {status, signal, error} = spawnSync('npx', args, {stdio: 'inherit'})
if (status !== 0) {
const reason = {status, signal, code: error && 'code' in error ? error.code : undefined}
throw new Error(`npx ${args.slice(0, 3).join(' ')} failed: ${JSON.stringify(reason)}`)
}
}
// `blueprints destroy` keeps the config file and removes only its stackId.
function hasStack(configPath: string): boolean {
if (!existsSync(configPath)) return false
const config: unknown = JSON.parse(readFileSync(configPath, 'utf8'))
return isRecord(config) && typeof config.stackId === 'string' && config.stackId !== ''
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
npx('sanity-workflows', 'blueprints', 'generate')
npx('sanity-workflows', 'deploy')
if (!hasStack('.sanity/blueprint.config.json')) {
npx(
'sanity',
'blueprints',
'init',
'.',
'--project-id',
projectId,
'--stack-name',
'slack-example',
)
}
npx('sanity', 'blueprints', 'deploy')
npx('sanity', 'functions', 'env', 'add', 'wf-slack-drain-effects', 'SLACK_WEBHOOK_URL', webhookUrl)Deploy and run it
Fill in .env. SANITY_AUTH_TOKEN is an API token with the Developer role or higher on the project in sanity.workflow.ts. The token needs that role because setup deploys a Functions stack. SLACK_WEBHOOK_URL is the webhook URL you copied from Slack. Then install and run setup:
npm install
npm run setuppnpm install
pnpm run setupyarn install
yarn run setupbun install
bun run setupsetup runs these steps in order:
- Generates the Functions runtime with
sanity-workflows blueprints generate. - Deploys the workflow definition.
- Runs
sanity blueprints initif the directory has no stack yet, then deploys the project stack. - Sets
SLACK_WEBHOOK_URLon the generated drain function,wf-slack-drain-effects. The drain function runs queued effects.
The generator also writes a heartbeat stack in workflows-heartbeat/. The heartbeat re-queues an effect that no drain picked up. setup doesn’t deploy it, because deploying it needs an organization deploy token. To decide whether you need it, see Plan for heartbeat recovery.
Wait for setup to finish. The stack deploy takes a minute or two and may print You can safely exit. If you exit then, setup doesn’t set SLACK_WEBHOOK_URL, and the handler fails.
setup writes generated files next to yours, including .sanity/, functions/, and workflows-heartbeat/. If you keep the project in Git, leave them and .env out of it:
node_modules
.env
# Written by `npm run setup`
/workflows.blueprint.ts
/sanity.blueprint.ts
/workflows-heartbeat/
/functions/
/effect-handlers/all.ts
/.sanity/
/.build/Start an instance, approve it, and show it:
npm run workflows -- start approval --field title="Launch post"
npm run workflows -- fire-action INSTANCE_ID --activity review --action approve
npm run workflows -- show INSTANCE_IDpnpm run workflows -- start approval --field title="Launch post"
pnpm run workflows -- fire-action INSTANCE_ID --activity review --action approve
pnpm run workflows -- show INSTANCE_IDyarn run workflows -- start approval --field title="Launch post"
yarn run workflows -- fire-action INSTANCE_ID --activity review --action approve
yarn run workflows -- show INSTANCE_IDbun run workflows -- start approval --field title="Launch post"
bun run workflows -- fire-action INSTANCE_ID --activity review --action approve
bun run workflows -- show INSTANCE_IDReplace INSTANCE_ID with the ID that start prints. The workflows and sanity scripts run the sanity-workflows and sanity CLIs with the token from .env. If you’re logged in with npx sanity@latest login, you can run npx sanity-workflows directly instead.
Approving resolves the review activity, queues slack-notify, and moves the instance to approved. The queued effect starts the generated drain function, which posts one message to your channel: Approved: Launch post.
Wait a few seconds for the drain, then run show again. The Pending effects section is gone.
To see the result, run npm run workflows -- show INSTANCE_ID --json. The instance document’s effectHistory has a slack-notify entry with status set to done and the post time in outputs.sentAt.
If no message arrives, check the drain function’s logs with npm run sanity -- functions logs wf-slack-drain-effects.
Remove it
To remove the stack, run npm run sanity -- blueprints destroy. It removes the function and the robot token the stack created for it. The workflow definition stays deployed, because it’s a document in your dataset, not part of the stack. Instance documents stay in the dataset until you delete them.
Next steps
- Deploy the heartbeat stack and keep the generated runtime in sync with your definitions in Generate Functions from your workflow definitions.
- Read effect outputs from conditions in Run an external step with an effect.
Visiting agent?
Workflows has MCP tools that let an agent inspect, operate, author, validate, and deploy workflows. An agent host embeds these tools. Ask the person you work for whether your host includes the Workflows tools.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?