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

# Generate Functions from your workflow definitions

Generate the Sanity Functions your workflow definitions need, connect an effect handler, and deploy them with Blueprints. Experimental.

> [!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.



The Workflows generator reads your workflow definitions and creates the Sanity Functions they need: drains that run effects, start watchers that start autonomous workflows when documents match their start conditions, and a scheduled heartbeat for periodic reevaluation and effect recovery.

> [!WARNING]
> Experimental generator
> Runtime generation is experimental and not ready for production use. Its commands and generated files may change. To configure the Functions yourself, follow [Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions).

This guide generates the Functions for the `notify-reviewer` effect on `article-review`, connects the effect handler you already wrote, deploys everything, and checks that a queued effect runs without a terminal.

You need:

- [The finished quick start](https://www.sanity.io/docs/workflows/getting-started): `article-review` deployed, and a `sanity.workflow.ts` that exports the `dev` deployment.
- [Handle asynchronous work with effects](https://www.sanity.io/docs/workflows/effects-and-runtimes): the `notify-reviewer` effect on `article-review`, deployed, and the `effect-handlers.ts` module at the project root that exports `effectHandlers`.
- Familiarity with [Sanity Functions](https://www.sanity.io/docs/functions/functions-introduction) and [Blueprints](https://www.sanity.io/docs/blueprints/blueprints-introduction), and an [organization-scoped stack](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope).

The generated Functions import each deployment from `sanity.workflow.ts` by its `name`, so the [workflow deployment config](https://www.sanity.io/docs/workflows/deploy-definitions) must export each deployment object under that name, as the quick start does with `export const dev`, and include it in the default config. Deployment names must be valid JavaScript binding names: `production` works, `prod-eu` does not. A workflow with no effects, no `$now` conditions, no subworkflows, and no autonomous start needs no generated Functions. The effect is what gives `article-review` a drain and a heartbeat.

## Install the generator packages

Install the Functions and Blueprints tooling and the Workflows generator in the quick start project. `@sanity/workflow-engine` and `@sanity/workflow-cli` are already installed:

**npm**

```shell
npm install --save-dev --save-exact @sanity/blueprints @sanity/functions \
  @sanity/workflow-blueprint
```

**pnpm**

```shell
pnpm add --save-dev --save-exact @sanity/blueprints @sanity/functions \
  @sanity/workflow-blueprint
```

**yarn**

```shell
yarn add --dev --exact @sanity/blueprints @sanity/functions \
  @sanity/workflow-blueprint
```

**bun**

```shell
bun add --dev --exact @sanity/blueprints @sanity/functions \
  @sanity/workflow-blueprint
```

Keep `@sanity/workflow-blueprint` at the same version as `@sanity/workflow-engine` and `@sanity/workflow-cli`, and commit the updated lockfile. Each generated Function declares the versions of `@sanity/functions`, `@sanity/workflow-blueprint`, and `@sanity/workflow-engine` from your `package.json`, and the generator stops if one of them is missing.

## Generate the Functions

> [!WARNING]
> Remove hand-written Functions first
> If you deployed the Functions from [Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions), remove them before you generate. The generator uses the same names: it overwrites `functions/wf-dev-drain-effects/index.ts` and adds a second `wf-dev-runtime` robot token to `sanity.blueprint.ts`. Delete `functions/wf-dev-drain-effects/`, `functions/wf-dev-tick-instances/`, and their resources in `sanity.blueprint.ts`.

From the directory that contains `sanity.workflow.ts`, run the generator:

**npm**

```shell
npx sanity-workflows blueprints generate
```

**pnpm**

```shell
pnpm dlx sanity-workflows blueprints generate
```

**yarn**

```shell
yarn dlx sanity-workflows blueprints generate
```

**bun**

```shell
bunx sanity-workflows blueprints generate
```

The command first prints the runtime needs it found for each deployment, then the files it wrote:

**Output**

```text
Generated tree:
  created    workflows.blueprint.ts
  created    functions/wf-dev-drain-effects/index.ts
  created    functions/wf-dev-drain-effects/package.json
  created    functions/wf-dev-heartbeat/index.ts
  created    functions/wf-dev-heartbeat/package.json
  created    effect-handlers/all.ts
  created    effect-handlers/notify-reviewer.ts  yours now — implement the effect
  created    sanity.blueprint.ts  the workflow resources spread

✔ 8 file(s) written, 0 left untouched
```

The command covers every deployment in the config. `workflows.blueprint.ts` declares a robot token, the drain, and the heartbeat, using the project and dataset from the `dev` deployment's `workflowResource`. The generator adds those resources to `sanity.blueprint.ts`, and creates that file if it does not exist. Generated function and robot names derive from each deployment's `name`, so renaming a deployment replaces its Functions. Resolve any reported naming collision or manual wiring requirement before deployment.

Every generated function builds a runtime with `createRuntime` from `@sanity/workflow-blueprint/runtime` and calls one verb on it. A drain calls `runtime.drain(event)`, the heartbeat calls `runtime.heartbeat()`, and a start watcher calls `runtime.start(event)`:

**functions/wf-dev-drain-effects/index.ts**

```typescript
// Generated by `sanity-workflows blueprint generate` from the workflow definitions.
// Every run rewrites this file, so an edit here does not survive.

import {documentEventHandler} from '@sanity/functions'
import {createRuntime} from '@sanity/workflow-blueprint/runtime'

import {effectHandlers} from '../../effect-handlers/all'
import {dev as deployment} from '../../sanity.workflow'

export const handler = documentEventHandler(async ({context, event}) => {
  const runtime = createRuntime(deployment, {context, effects: effectHandlers})
  await runtime.drain(event)
})
```

The heartbeat in `functions/wf-dev-heartbeat/index.ts` has the same shape. It calls `runtime.heartbeat()` and passes no handlers.

For a function of your own, use `runtime.engine`: an engine that runs the handlers passed in `effects`.

## Connect the effect handler

`effect-handlers/all.ts` is the handler registry the drain imports. The generator rewrites it on every run. `effect-handlers/notify-reviewer.ts` is a stub that throws until you implement it. Point the stub at the handler in `effect-handlers.ts`, so the drain script from the effects guide and the generated drain run the same code. Replace the contents of `effect-handlers/notify-reviewer.ts`:

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

```typescript
import type {EffectHandler} from '@sanity/workflow-engine'

import {effectHandlers} from '../effect-handlers'

// Runs the handler from effect-handlers.ts, so the drain script and the Function share it.
export const notifyReviewer: EffectHandler = (params, ctx) => {
  const handler = effectHandlers['notify-reviewer']
  if (!handler) throw new Error('effect-handlers.ts has no notify-reviewer handler')
  return handler(params, ctx)
}
```

From inside the `effect-handlers/` directory, `../effect-handlers` resolves to the `effect-handlers.ts` file at the project root. Rerun `npx sanity-workflows blueprints generate`: it reports the stub as kept, and `--check` reports that the generated tree matches the definitions.

Regeneration preserves your handler files and rewrites the generated modules. It deletes nothing. A directory under `functions/` that carries the generator's header but that the current definitions no longer produce is stale: the generated Blueprint does not deploy it, `blueprints generate` lists it for you to remove, and `blueprints generate --check` fails until you do. Before deployment, test repeated delivery using `ctx.effectKey`, the effect's idempotency key, and confirm that your handler does not repeat external writes.

## Deploy definitions and Functions

Check for drift, deploy definitions, then deploy the functions:

**npm**

```shell
npx sanity-workflows blueprints generate --check
npx sanity-workflows deploy --all-partitions
npx sanity@latest blueprints deploy
```

**pnpm**

```shell
pnpm dlx sanity-workflows blueprints generate --check
pnpm dlx sanity-workflows deploy --all-partitions
pnpm dlx sanity@latest blueprints deploy
```

**yarn**

```shell
yarn dlx sanity-workflows blueprints generate --check
yarn dlx sanity-workflows deploy --all-partitions
yarn dlx sanity@latest blueprints deploy
```

**bun**

```shell
bunx sanity-workflows blueprints generate --check
bunx sanity-workflows deploy --all-partitions
bunx sanity@latest blueprints deploy
```

The generated Blueprint leaves its definition resources commented out because the Blueprints service does not accept `sanity.workflow` resources yet. Functions resolve definitions from the Content Lake, so the definition deploy must finish first.

The generated heartbeat requires an [organization-scoped stack](https://www.sanity.io/docs/blueprints/promote-stack-to-organization-scope). It requests a once-per-minute schedule. Check the [Functions frequency limits](https://www.sanity.io/docs/functions/functions-introduction) before you rely on it: a schedule more frequent than your plan permits may deploy without being invoked.

## Check the result

The quick start instance is already finished, so start a new one and submit it. Entering `review` fires the `notify` trigger, which queues `notify-reviewer`:

**npm**

```shell
npx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
npx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
```

**pnpm**

```shell
pnpm dlx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
pnpm dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
```

**yarn**

```shell
yarn dlx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
yarn dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
```

**bun**

```shell
bunx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
bunx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
```

Replace `INSTANCE_ID` with the ID the start command printed. The queued effect invokes the generated drain. Give it a moment, then show the instance with its history:

**npm**

```shell
npx sanity-workflows show INSTANCE_ID --include history
```

**pnpm**

```shell
pnpm dlx sanity-workflows show INSTANCE_ID --include history
```

**yarn**

```shell
yarn dlx sanity-workflows show INSTANCE_ID --include history
```

**bun**

```shell
bunx sanity-workflows show INSTANCE_ID --include history
```

The Pending effects section is gone, and the History section includes `Effect settled: notify-reviewer (done)`. The drain Function's logs contain the handler's line, `Notify the reviewer: ... is ready for review`. If the effect is still listed under Pending effects, check the drain's logs. If the drain never picks it up, the heartbeat re-queues it after five minutes.

Finish the instance with `npx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approve`.

## Check changes before deployment

When `workflows.blueprint.ts` exists beside the workflow config, `deploy` checks the generated runtime against the whole config before resolving a token or writing definitions. `--check`, `--dry-run`, `--only`, and deployment selectors do not bypass or narrow this check. Regenerate if generated files drift, restore missing handlers, and resolve reported wiring problems before deploying. Without a generated tree, deploy reports runtime needs and continues.

After a package upgrade or a change to runtime settings, regenerate, preserve and review your handlers, deploy definitions, then redeploy Functions. Use `blueprints generate --check` in CI for a standalone drift check. Editing generated modules creates drift and regeneration overwrites those edits.

## Predict when generated triggers run

A start watcher observes the content documents a workflow can start on. Its trigger follows the document reads in `start.filter`:

| What the start filter reads | When the watcher runs |
| No start filter, or a filter with no document or clock dependencies beyond `_type` | Document creates only. Definitions can share one creates-only watcher. |
| Known document paths | Creates and updates that change the paths the filter reads, for that definition. |
| The whole document, other documents, dynamic paths, or `$now` | Creates and every update. Time passing alone does not trigger a document watcher. |

Creates-only watchers do not revisit existing documents after you deploy them. Start those workflow instances explicitly when needed. A watcher without explicit subject types excludes engine-owned documents in its trigger, and the engine applies the remaining [subject type rules](https://www.sanity.io/docs/workflows/workflow-authoring-reference). Generated effect drains wake when an instance is created or its `pendingEffects` changes, and only when an unclaimed effect belonging to that drain is present.

A start watcher passes the whole event document to `runtime.start`, which reads its `_rev` and throws a `ContractViolationError` when it is missing. Give hand-built documents for `sanity functions test` a `_rev`, and keep `_rev` in any function projection.

## Plan for heartbeat recovery

The generator emits a heartbeat when a function-hosted workflow reads `$now` or [spawns subworkflows](https://www.sanity.io/docs/workflows/subworkflows), or when any workflow declares a function-hosted effect. Each heartbeat ticks in-flight instances of all function-hosted workflows in the deployment. It does not tick workflows hosted as `durableFunction` or `selfHosted`.

Effect recovery is separate from workflow ticking. The heartbeat re-queues every pending entry of a function-hosted effect that no drain picked up within five minutes of being queued, on any instance in the partition, and releases expired claims on those instances. The re-queue wakes the matching drain, which runs the handler. The heartbeat itself runs no effect handler.

Each heartbeat run works on at most 30 instances for recovery and at most 30 for ticking. Later runs reach the rest, so a large backlog takes several runs.

Recovery has no guaranteed completion cadence, so do not rely on the heartbeat to bound how long a stuck effect waits. Generation also does not check that an effect's retry policy fits its Function timeout: retries run inside one drain invocation, so size `timeout` for the [whole retry run](https://www.sanity.io/docs/workflows/engine).

## Next steps

- Choose hosting and effect budgets with the [runtime settings](https://www.sanity.io/docs/workflows/deployments-and-resources).
- Look up generation output and drift failures in the [CLI reference](https://www.sanity.io/docs/workflows/cli-reference).
- Write the drain and a scheduled tick yourself with [Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions).

