Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-studio-plugin - v0.32.0

    @sanity/workflow-studio-plugin

    Status: private, unpublished. This README describes the intended consumer setup for when the package is published. Every @sanity/workflow-* package is pre-1.0; APIs may change.

    Workflows inside Sanity Studio. You describe a workflow in code — stages like Drafting → Review → Approved, and the actions that move work between them — deploy it to your dataset, and this plugin gives editors the UI: a workflow strip above the editor form of mapped documents (the current stage, your task count, and a Start workflow button), and a Workflows tab next to the editor with the stage's activities and to-dos, where they fire actions, and a Workflows tool in the Studio navbar — an Overview run table across every workflow (filterable by workflow, assignee, stage, release, and attention, with a Table | Board display toggle), a For me tab cutting the same table to the reader's own work, and a Definitions catalog behind the title row, each definition with its own page. Every one of those is a shareable address, run selection included: a selected row opens the run's detail in a panel beside the list, whose history feed rests at its newest entries with the rest behind Show all history. The panel is also where a running workflow can be stopped: a Cancel workflow button below the stage card, against a reason recorded in the workflow's history. Canceling is open to anyone who can act on the instance, and cannot be undone.

    Four ideas cover everything in this guide:

    • A definition describes a workflow's stages and actions. It's authored in code and deployed as a document into your dataset.
    • An instance is one run of a definition, attached to one of your documents. Starting a workflow creates an instance; firing actions moves it through stages.
    • The plugin (this package) is the Studio UI over both.
    • A small runtime you host (two Sanity Functions, step 5) handles what editors can't: waits that resolve on a timer, and side-effects that should run unattended.

    Everything the plugin checks is advisory — it disables the right buttons and explains why, but the Content Lake is the only enforcement point. Don't treat plugin-side checks as a security boundary.

    npm install @sanity/workflow-studio-plugin @sanity/workflow-components @sanity/workflow-diagram @sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk @sanity/workflow-studio @sanity/workflow-cli
    

    Use one matching version for every @sanity/workflow-* package in that command; they publish as a fixed runtime stack. The plugin also requires a Studio v6 project (sanity ^6, react ^19, styled-components ^6, @sanity/sdk ^2.12 — the usual Studio peers).

    A complete, working definition — a document goes Drafting → Review → Approved; a writer submits, an editor approves. Save as workflows/article-review.ts next to your studio config:

    import {defineWorkflow} from '@sanity/workflow-engine/define'

    export const articleReview = defineWorkflow({
    name: 'article-review',
    title: 'Article review',
    description: 'Draft, review, approve.',
    initialStage: 'drafting',
    fields: [
    // The document this workflow is about. The plugin fills it in when an
    // editor starts the workflow from a document.
    {type: 'subject', name: 'subject', title: 'Article', initialValue: {type: 'input'}},
    ],
    stages: [
    {
    name: 'drafting',
    title: 'Drafting',
    activities: [
    {
    name: 'write',
    title: 'Write the article',
    // `status: 'done'` = firing this action completes the activity.
    actions: [{name: 'submit', title: 'Submit for review', status: 'done'}],
    },
    ],
    // No `when` = the stage advances once all its activities are done.
    transitions: [{name: 'to-review', title: 'Send to review', to: 'review'}],
    },
    {
    name: 'review',
    title: 'Editorial review',
    activities: [
    {
    name: 'review',
    title: 'Review the article',
    actions: [{name: 'approve', title: 'Approve', status: 'done'}],
    },
    ],
    transitions: [{name: 'to-approved', title: 'Approve', to: 'approved'}],
    },
    // No transitions out = terminal stage. The workflow completes here.
    {name: 'approved', title: 'Approved', activities: []},
    ],
    })

    Save as sanity.workflow.ts next to your studio config, filling in your project id and dataset:

    import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
    import {articleReview} from './workflows/article-review'

    export default defineWorkflowConfig({
    deployments: [
    {
    expectedMinReaderModel: 4,
    name: 'production',
    // The tag namespaces all workflow data — the plugin only sees
    // definitions and instances deployed under the tag it's configured with.
    tag: 'production',
    workflowResource: {type: 'dataset', id: '<projectId>.<dataset>'},
    definitions: [articleReview],
    },
    ],
    })
    npx sanity-workflows deploy --tag production   # add --dry-run to preview
    

    Auth comes from your sanity login session (or a SANITY_AUTH_TOKEN env var). Deploys are idempotent — re-running with an unchanged definition is a no-op.

    Add to your sanity.config.ts:

    import {defineConfig} from 'sanity'
    import {structureTool} from 'sanity/structure'
    import {workflowDefaultDocumentNode, workflowStudioPlugin} from '@sanity/workflow-studio-plugin'

    export default defineConfig({
    // ...your projectId, dataset, schema...
    plugins: [
    structureTool({
    // Adds the "Workflows" tab next to document editors.
    defaultDocumentNode: workflowDefaultDocumentNode(),
    }),
    workflowStudioPlugin({
    tag: 'production', // must match the deploy tag
    }),
    ],
    })

    The plugin discovers deployed definitions whose caller-provided subject accepts a document type this Studio schema declares. System document types — Sanity's reserved sanity. namespace, the built-in asset documents among it, and the engine's own types — are never discovered; binding a workflow to one takes an explicit mapping row. Start the studio and open an article: the workflow strip above the form offers Start workflow. Once started, the strip shows the current stage, the document footer shows an active workflow chip, and the Workflows tab lists the stage's activities — click one to open it and fire Submit for review, then Approve, and watch it reach Approved.

    Mappings customize discovered subject bindings or explicitly bind definitions that use a plain doc.ref instead of a first-class subject. Multiple definitions for the same docType remain separate workflows. A mapping row replaces the discovered defaults for its exact (docType, definition) pair or adds that pair when discovery did not produce it. Duplicate rows for the same pair are rejected as configuration errors.

    autoStart on a mapping override starts a workflow the moment an editor opens a fresh document of a given type — the document is born with its workflow instead of relying on someone to press "Start". Add one override row for each definition that should start automatically:

    workflowStudioPlugin({
    tag: 'production',
    mappings: [
    {docType: 'article', definition: 'article-review', label: 'Article review', autoStart: true},
    {docType: 'campaign', definition: 'legal-review', label: 'Legal review', autoStart: true},
    {docType: 'campaign', definition: 'brand-review', label: 'Brand review', autoStart: true},
    ],
    })

    How it behaves:

    • Fresh only. Studio doesn't persist a new document until its first edit, so "fresh" means never-persisted (no _createdAt). The gate takes over that first form render and never touches a document that already exists.
    • The document is the subject. The gate materializes the document (like pressing "Start workflow" does) in the Studio's active perspective — the selected release's version, or the draft when none is selected — and seeds it as each workflow's subject. When several workflows share an input beyond the subject, one dialog collects that union once and feeds every workflow; when nothing is needed, they start silently. The dialog is mandatory — auto-start means the workflow will start, so there's no skip; the required inputs must be filled. The form is revealed once the workflow instance is live.
    • Best-effort with several workflows. When a type maps to more than one workflow they start in parallel, and the form stays held until they've all started. A failure is never a dead end: the takeover shows the error with a Try again (re-attempts only the workflows that didn't start — never double-starting one) and a Continue editing without it. There's no automatic rollback — the document is left created, its workflows as they landed.
    • A floor, not a guarantee. A raw client or any non-Studio write bypasses it entirely; enforcing "this type must have a workflow" needs a service in front of the Content Lake, which is out of scope. A misconfigured entry (unknown type, undeployed or spawn-only workflow, a subject that doesn't accept the type) is dropped with a console.warn, never a crash.

    Each auto-start workflow needs a mapping override with autoStart: true; its inputs still come from the deployed definition.

    Plugin options, for later: workflowDataset (keep workflow state in a separate dataset), effectHandlers (run effect side-effects in the browser — the runtime below is usually the better home), and per-mapping initialStateBuilder and perspectiveField (bind a release to the workflow).

    Everything above works with editors driving. Two things need a caller when no editor is looking, and both are small Sanity Functions:

    • heartbeat — definitions can wait on time (when triggers over $now, e.g. an embargo date). Time passing emits no event, so a schedule must periodically tick in-flight instances. Ticking an instance with nothing due is a no-op.
    • drain-effects — definitions can queue effects (named side-effects like "publish the subject"). This function runs your handlers for them whenever an instance has pending effects. Effects it has no handler for stay pending; completing one re-triggers the function, so cascades drain themselves.

    functions/heartbeat/index.ts:

    import {createClient} from '@sanity/client'
    import {scheduledEventHandler} from '@sanity/functions'
    import {createEngine, type WorkflowClient} from '@sanity/workflow-engine'

    const TAG = 'production'

    export const handler = scheduledEventHandler(async ({context}) => {
    const projectId = process.env.SANITY_PROJECT_ID
    const dataset = process.env.SANITY_DATASET ?? 'production'
    if (!projectId) throw new Error('heartbeat: SANITY_PROJECT_ID is required')

    const client = createClient({
    projectId,
    dataset,
    apiVersion: '2026-04-29',
    useCdn: false,
    perspective: 'raw',
    token: context.clientOptions?.token,
    })
    const engine = createEngine({
    client: client as unknown as WorkflowClient,
    tag: TAG,
    workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
    })

    const inflight = await engine.query<{_id: string}[]>({
    groq: `*[_type == "sanity.workflow.instance" && tag == $tag && !defined(completedAt)]{_id}`,
    })
    for (const {_id} of inflight) {
    await engine.tick({instanceId: _id})
    }
    })

    functions/drain-effects/index.ts:

    import {createClient} from '@sanity/client'
    import {documentEventHandler} from '@sanity/functions'
    import {createEngine, type EffectHandler, type WorkflowClient} from '@sanity/workflow-engine'

    const TAG = 'production'

    // Your side-effects, keyed by the effect names your definitions queue.
    const effectHandlers: Record<string, EffectHandler> = {
    // 'publish.article': async (params, ctx) => { ... },
    }

    export const handler = documentEventHandler(async ({event, context}) => {
    const projectId = process.env.SANITY_PROJECT_ID
    const dataset = process.env.SANITY_DATASET ?? 'production'
    if (!projectId) throw new Error('drain-effects: SANITY_PROJECT_ID is required')

    const client = createClient({
    projectId,
    dataset,
    apiVersion: '2026-04-29',
    useCdn: false,
    perspective: 'raw',
    token: context.clientOptions?.token,
    })
    const engine = createEngine({
    client: client as unknown as WorkflowClient,
    tag: TAG,
    workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
    effects: {
    handlers: effectHandlers,
    // Leave effects this runtime has no handler for pending, for another
    // runtime (or the Studio's manual controls) to resolve.
    missingHandler: 'skip',
    },
    })

    await engine.drainEffects({instanceId: event.data._id as string})
    })

    Each function directory needs its own package.json depending on @sanity/functions, @sanity/client, and @sanity/workflow-engine.

    sanity.blueprint.ts at the project root declares both, plus the robot token they run under:

    import {
    defineBlueprint,
    defineDocumentFunction,
    defineRobotToken,
    defineScheduledFunction,
    } from '@sanity/blueprints'

    const projectId = process.env.SANITY_PROJECT_ID ?? ''
    const dataset = process.env.SANITY_DATASET ?? 'production'

    export default defineBlueprint({
    resources: [
    defineRobotToken({
    name: 'wf-robot',
    label: 'Workflow runtime',
    memberships: [{resourceType: 'project', resourceId: projectId, roleNames: ['editor']}],
    }),
    defineDocumentFunction({
    name: 'drain-effects',
    src: './functions/drain-effects',
    event: {
    on: ['create', 'update'],
    filter: "_type == 'sanity.workflow.instance' && count(pendingEffects[!defined(claim)]) > 0",
    projection: '{_id}',
    resource: {type: 'dataset', id: `${projectId}.${dataset}`},
    },
    }),
    defineScheduledFunction({
    name: 'heartbeat',
    src: './functions/heartbeat',
    event: {expression: '* * * * *'}, // every minute; loosen to taste
    robotToken: '$.resources.wf-robot.token',
    }),
    ],
    })
    npx sanity blueprints deploy
    

    Note: scheduled functions currently require an organization-scoped stack — see Sanity's Functions documentation for stack setup. Concurrent runtimes are safe: ticks are idempotent, pending effects carry claims, and effects.missingHandler: 'skip' keeps runtimes out of each other's effects.

    A third function is worth considering: deleting a document does not cascade into its workflows, so instances whose documents are gone stay in-flight until something settles them. A document-delete function settles them the moment the deletion happens, on the robot token — see the cookbook recipe Handle a deleted subject document.

    • Advisory enforcement. The plugin's gates and locks are UI; only lake-side rules actually block writes.
    • Studio actions run on the editor's token — including workflow bookkeeping. The runtime's robot token covers the unattended paths.
    • Failed effects settle as failed (workflows proceed rather than strand) and can't be retried from the Studio — run consequential effects in the runtime, not the browser.

    Interfaces

    WorkflowMapping
    WorkflowPluginConfig

    Variables

    workflowStudioPlugin

    Functions

    workflowDefaultDocumentNode
    workflowsView