跳转到内容

Recipe: Triage a Support Ticket End to End

此内容尚不支持你的语言。

This recipe builds one support-ticket workflow from start to finish, using the newest workflow nodes together: await_reply to collect the ticket, an llm classifier, set_variable to make the values safe to branch on, a switch to pick the queue, an approval so a workspace owner signs off urgent escalations, then response and end_session. Each node page documents its own fields in full; this page shows how they fit together and which failure each one prevents.

What you’ll build: the agent asks for a description of the problem, then asks who is affected (four fixed choices), classifies the severity, and routes the ticket. A high-severity ticket waits for an owner to approve an emergency escalation; a ticket that affects many people goes to the priority queue; everything else goes to the standard queue. Every path ends the session with a reply.

Every request below needs the x-huat-platform: customer header and a valid dashboard Authorization: Bearer <token> (see Getting Started and Authentication). Approving or rejecting needs a workspace owner.

flowchart TD
S[start] --> Q1[/await_reply: describe the problem/]
Q1 -- answered --> O[set_variable: impact levels]
O --> Q2[/await_reply: who is affected?/]
Q2 -- unanswered --> R[response: sorry] --> Q2
Q2 -- answered --> C[llm: classify severity]
C --> N[set_variable: normalise]
N --> SW{switch: triage}
SW -- escalate --> T[response: tell the user]
T --> A{{approval: on-call lead}}
A -- approved --> E[response: escalated]
A -- rejected --> P2[response: priority queue after review]
A -- expired --> P2
SW -- priority --> P[response: priority queue]
SW -- default --> D[response: standard queue]
E --> X[end_session]
P2 --> X
P --> X
D --> X

This is the whole graph: 15 nodes and 19 edges. It is the editor’s { "nodes": [...], "edges": [...] } shape, so you can paste it as is into the JSON to Flow visualizer to see it drawn, into the workflow editor’s Import dialog, or send it as the nodes and edges variables of updateWorkflow (the two-call create pattern: createWorkflow, then updateWorkflow with the graph in variables, then publishWorkflow).

{
"nodes": [
{
"id": "start",
"type": "start",
"label": "Start",
"config": {},
"position": { "x": 0, "y": 0 }
},
{
"id": "ask_issue",
"type": "await_reply",
"label": "Ask for the problem",
"config": {
"promptTemplate": "Hi, I can log a support ticket for you. Please describe the problem in a sentence or two: what happened, and what were you trying to do?",
"saveAs": "description"
},
"position": { "x": 0, "y": 100 }
},
{
"id": "impact_options",
"type": "set_variable",
"label": "Impact levels",
"config": {
"assignments": [
{
"variable": "impactOptions",
"kind": "literal",
"source": "[{\"id\": \"1\", \"label\": \"Only me\"}, {\"id\": \"2\", \"label\": \"My team\"}, {\"id\": \"3\", \"label\": \"Many of our customers\"}, {\"id\": \"4\", \"label\": \"Everyone, the service is down\"}]",
"coerce": "json"
}
]
},
"position": { "x": 0, "y": 200 }
},
{
"id": "ask_impact",
"type": "await_reply",
"label": "Ask who is affected",
"config": {
"promptTemplate": "Thanks. Who is affected?\n\n{{#each awaitReplyOptions}}{{slot}}. {{label}}\n{{/each}}\nReply with the number.",
"saveAs": "impactChoice",
"matchMode": "options",
"optionsFrom": "impactOptions"
},
"position": { "x": 0, "y": 300 }
},
{
"id": "reask_impact",
"type": "response",
"label": "Ask again",
"config": {
"messageTemplate": "Sorry, I didn't catch that."
},
"position": { "x": 300, "y": 300 }
},
{
"id": "classify",
"type": "llm",
"label": "Classify severity",
"config": {
"promptTemplate": "You triage support tickets. Classify the severity of this ticket.\nhigh: a service is down, data is lost or at risk, or there is a security problem.\nmedium: something is broken but there is a workaround.\nlow: a question, a request or a cosmetic issue.\n\nTicket: \"{{description}}\"\n\nRespond with one word: high, medium or low.",
"outputVar": "severityLabel",
"matchAnyOf": ["high", "medium", "low"],
"streamOutput": false
},
"position": { "x": 0, "y": 400 }
},
{
"id": "normalise",
"type": "set_variable",
"label": "Normalise",
"config": {
"assignments": [
{
"variable": "severity",
"kind": "template",
"source": "{{severityLabel}}"
},
{
"variable": "impact",
"kind": "template",
"source": "{{impactChoice.id}}",
"coerce": "number"
}
]
},
"position": { "x": 0, "y": 500 }
},
{
"id": "triage",
"type": "switch",
"label": "Triage",
"config": {
"cases": [
{ "label": "escalate", "expression": "severity == \"high\"" },
{ "label": "priority", "expression": "impact >= 3" }
]
},
"position": { "x": 0, "y": 600 }
},
{
"id": "tell_escalation",
"type": "response",
"label": "Tell the user",
"config": {
"messageTemplate": "This looks urgent. I've asked the on-call lead to approve an emergency escalation, and I'll update you here as soon as they decide."
},
"position": { "x": -350, "y": 700 }
},
{
"id": "escalation",
"type": "approval",
"label": "On-call lead approves",
"config": {
"titleTemplate": "Urgent ticket: {{impactChoice.label}}",
"summaryTemplate": "Severity: {{severity}}. Impact level {{impact}} ({{impactChoice.label}}).\n\nThe customer wrote: {{description}}",
"saveAs": "leadDecision",
"expiresAfterSeconds": 7200,
"notify": { "inApp": true }
},
"position": { "x": -350, "y": 800 }
},
{
"id": "reply_escalated",
"type": "response",
"label": "Escalated",
"config": {
"messageTemplate": "An engineer has been paged and is working on it now. {{leadDecision.comment}}"
},
"position": { "x": -500, "y": 920 }
},
{
"id": "reply_reviewed",
"type": "response",
"label": "Priority queue after review",
"config": {
"messageTemplate": "Thanks for waiting. Your ticket is in our priority queue, and someone will reply within four hours."
},
"position": { "x": -200, "y": 920 }
},
{
"id": "reply_priority",
"type": "response",
"label": "Priority queue",
"config": {
"messageTemplate": "Thanks. This affects a lot of people, so your ticket is in our priority queue, and someone will reply within four hours."
},
"position": { "x": 100, "y": 700 }
},
{
"id": "reply_standard",
"type": "response",
"label": "Standard queue",
"config": {
"messageTemplate": "Thanks, your ticket is logged. Someone will reply within one business day."
},
"position": { "x": 400, "y": 700 }
},
{
"id": "close",
"type": "end_session",
"label": "End the conversation",
"config": { "runEvaluation": false },
"position": { "x": 0, "y": 1040 }
}
],
"edges": [
{ "id": "e1", "source": "start", "target": "ask_issue" },
{
"id": "e2",
"source": "ask_issue",
"target": "impact_options",
"label": "answered"
},
{ "id": "e3", "source": "impact_options", "target": "ask_impact" },
{
"id": "e4",
"source": "ask_impact",
"target": "classify",
"label": "answered"
},
{
"id": "e5",
"source": "ask_impact",
"target": "reask_impact",
"label": "unanswered"
},
{ "id": "e6", "source": "reask_impact", "target": "ask_impact" },
{ "id": "e7", "source": "classify", "target": "normalise" },
{ "id": "e8", "source": "normalise", "target": "triage" },
{
"id": "e9",
"source": "triage",
"target": "tell_escalation",
"label": "escalate"
},
{
"id": "e10",
"source": "triage",
"target": "reply_priority",
"label": "priority"
},
{
"id": "e11",
"source": "triage",
"target": "reply_standard",
"label": "default"
},
{ "id": "e12", "source": "tell_escalation", "target": "escalation" },
{
"id": "e13",
"source": "escalation",
"target": "reply_escalated",
"label": "approved"
},
{
"id": "e14",
"source": "escalation",
"target": "reply_reviewed",
"label": "rejected"
},
{
"id": "e15",
"source": "escalation",
"target": "reply_reviewed",
"label": "expired"
},
{ "id": "e16", "source": "reply_escalated", "target": "close" },
{ "id": "e17", "source": "reply_reviewed", "target": "close" },
{ "id": "e18", "source": "reply_priority", "target": "close" },
{ "id": "e19", "source": "reply_standard", "target": "close" }
]
}

The conversation takes three turns before the ticket is routed: the first message starts the run and the agent asks for a description; the reply to that resumes the run and the agent asks who is affected; the reply to that resumes it again, and the rest of the graph runs in that third turn.

1. ask_issue: collect the description (await_reply)

Section titled “1. ask_issue: collect the description (await_reply)”

await_reply speaks its promptTemplate, ends the turn, and continues from its answered edge when the next message arrives, with the reply stored in saveAs (description). Without it, every message would restart the graph at start and the classifier would see “Hi” instead of the problem.

  • In free_text mode (the default) every reply is the answer, so only the answered edge is required. Publishing refuses an await_reply node with no answered edge.
  • The JSON to Flow visualizer shows a warning on this node: “Await Reply has no unanswered edge”. In free_text mode that edge can never be taken (see Edge labels), the backend does not ask for it, and workflowDiagnostics reports nothing here, so leave it out.
  • A wait stays open for 24 hours by default. If the customer answers later than that, their message starts a fresh run from start; the expired wait does not take any edge.

2. impact_options: build the choice list (set_variable, literal + coerce: "json")

Section titled “2. impact_options: build the choice list (set_variable, literal + coerce: "json")”

An options-mode question needs an array in the run context to offer. A literal assignment with coerce: "json" parses the JSON text in source and stores a real array in impactOptions, with no model call. The same node could compute the list from other variables with a template, as long as the template builds the JSON around scalar values (see the escaping trap).

3. ask_impact: a fixed set of answers (await_reply, options mode)

Section titled “3. ask_impact: a fixed set of answers (await_reply, options mode)”

matchMode: "options" with optionsFrom: "impactOptions" renders the list once, when the question is asked, and matches the reply against that stored list only: the slot number (3), the exact id, the label, or a unique whole word of it. Each option uses the default id and label fields, so no optionIdField/optionLabelField is needed.

  • On answered, impactChoice holds the matched option object, for example { "slot": 3, "id": "3", "label": "Many of our customers" }.
  • On unanswered (a reply that matches nothing, or more than one option), impactChoice holds the raw text instead. Publishing refuses an options-mode node without an unanswered edge.
  • Here unanswered goes to reask_impact, a response that says sorry, and back into ask_impact, which asks again in the same turn and pauses again. Each attempt is a new run, so this loop is bounded by the person replying, not by maxVisits (see the ask-again note). If you want “three tries, then hand over”, count attempts in a variable and gate the back-edge with a condition.
  • Everything the graph wrote before a pause (description, impactOptions) is carried into the resumed run, so the classifier can still read description two turns later.

4. classify: severity as one exact label (llm, matchAnyOf, streamOutput: false)

Section titled “4. classify: severity as one exact label (llm, matchAnyOf, streamOutput: false)”

The classifier stores its answer in severityLabel. Two settings make that value something you can branch on:

  • matchAnyOf: ["high", "medium", "low"] stores the candidate label that appears earliest in the reply, so a model that answers “This is high severity because…” still stores exactly high. If no label appears at all, the raw text is stored unchanged, and the switch below sends it to default. The match is a case-insensitive substring search, so ask for one word: in a reply like “not high, I’d say medium”, high appears first and wins.
  • streamOutput: false is required for matchAnyOf to work at all. An llm node streams by default, and the streaming executor ignores matchAnyOf, extractFirstInteger and extractAlphanumericId and stores the raw reply (see streamOutput defaults to true). A classification is not something the customer should see streaming anyway. workflowDiagnostics reports LLM_HELPER_IGNORED_WHEN_STREAMING if you leave this line out.

matchAnyOf replaces the output with the label, so use it only on a node whose output is a single label, never on one that must return JSON.

5. normalise: copy and convert (set_variable)

Section titled “5. normalise: copy and convert (set_variable)”

Two assignments, applied in order, with no model call:

  • severity = {{severityLabel}} copies the classification into the name the switch reads. The switch’s expressions now depend on severity, not on which node produced it, so you can swap or add a classifier without touching the switch.
  • impact = {{impactChoice.id}} with coerce: "number". An option’s id is always text ("3"), and a template result is always text too. Expressions compare without converting types, so impact >= 3 on the text "3" would not behave like a number comparison. coerce: "number" stores the real number 3. It fails the run if the text is not a number, which cannot happen here because the options are fixed and every id is a digit.

The node’s trace row lists both names in assigned.

6. triage: ordered tests with a mandatory default (switch)

Section titled “6. triage: ordered tests with a mandatory default (switch)”
{
"cases": [
{ "label": "escalate", "expression": "severity == \"high\"" },
{ "label": "priority", "expression": "impact >= 3" }
]
}

Cases are tested in order and the first true one wins, so a high-severity ticket that also affects everyone takes escalate, not priority. When neither is true the run follows the edge labelled default. Publishing refuses a switch unless every case label has exactly one outgoing edge with that label, there is exactly one default edge, and there is no other edge (only loop_exhausted is also allowed).

A case whose expression names a variable the run does not hold counts as false and is listed in the trace’s switchUnresolved, so a misspelt variable name shows up there instead of failing silently.

Why a switch here and not a router? The decision depends on two values (severity and impact >= 3), and a router can only compare one variable’s text with its edge labels. Use a router when one variable already holds the branch name, for example if you only branched on severity:

{
"id": "route_severity",
"type": "router",
"label": "Route on severity",
"config": { "variable": "severity", "defaultTarget": "reply_standard" }
}

with edges labelled high, medium and low. Set defaultTarget on every router: a value that matches no edge label and has no defaultTarget fails the run with the standard error reply. And route on a flat variable such as severity, not a dotted path into the classifier’s output: an llm node stores text, so a router on severityLabel.level never matches, falls through to defaultTarget, and its trace row carries a routerNote such as:

variable analysis.severity: analysis is text, not an object, so severity cannot be read from it; add a node that writes severity as its own variable

See Switch, router or condition? for the full comparison.

7. tell_escalation and escalation: an owner decides (response + approval)

Section titled “7. tell_escalation and escalation: an owner decides (response + approval)”

The approval node says nothing to the customer, so the response before it tells them a person is checking. The approval then pauses the run (no worker is held while it waits), opens a request in the approvals inbox, and sends every active workspace owner an in-app notification (notify.inApp). titleTemplate and summaryTemplate are rendered from the run context, so the owner sees the severity, the impact and the customer’s own words.

  • All three edges are required: approved, rejected and expired, each its own edge. Here rejected and expired both lead to reply_reviewed, as two separate edges.
  • expiresAfterSeconds: 7200 gives the owner two hours. If nobody decides in time, the request closes as EXPIRED and the run follows expired, so the customer is never left without an answer.
  • When the run resumes, leadDecision holds { decision, comment, decidedBy, decidedAt, payload }. reply_escalated uses {{leadDecision.comment}}, which renders blank when the owner left no comment.
  • The decision is asynchronous: decideApproval returns the decided request, and the reply reaches the customer a moment later from a background worker.

See Human approval: a walkthrough for the inbox and the decision API.

8. Replies and close (response + end_session)

Section titled “8. Replies and close (response + end_session)”

Each branch ends with a response and then one shared end_session node with runEvaluation: false (no post-session evaluation, no extra model calls; set it to true if you read evaluations). Four edges lead into close, but they come from branches that split at the switch and the approval, where only one branch ever runs, so close runs once per conversation.

Query the session’s runs with workflowRuns(sessionId:), then one run’s nodeTrace with workflowRun(id:). The conversation produces one run per turn: the first two stop with status AWAITING_INPUT at ask_issue and ask_impact, and each later run’s resumedFromRunId points at the run it continued.

Below is the third run of a draft test (next section) where the customer reported a payment outage, chose 4 (“Everyone, the service is down”), and the simulated decision was APPROVED. Rows are trimmed to the fields this page discusses (startedAt, finishedAt and others are left out):

[
{
"nodeId": "classify",
"nodeType": "llm",
"status": "COMPLETED",
"outputVar": "severityLabel",
"outputValue": "high"
},
{
"nodeId": "normalise",
"nodeType": "set_variable",
"status": "COMPLETED",
"outputVar": "severity",
"outputValue": { "severity": "high", "impact": 4 },
"assigned": ["severity", "impact"]
},
{
"nodeId": "triage",
"nodeType": "switch",
"status": "COMPLETED",
"matchedCase": "escalate",
"matchedCaseIndex": 0
},
{
"nodeId": "tell_escalation",
"nodeType": "response",
"status": "COMPLETED"
},
{
"nodeId": "escalation",
"nodeType": "approval",
"status": "COMPLETED",
"outputVar": "leadDecision",
"outputValue": {
"decision": "approved",
"comment": null,
"decidedBy": null,
"decidedAt": "2026-10-12T09:15:02.000Z",
"payload": {},
"simulated": true
}
},
{
"nodeId": "reply_escalated",
"nodeType": "response",
"status": "COMPLETED"
},
{ "nodeId": "close", "nodeType": "end_session", "status": "COMPLETED" }
]

What to read in it:

  • assigned on the set_variable row lists the variables written, in order, and outputValue maps each to its value. impact is 4, a number, not "4".
  • matchedCase on the switch row is the case that won (matchedCaseIndex counts from 0). With severity medium and impact 3 it would read "priority" with index 1; with nothing true it reads "default" and has no index. Which case won is recorded on the trace only, not in the run context.
  • switchUnresolved appears only when a case named a variable the run did not hold.
  • routerNote appears only on a router row that fell through to its default for a reason the router can explain (the dotted-path case above). This graph has no router, so you will not see it here.
  • In a real (non-draft) run, the approval row ends the run with status AWAITING_INPUT; the owner’s decision starts a new run that begins at the matching edge’s target (reply_escalated for an approval) and carries the real decidedBy.

Save the graph with updateWorkflow, attach the workflow to a test agent, start a test session, and send each turn with sendMessage and testDraft: true. Every turn, including the ones that resume a pause, then runs the draft (on staging, coming to production, a draft test also runs in mock mode by default, so no step calls another system; see Testing a Workflow Safely):

mutation Draft($input: SendMessageInput!) {
sendMessage(input: $input) {
id
}
}
{ "input": { "sessionId": 123, "text": "Hi", "testDraft": true } }
{
"input": {
"sessionId": 123,
"text": "Card payments fail at checkout for every customer since 10:00",
"testDraft": true
}
}
{
"input": {
"sessionId": 123,
"text": "4",
"testDraft": true,
"draftApprovalOutcome": "APPROVED"
}
}

The approval node never opens a real request in a draft test. Without draftApprovalOutcome on the turn that reaches it, the node fails on purpose; with it, the node completes in place, follows only that edge, and writes "simulated": true into leadDecision. Run the third turn again in a new session with REJECTED and EXPIRED, and with answers that should take priority (a minor bug, impact 3) and default (a question, impact 1). Send "lots" as the second answer once to see the unanswered loop ask again.

To exercise a real approval, publish the workflow with publishWorkflow and run the conversation without testDraft; an owner then decides from the inbox or with decideApproval.

workflowDiagnostics checks the draft graph without running it:

query Check($id: Int!) {
workflowDiagnostics(id: $id) {
severity
code
nodeId
message
}
}

For the graph on this page it returns an empty list. The findings you would see if you changed it, and what each means here:

CodeYou would see it if…What it means for this graph
LLM_HELPER_IGNORED_WHEN_STREAMINGyou removed "streamOutput": false from classifymatchAnyOf is ignored, severityLabel holds the whole reply, severity == "high" is never true, and urgent tickets never reach an owner.
ROUTER_NO_DEFAULTyou replaced the switch with a router and left out defaultTargeta classification outside the edge labels fails the run with the standard error reply.
ROUTER_DOTTED_VARIABLE_OF_TEXTthat router read severityLabel.level instead of severitythe router never matches, because severityLabel is text.
DEAD_END_NON_TERMINALyou deleted the edge out of a node that is not a reply, for example e8 out of normalisethe turn ends there: the customer answers the impact question and hears nothing back.
UNREACHABLE_NODEa node has no path from start, for example the triage branch after deleting e8, or a node you added but never connectedthat node never runs, and publishing refuses the workflow.
FAN_IN_WITHOUT_JOINtwo paths that both run in the same turn met at one nodenot raised for close: its four incoming paths split at the switch and the approval, which follow one edge each.

Diagnostics never block saving or publishing. A missing default edge on the switch, or a missing approved/rejected/expired edge on the approval, is not a diagnostic: publishWorkflow refuses the graph with a message naming the node (for the approval, the missing label; for the switch, often the node that the missing edge used to reach, which is now unreachable).

  • Different queues: add cases to the switch (up to 10), each with its own edge. Keep the most specific test first.
  • A manager edits before approving: put an object in a context variable (for example with a set_variable template and coerce: "json"), then set payloadVar and editableFields on the approval. See Let the manager edit before approving.
  • Create the ticket in your own system: add an action node after the switch (with success and failure edges) to send description, severity and impact to your API.
  • Skip the first question when your channel already sends the description in the first message: remove ask_issue and read {{userMessage}} in the classifier instead.