Skip to main content
This is the field reference. Setup covers the happy path, and Patterns covers which option to pick. This page covers everything you reach for when you need precise control.

Node configuration

Every node has a nodeId, a type, and a config block. config is validated strictly against the type. Unknown fields are rejected.

Agent nodes

An agent node runs a Velt agent against a URL, then routes on the result. The step parks in waiting while the agent runs and resumes on its own when the agent finishes, so you never have to poke it. You must set url or urlPath. Setting neither is rejected with APPROVAL_AGENT_NODE_REQUIRES_URL_OR_URLPATH. If urlPath resolves to nothing at run time and no static url is set, the step fails with agent-url-unresolved. A urlPath value with no scheme is normalized. Vercel’s payload.deployment.url of my-app.vercel.app becomes https://my-app.vercel.app. Existing http:// and https:// values are left alone.
The step output carries agentExecutionStatus, agentResultsSummary, resolvedUrl, agentDurationMs, and a decision of approve when the agent passed. Route on any of them with a custom predicate.
blocking: true requires a sibling resolutionPolicy to pass schema validation, and the pair is then rejected at run time with agent-blocking-not-supported, for every agent id including __mock__. To put a person in front of an agent’s findings, place a human node downstream of the agent node instead.

Human nodes

A human node waits for reviewers to approve or reject. Set exactly one of reviewers[] or the legacy reviewerIds[].
The step resolves when every mandatory reviewer approves, or when any reviewer rejects. Record decisions with Record Reviewer Decision.
A human node carries no rejection config. Its reject path is an outgoing on: "reject" edge, and every human node must have one, or the definition is rejected with APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH. Group members are included in that rule.

Notification nodes

A notification node sends an email or Slack message built from the previous step’s output. No extra cloud function needed.
Templating is dot-path substitution only. There is no code execution. Missing tokens render as an empty string, and objects and arrays are JSON-stringified. Three roots are available: Delivery notes. Email goes through your workspace’s SendGrid configuration. Delivering to at least one recipient completes the step; delivering to none fails it and retries. Slack delivery to a channel id needs a workspace bot token, and a webhook-URL target must be https on an allowed host. Slack config errors such as channel_not_found or invalid_auth are terminal and are not retried, while 5xx, network errors, and rate_limited are retried. format: "slack-blocks" means the rendered body must be a JSON array of Slack Block Kit blocks. It is valid only on the Slack channel and is rejected on email with APPROVAL_NOTIFICATION_SLACK_BLOCKS_REQUIRES_SLACK.

Webhook nodes

A webhook node calls your own HTTPS endpoint as a workflow step. sync mode posts and waits for your response. A 2xx (or a listed expectedStatusCodes value) completes the step. A 4xx fails it terminally, because that is a configuration error on your side. A 5xx, timeout, or network error fails it with retry budget remaining. async mode posts the payload, then parks the step in waiting. With the default bodyTemplate: "envelope", the body carries callback.url, callback.token, and callback.tokenHeader. With bodyTemplate: "pass-through", the same three values are nested under _velt as _velt.callbackUrl, _velt.callbackToken, and _velt.callbackTokenHeader. To complete or fail the step, POST to that callback URL with the token in the x-velt-callback-token header and a body of { "status": "completed" | "failed", "output"?: {}, "error"?: {} }. Async mode needs a webhookSecret on the execution to sign the callback token. With authMode: "hmac" the engine signs the outbound body with the execution’s webhookSecret in x-velt-signature. With authMode: "token" it sends a static token, whose value is read at dispatch time from triggerContext.webhookAuth[<nodeId>] so the secret never lives in the definition. On success the step output carries httpStatus, allowlisted responseHeaders, responseJson when the response is JSON, and responseText capped at 64 KB.

Edge model

Every transition is one entry in edges[]: approve routing, reject routing, group fan-out and fan-in, and loop-backs.
Edges round-trip exactly. What you POST is what you read back.

Reject, loop-back, and exhausted

  • Reject route. 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 from it.
  • Exhausted route. A sibling on: "exhausted" edge from the same from, fired when the loop hits its cap. Without one, 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.
Common shapes: Supported operators: equality, comparison, boolean and/or/not, regex, includes, startsWith, endsWith, length, isEmpty. Path roots: An on: "always" edge, the default when on is omitted, fires whenever the source step reaches a fan-out-eligible status.

Groups as edge sources

A group can be an edge source (from: { kind: "group", groupId }), giving it one collective branch instead of one fan-out per member.
  • waitAll as an edge source. All members must terminate, then the group takes one branch by unanimity: approve if every member approved, otherwise reject. Provide both branches and exactly one fires. A routed collective reject is not a failed run: when the reject branch’s successor completes, the execution rolls up to completed, and the rejecting member is completed with output.decision: "reject", never failed.
  • cancelOnQuorum and joinOnQuorum as edge sources. They fire one collective approve-successor on approval quorum. A forward on: "reject" from either is rejected as a dead edge, because those policies only fan out on approval.
  • Edges into a group (to: { kind: "group", groupId }) expand to one edge per member, for every quorum policy.
Successor step ids from a group are group_<groupId>__to__<childNodeId>. Group-to-group edges are rejected.

The compiled view

Every DefinitionView returns a read-only compiled block next to your authored edges. compiled.forwardEdges is the runtime edge list with group endpoints expanded and on roles compiled to predicate ASTs. compiled.loops is the derived loop-region list. Render compiled directly instead of re-implementing the compiler in your client. See Get Definition.

SLA and breach handling

Set slaMs on any node to give the step a deadline, up to 7 days. If the step does not complete in time it becomes breached and emits step.breached. Declare an outgoing edge that routes on the breached status: either an on: "always" edge, which fires on the four fan-out-eligible statuses completed, skipped, breached, and failed, or an on: "custom" edge whose when tests for breached. A node that has outgoing edges but none that route on a breach is rejected with missing-breach-edge. A terminal node with no outgoing edges is accepted; if it breaches, the execution rolls up to failed and emits execution.failed. Agent nodes also have their own ceiling via agentMaxRuntimeMs, which defaults to 10 minutes.

Parallel groups and quorum policies

A parallel group declares member nodes that run in parallel and share an approval threshold.

Quorum counts approvals, not completions

A member counts toward quorum only when it ends completed with output.decision === "approve". Rejections, failures, breaches, and cancellations advance the completion counter, never the approval counter. Two consequences follow:
  1. Agent nodes do count toward quorum. An agent step ends completed with output.decision: "approve" when its run passes or is skipped, so it advances the approval counter exactly like a human approval. The one exception is an agent step that fails before dispatch: it takes the failure path, whose output is empty and carries no decision. See Anti-patterns.
  2. A reject does not block group completion. It just stops the approval counter from advancing. Completion and quorum are tracked separately.

onQuorumMet policies

A joinOnQuorum successor’s input is { groupOutputs, groupId, quorum, totalApproved }.

Requiring specific approvers

By default quorum is anonymous: any N approvals out of M members fire the policy. To require specific people, list them in requiredNodeIds:
Quorum now needs both conditions: every node in requiredNodeIds approved, and the numeric quorum reached. Here brand approving alone never satisfies quorum, even though quorum: 2 could be met numerically. Omit requiredNodeIds to fall 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 do not declare loops directly. Mark an on: "reject" edge with loop, where the to is an ancestor of the from, and the server derives the region. Add a sibling on: "exhausted" edge to route when the cap is reached.
The derived region is surfaced read-only as compiled.loops[]: The iteration predicate is decision == 'reject' && rejectorMandatory == true. Custom loop predicates are not supported: loop is valid only on an on: "reject" back-edge.

Body-shape constraint

The derived loop body must be one of two shapes:
  1. Single-terminal sequential. Exactly one body node has edges leaving the body. That node is the iteration terminal.
  2. Group-bounded. The exit-bearing body nodes are exactly the memberNodeIds of one joinOnQuorum group, every member is inside the body, and the group has quorum === expectedSteps.
Other shapes are rejected with loop-body-must-have-single-terminal.

Context threaded into the next iteration

The entry step of iteration N+1 receives:
When a parallel group lives inside a loop body, each iteration gets fresh quorum state, so per-iteration quorum always starts from zero.

Triggers

Add a triggers[] entry to a definition and the engine starts runs for you, with no dispatch call. Each entry carries at most one mechanism: inboundWebhook, schedule, or appTrigger. Combining two on one entry is rejected. A definition may declare up to 50 entries, each using a different mechanism.
Scope is inherited, always. A triggered run carries the owning definition’s scope. An organization- or document-scoped definition fires runs with the same organizationId and documentId. Org and document ids in a webhook body never set the run’s scope.

Inbound webhook trigger

Declaring inboundWebhook exposes the definition at POST /v2/workflow/webhook-inbound/trigger so external systems can start runs. Velt’s native contract verifies an HMAC or bearer signature, and provider presets let GitHub, Vercel, or a custom source sign with their own scheme.
Presets resolve the signature header, algorithm, prefix, and event-name source for you: Any provider other than velt requires authMode: "hmac". Request rules.
  • API key. Send x-velt-api-key, or use the ?apiKey= query param for providers that cannot set custom headers. The header wins if both are present. The Velt API key is a publishable client key, and the per-trigger secret is what actually authenticates the call.
  • Identifiers. Put definitionId and triggerId in the body for velt, or pass them as ?definitionId= and ?triggerId= query params.
  • Body. A JSON object up to 1 MB. For velt, shape it as { definitionId, triggerId, payload } and payload becomes the triggerContext. For every other provider, the entire body becomes the triggerContext.
  • Idempotency. The source event id becomes the idempotencyKey, namespaced as trig:<triggerId>:<id> and deduplicated for 24 hours. Without one, the engine falls back to a per-request key.
  • Responses. Success returns { ok: true, code: "accepted", executionId, deduplicated }. A filtered event returns HTTP 200 with { ok: false, code: "event-ignored" }.
An allowedEvents gate fails closed. If the event name cannot be resolved, the event is dropped.
This endpoint takes raw JSON. Unlike the other REST endpoints, you do not wrap the payload in a data envelope, because external providers cannot reshape their outgoing bodies. Bodies over 1 MB are rejected with HTTP 413 and code body-too-large. This endpoint applies no per-source rate limiting, so add your own throttling in front of it if your source can burst. URL values inside the payload are not screened: the SSRF allowlist applies only to outbound destination URLs you configure on a definition, meaning the webhook node url, webhookConfig.url, the dispatch webhookUrl, and a URL-valued slackTarget.

Scheduled (cron) trigger

Declaring schedule starts runs on a cron cadence, for example a nightly audit.
The dispatched run’s triggerContext.schedule carries { triggerId, scheduledAt, payload }. A schedule fires at most once per scheduled instant, and missed runs are not replayed.

App trigger

Install the Velt GitHub App or Vercel Integration once, and matching events route to your workflows automatically. There is no per-repo or per-project webhook to configure, and no secret on the trigger: deliveries are authenticated app-wide against the provider’s signature. Three steps:
  1. Connect the installation once from your Velt dashboard. This returns an installationRef: the GitHub installation.id, or the Vercel configuration.id.
  2. Add an appTrigger to a definition, referencing that installationRef.
  3. Events fire automatically. Every matching event starts a run.
Each delivery is deduplicated on the provider’s delivery id, so duplicate deliveries never re-run a workflow. Events are matched by (provider, installationRef), then narrowed by your filters.
App triggers are available on Superflow-platform workspaces. Creating a definition with an appTrigger from another workspace returns FAILED_PRECONDITION with APPROVAL_APP_PLATFORM_NOT_SUPPORTED.
Example: scan every Vercel production deploy for broken links, then email the result.
urlPath resolves the just-deployed URL from the Vercel payload. It arrives without a scheme, so the engine normalizes it to https://.

Cancelling and overriding

Cancelling a run is a no-op on a run that already reached a terminal status. Successors of a cancelled step are never scheduled. Resolve Step has two flavors. force-approve, force-reject, force-complete, and force-fail are admin overrides for hung steps. reviewer-approve and reviewer-reject are reviewer actions, and they additionally require actorId to be one of the step’s declared reviewers, or the call is rejected with PERMISSION_DENIED. For every approve or reject action the engine computes decision, approved, and approvalReply itself and writes them as authoritative. A caller-supplied output carrying those keys cannot override them, so you cannot record a rejection in the audit log while routing downstream edges as an approval. Other keys in output still pass through.

Webhook delivery

Set a receiver in one of two places: secret is 16 to 512 chars. Optional eventTypes narrows delivery to up to 50 event types. Both forms are https-only and SSRF-guarded at write time and again at delivery time.

Retry policy

After the final retry the payload goes to a dead-letter queue. Recover missed events with Get Execution Events and sinceSeq. Delivery is at-least-once. The same eventId and seq appear on retries, so make your receiver idempotent on (executionId, seq). For signature verification and receiver setup, see Setup, Step 4.

Events

Event reference

Events delivered by webhook and returned from Get Execution Events: A step using the reserved __mock__ agent id completes inline and emits { agentId, synthetic, decision } instead, so a receiver you tested against __mock__ sees different data keys in production. The { code, message } error object is not part of the event data. It lives on the step’s error field, which you read with Get Execution as steps[].error. Route on event.type, then fetch the step for the detail.
Internal events such as step.scheduled, step.started, step.retried, and step.overridden consume seq numbers but are not delivered externally. Your stream may have gaps in seq, which is expected.

Cancellation reasons

step.cancelled carries a data.reason string. This is an open set, so switch on event.type for control flow, not on data.reason.

Linter rules

Definitions are validated on create and update. Any violation returns INVALID_ARGUMENT with the code in the message.

Edge validation errors

Errors

All errors follow the standard envelope:

Canonical codes

Schema validation messages

These are the literal message strings returned in the error envelope. The APPROVAL_* names used elsewhere on this page are internal rule identifiers, not values you will find in a response body.

Rate limiting

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

Object reference

Step ids

Step ids are deterministic, so retries land on the same record:

Human step output

After a human step resumes, output carries the aggregator rollup:

joinOnQuorum successor input