Command-line tool for deploying, inspecting, and administering Sanity workflow definitions and instances.
Early access, restricted. The commands below talk to a real Sanity dataset,
configured by a sanity.workflow.ts file and authenticated with
sanity login.
pnpm --filter @sanity/workflow-cli dev <command> [...args]
# Examples:
pnpm --filter @sanity/workflow-cli dev --help
pnpm --filter @sanity/workflow-cli dev deploy --dry-run
pnpm --filter @sanity/workflow-cli dev list --include-completed
pnpm --filter @sanity/workflow-cli dev show wf-instance.abc123
The package is an oclif plugin: every command's canonical
id nests under the workflows topic
(sanity-workflows workflows deploy), so mounting the package into
the sanity CLI's plugin list surfaces the same commands as
sanity workflows … without colliding with the host's own root
commands (the sanity CLI already has a deploy). The short forms used
throughout this README (deploy, definition list, …) are the standalone
binary's stable surface — its production and development entrypoints
(bin/run.js and bin/dev.js) rewrite them onto the topic before oclif
resolves the command. Those short forms are not registered as oclif aliases,
so a host mount does not pollute the host root.
Authenticate once with sanity login (the CLI reads that session token); for
CI, set SANITY_AUTH_TOKEN instead. Then create a sanity.workflow.ts in the
directory you run from — see Configuration.
The token must span every resource the workflow references, not just the
workflow resource. Runtime commands build one client from the deployment
descriptor; when a workflow's subject or doc.ref fields point into other
datasets (or a canvas / media library), the engine reaches them through
sibling clients derived from that same token. A sanity login session or an
org-capable token covers this out of the box; a token scoped to a single
project will fail on cross-resource reads.
The CLI is configured by a sanity.workflow.ts (or .js/.mjs) discovered in
the directory you run from. It exports a config built with
defineWorkflowConfig, declaring your deployments (typically one per
environment):
import {defineWorkflowConfig} from '@sanity/workflow-engine/define'
import {articleReview, urlDraft} from './src/workflows.ts'
export default defineWorkflowConfig({
deployments: [
{
expectedMinReaderModel: 4,
name: 'production', // the deployment's unique identity (lowercase letters, digits, dashes)
tag: 'prod', // the environment partition the engine's docs are scoped to
workflowResource: {type: 'dataset', id: 'acme.workflows'}, // where those docs live
resourceAliases: [
// binds each `@<handle>:` a definition references to a physical resource
// in a DIFFERENT dataset from `workflowResource` (same-resource content
// needs no alias — see below)
{name: 'content', resource: {type: 'dataset', id: 'acme.content'}},
{name: 'assets', resource: {type: 'dataset', id: 'acme.assets'}},
],
definitions: [articleReview, urlDraft], // the batch this deployment ships
},
],
})
name is the deployment's identity: unique across the config and constrained
to the same grammar as tag. Tags group deployments by environment and may
repeat, as long as no two deployments share both a workflowResource and a
tag — that pair is the storage partition, and the config rejects the
collision naming both entries.
Pick a deployment with --deployment <name>; with a single deployment configured you
can omit it. --tag <tag> also works on single-deployment commands while the
tag names exactly one deployment — on deploy it targets every deployment
carrying the tag (a tag is an environment group). With several configured, a
bare interactive run presents a keyboard-driven deployment selector (by name,
tag alongside) — on deploy, start, and definition diff/delete, and on
the instance-targeted commands (abort, set-stage, reset-activity,
fire-action, diagnose) when the config spans several resources. In CI or another
non-interactive shell, the command fails asking for --deployment or --tag
(deploy also suggests --all-tags) instead of blocking for input.
--all-tags deploys every deployment in the config in one run: a failure in one doesn't
stop the rest — the run continues, prints a summary of what failed, and exits
non-zero. The client's project + dataset are derived from the deployment's
workflowResource, and deploy expands each definition's @<handle>:
reference to the bound physical resource. An invalid config fails with a clean,
path-prefixed error before the command runs.
resourceAliases is only for content that lives in a different resource from
workflowResource. A definition can reference a document in the same
resource by bare id, no alias needed — bare ids root at workflowResource at
runtime. Bindings exist at deploy time only: deploy expands each @<handle>:
reference to its bound physical GDR, and nothing alias-shaped survives into the
deployed definition or the runtime. Rule of thumb: different resource →
bind an alias; same resource → use a bare id and omit resourceAliases
entirely. So the simplest single-dataset setup is just a workflowResource
and definitions, with no resourceAliases at all.
Refs supplied at runtime (start --field, fire-action ref params) are gated
separately: they may target the workflowResource itself, and anything else is
rejected at the write with RefResourceUndeclaredError. The CLI wires no
resourceClients resolver, so it has no way to widen that surface — a runtime
foreign-resource ref goes through an engine consumer that serves the target
resource, not through the CLI.
| Var | Purpose |
|---|---|
SANITY_AUTH_TOKEN |
Explicit token for CI / scripted use — wins over the sanity login session (editor role for writes) |
SANITY_API_HOST |
Optional API host override (defaults to the prod API) |
Run through the dev / cli scripts and the CLI itself and your
sanity.workflow.ts load straight from TypeScript source: the scripts set
NODE_OPTIONS='--conditions=development' under tsx, and the config file is
transpiled on the fly by jiti. Definitions you author inline in the
config, or import by relative path, transpile the same way — no build step.
One caveat: a definition imported from a separate workspace package (e.g.
@sanity/workflow-examples) resolves to that package's built dist/. jiti
applies its own module resolution and does not honour
--conditions=development, so it never picks up a package's src/ export
condition — rebuild that package after editing it, or the config loads its
stale compiled output.
pnpm intercepts a handful of its own flag names (--check,
--filter, --recursive, …) when they appear after a script name.
To invoke deploy --check, run from inside the package or via
pnpm exec:
# from inside packages/workflow-cli
pnpm dev deploy --check
# or, from anywhere
pnpm --filter @sanity/workflow-cli exec \
env NODE_OPTIONS='--conditions=development' \
tsx --env-file-if-exists=../../.env bin/dev.js deploy --check
--dry-run and --only don't collide with pnpm flags and work
through the normal pnpm --filter … dev … invocation.
Invoke as sanity-workflows <command> (or sanity workflows <command> once the host CLI mount lands).
| Command | Description |
|---|---|
deploy |
Validate and deploy the definitions in the selected deployment. |
deploy --all-tags |
Deploy every deployment in the config. A failure in one does not stop the rest. |
deploy --check |
Validate local definitions without contacting the dataset. |
deploy --dry-run |
Show a diff against what is already deployed, without writing. |
deploy --only <name> |
Limit deploy, check, or dry-run to one definition. |
start <name> |
Start an instance from a deployed definition. Pass --field for input values. |
list |
List instances (in-flight by default). Filter with --definition. |
show <instance-id> |
Show an instance's state, activities, and effects. |
diagnose <instance-id> |
Explain why an instance is or isn't progressing, and what would unstick it. |
tail <instance-id> |
Stream new history entries as they land. |
abort <instance-id> |
Stop an in-flight instance. Pending effects cancel; the instance stays where it is. |
set-stage <instance-id> --to <stage> |
Move an instance to a stage, skipping declared transitions. The target stage's enter lifecycle still runs. |
reset-activity <instance-id> <activity> |
Re-run a failed activity, or --skip it so a gated transition can fire. |
fire-action <instance-id> |
Fire an action on a waiting activity. Omit --action to list what can be fired. |
definition list |
List deployed definitions. |
definition show <name> |
Show a deployed definition (latest version, or --version). |
definition diff <name> |
Diff the in-code definition against what is deployed. |
definition delete <name> |
Delete a deployed definition. Refuses while live instances exist unless --cascade. |
nuke --deployment <name> / nuke --tag <tag> |
Dev reset: delete every engine-owned document for that tag (instances, definitions, guards). Prints a plan, then asks for confirmation. Content documents are never touched. |
nuke --instance <id> |
Dev reset: delete one finished instance and its guards. Refuses in-flight instances (abort first). |
The CLI collects usage telemetry through Sanity's standard pipeline: a
per-command trace (Workflows CLI Command Executed — the command id, the
names of declared flags used, never their values, and a success flag) plus
the engine's adoption events from the operations it drives. Consent is the
account-wide status managed by npx sanity telemetry enable|disable|status;
CI and trueish DO_NOT_TRACK suppress everything (except a deploy that may share
new definitions, which forces that deploy's telemetry unless --no-share-defs
is passed — see Definition sharing),
and a session that isn't logged in sends nothing. A one-time notice on stderr
discloses collection on first use.
One extension point: supply your own logger as telemetry in
sanity.workflow.ts and the built-in pipeline is not constructed at all —
every event flows to your implementation unconditionally (CI included), and
consent, suppression, and destination become its business.
Separate from telemetry, a deploy that creates new definition versions
donates those documents to Sanity — verbatim (structure, names, titles, GROQ
filters, effect configuration, seeded values), plus their deployment
coordinates (project and dataset, or resource id) — never content documents,
instances, or your auth token. The documents go to Sanity's first-party
definition-feedback endpoint, not the telemetry pipeline; telemetry carries
only a content-free marker (content hash plus structural counts) per shared
definition and a per-invocation decision event.
Sharing is opt-out in every environment. Pass --no-share-defs to decline
for one invocation. With neither flag, the first interactive deploy prints a
full notice before sending and remembers that the notice was shown; every later
flagless deploy prints a status block showing that sharing is on by default and
showing runnable shell examples for the default, explicit opt-in, and opt-out
forms; the explicit flags suppress the status block. Unattended runs (CI,
DO_NOT_TRACK, or a non-TTY pipe) also share by default and print that status
block in their logs.
A flagless deploy or explicit --share-defs forces that deploy's telemetry
through the environment gate so donated definitions retain their content-free
markers; account-level sanity telemetry disable still applies. A failed share
only warns, never failing a completed deploy.
sanity CLI yet. The package side is mount-ready —
commands nest under the workflows oclif topic with no root
aliases (see Run) — but the sanity CLI does not list this package
in its plugin array, so the commands ship only through the standalone
sanity-workflows binary today. The host-side mount is a separate piece of
work.set-stage is the engine's setStage admin
override: it skips the definition's declared transitions and filters, but
the target stage's enter lifecycle and the post-move cascade still run. A
true force-set that also bypasses guards + enter effects does not exist
yet — it needs engine work that hasn't landed.Identity is the token behind the configured client: the engine resolves the
acting user (and their roles) from /users/me, so every write is attributed
to whoever the token authenticates — there is no identity injection and no
--as impersonation. Alongside that "who", the CLI declares an advisory
"via what": every verb carries
executionContext: { kind: "cli", id: "workflow-cli" }, stamped on history
entries as provenance.
Mirrors the Sanity CLI's stack so the eventual plugin port is small:
@oclif/core for command definitions, flags,
help.@oclif/plugin-help for help output.ora, boxen, log-symbols, console-table-printer for terminal UX;
color comes from node:util's styleText (no separate color dep).@sanity/cli-core for timestamp formatting (absolute for detail views, relative
"… ago" for list tables).diff for the colored JSON unified diff in deploy --dry-run.