> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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.

> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) 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](https://www.sanity.io/docs/workflows/effects-and-runtimes) explains effects and handlers, and [Generate Functions from your workflow definitions](https://www.sanity.io/docs/workflows/generate-functions) 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](https://api.slack.com/messaging/webhooks) describes:

1. 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-webhook` bot scope.
2. Open **Incoming Webhooks** in the app settings and switch **Activate Incoming Webhooks** on.
3. Click **Add New Webhook to Workspace**, pick the channel the messages go to, and click **Allow**.
4. Copy the webhook URL. It starts with `https://hooks.slack.com/services/`. You put it in `.env` as `SLACK_WEBHOOK_URL` in a later step.

**Slack app manifest**

```yaml
display_information:
  name: Workflows notifications
features:
  bot_user:
    display_name: Workflows
oauth_config:
  scopes:
    bot:
      - incoming-webhook
```

The 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`:

**package.json**

```json
{
  "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:

**.env.example**

```sh
# 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`:

**workflow.ts**

```typescript
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:

**effect-handlers/slack-notify.ts**

```typescript
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`:

**sanity.workflow.ts**

```typescript
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:

**setup.ts**

```typescript
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**

```shell
npm install
npm run setup
```

**pnpm**

```shell
pnpm install
pnpm run setup
```

**yarn**

```shell
yarn install
yarn run setup
```

**bun**

```shell
bun install
bun run setup
```

`setup` runs these steps in order:

1. Generates the Functions runtime with `sanity-workflows blueprints generate`.
2. Deploys the workflow definition.
3. Runs `sanity blueprints init` if the directory has no stack yet, then deploys the project stack.
4. Sets `SLACK_WEBHOOK_URL` on 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](https://www.sanity.io/docs/workflows/generate-functions).

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:

**.gitignore**

```sh
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**

```shell
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_ID
```

**pnpm**

```shell
pnpm run workflows -- start approval --field title="Launch post"
pnpm run workflows -- fire-action INSTANCE_ID --activity review --action approve
pnpm run workflows -- show INSTANCE_ID
```

**yarn**

```shell
yarn run workflows -- start approval --field title="Launch post"
yarn run workflows -- fire-action INSTANCE_ID --activity review --action approve
yarn run workflows -- show INSTANCE_ID
```

**bun**

```shell
bun run workflows -- start approval --field title="Launch post"
bun run workflows -- fire-action INSTANCE_ID --activity review --action approve
bun run workflows -- show INSTANCE_ID
```

Replace `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](https://www.sanity.io/docs/workflows/generate-functions).
- Read effect outputs from conditions in [Run an external step with an effect](https://www.sanity.io/docs/workflows/effects-and-runtimes).

> [!NOTE]
> 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](https://www.sanity.io/docs/workflows/mcp).



