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

FieldTypeNotes
agentIdstringRequired.
promptOverridestring≤ 8000 chars.
inputMappingobjectPass step inputs to the agent.
blockingbooleanDefault false. When true, the step parks in waiting until external resolutions arrive via /steps/recordAgentResolution.
resolutionPolicyobjectRequired when blocking: true. { kind: "allResolved" | "minResolved", minCount?: integer }. minCount is required when kind === "minResolved".
agentMaxRuntimeMsinteger≤ 86400000.
requireNonEmptyOutputbooleanFail the step if the agent returns an empty output.
{
  "nodeId": "brand-check",
  "type": "agent",
  "config": {
    "agentId": "brand-agent-v1",
    "blocking": false,
    "requireNonEmptyOutput": true
  },
  "slaMs": 3600000
}

Human nodes

Exactly one of reviewers[] (preferred) or reviewerIds[] (legacy) must be provided.
FieldTypeNotes
reviewersarrayPreferred: [{ userId, mandatory }]. Must include at least one mandatory: true. UserIds must be unique.
reviewerIdsstring[]Legacy. Accepted for back-compat only.
reviewerEmailsstring[]Optional. 0–50 reviewer email addresses. Surfaced in the human step’s output.reviewerEmails for downstream notification UIs.
commentBodystring≤ 8000 chars. Stored on the human step’s output for use by your reviewer-facing UI. The engine does not auto-create a Velt annotation per human step in v1 — your application is responsible for surfacing this string to reviewers (and, if you use the legacy comment-resolution flow, for creating the comment thread the reviewer replies to).
{
  "nodeId": "human-legal",
  "type": "human",
  "config": {
    "reviewers": [{ "userId": "u_legal_01", "mandatory": true }],
    "commentBody": "Please review for legal compliance."
  }
}

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? }.
{ "from": "human-review", "to": "rework-notice", "on": "reject" }
FieldTypeRequiredNotes
fromEdgeEndpointyesBare node-id string, { kind: "node", nodeId }, or { kind: "group", groupId }.
toEdgeEndpointyesSame shape as from.
onenumno (default "always")approve / reject / always / exhausted / custom. approve and reject auto-compile their predicates.
whenJSON-AST stringonly when on:"custom"Custom predicate over the source step’s output. Invalid on any other on role.
loop{ maxIterations }only on an on:"reject" back-edgemaxIterations 1–20. Marks the reject edge as a loop-back; the server derives the loop region.

Reject, loop-back, and exhausted

  • Reject path: an outgoing on:"reject" edge from the rejecting node.
    { "from": "human-review", "to": "rework-notice", "on": "reject" }
    
  • 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.
    { "from": "human-review", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 3 } }
    
  • 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.
    { "from": "human-review", "to": "escalate", "on": "exhausted" }
    

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.
{ "from": "brand-check", "to": "legal-review", "on": "custom", "when": "{\"op\":\"eq\",\"args\":[{\"var\":\"output.passesBrandCheck\"},true]}" }
Supported operators: equality, comparison, boolean, regex, includes, startsWith, endsWith, length, isEmpty. Path roots:
RootResolves to
output.*The source step’s output object.
step.*The source step’s metadata (status, timing).
execution.input.*The triggerContext you passed on dispatch.
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.
{
  "groupId": "parallel-review",
  "memberNodeIds": ["human-legal", "human-brand"],
  "expectedSteps": 2,
  "quorum": 2,
  "onQuorumMet": "waitAll"
}
FieldTypeRequiredDescription
groupIdstringyes1 to 64 chars. Stable identifier.
memberNodeIdsstring[]yes1 to 500 nodes. Each must be declared as a top-level node. A node may belong to at most one group.
expectedStepsintegeryes1 to 500. Total members the group expects to terminate (any status) before it considers itself “fully done.” Should equal memberNodeIds.length in practice — values higher than the member count cause the group to never roll up to “complete.”
quorumintegeryes1 to expectedSteps. The number of approvals required to fire the policy’s side effect.
onQuorumMetenumnowaitAll (default), cancelOnQuorum, or joinOnQuorum.
requiredNodeIdsstring[]noSpecific members whose approval is required for quorum to be met. Every entry must also be in memberNodeIds. length must be <= quorum.

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

PolicySide effect on first-time approval-quorum-metPer-member fan-out
waitAll (default)Without group-source edges, emits group.quorum-met event only and is informational. When used as an edge source, waits for all members and fires one collective approve/reject branch by unanimity.Each member’s outgoing edges fire on its own completion. If two members both fan out to the same downstream node, you get two downstream step instances.
cancelOnQuorumEmits group.quorum-met AND cancels every sibling member step still in waiting (system-actor cancellation, audit reason group-quorum-met).Each completing member still fans out per-edge. Cancelled siblings do not fan out.
joinOnQuorumEmits group.quorum-met, cancels waiting siblings, AND fires a single group-owned downstream fan-out: one new step per shared outgoing-edge target with deterministic stepId group_<groupId>__to__<childNodeId>. The successor’s input is { groupOutputs, groupId, quorum, totalApproved }.Suppressed for group members. The group container owns fan-out, so downstream successors run exactly once.

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:
{
  "groupId": "approver-group",
  "memberNodeIds": ["legal", "finance", "brand"],
  "expectedSteps": 3,
  "quorum": 2,
  "requiredNodeIds": ["legal", "finance"]
}
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.
{ "from": "human-legal", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 5 } }
{ "from": "human-legal", "to": "human-escalate", "on": "exhausted" }
The derived region is surfaced read-only as compiled.loops[]:
FieldTypeDescription
loopIdstringServer-assigned stable identifier for the derived loop.
entryNodeIdstringNode re-entered first on each iteration (the reject edge’s to).
bodyNodeIdsstring[]Nodes inside the loop’s iteration scope, derived from the topological closure between to and from.
maxIterationsinteger1–20. Hard cap per execution, taken from the reject edge’s loop.maxIterations.
onExhausted{ routeToNodeId } | nullTarget of the sibling on:"exhausted" edge. null rolls the execution up to failed at the cap.
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: number;            // N+1
  loopId: string;
  previousAttempts: Array<{
    iteration: number;
    authorOutput: Record<string, unknown>;  // the body's iteration-terminal output
    rejectedBy: string;
    rejectorMandatory: boolean;
    rejectionReason: string | null;
    rejectedAt: number;
  }>;
}

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.
Event typeWhen emitteddata
loop.iteration-startedIteration N+1 spawns after a body iteration terminated rejected and the cap wasn’t hit.{ loopId, iteration, triggeredBy: 'rejection' }
loop.exhaustedCap reached. Either the onExhausted.routeToNodeId step is spawned next, or the execution rolls up to failed if no route node was declared.{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }

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.
CodeMeaning
duplicate-node-idTwo nodes share the same nodeId.
dangling-edgeEdge references a from or to that isn’t declared.
cycle-detectedThe graph contains a cycle. v1 is DAG-only.
unreachable-nodeA node has no path from any root.
node-missing-configA node has no config block.
missing-breach-edgeA node has slaMs set but no outgoing edge that routes on status == 'breached'. Breaches would silently dead-end.
group-duplicate-idTwo groups share the same groupId.
group-members-emptymemberNodeIds is empty.
group-member-missingA member references an unknown node.
group-expected-steps-invalidexpectedSteps < 1.
group-quorum-invalidquorum < 1 or quorum > expectedSteps.
group-cancelonquorum-requires-quorum-lt-expectedcancelOnQuorum requires quorum < expectedSteps.
group-joinonquorum-members-must-share-successorsjoinOnQuorum requires every member to have an identical set of outgoing-edge target nodes.
group-required-not-in-membersAn entry in requiredNodeIds is not in memberNodeIds.
group-required-exceeds-quorumrequiredNodeIds.length > quorum.
group-node-in-multiple-groupsA node appears as a member of two or more groups.
loop-body-must-have-single-terminalDerived loop body shape is neither single-terminal sequential nor group-bounded — see Loop regions › Body-shape constraint.
loop-group-bounded-quorum-must-equal-expectedDerived body is group-bounded but the bounding joinOnQuorum group has quorum < expectedSteps. Force quorum === expectedSteps so iteration-terminal coincides with all-members-done; otherwise a late rejection races against an already-fired join successor.

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:
Error keyCause
APPROVAL_EDGE_CUSTOM_REQUIRES_WHENon:"custom" without a non-empty when.
APPROVAL_EDGE_WHEN_ONLY_FOR_CUSTOMwhen supplied on a non-custom edge.
APPROVAL_EDGE_LOOP_REQUIRES_REJECTloop on a non-reject edge.
APPROVAL_EDGE_LOOP_TARGET_NOT_ANCESTORreject+loop whose to is not an ancestor of from.
APPROVAL_EDGE_REJECT_CYCLE_REQUIRES_LOOPon:"reject" to an ancestor without loop (unmarked cycle).
APPROVAL_EDGE_EXHAUSTED_REQUIRES_LOOP_SIBLINGon:"exhausted" with no sibling reject+loop from the same from.
APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATHHuman node with no outgoing on:"reject" edge.
APPROVAL_EDGE_GROUP_TO_GROUP_FORBIDDENBoth endpoints are group containers.
APPROVAL_GROUP_FROM_REJECT_REQUIRES_LOOPForward on:"reject" from a joinOnQuorum / cancelOnQuorum group (dead edge — those policies fan out only on approval-quorum).
APPROVAL_GROUP_FROM_LOOP_REQUIRES_JOINONQUORUMreject+loop back-edge from a non-joinOnQuorum group.

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:
Event type (external)Internal nameWhen emitteddata highlights
execution.dispatchedsameExecution created. First step(s) scheduled.{ definitionId, definitionVersion, rootStepIds }
execution.completedsameAll steps terminal, no unhandled failures.null
execution.failedsameAny blocking step ended in failed or breached without a recovery edge.{ failureReason }
execution.cancelledsame/executions/cancel or full-execution rollback.{ reason? }
step.awaiting-approvalstep.waitingA human or blocking-agent step entered waiting.{ waitingForReviewers, mandatoryCount, resumeKey }
step.completedsameStep transitioned to completed.For human or blocking-agent: { aggregatorStatus, nodeType, decision, aggregatorBacked }. For non-blocking agents: { agentId }.
step.failedsameStep transitioned to failed (retry budget exhausted).{ error: { code, message } }
step.breachedsameStep exceeded its configured SLA before completing.{ reason }
step.cancelledsameStep cancelled via /steps/cancel or by quorum-met side effect.{ actorId, reason }
group.quorum-metparallel-group.quorum-metA parallel group’s approval threshold was first satisfied.{ groupId, total, quorum, completedTotal, expectedSteps }
loop.iteration-startedsameIteration N+1 spawns after a body iteration terminated rejected and the cap wasn’t hit.{ loopId, iteration, triggeredBy: 'rejection' }
loop.exhaustedsameThe loop’s maxIterations cap was reached. If onExhausted.routeToNodeId is set, that step is spawned next; otherwise the execution rolls up to failed.{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }
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.
ReasonSourceMeaning
group-quorum-metsystemCancelled by the engine when the parent group’s approval quorum was met under cancelOnQuorum or joinOnQuorum. Audit shows actorId: "system:group-quorum".
loop-restartsystemCancelled by the engine when a loop region is starting iteration N+1 and an in-flight body step from iteration N is still running. Audit shows actorId: "system:loop-restart".
(admin-supplied)adminFree-form reason passed to /steps/cancel.

Webhook retry policy

AttemptDelay before retry
1 (initial)n/a
22 s
38 s
432 s
52 min
68 min, then dead-letter
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:
{ "error": { "message": "...", "status": "INVALID_ARGUMENT", "details": {} } }

Canonical codes

CodeMeaningTypical cause
INVALID_ARGUMENTSchema, compileGraph, or linter failure.Missing field, wrong type, value out of range, edge contract violation, or linter rule violation.
UNAUTHENTICATEDMissing or invalid x-velt-auth-token.
PERMISSION_DENIEDAuth token valid but lacks the required scope./steps/resolve with reviewer-approve / reviewer-reject and actorId not in the step’s reviewer list.
NOT_FOUNDTarget doc does not exist.Unknown executionId, definitionId, or stepId.
ALREADY_EXISTSConflicting create.Creating a definition with a definitionId already in use.
FAILED_PRECONDITIONOptimistic lock or state-machine violation.ifVersion mismatch on update. Cancelling a terminal step. Deleting a definition with in-flight executions.
RESOURCE_EXHAUSTEDRate limit exceeded.Per-IP or per-API-key quota.
DEADLINE_EXCEEDEDInternal timeout.Retry with idempotency.

Schema-level validation errors

messageTrigger
webhookUrl and webhookSecret must be provided togetherDispatch supplied one but not the other.
webhookUrl must use https schemeNon-HTTPS scheme.
webhookUrl host resolves to a private, loopback, or link-local addressLiteral private IP, localhost, metadata.google.internal, or *.internal.
at least one of reviewerIds or reviewers must be providedHuman node with no reviewers.
cannot set both reviewerIds and reviewers, use oneBoth populated. Pick the modern reviewers[] form.
reviewer userIds must be uniqueDuplicate userId in reviewers[].
reviewers must include at least one mandatory reviewer (allMandatoryApproved would otherwise never resolve)Every reviewer.mandatory === false.
resolutionPolicy required when blocking === trueBlocking agent node without a policy.
minCount required when kind === "minResolved"resolutionPolicy.kind = "minResolved" with no minCount.

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

interface ExecutionView {
  executionId: string;
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
  startedAt: number;             // epoch ms
  completedAt: number | null;
  cancelledAt: number | null;
  definitionId: string;
  definitionVersion: number;
  correlationId: string;
  idempotencyKey: string;
  failureReason: { code: string; message: string } | null;
  steps: StepView[];
}

interface StepView {
  stepId: string;
  nodeId: string;
  nodeType: 'agent' | 'human';
  status: 'pending' | 'running' | 'waiting' | 'completed' | 'failed' | 'skipped' | 'cancelled' | 'breached';
  groupId: string | null;
  startedAt: number | null;
  completedAt: number | null;
  output: Record<string, unknown>;
  error: { code: string; message: string } | null;
}

interface DefinitionView {
  definitionId: string;
  name: string;
  description: string | null;
  version: number;
  scope: { level: 'apiKey' | 'organization' | 'document'; organizationId: string | null; documentId: string | null };
  nodes: NodeView[];
  edges: EdgeView[];
  groups: ParallelGroupDef[] | null;
  compiled: CompiledGraph;
  triggers: WorkflowTriggerConfig[] | null;
  tags: string[] | null;
  custom: Record<string, unknown> | null;
  createdAt: number;
  updatedAt: number;
  status: 'active' | 'tombstoned';
}

type JsonAst = Record<string, unknown>;

interface CompiledGraph {
  forwardEdges: CompiledForwardEdge[];
  loops: CompiledLoopRegion[];
}

interface CompiledForwardEdge {
  from: string;
  to: string;
  role: 'approve' | 'reject' | 'always' | 'exhausted' | 'custom';
  when: JsonAst | null;
  fromGroupId?: string;
  toGroupId?: string;
}

interface CompiledLoopRegion {
  loopId: string;
  entryNodeId: string;
  bodyNodeIds: string[];
  maxIterations: number;
  onExhausted: { routeToNodeId: string } | null;
}

interface ApprovalEventView {
  eventId: string;
  seq: number;             // monotonic per-execution
  type: string;            // external event type, see Event reference
  stepId: string | null;
  timestamp: number;       // epoch ms
  correlationId: string;
  data?: Record<string, unknown>;
}
For human steps, output (after resume) includes the aggregator rollup:
{
  reviewers: Array<{ userId: string; mandatory: boolean }>;
  reviewerIds: string[];
  reviewerEmails: string[];
  commentBody: string | null;
  aggregatorStatus: 'resolved' | 'rejected';
  approveCount: number;
  rejectCount: number;
  totalResponses: number;
  mandatoryCount: number;
  mandatoryApproveCount: number;
  decision: 'approve' | 'reject';
  approved: boolean;
  resumedAt: number;
  resumeKey: string;
}
For joinOnQuorum group successor steps, input includes:
{
  groupOutputs: Record<string /* memberNodeId */, Record<string, unknown> /* member's output */>;
  groupId: string;
  quorum: number;
  totalApproved: number;
}