Three docs, three jobs. This guide teaches you the journey: concepts plus a working integration. Customize Behavior is the patterns and field reference: which feature fits your scenario, plus every node/edge/group field. The REST API Reference is the field-level law: every endpoint, every field, every error. When this guide and the API Reference disagree, the API Reference wins.
What is the Approval Engine?
The Approval Engine lets you describe a review and approval process as a workflow, for example “an AI agent checks the document, then two people sign off, then we publish”, and have Velt run it for you, durably and auditably. You author the workflow once as a definition (a graph of steps). Then, whenever something in your app needs review, you dispatch an execution against it. The engine walks the graph: it runs AI agents, waits for human approvals, evaluates branching conditions, enforces deadlines, and notifies you when the run finishes. The key idea: comments are the UI. Each step surfaces in the same Velt comment thread your users already work in, so there’s no separate dashboard to build or learn. Reviewers act where the work lives. What you can build with it:- AI-first pipelines (agent drafts/checks, humans approve)
- Sequential sign-offs (manager → legal → publish)
- Parallel reviews with quorum (“any 2 of 3 approve”)
- Time-boxed reviews that escalate on breach
- Kick-back-for-revision loops
- Externally-triggered runs and callbacks via webhooks
Core concepts
| Concept | What it is |
|---|---|
| Definition | The static blueprint of a workflow: nodes + edges + optional groups. You give it a stable definitionId. Definitions are versioned: every update creates a new version, and in-flight runs stay pinned to the version they started on. |
| Node | One unit of work. Three types: agent (runs an AI agent), human (waits for a reviewer’s approve/reject), and webhook (call out to an external system; runtime deferred in v1). |
| Edge | A directed connection: “when this node finishes, start that one.” An edge can carry an optional when condition so it only fires when the condition holds. |
| Group | A parallel-group declaration: a set of nodes that run together and share an approval quorum policy. |
| Execution | One live run of a definition against a work item. Has an executionId, a status (pending → running → completed | failed | cancelled), and a steps[] array. |
| Step | One runtime instance of a node. Human and blocking-agent steps park in waiting until a signal arrives. |
| Scope | A definition applies at the apiKey (workspace), organization, or document level. The most specific matching definition wins. See Scope. |
loops[], not back-edges). A definition can have up to 100 nodes and 500 edges. See the full field-level contract in Object reference.
Prerequisites & authentication
Base URLs| Environment | Base URL |
|---|---|
| Production | https://api.velt.dev |
| Staging | https://staging.velt.dev |
/v1/... and /v2/... accept the same shapes; new integrations should use /v2/.
Required headers on every request
| Header | Description |
|---|---|
x-velt-api-key | Your workspace API key. |
x-velt-auth-token | A short-lived auth token. See Auth Tokens. |
content-type | application/json |
apiKey/authToken in the body; they’re read from the headers.
Request / response envelope. Wrap your payload in data; success comes back under result, errors under error:
Build your first workflow
You’ll build the smallest workflow that exercises the whole engine: one human approval, where approval finishes the run and a rejection routes to a follow-up step.Create the definition
A A successful create returns a
human node must declare what happens on rejection: either an onReject shorthand or membership in a loops[] body. Here we use the onReject.routeToNodeId shorthand to send rejections to a follow-up node, and we gate the success edge with when: decision == 'approve' so it only fires on approval.The when value is a JSON-AST string: the engine parses it as JSON and evaluates it with a safe walker, never as JavaScript. 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". If the engine rejects the definition you’ll get INVALID_ARGUMENT with a linter code in the message.The onReject.routeToNodeId shorthand synthesizes the reject-gated edge for you, so you only need to author the explicit approve-side routing. See Per-human-node onReject shorthand for choosing a rejection strategy. Full request shape: Create Definition.Dispatch an execution
Dispatch starts a run against a work item. Response:Keep the
triggerContext is free-form data your nodes and edge expressions can read as execution.input.*. Pass an idempotencyKey so retries never spawn duplicates.executionId; it’s the handle for everything that follows. (deduplicated: true means this was a replay of an earlier dispatch with the same idempotencyKey, and you got the original execution back.) Full request shape: Dispatch Execution.Find the pending step and record a decision
Fetch the execution to see which step is waiting on a human:Look for a step with Response:
"status": "waiting" and "nodeType": "human", and grab its stepId. In v1 you own the reviewer UI: render the pending step to your user, and when they click approve/reject, call recordReviewerDecision:reviewerId must match a userId declared on the node. When all mandatory reviewers approve (or any reviewer rejects), the step resolves and the workflow advances. Recording the same reviewer’s decision twice is idempotent (recorded: false on replay). Full request shape: Record Reviewer Decision.Get the outcome
You have two complementary ways to learn how the run ends.A. Webhook push (real-time). Pass Each delivery carries an Delivery is at-least-once with retries (
webhookUrl + webhookSecret on dispatch and the engine POSTs every externally-visible event to you, signed with HMAC-SHA256:x-velt-signature: sha256=<hex> header. Verify it against the raw body:2s → 8s → 32s → 2m → 8m) before dead-lettering, so make your receiver idempotent: dedupe by eventId or (executionId, seq). webhookUrl must be HTTPS and public (private/loopback hosts are rejected). See Webhook retry policy.B. Events polling (catch-up). Whether or not you use webhooks, you can read the event stream directly. Pass the highest seq you’ve processed as sinceSeq to get only what’s new; it’s the recovery path after a missed webhook:seq is monotonic per execution (internal events are filtered out, so values may skip; that’s normal). When you see execution.completed (or execution.failed), the run is done. Full request shape: Get Execution Events.Recommended production setup: webhooks for liveness, polling as the recovery path.
Putting it together: a realistic workflow
Once the basics click, you compose richer graphs. Here’s an AI-assisted parallel review: an agent drafts, then 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. Agent nodes run automatically (the engine dispatches the agent and resumes the step when it finishes); human nodes wait for recordReviewerDecision as in Step 3. See Parallel groups and quorum policies.
Going further
Short pointers to the features you’ll reach for next. Each links to the decision-level guidance and the field-level contract.- Conditional routing (
whenexpressions). Gate any edge with awhenJSON-AST predicate overoutput.*,step.*, andexecution.input.*. Operators include equality, comparison, boolean and/or/not, regex, includes, startsWith, endsWith, length, isEmpty. → Edge gating expressions. - Parallel groups & quorum. Declare a
groups[]entry to run reviewers in parallel under one of three policies:waitAll(observability only),cancelOnQuorum(stop bothering siblings once enough approve),joinOnQuorum(run the successor once after quorum). UserequiredNodeIdsfor “these specific people must approve.” →onQuorumMetpolicies · Specific-must-approve quorum. - Rejection handling: shorthand vs. loops. For one reviewer,
onReject.routeToNodeId(route away) oronReject.loopBack(retry up to N times, then escalate) usually suffice. When multiple parallel reviewers must share one retry budget, or a whole stage must rewind as a unit, declare a top-levelloops[]region instead. → Loop regions · When to use a loop vs. theonRejectshorthand. - SLA timers & escalation. Set
slaMson a node to enforce a deadline; on breach the step becomesbreachedand the engine follows your outgoing edges. Gotcha: a node withslaMsmust have an edge that routes onstatus == 'breached', or the definition is rejected (missing-breach-edge). Agent nodes also have a hard runtime ceiling (agentMaxRuntimeMs, default 30 min). → SLA and breach handling. - Agent nodes. An agent node requires
agentIdandurlPath(a dot-path intotriggerContextthat resolves the URL the agent should act on). The step output exposesagentExecutionStatus,agentResultsSummary, and adecision(approvewhen the agent passed). Some configurations post findings that are resolved via Record Agent Resolution. → Agent nodes. - Externally-triggered runs & async callbacks. Beyond dispatching from your backend, external systems can kick off runs or complete long-running steps via the inbound webhook surface. → Inbound webhook handler.
- Versioning & scope. Editing a definition (Update Definition) creates a new version; in-flight executions keep running on their pinned version. Scope a definition to a workspace, organization, or document; the most specific match wins. → Scope.
- Cancellation. Stop a whole run with Cancel Execution, or a single step with Cancel Step. Admins can override a parked step with Resolve Step.
Events you’ll receive
These event types are delivered via webhook and returned from Get Execution Events:| Event | When |
|---|---|
execution.dispatched | Run created; first step(s) scheduled. |
step.awaiting-approval | A human (or blocking-agent) step entered waiting. |
step.completed | A step finished successfully (human resumes include decision). |
step.failed | A step failed after exhausting its retry budget. |
step.breached | A step missed its SLA deadline. |
step.cancelled | A step was cancelled (directly or by a quorum side effect). |
group.quorum-met | A parallel group’s approval threshold was first met. |
execution.completed | All steps terminal, no unhandled failure. |
execution.failed | A blocking step failed/breached with no recovery edge. |
execution.cancelled | The run was cancelled. |
data fields in the Event reference.
Errors & troubleshooting
Errors use the standard envelope with a gRPC-style status code:| Code | Meaning |
|---|---|
INVALID_ARGUMENT | Request failed schema or linter validation. |
UNAUTHENTICATED | Missing/invalid x-velt-auth-token. |
PERMISSION_DENIED | Token valid but lacks the required scope (e.g. admin-only /steps/resolve). |
NOT_FOUND | Unknown executionId, definitionId, or stepId. |
ALREADY_EXISTS | A definition with that definitionId already exists. |
FAILED_PRECONDITION | State/lock violation (e.g. resolving a step that isn’t waiting, deleting a definition with in-flight runs). |
RESOURCE_EXHAUSTED | Rate limited; back off and retry (safe with an idempotencyKey). |
INVALID_ARGUMENT, with a code in the message):
- Human node missing a reject path: add
onReject, or include the node in aloops[]body. missing-breach-edge: a node hasslaMsbut no edge routes onstatus == 'breached'.whenwritten as JavaScript: it must be a JSON-AST string, not"output.decision == 'approve'".- Group quorum >
expectedSteps, or a non-blocking agent placed in a quorum group (it has nodecision, so quorum can never be met).
Limitations in v1
- No visual builder: you author definitions as JSON.
- You host the reviewer UI: render the pending step and call
recordReviewerDecision; a native comment-reply approval UX is not yet a v1 promise. webhooknodes are deferred: the type validates in a definition, but the runtime handler isn’t enabled in v1. (This is distinct from the inboundapprovalwebhookhandler, which is available for external systems to trigger or call back into runs.)- No in-flight definition migration: editing a definition only affects new runs; in-flight ones finish on their pinned version.

