Reactive workflow adapter over the App SDK store. useWorkflowSession
drives an @sanity/workflow-engine session from getDocumentState
(optimistic, per-doc) and returns
{evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}.
useWorkflowInstances / useDocumentWorkflows are live instance lists
(filterable collection / per-document discovery) read from the engine's state
dataset via getQueryState, each returning {instances, loading, unreadable, error} —
rows the read discipline refuses are skipped from instances and flagged in
unreadable, so one bad row can't sink the listing.
The session's invalid is the explicit validation state (a model-ahead doc
needs a newer @sanity/workflow-engine; a shape violation is a corrupt
stored tree) — surfaced as state instead of a throw, never ready while set.
SDK state-source throws are normalized into the same reactive observer state;
error clears when a later live emission succeeds.
const engine = useMemo(() => createEngine({client, workflowResource, tag}), [client])
const {evaluation, ready, guards, fireAction} = useWorkflowSession({engine, instanceId})
const {instances, loading} = useDocumentWorkflows({engine, document: subjectGdrUri})
sdkProjectUserDirectory(sdk, projectId) implements the engine's
ProjectUserDirectory contract with App SDK resolveUser. Successful lookups
return the complete native SanityUser; missing and inaccessible users remain
distinct outcomes. Pass the directory to resolveActor from
@sanity/workflow-engine in UI code or effect handlers.
loadProjectUserProfiles is available from the headless
@sanity/workflow-sdk/project-users entry for adapters that already have
project membership IDs. It coalesces profile reads into requests of at most
400 IDs and caches successful profiles per client and project. Missing rows
are omitted, while malformed project-user rows reject the batch.
loadProjectRolesOrNone sits at the same entry and reads the project's
assignable roles — the only account of a role nobody currently holds, and the
authority on a role's title. It keeps roles a person can be assigned and drops
the token-only ones, memoizes one read per client and project, and reports an
unreadable catalog as an empty list with a console warning rather than throwing,
so a project that withholds the read keeps working assignment controls.
useProjectMembers(projectId) combines that bounded loader with the App SDK
project membership list and the role catalog. Its rows use each profile's
account-global sanityUserId, which is the only user-ID namespace accepted by
workflow assignment writes; project roles and display fields remain
project-specific. The returned state carries members and roles together,
which is the RoleVocabulary the shared components resolve role labels through.
| Stream | Support |
|---|---|
| Instance doc | ✅ Observed in the engine's own state dataset via a per-resource handle derived from engine.workflowResource — routinely a dedicated workflows dataset, NOT the app's default resource |
Watch-set content docs — dataset, media-library, canvas |
✅ Routed per-doc via the handle's resource field (dataset: → {projectId, dataset}, media-library: → {mediaLibraryId}, canvas: → {canvasId}) — the field the SDK store's binder keys on for both the per-doc and query-store reads (top-level projectId/dataset alone would fetch once but bind the live listener to the default resource). Verified live end-to-end (staging) for the dataset case: an instance in production with its subject in another dataset re-evaluated on a foreign-doc patch, a same-_id production decoy proving no mis-route; ML/Canvas ride the identical resource-handle path the Canvas app uses to observe its own docs. Auth caveat: the instance's auth must cover the resource |
Watch-set refs with the dashboard: scheme |
❌ Throws — not observable reactively. @sanity/sdk's DocumentResource union (dataset | media-library | canvas) has no dashboard variant, so the store can't address one. A dashboard: ref throws as the session builds its observation set, so a workflow with a dashboard subject cannot be driven through a reactive session — run it engine-direct (CLI / MCP / server), where the engine reads dashboard docs correctly on tick/commit via resourceClients. (@sanity/client supports a dashboard resource; the gap is the App SDK store — worth SDK feedback. Degrading the reactive path to skip an unaddressable ref instead of throwing is a tracked follow-up.) |
Guards (observeGuards) |
✅ Fanned across every dataset the instance's currently-observed GDRs name (engine dataset ∪ each open-stage/workflow-scope subject dataset) via a per-resource getQueryState, merged by combineGuardStores — a guard co-locates with the subject it locks, so this reads guards where they live, not only the engine dataset. With sharedIds (the consolidation seam), every co-mounted session subscribes one canonicalized set-shaped query per resource — deduped into a single live query by the SDK query store's options-keyed state — and each session's rows are sliced back out by sourceInstanceId before validation; a set omitting the session's own instance throws |
| Closed-stage stage-scoped foreign guards / ancestor instances' guards | ❌ Not observed — closed-stage guards are deleted (the engine's all-stages guardsForInstance walks every stage); ancestor guards are known follow-up work |
makeSdkObserver (+ SdkObserverOptions) composes the App SDK–backed
WorkflowObserver from the per-stream stores sdkContentDocStore,
sdkInstanceDocStore, sdkGuardStore and sdkInstancesStore, plus the
preview read path: fetchInstancePreviews (one engine-dataset fetch per
preview page) and listenInstanceChanges — one shared, refcounted upstream
listener per change feed however many stores subscribe, reconnecting with
exponential backoff on a listener error and then emitting a reset event on
which consumers resynchronize the gap. All of the stores live on the headless
./observer entry — the store layer without the React hook; the main
entry exports the hooks
(useWorkflowSession, useWorkflowInstances, useDocumentWorkflows,
useProjectMembers).
The composed observer VALIDATES the engine-owned streams (instance doc,
guards, instance lists) at its boundary; the exported leaf stores stay RAW —
a consumer reusing one directly must wrap it with the
@sanity/workflow-react/observer validated-store helpers, the way
makeSdkObserver and the Studio adapter do.
@sanity/sdk-react is an optional peer: the main entry requires it,
./observer consumers don't. @sanity/workflow-studio delegates every ref
outside its mounted workspace dataset (foreign content refs, and the engine
dataset when it differs from the workspace) to these stores via ./observer.
The App SDK's telemetry channel is closed to libraries, so this package
ships its own shell: mount WorkflowTelemetryProvider inside your SDK
providers and the workflow hooks gain a telemetry channel — without it
nothing is emitted. The shell replicates the SDK's policy: events flow only
on Sanity-controlled hostnames (localhost, *.sanity.studio,
*.sanity.io — apps on customer domains create no store at all), only with
an authenticated client, and only when the account's telemetry consent is
granted. useWorkflowTelemetry() (re-exported from @sanity/workflow-react)
reads the same logger for createEngine({telemetry}).
<WorkflowTelemetryProvider>
<MyWorkflowSurfaces />
</WorkflowTelemetryProvider>
getDocumentState), not
useQuery/Live API — committed-only reads tear against an optimistically
read instance.@sanity/workflow-react's README for the guard-verdict semantics
(what mutation-guard-denied does and doesn't cover) and the editField
commit caveat (discrete commits — never bind to an onChange per
keystroke).