Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-blueprint - v0.37.0

    @sanity/workflow-blueprint

    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.

    Note

    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:

    1. Authors import defineWorkflows from this package.
    2. sanity blueprints deploy evaluates sanity.blueprint.ts and sends the resulting sanity.workflow resource to the existing Blueprints API.
    3. The Blueprints coordinator resolves resource references and ownership actions before its restricted backend provider deploys definitions.
    4. Generated functions import /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.

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

    Modules

    index
    runtime