Global document references
Why every document pointer in a workflow carries its location, and how resource aliases keep deployed definitions portable across environments.
Prerelease
Editorial Workflows is a prerelease, built in public. Read How the prerelease works before you rely on it.
A workflow’s own documents (the instance, its guards) live in one place, but the content it governs often lives somewhere else: another dataset, another project, sometimes Canvas or the Media Library. A normal Sanity reference (_ref) only points within a single dataset, so it cannot cross that boundary. The engine works across a whole organization, so everywhere it points at a document it uses a global document reference, a GDR: a reference that carries both which document and where it lives.
One workflow across resources
A launch review can keep its workflow data in one project while the content it coordinates remains in other Sanity resources.
- Workflow data: the workflows dataset in the operations project.
- Editorial content: the production dataset in the web project.
- Campaign assets: the mlcampaign Media Library.
Configure the engine against the workflow resource. The resourceClients resolver admits the two foreign resources and routes their reads.
import {createClient} from '@sanity/client'
import {
createEngine,
ENGINE_API_VERSION,
type ParsedGdr,
} from '@sanity/workflow-engine'
const token = process.env.SANITY_AUTH_TOKEN!
const workflowClient = createClient({
projectId: 'opsproject',
dataset: 'workflows',
apiVersion: ENGINE_API_VERSION,
token,
useCdn: false,
})
const editorialClient = createClient({
projectId: 'webproject',
dataset: 'production',
apiVersion: ENGINE_API_VERSION,
token,
useCdn: false,
})
const mediaClient = createClient({
resource: {type: 'media-library', id: 'mlcampaign'},
apiVersion: ENGINE_API_VERSION,
token,
useCdn: false,
})
export const engine = createEngine({
client: workflowClient,
workflowResource: {type: 'dataset', id: 'opsproject.workflows'},
tag: 'production',
resourceClients: (resource: ParsedGdr) => {
if (
resource.scheme === 'dataset' &&
resource.projectId === 'webproject' &&
resource.dataset === 'production'
) {
return editorialClient
}
if (
resource.scheme === 'media-library' &&
resource.resourceId === 'mlcampaign'
) {
return mediaClient
}
return undefined
},
})The deployed launch-review definition declares subject and heroImage as input fields. Start it with references that preserve each document’s resource identity.
import {
refDataset,
refMediaLibrary,
} from '@sanity/workflow-engine'
import {engine} from './engine'
const article = refDataset({
projectId: 'webproject',
dataset: 'production',
documentId: 'launch-article',
type: 'article',
})
const heroImage = refMediaLibrary({
resourceId: 'mlcampaign',
documentId: 'image-1',
type: 'sanity.asset',
})
await engine.startInstance({
definition: 'launch-review',
initialFields: [
{type: 'subject', name: 'subject', value: article},
{type: 'doc.ref', name: 'heroImage', value: heroImage},
],
})The workflow stores the references, not copies of the content. Conditions and effects can read both documents through the configured resource clients.
The GDR shape
A GDR is a small object, an id and a type:
import {refDataset} from '@sanity/workflow-engine'
// an article in the "production" dataset of project "yourprojectid"
const subject = refDataset({
projectId: 'yourprojectid',
dataset: 'production',
documentId: 'article-1',
type: 'article',
})
// → { id: 'dataset:yourprojectid:production:article-1', type: 'article' }That id is not a bare document id. Everywhere else in Sanity a reference is bare, a document’s _id or the _ref inside a reference, like "article-1". A GDR’s id carries the resource prefix, because the engine has to know which project and dataset (or which Canvas, Media Library, or Dashboard resource) holds the document. The scheme leads:
// A GDR id is a typed URI:
// dataset:<projectId>:<dataset>:<documentId>
// canvas:<resourceId>:<documentId>
// media-library:<resourceId>:<documentId>
// dashboard:<resourceId>:<documentId>
export type GdrScheme = 'dataset' | 'canvas' | 'media-library' | 'dashboard'
export type GdrUri = `${GdrScheme}:${string}`The type is the document’s schema type, carried so a reader knows what it points at without a second lookup.
Build one with the per-scheme constructors (refDataset, refCanvas, refMediaLibrary, refDashboard), and call extractDocumentId to get the bare _id back when you need it (for a *[_id == $id] query, say). A bare id passed where a GDR belongs is rejected at compile time, so a location-less id can never reach a filter or a stored field. The one deliberate exception: a literal initialValue seed in a definition may name a bare id, and the engine roots it at the workflow’s home resource when the instance materializes the field.
Use stable dataset document IDs
A dataset GDR identifies the logical document. Pass its stable ID, such as article-1. Do not pass drafts.article-1 or versions.<release>.article-1. The workflow perspective selects the draft, published document, or release version to read.
You meet global document references wherever a workflow names a document: the subject passed at start, doc.ref and doc.refs fields, release.ref, and the ancestor chain. A start.filter reads the loaded candidate document. Start requirements read the caller-supplied input fields.
Compare global document references directly; do not assemble resource-qualified strings. Use a single-subject start requirement when only one unfinished run of the same definition may exist for a subject. See Conditions for start filters, requirements, and their available values.
In the rendered scope the instance refers to itself as $self, its own GDR. That location-carrying id is also what lets a workflow read content it does not live next to: when a filter dereferences a subject in another dataset, the engine routes the read to a client for that GDR’s resource. A resourceClients resolver you give createEngine wins when it returns one; otherwise the engine derives a sibling client from the workflow client’s own credentials. A single-dataset setup never notices; a cross-dataset or cross-project one works with zero extra wiring on an org-capable token. You add resourceClients for two reasons: to give a foreign resource different credentials, and to declare it as part of the write surface, since a runtime-supplied ref may only target a resource resourceClients serves. A read-only derived sibling does not declare it. A client that cannot derive siblings makes a foreign GDR fail loudly instead of reading the wrong place.
The same shape flows into effects. When you bind a referenced id into an effect (bindings: {assetId: '$fields.asset._id'}), the value the handler receives is that document’s GDR URI, which it turns into a bare id with extractDocumentId. And $fields.asset._id resolves from the stored reference itself, so identity reads never load the referenced document at all. Content reads ($fields.asset.title, say) dereference into the loaded document, and the engine reaches the Media Library by deriving a sibling client from the workflow client’s own credentials; wire resourceClients when that resource needs different credentials. You must also serve the resource to declare it as a write target for Media Library refs.
The engine exposes GDRs directly because it is organization-wide by design. Writing a doc.ref value or wiring up resourceClients means working with GDRs explicitly.
Runtime-supplied refs are checked against that declared surface before they enter field state. A doc.ref, doc.refs, or release.ref value can arrive from a start initialFields value, an action’s param-sourced op value, an editField value, or an effect-completion op. It must target the workflow’s own resource or a resource resourceClients serves; a ref to any other resource aborts the commit with RefResourceUndeclaredError, and nothing is written. Refs the deployed definition already carries are exempt, because deploy vetted them: literal initialValue seeds, type: 'query' texts, spawn with projections, and an op value that binds no parameter. Like every engine check, this is advisory. The Content Lake is the only enforcement point. This check makes a misdirected ref fail at the write that introduces it instead of at a later read.
Resource aliases
Resource aliases make references portable. A GDR is physical: it names an exact project and dataset (or resource). For a definition you deploy across environments, you rarely want that baked in. So a definition can reference content through a named alias instead (@content: followed by the document id) and bind that alias to a physical resource at deploy time: deployDefinitions({expectedMinReaderModel: 4, definitions, resourceAliases: {content: {type: 'dataset', id: 'yourprojectid.production'}}}). Every @content: reference expands to a physical GDR at deploy, so the deployed definition holds only physical refs, and moving to another environment is deploying the same source with a different map. Bare ids and physical GDRs are unchanged, so a single-resource setup needs no aliases at all.
Next steps
- Concepts: the core model these references plug into.
- Effects and runtimes: how GDR-valued bindings reach effect handlers.
- Reference: every construct spelled out exactly, each field, type, default, and option.
Visiting agent?
Editorial Workflows includes an MCP server for inspecting, operating, authoring, validating, and deploying workflows. Ask your human to set up the MCP server.