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

# Add approvals and signals

> Pause a run for a person's decision or an external event, read the outcome in the next step, and answer from the CLI or code

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](/build/workflows/operate-runs).

*Verified against lua-cli 3.33.0.*

**Before you begin**

* A [workflow](/concepts/workflows) that compiles ([Author a workflow](/build/workflows/authoring)).
* The users, role, or group that should decide, as known to your organization.

<Steps>
  <Step title="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.

    ```ts src/workflows/refund-approval.ts expandable theme={null}
    import { z } from 'zod';
    import { createStep, createWorkflow, fromInit, template, Integrations } from 'lua-cli';

    const RefundRequest = z.object({ chargeId: z.string(), amount: z.number() });

    const postRefund = createStep({
      id: 'postRefund',
      inputSchema: z.object({ approved: z.boolean(), editedPayload: RefundRequest.optional() }).passthrough(),
      outputSchema: z.object({ refundId: z.string().nullable() }),
      sideEffects: 'external',
      onError: 'park',
      requiredConnections: ['stripe'],
      async execute({ inputData, getStepResult, once }) {
        if (!inputData.approved) return { refundId: null };
        // The approval does not echo its payload back: read the edited copy, else the one it showed.
        const request = inputData.editedPayload ?? getStepResult<z.infer<typeof RefundRequest>>('request');
        const refundId = await once(`refund:${request.chargeId}`, async () => {
          const res = await Integrations.passthrough('stripe', {
            method: 'POST',
            path: '/v1/refunds',
            data: { charge: request.chargeId, amount: request.amount },
          });
          return (res.data as { id: string }).id;
        });
        return { refundId };
      },
    });

    export const refundApproval = createWorkflow({
      name: 'refund-approval',
      description: 'Ask a support lead to approve a refund, then post it through Stripe.',
      inputSchema: RefundRequest,
      outputSchema: z.object({ refundId: z.string().nullable() }),
      budget: { maxCredits: 5, maxDurationSeconds: 14 * 24 * 3600 },
      connections: [{ key: 'stripe', integrationType: 'stripe', required: true, description: 'Posts refunds' }],
    })
      .map({ chargeId: fromInit('chargeId'), amount: fromInit('amount') }, { id: 'request' })
      .approval('approveRefund', {
        title: 'Refund request',
        details: template('Refund ${initData.amount} on charge ${initData.chargeId}'),
        approver: { role: 'support-lead' },
        excludeInitiator: true,
        timeoutHours: 8,
        businessHours: { tz: 'Europe/London', calendar: 'mon-fri' },
        onTimeout: [{ escalateTo: 'org-admins', timeoutHours: 24 }, 'deny'],
        onDeny: 'continue',
        editablePaths: ['amount'],
        editedPayloadSchema: RefundRequest.extend({ amount: z.number().positive().max(500) }),
      })
      .then(postRefund)
      .commit();
    ```

    `approver` is `'creator'` (the default), `'org-admins'`, `{ users: [...] }`, `{ role }`, `{ group }`, or `{ governance: { policyId } }`; `excludeInitiator` keeps whoever started the run from deciding. `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.
  </Step>

  <Step title="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()`.

    ```json theme={null}
    { "approved": false, "decision": "denied", "text": "denied", "editRevision": 0, "decidedBy": { "id": "local", "kind": "user" } }
    ```

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

  <Step title="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.

    ```ts src/workflows/invoice-paid.ts theme={null}
    import { z } from 'zod';
    import { createStep, createWorkflow, fromInit, Data } from 'lua-cli';

    const Payment = z.object({ invoiceId: z.string(), amount: z.number() });

    const closeInvoice = createStep({
      id: 'closeInvoice',
      inputSchema: z.object({ received: z.boolean().optional(), payload: Payment.optional() }).passthrough(),
      outputSchema: z.object({ closed: z.boolean() }),
      async execute({ inputData, getInitData, once }) {
        if (!inputData.payload) return { closed: false };
        const { invoiceId } = getInitData<{ invoiceId: string }>();
        await once(`close:${invoiceId}`, async () => (await Data.create('closed-invoices', { invoiceId })).id);
        return { closed: true };
      },
    });

    export const invoicePaid = createWorkflow({
      name: 'invoice-paid',
      description: 'Wait for a payment webhook, then close the invoice.',
      inputSchema: z.object({ invoiceId: z.string() }),
      outputSchema: z.object({ closed: z.boolean() }),
    })
      .map({ invoiceId: fromInit('invoiceId') }, { id: 'sent' })
      .waitForSignal('payment', {
        signal: 'payment.received',
        schema: Payment,
        timeoutHours: 72,
        onTimeout: 'continue',
        acceptedSources: ['webhook', 'api'],
      })
      .then(closeInvoice)
      .commit();
    ```

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

    <Info>
      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.
    </Info>
  </Step>

  <Step title="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.

    ```ts src/webhooks/payment-received.webhook.ts theme={null}
    import { z } from 'zod';
    import { LuaWebhook, Workflows } from 'lua-cli';

    export default new LuaWebhook({
      name: 'payment-received',
      description: 'Payment provider callback: signals the waiting invoice-paid run',
      bodySchema: z.object({ eventId: z.string(), invoiceId: z.string(), amount: z.number() }),
      async execute(event) {
        const { eventId, invoiceId, amount } = event.body as { eventId: string; invoiceId: string; amount: number };
        const { delivered } = await Workflows.signalByKey(
          'invoice-paid',
          `invoice:${invoiceId}`,
          'payment.received',
          { invoiceId, amount },
          { dedupeKey: eventId }
        );
        return { delivered };
      },
    });
    ```

    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.

    <Info>
      Local runs only. The deployed runtime fails `Workflows.signal` and `Workflows.signalByKey` with `signal_unavailable`, so a deployed webhook delivers the signal through the [REST API](/reference/rest/workflows) instead; `lua test webhook` runs this file as written.
    </Info>
  </Step>

  <Step title="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.

    ```bash theme={null}
    lua workflows approval-payload wfr_dbd92cd2-034e-48e5-bfb7-cf758ece98bf --approval wfa_d162baea11142a5922c1306fa932eafd
    ```

    ```text Output theme={null}
    🧾 Approval wfa_d162baea11142a5922c1306fa932eafd · revision 0 · fingerprint sha256-cj1:854973c69edea38625bfebf2b0308a189eceefcd321145a90888984674738606
       Editable: yes — drafts, drafts[*].body · 708 bytes
       Payload:
         {
           "drafts": [
             {
               "to": "a@example.com",
               "subject": "Quick intro for Acme",
    …
    ✨ Edit and approve against this revision:
       Approve:  `lua workflows approve wfr_dbd92cd2-034e-48e5-bfb7-cf758ece98bf --approval wfa_d162baea11142a5922c1306fa932eafd --edit @edited.json --fingerprint sha256-cj1:854973c69edea38625bfebf2b0308a189eceefcd321145a90888984674738606`
    ```

    Save the edited payload to a file and approve against that fingerprint, or deny with a note:

    ```bash theme={null}
    lua workflows approve <runId> --approval <approvalId> --edit @edited.json --fingerprint <fingerprint> --note "Capped at 500"
    lua workflows approve <runId> --approval <approvalId> --decision deny --note "Duplicate request"
    ```

    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`).
  </Step>

  <Step title="Verify">
    Run both workflows offline: deny the refund, then deliver the payment signal.

    ```bash theme={null}
    lua test --ci workflow --name refund-approval --input '{"chargeId":"ch_abc123","amount":120}' --deny approveRefund
    lua test --ci workflow --name invoice-paid --input '{"invoiceId":"inv_abc123"}' --signal 'payment.received={"invoiceId":"inv_abc123","amount":120}'
    ```

    ```text Output theme={null}
    🧭 Running workflow locally: refund-approval
    [18:04:09] run local-1789236249226 · 3 planned step(s)
    [18:04:09] request · completed
    [18:04:09] approveRefund · completed
    [18:04:09] postRefund · completed
    [18:04:09] run · completed

    Workflow returned: Object — fields: refundId
    Output:
    { refundId: null }
    …
    ```

    `--approve approveRefund=@edited.json` 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)`.
  </Step>
</Steps>

## If it isn't working

<Accordion title="PAYLOAD_MISMATCH">
  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.
</Accordion>

<Accordion title="APPROVAL_REQUIRES_HUMAN">
  You ran `resume` on an approval step, or `resolve-step` with an API key. Approvals are answered with `approve`, by a signed-in person.
</Accordion>

<Accordion title="SIGNAL_SCHEMA_INVALID">
  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.
</Accordion>

## Next steps

<Columns cols={2}>
  <Card title="Operate runs" href="/build/workflows/operate-runs">Watch a parked run, resume a suspended step, and retry or resolve a parked one.</Card>
  <Card title="Workflow builder reference" href="/reference/sdk/workflow-builder">Every `approval()` and `waitForSignal()` option and the output fields.</Card>
  <Card title="Handle a webhook" href="/build/handle-a-webhook">Verify the provider's signature before you signal a run.</Card>
  <Card title="Workflows runtime API" href="/reference/sdk/workflows">`signal`, `signalByKey`, and `resume` from code.</Card>
</Columns>
