> For AI agents: the complete Sanity documentation index is available at [https://www.sanity.io/docs/llms.txt](https://www.sanity.io/docs/llms.txt).

# 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.

> [!WARNING]
> Early access
> Workflows is in early access, built in public. Read [How early access works](https://www.sanity.io/docs/workflows/prerelease) 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](https://www.sanity.io/docs/workflows/getting-started): `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:

**workflows/article-review.ts**

```typescript
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](https://www.sanity.io/docs/workflows/effects-and-runtimes). [Operations and value expressions](https://www.sanity.io/docs/workflows/workflow-authoring-reference) 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:

**npm**

```shell
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
```

**pnpm**

```shell
pnpm 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 submit
```

**yarn**

```shell
yarn 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 submit
```

**bun**

```shell
bunx 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 submit
```

List what you can fire now:

**npm**

```shell
npx sanity-workflows fire-action INSTANCE_ID
```

**pnpm**

```shell
pnpm dlx sanity-workflows fire-action INSTANCE_ID
```

**yarn**

```shell
yarn dlx sanity-workflows fire-action INSTANCE_ID
```

**bun**

```shell
bunx sanity-workflows fire-action INSTANCE_ID
```

**CLI**

```text
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:

**npm**

```shell
npx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-back
```

**pnpm**

```shell
pnpm dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-back
```

**yarn**

```shell
yarn dlx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-back
```

**bun**

```shell
bunx sanity-workflows fire-action INSTANCE_ID --activity fact-check --action send-back
```

**CLI**

```text
✔ 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:

**npm**

```shell
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
```

**pnpm**

```shell
pnpm 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 approve
```

**yarn**

```shell
yarn 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 approve
```

**bun**

```shell
bunx 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 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:

**CLI**

```text
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:

**workflows/article-review.ts**

```typescript
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](https://www.sanity.io/docs/workflows/claim-assignment) shows one. These checks are advisory: they tell an interface what to disable, and [Actors and enforcement](https://www.sanity.io/docs/workflows/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:

**workflows/article-review.ts**

```typescript
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](https://www.sanity.io/docs/workflows/sanity-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:

**npm**

```shell
npx sanity-workflows reset-activity INSTANCE_ID fact-check
npx sanity-workflows reset-activity INSTANCE_ID fact-check --skip
```

**pnpm**

```shell
pnpm dlx sanity-workflows reset-activity INSTANCE_ID fact-check
pnpm dlx sanity-workflows reset-activity INSTANCE_ID fact-check --skip
```

**yarn**

```shell
yarn dlx sanity-workflows reset-activity INSTANCE_ID fact-check
yarn dlx sanity-workflows reset-activity INSTANCE_ID fact-check --skip
```

**bun**

```shell
bunx sanity-workflows reset-activity INSTANCE_ID fact-check
bunx sanity-workflows reset-activity INSTANCE_ID fact-check --skip
```

From code, call `engine.resetActivity`. Create `scripts/reset-fact-check.ts`:

**scripts/reset-fact-check.ts**

```typescript
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:

**scripts/firing-preview.ts**

```typescript
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](https://www.sanity.io/docs/workflows/conditions).
- Declare, seed, and edit fields in [Configure workflow fields](https://www.sanity.io/docs/workflows/fields).
- Queue external work from an action in [Run an external step with an effect](https://www.sanity.io/docs/workflows/effects-and-runtimes).
- Look up every property in [Activities and actions](https://www.sanity.io/docs/workflows/workflow-authoring-reference) and [Operations and value expressions](https://www.sanity.io/docs/workflows/workflow-authoring-reference).
- Browse the TypeScript API in [@sanity/workflow-engine/define](https://reference.sanity.io/_sanity/workflow-engine/define/) on reference.sanity.io.

> [!NOTE]
> Visiting agent?
> Workflows includes an MCP server for inspecting, operating, authoring, validating, and deploying workflows. Ask your human to [set up the MCP server](https://www.sanity.io/docs/workflows/mcp).



