Workflows

Run Workflows with Sanity Functions

Use GROQ-triggered and scheduled Sanity Functions to start workflows, reevaluate conditions, and process queued effects.

Prerelease

Use Sanity Functions to call the Workflows engine without maintaining a server.

A Document Function responds to GROQ-filtered document changes. A Scheduled Function reevaluates workflows as time passes. An effect is external work queued by a workflow; a Function with the required handlers runs it.

Choose a Function for each use case

Start with the event that should run your code: a content change or a schedule. Inside the Function, call the engine operation that produces the required workflow outcome. The sections below cover common pairings. The Engine reference lists every operation.

Start a workflow when content becomes eligible

Use a Document Function whose GROQ filter matches the change that makes a document eligible. Call startInstance() to create an instance, the stored run of that workflow. Filter on the qualifying change instead of every edit. Only startInstance() creates an instance. If the Function misses that document change, a later tick() call cannot create it.

Reevaluate after relevant content changes

Use a Document Function when an existing instance must respond promptly after a field used by a workflow condition changes. Filter on that field delta, find the affected instances, and call tick() once per instance. If an immediate response is unnecessary, omit this Function. The Scheduled Function described below can call tick() periodically instead.

Reevaluate when time passes

Time passing does not create a document event. To reevaluate conditions that depend on time, run a Scheduled Function on a cron schedule. On each invocation, query in-flight instances and call tick() once for each one. The cron interval is the maximum delay before a time-based condition or transition is reevaluated.

Handle an explicit workflow event

An action is a named event accepted by a workflow, such as approval or rejection. When a document change or schedule should send that event, run the matching Function and call fireAction(). Use fireAction() only for actions that do not declare a when condition. An action with when fires automatically the next time another engine operation reevaluates the instance and finds the condition true.

Run queued work outside the engine

An effect is external work queued by a workflow. The engine records the work; a registered effect handler performs it. To run new work promptly, use a Document Function triggered when the number of unclaimed effects increases. Inside that Function, call drainEffects(). This guide calls a Function that invokes drainEffects() an effect drainer. If a Scheduled Function already calls tick() for the same instances and registers the required handlers, it can call drainEffects() in the same invocation instead. That design accepts the schedule interval as the delay before queued work runs. There is no combined tick-and-drain operation.

Register together only the handlers that can share credentials, dependencies, timeout budget, scaling, and ownership. drainEffects() selects a handler by the effect name and continues until no registered work can be claimed. Use separate drainers when one of those runtime boundaries differs.

The rest of this guide implements two independent triggers. A Document Function drains effects as soon as work is queued. A Scheduled Function periodically ticks in-flight instances.

Keep document triggers narrow

Engine operations mutate workflow instance documents when they claim work, record outcomes, append history, or move between stages. A Document Function can therefore react to mutations produced by its own previous call.

Filter for the smallest document delta that makes the engine operation useful. Broad filters spend invocations and reads on no-op calls. They can also keep waking the same Function as its previous invocation updates the instance.

For an effect drainer, run only when the number of unclaimed pending effects increases:

_type == "sanity.workflow.instance" &&
tag == "prod" &&
count(after().pendingEffects[!defined(claim)]) >
coalesce(count(before().pendingEffects[!defined(claim)]), 0)

The comparison between after() and before() detects newly available work. Claiming or completing an existing effect does not increase the count, so those mutations do not wake the Function again.

The coalesce(..., 0) fallback also handles a newly created instance that already contains pending work. A filter that only checks whether pending work exists is simpler, but unrelated instance mutations can then cause repeated invocations while the work remains.

Deploy the default runtime

The Blueprint below binds the effect drainer to its GROQ event and the Scheduled Function to its cron schedule. Both Functions use the same robot token. Scheduled Functions are organization-scoped and do not receive a target project or dataset in event context, so the Blueprint passes those values as environment variables. Follow Define a robot token with Blueprints for the token resource and membership fields.

// sanity.blueprint.ts
import {
  defineBlueprint,
  defineDocumentFunction,
  defineRobotToken,
  defineScheduledFunction,
} from '@sanity/blueprints'

const projectId = process.env.SANITY_PROJECT_ID ?? ''
const dataset = process.env.SANITY_DATASET ?? 'workflows'
const robotToken = '$.resources.wf-prod-runtime.token'

export default defineBlueprint({
  resources: [
    defineRobotToken({
      name: 'wf-prod-runtime',
      label: 'Workflows runtime',
      memberships: [
        {resourceType: 'project', resourceId: projectId, roleNames: ['editor']},
      ],
    }),
    defineDocumentFunction({
      name: 'wf-prod-drain-effects',
      src: './functions/wf-prod-drain-effects',
      project: projectId,
      robotToken,
      event: {
        on: ['create', 'update'],
        filter:
          '_type == "sanity.workflow.instance" && tag == "prod" && ' +
          'count(after().pendingEffects[!defined(claim)]) > ' +
          'coalesce(count(before().pendingEffects[!defined(claim)]), 0)',
        projection: '{_id}',
        resource: {type: 'dataset', id: `${projectId}.${dataset}`},
      },
    }),
    defineScheduledFunction({
      name: 'wf-prod-tick-instances',
      src: './functions/wf-prod-tick-instances',
      event: {expression: '* * * * *'},
      robotToken,
      env: {
        SANITY_PROJECT_ID: projectId,
        SANITY_DATASET: dataset,
      },
    }),
  ],
})

Keep the tag identical in the drainer trigger, engine configuration, and Scheduled Function query. Set the cron expression to the maximum delay your time-based workflow decisions can tolerate and that your plan supports.

Drain effects when work is queued

The effectHandlers object maps each effect name to the code that performs it. When unclaimed work appears, the Function calls drainEffects() once for the instance in the event.

// functions/wf-prod-drain-effects/index.ts
import {createClient} from '@sanity/client'
import {documentEventHandler} from '@sanity/functions'
import {createEngine, ENGINE_API_VERSION} from '@sanity/workflow-engine'

import {effectHandlers} from '../../effect-handlers'

interface WorkflowEvent {
  _id: string
}

export const handler = documentEventHandler<WorkflowEvent>(
  async ({context, event}) => {
    const projectId = context.clientOptions.projectId
    const dataset = context.clientOptions.dataset
    if (!projectId || !dataset) {
      throw new Error('The Function event has no project or dataset')
    }

    const client = createClient({
      ...context.clientOptions,
      projectId,
      dataset,
      apiVersion: ENGINE_API_VERSION,
      perspective: 'raw',
      useCdn: false,
    })
    const engine = createEngine({
      client,
      workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
      tag: 'prod',
      executionContext: {kind: 'drainer', id: 'wf-prod-drain-effects'},
      effectHandlers,
    })

    await engine.drainEffects({instanceId: event.data._id})
  },
)

One drainEffects() call continues until no effect registered by this Function can be claimed. After each handler completes, the engine reevaluates automatic transitions. Effects queued during that reevaluation are available to the same drain.

Do not add an outer loop or call tick() after the drain. Those calls repeat reads and cascade evaluation without advancing the effect queue.

A drain may run several handlers, so budget for their combined duration. A handler may receive the same effect more than once. Use ctx.effectKey as the idempotency key for any external write. Use separate drainers before their combined work approaches the Function timeout.

Tick workflows on a schedule

This Scheduled Function is needed because time passing does not create a document event. On each cron invocation, it queries the in-flight instances for this tag and calls tick() once for each one. tick() runs automatic progression until the instance is stable and persists the result. You do not need a Document Function merely to react to those writes.

The example first calls sweepStaleClaims(). It releases ownership left by a handler that stopped before recording an outcome. Releasing the claim increases the unclaimed-work count and wakes the effect drainer.

// functions/wf-prod-tick-instances/index.ts
import {createClient} from '@sanity/client'
import {scheduledEventHandler} from '@sanity/functions'
import {
  createEngine,
  ENGINE_API_VERSION,
  errorMessage,
  instancesQuery,
  sweepStaleClaims,
} from '@sanity/workflow-engine'

export const handler = scheduledEventHandler(async ({context}) => {
  const projectId = process.env.SANITY_PROJECT_ID
  const dataset = process.env.SANITY_DATASET
  if (!projectId || !dataset) {
    throw new Error('The Scheduled Function requires SANITY_PROJECT_ID and SANITY_DATASET')
  }

  const client = createClient({
    ...context.clientOptions,
    projectId,
    dataset,
    apiVersion: ENGINE_API_VERSION,
    perspective: 'raw',
    useCdn: false,
  })
  const executionContext = {kind: 'server', id: 'wf-prod-tick-instances'} as const
  const engine = createEngine({
    client,
    workflowResource: {type: 'dataset', id: `${projectId}.${dataset}`},
    tag: 'prod',
    executionContext,
  })

  const {query, params} = instancesQuery({tag: 'prod'})
  const instances = await client.fetch<Array<{_id: string}>>(
    `${query}{_id}`,
    params,
  )

  let failed = 0
  for (const {_id} of instances) {
    try {
      await sweepStaleClaims({
        client,
        tag: 'prod',
        instanceId: _id,
        executionContext,
      })
      await engine.tick({instanceId: _id})
    } catch (error) {
      failed += 1
      console.error(`Scheduled tick failed for ${_id}: ${errorMessage(error)}`)
    }
  }

  if (instances.length > 0 && failed === instances.length) {
    throw new Error('Scheduled tick failed for every in-flight instance')
  }
})

tick() does not run effect handlers. If this Scheduled Function also owns the handlers, register them on its engine and call drainEffects() after each tick(). This runs queued effects in the same invocation, but checks for effects on every schedule.

Handle each instance independently so one failure does not stop the Scheduled Function from ticking the rest. Fail the Function invocation when every attempted instance fails so the problem remains visible in logs.

Test and deploy

  • Use Testing functions locally to exercise both handlers with representative payloads and credentials. Invoke each effect handler more than once with the same ctx.effectKey and confirm that it does not repeat the external write.
  • Deploy to a non-production stack and verify that the document trigger detects new work with GROQ before() and after(). Local Function test commands cannot evaluate these delta functions. See GROQ feature support across Sanity.
  • Confirm that new work invokes the drainer, claim and completion mutations do not create an invocation loop, work left claimed by an interrupted handler becomes available to the drainer again, and a scheduled tick that makes no state change does not write.

Use the Functions cheat sheet for deployment and log commands.

Was this page helpful?