Sanity Library Reference Docs
    Preparing search index...

    Module @sanity/workflow-sdk - v0.32.0

    @sanity/workflow-sdk

    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>
    • The source is the optimistic per-doc store (getDocumentState), not useQuery/Live API — committed-only reads tear against an optimistically read instance.
    • See @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).

    Modules

    index
    observer
    project-users