@sanity-labs/workflow-engine-explore
v0.4.0
Published
A workflow / BPM engine for Sanity content. Defines workflows as data, runs them as instances against a Sanity client, gates actions on guards, queues effects for runtimes to drain.
Maintainers
Keywords
Readme
@sanity-labs/workflow-engine-explore
A workflow / BPM engine for Sanity content. Defines workflows as data, runs them as instances against a Sanity client, gates actions on guards, queues effects for runtimes to drain.
Status: 0.x POC. Sharp edges. Breaking changes between minor versions. Use it to learn, not to bet a product on.
npm install @sanity-labs/workflow-engine-exploreCompanion packages, shipped separately so the engine's runtime install graph stays minimal (groq-js + zod only — no React, no SDK, no test deps):
| Package | Purpose |
|---|---|
| @sanity-labs/workflow-engine-explore-react | Sanity App SDK hooks for React consumers |
| @sanity-labs/workflow-engine-explore-test | In-memory bench (createBench) for testing workflows |
| @sanity-labs/workflow-engine-explore-test-client | Standalone in-memory @sanity/client for tests |
Quickstart
import { workflow } from "@sanity-labs/workflow-engine-explore";
import { defineWorkflow } from "@sanity-labs/workflow-engine-explore/define";
import { createClient } from "@sanity/client";
const client = createClient({ projectId: "…", dataset: "…", token: "…", useCdn: false });
const tags = ["acme-prod"]; // engine-scope; see Tags below
// 1. Define a workflow.
const articleReview = defineWorkflow({
workflowId: "article-review",
version: 1,
name: "Article review",
initialStageId: "draft",
stages: [
{
id: "draft",
tasks: [{ id: "write", actions: [{ name: "submit", setStatus: "done" }] }],
transitions: [{ to: "in-review", on: "auto", guard: "allTasksDone" }],
},
{
id: "in-review",
tasks: [{ id: "approve", actions: [{ name: "approve", setStatus: "done" }] }],
transitions: [{ to: "published", on: "auto", guard: "allTasksDone" }],
},
{ id: "published", kind: "terminal" },
],
predicates: [
{
id: "allTasksDone",
name: "All tasks done",
groq: "count(*[_id == $self][0].taskStatus[status != 'done' && status != 'skipped']) == 0",
},
],
});
// 2. Deploy.
await workflow.deployDefinitions({ client, tags, definitions: [articleReview] });
// 3. Start an instance, fire actions.
const actor = { kind: "user" as const, id: "alice" };
const inst = await workflow.startInstance({
client, tags,
workflowId: "article-review",
subject: { kind: "document", ref: "article-123" },
actor,
});
await workflow.fireAction({
client, tags,
instanceId: inst._id,
taskId: "write",
action: "submit",
actor,
});
// Auto-transition fires: draft → in-review
await workflow.fireAction({
client, tags,
instanceId: inst._id,
taskId: "approve",
action: "approve",
actor,
});
// Auto-transition fires: in-review → published
// 4. Read current state.
const evaluation = await workflow.evaluate({ client, tags, instanceId: inst._id, actor });
console.log(evaluation.currentStage.stage.id); // "published"Concepts
A workflow is stages connected by transitions. Each stage has tasks, each task exposes actions. Firing an action mutates task status; guards decide which transitions become legal. The engine commits one transition at a time and cascades auto-transitions until stable.
Stages
A stage is one named phase of a workflow. The active stage of an instance is its currentStageId. Stages declare:
id(required) and optionalname/descriptionkind:"normal"(default) or"terminal"(workflow stops here, no transitions allowed)tasks: work assigned to people, services, or bothtransitions: outgoing moves to other stages, with optionalguardandontriggereffects: side-effect descriptors fired when the stage is entered
Tasks
A task is a unit of stage-local work. It has a lifecycle (pending → active → done/skipped/failed) and a set of named actions that move it through that lifecycle.
{
id: "review",
name: "Editor review",
assignees: [{ kind: "role", role: "editor" }],
actions: [
{ name: "approve", setStatus: "done", roles: ["editor", "admin"] },
{ name: "reject", setStatus: "failed" },
],
}assignees is a soft hint — the engine attaches it to the task but doesn't enforce identity gates by itself. Use actions[].roles for action-level gating (still soft; combine with workflow.permissions for real ACL).
Transitions
A transition moves the instance from one stage to another.
{ to: "published", on: "auto", guard: "allTasksDone" }on: "auto"(default) — the engine considers this transition every time it cascades. Fires the first one whose guard passes.on: "manual"— only fires when explicitly requested (rare; the common case is auto + guard).guard— either an inline GROQ string, or a{ ref: "predicateId", args: { … } }reference to a named predicate on the workflow.
A transition guard is evaluated against client.fetch with reserved params:
| Param | Resolves to |
|---|---|
| $self | The instance _id |
| $subject | instance.subject.ref (or null) |
| $parent | The immediate parent ref (ancestors.at(-1)._ref), or null at root |
| $ancestors | Full ancestor chain, root-first |
Plus any args supplied on a predicate ref.
Actions
An action is an event you fire against a task. Firing an action:
- Mutates the task's
status(setStatus) and optionally the instance'seffectsContext(viaoutputs). - Cascades auto-transitions until stable.
- Propagates to ancestor instances (so a child completing can roll up to its parent).
await workflow.fireAction({
client, tags,
instanceId, taskId: "review", action: "approve",
actor,
});Predicates
A named, reusable GROQ guard. Declared on the workflow definition; referenced by transitions and action availableWhen clauses:
predicates: [
{
id: "subjectApproved",
name: "Subject is approved",
groq: "*[_id == $subject][0].state == \"approved\"",
},
],
// Used:
transitions: [{ to: "next", on: "auto", guard: { ref: "subjectApproved" } }],Predicates can take typed params (string, number, boolean, enum):
{
id: "subjectInState",
name: "Subject in state",
groq: "*[_id == $subject][0].state == $state",
params: [{ name: "state", type: "string", enum: ["draft", "approved", "rejected"] }],
}
// Used: guard: { ref: "subjectInState", args: { state: "approved" } }Effects
Side effects (publish a release, notify Slack, write a log, …) are declared as descriptors on stages, tasks, transitions, or actions:
effects: [{ name: "sanity.release.publish", input: { releaseId: "rls-2026-q2" } }]The engine does not run effects. It appends a PendingEffect to the instance's queue, snapshots its inputs (resolved against effectsContext bindings), and trusts a runtime to drain the queue. After running an effect, the runtime calls workflow.completeEffect to report the outcome (and optionally seed effectsContext with outputs).
This separation is intentional:
- Effects can be any side effect — publishing, sending an email, calling an API — without the engine needing to know
- Multiple runtimes can split responsibility (e.g. one Sanity Function per effect name)
- Idempotency lives at the runtime layer
See effectsContext for the binding model.
effectsContext
A stable, named bag of params on the instance. Effect handlers read from it; effect outputs upsert into it. Seeded at startInstance and updated by completeEffect({ outputs }):
const inst = await workflow.startInstance({
client, tags,
workflowId: "release",
effectsContext: { releaseId: "rls-2026-q2", channel: "#editorial" },
actor,
});
// Inside an effect declaration:
effects: [{ name: "slack.notify", input: { channel: "$channel", text: "Published" } }]
// The runtime substitutes "$channel" from effectsContext at queue time.Instances
A workflow instance is one running execution of a definition. It carries:
_id,_type: "workflow.instance",tags: string[]workflowId,pinnedVersion,definitionSnapshot(frozen JSON)currentStageId,taskStatus[]pendingEffects[],effectHistory[]history[](ordered, polymorphic — stage transitions, action fires, effect outcomes, spawns, etc.)subject(optional{ kind, ref }— the document the workflow is about)ancestors[](parent → grandparent → … for spawned children)startedAt,lastChangedAt, optionalcompletedAt
Tags — engine-scope multi-tenant isolation
The engine takes a required tags: string[] config on every call. Tags must be Sanity-ID-compatible:
^[a-z0-9][a-z0-9-]*$ASCII lowercase + digits + dashes, no leading dash, no dots (dots are reserved as the _id prefix separator).
When the engine writes a definition or instance:
_idis prefixed withtags[0]joined by.— e.g.acme-prod.article-review.v1,acme-prod.wf-instance.7f3b…- The full
tagsarray is stamped on the document as atags: string[]field
When the engine reads:
- Every GROQ query for
workflow.definition/workflow.instancefilters bycount(tags[@ in $engineTags]) > 0 - Direct
getDocument(id)lookups check tag intersection on the returned doc
Two engines with disjoint tag sets share a workflow resource without seeing each other's data. Engines sharing a tag have visibility through that tag. Spawn-children inherit the parent's tags so they stay visible to whichever engine spawned the chain.
This is the answer to test/prod isolation, multi-app coexistence, per-team scopes, and the general "many tenants in one workflow resource" question — same mechanism handles all of them.
import { validateTags, canonicalTag } from "@sanity-labs/workflow-engine-explore";
validateTags(["acme-prod"]); // ok
validateTags(["acme-prod", "shared"]); // ok — multiple tags
validateTags([]); // throws
validateTags(["bad.dot"]); // throws
validateTags(["BadCase"]); // throws
canonicalTag(["acme-prod", "shared"]); // → "acme-prod"API
All entrypoints take { client, tags, actor, … }. Actor must always be explicit — the engine never fabricates an identity for production callers.
workflow.deployDefinitions({ client, tags, definitions })
Bulk, idempotent deploy. Topologically sorts so children deploy before parents that spawn them. Skips unchanged definitions (deep-compare). Per-definition status:
const { results } = await workflow.deployDefinitions({ client, tags, definitions: [a, b, c] });
// results: [{ workflowId, version, status: "created" | "updated" | "unchanged" }, …]Errors:
- Unresolved
spawn.definitionRef._ref(neither in batch nor deployed) → before any write - Dependency cycle in the batch → before any write
- Invalid Zod-validated definition shape → before any write, with path-prefixed error message
workflow.startInstance({ client, tags, workflowId, version?, subject?, ancestors?, effectsContext?, instanceId?, actor })
Spawn a new instance from a deployed definition. Pins the version (frozen snapshot), seeds effectsContext, enters the initial stage, primes onEnter effects, cascades auto-transitions until stable. Returns the resulting instance.
If version is omitted, picks the highest-version deployed definition for workflowId. If instanceId is omitted, the engine generates one as ${tags[0]}.wf-instance.${random}.
workflow.fireAction({ client, tags, instanceId, taskId, action, actor, idempotent?, grants?, grantsFromPath? })
The universal "something happened" call. Editors fire it; runtimes fire it in response to webhooks, effect completions, timer firings. Auto-invokes pending tasks (pending → active) so callers don't need to know task lifecycle.
idempotent: true— no-op (don't throw) if the task is missing from the current stage (e.g. workflow already advanced past it). Returns{ instance, cascaded: 0, fired: false }.grants/grantsFromPath— see Permissions below.
Returns { instance, cascaded, fired }.
workflow.tick({ client, tags, instanceId, actor })
Re-evaluates auto-transitions and resolves due waits until stable. Use this when something changed outside the instance that might affect it (a subject doc was patched, a sibling workflow completed, a waitUntil came due). The runtime doesn't need to know what changed — tick re-evaluates.
workflow.completeEffect({ client, tags, instanceId, effectKey, status, outputs?, detail?, error?, durationMs?, actor })
Drains a pending effect from the queue, appends to effectHistory, optionally seeds effectsContext with outputs (for downstream bindings), cascades.
await workflow.completeEffect({
client, tags,
instanceId, effectKey: "ef-publish-abc",
status: "done",
outputs: { publishedAt: new Date().toISOString() },
actor: { kind: "system", id: "drain-effects-fn" },
});workflow.evaluate({ client, tags, instanceId, actor, grants?, grantsFromPath? })
Read-only projection of the instance's current state from an actor's perspective. Returns per-action verdicts with structured disabledReason for UI rendering. Same logic fireAction uses for its soft gate.
workflow.permissions
Sanity ACL helpers — GROQ-evaluated grants, mirror of canvas's permission store. Used by workflow.evaluate to soft-gate actions when grants are supplied.
const allowed = workflow.permissions.grantsPermissionOn({
grants, document: instanceDoc, permission: "update",
});Migration / versioning
Each instance carries a definitionSnapshot — a frozen JSON copy of the definition at start time. New versions of a workflow deploy alongside earlier ones; in-flight instances stay on their pinned version indefinitely. New instances pick up the latest.
This is the simplest viable strategy:
- ✅ No mid-flight migration needed
- ✅ Definition authors can iterate without breaking running work
- ❌ Long-running instances drift from current definition state (audit history is honest about which version they ran on)
- ❌ No automatic backport of fixes to in-flight instances (deliberate — automatic structural migration is a rabbit hole)
If a 0.x project wants long-running instances to pick up changes, the path today is: drive them to a terminal stage, kick off new instances on the new version.
Permissions / ACL
The engine has a soft gate on actions:
- If
grants(orgrantsFromPath) is supplied → action evaluation checkspermissions.grantsPermissionOnagainst the instance doc + thepermissionfield on the action. - If the actor's role intersects
action.roles[](or["*"]wildcard).
Both gates emit structured disabledReason so UIs can render disabled-with-tooltip without re-implementing the logic. The gate is informational by default — Sanity's storage-layer ACL is the actual write boundary. The engine's gate exists so callers can render the same UI verdict the write will produce.
grantsFromPath is the recommended pattern in production — pass a URL path on the client (e.g. /canvases/<id>/acl for canvas-resource workflows or /projects/<id>/datasets/<ds>/acl for project workflows) and the engine fetches + caches per (client, path) for the process lifetime.
Testing
npm install -D @sanity-labs/workflow-engine-explore-testimport { createBench } from "@sanity-labs/workflow-engine-explore-test";
const bench = createBench();
await bench.deployDefinitions({ definitions: [articleReview] });
const inst = await bench.startInstance({ workflowId: "article-review" });
await bench.fireAction({ instanceId: inst._id, taskId: "write", action: "submit" });
expect(await bench.currentStage(inst._id)).toBe("in-review");See @sanity-labs/workflow-engine-explore-test for the full bench API.
React
npm install @sanity-labs/workflow-engine-explore-reactimport { useFireAction, useWorkflowInstance } from "@sanity-labs/workflow-engine-explore-react";
function ApproveButton({ instanceId, taskId }: { instanceId: string; taskId: string }) {
const { fire, pending } = useFireAction({ tags: ["acme-prod"] });
return (
<button disabled={pending} onClick={() => fire({ instanceId, taskId, action: "approve" })}>
Approve
</button>
);
}See @sanity-labs/workflow-engine-explore-react for the full hook API.
License
UNLICENSED — internal Sanity labs exploration. Restricted-access npm scope (@sanity-labs).
