Reactive workflow adapter for Sanity Studio. Everything must render inside
Studio source context (useSource / useClient).
useWorkflowEngine({workflowResource, tag, resourceClients?}) —
build the Studio-side engine once: the workspace client rebound to the
engine's state dataset (engine traffic always rides the engine's own API
version — createEngine pins it), with studioResourceClients (also
exported) as the default per-resource routing that reaches any dataset:
GDR by rebinding the same client, pinned to the engine version too. A custom resourceClients replaces the default — delegate
to studioResourceClients for the dataset arm; schemes it returns
undefined for (media-library, canvas) are READ by the engine itself,
which derives a sibling client from the engine client's credentials.
Writing a runtime-supplied ref to such a scheme into field state
additionally needs the resolver to SERVE that resource — serving is what
declares it on the engine's written-ref surface; an undeclared
media-library/canvas ref is rejected at the write with
RefResourceUndeclaredError. (Definition content is unaffected — deploy
vets it.)useWorkflowSession — drives an engine session for one instance and
returns
{evaluation, ready, invalid, error, guards, tick, fireAction, editField, previewField, discardFieldPreview}.
The observer validates every engine-owned stream, so a model-ahead or
corrupt doc surfaces as the explicit invalid state (never ready while
set) instead of an eternal spinner.useWorkflowInstances / useDocumentWorkflows — live instance lists
(filterable collection / per-document discovery), each returning
{instances, loading, unreadable, error} through the App SDK query store —
rows the read discipline refuses are skipped from instances and flagged
in unreadable. Hooks under one Studio source share the auto-bootstrapped
SDK instance; callers can instead pass a shared sdk explicitly. SDK store
errors surface reactively through error.useWorkflowInstancePreviews — useInstancePreviews (workflow-react) bound
to the auto-bootstrapped observer: the whole tag partition as light preview
rows, drained page by page and kept fresh from the shared change feed.
Returns {previews, loading, drained, unreadable, error}.useStudioProjectUsers — the shared Studio project-user directory used by
actor resolution and member pickers. It returns the upstream membership and
profile records without a Workflows projection, the project's
assignable roles, and the engine's ProjectUserDirectory integration. The
role catalog is read alongside the memberships and is the only account of a
role nobody currently holds; it is filtered to roles a person can be assigned,
and it is empty when the read is unavailable, which leaves role labels to the
roles members hold and is reported once on the console with its cause.
Concurrent loads are coalesced, the settled successful directory is reused for
the client/project lifetime, and a failed membership load retries after a
30-second backoff. That backoff does not cover the catalog: an unavailable one
is held empty for the client's lifetime rather than re-read. A directory that has not
answered reports loading, including before its fetch starts (the fetch
begins in a consumer's effect), so an empty users with loading: false
and no error is a confirmed "no members" rather than a directory nobody
has asked for. studioProjectUserDirectory
provides the same contract outside React when a Studio client and project ID
are already available.Every reactive stream uses the App SDK store, including documents in the mounted workspace, instance documents in the engine dataset, per-resource guard queries, and instance lists. Document and query observation therefore share the SDK's resource-scoped live streams instead of opening one Studio listener per document or query.
Dataset GDRs route through {projectId, dataset} resource handles. canvas:
and media-library: GDRs route through {canvasId} and {mediaLibraryId}.
dashboard: remains unsupported because the App SDK has no dashboard document
resource; a workflow with a dashboard subject must run engine-direct.
The SDK store does not expose Studio's private, uncommitted form buffer.
Reactive evaluation follows App SDK document state; Content Lake remains the
authoritative state read by commits. For an explicit uncommitted projection,
call the session's previewField callback and remove it with
discardFieldPreview; these previews never persist the instance or content
document.
sdk is supplied, the hook bootstraps an App SDK instance
automatically in Studio mode. The SDK follows Studio's reactive token
source, including token rotation; an authenticated Studio whose token source
emits null uses cookie authentication. Callers may instead supply their own
instance: useWorkflowSession({engine, instanceId, sdk}).observeGuards) are observed wherever they live: a guard
co-locates with the subject it locks, so the stream spans the datasets the
instance's currently-observed GDRs name (engine dataset ∪ each
open-stage/workflow-scope subject dataset). Every resource routes through
the App SDK query store.makeStudioObserver(sdk, {client, engineResource}) is exported for custom
wiring, matching makeSdkObserver. client is
required: a workspace-bound client that carries ensureDocumentExists
writes into the mounted dataset.Remaining gaps (ancestor-instance guards, full cascade write-set pre-flight, live cross-dataset end-to-end verification) are known follow-up work.
This adapter composes @sanity/workflow-sdk's store layer through its headless
./observer entry. What that means for installs:
@sanity/sdk is required (peer) — it addresses every observed resource.@sanity/workflow-engine is required (peer) — the host and adapter share one engine runtime.@sanity/workflow-react and @sanity/workflow-sdk are required (exact-version peers) — install the matching Workflows release stack so the reactive context and SDK store are single-sourced.@sanity/sdk-react is NOT required — nothing here touches the App
SDK's React binding.useSource remains a non-public Studio context hook, which is the standard
plugin-context caveat.
@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).