Approval Node
此内容尚不支持你的语言。
The approval node pauses the run and asks a workspace owner to approve or reject a step before the AI employee continues. Nothing is held open while it waits: the run is parked, no worker slot is used, and the run resumes down the approved, rejected or expired edge when the decision (or the deadline) arrives. An owner can also send a draft back with feedback, which resumes the run on an optional changes_requested edge (see below).
Reach for it when the agent is about to do something a person should sign off first: issue a refund, send a quote, write to a customer record, escalate to a vendor. For a question the end user answers, use await_reply instead. For the whole flow end to end, see Human approval: a walkthrough. See the end-to-end example in Triage a support ticket end to end, where an owner approves urgent escalations.
See Workflow Overview for the node/edge envelope this fits into.
Config fields
Section titled “Config fields”ApprovalNodeConfig:
| Field | Type | Required | Description |
|---|---|---|---|
titleTemplate | string | Yes | The request’s headline, a Handlebars template rendered against the run context. Shown in the approvals inbox and in notifications. Truncated to 200 characters. |
saveAs | string | Yes | The context variable the decision lands in when the run resumes. Validated against the reserved-key list, same as any other node’s output name. |
summaryTemplate | string | No | A longer description for the approver, also a Handlebars template. Truncated to 4,000 characters. |
payloadVar | string | No | Name of a context variable holding the object the manager is deciding on (for example an order or a draft refund). Shown to the approver, and the only thing they can edit. Limited to 64 KB when serialized; secret-shaped values are redacted. |
editableFields | string[] | No | Keys of the payload an approver may change while approving. Requires payloadVar, no duplicates. An edit to any other key is refused. |
approverRole | 'OWNER' | No (default OWNER) | Who may decide. OWNER is the only value today: the workspace owners. |
expiresAfterSeconds | number | No (default 259200, 72 h) | How long the request stays decidable. Must be at least 1 and at most 1209600 (14 days); a larger value is rejected when the workflow is saved or published. |
notify | { inApp?: boolean; email?: boolean } | No | inApp creates a notification for every active owner. email is accepted but no email is sent yet: the platform has no email transport configured. |
Edge labels: approved, rejected and expired
Section titled “Edge labels: approved, rejected and expired”Routing lives on the edges, the same way it does for condition and await_reply. All three labels are required, each on its own edge, and the graph is refused at save and publish time otherwise. The expired edge may lead to the same node as rejected. Any other label, or an unlabeled edge, on an approval node is refused, because no outcome would ever follow it.
Optional edge: changes_requested
Section titled “Optional edge: changes_requested”An approval node may also carry one edge labelled changes_requested. It is optional: a graph without it keeps working exactly as before, and the three required labels stay required. When an owner decides with REQUEST_CHANGES (a comment is required, it is the feedback), the run resumes down this edge, normally back to the node that drafted the thing, with the owner’s comment available as {{approval.comment}}. The revised draft reaches the approval node again and opens a new request.
- The request being sent back gets the status
CHANGES_REQUESTEDand is closed. Its successor hasrevisionone higher (0for the first draft). - At most 3 revisions. After the third send-back,
REQUEST_CHANGESis refused for that lineage and onlyAPPROVEorREJECTis accepted. - No
changes_requestededge, no send-back.REQUEST_CHANGESon an approval step whose graph has no such edge is refused before anything is claimed, so the request stays pending and can still be approved or rejected. - Draft tests cannot simulate it:
draftApprovalOutcomeaccepts onlyAPPROVED,REJECTEDandEXPIRED.
What saveAs holds when the run resumes
Section titled “What saveAs holds when the run resumes”{ "decision": "approved", "comment": "OK, within policy", "decidedBy": { "userId": 12, "name": "Aisha" }, "decidedAt": "2026-10-07T14:03:11.000Z", "payload": { "refundAmount": 40 }}decisionisapproved,rejectedorexpired(orchanges_requested, once that is available), the same word as the edge taken. Onchanges_requestedthe object also carriesrevision: how many send-backs this request lineage has used so far, counting this one.commentis what the approver typed (up to 1,000 characters), ornullwhen none was given (it renders as blank in a template).decidedByisnullfor an expiry and for a simulated draft decision.payloadis the object under decision. On an approval, anyeditableFieldsedits are laid over the original, so unedited keys survive; a rejection or expiry carries the original.
With "saveAs": "approval", a later node uses {{approval.decision}}, {{approval.comment}} and {{approval.payload.refundAmount}}.
Behaviour to know
Section titled “Behaviour to know”- The decision is asynchronous.
decideApprovalreturns the decided request; the paused run resumes a moment later on a background worker, and its replies or side effects follow after that. - Nothing is said to the end user by this node. Put a
responsenode before it if the user should be told that a person is checking. - The pause ends the chat turn. When the run pauses here, subscribers to
sessionEventsreceive an emptyAiResponseEvent(text: "",isFinal: true,turnComplete: true, with the turn’sclientTurnId), so a client that waits forturnCompleteis no longer held until the decision. Nothing is stored: no message is added to the session, and the event is not replayed on reconnect. Skip events with empty text if you render every event. After the decision, the outcome branch’s replies are delivered as usual, each with its normalturnComplete. - Owner-only. Reading the inbox and deciding both need a signed-in workspace owner. API keys cannot decide.
- An overdue request cannot be decided, even in the short window before the expiry sweeper closes it. Cancelling a request leaves the run paused: it never resumes.
- At most 5 pending approvals per run, which bounds a looping graph.
- Inside a sub-agent (pilot). A workflow containing an
approvalnode can be another workflow’ssub_agenttarget, andpublishWorkflowno longer refuses it (changed 2026-11-05, in production since that date). The paused target is reported to the master asawaiting_approval; see A target that waits for an owner approval. The owner’s decision resumes the target’s own workflow, not the master’s. - Like
await_reply, a run whose context is too large to snapshot cannot pause and fails with a message naming the node. - Draft tests don’t open real requests. In a dashboard draft test the node fails closed, unless you pass
draftApprovalOutcome(below).
Draft tests: draftApprovalOutcome
Section titled “Draft tests: draftApprovalOutcome”sendMessage accepts draftApprovalOutcome: APPROVED | REJECTED | EXPIRED together with testDraft: true. The approval node then completes in place: no request is created, no notification sent, and the run follows only the matching edge, with saveAs set to the usual object (decidedBy is null) plus "simulated": true, which also shows in that node’s trace output. Without testDraft: true the option is refused. A mock-mode test (available on staging, coming to production) behaves the same way and never opens a real request, a sub-agent’s child run included; a regression replay that reaches an approval node fails in either mode. See Testing a Workflow Safely.
Operations
Section titled “Operations”All of these need a signed-in workspace owner (x-huat-platform: customer).
| Operation | What it does |
|---|---|
approvals(status, workflowId, agentId, first, after) | The inbox, newest first. With no status, only PENDING. Relay-style paging: first (default 20, max 100) and after (a previous pageInfo.endCursor). |
approval(id) | One request: title, summary, payload, editableFields, status, deadline. |
decideApproval(id, decision, comment, editedPayload) | APPROVE or REJECT; REQUEST_CHANGES too (in production since 2026-11-05), with a required comment. editedPayload (a JSON object sent through variables) is honoured on approve only and every key must be in editableFields. Limited to 30 calls a minute per client IP. |
cancelApproval(id, reason) | Withdraws a pending request (CANCELLED). The run stays paused. |
subscription approvalOpened, approvalDecided | Live inbox updates: just the request id, status and title. Query approval(id) for the payload. |
Errors: NOT_FOUND for an id in no workspace you own; CONFLICT if the request was already decided, cancelled or has expired (the message names who and when, by user id); BAD_REQUEST for a comment over the limit or an edit to a key that is not editable (and, for REQUEST_CHANGES, a missing comment, an approval step with no changes_requested edge, or a request already sent back 3 times). Read extensions.code, not the message text.
When a request opens, every active owner also gets an in-app notification titled “Approval needed”, linking to /approvals/<id>.