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:
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:
_createdAt). The gate takes over that
first form render and never touches a document that already exists.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:
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.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.