Sanity Blueprints
authoring and generated runtime support for Workflows. This public package
declares the sanity.workflow manifest contract, and provides the runtime the
generated Sanity Functions run on:
| Entry point | What it holds |
|---|---|
@sanity/workflow-blueprint |
defineWorkflows, display descriptors, manifest types, validation, and config file names |
@sanity/workflow-blueprint/runtime |
createRuntime, which every generated function calls |
@sanity/workflow-blueprint/generate is the generator behind
sanity-workflows blueprints generate. It is internal to the Workflows CLI and
not a supported API.
Publishing this package does not register a new resource type.
sanity.workflow becomes deployable after the Blueprints service enables its
backend provider. Until that service change ships, deploy definitions with
npx @sanity/workflow-cli deploy instead of adding this resource to a live
stack.
The public package owns the authoring contract:
defineWorkflows from this package.sanity blueprints deploy evaluates sanity.blueprint.ts and sends the
resulting sanity.workflow resource to the existing Blueprints API./runtime.Evaluating the definer performs no network or Content Lake operations. The public package does not include the provider SDK or its lifecycle adapter.
describeWorkflowsResource returns ordered, labeled data for a Workflows
resource. A renderer can display these scalar, list, and nested rows without
knowing which Workflows fields to select. It does not read deployment state.
Use the prod deployment from Declaring the resource
to describe the planned resource:
import {defineWorkflows, describeWorkflowsResource} from '@sanity/workflow-blueprint'
import {prod} from './shared'
const resource = defineWorkflows(prod)
const plannedDetails = describeWorkflowsResource(resource)
Pass deployedDefinitions only when an operation or read supplies actual
definition names and versions. Without it, the descriptor omits the deployed
row. An empty array produces a deployed row with an empty list. The values
retain any unresolved resource reference in authored input. The helper does not
change the serialized defineWorkflows resource.
The restricted Workflows provider stores a workflowDisplay descriptor in
providerMetadata after a successful create or update. That value describes
the last successful operation, not the current state of the Content Lake.
Older or attached stack resources can lack this metadata. The existing
Blueprints CLI does not consume this descriptor, so this package alone does not
change local plan or remote info output.
defineWorkflows takes the same authoring shape as one deployments[]
entry of a sanity.workflow.ts config (WorkflowDeploymentInput — the
reader-floor acknowledgement is required). Share that authored object with
defineWorkflowConfig; do not pass a parsed config.deployments[n] (that
type leaves the floor unverified so commands that do not submit definitions
can load a missing entry):
Every deployment must also be exported by name from sanity.workflow.ts. The
generated modules import it from there, including the resources module a
deployment that needs no functions still gets, so the export is required
whatever a deployment emits. The generated runtime
shows the generated functions that read it.
// shared.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const articleReview = defineWorkflow({
name: 'article-review',
title: 'Article review',
initialStage: 'drafting',
stages: [
{name: 'drafting', transitions: [{name: 'finish', to: 'published'}]},
{name: 'published'},
],
})
export const prod = {
expectedMinReaderModel: 4,
name: 'prod',
partition: 'prod',
workflowResource: {type: 'dataset', id: 'acme.workflows'},
definitions: [articleReview],
} satisfies WorkflowDeploymentInput
// sanity.workflow.ts
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {prod} from './shared'
export {prod}
export default defineWorkflowConfig({deployments: [prod]})
// sanity.blueprint.ts
import {defineBlueprint} from '@sanity/blueprints'
import {defineWorkflows} from '@sanity/workflow-blueprint'
import {prod} from './shared'
export default defineBlueprint({
resources: [defineWorkflows(prod)],
})
The definer validates eagerly. Definition structure, GROQ, and
@<handle>: alias bindings all fail at manifest evaluation, in the author's
terminal, instead of as a failed stack operation. The resulting resource is
pure data. The deployment rides the manifest verbatim with definitions in
authored form. Alias expansion happens during deployment.
Blueprints matches resources by name (default:
workflows-<deployment name>). Keep it stable after deployment: a retained
resource cannot be renamed because removing the old name is rejected.
Definitions are retain-only through Blueprints: the default lifecycle is
{deletionPolicy: 'retain'}. Destroying the stack detaches the resource and
leaves every definition version in place; removing the resource from a deployed
blueprint fails instead of deleting it. Removing one definition from the
resource's deployment.definitions list stops managing that name but leaves its
deployed versions in place. Policies that promise deletion (allow, replace)
are rejected. The lifecycle also supports coordinator-owned ownership actions:
{ownershipAction: {type: 'attach', id: RESOURCE_ID}} adopts an existing
resource by its provider ID.{ownershipAction: {type: 'detach'}} removes Stack ownership without deleting
definitions.Use dependsOn: '$.resources.RESOURCE_NAME' when this resource depends on
another resource in the same Stack. The coordinator resolves the reference
before it invokes the backend provider.
The engine has no runtime of its own: an instance advances only when something
calls it. sanity-workflows blueprints generate from
@sanity/workflow-cli
reads sanity.workflow.ts, prints what the definitions need, and writes the
Sanity Functions that call the engine. --check is the CI drift gate.
| Declared in the definition | What is generated |
|---|---|
| Any effect | A drain, which runs the effect's handler when an instance queues it |
Any condition reading $now |
The heartbeat, which ticks the deadline |
| An action that spawns children | The heartbeat, which re-evaluates the parent when a child settles |
start.kind: 'autonomous' with a subject |
A start watcher, which starts instances when a matching document is written |
| None of the above | No functions at all |
The last row is a feature. A purely interactive review workflow needs no functions, because the reactive Studio session is already its runtime.
sanity.workflow.ts yours: the definitions and the runtime block
sanity.blueprint.ts yours: spreads the generated resources
workflows.blueprint.ts generated (definitions entry commented out)
functions/
wf-prod-drain-effects/ generated
wf-prod-heartbeat/ generated
wf-prod-start-articles-creates/
generated
effect-handlers/
all.ts generated registry
notify-reviewer.ts yours, scaffolded once
Every generated function builds the runtime and calls one verb:
// functions/wf-prod-drain-effects/index.ts
export const handler = documentEventHandler(async ({context, event}) => {
const runtime = createRuntime(deployment, {context, effects: effectHandlers})
await runtime.drain(event)
})
// functions/wf-prod-heartbeat/index.ts
export const handler = scheduledEventHandler(async ({context}) => {
const runtime = createRuntime(deployment, {context})
await runtime.heartbeat()
})
// functions/wf-prod-start-articles-creates/index.ts
export const handler = documentEventHandler(async ({context, event}) => {
const runtime = createRuntime(deployment, {context})
await runtime.start(event)
})
You write the effect handlers, in effect-handlers/, and nothing else. A
handler file is scaffolded once, as a stub that throws, and never overwritten;
the registry in all.ts is regenerated from the declared effect names, so an
effect with no handler is a compile error. When an effect is removed, its
handler file stays and the generator says so. The generator adds the
workflowResources import and spread to your sanity.blueprint.ts once, and
never touches it again. A function directory the definitions no longer produce
is a stale directory: the generated blueprint no longer deploys it, and the
generator lists it for you to remove.
The defineWorkflows entry in workflows.blueprint.ts is generated commented
out. The Blueprints service does not register the sanity.workflow resource
type yet, and one unknown resource fails the whole deploy. Deploy definitions
with npx @sanity/workflow-cli deploy until then.
A deployment's name names its generated functions, so renaming a deployment
replaces them. Run npx sanity@latest blueprints plan and review every
replacement before deploying.
Start watchers run in every watched dataset: the workflow dataset and each
dataset alias. An autonomous definition shares the watcher that fires only when
a document is created when its start.filter reads no field of the written
document other than _type, reads no other document, and does not read $now.
Every other autonomous definition gets a start watcher of its own. A document that
existed before that watcher was deployed starts only when you start it
explicitly, with sanity-workflows start or your own script.
runtime blockWhere the generated runtime hosts a workflow or an effect is one runtime block
on three nodes: the deployment, the workflow in defineWorkflow, and the effect
node. Each level inherits from the one above, and omitting it everywhere hosts
everything on 'function'. kind is the whole block at the deployment and
workflow levels; an effect's 'function' block may also carry timeout in
seconds and memory in gigabytes. An effect declaring either gets a drain of
its own, sized to them. Each is a whole number
inside the range Sanity Functions accepts: timeout from 1 to 900 seconds and
memory from 1 to 10 gigabytes. A value outside its range fails at
defineWorkflow rather than at blueprint deploy.
| Kind | What the generator emits |
|---|---|
function |
A drain on instance writes, and the heartbeat when a deadline, a spawn, or an effect needs one |
durableFunction |
Nothing yet. The plan reports the workflow or effect, and the rest of the deployment emits |
selfHosted |
Nothing. The plan reports what the developer's own process must cover |
A start watcher is not in that table, because starting an instance does not
depend on where the workflow runs. Only 'selfHosted' takes a definition off
the start watchers.
The heartbeat is a scheduled function that runs every minute, subject to your
organization's plan: every minute on Enterprise, hourly on Growth, and daily on
Free (Functions).
It ticks the in-flight instances of the deployment's 'function'-hosted
workflows, which checks their deadlines and re-evaluates parents whose children
settled, and ticks no instance of a workflow hosted elsewhere or deployed into
the partition by another config. It also re-queues every pending entry of one
of the deployment's 'function'-hosted effects that no drain picked up within
five minutes of being queued, on any instance in the partition, whichever
workflow declares the effect, so the drain runs an effect queued before the
drain was deployed. On those instances it releases every claim whose lease
expired, so a drain can run the entry again. It runs no effect handler itself.
Each run re-queues on at most 30 instances and ticks at most 30, and later runs
reach the rest.
A scheduled function is organization-scoped, so the heartbeat needs an
organization-scoped stack.
The block is authoring-only. The deploy strips it, so it never reaches the
Content Lake and changing a kind never mints a new definition version. It does
change the emitted tree, so regenerate after changing one. A function directory
the new tree no longer contains is inert: the blueprint lists each function by
its src, so nothing deploys it.
The block is a hint to the generator and to callers, never a rule the engine
enforces. The engine never sees it: any engine built anywhere with a handler for
an effect drains that effect when it calls drainEffects, whatever kind the node
declares, so a selfHosted effect is drained by whoever registers its handler,
the Studio included.
Nothing declared. articleReview and expenseApproval are definitions from
defineWorkflow, imported from wherever you keep them:
// sanity.workflow.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
export const prod = {
name: 'prod',
partition: 'prod',
expectedMinReaderModel: 4,
workflowResource: {type: 'dataset', id: 'acme.workflows'},
definitions: [articleReview, expenseApproval],
} satisfies WorkflowDeploymentInput
export default defineWorkflowConfig({deployments: [prod]})
Export the deployment under its own name and hand the same value to
defineWorkflowConfig; the generated modules import it from that named export.
Emitted: one shared drain, the heartbeat, and the start watchers. A deployment
with no effect, no $now deadline and no spawn emits no drain and no
heartbeat.
One workflow opts in:
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const expenseApproval = defineWorkflow({
name: 'expense-approval',
title: 'Expense approval',
runtime: {kind: 'durableFunction'},
initialStage: 'approval',
stages: [{name: 'approval', transitions: [{name: 'settle', to: 'paid'}]}, {name: 'paid'}],
})
article-review keeps its drain and heartbeat. Nothing is emitted for
expense-approval yet, and the heartbeat does not tick its instances. The
generator reports it as durable hosted.
The deployment default flips, and one workflow stays on functions. Both definitions elide their stages:
// definitions.ts
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const erpSync = defineWorkflow({
/* … */
})
export const articleReview = defineWorkflow({
name: 'article-review',
title: 'Article review',
runtime: {kind: 'function'},
initialStage: 'drafting',
stages: [
/* … */
],
})
// sanity.workflow.ts
import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {articleReview, erpSync} from './definitions'
export const prod = {
name: 'prod',
partition: 'prod',
expectedMinReaderModel: 4,
workflowResource: {type: 'dataset', id: 'acme.workflows'},
runtime: {kind: 'selfHosted'},
definitions: [erpSync, articleReview],
} satisfies WorkflowDeploymentInput
export default defineWorkflowConfig({deployments: [prod]})
Emitted: article-review's drain and heartbeat only. For erp-sync the
generator reports what your own process must do: the deadlines it must tick,
the effects it must drain, the parents it must re-evaluate when a child settles,
and the autonomous definitions it must start.
A durable workflow with one cheap effect on a plain function and one effect drained by an existing server:
import {defineWorkflow} from '@sanity/workflow-engine/define'
export const expenseApproval = defineWorkflow({
name: 'expense-approval',
title: 'Expense approval',
runtime: {kind: 'durableFunction'},
initialStage: 'approval',
fields: [{type: 'boolean', name: 'approved', title: 'Approved'}],
stages: [
{
name: 'approval',
activities: [
{
name: 'pay',
actions: [
{
name: 'issue',
when: '$fields.approved',
status: 'done',
effects: [
{name: 'issue-payout'},
{name: 'stamp-approved', runtime: {kind: 'function', timeout: 30}},
{name: 'sync-erp', runtime: {kind: 'selfHosted'}},
],
},
],
},
],
transitions: [{name: 'settle', to: 'paid'}],
},
{name: 'paid'},
],
})
Emitted: a drain for stamp-approved alone, with a thirty-second timeout.
issue-payout stays on the durable and sync-erp on your own process, so no
emitted drain runs either. A heartbeat is emitted too: it does not tick
expense-approval's instances, and it re-queues stamp-approved when no drain
picked it up. The handler registry binds every effect except the self-hosted
ones, whose handler lives in that process.
createRuntime(deployment, {context, effects}) builds the runtime a generated
function calls. deployment is the deployment exported from
sanity.workflow.ts, context is the context Sanity Functions passes the
handler, and effects is the handler registry from effect-handlers/all.ts,
which the drains and runtime.engine run. It throws when the context carries
no token: bind the runtime robot token to the function in the blueprint, as the
generated blueprint does.
| Verb | Called by | What it does |
|---|---|---|
runtime.drain(event) |
The shared drain | Runs the instance's pending effects that declare no timeout or memory |
runtime.drain(event, {effect}) |
The drain of an effect with its own timeout or memory |
Runs that effect |
runtime.heartbeat() |
The heartbeat | Re-queues the 'function'-hosted effects no drain picked up, then ticks the in-flight instances of the 'function'-hosted workflows |
runtime.start(event) |
The start watcher for created documents | Starts the applicable definitions whose start filter reads nothing but _type |
runtime.start(event, {definition}) |
A definition's own start watcher | Starts that definition when it is applicable to the written document |
A start keys each instance on the definition, the dataset the event came from,
and the written document's _id and _rev, so a redelivery of the same write
starts nothing new, even when it overlaps the first delivery. A start throws a
ContractViolationError when the event document has no _rev, for example a
hand-built document passed to sanity functions test; give such a document a
_rev.
A drain throws when an effect fails, so the platform records a failed
invocation; the failure is also on the instance's history. runtime.engine is
an engine for a function of your own that works on one instance. It runs the
effects registry's handlers, so its drainEffects fails on a pending effect
the registry lacks unless names leaves that effect out. runtime.start throws
when the context lacks clientOptions.projectId or clientOptions.dataset,
which name the dataset the event came from.