Skip to content

Human approval: a walkthrough

This content is not available in your language yet.

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]
  • 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.

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.

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.

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.

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.

  • 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 CONFLICT naming 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.