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
Workflows is in early access, built in public. Read How early access works 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:
article-reviewdeployed, and asanity.workflow.tsthat exports thedevdeployment. scripts/engine.tsfrom Run the engine from your own code, and the token it reads fromSANITY_AUTH_TOKEN.- The
_idof the published article from the quick start. - Optional: Define activities, actions, and operations covers
whentriggers and field operations. Write GROQ 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:
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 deploypnpm dlx sanity-workflows deployyarn dlx sanity-workflows deploybunx sanity-workflows deployAn 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_IDpnpm 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_IDyarn 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_IDbunx 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_IDReplace 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_IDThe 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-7d0d377ba7d8Verify the settled effect
Check the instance from the CLI:
npx sanity-workflows show INSTANCE_ID --include historypnpm dlx sanity-workflows show INSTANCE_ID --include historyyarn dlx sanity-workflows show INSTANCE_ID --include historybunx sanity-workflows show INSTANCE_ID --include historyThe 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
- Look up the effect declaration, retry policy, runtime configuration, leases, handler context, and runtime methods in Engine, effects, and insights reference.
- Browse the TypeScript signatures in @sanity/workflow-engine on reference.sanity.io.
- Host your effect drainer and scheduled tick worker in Run Workflows with Sanity Functions.
- Advance instances when time passes with Schedule time-based workflow checks.
- Read how a runtime calls the engine in Runtimes and engine verbs.
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?