Editorial Workflows

App SDK

Build an App SDK interface that lists workflow instances, renders live state, explains blocked work, and commits actions and field edits.

Prerelease

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.

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

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.

  • Selected from a row returned by the instance-list hooks, or supplied by application routing.

  • session

    WorkflowSession

    Returned by useWorkflowSession for the selected instance.

  • evaluation

    WorkflowEvaluation

    Read from session.evaluation after 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.fireAction and surfaces a rejected commit.

  • projectId

    string

    The project ID from the application’s SanityApp configuration.

  • 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

Visiting agent?

Was this page helpful?