Skip to content

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.

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 runsIs channel.raw there?What to do
The agent’s own workflow, handling a message your bridge sentYes, from the first node onwardUse channel.raw directly. This whole recipe applies.
A sub-agent’s workflow, started by a sub_agent node in another workflowNo. There is no channel and no attachmentPass 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 first version of the workflow used one llm node for every value it needed:

Node (outputVar)What it extractedTokens
supportRoutethe intent (CREATE)6,346
supportCompanyIdthe company id (24)6,132
conversationIdthe conversation id (439)6,136
workspaceIdthe workspace id (28)6,133
workspaceNamethe workspace name6,139
contactNamethe customer’s name6,131
contactEmailthe customer’s email6,137
contactPhonethe customer’s phone6,133
ticketSubjecta subject written from the chat6,190
ticketDescriptiona description written from the chat6,263
ticketCategorytechnical6,209
ticketPriorityhigh6,222
replyTextthe confirmation message3,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.

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 here

and the run’s context holds:

{
"channel": {
"raw": {
"support.name": "Alex Tan",
"support.email": "[email protected]",
"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:

ReasonWhy
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 defaultchannel.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 nameNodes 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 useIf 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.

{{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]}}.

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.

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: true on narrow nodes. By default every llm node’s model call receives the session’s full conversation history, even when its promptTemplate never mentions it. For ticketCategory and ticketPriority, which can be decided from {{ticketSubject}} and {{ticketDescription}} alone, set excludeHistory: true so the call carries only the short prompt. Never set it on a node that needs the conversation. See the excludeHistory gotcha.
  • matchAnyOf for a fixed set of labels. On ticketCategory and ticketPriority list the valid labels (["technical", "billing", "general"], ["low", "medium", "high"]), so a model that answers in a sentence still stores a clean label. See the matchAnyOf gotcha.
  • A smaller model per node. modelOverride names the model for one node; the provider follows it. A narrow classification does not need your largest model. See the modelOverride gotcha.
{
"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 }
}
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]
end

Send one test message with the markers, then read the run’s trace (workflowRuns { nodeTrace }). Check three things:

  1. The set_variable row lists the seven variables under assigned, with their values in outputValue, and shows no tokensUsed.
  2. 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.
  3. Add up tokensUsed across the llm rows and compare it with the table above.
  • 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.
  • channel is not available in a sub-agent. It exists only for the message that came in from outside. A sub_agent run is started by a node, so it has no channel at all. Pass what it needs in the target’s contextTemplate as 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 with inputs (for example "accountId": "{{channel.raw.[support.account_id]}}").
  • set_variable cannot 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 an llm node.
  • channel.raw values 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 not 24. 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]}}.