Human approval: a walkthrough
This walkthrough adds a manager sign-off to a workflow: the agent collects a refund request, pauses, an owner approves or rejects it, and the agent carries on down the matching branch. It uses the approval node; read its reference page for every field and limit.
flowchart LR S[start] --> A[response: I've asked a manager] A --> P{{approval}} P -- approved --> OK[response: approved] P -- rejected --> NO[response: declined] P -- expired --> EX[response: no answer in time]Before you start
Section titled “Before you start”- You need a workspace owner account. Only owners can read the approvals inbox or decide.
- A saved, publishable workflow attached to an agent. If you have not built one, follow the two-call create pattern.
- The approval operations are on the main API (
x-huat-platform: customer) with the owner’s JWT.
1. Build the graph
Section titled “1. Build the graph”Create the workflow with createWorkflow, then save the graph with updateWorkflow (the graph goes in variables, not an inline literal):
{ "nodes": [ { "id": "start", "type": "start", "label": "Start", "config": {}, "position": { "x": 0, "y": 0 } }, { "id": "tell_user", "type": "response", "label": "Tell the user", "config": { "messageTemplate": "Thanks, I've asked a manager to review your request. I'll come back as soon as they decide." }, "position": { "x": 0, "y": 100 } }, { "id": "ask_manager", "type": "approval", "label": "Manager approval", "config": { "titleTemplate": "Refund request", "summaryTemplate": "The customer wrote: {{userMessage}}", "saveAs": "approval", "expiresAfterSeconds": 86400, "notify": { "inApp": true } }, "position": { "x": 0, "y": 200 } }, { "id": "approved_reply", "type": "response", "label": "Approved", "config": { "messageTemplate": "Good news: your request was approved. {{approval.comment}}" }, "position": { "x": -200, "y": 320 } }, { "id": "rejected_reply", "type": "response", "label": "Declined", "config": { "messageTemplate": "Sorry, we couldn't approve this one. {{approval.comment}}" }, "position": { "x": 0, "y": 320 } }, { "id": "expired_reply", "type": "response", "label": "No answer in time", "config": { "messageTemplate": "I haven't heard back from the team yet. Someone will follow up with you." }, "position": { "x": 200, "y": 320 } } ], "edges": [ { "id": "e1", "source": "start", "target": "tell_user" }, { "id": "e2", "source": "tell_user", "target": "ask_manager" }, { "id": "e3", "source": "ask_manager", "target": "approved_reply", "label": "approved" }, { "id": "e4", "source": "ask_manager", "target": "rejected_reply", "label": "rejected" }, { "id": "e5", "source": "ask_manager", "target": "expired_reply", "label": "expired" } ]}All three edge labels are required. Leave one out and updateWorkflow/publishWorkflow tells you which. Then call publishWorkflow: a draft that is not published does not run for real sessions.
2. Test it in a draft (nothing real is opened)
Section titled “2. Test it in a draft (nothing real is opened)”Send a message with testDraft: true and a simulated outcome. The approval node completes in place and follows only that edge:
mutation Draft($input: SendMessageInput!) { sendMessage(input: $input) { id }}{ "input": { "sessionId": 123, "text": "Please refund order 8841", "testDraft": true, "draftApprovalOutcome": "APPROVED" }}Try REJECTED and EXPIRED too. The node’s trace output and the run context show approval.simulated: true. No request is created and no one is notified.
3. Run it for real and decide
Section titled “3. Run it for real and decide”Start a session and send the message without testDraft. The run pauses at the approval node. The user’s turn ends there: after the “I’ve asked a manager” reply, sessionEvents sends an empty AiResponseEvent with turnComplete: true (nothing is stored), so a chat client that waits for turnComplete unlocks its input instead of waiting for the decision. Ignore events with empty text. Every active owner gets an in-app “Approval needed” notification, and the request is in the inbox:
query Inbox { approvals(first: 20) { edges { node { id title summary status expiresAt workflowRunId } } pageInfo { endCursor hasNextPage } }}With no filter the inbox lists only PENDING requests. Pass status for one other state, or statuses: [APPROVED, REJECTED, EXPIRED, CANCELLED] for every decided request in one list (see approvals and the changelog).
An owner approves (or rejects) with:
mutation Decide($id: ID!) { decideApproval(id: $id, decision: APPROVE, comment: "Within policy") { id status decidedAt }}The response is the decided request, not the finished run. The run resumes a moment later on a background worker, and the approved branch’s reply follows. REJECT follows the rejected branch. If nobody decides before expiresAfterSeconds, a background sweeper closes the request as EXPIRED and the run follows the expired branch. cancelApproval(id, reason) withdraws a pending request instead; the run stays paused and does not resume.
To react live, subscribe to approvalOpened and approvalDecided (see Events & Subscriptions). They carry only the id, status and title; read approval(id) for the details.
4. Let the manager edit before approving
Section titled “4. Let the manager edit before approving”If the manager should be able to correct a value, put the object in a context variable (for example from an earlier action node whose outputVar is refund), then name it and the editable keys:
{ "titleTemplate": "Refund of {{refund.amount}}", "payloadVar": "refund", "editableFields": ["amount"], "saveAs": "approval"}The approver sends the change with the approval. Edits are honoured on approve only: a reject that carries editedPayload is accepted and the edits are ignored. editedPayload goes through variables:
mutation Decide($id: ID!, $edit: JSON) { decideApproval(id: $id, decision: APPROVE, editedPayload: $edit) { id status }}{ "id": "42", "edit": { "amount": 35 } }After approval the workflow reads {{approval.payload.amount}} (35); a key the node did not list as editable is refused with BadRequest.
5. Send a draft back for changes
Section titled “5. Send a draft back for changes”An owner can answer a draft with “not yet, change this” instead of approve or reject. Add one edge labelled changes_requested from the approval node, normally back to the node that drafted the content:
{ "source": "approve", "target": "draft_reply", "label": "changes_requested" }Then decide with REQUEST_CHANGES. The comment is required: it is the feedback.
mutation SendBack($id: ID!) { decideApproval( id: $id decision: REQUEST_CHANGES comment: "Shorter, and mention the refund window" ) { id status revision }}The request ends as CHANGES_REQUESTED and the run resumes on the changes_requested edge. The drafting node can read the feedback as {{approval.comment}}, and {{approval.revision}} says how many send-backs were used. When the revised draft reaches the approval node, a new request opens with revision one higher (0 is the first draft). You can send a draft back 3 times; after that only APPROVE or REJECT is accepted. An approval step with no changes_requested edge refuses REQUEST_CHANGES and stays pending. See Approval Node.
Good to know
Section titled “Good to know”- A workflow published while a request is pending does not change it: the run resumes on the version it paused on.
- The request holds the payload, which may contain customer data, so only owners can read it.
- Email notification is not available yet; in-app notifications are.
- A decision is final: a second decide on the same request answers
CONFLICTnaming who decided and when. - The dashboard has an Approvals inbox and a decision panel, and the workflow editor has a form for the approval node.