Skip to main content
This is the deep-dive reference. Setup covers the happy path. This page covers everything you reach for when you need precise control over node behavior, edge routing, quorum policies, SLA breaches, and event consumption.

Node configuration

Every node has a nodeId, a type (agent, human, or webhook), a config block, and an optional slaMs deadline. webhook nodes validate in a definition but their runtime handler isn’t enabled in v1, so this section covers the two runnable types. (The deferred webhook node is distinct from the inbound webhook handler and from per-execution webhook delivery, both of which are live in v1.) Every node also accepts optional cosmetic name (1–200 chars) and description (≤ 2000 chars) fields, echoed verbatim in NodeView. They’re labels for visual graph editors and don’t affect runtime behavior.

Agent nodes

Human nodes

Exactly one of reviewers[] (preferred) or reviewerIds[] (legacy) must be provided.

Rejection paths are edges

A human node carries no rejection config. Its reject path is an outgoing on:"reject" edge — see the edge model below. Every human node must have one, or the definition is rejected with APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH.

Edge model

All workflow transitions — approve routing, reject routing, group fan-out/in, and loop-backs — are expressed as a single unified edges[] array. Each edge is { from, to, on?, when?, loop? }.

Reject, loop-back, and exhausted

  • Reject path: an outgoing on:"reject" edge from the rejecting node.
  • Loop-back: an on:"reject" edge whose to is an ancestor of from, marked with loop. The server derives the loop region (entry node, body, cap) from the marked edge.
  • Exhausted handling: a sibling on:"exhausted" edge from the same from, fired when the loop’s maxIterations cap is reached. Without it, an exhausted loop rolls the execution up to failed.

Custom predicates

Use on:"custom" with a when expression to gate an edge on the source step’s output. when is a JSON-AST string — the engine parses it as JSON and evaluates it with a safe walker, never as JavaScript. No untrusted code ever runs. when is invalid on any non-custom edge.
Supported operators: equality, comparison, boolean, regex, includes, startsWith, endsWith, length, isEmpty. Path roots: An on:"always" edge (the default when on is omitted) always fires.

Groups as edge sources

A group can be an edge source (from: { kind: "group", groupId }), giving it one collective branch instead of N per-member fan-outs:
  • waitAll edge-source: all members must terminate, then the group takes one collective branch by unanimityapprove if every member approved, else reject. Provide both an on:"approve" and an on:"reject" branch; exactly one fires. Successor step ID: group_<groupId>__to__<childNodeId>. A routed collective reject is not a failed run — when the reject branch’s successor completes, the execution rolls up to completed, and a member whose rejection is handled by the group edge is surfaced as completed with output.decision="reject", never failed.
  • cancelOnQuorum edge-source: fires one collective approve-successor on approval-quorum (same as joinOnQuorum). A forward on:"reject" from a joinOnQuorum or cancelOnQuorum group is rejected as a dead edge — those policies fan out only on approval-quorum.
  • Edge-to-group fan-out (to: { kind: "group", groupId }) expands to one compiled edge per group member for all quorum policies.

The compiled view

Every DefinitionView returns a read-only compiled block alongside the authored edges (which echo sourceEdges byte-for-byte): compiled.forwardEdges is the runtime forward-edge list (group endpoints expanded, on roles compiled to predicate ASTs) and compiled.loops is the server-derived loop region list. See Get Definition for the field schema.

SLA and breach handling

Set slaMs on any node to give the step a deadline. If the step doesn’t complete within the window, it transitions to breached and emits a step.breached event. To handle breaches, declare an outgoing edge that routes on the breached status. Otherwise the engine emits the missing-breach-edge linter rule and rejects the definition. Silent dead-ends are a bug.

Parallel groups and quorum policies

A parallel group declares a set of member nodes that conceptually run in parallel and share an approval threshold.

Quorum is approval count, not completion count

A member counts as an approval when its terminal status is completed AND its output.decision === 'approve'. Rejections, failures, breaches, and cancellations contribute to the completion counter only, never the approval counter. Two consequences:
  1. Non-blocking agent members never satisfy quorum. They have no decision concept. Only place human or blocking agent nodes inside groups whose policy is cancelOnQuorum or joinOnQuorum.
  2. A reject does not block the group from rolling up to “complete”. It just keeps approvedShards from advancing. Group-completion (expectedSteps met) and group-quorum (approval threshold) are tracked separately.

onQuorumMet policies

Specific-must-approve quorum

By default, quorum is anonymous: any N approvals out of M members trigger the policy. To express “these specific members must approve”, declare them in requiredNodeIds:
Quorum-met now requires both:
  1. Every nodeId in requiredNodeIds is among the approvers, AND
  2. Total approval count reaches the numeric quorum.
In the example, brand alone approving doesn’t satisfy quorum even if quorum: 2 is reached numerically. legal AND finance must both also approve. If requiredNodeIds is omitted or empty, behavior collapses back to anonymous quorum.

Loop regions

A loop region lets a workflow re-enter an earlier node when a reviewer rejects, instead of failing outright. You don’t declare loops directly — you mark an on:"reject" edge with loop (its to must be an ancestor of from), and the server derives the loop region (entry node, body, cap) on the compiled graph. Add a sibling on:"exhausted" edge to route when the cap is reached.
The derived region is surfaced read-only as compiled.loops[]: The default iteration predicate is decision == 'reject' && rejectorMandatory == true. The unified contract does not support custom loop predicates on loop-back edges: loop is valid only on an on:"reject" back-edge.

Body-shape constraint

The derived loop body must be one of:
  1. Single-terminal sequential. Exactly one body node has outgoing edges that leave the body — that node is the iteration-terminal.
  2. Group-bounded. The set of exit-bearing body nodes equals the memberNodeIds of one parallel group with onQuorumMet: 'joinOnQuorum', every member lies inside the body, and the group has quorum === expectedSteps.
The linter rejects other shapes with loop-body-must-have-single-terminal.

previousAttempts payload threaded into iteration N+1

The entry step of iteration N+1 receives:

Iteration-scoped parallel groups

When a parallel group lives inside a loop body, each iteration gets fresh quorum state — the container path becomes parallelGroups/<groupId>_iter_<N> internally. You don’t address this directly, but it explains why per-iteration quorum starts from zero.

Loop events

Two new event types fire alongside the standard step.* and execution.* events. Both are returned from Get Execution Events.

Linter rules

Definitions are validated at create and update time. Authored-edge provenance errors come from compileGraph; graph-shape errors come from the linter. Any violation is rejected with INVALID_ARGUMENT and an explicit code in the error message.

Edge model validation errors

The unified edges[] contract is validated at create and update time. Violations are rejected with INVALID_ARGUMENT and one of these keys in the error message:

Events

For receiver setup (signature verification, security rules, delivery basics), see Setup, Configure your webhook receiver.

Event reference

Externally-visible events delivered via webhook and returned from Get Execution Events:
Internal-only events (step.scheduled, step.started, step.retried, step.resumed, step.response-recorded, step.overridden, parallel-group.completed, idempotency.suppressed) fill seq gaps but are filtered from external delivery. Your stream may have non-contiguous seq values.

Cancellation reasons

step.cancelled events carry a data.reason string. This is an open string set. Consumers should switch on event.type for control flow, not on data.reason.

Webhook retry policy

After 5 failed retries, the payload is written to a dead-letter queue. Recover missed events via Get Execution Events with sinceSeq. At-least-once delivery. The same eventId and seq appear on retries. Make your receiver idempotent on (executionId, seq).

Errors

All errors follow the standard envelope:

Canonical codes

Schema-level validation errors

Rate limiting

Rate limits are applied per API key, with additional per-endpoint tiers on high-volume routes. A RESOURCE_EXHAUSTED error indicates you should back off with exponential retry. Dispatch retries are safe to replay with an idempotencyKey.

Object reference

For human steps, output (after resume) includes the aggregator rollup:
For joinOnQuorum group successor steps, input includes: