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

# Run an external step with an effect

Declare effects on workflow actions, run effect drainers with leases and retries, and return typed outputs to conditions.

> [!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.



A workflow regularly needs to act on the world beyond its own fields: send an email, kick off a build, call an API. Workflows splits that work in two. The engine records what should happen, and a runtime you supply makes it happen.

This guide adds a `notify-reviewer` effect to the quick start’s `article-review` workflow, queues it, drains it from a script, and checks the settled result.

You need:

- The finished [quick start](https://www.sanity.io/docs/workflows/getting-started): `article-review` deployed, and a `sanity.workflow.ts` that exports the `dev` deployment.
- `scripts/engine.ts` from [Run the engine from your own code](https://www.sanity.io/docs/workflows/run-the-engine), and the token it reads from `SANITY_AUTH_TOKEN`.
- The `_id` of the published article from the quick start.
- Optional: [Define activities, actions, and operations](https://www.sanity.io/docs/workflows/activities-and-actions) covers `when` triggers and field operations. [Write GROQ conditions](https://www.sanity.io/docs/workflows/conditions) covers the variables a condition reads.

## Declare and queue an effect

The engine never runs that outside work itself. Effects are declared on actions, the only construct that carries work. When the action fires, whether a caller fired it or its `when` condition turned true, the engine queues the effect and records what needs doing. A separate runtime then picks it up, runs it, and reports back.

Queuing writes a row into the instance’s `pendingEffects`. The row carries the effect’s name and its resolved `params`: an effect’s `bindings` are GROQ over the workflow’s state, and the engine evaluates them to concrete JSON at the moment it queues the effect. So a handler receives plain values, and never has to read the workflow back. The stored definition names the effect and never references code. A runtime registers a handler against that name, one handler per name.

Add an effect to `article-review` that notifies the reviewer when an article enters review. In `workflows/article-review.ts`, import `defineEffect` and add a `notify` action to the `sign-off` activity:

**workflows/article-review.ts**

```typescript
import {
  defineAction,
  defineActivity,
  defineEffect,
  defineField,
  defineStage,
  defineTransition,
  defineWorkflow,
} from '@sanity/workflow-engine/define'

// ...

    defineStage({
      name: 'review',
      title: 'Editorial review',
      description: 'An editor reads the article and approves it.',
      activities: [
        defineActivity({
          name: 'sign-off',
          title: 'Review the article',
          actions: [
            defineAction({
              name: 'approve',
              title: 'Approve',
              status: 'done',
            }),
            defineAction({
              name: 'notify',
              title: 'Notify the reviewer',
              when: 'true',
              effects: [
                defineEffect({
                  name: 'notify-reviewer',
                  bindings: {article: '$fields.subject._id'},
                  outputs: [{name: 'messageId', type: 'string'}],
                }),
              ],
            }),
          ],
        }),
      ],
      // ...
```

`notify` declares `when: 'true'`, so it is a trigger. The engine fires it in the call that enters `review`, and nobody fires it with `fire-action`. It sets no `status`, so `sign-off` stays active until an editor fires `approve`. The binding `$fields.subject._id` resolves to the article's global document reference, and the handler receives it as `params.article`. `outputs` declares the one value the handler may return.

Deploy the change:

**npm**

```shell
npx sanity-workflows deploy
```

**pnpm**

```shell
pnpm dlx sanity-workflows deploy
```

**yarn**

```shell
yarn dlx sanity-workflows deploy
```

**bun**

```shell
bunx sanity-workflows deploy
```

An instance keeps the definition version it started under, and the quick start's instance is already finished. Start a new instance and submit it:

**npm**

```shell
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
npx sanity-workflows show INSTANCE_ID
```

**pnpm**

```shell
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
pnpm dlx sanity-workflows show INSTANCE_ID
```

**yarn**

```shell
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
yarn dlx sanity-workflows show INSTANCE_ID
```

**bun**

```shell
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
bunx sanity-workflows show INSTANCE_ID
```

Replace `INSTANCE_ID` with the ID the `start` command printed. Submitting moves the instance to `review`, which fires `notify` and queues the effect. `show` lists it under **Pending effects**:

**Output**

```text
article-review v2  dev.wf-instance.a1b2c3d4e5f6
  Stage:     review
  Started:   2026-10-23 14:02:11
  Completed: —
  Partition: dev

Stage history:
  • drafting (exited 2026-10-23 14:02:15)
      ✔ write  [done]
  • review (current)
      ● sign-off  [active]

Pending effects:
  • notify-reviewer  (key=7d0d377ba7d8)
```

Nothing has run the handler yet. The CLI has no drain command, so a script drains the effect in [Drain the effect from a script](https://www.sanity.io/docs/workflows/effects-and-runtimes).

## Drain the effect from a script

Because an effect is queued rather than run inline, the work it represents need not happen in the process that fired the action, or even on the same machine. The engine records the effect as pending and moves on, and a runtime you write picks it up later. [Runtimes and engine verbs](https://www.sanity.io/docs/workflows/actors-and-enforcement) explains how a runtime drives the engine.

A drainer claims a pending effect under a lease, invokes its registered handler, and reports the outcome. One `engine.drainEffects({instanceId})` call continues until no claimable entries remain, including effects queued by its completion cascades. The result groups entries into `drained`, `failed`, `skipped`, and `lost`.

Calling code queues work through the engine, which records the pending effect in the Content Lake. The effect runtime owns delivery: it drains and claims the effect before the engine invokes the registered handler and commits the result.

![Calling code invokes the engine, which commits a pending effect to the Content Lake. An effect runtime drains it, the engine claims it under a lease and invokes the registered handler, then commits completion and reevaluates the workflow.](https://cdn.sanity.io/images/3do82whm/next/c17881e3051a3a2b9bef1e9129e1c526eb5ec042-2800x1444.png)

Put the handler in its own module, `effect-handlers.ts` at the project root, so this script and any hosted runtime can share it. The map is keyed by the effect name the definition queues:

**effect-handlers.ts**

```typescript
import type {EffectHandler} from '@sanity/workflow-engine'

// Keyed by the effect name the definition queues.
export const effectHandlers: Record<string, EffectHandler> = {
  'notify-reviewer': async (params, ctx) => {
    // Replace this with a call to your email or chat service.
    console.log(`Notify the reviewer: ${params.article} is ready for review`)
    return {outputs: {messageId: `log-${ctx.effectKey}`}}
  },
}
```

Create `scripts/drain-effects.ts`. Handlers are part of engine construction, so the script builds a second engine on the `client` from `scripts/engine.ts` with those handlers. It reads the instance through the shared `engine`:

**scripts/drain-effects.ts**

```typescript
import {createEngine} from '@sanity/workflow-engine'

import {effectHandlers} from '../effect-handlers'
import {dev} from '../sanity.workflow'
import {client, engine} from './engine'

// Handlers are construction config, so the drainer is its own engine on the same client.
const drainer = createEngine({
  client,
  workflowResource: dev.workflowResource,
  partition: dev.partition,
  effects: {handlers: effectHandlers},
})

async function main() {
  const instanceId = process.argv[2]
  if (!instanceId) {
    throw new Error('Pass the instance id: npx tsx scripts/drain-effects.ts INSTANCE_ID')
  }

  const pending = await engine.listPendingEffects({instanceId})
  console.log(`Pending: ${pending.map((effect) => effect.name).join(', ') || 'none'}`)

  const result = await drainer.drainEffects({instanceId})
  console.log(`Drained: ${result.drained.map((effect) => effect.name).join(', ') || 'none'}`)

  const {effectHistory} = await engine.getInstance({instanceId})
  for (const run of effectHistory) {
    console.log(`${run.name}: ${run.status}, messageId ${run.outputs?.messageId}`)
  }
}

main().catch((error) => {
  console.error(error)
  process.exit(1)
})
```

The handler receives the resolved params and returns the declared `messageId` output. Delivery is at-least-once, so a handler can run more than once for one queued entry. `ctx.effectKey` stays the same for every dispatch of that entry, so a real handler can pass it to the external service as an idempotency key. See [At-least-once delivery](https://www.sanity.io/docs/workflows/engine).

Run the script with the instance you submitted:

**CLI**

```sh
SANITY_AUTH_TOKEN=YOUR_TOKEN npx tsx scripts/drain-effects.ts INSTANCE_ID
```

The script lists the pending effect, runs the handler, and prints the settled run with its output:

**Output**

```text
Pending: notify-reviewer
Notify the reviewer: dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID is ready for review
Drained: notify-reviewer
notify-reviewer: done, messageId log-7d0d377ba7d8
```

## Verify the settled effect

Check the instance from the CLI:

**npm**

```shell
npx sanity-workflows show INSTANCE_ID --include history
```

**pnpm**

```shell
pnpm dlx sanity-workflows show INSTANCE_ID --include history
```

**yarn**

```shell
yarn dlx sanity-workflows show INSTANCE_ID --include history
```

**bun**

```shell
bunx sanity-workflows show INSTANCE_ID --include history
```

The **Pending effects** section is gone, and the history ends with the trigger, the queued effect, and its settled run:

**Output**

```text
History:
  ...
  [2026-10-23 14:02:15] Stage entered: review (by USER_ID)
  [2026-10-23 14:02:15] Action fired: sign-off → notify (triggered)
  [2026-10-23 14:02:15] Effect queued: notify-reviewer
  [2026-10-23 14:05:40] Effect settled: notify-reviewer (done) (by USER_ID)
```

Finish the instance with `npx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approve`.

For a complete deployment with a Document Function that drains newly queued effects and a Scheduled Function that ticks existing instances, see [Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions).

Retries, failure routing, progress reports, field operations from a handler, and completion from another process are documented in [Effects and handler runtime contracts](https://www.sanity.io/docs/workflows/engine).

## Return typed outputs to conditions

An effect can hand values back when it finishes, and later steps can read them. An effect that creates a record elsewhere can return the new ID, and a downstream condition or another effect can use it. The effect declares what it can hand back, as typed outputs on its declaration. A completion that returns anything undeclared is rejected, so unexpected data never reaches the instance.

Two condition variables read a completed run, and they answer different questions. `$effects['<effect>'].<output>` is the value: the outputs of that effect’s latest completed run, anywhere in the instance’s history. `$effectStatus['<effect>']` is the settle state, `'done'` or `'failed'`, and it counts only runs queued during the current stage visit, so it is absent until this visit’s run settles. Gate a transition on `$effectStatus`. A transition gated on `$effects` can fire on a value left behind by an earlier visit to the same stage.

In `article-review`, you can hold approval until the notification has gone out. Change the `to-approved` transition in the `review` stage of `workflows/article-review.ts`, then redeploy:

**workflows/article-review.ts**

```typescript
    defineStage({
      name: 'review',
      // ...
      transitions: [
        defineTransition({
          name: 'to-approved',
          title: 'Approve and finish',
          to: 'approved',
          // Waits for this visit's run to settle. Reads the status, not the value.
          when: "$allActivitiesDone && $effectStatus['notify-reviewer'] == 'done'",
        }),
      ],
    }),
```

If an editor fires `approve` before the effect is drained, the instance stays in `review`. The drain settles `notify-reviewer`, re-evaluates the transitions, and moves the instance to `approved` in the same call.

A later binding reads the value as `$effects['notify-reviewer'].messageId`. Bindings resolve when their effect is queued, so the value is `null` if `notify-reviewer` hasn't settled by then.

Effect bindings are conditions too, evaluated over the same scope with a narrower set of variables. [Conditions](https://www.sanity.io/docs/workflows/conditions) lists what binds where.

## Next steps

- Look up the effect declaration, retry policy, runtime configuration, leases, handler context, and runtime methods in [Engine, effects, and insights reference](https://www.sanity.io/docs/workflows/engine).
- Browse the TypeScript signatures in [@sanity/workflow-engine](https://reference.sanity.io/_sanity/workflow-engine/) on reference.sanity.io.
- Host your effect drainer and scheduled tick worker in [Run Workflows with Sanity Functions](https://www.sanity.io/docs/workflows/sanity-functions).
- Advance instances when time passes with [Schedule time-based workflow checks](https://www.sanity.io/docs/workflows/sanity-functions).
- Read how a runtime calls the engine in [Runtimes and engine verbs](https://www.sanity.io/docs/workflows/actors-and-enforcement).

> [!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).



