> ## Documentation Index
> Fetch the complete documentation index at: https://docs.heylua.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow approvals

> Human input on workflow runs: resuming a suspended step, approvals and their payloads, per-item decisions, signals, gates, and reassignment

These routes answer what a workflow [run](/concepts/workflows) waits for: input to a step that called `ctx.suspend()`, a decision on an approval step, an edit to the payload under review, a signal a step waits on, and the reassignment of one person's approvals and schedules to another. Acting on a run needs `workflows:execute` on the agent; reading a payload needs `workflows:read`. Approving, denying, editing a payload, and reassigning are human-only: a typed API credential is refused with `403 APPROVAL_REQUIRES_HUMAN`. Starting, listing, and recovering runs are on [Workflows](/reference/rest/workflows), which also holds the [error codes](/reference/rest/workflows#error-codes) and the base URL, scopes, and envelope pointer; the ledger these decisions land in is on [Workflow events](/reference/rest/workflow-events).

*Verified against lua-cli 3.33.0.*

## Step input

### POST /workflows/:agentId/runs/:runId/steps/:stepId/resume

Resumes a step that called `ctx.suspend()` and is waiting for input. Creator or a `workflows:execute` holder.

<ParamField body="resumeData" type="any" required>Validated against the step's `resumeSchema`; at most 256 KB.</ParamField>

**Response**

`200` with `{ "resumed": true, "runStatus" }`, or the no-op `{ "resumed": false, "reason": "already_resumed", "recorded"?, "runStatus" }` when someone got there first.

**Errors**

| Status | Code                                                                            | Meaning                                                               |
| ------ | ------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `400`  | `RESUME_SCHEMA_INVALID`                                                         | `resumeData` fails the schema                                         |
| `404`  | `RUN_NOT_FOUND`, `STEP_NOT_FOUND`                                               | No such run or step                                                   |
| `409`  | `NOT_SUSPENDED`, `APPROVAL_REQUIRES_HUMAN`, `USE_SIGNAL_ROUTE`, `NOT_RESUMABLE` | The step is not waiting for input; it is an approval or a signal wait |
| `413`  | `PAYLOAD_TOO_LARGE`                                                             | `resumeData` over 256 KB                                              |
| `422`  | `RESUME_SCHEMA_UNCOMPILABLE`                                                    | The stored schema does not compile; a definition defect               |
| `503`  | `CONTROL_UNAVAILABLE`                                                           | The control plane did not answer                                      |

Equivalent: `lua workflows resume <runId> --step <stepId> --data '{"answer":42}'`, `Workflows.resume()`.

<CodeGroup>
  ```bash CLI theme={null}
  lua workflows resume <runId> --step ask --data '{"answer":42}'
  ```

  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/steps/ask/resume', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ resumeData: { answer: 42 } }),
  });
  const resumed: { resumed: boolean; runStatus: string } = await response.json();
  console.log(resumed.resumed, resumed.runStatus);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/steps/ask/resume" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "resumeData": { "answer": 42 } }'
  ```
</CodeGroup>

## Approvals

### POST /workflows/:agentId/runs/:runId/approvals/:approvalId/resolve

Approves or denies an approval step. The `approvalId` (`wfa_...`) is on the run's `suspensions[].suspend.approvalId`, on the `approval.requested` event, and on the inbox card. Person only.

<ParamField header="X-Lua-Surface" type="string" default="api">Where the decision is made: `desktop`, `inbox`, `cli`, or `chat`. The organization's approval policy can restrict surfaces and demand a recent sign-in, which only `desktop` and `inbox` satisfy.</ParamField>
<ParamField body="decision" type="string" required>`approve` or `deny`.</ParamField>
<ParamField body="note" type="string">At most 2,000 characters.</ParamField>
<ParamField body="editedPayload" type="object">A replacement payload; only when the step declared `editable: true` and `editablePaths`.</ParamField>
<ParamField body="expectedFingerprint" type="string">The `payloadFingerprint` of the revision you looked at (at most 128 characters). Required once the payload has been edited; a stale value is `409 PAYLOAD_MISMATCH`.</ParamField>
<ParamField body="evidenceArtefactIds" type="string[]">At most 5 artifact ids uploaded for this run.</ParamField>

**Response**

`200` with `{ "resolved": true, "outcome": "approved" | "denied", "runStatus", "stepId"? }`, or the no-op `{ "resolved": false, "outcome": "noop", "reason": "already_resolved" | "expired" | "cancelled" | "run_gone", "decision"?, "decidedBy"? }`.

**Errors**

| Status | Code                                                                                                              | Meaning                                                                                                                 |
| ------ | ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `403`  | `APPROVAL_REQUIRES_HUMAN`, `NOT_AN_APPROVER`, `FOUR_EYES_REQUIRED`, `APPROVAL_SURFACE_DENIED`, `STEP_UP_REQUIRED` | A typed credential; not an approver; a second person is needed; this surface may not approve; a fresh sign-in is needed |
| `404`  | `RUN_NOT_FOUND`, `APPROVAL_NOT_FOUND`                                                                             | No such run, or the approval belongs to another run                                                                     |
| `409`  | `PAYLOAD_MISMATCH`, `EDIT_NOT_ALLOWED`                                                                            | The payload changed since your fingerprint (`payloadFingerprint`, `editRevision`); the step is not editable             |
| `423`  | `ORG_ARCHIVED`                                                                                                    | The organization is archived                                                                                            |
| `503`  | `CONTROL_UNAVAILABLE`                                                                                             | The control plane did not answer                                                                                        |

Equivalent: `lua workflows approve <runId> --approval <id> --decision approve --note "Go ahead"`.

<CodeGroup>
  ```bash CLI theme={null}
  lua workflows approve <runId> --approval <approvalId> --note "Reviewed the drafts"
  ```

  ```ts TypeScript theme={null}
  const response = await fetch(
    'https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/approvals/<<APPROVAL_ID>>/resolve',
    {
      method: 'POST',
      headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
      body: JSON.stringify({ decision: 'approve', note: 'Reviewed the drafts' }),
    },
  );
  const decided: { resolved: boolean; outcome: string; reason?: string } = await response.json();
  console.log(decided.outcome, decided.reason ?? '');
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/approvals/<<APPROVAL_ID>>/resolve" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "decision": "approve", "note": "Reviewed the drafts" }'
  ```
</CodeGroup>

### GET /workflows/:agentId/runs/:runId/approvals/:approvalId/payload

Reads the current approval payload, whole or paged. Needs `workflows:read`.

<ParamField query="path" type="string">A dotted path to an array inside the payload, for example `drafts`, to page that array; any other target is `400 VALIDATION_FAILED`.</ParamField>
<ParamField query="cursor" type="string">The index to start the page at; `nextCursor` from the previous page.</ParamField>
<ParamField query="limit" type="integer" default="100">Items per page, at most 100.</ParamField>

**Response**

`200` with `{ approvalId, editRevision, payloadFingerprint?, editable, editablePaths, size }` and either `kind: "whole"` with `payload` (payloads up to 1 MB), `kind: "paged"` with `arrays { <key>: { totalItems } }` for a larger payload, or, with `path`, `{ path, items: [{ index, value }], nextCursor?, totalItems }`. `404 RUN_NOT_FOUND` or `APPROVAL_NOT_FOUND`; `413 PAYLOAD_PAGE_REQUIRED` (`offloaded: true`) when the original payload is stored out of line and cannot be returned whole.

Equivalent: `lua workflows approval-payload <runId> --approval <id>`.

### PATCH /workflows/:agentId/runs/:runId/approvals/:approvalId/payload

Edits the payload with a JSON Patch before deciding. Person only.

<ParamField body="expectedFingerprint" type="string" required>The fingerprint the editor loaded; at most 128 characters.</ParamField>
<ParamField body="ops" type="array" required>At most 200 operations of `{ op: "replace" | "add" | "remove", path, value? }` with RFC 6901 paths of at most 1,024 characters.</ParamField>
<ParamField body="note" type="string">At most 2,000 characters.</ParamField>

**Response**

`200` with `{ "applied": true, "editRevision", "payloadFingerprint" }`; use the new fingerprint to resolve. `400 PATCH_INVALID` or `RESUME_SCHEMA_INVALID`; `403 APPROVAL_REQUIRES_HUMAN`, `NOT_AN_APPROVER`, or `APPROVAL_SURFACE_DENIED`; `404 RUN_NOT_FOUND` or `APPROVAL_NOT_FOUND`; `409 PAYLOAD_MISMATCH`, `EDIT_NOT_ALLOWED`, or `NOT_SUSPENDED`; `503 CONTROL_UNAVAILABLE`.

Equivalent: `lua workflows approve <runId> --approval <id> --edit @file --fingerprint <f>` edits and decides in one step.

### GET /workflows/:agentId/runs/:runId/approvals/:approvalId/items

Lists the per-item approvals fanned out under a parent approval. Needs `workflows:read`.

**Response**

`200` with `{ approvalId, items: [{ index, approvalId, itemKey?, status, decision?, decidedBy?, itemPayloadFingerprint?, editRevision?, payload }], totalItems }`. `404 RUN_NOT_FOUND` or `APPROVAL_NOT_FOUND`; `503 CONTROL_UNAVAILABLE`.

### POST /workflows/:agentId/runs/:runId/approvals/:approvalId/items/:index

Decides one fanned-out item. Person only; the `X-Lua-Surface` header applies as on resolve.

<ParamField body="decision" type="string" required>`approve` or `deny`.</ParamField>
<ParamField body="note" type="string">At most 2,000 characters.</ParamField>
<ParamField body="payloadFingerprint" type="string">The item's `itemPayloadFingerprint`; a mismatch is `409 PAYLOAD_FINGERPRINT_STALE`.</ParamField>

**Response**

`200` with `{ "resolved": true, "item", "decision", "parentResolved", "runStatus"? }`. `400 VALIDATION_FAILED` for a bad index or decision; `403 APPROVAL_REQUIRES_HUMAN`, `NOT_ITEM_APPROVER`, or `CUSTOMER_PRINCIPAL_CANNOT_APPROVE`; `404 RUN_NOT_FOUND`, `APPROVAL_NOT_FOUND`, or `ITEM_NOT_FOUND`; `409 PAYLOAD_FINGERPRINT_STALE`, `ITEM_ALREADY_DECIDED`, or `NOT_SUSPENDED`; `503 CONTROL_UNAVAILABLE`.

## Signals

### POST /workflows/:agentId/runs/:runId/signals/:name

Delivers a named signal to a run.

<ParamField path="name" type="string" required>The signal name, from 1 to 64 characters.</ParamField>
<ParamField body="payload" type="any">At most 64 KB; validated against the waiting step's schema.</ParamField>
<ParamField body="dedupeKey" type="string">At most 128 characters; a repeat answers `200` with `duplicate: true` and delivers nothing.</ParamField>

**Response**

| Status | Body                                                           | Meaning                                                              |
| ------ | -------------------------------------------------------------- | -------------------------------------------------------------------- |
| `202`  | `{ accepted: true, signalId, consumed: true, stepId }`         | A waiting step took it                                               |
| `202`  | `{ accepted: true, signalId, consumed: false }`                | Parked until a step waits for it; at most 256 parked signals per run |
| `200`  | `{ accepted: true, signalId, duplicate: true }`                | The `dedupeKey` was seen before                                      |
| `202`  | `{ accepted: false, signalId, reason: "source_not_accepted" }` | The wait's `acceptedSources` excludes this caller                    |

**Errors**

`400 VALIDATION_FAILED` (an empty or over-long name) or `SIGNAL_SCHEMA_INVALID` (`stepId`, `issues`; the rejected payload keeps its `dedupeKey` free for a corrected retry); `404 RUN_NOT_FOUND`; `409 RUN_TERMINAL`; `413 PAYLOAD_TOO_LARGE`; `422 SIGNAL_SCHEMA_UNCOMPILABLE`; `429 SIGNAL_INBOX_FULL`; `503 CONTROL_UNAVAILABLE`.

Equivalent: `lua workflows signal <runId> <name> --payload '{"ok":true}'`, `Workflows.signal()`.

<CodeGroup>
  ```bash CLI theme={null}
  lua workflows signal <runId> review --payload '{"ok":true}' --dedupe-key review-1042
  ```

  ```ts TypeScript theme={null}
  const response = await fetch('https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/signals/review', {
    method: 'POST',
    headers: { Authorization: 'Bearer <<YOUR_API_KEY>>', 'Content-Type': 'application/json' },
    body: JSON.stringify({ payload: { ok: true }, dedupeKey: 'review-1042' }),
  });
  const delivered: { accepted: boolean; consumed?: boolean; duplicate?: boolean } = await response.json();
  console.log(response.status, delivered);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://api.heylua.ai/workflows/<<YOUR_AGENT_ID>>/runs/<<RUN_ID>>/signals/review" \
    -H "Authorization: Bearer <<YOUR_API_KEY>>" \
    -H "Content-Type: application/json" \
    -d '{ "payload": { "ok": true }, "dedupeKey": "review-1042" }'
  ```
</CodeGroup>

### POST /workflows/:agentId/signals/by-key

Delivers a signal to the live runs of a workflow started with a `correlationKey`, without knowing their ids.

<ParamField body="workflowId" type="string" required>The definition.</ParamField>
<ParamField body="correlationKey" type="string" required>The key the runs were started with.</ParamField>
<ParamField body="name" type="string" required>Signal name, at most 64 characters.</ParamField>
<ParamField body="payload" type="any">At most 64 KB.</ParamField>
<ParamField body="allowMultiple" type="boolean">Fan out to every matching run instead of refusing when several match.</ParamField>
<ParamField body="dedupeKey" type="string">At most 128 characters.</ParamField>

**Response**

`200` with `{ "runIds", "delivered", "results": [{ runId, accepted, consumed?, duplicate?, signalId?, code? }] }`; a per-run refusal is reported in `code` and never fails the call.

**Errors**

`400 CORRELATION_KEY_INVALID`, `SIGNAL_SCHEMA_INVALID`, or `VALIDATION_FAILED`; `404 CORRELATION_KEY_NOT_FOUND` (no live run); `409 CORRELATION_KEY_AMBIGUOUS` with `runIds` when several match and `allowMultiple` is not set; `413`, `422`, `503` as on the per-run route.

Equivalent: `Workflows.signalByKey()`.

## Gates

A `gated` or `suspended` run carries `gate.kind`, and each kind has one way out:

| Kind                       | Detail fields                                 | Cleared by                                                              |
| -------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- |
| `start-consent`            | `approvalLinkId`, `expiresAt`                 | A person deciding the consent approval before the run starts            |
| `quota`                    | `code` (`concurrency_cap` or `suspended_cap`) | The platform, when a slot frees; nothing to call                        |
| `billing`                  | `code`, `operationId`, `stepId`               | Topping up, then `retry` on the held step                               |
| `budget`                   | `dimension`, `spent`, `cap`, `stepId`         | `POST .../runs/:runId/budget`                                           |
| `exception`                | `stepId`, `attempt`                           | `retry` or `resolve` on the parked step                                 |
| `org_archived`, `disabled` | —                                             | Restoring the organization or re-enabling workflows; no run-level route |

The recovery verbs are on [Workflows](/reference/rest/workflows#recovery). A step waiting on a person shows as `suspensions[].suspend.kind`: `input` answers to the resume route, `approval` to the resolve route, and `signal` to the signal routes; using the wrong one is `409 USE_SIGNAL_ROUTE` or `APPROVAL_REQUIRES_HUMAN`.

## Reassignment

### POST /workflows/reassign

Moves one user's workflow assets to another user in the organization. Needs `workflows:write` on the organization, an organization admin (`org:manage`), and a person.

<ParamField body="orgId" type="string" required>The organization; at most 128 characters.</ParamField>
<ParamField body="fromUserId" type="string" required>The user leaving.</ParamField>
<ParamField body="toUserId" type="string" required>The user taking over; must be an organization member and, to inherit approvals, hold `workflows:execute`.</ParamField>
<ParamField body="kinds" type="string[]">A subset of `schedules`, `approvals`, `connections`, `drafts`; defaults to all four.</ParamField>
<ParamField body="dryRun" type="boolean">Report what would move without moving it.</ParamField>

**Response**

`200` with `{ "schedules": { moved, skipped }, "approvals": { rerouted, skipped, detail? }, "connections": { remounted, unmountable[] }, "drafts": { moved }, "dryRun", "unavailable"? }`; `unavailable` names blocks the server did not walk, whose zeros are not results. `400 VALIDATION_FAILED` (a missing id) or `REASSIGN_SAME_USER`; `403 NOT_ORG_ADMIN` or `APPROVAL_REQUIRES_HUMAN`; `404 USER_NOT_IN_ORG` (`userId`) or `WORKFLOWS_DISABLED`; `409 REASSIGN_TARGET_LACKS_EXECUTE`.

There is no CLI equivalent.

## See also

* [Workflows](/reference/rest/workflows) — the run model, the recovery verbs, and every error code
* [Approvals and signals](/build/workflows/approvals-and-signals) — designing the steps these routes answer
* [Workflow builder](/reference/sdk/workflow-builder) — `approval()`, `waitForSignal()`, and `ctx.suspend()`
* [`lua workflows`](/reference/cli/workflows) — `resume`, `approve`, `approval-payload`, and `signal`
* [Workflow events](/reference/rest/workflow-events) — the `approval.*` and `signal.*` events these decisions append
