Workflow Best Practices
此内容尚不支持你的语言。
This page covers judgment calls that come up once you start authoring real workflows: which templating behavior applies to which field, and a specific, well-documented failure mode in LLM nodes that’s worth designing around from the start.
See Workflows Overview first if you haven’t read about the node/edge model yet.
Templating: know which fields escape what
Section titled “Templating: know which fields escape what”Every node config field that accepts a template — a prompt, a message, an arguments object, a request body — is Handlebars syntax ({{variableName}}, with {{#if}} / {{#each}} block helpers available). But different fields are rendered with different escaping rules, and knowing which applies to which field tells you what’s safe to put in a variable.
- Text fields (an LLM node’s prompt, a response node’s message, an end-session confirmation prompt) render with no escaping at all. This is correct for anything a human or a model reads as natural language — a contraction like
you'dstaysyou'd, it doesn’t get turned into an HTML entity. - JSON fields (a tool or action node’s arguments template, a webhook’s request body) render with escaping that guarantees the result is valid to embed inside a JSON string literal — quotes, backslashes, and control characters (like a stray newline in an LLM-extracted value) are all handled for you. You never need to hand-roll escaping logic in these templates yourself.
Rule of thumb: if the field’s value ultimately gets parsed as JSON, don’t worry about escaping — it’s handled. If the field’s value is meant to be read as text, write it exactly as you want it to read.
Building a hand-authored JSON envelope inside a text field: use jsonString
Section titled “Building a hand-authored JSON envelope inside a text field: use jsonString”Some workflows deliberately make a response node’s messageTemplate render a JSON envelope as its outgoing text — e.g. {"message": "...", "type": "ORDER_STATUS", "data": {...}} — so the client app can branch on type instead of parsing free text. messageTemplate is a text field (no-escape rendering, per the rule of thumb above), which is correct for the JSON structure you’re hand-authoring around a variable, but wrong the moment that variable’s own value needs to survive being embedded inside a JSON string literal — a raw newline or an unescaped quote in an LLM-generated value breaks the JSON the moment the client tries to parse it, even though the template itself rendered without error.
Use the jsonString Handlebars helper for exactly this case:
{"message": {{jsonString summaryText}}, "type": "SUMMARY"}jsonString runs its argument through the same JSON-safe escaping a JSON field gets (see above), but is available inside any text-field template — closing the one gap the text/JSON split above doesn’t cover on its own. Note there are no quotes around {{jsonString summaryText}} in the example — the helper’s output already includes the surrounding quotes, since JSON.stringify on a string produces a quoted string.
Turning an array into a spoken list: use joinList
Section titled “Turning an array into a spoken list: use joinList”{{joinList items}} renders an array as natural prose — "Acme, Globex or Initech" — instead of Handlebars’ default comma-run. Pass a second argument to change the conjunction: {{joinList items "and"}}. Empty arrays render as an empty string, single items as themselves. Useful for the “which one did you mean?” prompt when an action reports NEEDS_ORG_CHOICE, or any time you hand a response node a list of names.
Those are the only three custom helpers (jsonString, findId, joinList). There is no eq/comparison helper — templates can test truthiness with {{#if var}}, but string-equality branching belongs in a router or condition node, not in a template.
Tool results: raw text vs. structured extraction, and choosing your response shape
Section titled “Tool results: raw text vs. structured extraction, and choosing your response shape”A tool node’s outputVar always holds the flattened, plain-text form of whatever the MCP server returned. If the server’s response happens to be prose (“Room 401 is available from 2pm to 4pm”), that’s fine to interpolate directly. But if the server returns a JSON-shaped payload as its text content — some real-world MCP servers do this instead of using the MCP protocol’s own structuredContent field — naively writing {{outputVar}} into a response node’s messageTemplate leaks the entire raw JSON envelope verbatim into the user-facing reply. This is a real, easy-to-hit bug, not a hypothetical: it reproduces identically regardless of which LLM model is generating anything upstream, because it’s a template-wiring issue, not a model-quality one.
To guard against this, every tool node also writes two extra context keys alongside outputVar, whether or not the server used structuredContent:
_{outputVar}Structured— the parsed object. Populated from the MCP protocol’sstructuredContentfield when the server sets it; otherwise, if the flattened text itself looks JSON-shaped (starts with{or[), it’s parsed automatically as a fallback. If the text is genuinely plain prose, this isnull._{outputVar}StructuredJson— the same object, pre-serialized to a JSON string (ornull). Handlebars has no built-in way to stringify a real object ({{_fooStructured}}alone renders[object Object]), so use this twin whenever a template needs the JSON text itself — e.g. an LLM node’spromptTemplatematching a human-given value against the structured data.
There’s no fixed schema for what’s inside _{outputVar}Structured — it’s exactly whatever shape your own MCP server’s response happens to have. A server that returns {"available": true, "rooms": [...]} gives you _{outputVar}Structured.available / .rooms; a server that wraps everything in {"message": "...", "data": {...}} gives you _{outputVar}Structured.message / .data, and so on.
Two legitimate response-shape choices, both fully supported — pick based on your client, not a platform default:
- Plain-text extraction — pull out just the human-readable part for a client that displays the reply as-is (Wetel’s own SDK widget, a plain chat UI):
{{_orderResultStructured.message}}(substituting whatever field your own server’s payload actually names the human-readable text). - Hand-authored structured envelope — if your own frontend parses the reply client-side to drive richer UI (e.g. rendering a room-availability card, not just a sentence), re-emit a JSON envelope on purpose using the
jsonStringhelper from the section above:{"message": {{jsonString _roomResultStructured.message}}, "data": {{_roomResultStructuredJson}} }. Your frontend thenJSON.parse()s the message text it receives back.
Neither is “more correct” — a response node’s messageTemplate is free-form per node, so different workflows (or even different branches of the same workflow) can legitimately choose differently depending on what’s consuming the reply. The failure mode to avoid is the accidental third option: forgetting to extract or re-wrap at all, and letting the raw tool payload reach the user by default.
The triple-brace gotcha
Section titled “The triple-brace gotcha”Handlebars reads three closing braces in a row (}}}) as its own unescaped-output syntax, not as “end of expression, then a JSON closing brace.” If a {{...}} expression is the very last thing before a JSON object’s closing }, add a space:
❌ {"bookingId": {{someHelper arg1 arg2}}} — parse error✅ {"bookingId": {{someHelper arg1 arg2}} } — space before the final braceYou don’t need the space if anything else follows the expression before the closing brace — a trailing comma and another field is enough to avoid the collision. This is a general Handlebars parsing quirk, not specific to any one field — watch for it in any arguments or body template you hand-author.
Matching a human-given value to an internal id: use findId, not an LLM
Section titled “Matching a human-given value to an internal id: use findId, not an LLM”A very specific variant of the extraction pattern below deserves its own callout because the wrong tool for it fails in a way that’s easy to miss in testing: matching something a user typed or said (a room code, a job title, a product name) against an array of records you already fetched, to pull out that record’s real internal id for a downstream tool/action node’s argsTemplate.
Asking an LLM node to do this lookup is unreliable in a specific, dangerous way — not just “sometimes wrong,” but wrong in a way that can look right. A model asked to find the matching entry and output its id tends to confuse a different field’s own digits for the real id: a room code (POD-401) mistaken for roomId: 401, or an ISBN (9780262046305) mistaken for a bookId. Three rounds of increasingly explicit few-shot prompting — a basic instruction, worked examples, an explicit “don’t confuse this with a code/ISBN’s own digits” warning — still produced a wrong id on live tests before this was replaced with a deterministic lookup.
The findId Handlebars helper performs the same lookup with no LLM involved, so there’s no field to confuse:
{{findId <arrayVariable> "<matchField>" <matchValueVariable> "<idField>"}}<arrayVariable>— any context variable holding an array of objects. This works equally well on atoolnode’s_{outputVar}Structuredfield (see Tool results: raw text vs. structured extraction above) or on a plain array-shapedactionnodeoutputVar(e.g. a REST endpoint’s own{"data": [...]}response) —findIddoesn’t care which node type produced the array, only that it’s an array of objects."<matchField>"— the field name to match on, as a literal string (quoted).<matchValueVariable>— a context variable holding the value to match against (unquoted — it’s a variable reference, not a literal)."<idField>"— the field name whose value you want back, as a literal string (quoted). Since 2026-09-11, this can be a dotted path (e.g."current_application.id") to reach a nested field — a missing or non-object intermediate segment resolves to the same0“no match” sentinel rather than throwing, so a shape change on the partner’s side degrades onto the graph’s existing not-found branch instead of failing the run. A numeric-string id (some partner APIs return"21"rather than21) is coerced to a real number either way —findIdalways returns a number or0, never a string.
Returns the matching record’s <idField> value as a number, or 0 if the array variable isn’t actually an array or no element matches — the same “no match” sentinel convention extractFirstInteger uses, so a downstream condition node can check matchedId != 0 the same way either helper’s output would be checked.
How matching works (since 2026-09-10). findId tries three tiers in order and returns a value only when a tier produces exactly one winner; anything ambiguous falls through and ultimately returns 0 rather than guessing:
- Exact — the field equals the value character for character (and only one record does).
- Normalised — both sides lower-cased, whitespace collapsed, punctuation and diacritics stripped, trailing plurals singularised. So an LLM that writes
Solutions Architectstill resolves the posting titledSolution Architect. Abbreviations are not expanded (Sr.≠Senior). - Unique whole-token containment — the normalised value’s tokens all appear in exactly one record (or vice versa), with a minimum length of four characters. Whole tokens only: a stray
ITorN/Anever matches insidearchitect.
Duplicate titles in the array return 0 at every tier — resolve the ambiguity upstream (ask the user to pick) instead of letting the helper choose. Always route the 0 case explicitly before an outbound write: a write fired with an empty or zero id is rejected by the partner API with a far less legible error than your own “which role did you mean?” reply.
Worked example — matching a candidate-named job title back to the real job posting id fetched by an earlier action node (jobPostings, holding {"data": [{"id": 5, "title": "Solutions Architect"}, ...]}), for a Submit Resume write:
{ "id": "submit_resume", "type": "action", "label": "Submit Resume (REST)", "config": { "outputVar": "submitResumeResult", "argsTemplate": "{\"job_posting_id\": {{findId jobPostings.data \"title\" matchedRoleTitle \"id\"}}, \"file_base64\": {{jsonString resumeBase64}} }", "customActionId": 13, "certifiedOperationId": 172 }, "position": { "x": 250, "y": 220 }}matchedRoleTitle here is a context variable an earlier extraction node wrote — see the framing rules below for why that extraction node’s prompt should give the model the real list of titles to choose from, rather than asking it to invent one.
Note the space before the final } in the example above — see The triple-brace gotcha just above; a bare {{findId ...}}} with no space parses as Handlebars’ own triple-mustache syntax instead of “expression, then a literal JSON brace.”
LLM nodes: extraction vs. conversation
Section titled “LLM nodes: extraction vs. conversation”A very common workflow pattern is using an LLM node purely to extract one specific value out of a user’s free-form message — a date, an order number, a search term — into a variable that a later node consumes. This works reliably, but only if you’re explicit in the prompt about what kind of task it is.
Why the framing matters
Section titled “Why the framing matters”Without an explicit instruction otherwise, a smaller or faster model asked to extract one narrow field can drift into answering what the conversation seems to be about, rather than doing its own narrow job. For example, a model asked only to extract a booking date can instead generate a full “Your booking is confirmed!” reply — because the surrounding conversation is thematically about a booking, and the model treats itself as the assistant handling that booking rather than as a pipeline step extracting one field from it. This isn’t a hypothetical edge case; it’s a real, recurring failure pattern with small/fast models, and it gets worse the more “conversational” the surrounding context is.
Best practice: explicitly tell the model it is a pipeline step, not the assistant, and that it must not generate a reply of its own. Apply this to every extraction node from the start, rather than waiting to discover the drift during testing.
Example prompt for an extraction node:
You are a data extraction step in an automated pipeline, not the assistanttalking to the user — you must NOT generate a reply, confirmation,explanation, or any commentary. Output ONLY the requested value, nothingelse. Extract ONLY the appointment date from this message: "{{userMessage}}".Respond with ONLY the date in YYYY-MM-DD format, nothing else.The key elements: state plainly that the model is not talking to the end user, forbid any reply/confirmation/commentary, and restate the exact output format at the end.
If the negative framing alone still drifts, give the model a bounded answer set instead of an open-ended instruction. The negative framing above closes most of this failure mode, but not all of it — the same drift has recurred on a genuinely fresh session with no history to blame, with a smaller/faster model generating a full conversational reply once despite explicit “you are a pipeline step” instructions. What closed it for good: instead of “output ONLY the job title,” give the node the real, current list of valid values inline (e.g. {{jsonString jobPostings}}’s actual titles) and ask it to copy one verbatim from that list. A model is far more reliable picking from a visible, bounded set than generating a bare value from scratch — this is the same principle matchAnyOf applies structurally for a fixed label set; here it’s the same idea applied inside the prompt itself, for a value set that’s only known at run time (this turn’s actual fetched data) rather than fixed at design time.
Keep extraction and conversation in separate nodes
Section titled “Keep extraction and conversation in separate nodes”Don’t combine a narrow extraction task and a reply-generation task in a single “clever” prompt, even when it seems like it should work. Keep them as two nodes — one that extracts, one that responds — even if that means an extra hop in the graph. It’s more reliable and easier to debug when something goes wrong, since you can inspect each node’s output independently.
Other general guidance
Section titled “Other general guidance”- Give your intent classifier the full list of possible intents in one prompt. A downstream
routernode only ever sees what the classifier outputs — if the classifier’s prompt doesn’t mention an intent, the router can never route to it, no matter how well the router itself is configured. - Keep response messages as thin pass-throughs where possible. If a tool or LLM node’s result is already good, readable text, template it directly into the response (
{{toolResult}}) rather than adding an unnecessary LLM paraphrasing step in between — that extra step adds latency, cost, and a chance of the model subtly changing the meaning. - Test extraction prompts against varied real phrasing, not just your first example. One round of prompt hardening is rarely enough on the first try. That said, don’t chase endless rounds of prompt tweaking for a task that keeps failing the same way — if the same category of mistake recurs repeatedly, treat it as a signal to change the workflow’s structure (for example, splitting a task into simpler steps) rather than continuing to patch the prompt.
- Don’t apply voice-style formatting instructions to a node whose output feeds a
conditionorrouternode. Instructions like “speak naturally, in short sentences” conflict directly with “respond with ONLY one word,” and will make the value you need for exact-match routing unreliable. - Before a risky edit to a classification/routing node (a new prompt, a different model), save a real run as a regression scenario first.
captureRegressionScenariofreezes a completed run’snodeTraceas a baseline, thenrunRegressionScenarioreplays it against your current draft and reports a structural pass/fail — useful specifically because a model swap can make anintentnode silently start routing differently with no error anywhere. Pass/fail compares the node-visitation path only, never the literal wording of an LLM response, so it won’t false-fail on ordinary response-text variance.
Next steps
Section titled “Next steps”- See Workflows Overview for the node/edge model and the
conditionnode’s exact'true'/'false'label requirement. - See MCP Connectors for registering the tools your
toolnodes call. - See Events & Subscriptions for observing node execution live while testing a workflow.
- See Troubleshooting if a node’s output isn’t behaving as expected.