Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-blueprint - v0.36.0

    @sanity/workflow-blueprint

    Sanity Blueprints authoring and generated runtime support for Workflows. This public package declares the sanity.workflow manifest contract. It also provides two entry points for the runtime that a deployment's definitions require:

    Entry point What it holds
    @sanity/workflow-blueprint defineWorkflows, display descriptors, manifest types, validation, and config file names
    @sanity/workflow-blueprint/generate The needs analysis, the emission plan, names, trigger filters, and the file bodies
    @sanity/workflow-blueprint/runtime The wiring a generated function shell imports
    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 function shells import /runtime; generators and other tooling import /generate.

    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 tree names which modules 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',
    tag: '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 is a library with no runtime of its own: an instance advances only when something calls a verb. @sanity/workflow-blueprint/generate answers what must call it for a given deployment, and plans the functions that will.

    runtimeNeeds(deployment) performs no I/O, so it gives the same answer at authoring, generation and deploy time.

    Declared in the definition Inferred runtime need
    Any effect A drain must run its handler
    Any condition reading $now A clock must tick past the deadline
    An action that spawns children A tick must re-evaluate the parent once one settles
    start.kind: 'autonomous' on a startable A watcher must start instances from writes
    Subject entries Which document types are workflow-relevant
    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.

    A child settling writes the child, so nothing re-evaluates a parent gate reading $subworkflows on its own, which is why spawning is a need of its own rather than a consequence of the child's own runtime.

    import {runtimeEmissionPlan, runtimeNeeds} from '@sanity/workflow-blueprint/generate'

    const needs = runtimeNeeds(deployment)
    needs.drain.effectNames // ['notify-reviewer']
    needs.heartbeat.timeSites // the exact $now conditions, as one of its reasons
    needs.heartbeat.spawnSites // the exact spawning actions, as another

    for (const planned of runtimeEmissionPlan(config).deployments) {
    planned.emission?.drains // one per effect with its own budget, plus one shared
    planned.emission?.heartbeat?.reasons // why the heartbeat exists
    planned.emission?.heartbeat?.definitions // whose instances it ticks
    planned.hosting // every workflow and effect by resolved runtime kind
    planned.selfHosted // what a self-hosted process must cover
    planned.anyTypeSubjects // autonomous definitions whose watcher cannot narrow by type
    planned.unnarrowedStartFilters // autonomous definitions whose watcher cannot rule out an update
    planned.subjectlessDefinitions // non-self-hosted autonomous definitions no write can start
    }

    Datasets come from the deployment rather than the definitions — the workflow dataset plus every alias-bound one — because a definition names accepted document types and never says where those documents live.

    Each definition that watches updates gets its own watcher per dataset, and the creates-only definitions share one: a fire on a path a definition does not read would start it again, and the engine dedupes no start, while a create fires once and admits nothing spurious. The plan never hands a watcher back for you to write by hand. Which definitions each watcher carries is one rule with one precedence:

    1. An autonomous definition whose resolved kind is not 'selfHosted' and which declares a subject entry gets a watcher of its own in every watched dataset, or shares the creates-only one when its filter reads nothing of the document but its _type, and nothing of the dataset or the clock.
    2. A 'selfHosted' one is listed on none, whatever else it declares, and appears in selfHosted.startInstances.
    3. A 'function' or 'durableFunction' one with no subject entry is listed on none either, and appears in subjectlessDefinitions: it contributes no trigger, and a deployment whose autonomous definitions are all subjectless plans no watcher at all.

    When a listed definition's subject entry declares no types the type leg excludes the engine's own documents and narrows nothing else, so the watcher triggers on writes to every other document type in the dataset, system documents included, and the runtime declines those. The definition is named in anyTypeSubjects for a needs report to warn about: a subject that accepts any document type is almost always a misconfigured entry rather than an intent.

    A watcher fires on a write that changed a path its definition reads even when the filter was already true before the write, so an edit to a read path can restart a definition whose verdict did not change.

    Start watchers supply the written document only to matching input-sourced subject fields. Literal and query subject initializers resolve through the engine when the instance starts.

    Each document trigger is a prefilter: it removes invocations no workflow in the deployment can need, and the function still re-checks what it was invoked for. The heartbeat is a scheduled function and has no trigger filter at all; what it works on is the runtime block's topic.

    A drain fires on an instance write in its deployment's tag that touched pendingEffects and left an entry unclaimed, and only for the effects that drain runs, so a write leaving another drain's effect, a 'durableFunction' one, or a 'selfHosted' one unclaimed does not invoke it. A comment or an assignee change on the instance touches no queue entry and invokes nothing.

    A start watcher fires on a create of a watched type, or on an update that changed a field one of the listed definitions' start.filter reads. The paths come from the filter's GROQ: watchedUpdatesOf(definition) walks it and reports the document fields it reads, so status == "ready" && !defined(publishedAt) narrows the trigger to (publishedAt, status), the paths deduped and sorted. Each definition that watches updates gets a watcher of its own, wf-<tag>-start-<dataset>-<definition>, carrying exactly the types and paths it reads, so no write another definition's paths admitted can start it. The definitions that watch creates alone share wf-<tag>-start-<dataset>-creates: a create fires once, so their types are a union there and the handler drops what is not applicable. A definition named creates that watches updates therefore collides with that shared watcher, and generation fails asking you to rename it.

    Two filter shapes yield no paths, and they resolve in opposite directions. A filter that reads nothing of the document but its _type, and nothing of the dataset or the clock, watches creates only, because no update to that document can change which definitions it is applicable for: the lake rejects a write that changes _type, so a document cannot become a watched type after it exists, and _type is never a watched path. Such a definition starts each document once, at its creation. A document that existed before its watcher deployed starts only when you start it explicitly, through sanity-workflows start with the subject or your own script, or once the definition gains a start filter reading the subject or the dataset, which makes that document's later writes start candidates again. A filter whose truth can change without a write to the subject keeps every update in and names its definition in unnarrowedStartFilters, because narrowing there would drop a write that does make the definition applicable. That covers the whole document through @, a field named by an expression, a top-level field whose name needs quoting, such as @["status.code"], a function handed the document implicitly, such as references(), any read of another document through a dataset scan or a dereference, and any read of $now. A document that becomes eligible by time alone starts on its next write, because applicability is evaluated on a write and nothing else. A quoted name deeper in a read still narrows to the nearest field path above it, so status["weird name"] watches status.

    A trigger that narrows carries an explicit create leg, because delta::changedAny compares a field against its previous value and a created document has none. A watcher that narrows nothing has no delta clause at all, so it already accepts both creates and updates.

    workflowRuntimeNames, effectDrainName and startWatcherName derive every emitted resource name from the deployment's tag, and drainEffectsFilter and startWatcherFilter compile the triggers. They live here rather than in the emitted text so one definition drives the blueprint, the handlers' execution identities, and any tooling that finds a deployment's runtime by name. The four that take a tag validate it first, and beyond that they differ, because what reaches the output differs. effectDrainName and startWatcherName slugify their effect, dataset and group name into the lowercase-dash form a resource name takes, so a hostile character cannot survive; startWatcherName also rejects a group outside EMITTED_NAME_PATTERN, because a definition name reaches the emitted text as itself elsewhere. The filter compilers embed each name verbatim in GROQ and therefore check it first: an effect name and a subject type against EMITTED_NAME_PATTERN, and a delta selector's field path against the engine's bare-attribute grammar through assertEmittablePath. A path holds only field names a selector can spell unquoted, so a quoted name never becomes a selector for some other field. The emission plan applies the name grammar to every effect name, definition name and subject type the generator will embed.

    A deployment's name is unique across a config but its tag may repeat, and every emitted resource name derives from the tag. If two deployments share a tag and both emit runtime resources, the plan rejects the colliding names and names both deployments. A deployment that emits no runtime resources may share a tag with another deployment.

    Most projects never call this part directly: @sanity/workflow-cli wraps it as sanity-workflows blueprint generate, which reads sanity.workflow.ts, prints the needs analysis, and writes the tree described below. sanity-workflows blueprint generate --check is the CI drift gate. Reach for the functions here when you are building your own generation step. When you do, make the check the CLI makes: the config module must export each deployment under the name deploymentExportIdentifier returns, because the generated resources module and every function shell import it that way. These functions cannot check it for you, and writing a plan for a config missing that export produces modules that cannot compile while emissionDivergences still reports the tree aligned.

    CONFIG_FILE_NAMES is available from the package root and /generate. Custom loaders can use it to discover supported config files in the same order as the Workflows CLI.

    emissionWritePlan turns an emission plan into the files a developer sees. It does not read or write the generation root's files: the caller passes the paths already on disk, the developer's sanity.blueprint.ts source, and the project's declared dependency versions, then gets back every file body with the ownership that decides whether writing it is allowed to replace what is there. When wiring an existing blueprint without a supplied compiler, it loads the optional installed TypeScript package.

    sanity.workflow.ts              yours: the definitions and the runtime block
    sanity.blueprint.ts yours: spreads the generated resources
    workflows.blueprint.ts generated shell (definitions entry commented out)
    functions/
    wf-prod-drain-effects/ generated shell + package.json
    wf-prod-heartbeat/ generated shell + package.json
    wf-prod-start-articles-creates/
    generated shell + package.json
    effect-handlers/
    all.ts generated registry
    notify-reviewer.ts yours, scaffolded once
    Ownership Which files What a writer may do
    generated the resources module, every function shell and manifest, the registry replace it on every run
    scaffold one handler stub per registered effect name write it only when absent
    wired the developer's sanity.blueprint.ts write the one-time insertion
    import {emissionWritePlan, runtimeEmissionPlan} from '@sanity/workflow-blueprint/generate'

    const written = emissionWritePlan({
    plan: runtimeEmissionPlan(config),
    configFile, // the config file name found in the root, such as 'sanity.workflow.ts'
    existingPaths, // every path already under the generation root
    blueprintSource, // the developer's sanity.blueprint.ts, or undefined
    declaredDependencies, // the project manifest's dependencies over devDependencies
    })

    written.writes // path, contents, and ownership
    written.orphanedStubs // handler files for effect names the definitions dropped
    written.orphanedRegistry // the registry, once no effect is registered at all
    written.manualBlueprintWiring // the two lines to add when no single resources array is found
    written.caseCollisions // scaffolds skipped because a file differs only in case

    readGenerationRoot gathers those three inputs from a directory, and applyEmissionWritePlan writes the plan back into it, reporting each file as created, updated, or unchanged. It throws before writing anything rather than replace a scaffold whose contents differ from the planned stub, so neither an incomplete existingPaths nor a plan kept across a handler edit can destroy an implemented handler. Rebuild the plan whenever the root may have changed since readGenerationRoot read it.

    import {
    applyEmissionWritePlan,
    emissionDivergences,
    emissionWritePlan,
    readGenerationRoot,
    runtimeEmissionPlan,
    } from '@sanity/workflow-blueprint/generate'

    const plan = emissionWritePlan({
    plan: runtimeEmissionPlan(config),
    configFile: 'sanity.workflow.ts',
    ...readGenerationRoot(root),
    })

    applyEmissionWritePlan({root, plan}) // path, ownership, and what the write did
    emissionDivergences({root, plan}) // planned files that are missing or differ; writes nothing

    emissionDivergences is the read-only half: empty means the files on disk are the files the plan would write. Because a scaffold is planned only when its path is absent, a scaffold divergence reports a declared effect whose handler has not been written yet.

    A stub is planned only when its path is absent, so an implemented handler survives every regeneration. The registry is regenerated from the registered names, so removing an effect (or moving it to runtime: {kind: 'selfHosted'}) drops its import; the file it imported stays on disk and is reported in orphanedStubs for the developer to delete. Leaving no registered effect at all leaves nothing to regenerate, so the registry itself is reported in orphanedRegistry rather than left behind importing files the same plan asks the developer to delete. The generator deletes nothing in either case. The registry types effectHandlers as Record<DeclaredEffectName, EffectHandler>, so a registered effect with no reachable handler is a compile error rather than a runtime surprise.

    Every identifier and path the generator derives from a declared effect name comes from effectHandlerBindings, so a stub's file name and the registry's import of it cannot disagree. It rejects the name all, whose handler file would be the registry; a name whose camel-cased identifier JavaScript will not accept as a binding, or that the registry module already binds (effectHandlers, EffectHandler, DeclaredEffectName); two names that collapse to one identifier, such as pay.out and pay-out; and two names whose handler files differ only by case, because they are one file on a case-insensitive filesystem. The registry's own module name is matched the same way, so All is rejected alongside all.

    Two kinds of generated module import the deployment, under the deployment's own name, because defineWorkflows takes the authored deployment and not a parsed config.deployments[n]: the resources module, from ./sanity.workflow beside it, and every function shell, from ../../sanity.workflow two directories up. The handler registry and the handler stubs import neither. A deployment name that cannot name a binding, because it is not an identifier or because it is a reserved word, fails the emission with a message naming it.

    A function shell imports it under the fixed local alias deployment (import {prod as deployment} from '../../sanity.workflow') and refers to it by that name throughout. A shell binds nothing else called deployment, so a deployment named after something a shell declares or imports — handler, engine, createRuntimeEngine — cannot collide with it, however the templates change. workflows.blueprint.ts names the deployment directly, and its own bindings are a fixed set, so a deployment named workflowResources or after one of the definers it imports is rejected at emission instead.

    The defineWorkflows entry itself is emitted commented out, under the reason and the command that deploys definitions meanwhile. The Blueprints service does not register the sanity.workflow resource type yet, and one unknown resource fails the whole deploy, the emitted functions included. The import stays, so uncommenting is the only edit the entry takes once the resource type is enabled. A single generator constant decides this, not deployment config, so one flip and a regeneration turns it on everywhere.

    Each function's package.json copies the dependency specifications the project already declares, unchanged, so the generated functions and the project resolve against the same ranges. A dependency the project declares nowhere fails the plan instead of being stamped with a guessed range.

    The developer's sanity.blueprint.ts is theirs. The generator inserts the workflowResources import and one spread into its resources array, once, and never touches the file again.

    Where to insert is decided by parsing the file, not by matching text. wireBlueprint reads it with the TypeScript parser and looks for exactly one resources array literal among the object arguments of its defineBlueprint(...) calls. Text that merely reads like the anchor is not a node and is never matched, so a resources: [ inside a comment, a string, a template literal or a regular expression cannot receive the spread. A resources array belonging to some other object is not a candidate either. Finding none, or more than one, is reported through manualBlueprintWiring with the two lines to add.

    Wiring requires a value import of workflowResources from ./workflows.blueprint (with an optional .ts extension) and a ...workflowResources spread in that array. The generator inserts whichever is missing. Other bindings named workflowResources, including unrelated or type-only imports, require manual wiring; resolve the conflicting binding before adding the generated import and spread.

    Wiring an existing sanity.blueprint.ts uses the TypeScript compiler API. A direct caller of @sanity/workflow-blueprint/generate can use an installed TypeScript 6 compiler, or install @typescript/typescript6 and pass its default export as compiler to emissionWritePlan or as the second argument to wireBlueprint. This is required when the project uses TypeScript 7 or has no typescript installation; otherwise wiring reports the missing API clearly. Importing /generate does not itself require TypeScript. The Workflows CLI supplies the compatibility compiler for its Blueprint commands. The optional typescript peer accepts both 6 and 7, and the /runtime entry point and generated functions do not install or import a compiler.

    A scaffold is never written over a file whose path differs from it only in case. Renaming an effect from notify to Notify plans effect-handlers/Notify.ts while the developer's implementation sits in effect-handlers/notify.ts, and on a case-insensitive filesystem that write would replace it. Planned paths are matched against what is on disk without regard to case: neither path is written, and the pair is reported in caseCollisions. Rename the file and its exported handler to match the generated registry. For notify to Notify, change both the filename to Notify.ts and the exported binding to Notify.

    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 also carries the function budget, timeout in seconds and memory in gigabytes. 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 a deadline, a spawn, or that drain needs
    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: 'function' and 'durableFunction' are alike there, and only 'selfHosted' takes a definition off the watchers. The rule and its precedence are under Deriving the runtime, with the reports that name whatever the watchers leave out.

    Budgeted effects recover one per beat: a beat registers one effect from heartbeat.catchUp.budgetedEffectNames at a time, taken in turn, and drains a single instance holding it. For each field some recoverable effect declares, the beat carries the larger of the platform default and the largest declared value, reported in heartbeat.catchUp.limits. The beat dispatches its budgeted effect first: only the candidate query and that instance's claim sweep run before the handler, and ticks and unbudgeted recovery follow it. A handler therefore never runs in a budget smaller than the one its own drain gets, whether that drain dispatched it or a beat caught it up. Effects without a declared budget are not rationed and go out together.

    Hosting decides which instances a heartbeat ticks and which it recovers. It ticks the instances of the 'function'-hosted workflows in heartbeat.definitions, and it is emitted when one of those workflows reads $now or spawns children, or when any workflow in the deployment declares a 'function'-hosted effect. heartbeat.reasons lists the reasons it claims. Recovery is listed separately in heartbeat.catchUp.recoverDefinitions, because an effect that runs on a function has a drain whatever hosts its workflow, and a drain that dies mid-dispatch leaves a claim only the sweep releases: a durable or self-hosted workflow with a 'function'-hosted effect gets a beat that recovers its queues with heartbeat.definitions empty. A deployment with no 'function'-hosted workflow and no 'function'-hosted effect emits no heartbeat: a durable workflow's instances belong to its advancer and a self-hosted one's to the developer's process, which is why selfHosted.tickDeadlines reports the self-hosted deadlines nothing is guaranteed to tick.

    The emitted heartbeat runs every minute, and a scheduled function runs at most as often as the organization's plan allows — every minute on Enterprise, hourly on Growth, daily on Free (Functions) — so a schedule below that threshold is not guaranteed to run at the rate it asks for, with no error anywhere.

    The heartbeat is emitted as a plain defineScheduledFunction with no project. A scheduled function is organization-scoped, the definer rejects a project on one, and the two document functions beside it are project-scoped and do carry theirs.

    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',
    tag: '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 that catches up and sweeps after it, and a watcher per autonomous definition that watches updates, plus one the creates-only definitions share. 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 plain drain and heartbeat. expense-approval appears under hosting.workflows.durableFunction and its effects under hosting.effects.durableFunction, and its deadlines are no reason to emit a heartbeat, nor are its instances ticked by the one article-review gets. The durable advancer that runs it ships separately, so nothing is emitted for it yet.

    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',
    tag: '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. erp-sync is reported in selfHosted, which lists the deadlines the process 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, and its trigger names that one effect. issue-payout stays on the durable and sync-erp on the developer's process, so no emitted drain claims either, and a write leaving only those unclaimed invokes nothing. A heartbeat is emitted too, and expense-approval is absent from heartbeat.definitions, so the beat never ticks its instances. It recovers stamp-approved on them, because that drain can die mid-dispatch and leave a claim nothing else releases. The handler registry binds every effect except the self-hosted ones, whose handler lives in that process.

    @sanity/workflow-blueprint/runtime carries what a generated function shell needs to configure and run the engine: the client and engine factory, the beat's handler selection, and the beat itself.

    These two examples are fragments of a function handler. They assume what the generated handlers provide: deployment is the deployment imported from sanity.workflow, effectHandlers is the registry exported from effect-handlers/all.ts, and context and event are the arguments Sanity Functions passes the handler.

    createRuntimeEngine(deployment, {context}) turns a Sanity Function context into a scoped engine plus the client it runs on. It builds a raw-perspective client pinned to the engine's API version, derives cross-resource routing from the deployment's resourceAliases, and declares a server execution identity named after the deployment's robot token. It throws when the context carries no token, because a bound robot token always delivers one.

    import {createRuntimeEngine} from '@sanity/workflow-blueprint/runtime'

    const {engine} = createRuntimeEngine(deployment, {
    context,
    effects: {handlers: effectHandlers, missingHandler: 'skip'},
    })
    await engine.drainEffects({instanceId: event.data._id})

    runHeartbeat({engine, client, tag, definitions, execution}) is the heartbeat's loop. It ticks every in-flight instance of definitions in the tag, not only those with active activities, because an instance parked on a $now deadline has no active activity and still needs the tick. It also releases effect claims whose lease lapsed, since the drain trigger fires only on unclaimed entries and a drain that died mid-dispatch leaves one no event would revisit. One instance's failure is isolated and counted; an all-failed run throws, so the invocation reports red instead of a green no-op.

    import {
    beatCatchUp,
    createRuntimeEngine,
    HEARTBEAT_BEAT_MS,
    runHeartbeat,
    } from '@sanity/workflow-blueprint/runtime'

    const execution = {kind: 'server', id: 'wf-prod-heartbeat'} as const
    const catchUp = beatCatchUp({
    registry: effectHandlers,
    effectNames: ['notify-reviewer', 'issue-payout'],
    budgetedEffectNames: ['issue-payout'],
    recoverDefinitions: ['article-review', 'expense-approval'],
    afterMs: HEARTBEAT_BEAT_MS,
    })
    const {engine, client} = createRuntimeEngine(deployment, {
    context,
    execution,
    effects: {handlers: catchUp.handlers, missingHandler: 'skip'},
    })

    const totals = await runHeartbeat({
    engine,
    client,
    tag: deployment.tag,
    definitions: ['article-review'],
    execution,
    catchUp: catchUp.args,
    })
    totals.caughtUp // pending effects this beat settled successfully
    totals.caughtUpUnsettled // ones it settled as failed or could not settle, also logged
    totals.deferred // instances whose budgeted dispatch waits for a later beat

    catchUp picks the instances the beat drains: those of catchUp.recoverDefinitions holding an entry one of effectNames names that no drainer is working on, either unclaimed for longer than afterMs or holding a claim whose lease lapsed. That list is separate from the definitions the beat ticks, because an effect that runs on a function has a drain to recover whatever hosts its workflow: a durable or self-hosted workflow with a 'function' effect gets a beat that recovers its queues. A completed instance holding such an entry is drained as well, and never ticked, so the beat reaches it without another write to its queue. The drain that follows is an ordinary one for the unbudgeted names, so it covers every entry of those on that instance, a fresh one beside the overdue entry included; the budgeted name is capped at one entry.

    Budgeted effects recover one per beat. beatCatchUp registers every unbudgeted handler plus one of budgetedEffectNames, taken in turn, and runHeartbeat drains a single instance holding it before any other work: only the candidate query and that instance's claim sweep run before the handler, and ticks and unbudgeted recovery follow it. If another drainer took that entry first, the next instance holding the effect gets the run after the first instance's work. Deferral is of the budgeted dispatch alone: an instance the beat passes over still has its unbudgeted entries drained, and totals.deferred counts the instances whose budgeted run waits for a later beat. The generator sizes the emitted beat to the widest effective budget among those effects; a hand-written beat needs the same. Pass catchUp.handlers to the engine with effects.missingHandler: 'skip' and catchUp.args to the beat, and the registered handlers and the effects the beat looks for stay the same set.

    This is what lets you deploy definitions before the functions, on a plan whose schedule the platform actually runs: an effect queued in the gap, or while the drain was down, is dispatched by a beat rather than waiting for the next write to its instance. Below the plan's own cadence, the beat is not guaranteed to run at the rate it asks for, so recovery may wait for a write that reaches the drain. An effect with no declared budget goes out on the first beat that reaches its instance. A budgeted one waits for a beat whose turn holds it and which has not already spent that run on another instance, so a backlog of a budgeted effect clears one entry per beat whose turn it holds, and two budgeted effects share the turns in an order the clock picks.

    One beat drains the instances it selected, in one invocation and in sequence, so a backlog large enough to exhaust the function's budget ends the invocation partway. Nothing is lost: the entries it claimed keep their claims until the leases lapse, and the beat that finds them next releases and redispatches them. The recoverable instances are walked first, so the leg a short invocation is most likely to drop is the tick pass. Because its order is stable, a deployment whose in-flight set is larger than one invocation can walk ticks the same prefix on every beat and never reaches its tail. The beat's budget is derived, not configurable: a deployment whose effects declare no budget leaves the platform's defaults in place, and the only way to raise it is to declare a runtime budget on an effect, which also gives that effect its own drain and its own turn.

    Modules

    generate
    index
    runtime