Generate Functions from your workflow definitions
Generate the Sanity Functions your workflow definitions need, connect an effect handler, and deploy them with Blueprints.
Early access
Workflows is in early access, built in public. Read How early access works before you rely on it.
The Workflows generator reads your workflow definitions and creates the Sanity Functions they need. It can create three kinds of Function:
- A drain runs your effect handlers when an instance queues an effect.
- A start watcher starts an autonomous workflow when a document matches its start conditions.
- A scheduled heartbeat re-checks instances on a timer and recovers effects that no drain ran.
To write the Functions yourself, Run Workflows with Sanity Functions lists what they must do.
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:
article-reviewdeployed, and asanity.workflow.tsthat exports thedevdeployment. - Handle asynchronous work with effects: the
notify-reviewereffect onarticle-review, deployed, and theeffect-handlers.tsmodule at the project root that exportseffectHandlers. - Familiarity with Sanity Functions and Blueprints, a token that can deploy to your project, and an organization deploy token for the heartbeat’s organization-scoped stack.
The generated Functions import each deployment from sanity.workflow.ts by its name. So the workflow deployment config must export each deployment under that name and include it in the default config. The quick start does this with export const dev.
Each deployment name must be a valid JavaScript identifier: production works, prod-eu doesn’t.
A workflow needs generated Functions only if it has effects, $now conditions, subworkflows, or an autonomous start. In this guide, the effect 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 install --save-dev --save-exact @sanity/blueprints @sanity/functions \ @sanity/workflow-blueprint
pnpm add --save-dev --save-exact @sanity/blueprints @sanity/functions \ @sanity/workflow-blueprint
yarn add --dev --exact @sanity/blueprints @sanity/functions \ @sanity/workflow-blueprint
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 copies the versions of @sanity/functions, @sanity/workflow-blueprint, and @sanity/workflow-engine from your package.json. If one of them is missing, the generator stops.
Generate the Functions
Remove hand-written Functions first
If you deployed the Functions from Run Workflows with 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:
npx sanity-workflows blueprints generate
pnpm dlx sanity-workflows blueprints generate
yarn dlx sanity-workflows blueprints generate
bunx sanity-workflows blueprints generate
The command prints a short summary: the Sanity Functions each deployment needs, the files that need you, a count of the files it wrote, and a Deploy: section:
Runtime needs:
dev (dev) needs 2 Sanity Functions (1 effect drain, 1 heartbeat)
Generated tree:
created effect-handlers/notify-reviewer.ts yours now — implement the effect
created sanity.blueprint.ts the workflow resources spread
✔ 10 file(s) written (10 created), 0 left untouched
Deploy:
project stack: `npx sanity@latest blueprints deploy` here, with a token that can deploy to the project. It holds every document function; redeploy it after any definition change.
heartbeat stack: `npx sanity@latest blueprints deploy` in workflows-heartbeat/, with an organization deploy token. It holds only the scheduled heartbeat.
⚠ Without the heartbeat stack nothing checks these on a schedule, and nothing reports it:
an effect queued before its drain existed, or whose drain timed out, waits until a later drain picks it up: notify-reviewer
Pass --verbose for the full report.Pass --verbose to see the full runtime-needs report and every generated file with its status.
The command covers every deployment in the config. It writes these files:
workflows.blueprint.ts: a robot token and the drain. They use the project and dataset from thedevdeployment’sworkflowResource.sanity.blueprint.ts: the generator adds the resources fromworkflows.blueprint.tshere, and creates the file if it doesn’t exist.workflows-heartbeat/sanity.blueprint.ts: the whole blueprint of a separate stack that holds only the heartbeat and its own robot token.workflows-heartbeat/package.json: copies the versions of@sanity/functions,@sanity/workflow-blueprint, and@sanity/workflow-enginefrom yourpackage.json. Each generated Function’spackage.jsongets the same versions.
Generated Function and robot names come from the deployment’s name, so renaming a deployment replaces its Functions. Before you deploy, fix any naming collision or manual wiring step the command reports.
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):
// 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.
createRuntime builds its clients as CDN-less @sanity/client instances on the raw perspective. To build them yourself, pass a clientFactory option. Each client it returns must use the raw perspective. A deployment can bind a resource alias that isn’t a dataset, such as a Media Library, Canvas, or Dashboard. In that case, the workflow dataset client must also have withConfig, because the runtime derives the alias’s client from it. Without withConfig, createRuntime throws. @sanity/client has withConfig.
Connect the effect handler
The generator creates two files in effect-handlers/:
effect-handlers/all.ts: the handler registry the drain imports. The generator rewrites it on every run.effect-handlers/notify-reviewer.ts: a stub that throws until you implement it. The generator never overwrites 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:
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 leaves the stub untouched, and --check reports that the generated tree matches the definitions.
Regeneration keeps your handler files, rewrites the generated modules, and deletes nothing. A generated directory under functions/ that the current definitions no longer produce is stale. The generated blueprint doesn’t deploy it, blueprints generate lists it for you to remove, and blueprints generate --check fails until you remove it. Before you deploy, check that your handler is safe to run twice: a repeated delivery has the same ctx.effectKey, so use it to avoid repeating external writes.
Deploy definitions and Functions
The generated Functions deploy to two stacks:
- Project stack: your
sanity.blueprint.ts. It holds every drain and start watcher. Deploy it with a token that can deploy to the project. - Heartbeat stack:
workflows-heartbeat/sanity.blueprint.ts. Deploy it with an organization deploy token, because scheduled Functions only run on an organization-scoped stack.
Check for drift, deploy the definitions, then deploy each stack. Run blueprints init once before each stack’s first deploy. Replace PROJECT_ID and ORGANIZATION_ID with your IDs:
npx sanity-workflows blueprints generate --check npx sanity-workflows deploy --all-partitions # Project stack, from the directory that contains sanity.workflow.ts npx sanity@latest blueprints init . --project-id PROJECT_ID # first deploy only npx sanity@latest blueprints deploy # Heartbeat stack cd workflows-heartbeat npx sanity@latest blueprints init . --organization-id ORGANIZATION_ID # first deploy only npx sanity@latest blueprints deploy
pnpm dlx sanity-workflows blueprints generate --check pnpm dlx sanity-workflows deploy --all-partitions # Project stack, from the directory that contains sanity.workflow.ts pnpm dlx sanity@latest blueprints init . --project-id PROJECT_ID # first deploy only pnpm dlx sanity@latest blueprints deploy # Heartbeat stack cd workflows-heartbeat pnpm dlx sanity@latest blueprints init . --organization-id ORGANIZATION_ID # first deploy only pnpm dlx sanity@latest blueprints deploy
yarn dlx sanity-workflows blueprints generate --check yarn dlx sanity-workflows deploy --all-partitions # Project stack, from the directory that contains sanity.workflow.ts yarn dlx sanity@latest blueprints init . --project-id PROJECT_ID # first deploy only yarn dlx sanity@latest blueprints deploy # Heartbeat stack cd workflows-heartbeat yarn dlx sanity@latest blueprints init . --organization-id ORGANIZATION_ID # first deploy only yarn dlx sanity@latest blueprints deploy
bunx sanity-workflows blueprints generate --check bunx sanity-workflows deploy --all-partitions # Project stack, from the directory that contains sanity.workflow.ts bunx sanity@latest blueprints init . --project-id PROJECT_ID # first deploy only bunx sanity@latest blueprints deploy # Heartbeat stack cd workflows-heartbeat bunx sanity@latest blueprints init . --organization-id ORGANIZATION_ID # first deploy only bunx sanity@latest blueprints deploy
Keep workflows-heartbeat/package.json next to the heartbeat blueprint. The Functions build installs the heartbeat’s dependencies from it, so the deployed heartbeat runs the versions your project pins. Without it, a project installed with npm builds the heartbeat against the latest published Workflows packages, which can fail on every run while the deploy reports success.
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.
Each heartbeat stack counts toward your organization’s limit of 10 organization-scoped stacks. The heartbeat asks to run once a minute. Your plan may allow fewer runs, so check the Functions frequency limits. A schedule more frequent than your plan allows can deploy but never run.
The Deploy: section of the blueprints generate output names the token for each stack and lists any heartbeat-stack files the run changed. Use it to decide what to redeploy:
- After any definition change, redeploy the project stack.
- When the
Deploy:section lists a heartbeat-stack file as changed, redeploy the heartbeat stack with the organization deploy token. This happens when you add or remove a Function-hosted workflow or effect, or change the version of@sanity/functions,@sanity/workflow-blueprint, or@sanity/workflow-engine. Until you redeploy, the heartbeat doesn’t tick the new workflow’s instances or re-queue the new effect. - After upgrading the Workflows packages, redeploy both stacks, even if no generated file changed. Each Function bundles the installed Workflows runtime when it deploys.
The header of workflows-heartbeat/sanity.blueprint.ts lists what the heartbeat depends on: the partition, the datasets, the Function-hosted workflows it ticks, and the Function-hosted effects it re-queues. Editing an existing workflow doesn’t change that file.
When no deployment needs a heartbeat, blueprints generate reports a leftover workflows-heartbeat/sanity.blueprint.ts, and blueprints generate --check and deploy fail until you remove it. Run npx sanity@latest blueprints destroy from that directory with the organization deploy token, then delete the directory.
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:
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 submitpnpm 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 submityarn 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 submitbunx 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 submitReplace 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:
npx sanity-workflows show INSTANCE_ID --include history
pnpm dlx sanity-workflows show INSTANCE_ID --include history
yarn dlx sanity-workflows show INSTANCE_ID --include history
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.
In the function logs, the generated Functions write routine lines, including an effect handler’s ctx.log output, at the info level. Warnings and errors appear as ERROR. When a heartbeat run fails, its error includes each failed step’s message and, for each instance that failed, the instance ID followed by its error.
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 next to the workflow config, deploy checks the generated files against the whole config. It does this before it resolves a token or writes any definition. --check, --dry-run, --only, and deployment selectors don’t skip or narrow the check. If the check fails, regenerate the files, restore missing handlers, and fix any reported wiring problem. Without generated files, deploy reports which Functions the definitions need and continues.
After a package upgrade or a change to runtime settings, regenerate, preserve and review your handlers, deploy definitions, then redeploy both stacks. 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, and starts a definition on the write that makes its start.filter true. What the filter reads decides the trigger:
What the start filter reads | When the watcher runs |
No start filter, or a filter with no document or clock dependencies beyond | Document creates only. Definitions can share one creates-only watcher. |
Fields of the written document, or the whole document as | The write that makes the filter true: a create where it holds, or an update that turns it from false to true. A write that keeps it true starts nothing, and null counts as false. A document whose filter turns false and then true again gets another instance. The definition has a watcher of its own. |
| Every create and update to a matching document, so a later write can start another instance for the same document. Time passing alone does not trigger a document watcher. |
The watcher can only read the written document. It has no $now, $tag, or $definition, and it can’t read other documents. It starts an instance only when the filter returns exactly true. So it can’t evaluate filters in the last row of the table the way the engine does, and blueprints generate warns about each such definition. To avoid repeated starts, do one of these:
- Move the time, cross-document, or
$tag/$definitioncondition into a stage. - Rewrite an unrecognized filter as a boolean expression (comparisons,
&&,||,!,defined(),references()), such ascount(tags) > 0ordefined(slug.current) && slug.current != "". - Self-host the start with
runtime: {kind: 'selfHosted'}in the runtime settings.
A creates-only watcher doesn’t revisit documents that existed before you deployed it. Start instances for those documents yourself. A watcher without explicit subject types skips engine-owned documents, and the engine applies the other subject type rules.
A generated drain wakes when an instance is created or its pendingEffects changes. It runs only when an unclaimed effect for 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, 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 ticking. The heartbeat finds pending effects that no drain picked up within five minutes of being queued, on any instance in the partition. It re-queues them, and it releases expired claims on those instances. Re-queuing wakes the matching drain, which runs the handler. The heartbeat itself never runs a 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.
Nothing reports a missing heartbeat stack, and no error is recorded. A child that finishes still re-evaluates its parent directly. Without the heartbeat, these wait until another engine call reaches them:
- An instance waiting on a
$nowdeadline - A parent that missed a child finishing
- An effect queued before its drain was deployed, or whose drain timed out while holding its claim
The Deploy: section of blueprints generate lists which of these your definitions can produce.
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.
Next steps
- Choose hosting and effect budgets with the runtime settings.
- Look up generation output and drift failures in the CLI reference.
- Settle workflows whose documents are deleted with a Function of your own: Handle workflows when referenced content is deleted.