> ## 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.

# Customize Behavior

> Node config, edge routing, parallel groups, triggers, SLAs, linter rules, events, errors, and the object reference.

This is the field reference. [Setup](https://velt.dev/docs/ai/approval-engine/setup.md) covers the happy path, and [Patterns](https://velt.dev/docs/ai/approval-engine/patterns.md) 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.

| Field | Type | Notes |
| - | - | - |
| `nodeId` | string | Required. 1 to 64 chars. Unique within the definition. |
| `type` | enum | Required. `agent`, `human`, `notification`, or `webhook`. |
| `config` | object | Required. Shape depends on `type`. See below. |
| `slaMs` | integer | Deadline for the step, up to 7 days. See [SLA and breach handling](#sla-and-breach-handling). |
| `requireNonEmptyOutput` | boolean | Sync webhook nodes only. Fails the step terminally with `webhook-node-empty-response` when the receiver returns an empty body. It has no effect in `mode: "async"`, because the step parks in `waiting` before the check runs. Accepted on the other node types, with no runtime effect. |
| `name` | string | Cosmetic label, 1 to 200 chars. Echoed back, no runtime effect. |
| `description` | string | Cosmetic, up to 2000 chars. Echoed back, no runtime effect. |

`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.

| Field | Type | Notes |
| - | - | - |
| `agentId` | string | Required. A built-in or custom agent id. The reserved value `__mock__` returns a synthetic pass, for demos and tests only. |
| `url` | string | A fixed absolute URL, up to 2000 chars. **Wins when both `url` and `urlPath` are set.** |
| `urlPath` | string | A dot-path into `triggerContext` that resolves the URL from the trigger payload, up to 500 chars. |
| `crossPageExecute` | boolean | Let the agent crawl beyond the seed URL. Defaults to `false`. |
| `maxUrlsToProcess` | integer | Cap on URLs crawled per run, up to 500. |
| `userContextMapping` | object | `{ field: dotPath }` map that builds the agent's `userContext` from `triggerContext`. |
| `promptOverride` | string | Up to 8000 chars. |
| `inputMapping` | object | Extra step inputs passed to the agent. |
| `pollIntervalMs` | integer | 5000 to 60000. How often the engine checks the agent's status. Defaults to 15000. |
| `agentMaxRuntimeMs` | integer | Hard ceiling, up to 24 hours. Defaults to 10 minutes. |
| `aiConfig` | object | The AI provider and model for the agent run this node starts. Same fields and rules as `aiConfig` on [Run Execution](https://velt.dev/docs/api-reference/rest-apis/v2/agents/execution/run.md). Omit it to use the provider and model the platform resolves for the agent. |

**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.

```json theme={null}
{
  "nodeId": "brand-check",
  "type": "agent",
  "config": {
    "agentId": "brand-agent-v1",
    "urlPath": "documentUrl"
  },
  "slaMs": 3600000
}
```

**Set the AI model for every run.** `aiConfig` applies to every run the node starts, including scheduled and triggered runs. It takes `provider`, `model`, `defaultModels`, and `maxToolTurns`, with the same validation as [Run Execution](https://velt.dev/docs/api-reference/rest-apis/v2/agents/execution/run.md): at least one field set, unknown keys rejected, and models limited to the allowlist. The API rejects an invalid value with `INVALID_ARGUMENT` when you create or update the definition. If the allowlist later stops accepting a saved value, the step drops it when it starts the agent and the run uses the platform default. The step does not fail.

```json theme={null}
{
  "nodeId": "nightly-audit",
  "type": "agent",
  "config": {
    "agentId": "broken-links",
    "url": "https://acme.com",
    "aiConfig": { "provider": "claude", "model": "claude-sonnet-5" }
  }
}
```

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](#custom-predicates).

> [!WARNING]
>
> `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[]`.

| Field | Type | Notes |
| - | - | - |
| `reviewers` | array | Preferred. `[{ userId, mandatory }]`. Must include at least one `mandatory: true`. UserIds must be unique. |
| `reviewerIds` | string\[] | Legacy. Every entry is treated as mandatory. |
| `reviewerEmails` | string\[] | Up to 50 addresses. Surfaced on the step's `output.reviewerEmails` for your notification UI. |
| `commentBody` | string | Up to 8000 chars. Stored on the step's output for your reviewer UI to render. |

```json theme={null}
{
  "nodeId": "human-legal",
  "type": "human",
  "config": {
    "reviewers": [{ "userId": "u_legal_01", "mandatory": true }],
    "commentBody": "Please review for legal compliance."
  }
}
```

The step resolves when every mandatory reviewer approves, or when any reviewer rejects. Record decisions with [Record Reviewer Decision](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/steps/record-reviewer-decision.md).

> [!NOTE]
>
> 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.

| Field | Type | Notes |
| - | - | - |
| `channel` | enum | Required. `email` or `slack`. |
| `bodyTemplate` | string | Required. 1 to 16000 chars, with `{{dot.path}}` interpolation. |
| `recipients` | string\[] | Required for `email`. 1 to 50 addresses. |
| `subjectTemplate` | string | Email subject, up to 2000 chars. |
| `slackTarget` | string | Required for `slack`. A channel id such as `C0123`, or an `https` incoming-webhook URL. |
| `format` | enum | `text` (default), `html`, or `slack-blocks`. |

```json theme={null}
{
  "nodeId": "notify-stakeholders",
  "type": "notification",
  "config": {
    "channel": "email",
    "recipients": ["lead@acme.dev", "pm@acme.dev"],
    "subjectTemplate": "Approval {{input.decision}} for {{execution.triggerContext.page.title}}",
    "bodyTemplate": "Findings: {{input.agentResultsSummary.summary}}",
    "format": "text"
  }
}
```

**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:

| Root | Resolves to |
| - | - |
| `input.*` | The previous step's output. |
| `execution.*` | `executionId`, `definitionId`, `correlationId`, and `triggerContext`. |
| `step.*` | This step's metadata. |

**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.

| Field | Type | Notes |
| - | - | - |
| `url` | string | Required. `https` only, host-allowlisted, up to 2000 chars. |
| `mode` | enum | `sync` (default) or `async`. |
| `method` | enum | `GET` or `POST`. Defaults to `POST`. |
| `authMode` | enum | `hmac` (default), `token`, or `none`. |
| `authTokenHeader` | string | Required when `authMode: "token"`. The header the engine puts your token in. |
| `timeoutMs` | integer | 1000 to 60000. Defaults to 10000. |
| `expectedStatusCodes` | integer\[] | Up to 20 status codes to treat as success instead of the 2xx default. |
| `bodyTemplate` | enum | `envelope` (default), `pass-through`, or `none`. `none` requires `method: "GET"`. |
| `requestHeaders` | object | Extra headers. `x-velt-*` and reserved header names are rejected. |

**`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.

```json theme={null}
{ "from": "human-review", "to": "rework-notice", "on": "reject" }
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `from` | `EdgeEndpoint` | yes | Bare node-id string, `{ kind: "node", nodeId }`, or `{ kind: "group", groupId }`. |
| `to` | `EdgeEndpoint` | yes | Same shape as `from`. |
| `on` | enum | no, defaults to `always` | `approve`, `reject`, `always`, `exhausted`, or `custom`. `approve` and `reject` auto-compile their predicates. |
| `when` | JSON-AST string | only with `on: "custom"` | Up to 1000 chars. Rejected on any other role. |
| `loop` | `{ maxIterations }` | only on an `on: "reject"` back-edge | 1 to 20. Marks the edge as a loop-back. |

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.

  ```json theme={null}
  { "from": "human-review", "to": "rework-notice", "on": "reject" }
  ```

* **Loop-back.** An `on: "reject"` edge whose `to` is an ancestor of `from`, marked with `loop`. The server derives the loop region from it.

  ```json theme={null}
  { "from": "human-review", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 3 } }
  ```

* **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`.

  ```json theme={null}
  { "from": "human-review", "to": "escalate", "on": "exhausted" }
  ```

### 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.

```json theme={null}
{ "from": "brand-check", "to": "legal-review", "on": "custom", "when": "{\"op\":\"eq\",\"args\":[{\"var\":\"output.passesBrandCheck\"},true]}" }
```

Common shapes:

| Goal | `when` value |
| - | - |
| equality | `{"op":"eq","args":[{"var":"output.decision"},"approve"]}` |
| numeric compare | `{"op":"gt","args":[{"var":"output.score"},0.8]}` |
| boolean AND | `{"op":"and","args":[<a>,<b>]}` |
| boolean OR | `{"op":"or","args":[<a>,<b>]}` |
| negation | `{"op":"not","args":[<a>]}` |
| regex match | `{"op":"regex","args":[{"var":"output.body"},"^urgent"]}` |

Supported operators: equality, comparison, boolean and/or/not, `regex`, `includes`, `startsWith`, `endsWith`, `length`, `isEmpty`.

Path roots:

| Root | Resolves to |
| - | - |
| `output.*` | The source step's terminal output. |
| `step.*` | The source step's metadata: `stepId`, `nodeId`, `status`, `retryCount`. |
| `execution.input.*` | The `triggerContext` you passed on dispatch. |

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](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/definitions/get-definition.md#the-compiled-block).

## 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.

```json theme={null}
{
  "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 a declared node. A node belongs to at most one group. |
| `expectedSteps` | integer | yes | 1 to 500. Members the group expects to terminate before it considers itself done. Set it equal to `memberNodeIds.length`; a higher value means the group never completes. |
| `quorum` | integer | yes | 1 to `expectedSteps`. Approvals needed to fire the policy. |
| `onQuorumMet` | enum | no | `waitAll` (default), `cancelOnQuorum`, or `joinOnQuorum`. |
| `requiredNodeIds` | string\[] | no | Members whose approval is required. Each must be in `memberNodeIds`, and `length` must be at most `quorum`. |

### 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](https://velt.dev/docs/ai/approval-engine/patterns.md#assuming-an-agent-in-a-quorum-group-never-counts).
2. **A reject does not block group completion.** It just stops the approval counter from advancing. Completion and quorum are tracked separately.

### `onQuorumMet` policies

| Policy | What happens when approval quorum is first met | Per-member fan-out |
| - | - | - |
| `waitAll` (default) | Emits `group.quorum-met` and nothing else. As an edge source, waits for all members and fires one collective branch by unanimity. | Each member's edges fire on its own completion. Two members pointing at the same node create two downstream steps. |
| `cancelOnQuorum` | Emits `group.quorum-met` and cancels every sibling still in `waiting`, with `actorId: "system:group-quorum"`. | Each completing member still fans out. Cancelled siblings do not. |
| `joinOnQuorum` | Emits `group.quorum-met`, cancels waiting siblings, and fires a single group-owned successor per shared target. | Suppressed for members. The group owns fan-out, so each successor runs exactly once. |

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`:

```json theme={null}
{
  "groupId": "approver-group",
  "memberNodeIds": ["legal", "finance", "brand"],
  "expectedSteps": 3,
  "quorum": 2,
  "requiredNodeIds": ["legal", "finance"]
}
```

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.

```json theme={null}
{ "from": "human-legal", "to": "agent-draft", "on": "reject", "loop": { "maxIterations": 5 } }
{ "from": "human-legal", "to": "human-escalate", "on": "exhausted" }
```

The derived region is surfaced read-only as `compiled.loops[]`:

| Field | Type | Description |
| - | - | - |
| `loopId` | string | Server-assigned identifier. |
| `entryNodeId` | string | Node re-entered on each iteration, the reject edge's `to`. |
| `bodyNodeIds` | string\[] | Nodes inside the iteration scope, derived from the closure between `to` and `from`. |
| `maxIterations` | integer | 1 to 20. Hard cap per execution. |
| `onExhausted` | object or `null` | Target of the sibling `on: "exhausted"` edge. `null` rolls the execution up to `failed` at the cap. |

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:

```ts theme={null}
{
  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;
  }>;
}
```

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.

| Field | Type | Required | Notes |
| - | - | - | - |
| `triggerId` | string | yes | 1 to 128 chars. Stable id, also the idempotency-key prefix. |
| `eventName` | string | no | Up to 128 chars. Optional label. |
| `filters` | object | no | Free-form match filters. |
| `inboundWebhook` | object | no | See [Inbound webhook trigger](#inbound-webhook-trigger). |
| `schedule` | object | no | See [Scheduled (cron) trigger](#scheduled-cron-trigger). |
| `appTrigger` | object | no | See [App trigger](#app-trigger). |

> [!NOTE]
>
> **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.

```json theme={null}
{
  "triggerId": "ci-build",
  "inboundWebhook": {
    "authMode": "hmac",
    "secret": "github-webhook-secret-...",
    "provider": "github",
    "allowedEvents": ["workflow_run", "push"],
    "idempotencyHeader": "x-github-delivery"
  }
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `authMode` | enum | yes | `hmac` verifies a body signature. `bearer` reads `Authorization: Bearer <secret>`, and is `velt`-only. |
| `secret` | string | yes | 16 to 512 chars. The per-trigger secret, and the real authenticator. |
| `provider` | enum | no | `velt` (default), `github`, `vercel`, or `custom`. |
| `signatureHeader` | string | custom only | Header carrying the hex signature. |
| `signatureAlgorithm` | enum | custom only | `sha1` or `sha256`. |
| `signaturePrefix` | string | custom only | Prefix stripped before comparing, such as `sha256=`. |
| `allowedEvents` | string\[] | no | 1 to 50 event names. Non-matching events return HTTP 200 with `event-ignored`. |
| `eventNameHeader` | string | custom only | Header carrying the event name for `allowedEvents`. |
| `idempotencyHeader` | string | no | Header to read the source event id from. |
| `idempotencyBodyPath` | string | no | Dot-path in the body for the source event id, such as `event.id`. |
| `payloadMapping` | enum | no | Applies to every provider. `pass-through` (default) sends the extracted payload as `triggerContext`; `wrap` nests it under `source`. |

Presets resolve the signature header, algorithm, prefix, and event-name source for you:

| `provider` | Signature header | Algorithm | Prefix | Event name from |
| - | - | - | - | - |
| `velt` (default) | `x-velt-signature` | HMAC-SHA256 | `sha256=` | body `type` |
| `github` | `x-hub-signature-256` | HMAC-SHA256 | `sha256=` | `x-github-event` header |
| `vercel` | `x-vercel-signature` | HMAC-SHA1 | none | `x-vercel-deployment-event` header, then body `type` |
| `custom` | `signatureHeader` | `signatureAlgorithm` | `signaturePrefix` | `eventNameHeader` |

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.

> [!NOTE]
>
> 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.

```json theme={null}
{
  "triggerId": "nightly-audit",
  "schedule": {
    "cron": "0 2 * * *",
    "timezone": "America/Los_Angeles",
    "enabled": true,
    "payloadTemplate": { "source": "nightly" }
  }
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `cron` | string | yes | Standard 5-field cron expression. Validated at write time (`APPROVAL_SCHEDULE_CRON_INVALID`). |
| `timezone` | string | yes | An IANA zone such as `America/Los_Angeles`. Validated at write time (`APPROVAL_SCHEDULE_TIMEZONE_INVALID`). DST is handled for you. |
| `enabled` | boolean | yes | Only enabled schedules fire. Setting `false` removes the schedule. |
| `payloadTemplate` | object | no | A static object merged into `triggerContext` under `schedule.payload`. |

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.

```json theme={null}
{
  "triggerId": "gh-deploy-review",
  "appTrigger": {
    "provider": "github",
    "installationRef": "41234567",
    "repoFilter": ["acme/website"],
    "allowedEvents": ["push", "pull_request"]
  }
}
```

| Field | Type | Required | Notes |
| - | - | - | - |
| `provider` | enum | yes | `github` or `vercel`. |
| `installationRef` | string | yes | 1 to 256 chars. Must already be connected to your workspace, or the request fails with `FAILED_PRECONDITION`. |
| `repoFilter` | string\[] | no | GitHub `org/repo` allowlist, up to 200 entries. Omit for every repo. |
| `projectFilter` | string\[] | no | Vercel project id or name allowlist, up to 200 entries. Omit for every project. |
| `allowedEvents` | string\[] | no | 1 to 50 event names, such as `push` or `deployment.succeeded`. Omit for all events. |
| `payloadFilters` | array | no | Up to 20 entries of `{ path, in }`. The binding fires only when every filter matches a value in its `in` list. |

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.

> [!NOTE]
>
> 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.**

```json theme={null}
{
  "definitionId": "vercel-broken-links-scan",
  "name": "Vercel deploy scan",
  "nodes": [
    {
      "nodeId": "scan",
      "type": "agent",
      "config": { "agentId": "broken-links", "urlPath": "payload.deployment.url", "maxUrlsToProcess": 50 }
    },
    {
      "nodeId": "notify",
      "type": "notification",
      "config": {
        "channel": "email",
        "recipients": ["web-team@acme.com"],
        "subjectTemplate": "Broken-link scan for {{execution.triggerContext.payload.deployment.url}}",
        "bodyTemplate": "Found {{input.agentResultsSummary.totalFindings}} broken link(s).",
        "format": "text"
      }
    }
  ],
  "edges": [{ "from": "scan", "to": "notify", "on": "always" }],
  "triggers": [
    {
      "triggerId": "vercel-prod-deploys",
      "appTrigger": {
        "provider": "vercel",
        "installationRef": "icfg_AbC123xyz",
        "projectFilter": ["my-marketing-site"],
        "allowedEvents": ["deployment.succeeded"]
      }
    }
  ]
}
```

`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

| Goal | Endpoint |
| - | - |
| Stop a whole run and all its in-flight steps | [Cancel Execution](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/executions/cancel-execution.md) |
| Stop one step, leaving its siblings running | [Cancel Step](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/steps/cancel-step.md) |
| Force a parked step to a decision | [Resolve Step](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/steps/resolve-step.md) |

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:

| Where | Applies to | Fields |
| - | - | - |
| `webhookConfig` on the definition | Every run of that definition | `{ url, secret, eventTypes? }` |
| `webhookUrl` + `webhookSecret` on dispatch | That one run | Overrides `webhookConfig` |

`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

| 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 the final retry the payload goes to a dead-letter queue. Recover missed events with [Get Execution Events](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events.md) 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](https://velt.dev/docs/ai/approval-engine/setup.md#step-4-get-the-outcome).

## Events

### Event reference

Events delivered by webhook and returned from [Get Execution Events](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution-events.md):

| Event type | When emitted | `data` highlights |
| - | - | - |
| `execution.dispatched` | Run created and first steps scheduled. | `{ definitionId, definitionVersion, rootStepIds }` |
| `execution.completed` | All steps terminal, no unhandled failure. | null |
| `execution.failed` | A blocking step failed or breached with no recovery edge. | null |
| `execution.cancelled` | Run cancelled, or fully rolled back. | `{ reason? }` |
| `step.awaiting-approval` | A step entered `waiting`: a human step, a running agent step, or an async webhook step. | Human steps: `{ waitingForReviewers, mandatoryCount, resumeKey }`. Agent steps: `{ agentId, agentExecutionId, pollIntervalMs }`. |
| `step.completed` | Step transitioned to `completed`. | Human steps: `{ aggregatorStatus, nodeType, decision }`. Agent steps: `{ agentExecutionId, agentExecutionStatus, decision, source }`. |
| `step.failed` | Step failed after its retry budget ran out, or failed terminally when retries cannot help. | Varies by node type. Human steps: `{ reason }` (`no-reviewers`, `no-mandatory-reviewers`, `duplicate-reviewer-user-ids`, `exception`). Webhook steps: `{ httpStatus, latencyMs, retryClass }`. Notification steps: `{ channel, retryClass }`, which is also the shape Slack config errors such as `channel_not_found` use. Config-validation failures on either type emit `{ reason }` instead, including the `webhook-node-empty-response` path. Agent resumes: `{ agentExecutionId, agentExecutionStatus, decision, source }`. |
| `step.breached` | Step passed its SLA before completing. | `{ reason }` |
| `step.cancelled` | Step cancelled directly or by a quorum side effect. | `{ actorId, reason }` |
| `group.quorum-met` | A group's approval threshold was first satisfied. | `{ groupId, total, quorum, completedTotal, expectedSteps }` |
| `loop.iteration-started` | A rejected iteration spawned the next one below the cap. | `{ loopId, iteration, triggeredBy: 'rejection' }` |
| `loop.exhausted` | The loop hit `maxIterations`. | `{ loopId, iteration, lastRejectedBy?, lastRejectionReason? }` |

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](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/executions/get-execution.md) as `steps[].error`. Route on `event.type`, then fetch the step for the detail.

> [!NOTE]
>
> 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`.

| Reason | Source | Meaning |
| - | - | - |
| `group-quorum-met` | system | The parent group met quorum under `cancelOnQuorum` or `joinOnQuorum`. Audit shows `actorId: "system:group-quorum"`. |
| `loop-restart` | system | A [loop region](#loop-regions) started the next iteration while a step from the previous one was still running. Audit shows `actorId: "system:loop-restart"`. |
| (admin-supplied) | admin | Free-form reason passed to [Cancel Step](https://velt.dev/docs/api-reference/rest-apis/v2/approval-engine/steps/cancel-step.md). |

## Linter rules

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

| Code | Meaning |
| - | - |
| `duplicate-node-id` | Two nodes share the same `nodeId`. |
| `dangling-edge` | An edge references a node or group that is not declared. |
| `cycle-detected` | The graph has a cycle. Only marked reject loop-backs may revisit a node. |
| `unreachable-node` | A node has no path from any root. |
| `node-missing-config` | A node has no `config` block. |
| `missing-breach-edge` | A node sets `slaMs` and has outgoing edges, but none routes on a breach. |
| `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` is below 1. |
| `group-quorum-invalid` | `quorum` is below 1 or above `expectedSteps`. |
| `group-cancelonquorum-requires-quorum-lt-expected` | `cancelOnQuorum` requires `quorum < expectedSteps`. |
| `group-joinonquorum-members-must-share-successors` | `joinOnQuorum` requires every member to have the same set of outgoing targets. |
| `group-required-not-in-members` | An entry in `requiredNodeIds` is not in `memberNodeIds`. |
| `group-required-exceeds-quorum` | `requiredNodeIds.length` is greater than `quorum`. |
| `group-node-in-multiple-groups` | A node is a member of two or more groups. |
| `loop-node-in-multiple-loops` | Two or more reject back-edges derive loop regions with overlapping bodies. Use one group-source back-edge instead. See [Anti-patterns](https://velt.dev/docs/ai/approval-engine/patterns.md#one-reject-back-edge-per-parallel-reviewer). |
| `loop-body-must-have-single-terminal` | The derived loop body is neither single-terminal sequential nor group-bounded. See [Body-shape constraint](#body-shape-constraint). |
| `loop-group-bounded-quorum-must-equal-expected` | A group-bounded loop body needs `quorum === expectedSteps`, so the iteration terminal coincides with all members finishing. |

### Edge validation errors

| Error key | Cause |
| - | - |
| `APPROVAL_EDGE_CUSTOM_REQUIRES_WHEN` | `on: "custom"` without a non-empty `when`. |
| `APPROVAL_EDGE_WHEN_ONLY_FOR_CUSTOM` | `when` supplied on a non-custom edge. |
| `APPROVAL_EDGE_LOOP_REQUIRES_REJECT` | `loop` on an edge that is not `on: "reject"`. |
| `APPROVAL_EDGE_LOOP_REQUIRES_BACK_EDGE` | A reject loop-back whose `to` is not an ancestor of `from`. |
| `APPROVAL_EDGE_REJECT_CYCLE_REQUIRES_LOOP` | `on: "reject"` to an ancestor without `loop`. |
| `APPROVAL_EDGE_EXHAUSTED_REQUIRES_LOOP_SIBLING` | `on: "exhausted"` with no sibling reject loop-back from the same `from`. |
| `APPROVAL_HUMAN_NODE_REQUIRES_REJECT_PATH` | A human node has no outgoing `on: "reject"` edge. |
| `APPROVAL_AGENT_NODE_REQUIRES_URL_OR_URLPATH` | An agent node sets neither `url` nor `urlPath`. |
| `APPROVAL_EDGE_GROUP_TO_GROUP_FORBIDDEN` | Both endpoints are group containers. |
| `APPROVAL_GROUP_FROM_REJECT_REQUIRES_LOOP` | Forward `on: "reject"` from a `joinOnQuorum` or `cancelOnQuorum` group. |
| `APPROVAL_GROUP_FROM_LOOP_REQUIRES_JOINONQUORUM` | A reject loop-back from a group that is not `joinOnQuorum`. |
| `APPROVAL_APP_TRIGGER_EXCLUSIVE` | One trigger entry declares more than one of `inboundWebhook`, `schedule`, `appTrigger`. |

## Errors

All errors follow the standard envelope:

```json theme={null}
{ "error": { "message": "...", "status": "INVALID_ARGUMENT", "details": {} } }
```

### Canonical codes

| Code | Meaning | Typical cause |
| - | - | - |
| `INVALID_ARGUMENT` | Schema or graph validation failed, or `x-velt-auth-token` is missing. | Missing field, wrong type, out-of-range value, edge contract violation, linter rule, absent auth token. |
| `PERMISSION_DENIED` | The `x-velt-auth-token` is not one of the workspace's registered tokens, or the actor is not allowed to take the action. | An unregistered auth token, or a `reviewer-*` resolve action whose `actorId` is not on the step's reviewer list. |
| `NOT_FOUND` | Target does not exist. | Unknown `executionId`, `definitionId`, or `stepId`. |
| `ALREADY_EXISTS` | Conflicting create. | An active definition already uses that `definitionId`. |
| `FAILED_PRECONDITION` | Optimistic lock or state-machine violation. | `ifVersion` mismatch, cancelling a terminal step, deleting a definition with in-flight runs. |
| `RESOURCE_EXHAUSTED` | Rate limit exceeded. | Per-IP or per-API-key quota. |
| `DEADLINE_EXCEEDED` | Internal timeout. | Retry with an idempotency key. |

### 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.

| 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` | 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. Use `reviewers[]`. |
| `reviewer userIds must be unique` | Duplicate `userId` in `reviewers[]`. |
| `reviewers must include at least one mandatory reviewer` | Every reviewer has `mandatory: false`. |
| `notification node with channel="email" requires a non-empty recipients array` | `channel: "email"` with no `recipients`. |
| `notification node with channel="slack" requires a slackTarget (channel id or incoming-webhook URL)` | `channel: "slack"` with no `slackTarget`. |
| `webhook node with authMode="token" requires authTokenHeader to be set` | `authMode: "token"` with no `authTokenHeader`. |
| `inboundWebhook provider="github"/"vercel"/"custom" requires authMode="hmac"` | A non-`velt` provider using `authMode: "bearer"`. |
| `inboundWebhook with provider="custom" and authMode="hmac" requires signatureHeader and signatureAlgorithm` | `provider: "custom"` with `hmac` but no `signatureHeader` or `signatureAlgorithm`. |
| `schedule.cron must be a valid 5-field cron expression` | `cron` is not a parseable 5-field expression. |
| `schedule.timezone must be a valid IANA timezone name (e.g. America/Los_Angeles)` | `timezone` is not a valid IANA zone. |

## 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

```typescript theme={null}
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;
  documentId?: string;           // dispatch documentId; omitted when not passed
  organizationId?: string;       // dispatch organizationId; omitted when not passed
  failureReason: { code: string; message: string } | null;
  steps: StepView[];
}

interface StepView {
  stepId: string;
  nodeId: string;
  nodeType: 'agent' | 'human' | 'notification' | 'webhook';
  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;
  compiled: CompiledGraph;
  triggers: WorkflowTriggerConfig[] | null;
  tags: string[] | null;
  custom: Record<string, unknown> | null;
  createdAt: number;
  updatedAt: number;
  status: 'active' | 'tombstoned';
}

type JsonAst = Record<string, unknown>;

interface CompiledGraph {
  forwardEdges: CompiledForwardEdge[];
  loops: CompiledLoopRegion[];
}

interface CompiledForwardEdge {
  from: string;
  to: string;
  role: 'approve' | 'reject' | 'always' | 'exhausted' | 'custom';
  when: JsonAst | null;
  fromGroupId?: string;
  toGroupId?: string;
}

interface CompiledLoopRegion {
  loopId: string;
  entryNodeId: string;
  bodyNodeIds: string[];
  maxIterations: number;
  onExhausted: { routeToNodeId: string } | null;
}

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>;
}
```

### Step ids

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

| Step | Id shape |
| - | - |
| Root step, no incoming edges | `step_<nodeId>_<timestamp>_<rand>` |
| Per-edge fan-out | `<parentStepId>__to__<childNodeId>` |
| `joinOnQuorum` fan-out | `group_<groupId>__to__<childNodeId>` |

### Human step output

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

```typescript theme={null}
{
  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;
}
```

### `joinOnQuorum` successor input

```typescript theme={null}
{
  groupOutputs: Record<string /* memberNodeId */, Record<string, unknown> /* member's output */>;
  groupId: string;
  quorum: number;
  totalApproved: number;
}
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.