Let reviewers claim an assignment

Add a role-restricted reviewer assignment to a workflow activity, let an editor claim it with editField, and limit approval to the claimer.

Early access

An assignment field lists who is responsible for an activity: specific people, project roles, or both. When a role is listed, anyone holding it can take the work. Claiming adds that person to the field, so the activity has one owner and nobody else in the role can act on it.

This guide adds a reviewer assignment to the sign-off activity of the quick start’s article-review workflow. The review starts assigned to the editor role. An editor claims it from a script, only the claimer can approve, and the claimer can release it back to the role.

Prerequisites

  • The finished quick start: workflows/article-review.ts deployed through the dev deployment in sanity.workflow.ts, and the _id of the published article you started it with.
  • The engine module from Run the engine from your own code (scripts/engine.ts). Run the claim script with a SANITY_AUTH_TOKEN that belongs to the person claiming, such as their personal token: the engine acts as the token’s identity, so the claim records whoever owns the token. Authentication and tokens explains how to create one.
  • A project member with the editor role. The roles constraint needs a dataset workflow resource, as in the quick start’s dev deployment, and read access to the project member directory, because eligibility checks look up each person’s project roles.

Add an assignment to the review

Assignment fields count for $assigned only at activity scope, so declare reviewer on sign-off. In workflows/article-review.ts, replace the sign-off activity:

export const articleReview = defineWorkflow({
  // ...
  stages: [
    // drafting is unchanged
    defineStage({
      name: 'review',
      // ...
      activities: [
        defineActivity({
          name: 'sign-off',
          title: 'Review the article',
          fields: [
            defineField({
              type: 'assignee',
              name: 'reviewer',
              title: 'Reviewer',
              roles: ['editor'],
              initialValue: {type: 'literal', value: [{type: 'role', role: 'editor'}]},
              editable: "count($fields.reviewer[@.type == 'user']) == 0 || $assigned",
            }),
          ],
          actions: [
            defineAction({
              name: 'approve',
              title: 'Approve',
              filter: "count($fields.reviewer[@.type == 'user']) > 0 && $assigned",
              status: 'done',
            }),
          ],
        }),
      ],
      // ...
    }),
    // approved is unchanged
  ],
})

An assignee field stores an ordered list with at most one person and any number of roles; assignees allows several people. An empty assignment is []. The literal initial value assigns the review to the editor role.

$assigned is true for the caller when the field lists them. When the field lists no person, callers match through the listed roles. Once a person is listed, only that person matches. Workflow- and stage-scope assignment fields do not affect $assigned.

roles controls who can be added to the field, and nothing else. It does not make the field editable, grant access to an action, or change $assigned. A person is eligible when their project membership includes a listed role, directly or through a role alias. A role entry is eligible only when it matches a listed role exactly. Without roles, any valid person or role is accepted.

The editable condition lets anyone edit the field while no person holds it, and only the holder once someone does. The approve filter requires a person in the field and requires the caller to be that person.

Let administrators claim too

Deploy and move an instance to review

The quick start’s deployment already acknowledges reader model 10, which covers singular assignee fields (model 9) and role-restricted assignments (model 8). If other runtimes share the workflow resource, see Upgrade Workflows packages first.

npx sanity-workflows deploy
npx sanity-workflows start article-review \
  --field subject='{"id":"dataset:PROJECT_ID:DATASET_NAME:DOCUMENT_ID","type":"article"}'
npx sanity-workflows fire-action INSTANCE_ID --activity write --action submit

The instance is in review. Run npx sanity-workflows fire-action INSTANCE_ID: no action is listed under Actions on stage 'review', because the filter hides approve until someone claims the review.

Claim the review

Create scripts/claim-review.ts. It reads the caller’s identity from an evaluation, appends that person to reviewer with engine.editField, and prints the activity’s fields:

import {engine} from '../../scripts/engine'

const instanceId = process.argv[2]
if (!instanceId) throw new Error('Usage: npx tsx scripts/claim-review.ts INSTANCE_ID')

const target = {scope: 'activity', activity: 'sign-off', field: 'reviewer'} as const

const {actor} = await engine.evaluate({instanceId})
const result = await engine.editField({
  instanceId,
  target,
  mode: 'append',
  value: {type: 'user', id: actor.id},
})

const review = result.instance.stages.at(-1)
const signOff = review?.activities?.find((activity) => activity.name === 'sign-off')
console.log(JSON.stringify(signOff?.fields, null, 2))

mode: 'append' adds one entry and keeps the editor role. target names the activity because the field is activity-scoped. The engine checks the editable condition and the roles list before it writes, records the edit in history, and re-evaluates the instance.

npx tsx scripts/claim-review.ts INSTANCE_ID

Verify the claim

The script prints the stored field. The editor role is still listed, and the claimer is the one person:

[
  {
    "_key": "c3414f255ffa",
    "_type": "assignee",
    "name": "reviewer",
    "title": "Reviewer",
    "value": [
      {
        "type": "role",
        "role": "editor"
      },
      {
        "type": "user",
        "id": "USER_ID",
        "_key": "e93940d9d08d"
      }
    ],
    "roles": [
      "editor"
    ]
  }
]

Run npx sanity-workflows fire-action INSTANCE_ID with the same token. It now lists ✔ sign-off → approve (Approve) [activity active]. With another editor’s token the list is empty, and the evaluation explains why: approve carries holderGate with the claimer as holder.

Edits that break the rules are rejected before anything is written:

  • A person without the editor role: Field entry item shape invalid for "reviewer" (assignee): user "USER_ID" does not fulfill any eligible role: "editor".
  • A second editor while the review is held: Field "activity:sign-off.reviewer" is not editable: editor not permitted (count($fields.reviewer[@.type == 'user']) == 0 || $assigned).

Approve as the claimer, and the instance moves to approved:

npx sanity-workflows fire-action INSTANCE_ID --activity sign-off --action approve

Release the review

To hand a held review back to the role, replace the value. Create scripts/release-review.ts:

import {engine} from '../../scripts/engine'

const instanceId = process.argv[2]
if (!instanceId) throw new Error('Usage: npx tsx scripts/release-review.ts INSTANCE_ID')

await engine.editField({
  instanceId,
  target: {scope: 'activity', activity: 'sign-off', field: 'reviewer'},
  value: [{type: 'role', role: 'editor'}],
})
console.log('Released the review to the editor role')

The default set mode replaces the whole list, restoring the editor role assignment. To clear every member, use mode: 'unset' or set []; the stored value becomes []. A replacement can keep members who no longer qualify for roles, and removing or reordering them is allowed. Adding a member, or a second copy of one, requires current eligibility.

Let any editor approve without claiming

To let any matching role member approve without claiming first, use filter: '$assigned' on approve. To keep the action visible but disabled until someone claims, move the check into a requirement:

defineActivity({
  name: 'sign-off',
  // ...
  requirements: [
    {
      type: 'groq',
      name: 'claimed',
      title: 'Claim the review first',
      query: "count($fields.reviewer[@.type == 'user']) > 0",
    },
  ],
  actions: [defineAction({name: 'approve', title: 'Approve', filter: '$assigned', status: 'done'})],
})

With the requirement, fire-action INSTANCE_ID shows ✖ sign-off → approve with disabled: unmet requirement(s): claimed until someone claims. Filters, requirements, and editable conditions are advisory; Actors and enforcement explains what enforces a rule.

Next steps

Visiting agent?

Was this page helpful?

sanity.ioAll Systems Operational© Sanity 2026
Change site theme