Workflows

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

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

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

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

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:

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 the dev deployment’s workflowResource.
  • sanity.blueprint.ts: the generator adds the resources from workflows.blueprint.ts here, 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-engine from your package.json. Each generated Function’s package.json gets 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):

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:

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:

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:

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:

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 _type

Document creates only. Definitions can share one creates-only watcher.

Fields of the written document, or the whole document as references() does, in a boolean expression

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.

$now; another document, through *[...] or ->; $tag or $definition together with the document beyond _type; or an expression not recognized as boolean, such as slug.current, count(tags), or select(...)

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/$definition condition into a stage.
  • Rewrite an unrecognized filter as a boolean expression (comparisons, &&, ||, !, defined(), references()), such as count(tags) > 0 or defined(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 $now deadline
  • 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

Was this page helpful?