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 --> XThe complete workflow
Section titled “The complete workflow”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.
Walking through the nodes
Section titled “Walking through the nodes”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_textmode (the default) every reply is the answer, so only theanswerededge is required. Publishing refuses anawait_replynode with noanswerededge. - The JSON to Flow visualizer shows a warning on this node: “Await Reply has no
unanswerededge”. Infree_textmode that edge can never be taken (see Edge labels), the backend does not ask for it, andworkflowDiagnosticsreports 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,impactChoiceholds 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),impactChoiceholds the raw text instead. Publishing refuses anoptions-mode node without anunanswerededge. - Here
unansweredgoes toreask_impact, aresponsethat says sorry, and back intoask_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 bymaxVisits(see the ask-again note). If you want “three tries, then hand over”, count attempts in a variable and gate the back-edge with acondition. - Everything the graph wrote before a pause (
description,impactOptions) is carried into the resumed run, so the classifier can still readdescriptiontwo 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 exactlyhigh. If no label appears at all, the raw text is stored unchanged, and the switch below sends it todefault. The match is a case-insensitive substring search, so ask for one word: in a reply like “not high, I’d say medium”,highappears first and wins.streamOutput: falseis required formatchAnyOfto work at all. Anllmnode streams by default, and the streaming executor ignoresmatchAnyOf,extractFirstIntegerandextractAlphanumericIdand stores the raw reply (seestreamOutputdefaults totrue). A classification is not something the customer should see streaming anyway.workflowDiagnosticsreportsLLM_HELPER_IGNORED_WHEN_STREAMINGif 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 onseverity, not on which node produced it, so you can swap or add a classifier without touching the switch.impact={{impactChoice.id}}withcoerce: "number". An option’sidis always text ("3"), and a template result is always text too. Expressions compare without converting types, soimpact >= 3on the text"3"would not behave like a number comparison.coerce: "number"stores the real number3. 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:analysisis text, not an object, soseveritycannot be read from it; add a node that writesseverityas 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,rejectedandexpired, each its own edge. Hererejectedandexpiredboth lead toreply_reviewed, as two separate edges. expiresAfterSeconds: 7200gives the owner two hours. If nobody decides in time, the request closes asEXPIREDand the run followsexpired, so the customer is never left without an answer.- When the run resumes,
leadDecisionholds{ decision, comment, decidedBy, decidedAt, payload }.reply_escalateduses{{leadDecision.comment}}, which renders blank when the owner left no comment. - The decision is asynchronous:
decideApprovalreturns 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.
What the run trace shows
Section titled “What the run trace shows”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:
assignedon theset_variablerow lists the variables written, in order, andoutputValuemaps each to its value.impactis4, a number, not"4".matchedCaseon theswitchrow is the case that won (matchedCaseIndexcounts from 0). With severitymediumand impact3it would read"priority"with index1; with nothing true it reads"default"and has no index. Which case won is recorded on the trace only, not in the run context.switchUnresolvedappears only when a case named a variable the run did not hold.routerNoteappears only on arouterrow 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_escalatedfor an approval) and carries the realdecidedBy.
Testing it
Section titled “Testing it”1. A draft test, without publishing
Section titled “1. A draft test, without publishing”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.
2. Diagnostics
Section titled “2. Diagnostics”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:
| Code | You would see it if… | What it means for this graph |
|---|---|---|
LLM_HELPER_IGNORED_WHEN_STREAMING | you removed "streamOutput": false from classify | matchAnyOf is ignored, severityLabel holds the whole reply, severity == "high" is never true, and urgent tickets never reach an owner. |
ROUTER_NO_DEFAULT | you replaced the switch with a router and left out defaultTarget | a classification outside the edge labels fails the run with the standard error reply. |
ROUTER_DOTTED_VARIABLE_OF_TEXT | that router read severityLabel.level instead of severity | the router never matches, because severityLabel is text. |
DEAD_END_NON_TERMINAL | you deleted the edge out of a node that is not a reply, for example e8 out of normalise | the turn ends there: the customer answers the impact question and hears nothing back. |
UNREACHABLE_NODE | a node has no path from start, for example the triage branch after deleting e8, or a node you added but never connected | that node never runs, and publishing refuses the workflow. |
FAN_IN_WITHOUT_JOIN | two paths that both run in the same turn met at one node | not 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).
Adapting it
Section titled “Adapting it”- 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_variabletemplateandcoerce: "json"), then setpayloadVarandeditableFieldson the approval. See Let the manager edit before approving. - Create the ticket in your own system: add an
actionnode after the switch (withsuccessandfailureedges) to senddescription,severityandimpactto your API. - Skip the first question when your channel already sends the description in the first message: remove
ask_issueand read{{userMessage}}in the classifier instead.
Next steps
Section titled “Next steps”- Await Reply Node, Set Variable Node, Switch Node, Router Node, Approval Node: the full reference for each node used here.
- Workflow Diagnostics: every code and severity.
- Workflows API Reference: creating, updating, publishing and reading runs.