Skip to content

Approval Node

This content is not available in your language yet.

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.

ApprovalNodeConfig:

FieldTypeRequiredDescription
titleTemplatestringYesThe request’s headline, a Handlebars template rendered against the run context. Shown in the approvals inbox and in notifications. Truncated to 200 characters.
saveAsstringYesThe context variable the decision lands in when the run resumes. Validated against the reserved-key list, same as any other node’s output name.
summaryTemplatestringNoA longer description for the approver, also a Handlebars template. Truncated to 4,000 characters.
payloadVarstringNoName 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.
editableFieldsstring[]NoKeys 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.
expiresAfterSecondsnumberNo (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 }NoinApp 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.

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_REQUESTED and is closed. Its successor has revision one higher (0 for the first draft).
  • At most 3 revisions. After the third send-back, REQUEST_CHANGES is refused for that lineage and only APPROVE or REJECT is accepted.
  • No changes_requested edge, no send-back. REQUEST_CHANGES on 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: draftApprovalOutcome accepts only APPROVED, REJECTED and EXPIRED.
{
"decision": "approved",
"comment": "OK, within policy",
"decidedBy": { "userId": 12, "name": "Aisha" },
"decidedAt": "2026-10-07T14:03:11.000Z",
"payload": { "refundAmount": 40 }
}
  • decision is approved, rejected or expired (or changes_requested, once that is available), the same word as the edge taken. On changes_requested the object also carries revision: how many send-backs this request lineage has used so far, counting this one.
  • comment is what the approver typed (up to 1,000 characters), or null when none was given (it renders as blank in a template).
  • decidedBy is null for an expiry and for a simulated draft decision.
  • payload is the object under decision. On an approval, any editableFields edits 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}}.

  • The decision is asynchronous. decideApproval returns 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 response node 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 sessionEvents receive an empty AiResponseEvent (text: "", isFinal: true, turnComplete: true, with the turn’s clientTurnId), so a client that waits for turnComplete is 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 normal turnComplete.
  • 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 approval node can be another workflow’s sub_agent target, and publishWorkflow no longer refuses it (changed 2026-11-05, in production since that date). The paused target is reported to the master as awaiting_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).

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.

All of these need a signed-in workspace owner (x-huat-platform: customer).

OperationWhat 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, approvalDecidedLive 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>.