Skip to main content
After this guide, a run pauses on an approval until someone approves, denies, or edits the payload, or waits for a signal your webhook delivers, and the next step reads the outcome as data. For a step that needs typed input from a person rather than a decision, call ctx.suspend(payload) and resume it with lua workflows resume; see Operate runs. Requires lua-cli 3.36.0 or later. Before you begin
1

Add an approval step

.approval(id, options) shows the previous entry’s output as the payload and parks the run until a decision arrives. Put a .map() in front of it so the payload is exactly what the approver should see and edit.
src/workflows/refund-approval.ts
approver is 'creator' (the default), 'org-admins', { users: [...] }, { role }, { group }, or { governance: { policyId } }; excludeInitiator keeps whoever started the run from deciding. On a public agent with approvers, a 'creator' step also goes to every approver unless the agent’s settings exclude workflow approvals: each gets a copy, the first decision wins, and a later decision answers resolved: false with reason: 'already_resolved', decision, and decidedBy, so every copy shows who decided. With the agent’s Require someone other than the person who triggered it setting on, the person who started the run gets no copy and is refused with 403 NOT_AN_APPROVER. An explicit approver, fourEyes, and a per-item approver are never changed. timeoutHours defaults to 168 and is at most 720; businessHours makes the deadline count working time. onTimeout is 'deny', 'cancel-run', 'fail', or up to three { escalateTo, timeoutHours } hops ending in one of those; 'continue' behaves as 'deny' with decision: 'timed_out'. A non-empty editablePaths (amount, drafts[*].body, summary.title) makes the payload editable, editedPayloadSchema validates the edit, and fourEyes: { edit, approve } keeps the editor from also approving.
2

Read the decision in the next step

The step after an approval receives the approval’s output, never the payload, so give its inputSchema a .passthrough() and read the original with getStepResult('<map id>') or getInitData().
approved is false on a denial and on a timeout under 'deny'; decision is 'approved', 'denied', or 'timed_out'; text is the approver’s note or the decision word, and note holds the note verbatim when one was left. editedPayload and editRevision appear when the payload was edited; timedOut, escalations, and items when a deadline chain or per-item approval decided. Under the default onDeny: 'continue' the run continues and your step decides what a denial means; onDeny: 'fail' fails the run.
3

Wait for a signal

.waitForSignal(id, options) parks the run until a named signal arrives from outside it. A signal that arrives before the wait begins is held and consumed when it does.
src/workflows/invoice-paid.ts
schema validates each delivery and rejects a payload that does not match. acceptedSources defaults to ['webhook', 'api', 'user']; add 'agent' to let the agent deliver it from chat. The step completes with { payload, source, signalId, receivedAt } (receivedAt in epoch milliseconds), or with { received: false, timedOut: true } when onTimeout is 'continue'; under the default 'fail' a timeout fails the step.
Local runs only. Under lua test workflow, --signal completes the wait with the payload itself rather than the { payload, source, signalId, receivedAt } envelope, so this step returns closed: false offline and closed: true on the platform.
4

Deliver the signal from a webhook

Start the run with a correlationKey (Workflows.start(…, { correlationKey }) or lua workflows start --correlation-key) and the webhook needs no run id.
src/webhooks/payment-received.webhook.ts
With a run id in hand, Workflows.signal(runId, 'payment.received', payload, { dedupeKey }) delivers to that run and answers { accepted, consumed, stepId }; dedupeKey makes a redelivered webhook a no-op. From a terminal, lua workflows signal <runId> payment.received --payload '{"invoiceId":"inv_abc123","amount":120}' --dedupe-key evt_abc123 does the same.
Deployed, Workflows.signal resolves { accepted, reason? } without consumed and stepId, and a delivery the platform refuses comes back as { accepted: false, reason } rather than an error; lua test webhook returns the full shape.
5

Answer an approval from the CLI

An approval is addressed by its wfa_… id, which lua workflows status <runId> --json exposes as suspendedFor.approvalId. To edit the payload, read it first: the fingerprint pins the revision you saw.
Output
Save the edited payload to a file and approve against that fingerprint, or deny with a note:
Only a signed-in person can decide; an API key is refused. The same decision is available in the desktop app and the inbox, and an organization policy can restrict which surfaces (desktop, inbox, chat-card, channel-reply, cli, api) may approve, exclude the initiator, or require a fresh sign-in (STEP_UP_REQUIRED).A web app can offer the same decision on its own page: a route calls Workflows.viewer.decideApproval, and the decision is made and recorded as the person using the app, under the same rules (lua-cli 3.50.0 and later).An organization-wide exclusion can meet a step whose approver is the creator, the default, and then the only candidate is the person who may not decide. Rather than failing the step with nobody asked, the platform escalates it to the organization’s admins, applies the exclusion to that set too, and records the escalation: lua workflows status <runId> --steps prints ⇅ approveRefund: approver escalated creator → org-admins — the initiator is excluded (maker-checker), the approval.requested event and the step’s suspend.approverFallback carry { from: "creator", to: "org-admins", reason: "initiator_excluded" }, and the run’s initiator is still refused even when they are an admin. The same applies to an escalation hop that names 'creator' and to per-item approvals. An organization with no other admin still has nobody to ask, and the step fails as it did before.
6

Verify

Run both workflows offline: deny the refund, then deliver the payment signal.
Output
--approve [email protected] answers with an edited payload, checked against editedPayloadSchema before the run starts and against editablePaths when the approval is reached. A wait you did not answer stops with exit 2 and SIGNAL_UNANSWERED waitForSignal "payment" needs --signal payment.received=<json> (no interactive stdin).

If it isn’t working

Someone edited the payload after you read it. Run approval-payload again, apply your edit to the new revision, and approve with the new fingerprint.
You ran resume on an approval step, or resolve-step with an API key. Approvals are answered with approve, by a signed-in person.
The payload does not match the wait’s schema. The message lists the failing paths; a rejected delivery does not consume its dedupeKey, so send the corrected payload with the same key.

Next steps

Operate runs

Watch a parked run, resume a suspended step, and retry or resolve a parked one.

Workflow builder reference

Every approval() and waitForSignal() option and the output fields.

Handle a webhook

Verify the provider’s signature before you signal a run.

Workflows runtime API

signal, signalByKey, and resume from code.