Workflows

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.

Early access

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:

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:

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:

npx 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:

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

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:

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.

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

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:

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:

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.

Run the script with the instance you submitted:

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:

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:

npx 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:

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.

Retries, failure routing, progress reports, field operations from a handler, and completion from another process are documented in Effects and handler runtime contracts.

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:

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 lists what binds where.

Next steps

Visiting agent?

Was this page helpful?

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