Define activities and actions
Add an activity to a workflow stage, record a decision with actions and operations, and route on it with a transition.
Early access
Workflows is in early access, built in public. Read How early access works before you rely on it.
An activity is a unit of work inside a stage, and its actions are the named responses available while that work is open. Firing an action can record a decision in a field and resolve the activity. A transition then reads the result and decides whether the instance moves.
This guide adds a fact-check activity to the review stage of the quick start’s article-review workflow. Its two actions record the outcome in a field and resolve the activity, and a new transition sends the article back to drafting when the facts need correcting. You fire both actions with the CLI and check where the instance went.
Prerequisites
- The finished quick start:
workflows/article-review.tsdeployed through thedevdeployment insanity.workflow.ts, and the_idof the published article you started it with.
Add the activity and its actions
Activities belong to a stage’s activities array, and actions to an activity’s actions array. Replace the review stage in workflows/article-review.ts, and add defineOp to the import:
import {
defineAction,
defineActivity,
defineField,
defineOp,
defineStage,
defineTransition,
defineWorkflow,
} from '@sanity/workflow-engine/define'
export const articleReview = defineWorkflow({
// ...
stages: [
// drafting is unchanged
defineStage({
name: 'review',
title: 'Editorial review',
description: 'An editor reads the article and approves it.',
fields: [
defineField({type: 'string', name: 'factCheck', title: 'Fact-check result'}),
defineField({type: 'actor', name: 'checkedBy', title: 'Checked by'}),
],
activities: [
defineActivity({
name: 'fact-check',
title: 'Check the facts',
actions: [
defineAction({
name: 'confirm',
title: 'Facts confirmed',
ops: [
defineOp({
type: 'field.set',
target: {field: 'factCheck'},
value: {type: 'literal', value: 'confirmed'},
}),
defineOp({type: 'field.set', target: {field: 'checkedBy'}, value: {type: 'actor'}}),
],
status: 'done',
}),
defineAction({
name: 'send-back',
title: 'Send back for corrections',
ops: [
defineOp({
type: 'field.set',
target: {field: 'factCheck'},
value: {type: 'literal', value: 'changes-requested'},
}),
defineOp({type: 'field.set', target: {field: 'checkedBy'}, value: {type: 'actor'}}),
],
status: 'done',
}),
],
}),
defineActivity({
name: 'sign-off',
// ...
}),
],
transitions: [
defineTransition({
name: 'back-to-drafting',
title: 'Return for corrections',
to: 'drafting',
when: "$fields.factCheck == 'changes-requested'",
}),
defineTransition({name: 'to-approved', title: 'Approve and finish', to: 'approved'}),
],
}),
// approved is unchanged
],
})Each action carries operations in ops. field.set writes a field, and the value {type: 'actor'} records who fired the action. Operations run in declaration order, and they, the status, any queued effects, and the history entry commit together or not at all. Later conditions see the new values at once, and queued effects run only when a runtime drains them. Operations change instance fields, never content documents: the engine validates each one against the definition before it commits, and work outside the instance belongs in an effect. Operations and value expressions lists every operation and value form.
Both activities in review are active from stage entry. The default to-approved waits for both to resolve. back-to-drafting comes first, so it wins as soon as factCheck reads changes-requested, even while sign-off is still open. Declaration order is routing priority.
Resolve the activity with a status
An action resolves its activity by declaring status. The engine never assumes one. defineWorkflow rejects an activity that no action can resolve, with activity "x" has no path to a terminal status. Use the status for the outcome of the work, never for the business decision:
done: the work completed. Approve, decline, send back, and hold are alldone.skipped: the work did not apply.failed: the work could not complete.
For a decision, resolve the activity as done and write the decision into a field that a transition reads, as send-back does with factCheck. Routing lives in the transition.
Deploy and fire the actions
Deploy the change, start a new instance, and submit the draft:
npx sanity-workflows deploy
npx sanity-workflows start article-review \
--field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
npx sanity-workflows fire-action INSTANCE_ID --activity write --action submitpnpm dlx sanity-workflows deploy
pnpm dlx sanity-workflows start article-review \
--field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
pnpm dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submityarn dlx sanity-workflows deploy
yarn dlx sanity-workflows start article-review \
--field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
yarn dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submitbunx sanity-workflows deploy
bunx sanity-workflows start article-review \
--field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
bunx sanity-workflows fire-action INSTANCE_ID --activity write --action submitList what you can fire now:
npx sanity-workflows fire-action INSTANCE_IDpnpm dlx sanity-workflows fire-action INSTANCE_IDyarn dlx sanity-workflows fire-action INSTANCE_IDbunx sanity-workflows fire-action INSTANCE_IDarticle-review v2 dev.wf-instance.a1b2c3d4e5f6
Stage: review
Started: 2026-10-26 18:15:49
Completed: —
Partition: dev
Actions on stage 'review':
✔ fact-check → confirm (Facts confirmed) [activity active]
✔ fact-check → send-back (Send back for corrections) [activity active]
✔ sign-off → approve (Approve) [activity active]
Fire one with:
sanity-workflows fire-action dev.wf-instance.a1b2c3d4e5f6 --activity <activity> --action <action>Fire send-back. The output lists the operations the action applied:
npx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-backpnpm dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-backyarn dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-backbunx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-back✔ Fired send-back on fact-check — now at drafting, then cascaded 1 auto-transition(s)
ops applied:
ℹ field.set → stage.factCheck
ℹ field.set → stage.checkedBy
ℹ status.setSubmit again, then confirm the facts and approve. Re-entering review starts a new stage visit, so factCheck starts empty and both activities are active again:
npx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
npx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action confirm
npx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approvepnpm dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
pnpm dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action confirm
pnpm dlx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approveyarn dlx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
yarn dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action confirm
yarn dlx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approvebunx sanity-workflows fire-action INSTANCE_ID --activity write --action submit
bunx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action confirm
bunx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approveVerify the route the instance took
Run npx sanity-workflows show INSTANCE_ID. The stage history shows the first review visit sending the article back with sign-off unfinished, and the second visit completing it:
Stage history:
• drafting (exited 2026-10-26 18:15:49)
✔ write [done]
• review (exited 2026-10-26 18:15:49)
✔ fact-check [done]
◌ sign-off [not finished]
• drafting (exited 2026-10-26 18:15:49)
✔ write [done]
• review (exited 2026-10-26 18:15:49)
✔ fact-check [done]
✔ sign-off [done]
• approved (current)Add --json to see the recorded values. Each review entry in stages holds its own factCheck and checkedBy fields: changes-requested on the first visit, confirmed on the second, each with the actor who fired the action.
Skip the activity or hold its actions
An activity’s filter decides whether the activity exists for a stage visit. The engine evaluates it once, at stage entry. A definite false records the activity as skipped, and $allActivitiesDone ignores it. If the engine cannot decide the filter, the activity stays in scope, so a missing value never opens the gate it protects.
requirements keeps an activity’s caller-fired actions disabled until every requirement passes. This version fact-checks news articles only, and only once the article has a body:
defineActivity({
name: 'fact-check',
title: 'Check the facts',
filter: "$fields.subject.category == 'news'",
requirements: [
{
type: 'groq',
name: 'has-body',
title: 'Article body required',
query: 'defined($fields.subject.body)',
},
],
actions: [
// confirm and send-back are unchanged
],
})An article whose category is not news enters review with fact-check already [skipped]. For a news article without a body, fire-action INSTANCE_ID marks both actions with disabled: unmet requirement(s): has-body, and firing one fails with ActionDisabledError.
An action can declare its own filter. When it fails, the evaluation reports filter-failed, the action does not exist for that caller, and surfaces hide it. When $assigned hides it, the evaluation can include holderGate naming who holds the work. A filter or requirement can also read the caller through $assigned or $actor; Let reviewers claim an assignment shows one. These checks are advisory: they tell an interface what to disable, and Actors and enforcement explains what actually stops a write.
Fire an action automatically
An action without when is caller-fired: a person or a script fires it. An action with when is a triggered action: the engine fires it when the condition becomes true, and fireAction can never invoke it. On a triggered action, filter still decides whether it exists and when decides when it fires. This one skips the fact-check once a deadline supplied at start has passed:
export const articleReview = defineWorkflow({
// ...
fields: [
// subject is unchanged
defineField({
type: 'datetime',
name: 'factCheckDue',
title: 'Fact-check due',
initialValue: {type: 'input'},
}),
],
stages: [
// ...
defineActivity({
name: 'fact-check',
// ...
actions: [
// confirm and send-back are unchanged
defineAction({
name: 'overdue',
title: 'Skip the fact-check when overdue',
when: 'dateTime($fields.factCheckDue) < dateTime($now)',
status: 'skipped',
}),
],
}),
// ...
],
})The engine fires each triggered action at most once per stage visit, in declaration order: activities in the order the stage declares them, then each activity’s actions in order. Re-entering the stage starts a new visit, and every trigger becomes eligible again. Start an instance with --field factCheckDue=2026-01-01T00:00:00Z and submit it: fact-check is [skipped] as soon as the instance enters review.
Nothing fires on a timer. A trigger is evaluated only inside a call your code makes, so a deadline that passes while the instance sits in review needs something to call tick. Run workflows with Functions schedules it.
Recover a terminal activity
Use resetActivity when an activity in the current stage has failed or otherwise reached a terminal status. Reset it to active to rerun it, or to skipped to bypass it. The engine records the change and continues the cascade. Resetting to active does not repeat a when action that already fired during this stage visit, and resetting an activity that is still active throws ContractViolationError. From the CLI:
npx sanity-workflows reset-activity INSTANCE_ID fact-check
npx sanity-workflows reset-activity INSTANCE_ID fact-check --skippnpm dlx sanity-workflows reset-activity INSTANCE_ID fact-check
pnpm dlx sanity-workflows reset-activity INSTANCE_ID fact-check --skipyarn dlx sanity-workflows reset-activity INSTANCE_ID fact-check
yarn dlx sanity-workflows reset-activity INSTANCE_ID fact-check --skipbunx sanity-workflows reset-activity INSTANCE_ID fact-check
bunx sanity-workflows reset-activity INSTANCE_ID fact-check --skipFrom code, call engine.resetActivity. Create scripts/reset-fact-check.ts:
import {engine} from './engine'
const [instanceId, mode] = process.argv.slice(2)
if (!instanceId) throw new Error('Usage: npx tsx scripts/reset-fact-check.ts INSTANCE_ID [skip]')
// Rerun the activity, or pass "skip" to bypass it.
const result = await engine.resetActivity({
instanceId,
activity: 'fact-check',
to: mode === 'skip' ? 'skipped' : 'active',
})
console.log(`fact-check reset. Now at ${result.instance.currentStage}`)Run npx tsx scripts/reset-fact-check.ts INSTANCE_ID to rerun the activity, or add skip to bypass it. It prints fact-check reset. Now at review.
Preview where an action leads
engine.evaluate previews what firing a caller-fired action would do right now, including the triggered actions it would set off and the transition selected next. Read firing on the action’s evaluation to place or label a control without repeating transition logic in your interface:
import {engine} from './engine'
const instanceId = process.argv[2]
if (!instanceId) throw new Error('Usage: npx tsx scripts/firing-preview.ts INSTANCE_ID')
const evaluation = await engine.evaluate({instanceId})
const approve = evaluation.currentStage.activities
.flatMap(({actions}) => actions)
.find(({action}) => action.name === 'approve')
if (approve?.firing?.exitsStage) {
console.log(`Approve exits through ${approve.firing.transition}`)
}The preview is advisory, not a promise about the commit. firing is omitted when an accurate replay needs something the engine does not have yet: caller parameters, a spawn whose rows come from the dataset, a terminal instance, or a stage visit that no longer matches the pinned definition. Triggered actions never carry it. Run the script while the instance is in review with fact-check resolved, and it prints Approve exits through to-approved.
Next steps
- Route on field values and other state in Write GROQ conditions.
- Declare, seed, and edit fields in Configure workflow fields.
- Queue external work from an action in Run an external step with an effect.
- Look up every property in Activities and actions and Operations and value expressions.
- Browse the TypeScript API in
@sanity/workflow-engine/defineon reference.sanity.io.
Visiting agent?
Workflows includes an MCP server for inspecting, operating, authoring, validating, and deploying workflows. Ask your human to set up the MCP server.
Was this page helpful?
Feedback submitted, thanks for helping us improve the docs. Have you tried these alternatives?