Documentation Index
Fetch the complete documentation index at: https://velt.dev/docs/llms.txt
Use this file to discover all available pages before exploring further.
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 or human), a config block, and an optional slaMs deadline.
Agent nodes
| Field | Type | Notes |
|---|
agentId | string | Required. |
promptOverride | string | ≤ 8000 chars. |
inputMapping | object | Pass step inputs to the agent. |
blocking | boolean | Default false. When true, the step parks in waiting until external resolutions arrive via /steps/recordAgentResolution. |
resolutionPolicy | object | Required when blocking: true. { kind: "allResolved" | "minResolved", minCount?: integer }. minCount is required when kind === "minResolved". |
agentMaxRuntimeMs | integer | ≤ 86400000. |
requireNonEmptyOutput | boolean | Fail 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.
| Field | Type | Notes |
|---|
reviewers | array | Preferred: [{ userId, mandatory }]. Must include at least one mandatory: true. UserIds must be unique. |
reviewerIds | string[] | Legacy. Accepted for back-compat only. |
reviewerEmails | string[] | Optional. 0–50 reviewer email addresses. Surfaced in the human step’s output.reviewerEmails for downstream notification UIs. |
commentBody | string | ≤ 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."
}
}
Per-human-node onReject shorthand
Authors can express the rejection path directly on a human node instead of declaring a top-level loops[] region or hand-writing an edge with when: decision == 'reject'. Two mutually-exclusive forms:
Form A — route on reject (synthesizes an edge):
{
"nodeId": "human-review",
"type": "human",
"config": {
"reviewers": [{ "userId": "u1", "mandatory": true }],
"onReject": { "routeToNodeId": "human-escalate" }
}
}
Form B — loop back on reject (synthesizes a top-level loop region):
{
"nodeId": "human-review",
"type": "human",
"config": {
"reviewers": [{ "userId": "u1", "mandatory": true }],
"onReject": {
"loopBack": {
"toNodeId": "agent-draft",
"maxIterations": 3,
"onExhausted": { "routeToNodeId": "human-final-call" }
}
}
}
}
| Field | Type | Required | Description |
|---|
onReject.routeToNodeId | string | one of | Synthesizes an edge { from: <thisNode>, to: routeToNodeId, when: 'output.decision == \'reject\'' }. |
onReject.loopBack.toNodeId | string | one of | Synthesizes a top-level loop region with entryNodeId = toNodeId. Body inferred from the topological closure (forward-reachable from toNodeId ∩ backward-reachable to the rejecting node). |
onReject.loopBack.maxIterations | integer | no | Default 5. Range 1–20. |
onReject.loopBack.onExhausted.routeToNodeId | string | no | Same semantics as LoopRegionDef.onExhausted.routeToNodeId. |
onReject.loopBack.when | string (JSON-AST) | no | Custom predicate. Defaults to mandatory-reject. |
Strict-mode validation
Every human node MUST satisfy ONE of:
config.onReject is set (any form), OR
- The node is a body member of a top-level
loops[] entry.
Otherwise the API returns INVALID_ARGUMENT with message Human nodes missing a reject path: <list>. This forces explicit rejection routing — silent dead-ends on rejection are a bug.
What desugaring rejects
The onReject shorthand is converted to canonical edges[] + loops[] at definition write time. The pass returns INVALID_ARGUMENT for:
| Condition | Why |
|---|
routeToNodeId (or loopBack.toNodeId / loopBack.onExhausted.routeToNodeId) references a node that doesn’t exist. | Target must be declared. |
| The synthesized reject edge would duplicate an existing user-supplied edge. | Pick one — declare the edge yourself or use the shorthand. |
The rejecting node has an unconditional outgoing edge alongside routeToNodeId. | The unconditional edge would also fire on rejection. Add an explicit when (e.g. decision == 'approve') to the forward edge, or switch to loopBack. |
loopBack body-inference returns empty (no path from toNodeId to this node along edges[]). | Customer must add edges or declare a top-level loops[] region. |
| The rejecting node is already a body member of another top-level loop, OR the inferred body would overlap another loop’s body. | A node may belong to at most one loop. Restructure or remove the conflicting loop. |
loopBack.toNodeId equals the node itself (degenerate self-loop). | No valid body can be inferred. |
onReject.routeToNodeId on a joinOnQuorum group member is dead code
The shorthand satisfies the strict-mode reject-path rule, but at runtime joinOnQuorum suppresses per-member fan-out — so the synthesized reject-gated edge can never fire. If you want per-rejecter routing on parallel reviewers, use cancelOnQuorum or waitAll (per-member fan-out preserved), or wrap the group in a top-level loops[] body so the loop interceptor handles rejections.
The stored definition has onReject STRIPPED from human node configs and the synthesized edges/loops appended to the top-level arrays. GET /definitions/get returns the canonical form (auto-synthesized loops carry a loopId prefix auto_<nodeId>_loopback). If you want to preserve your original input, store it on your side.
Edge gating expressions
Edges can carry an optional when expression evaluated against the source step’s output. Expressions compile at write time (pure AST, no eval) and walk at runtime. No untrusted code ever runs.
{ "from": "brand-check", "to": "legal-review", "when": "output.passesBrandCheck == true" }
Supported operators: equality, comparison, boolean, regex, includes, startsWith, endsWith, length, isEmpty.
Path roots:
| Root | Resolves to |
|---|
output.* | The source step’s output object. |
step.* | The source step’s metadata (status, timing). |
execution.input.* | The triggerContext you passed on dispatch. |
If when is omitted, the edge always fires.
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"
}
| Field | Type | Required | Description |
|---|
groupId | string | yes | 1 to 64 chars. Stable identifier. |
memberNodeIds | string[] | yes | 1 to 500 nodes. Each must be declared as a top-level node. A node may belong to at most one group. |
expectedSteps | integer | yes | 1 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.” |
quorum | integer | yes | 1 to expectedSteps. The number of approvals required to fire the policy’s side effect. |
onQuorumMet | enum | no | waitAll (default), cancelOnQuorum, or joinOnQuorum. |
requiredNodeIds | string[] | no | Specific 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:
- 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.
- 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
| Policy | Side effect on first-time approval-quorum-met | Per-member fan-out |
|---|
waitAll (default) | Emits group.quorum-met event only. Group is purely informational. | 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. |
cancelOnQuorum | Emits 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. |
joinOnQuorum | Emits 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:
- Every
nodeId in requiredNodeIds is among the approvers, AND
- 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. Declare loops at the top level of a definition, peer to groups[]:
{
"loops": [
{
"loopId": "draft-revision",
"entryNodeId": "agent-draft",
"bodyNodeIds": ["agent-draft", "human-legal", "human-brand"],
"onIterationReject": {
"when": "{\"op\":\"and\",\"args\":[{\"op\":\"eq\",\"args\":[{\"var\":\"output.decision\"},\"reject\"]},{\"op\":\"eq\",\"args\":[{\"var\":\"output.rejectorMandatory\"},true]}]}"
},
"onExhausted": { "routeToNodeId": "human-escalate" },
"maxIterations": 5
}
]
}
| Field | Type | Required | Description |
|---|
loopId | string | yes | 1–64 chars. Stable identifier for this loop. |
entryNodeId | string | yes | Node spawned first on each iteration. Must be a member of bodyNodeIds. |
bodyNodeIds | string[] | yes | 1–50 nodes inside the loop’s iteration scope. May include parallel-group members. |
onIterationReject.when | string (JSON-AST) | no | JSON-AST predicate evaluated when the loop body’s iteration-terminal step finishes. If it matches, iteration N+1 spawns. Default: decision == 'reject' && rejectorMandatory == true. |
onExhausted.routeToNodeId | string | no | Node spawned when maxIterations is reached. Receives previousAttempts like a regular iteration entry. If omitted, the execution rolls up to failed. |
maxIterations | integer | yes | 1–20. Hard cap per execution. |
Body-shape constraint
The body must be one of:
- Single-terminal sequential. Exactly one body node has outgoing edges that leave the body — that node is the iteration-terminal.
- 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.
When to use a loop vs. the onReject shorthand
- Use a loop region when multiple parallel reviewers should share a single retry counter, when the retry zone needs to contain a
joinOnQuorum group as a unit, or when you want explicit control over the loopId.
- Use the
onReject shorthand on a single human node for simple per-node reject routing (route to a different node on reject, or kick back to a single earlier node).
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 type | When emitted | data |
|---|
loop.iteration-started | Iteration N+1 spawns after a body iteration terminated rejected and the cap wasn’t hit. | { loopId, iteration, triggeredBy: 'rejection' } |
loop.exhausted | Cap 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 linted at create and update time. Any rule violation is rejected with INVALID_ARGUMENT and an explicit code in the error message.
| Code | Meaning |
|---|
duplicate-node-id | Two nodes share the same nodeId. |
dangling-edge | Edge references a from or to that isn’t declared. |
cycle-detected | The graph contains a cycle. v1 is DAG-only. |
unreachable-node | A node has no path from any root. |
node-missing-config | A node has no config block. |
missing-breach-edge | A node has slaMs set but no outgoing edge that routes on status == 'breached'. Breaches would silently dead-end. |
group-duplicate-id | Two groups share the same groupId. |
group-members-empty | memberNodeIds is empty. |
group-member-missing | A member references an unknown node. |
group-expected-steps-invalid | expectedSteps < 1. |
group-quorum-invalid | quorum < 1 or quorum > expectedSteps. |
group-cancelonquorum-requires-quorum-lt-expected | cancelOnQuorum requires quorum < expectedSteps. |
group-joinonquorum-members-must-share-successors | joinOnQuorum requires every member to have an identical set of outgoing-edge target nodes. |
group-required-not-in-members | An entry in requiredNodeIds is not in memberNodeIds. |
group-required-exceeds-quorum | requiredNodeIds.length > quorum. |
group-node-in-multiple-groups | A node appears as a member of two or more groups. |
loop-duplicate-id | Two loops share the same loopId. |
loop-entry-must-be-in-body | entryNodeId is not listed in bodyNodeIds. |
loop-body-member-missing | A bodyNodeIds entry isn’t a declared node. |
loop-body-unreachable-from-entry | Some body node is unreachable from entryNodeId along body-internal edges. |
loop-body-must-have-single-terminal | Body shape is neither single-terminal sequential nor group-bounded — see Loop regions › Body-shape constraint. |
loop-node-in-multiple-loops | A node appears in more than one loop body. A node may belong to at most one loop. |
loop-on-exhausted-route-to-not-found | onExhausted.routeToNodeId references an unknown node. |
loop-on-exhausted-route-to-in-body | onExhausted.routeToNodeId is itself a body node — exhausted-exit must escape the loop body. |
loop-group-bounded-quorum-must-equal-expected | 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. |
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 name | When emitted | data highlights |
|---|
execution.dispatched | same | Execution created. First step(s) scheduled. | { definitionId, definitionVersion, rootStepIds } |
execution.completed | same | All steps terminal, no unhandled failures. | null |
execution.failed | same | Any blocking step ended in failed or breached without a recovery edge. | { failureReason } |
execution.cancelled | same | /executions/cancel or full-execution rollback. | { reason? } |
step.awaiting-approval | step.waiting | A human or blocking-agent step entered waiting. | { waitingForReviewers, mandatoryCount, resumeKey } |
step.completed | same | Step transitioned to completed. | For human or blocking-agent: { aggregatorStatus, nodeType, decision, aggregatorBacked }. For non-blocking agents: { agentId }. |
step.failed | same | Step transitioned to failed (retry budget exhausted). | { error: { code, message } } |
step.breached | same | Step exceeded its configured SLA before completing. | { reason } |
step.cancelled | same | Step cancelled via /steps/cancel or by quorum-met side effect. | { actorId, reason } |
group.quorum-met | parallel-group.quorum-met | A parallel group’s approval threshold was first satisfied. | { groupId, total, quorum, completedTotal, expectedSteps } |
loop.iteration-started | same | Iteration N+1 spawns after a body iteration terminated rejected and the cap wasn’t hit. | { loopId, iteration, triggeredBy: 'rejection' } |
loop.exhausted | same | The 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.
| Reason | Source | Meaning |
|---|
group-quorum-met | system | Cancelled by the engine when the parent group’s approval quorum was met under cancelOnQuorum or joinOnQuorum. Audit shows actorId: "system:group-quorum". |
loop-restart | system | Cancelled 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) | admin | Free-form reason passed to /steps/cancel. |
Webhook retry policy
| Attempt | Delay before retry |
|---|
| 1 (initial) | n/a |
| 2 | 2 s |
| 3 | 8 s |
| 4 | 32 s |
| 5 | 2 min |
| 6 | 8 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
| Code | Meaning | Typical cause |
|---|
INVALID_ARGUMENT | Schema or linter failure. | Missing field, wrong type, value out of range, linter rule violation. |
UNAUTHENTICATED | Missing or invalid x-velt-auth-token. | |
PERMISSION_DENIED | Auth token valid but lacks the required scope. | /steps/resolve with reviewer-approve / reviewer-reject and actorId not in the step’s reviewer list. |
NOT_FOUND | Target doc does not exist. | Unknown executionId, definitionId, or stepId. |
ALREADY_EXISTS | Conflicting create. | Creating a definition with a definitionId already in use. |
FAILED_PRECONDITION | Optimistic lock or state-machine violation. | ifVersion mismatch on update. Cancelling a terminal step. Deleting a definition with in-flight executions. |
RESOURCE_EXHAUSTED | Rate limit exceeded. | Per-IP or per-API-key quota. |
DEADLINE_EXCEEDED | Internal timeout. | Retry with idempotency. |
Schema-level validation errors
message | Trigger |
|---|
webhookUrl and webhookSecret must be provided together | Dispatch supplied one but not the other. |
webhookUrl must use https scheme | Non-HTTPS scheme. |
webhookUrl host resolves to a private, loopback, or link-local address | Literal private IP, localhost, metadata.google.internal, or *.internal. |
at least one of reviewerIds or reviewers must be provided | Human node with no reviewers. |
cannot set both reviewerIds and reviewers, use one | Both populated. Pick the modern reviewers[] form. |
reviewer userIds must be unique | Duplicate userId in reviewers[]. |
reviewers must include at least one mandatory reviewer (allMandatoryApproved would otherwise never resolve) | Every reviewer.mandatory === false. |
resolutionPolicy required when blocking === true | Blocking 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;
triggers: WorkflowTriggerConfig[] | null;
tags: string[] | null;
custom: Record<string, unknown> | null;
createdAt: number;
updatedAt: number;
status: 'active' | 'tombstoned';
}
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;
}