New here? Read the Overview first for the mental model. Customize Behavior is the field reference.
Before you start
All endpoints are underhttps://api.velt.dev/v2/. Every request needs three headers:
Never put
apiKey or authToken in the body. They are read from the headers.
Wrap your payload in data. Success comes back under result, errors under error:
Build your first workflow
You will build the smallest workflow that exercises the whole engine: one human approval. Approving finishes the run. Rejecting routes to a follow-up step.Step 1: Create the definition
Everyhuman node needs a reject path, so give it an outgoing on: "reject" edge. Approving has no outgoing edge here, so the run completes after the approval.
The follow-up node uses the reserved __mock__ agent id so you can run this end to end without registering a real agent. Use a real agentId in production.
DefinitionView with version: 1 and status: "active".
The engine validates the graph at write time. Schema errors, edge-contract errors, cycles, dangling edges, unreachable nodes, and bad quorum settings all fail here rather than at run time. A rejected definition returns INVALID_ARGUMENT. The message is the human-readable rule text, for example every human node must have at least one outgoing edge with on="reject" (a forward reject route or a reject back-edge): manager-approval. Linter failures put everything in error.message too: the text Definition linter failed: followed by a JSON array whose entries each carry a code such as missing-breach-edge. Parse error.message to read those codes, because error.details is not set on a linter failure. Only schema failures populate error.details, with an issues array of Zod { code, path, message } entries. Match on the linter code; the APPROVAL_* names are internal rule identifiers and never appear in a response.
Full request shape: Create Definition.
Step 2: Dispatch an execution
Dispatch starts a run against one work item. Write the definition once and reuse it across many dispatches.triggerContext is free-form data your nodes and edge conditions read as execution.input.*. Pass an idempotencyKey so retries never spawn duplicates.
executionId. It is the handle for everything that follows. deduplicated: true means you replayed an earlier dispatch with the same idempotencyKey and got the original run back.
Full request shape: Dispatch Execution.
Step 3: Record a decision
Fetch the execution to find the step waiting on a human:"status": "waiting" and "nodeType": "human", then grab its stepId.
You own the reviewer UI in beta. Render the waiting step to your user, and when they click approve or reject, call recordReviewerDecision:
reviewerId must match a userId declared on the node. The step resolves and the workflow advances when every mandatory reviewer approves, or when any reviewer rejects. Recording the same reviewer’s decision twice is idempotent and returns recorded: false.
Full request shape: Record Reviewer Decision.
Step 4: Get the outcome
You have two ways to learn how the run ends. Use both in production: webhooks for liveness, polling for recovery.A. Webhooks, for real time
PasswebhookUrl and webhookSecret on dispatch. The engine POSTs every externally-visible event to you, signed with HMAC-SHA256:
Verify the signature against the raw request body bytes. Do not re-serialize the parsed JSON:
2s → 8s → 32s → 2m → 8m before dead-lettering. The same eventId and seq appear on retries, so make your receiver idempotent: dedupe on eventId or (executionId, seq).
To send the same events to one receiver for every run of a definition, set webhookConfig on the definition instead. See Webhook delivery.
B. Event polling, for catch-up
You can read the event stream directly whether or not you use webhooks. Pass the highestseq you have durably stored as sinceSeq to get only what is new:
seq is monotonic per execution. Only external event types are returned, so your stream may have gaps in seq. That is normal. When you see execution.completed or execution.failed, the run is done.
Full request shape: Get Execution Events.
Events you will receive
Payload shapes are in the Event reference.
A realistic workflow
Once the basics click, you compose richer graphs. Here an agent drafts, legal and brand review in parallel, and a single publish step fires once both approve:agent-draft runs first and fans out to both reviewers. Because the group uses joinOnQuorum with quorum: 2, agent-publish runs exactly once after both approve, not once per approver.
The reject back-edge from the group is required, not decorative. Every human node needs a reject route, including group members. It also makes the workflow useful: if either reviewer rejects, the work loops back to agent-draft for up to 3 revisions. See Parallel groups.
How the events play out
How the events play out
Stopping reviewers early with cancelOnQuorum
Stopping reviewers early with cancelOnQuorum
A group of 3 reviewers with The two approvers’ downstream paths still fan out. The cancelled reviewer’s edges do not fire.
quorum: 2 and onQuorumMet: "cancelOnQuorum":Start runs without calling dispatch
You do not have to call the dispatch API. Add atriggers[] entry to a definition and the engine starts runs for you:
- Inbound webhook: an external system POSTs to the engine. GitHub, Vercel, and custom signature presets are built in.
- Cron schedule: a cron expression starts a run on a cadence.
- Installed app: connect the Velt GitHub App or Vercel Integration once, then events route to your workflows automatically.
Common errors
The four most common reasons the engine rejects a definition:
- A human node has no outgoing
on: "reject"edge. The message isevery human node must have at least one outgoing edge with on="reject" (a forward reject route or a reject back-edge), then a colon and the offendingnodeId. - An agent node has neither
urlnorurlPath. The message isagent node requires either a static "url" or a "urlPath". - A node sets
slaMsbut nothing routes on a breach. The linter rejects it with thecodemissing-breach-edge. whenwritten as JavaScript. It must be a JSON-AST string, not"output.decision == 'approve'".
error.message as Definition linter failed: plus a JSON array, and every entry carries a code. Parse the message to read them, because error.details stays empty on a linter failure. Match on that code rather than on the surrounding message text.
See Linter rules for the full list, and Anti-patterns for the mistakes that trigger them.
Next steps
Customize Behavior
Node config, edge routing, triggers, quorum, SLAs, events, and errors.
REST API Reference
Definitions, Executions, and Steps with full schemas.

