App SDK
Build an App SDK interface that lists workflow instances, renders live state, explains blocked work, and commits actions and field edits.
Prerelease
Editorial Workflows is a prerelease, built in public. Read How the prerelease works before you rely on it.
This page is for applications outside Studio. To build a Document Action, tool, pane, input, or dialog inside Studio, use Custom Studio integrations.
Build a workflow UI
When the Studio plugin does not fit the interface you need, use @sanity/workflow-sdk to build the workflow experience in an App SDK application. It supplies live workflow state and the engine operations that change it.
This guide follows one application path: connect an instance, render its current work, commit user actions and field edits, then add instance lists. Use Reusable UI components for assignment and date controls. For the underlying live-data model, see The reactive session.
Install the runtime
Install the App SDK adapter with its matching engine and React runtime peers.
npm install @sanity/ui @sanity/workflow-components @sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
pnpm add @sanity/ui @sanity/workflow-components @sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
yarn add @sanity/ui @sanity/workflow-components @sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
bun add @sanity/ui @sanity/workflow-components @sanity/workflow-engine @sanity/workflow-react @sanity/workflow-sdk
Use the same version for every @sanity/workflow-* package.
How workflow data reaches your UI
Reading a workflow instance document is not enough to render its UI. What the current user can see and do also depends on referenced content, guards, and field previews—and those inputs can change while the screen is open.
- The engine loads the instance and evaluates its definition against the content it references.
- The App SDK adapter watches those documents and feeds changes back into the engine.
- The session exposes the latest evaluation: stages, activities, fields, actions, and the reasons they are or are not available.
- Your controls call the session’s commit verbs. Successful commits update the instance and produce a new evaluation.
Create a live session
Create one workflow session for the instance currently open in a detail view. The session keeps that instance’s evaluation current and provides the operations that change it. Pass the engine and instance ID to useWorkflowSession. Use the list hooks later in this guide to discover instances without creating a session for every row.
Do not edit workflow instances as content
Do not use useEditDocument or other content-editing APIs to change workflow instance documents. They bypass engine transactions and can break the audit trail or leave the instance internally inconsistent. Use the session’s operations instead. See Instance writes are transactions.
import {useClient} from '@sanity/sdk-react'
import {createEngine} from '@sanity/workflow-engine'
import {useWorkflowSession} from '@sanity/workflow-sdk'
import {useMemo} from 'react'
const PROJECT_ID = 'yourprojectid'
// The engine's state lives in its own dataset, separate from content.
const ENGINE_DATASET = 'workflows'
function WorkflowPanel({instanceId}: {instanceId: string}) {
const client = useClient({apiVersion: '2026-04-29'})
const engine = useMemo(
() =>
createEngine({
client: client.withConfig({dataset: ENGINE_DATASET}),
workflowResource: {type: 'dataset', id: `${PROJECT_ID}.${ENGINE_DATASET}`},
tag: 'main',
}),
[client],
)
const session = useWorkflowSession({engine, instanceId})
if (session.invalid) return <p>Unreadable workflow: {session.invalid.reason}</p>
if (session.error) return <p>Could not load workflow.</p>
if (!session.ready || !session.evaluation) return <p>Loading…</p>
return <pre>{session.evaluation.instance.currentStage}</pre>
}Mount it inside SanityApp, configured for the dataset your content lives in:
<SanityApp config={[{projectId: PROJECT_ID, dataset: 'production'}]}>
<WorkflowPanel instanceId={instanceId} />
</SanityApp>The App SDK client token identifies the actor and authorizes the session’s commits.
Connect the component inputs
The examples below pass workflow data from one session into presentation components. This map shows where each prop originates.
engine
Engine
Created with createEngine in WorkflowPanel.
instanceId
string
Selected from a row returned by the instance-list hooks, or supplied by application routing.
session
WorkflowSession
Returned by
useWorkflowSessionfor the selected instance.evaluation
WorkflowEvaluation
Read from
session.evaluationafter the session is ready.activity
ActivityEvaluation
One entry from
evaluation.currentStage.activities.action
ActionEvaluation
One entry from
activity.actions.field
EditableFieldEvaluation
One entry from
evaluation.editableFields.members
ProjectMembersState
Loaded by the host integration and passed into the shared presentation component.
fire
function
An application callback that awaits
session.fireActionand surfaces a rejected commit.projectId
string
The project ID from the application’s SanityApp configuration.
onBack / onSelect
function
Application-owned navigation callbacks, shown in the list-to-task example.
Render one workflow
A workflow screen must distinguish “still loading” from “this instance cannot be read.” The session reports that state and exposes the evaluation—a read-only projection of the workflow for the current actor—plus the operations that commit changes.
Handle invalid and error before loading. Render the workflow only when ready is true and an evaluation is available:
function WorkflowDetail({
engine,
instanceId,
onBack,
}: {
engine: Engine
instanceId: string
onBack: () => void
}) {
const session = useWorkflowSession({engine, instanceId})
if (session.invalid)
return <CenteredStatus>Unreadable instance: {session.invalid.reason}</CenteredStatus>
if (session.error)
return <CenteredStatus>Could not load workflow: {errorMessage(session.error)}</CenteredStatus>
if (!session.ready || !session.evaluation)
return (
<CenteredStatus>
<Spinner />
</CenteredStatus>
)
return <ReadyWorkflowDetail evaluation={session.evaluation} onBack={onBack} session={session} />
}Render activities and actions
The authored definition says which actions could exist; it does not tell your UI what is available now. Map evaluation.currentStage.activities, then pass each entry from activity.actions to the action control. The evaluation classifies each action as filtered out, automated, allowed, or blocked:
function ActionButton({
action,
activity,
fire,
}: {
action: ActionEvaluation
activity: string
fire: (activity: string, action: string) => Promise<void>
}) {
const rendering = actionRendering(action)
const title = action.action.title ?? action.action.name
if (rendering === 'absent') return null
if (rendering === 'automation') return <Text muted>{title} runs automatically</Text>
return (
<Button
disabled={!action.allowed}
mode="ghost"
onClick={() => void fire(activity, action.action.name)}
text={title}
/>
)
}Render editable fields
Editing every keystroke as a workflow commit would create a separate history entry for every change. The evaluation’s editableFields identify what the current actor may edit. Use previewField for immediate feedback, then editField when the user deliberately commits:
import {useState} from 'react'
import type {WorkflowSession} from '@sanity/workflow-sdk'
function ScoreField({session}: {session: WorkflowSession}) {
const [draft, setDraft] = useState<number>()
const field = session.evaluation?.editableFields.find((entry) => entry.name === 'score')
if (field === undefined) return null
return (
<input
type="number"
value={draft ?? Number(field.value ?? 0)}
disabled={!field.editable}
onChange={(event) => {
const value = Number(event.target.value)
setDraft(value)
session.previewField({field, value})
}}
onBlur={(event) => {
setDraft(undefined)
void session.editField({field, value: Number(event.target.value)})
}}
/>
)
}Resolve workflow actors
Workflow data stores stable actor IDs, but an interface needs names and profile details. Use sdkProjectUserDirectory to resolve an ID already present in workflow history or assignments:
import {sdkProjectUserDirectory} from '@sanity/workflow-sdk'
const actors = sdkProjectUserDirectory(sdk, projectId)
const actor = await actors.findById(actorId)
if (actor.status === 'resolved') {
renderActor(actor.user)
} else if (actor.status === 'missing') {
renderUnknownActor(actorId)
} else {
renderActorLookupUnavailable()
}Edit project-member assignments
Load the project directory with useProjectMembers(projectId). The hook selects members from that project and returns the ProjectMembersState consumed by the shared controls. A selected user is stored by account-global sanityUserId, so the same identity remains valid across projects.
Render the project member directory with the controlled picker:
import {Card, Stack, Text} from '@sanity/ui'
import {AssigneeBadges, AssigneePicker} from '@sanity/workflow-components'
import type {Assignee} from '@sanity/workflow-engine'
import {useProjectMembers} from '@sanity/workflow-sdk'
import {useState} from 'react'
export function ProjectMemberExample({projectId}: {projectId: string}) {
const memberState = useProjectMembers(projectId)
const [selected, setSelected] = useState<readonly Assignee[]>([])
return (
<Card aria-labelledby="assignment-heading" as="section" border padding={4} radius={3}>
<Stack gap={3}>
<Stack gap={2}>
<Text id="assignment-heading" size={2} weight="semibold">
Assign team members
</Text>
<Text muted size={1}>
Choose the project members or roles responsible for this work.
</Text>
</Stack>
<AssigneePicker {...memberState} onChange={setSelected} value={selected} />
{selected.length > 0 ? (
<Stack gap={2}>
<Text muted size={1} weight="medium">
Assigned to
</Text>
<AssigneeBadges assignees={selected} members={memberState.members} />
</Stack>
) : null}
</Stack>
</Card>
)
}import {Card, Text} from '@sanity/ui'
import {AssigneePicker, type ProjectMembersState} from '@sanity/workflow-components'
import {errorMessage, type Assignee, type EditableFieldEvaluation} from '@sanity/workflow-engine'
import type {WorkflowSession} from '@sanity/workflow-sdk'
import {useState} from 'react'
type AssignmentField = EditableFieldEvaluation & {
type: 'assignees'
value: readonly Assignee[]
}
export function AssignmentPicker({
field,
members,
session,
}: {
field: AssignmentField
members: ProjectMembersState
session: Pick<WorkflowSession, 'editField'>
}) {
const [failure, setFailure] = useState<string>()
const change = async (next: readonly Assignee[]) => {
setFailure(undefined)
try {
await session.editField({field, mode: 'set', value: next})
} catch (cause) {
setFailure(errorMessage(cause))
}
}
return (
<>
<AssigneePicker {...members} onChange={change} value={field.value} />
{failure ? (
<Card padding={3} role="alert" tone="critical">
<Text>Could not update assignment: {failure}</Text>
</Card>
) : null}
</>
)
}See Reusable UI components for the component contracts and other controls.
Explain workflow decisions
A blocked action or waiting transition needs an explanation, not only a boolean. The evaluation pairs each decision with an insight that describes the condition and its unmet parts.
Use insights to explain action and activity availability, automation, requirements, transitions, and field permissions. See Evaluation insights for every insight location and the field-centric proposal model.
For a blocked condition, render blockedBy. It lists the unmet parts of the condition in source order:
import type {TransitionEvaluation} from '@sanity/workflow-engine'
function whatIsLeft(transition: TransitionEvaluation): string[] {
return transition.insight.blockedBy.map((atom) =>
atom.pivotal ? `${atom.atom.groq} (this alone unblocks it)` : atom.atom.groq,
)
}For a publish transition gated on $fields.legalApproved == true && $fields.score > 5 with both atoms unmet, this returns both; once score passes 5, it returns ['$fields.legalApproved == true (this alone unblocks it)'].
List workflow instances
A session covers one instance; an inbox, board, or document banner must first discover which instances to show. Use useWorkflowInstances for collections and useDocumentWorkflows for the workflows involving one document. Address that document with a global document reference:
import {useDocumentWorkflows, useWorkflowInstances} from '@sanity/workflow-sdk'
// Every in-flight instance currently in stage `review`, live.
const {instances, loading} = useWorkflowInstances({engine, filter: {stage: 'review'}})
// The instances that involve this document, live. GDR URIs only.
const forDocument = useDocumentWorkflows({
engine,
document: `dataset:${PROJECT_ID}:production:article-123`,
})Build a list-to-task application
A usable workflow application needs both discovery and detail: list readable instances, let the user choose one, then mount a session for that instance. The reference application demonstrates that boundary.
export function WorkflowsTool({engine}: {engine: Engine}) {
const [selectedId, setSelectedId] = useState<string>()
if (selectedId) {
return (
<WorkflowDetail
engine={engine}
instanceId={selectedId}
onBack={() => setSelectedId(undefined)}
/>
)
}
return <WorkflowList engine={engine} onSelect={setSelectedId} />
}Branch on loading, read errors, unreadable workflow documents, an empty result, and readable instances before navigating to a detail session:
function WorkflowList({engine, onSelect}: {engine: Engine; onSelect: (id: string) => void}) {
const {instances, loading, unreadable, error} = useWorkflowInstances({engine})
const firstUnreadable = unreadable[0]
if (loading) return <CenteredStatus><Spinner /></CenteredStatus>
if (error) return <CenteredStatus>Could not load workflows: {errorMessage(error)}</CenteredStatus>
return (
<Page>
<Stack gap={4}>
<Text as="h1" size={3} weight="semibold">Editorial Workflows</Text>
{firstUnreadable ? (
<Text muted>
{unreadable.length} workflow document(s) aren’t listed — this app can’t read them (
{firstUnreadable.reason}).
</Text>
) : null}
{instances?.length ? (
<Stack gap={3}>
{instances.map((instance) => (
<WorkflowRow instance={instance} key={instance._id} onSelect={onSelect} />
))}
</Stack>
) : <Text muted>No workflow instances yet.</Text>}
</Stack>
</Page>
)
}Understand live updates
The session recomputes as watched content, committed workflow state, and field previews change. The reactive session explains watch sets, update timing, and preview behavior.
Integrating another host or data layer? Build a custom reactive adapter.
Next steps
- The reactive session for the conceptual model this page renders.
- Test your workflows: the test bench evaluates the same projection, so helpers like the ones on this page are directly assertable.
- Reference guide for the full API surface.
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.