Node configuration
Every node has anodeId, 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 inwaiting 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.
agentExecutionStatus, agentResultsSummary, resolvedUrl, agentDurationMs, and a decision of approve when the agent passed. Route on any of them with a custom predicate.
Human nodes
A human node waits for reviewers to approve or reject. Set exactly one ofreviewers[] or the legacy reviewerIds[].
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.
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 inedges[]: 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 whosetois an ancestor offrom, marked withloop. The server derives the loop region from it. -
Exhausted route. A sibling
on: "exhausted"edge from the samefrom, fired when the loop hits its cap. Without one, an exhausted loop rolls the execution up tofailed.
Custom predicates
Useon: "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.
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.
waitAllas an edge source. All members must terminate, then the group takes one branch by unanimity:approveif every member approved, otherwisereject. 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 tocompleted, and the rejecting member iscompletedwithoutput.decision: "reject", neverfailed.cancelOnQuorumandjoinOnQuorumas edge sources. They fire one collective approve-successor on approval quorum. A forwardon: "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.
group_<groupId>__to__<childNodeId>. Group-to-group edges are rejected.
The compiled view
EveryDefinitionView 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
SetslaMs 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 endscompleted with output.decision === "approve". Rejections, failures, breaches, and cancellations advance the completion counter, never the approval counter.
Two consequences follow:
- Agent nodes do count toward quorum. An agent step ends
completedwithoutput.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, whoseoutputis empty and carries nodecision. See Anti-patterns. - 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 inrequiredNodeIds:
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 anon: "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.
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:- Single-terminal sequential. Exactly one body node has edges leaving the body. That node is the iteration terminal.
- Group-bounded. The exit-bearing body nodes are exactly the
memberNodeIdsof onejoinOnQuorumgroup, every member is inside the body, and the group hasquorum === expectedSteps.
loop-body-must-have-single-terminal.
Context threaded into the next iteration
The entry step of iteration N+1 receives:Triggers
Add atriggers[] 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
DeclaringinboundWebhook 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-triggersecretis what actually authenticates the call. - Identifiers. Put
definitionIdandtriggerIdin the body forvelt, 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 }andpayloadbecomes thetriggerContext. For every other provider, the entire body becomes thetriggerContext. - Idempotency. The source event id becomes the
idempotencyKey, namespaced astrig:<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" }.
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
Declaringschedule 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:- Connect the installation once from your Velt dashboard. This returns an
installationRef: the GitHubinstallation.id, or the Vercelconfiguration.id. - Add an
appTriggerto a definition, referencing thatinstallationRef. - 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.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 returnsINVALID_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 literalmessage 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. ARESOURCE_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:

