Workflows

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

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.ts deployed through the dev deployment in sanity.workflow.ts, and the _id of 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 all done.
  • 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 submit

List what you can fire now:

npx sanity-workflows fire-action INSTANCE_ID
article-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-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.set

Submit 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 approve

Verify 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 --skip

From 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

Visiting agent?

Was this page helpful?

Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change site theme