Recipe: Read Channel Facts Without an LLM Call
This recipe is for a workflow on a messaging platform whose bridge (an omnichannel inbox, for example) tells Wetel who the customer is and where the conversation lives. Those facts arrive as [[namespace:key=value]] markers at the top of the message, and Wetel has already parsed them into the run’s context by the time your first node runs. Reading them back out with an llm node costs a model call per value, and none of those calls is needed.
The worked example is a real ticket-intake workflow: a customer asks for a support ticket, the workflow collects the ticket fields, creates the ticket through an action node and confirms. The token counts and timings below were read from one real run’s trace; the names, email and phone in the examples are made up.
Which workflow does this apply to?
Section titled “Which workflow does this apply to?”channel.raw is built from the markers on a message that arrives from outside, so it exists only in a workflow run that an incoming message started. That decides whether this recipe applies:
| Where your node runs | Is channel.raw there? | What to do |
|---|---|---|
| The agent’s own workflow, handling a message your bridge sent | Yes, from the first node onward | Use channel.raw directly. This whole recipe applies. |
A sub-agent’s workflow, started by a sub_agent node in another workflow | No. There is no channel and no attachment | Pass the values in the target’s contextTemplate as labelled text and read {{userMessage}} there. See Using master variables inside the sub-agent. |
The worked example below has no sub-agent, so it is the first row.
The problem: one model call per value
Section titled “The problem: one model call per value”The first version of the workflow used one llm node for every value it needed:
Node (outputVar) | What it extracted | Tokens |
|---|---|---|
supportRoute | the intent (CREATE) | 6,346 |
supportCompanyId | the company id (24) | 6,132 |
conversationId | the conversation id (439) | 6,136 |
workspaceId | the workspace id (28) | 6,133 |
workspaceName | the workspace name | 6,139 |
contactName | the customer’s name | 6,131 |
contactEmail | the customer’s email | 6,137 |
contactPhone | the customer’s phone | 6,133 |
ticketSubject | a subject written from the chat | 6,190 |
ticketDescription | a description written from the chat | 6,263 |
ticketCategory | technical | 6,209 |
ticketPriority | high | 6,222 |
replyText | the confirmation message | 3,674 |
That is about 78,000 tokens and 17 seconds of sequential model calls for one ticket. Seven of the thirteen calls (the company id through the phone) cost about 43,000 tokens, roughly 55% of the run and about 8 seconds, and every one of them returned a value that was already sitting in the context.
Where the values already are
Section titled “Where the values already are”When a message arrives from outside, Wetel parses the leading block of marker-only lines. A marker such as [[support:name=Alex Tan]] becomes the entry channel.raw["support.name"]. The key is the namespace, a dot, then the key. For this workflow the bridge sends markers like these (the namespace and keys are whatever your bridge chooses; these are illustrative):
[[support:name=Alex Tan]][[support:[email protected]]][[support:phone=+60123456789]][[support:company=24]][[support:workspace=28]][[support:workspaceName=Acme Support - DEMO]][[support:conversation=439]]the customer's actual message starts hereand the run’s context holds:
{ "channel": { "raw": { "support.name": "Alex Tan", "support.phone": "+60123456789", "support.company": "24", "support.workspace": "28", "support.workspaceName": "Acme Support - DEMO", "support.conversation": "439" } }}The full rules (the marker block must come first, how attachments work, the typed fields) are in Channels.
Step 1: Use the values without an LLM call
Section titled “Step 1: Use the values without an LLM call”The saving comes from deleting the extraction llm nodes, not from set_variable. The values are already in the context, so any later node can read them directly. For example, the action node’s argsTemplate can contain "organizationId": "{{channel.raw.[support.company]}}" and the confirmation prompt can say {{channel.raw.[support.name]}}, with no node in between.
Adding a set_variable node is optional. It copies the values into variables with shorter names and makes no model call, so it costs no tokens and no credits. It is worth adding in these cases:
| Reason | Why |
|---|---|
| Shorter names | {{contactName}} is easier to read, and easier to rename in one place, than {{channel.raw.[support.name]}} repeated through the graph. |
| A number or a default | channel.raw values are text. coerce: "number" stores a real number; an expression assignment can supply a default. See Set Variable. |
| A node that needs a flat name | Nodes that take a bare variable name (for example fetchAsBase64Var) cannot reach into channel.raw. Copy the value into a flat variable first. |
| The old names are in use | If the replaced llm nodes used outputVar names like contactName, defining the same names keeps every later node working without an edit. |
Skip it if none of those apply. It does not make the context meaningfully bigger: seven short strings add a few hundred bytes, which is negligible next to the output of the model calls you removed.
The node below is the version with the old variable names kept:
{ "id": "n_ticket_facts", "type": "set_variable", "label": "Read channel facts", "config": { "assignments": [ { "variable": "supportCompanyId", "kind": "template", "source": "{{channel.raw.[support.company]}}" }, { "variable": "conversationId", "kind": "template", "source": "{{channel.raw.[support.conversation]}}" }, { "variable": "workspaceId", "kind": "template", "source": "{{channel.raw.[support.workspace]}}" }, { "variable": "workspaceName", "kind": "template", "source": "{{channel.raw.[support.workspaceName]}}" }, { "variable": "contactName", "kind": "template", "source": "{{channel.raw.[support.name]}}" }, { "variable": "contactEmail", "kind": "template", "source": "{{channel.raw.[support.email]}}" }, { "variable": "contactPhone", "kind": "template", "source": "{{channel.raw.[support.phone]}}" } ] }, "position": { "x": 300, "y": 120 }}The variable names are the ones the old llm nodes used as outputVar, so every later node (the action node’s argsTemplate, the confirmation prompt) keeps working without an edit. A template result is always text, which is also what the llm nodes produced ("24", "439"). If you skip this node, change those later nodes to read channel.raw directly.
The key contains a dot: use brackets
Section titled “The key contains a dot: use brackets”{{channel.raw.support.name}} does not work. Handlebars reads a dot as a path separator, so it looks for raw.support, finds nothing and renders an empty string. The keys are literal strings that contain a dot, so wrap each one in square brackets: {{channel.raw.[support.name]}}.
Use the typed field where there is one
Section titled “Use the typed field where there is one”For the Nortia markers Wetel also fills typed fields, so prefer them over the raw text: {{channel.nortiaCompanyId}} is a number (or null when the marker is missing or not a positive integer) and {{channel.nortiaEnabled}} is a boolean (or null when the marker is absent). Everything else is read from channel.raw, where every value is a string.
Step 2: Guard against a missing marker
Section titled “Step 2: Guard against a missing marker”If a marker is missing, its template renders an empty string. The run does not fail, so an empty organizationId would reach the ticket API. Check the one value the write cannot do without before the action node, with a condition node:
{ "id": "n_has_company", "type": "condition", "label": "Company id present?", "config": { "expression": "supportCompanyId != \"\"" }, "position": { "x": 500, "y": 120 }}Draw the true edge to the action node and the false edge to a response node that tells the customer the ticket could not be raised and a person will follow up. For a number you will compute with, add a coerce: "number" assignment instead (see Converting types), which refuses empty text rather than storing it.
Step 3: Make the remaining model calls cheaper
Section titled “Step 3: Make the remaining model calls cheaper”Some values genuinely need a model: the intent, and the subject and description, which are written from the conversation. Three levers shrink them. Measure each on one of your own runs, because the saving depends on how long your conversations and prompts are.
excludeHistory: trueon narrow nodes. By default everyllmnode’s model call receives the session’s full conversation history, even when itspromptTemplatenever mentions it. ForticketCategoryandticketPriority, which can be decided from{{ticketSubject}}and{{ticketDescription}}alone, setexcludeHistory: trueso the call carries only the short prompt. Never set it on a node that needs the conversation. See theexcludeHistorygotcha.matchAnyOffor a fixed set of labels. OnticketCategoryandticketPrioritylist the valid labels (["technical", "billing", "general"],["low", "medium", "high"]), so a model that answers in a sentence still stores a clean label. See thematchAnyOfgotcha.- A smaller model per node.
modelOverridenames the model for one node; the provider follows it. A narrow classification does not need your largest model. See themodelOverridegotcha.
{ "id": "n_category", "type": "llm", "label": "Ticket category", "config": { "promptTemplate": "Pick the best category for this ticket.\nSubject: {{ticketSubject}}\nDescription: {{ticketDescription}}\nAnswer with one word: technical, billing or general.", "outputVar": "ticketCategory", "matchAnyOf": ["technical", "billing", "general"], "excludeHistory": true }, "position": { "x": 700, "y": 120 }}The graph, before and after
Section titled “The graph, before and after”flowchart LR subgraph Before direction LR A1[start] --> A2[route llm] --> A3[7 extraction llm nodes] --> A4[subject llm] --> A5[description llm] --> A6[category llm] --> A7[priority llm] --> A8[create action] end subgraph After direction LR B1[start] --> B2[route llm] --> B3[set_variable: 7 values, no model call] --> B4[condition: company present] --> B5[subject llm] --> B6[description llm] --> B7[category llm, no history] --> B8[priority llm, no history] --> B9[create action] endVerify it on a real run
Section titled “Verify it on a real run”Send one test message with the markers, then read the run’s trace (workflowRuns { nodeTrace }). Check three things:
- The
set_variablerow lists the seven variables underassigned, with their values inoutputValue, and shows notokensUsed. - None of the seven values is empty. An empty one means that marker is missing from the message, or its key is spelled differently, or the brackets are missing from the expression.
- Add up
tokensUsedacross thellmrows and compare it with the table above.
Gotchas
Section titled “Gotchas”- The markers must be at the very top of the message, as marker-only lines. A marker after the first line of real text, or after a blank line that follows the block, is ignored. This is deliberate: it stops an end user from typing a fake marker mid-sentence.
channelis not available in a sub-agent. It exists only for the message that came in from outside. Asub_agentrun is started by a node, so it has nochannelat all. Pass what it needs in the target’scontextTemplateas labelled text (Support name: {{channel.raw.[support.name]}}) and read{{userMessage}}in the sub-agent. This recipe does not change that: it applies to the workflow that receives the message. To use a channel fact inside a sub-agent without an extraction node, pass it withinputs(for example"accountId": "{{channel.raw.[support.account_id]}}").set_variablecannot split values out of text. It copies, defaults, converts and computes from variables that already exist. It is the right tool here because the markers are already parsed into variables. If the value you need only exists inside free text (for example, a sentence the customer typed), that is a job for anllmnode.channel.rawvalues are unvalidated. Use them in prose and in quoted JSON values. Never route one into a URL to fetch, or into an unquoted JSON position. Validate anything that acts on a value (a number, an id) before using it.- Every value is a string.
"24"is not24. Compare strings with strings, or coerce to a number first. - New markers need no Wetel change. If the bridge starts sending
[[support:plan=pro]], read it as{{channel.raw.[support.plan]}}.
Next steps
Section titled “Next steps”- Channels for the full marker rules and typed fields.
- Set Variable Node for expressions, defaults and
coerce. - LLM Node for
excludeHistory,matchAnyOfandmodelOverride. - Triage a Support Ticket End to End for a complete ticket graph built from these nodes.