Workflows

Coordinate content across projects and datasets

Run one workflow over content that lives in other projects, datasets, Media Libraries, or Canvas, and prove the routing before you rely on it.

One workflow can govern content that lives somewhere other than the workflow itself. Instances and their history sit in one resource. The documents they coordinate can sit in other datasets, other projects, a Media Library, or Canvas. This guide starts an article-review instance on an article in a second dataset, and shows how to prove the routing works before you rely on it.

The failure this guide is written against is a quiet one: a reference that names a resource your deployment never declared. Reads and writes treat that case differently, so a setup that looks correct while you browse can still reject a write later. Verify the routing shows you which one you have.

You need:

  • The finished quick start: article-review deployed, and a sanity.workflow.ts that exports the dev deployment.
  • A second dataset named marketing in the same project, with one published article document. Note its _id. Create the dataset with npx sanity@latest dataset create marketing.
  • To start instances from your own code: the scripts/engine.ts module from Run the engine from your own code.

For the model underneath the config on this page, how a deployment binds a workflowResource, what a partition partitions, and how each @<handle>: reference resolves at deploy, see Deployments, partitions, and resources.

Decide where workflow data lives and what it acts on

Two decisions come first, and they are independent. The workflow resource is where instances, history, and guards are stored. The content resources are where the documents a workflow governs already live. Nothing requires them to be the same place, and picking one workflow resource for an organization is a reasonable way to keep operational data out of editorial datasets.

In this guide, article-review spans two datasets in one project:

  • Workflow data: the quick start dataset, DATASET_NAME. The dev deployment keeps its instances there.
  • Editorial content: the marketing dataset, which holds the article to review.

The engine is configured against the workflow resource and reaches the marketing dataset through references. It never copies content: the instance's subject field holds a global document reference that names the marketing dataset, so the content stays where its own editors and permissions already are. A dataset in another project, a Media Library, or Canvas works the same way.

Bind the content dataset with a resource alias

A global document reference names an exact project and dataset, or an exact resource. Baked into a definition, that pins the definition to one environment. A resource alias avoids that. A definition references content as @<alias>:<documentId>, and each deployment binds the alias to a real resource.

Alias names are lowercase letters, digits, and dashes, with no leading dash. resourceAliases is a list of bindings on a deployment, and a duplicate name is rejected when the config parses. Add a binding for the marketing dataset to the dev deployment in sanity.workflow.ts:

import type {WorkflowDeploymentInput} from '@sanity/workflow-engine'
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'

import {articleReview} from './workflows/article-review'

export const dev = {
  name: 'dev',
  partition: 'dev',
  expectedMinReaderModel: 10,
  workflowResource: {type: 'dataset', id: 'PROJECT_ID.DATASET_NAME'},
  resourceAliases: [{name: 'marketing', resource: {type: 'dataset', id: 'PROJECT_ID.marketing'}}],
  definitions: [articleReview],
} satisfies WorkflowDeploymentInput

export default defineWorkflowConfig({deployments: [dev]})

Aliases are expanded at deploy, so a deployed definition holds only physical references. To bind the same alias to a different dataset in each environment, see Run the same workflow in more than one place.

article-review has no @marketing: references, so there is nothing to redeploy. The CLI reads the binding from sanity.workflow.ts each time it runs, and expectedMinReaderModel stays at 10. The binding is also what lets the CLI accept a reference into marketing, even though no definition uses the alias.

Start an instance on the marketing article

Start article-review with a subject in the marketing dataset. Replace DOCUMENT_ID with the _id of the marketing article:

npx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:marketing:DOCUMENT_ID","type":"article"}'

The instance is created in the quick start dataset, under dev, and its subject points at the marketing dataset. The start passes the write-time check because dev binds that dataset:

Started dev.wf-instance.a1b2c3d4e5f6 — now at drafting

Fire the first action. The subject field is required, so the engine reads the marketing article before it lets the action fire:

npx sanity-workflows fire-action INSTANCE_ID --activity write --action submit

Confirm the result:

npx sanity-workflows show INSTANCE_ID

show reports stage review and partition dev. The instance could only leave drafting by reading the article in marketing, so the read routed correctly. Add --json to see the stored subject, dataset:PROJECT_ID:marketing:DOCUMENT_ID.

Route foreign resources to their own clients

The CLI builds its clients from your config. An engine you create in your own code reads a document in another resource through a client derived from its own, with the same token. It accepts a reference in a write only when its resourceClients resolver serves that resource. Read routing and the write surface explains why the two differ.

Starting an instance on the marketing article writes a reference. When one token can reach every resource, extend scripts/engine.ts from Run the engine from your own code with deploymentResourceClients, which serves every resource the deployment binds in resourceAliases:

import {
  clientConfigFromResource,
  createEngine,
  deploymentResourceClients,
  ENGINE_API_VERSION,
} from '@sanity/workflow-engine'

// ...the client stays as it is...

export const engine = createEngine({
  client,
  workflowResource: dev.workflowResource,
  partition: dev.partition,
  resourceClients: deploymentResourceClients({client, deployment: dev}),
})

Write your own resolver when the derived sibling is not enough: a foreign resource that needs a different token, a different API host, or any config the workflow client cannot supply by changing resource alone. The resolver receives the parsed reference and returns a client or undefined. Returning undefined leaves that resource off the write surface. This version gives the marketing dataset its own token, and matches on the parsed reference with sameResource rather than comparing assembled strings:

import {
  clientConfigFromResource,
  createEngine,
  ENGINE_API_VERSION,
  resourceFromParsed,
  sameResource,
} from '@sanity/workflow-engine'

// ...the client stays as it is...

const marketing = {type: 'dataset', id: 'PROJECT_ID.marketing'} as const

// The marketing dataset needs its own token, so a derived sibling is not enough.
const marketingClient = client.withConfig({
  ...clientConfigFromResource(marketing),
  token: process.env.MARKETING_TOKEN,
})

export const engine = createEngine({
  client,
  workflowResource: dev.workflowResource,
  partition: dev.partition,
  resourceClients: (parsed) =>
    sameResource(resourceFromParsed(parsed), marketing) ? marketingClient : undefined,
})

Pass references that carry their resource

Build a reference with the constructor for its scheme: refDataset, refMediaLibrary, refCanvas, or refDashboard. Each produces a reference that names its own resource, so a value stays unambiguous wherever it travels. Create scripts/start-marketing-review.ts. It imports the engine you extended above and starts an instance on the marketing article:

import {refDataset} from '@sanity/workflow-engine'

import {engine} from './engine'

async function main() {
  const documentId = process.argv[2]
  if (!documentId) {
    throw new Error('Pass the article _id: npx tsx scripts/start-marketing-review.ts DOCUMENT_ID')
  }

  const subject = refDataset({
    projectId: 'PROJECT_ID',
    dataset: 'marketing',
    documentId,
    type: 'article',
  })
  const {instance} = await engine.startInstance({
    definition: 'article-review',
    initialFields: [{type: 'subject', name: 'subject', value: subject}],
  })
  console.log(`Started ${instance._id} at ${instance.currentStage}`)
  console.log(`Subject: ${subject.id}`)
}

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

Run it with the marketing article's published _id, as in the quick start:

SANITY_AUTH_TOKEN=YOUR_TOKEN npx tsx scripts/start-marketing-review.ts DOCUMENT_ID

The script prints the new instance and its subject:

Started dev.wf-instance.a1b2c3d4e5f6 at drafting
Subject: dataset:PROJECT_ID:marketing:DOCUMENT_ID

Fields covers the field types reference values land in, and Effects and runtimes covers reaching them from an effect handler.

Verify the routing

You proved the read side when fire-action moved the instance to review. Writes are checked separately: a reference is accepted only into the workflow resource or a resource the deployment declares (see the write surface). Prove the write side by removing the binding. Comment out the resourceAliases line in sanity.workflow.ts and run the start command again:

npx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:marketing:DOCUMENT_ID","type":"article"}'

The start is rejected before anything is written:

✖ Start rejected
✖ start error:
  Field entry "subject" (subject) rejected: ref "dataset:PROJECT_ID:marketing:DOCUMENT_ID" targets dataset "PROJECT_ID.marketing", which this deployment does not declare — declared surface: the workflow resource (dataset "PROJECT_ID.DATASET_NAME"). Add a resourceClients entry for the target resource, or fix the ref.

Restore the binding. A resource that reads fine and fails on write is the signature of a missing alias binding or resourceClients entry.

Was this page helpful?

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