Skip to content

Set Variable Node

The set_variable node writes one or more variables into the run’s execution context without calling a language model. Use it to copy or rename a value, give a variable a default, turn text into a real number or boolean, compute a value from others, or build a small JSON object from scalar values. It uses no tokens and no credits.

It replaces a common workaround: an llm node whose only job was to copy a value into its own variable, which cost a model call, added latency and could still answer with prose instead of the value.

See Workflow Overview for the node/edge envelope this fits into.

FieldTypeRequiredDescription
assignmentsarrayYes1 to 20 assignments, applied in order. Each one writes one context variable (fields below).

Each entry in assignments:

FieldTypeRequiredDescription
variablestringYesThe context variable to write. Letters, digits and underscores, starting with a letter, at most 64 characters. Must be unique within the node. Reserved names are refused (below).
kind"expression" | "template" | "literal"YesHow source is turned into a value (see The three kinds).
sourcestring | number | boolean | nullYesThe expression (at most 500 characters), the template (at most 2,000 characters), or the literal value.
coerce"string" | "number" | "boolean" | "json"NoConverts the result before it is stored (see Converting types).
defaultstring | number | boolean | nullNoexpression only. The value stored when the expression references a variable this run does not hold. Without it, that case fails the node (and the run, unless the node has a failure edge).

Reserved names: variable cannot be one of the engine’s own context keys (sessionId, tenantId, agentId, interruptGeneration, clientExternalId, clientTurnId, userMessage, conversationHistory, turnCount, agentTimezone, apiKeyAllowedLlmModels, apiKeyId, session, attachment, channel, attachmentUrl, attachmentMime, attachmentFilename, attachments, attachmentImageUrls, turn), and cannot start with _.

Every rule above is checked when you save or publish: an unknown field value, a duplicate or reserved name, an expression or template with a syntax error, or a source over its size limit is refused with a BAD_USER_INPUT error naming the node and the field (for example assignments[1].source is not a valid expression: ...).

The node has plain outgoing edges: no labels, and every edge is followed, like an llm or response node.

Evaluated with the same sandboxed expression language as the condition node (==, !=, and, or, not, arithmetic, comparisons). The result must be a number, a string or a boolean.

{ "variable": "total", "kind": "expression", "source": "price * qty" }

A missing variable fails the run, unlike a condition. A condition that references a variable this run does not hold evaluates false, because a gate has a safe branch to fall back to. An assignment has no such branch, so it fails the node with an error naming the node, the assignment and the missing name (which fails the run unless the node has a failure edge), unless you give a default:

{
"variable": "attempts",
"kind": "expression",
"source": "previousAttempts + 1",
"default": 1
}

A variable that exists with the value null counts as present: the default is not used, and an expression whose result is null is refused.

Note that in this expression language || joins two strings ("a" || "b" is "ab"); use or for logic.

The expression itself cannot assign anything: the assignment is the node’s variable field. An = inside source (such as x = 5) or a function definition is refused when you save; see No assignment and no function definitions.

A Handlebars template, rendered against the context exactly like a response node’s messageTemplate. The result is text.

{
"variable": "greeting",
"kind": "template",
"source": "Hi {{customerName}}, your order is {{orderStatus}}."
}

Templates are rendered without any escaping: an apostrophe stays an apostrophe and <b> stays <b>. That is right for text a person reads, and wrong for text you later put inside JSON. If the value is going to be parsed as JSON, use coerce: "json" (below), not a plain template.

source is stored as it is: a string, a number, a boolean or null.

{ "variable": "currency", "kind": "literal", "source": "MYR" }
coerceAcceptsStoresFails on
numbera number, numeric text (" 42 "), true/false (1/0)a real numbernon-numeric text, empty text, null, Infinity
booleantrue/false, 1/0, and the same as text ("TRUE", "0")a real booleananything else ("yes")
stringanything scalartext (null becomes "")nothing
jsontemplate or literal onlythe parsed object, array or valuetext that is not valid JSON

When default is used, coerce applies to it too.

A condition compares without converting types, so the text "0" is not the number 0, and a "0" “no match” marker walks straight through a count != 0 gate. Store the value with coerce: "number" and the gate behaves:

{
"variable": "count",
"kind": "template",
"source": "{{countText}}",
"coerce": "number"
}

Then count > 0 in a later condition compares a real number. Without coerce, a template result is always text.

With coerce: "json", a template is rendered with JSON-safe escaping (quotes, newlines and backslashes inside variable values are escaped) and the result is parsed. Write the JSON structure yourself, around scalar variables:

{
"variable": "payload",
"kind": "template",
"source": "{\"name\": \"{{customerName}}\", \"qty\": {{qty}} }",
"coerce": "json"
}

A customer name like Ann "the" Lee (with a line break) still produces valid JSON, because the value is escaped before it lands between your quotes.

Two traps:

  • It cannot re-parse a variable that already holds JSON text. "source": "{{analysis}}" where analysis is the text {"severity":"high"} fails: the quotes inside that value are escaped like any other value, so the result is not JSON. An llm node stores its answer as text; to route on one field of it, have the llm node write that field to its own variable (for example with matchAnyOf).
  • Put a space before a closing brace that follows a variable. {{qty}}} is Handlebars’ triple-brace syntax and does not parse; {{qty}} } does. Saving refuses the broken form.

A parse failure fails the node with an error naming the node and the variable (and the run, unless the node has a failure edge). Error messages never include the value itself.

  • Assignments run in order, and each one sees the variables written by the ones before it in the same node.
  • A single value larger than 64 KB (as JSON) is refused.
  • The node’s row in the run trace (workflowRuns { nodeTrace }) carries assigned (the variable names, in order) and an outputValue map of { variable: value }. Like every trace value, credential-shaped strings (tokens in URLs and similar) are redacted on the trace; the run context keeps the real value.
  • A failed assignment fails the run like any other node error: the trace row is FAILED with the error, a NodeFailedEvent is published, and the user gets the standard failure reply. To handle it instead, give the node success and failure edges (below).
  • A failed node writes none of its assignments, including the ones before the one that failed.

Added 2026-10-15. A set_variable node supports the same opt-in routing as a tool node: draw one outgoing edge labeled success and one labeled failure. A failed assignment (a referenced variable that is not set and has no default, a failed coerce, a template or coerce: "json" value that does not render or parse) then takes the failure edge instead of failing the run. The trace row is FAILED_HANDLED, the run completes, and the error message is in the _lastNodeError context variable.

This lets one node both normalise a value and send a missing or malformed one to a branch that asks the user again. A failure edge without a success edge is refused at publish, and a node with neither label behaves exactly as before. See Failure routing for the full rules.

Worked example: replace an llm node that only copies a value

Section titled “Worked example: replace an llm node that only copies a value”

Before: a classifier, then a second llm node whose prompt was “respond with only the severity”, just to get the value into the variable the router reads. That second node costs a model call and can answer with a sentence.

After: the classifier writes its label (with matchAnyOf, so the stored value is exactly one of the labels), a set_variable node copies it and sets a numeric retry counter, and the router reads the copy.

{
"nodes": [
{
"id": "start",
"type": "start",
"label": "Start",
"config": {},
"position": { "x": 0, "y": 0 }
},
{
"id": "classify",
"type": "llm",
"label": "Classify severity",
"config": {
"promptTemplate": "Classify the urgency of this message as high or low. Respond with one word: \"{{userMessage}}\"",
"outputVar": "classification",
"matchAnyOf": ["high", "low"],
"streamOutput": false
},
"position": { "x": 0, "y": 100 }
},
{
"id": "normalise",
"type": "set_variable",
"label": "Normalise",
"config": {
"assignments": [
{
"variable": "severity",
"kind": "template",
"source": "{{classification}}"
},
{
"variable": "retries",
"kind": "expression",
"source": "retries + 1",
"default": 0,
"coerce": "number"
}
]
},
"position": { "x": 0, "y": 200 }
},
{
"id": "route",
"type": "router",
"label": "Route on severity",
"config": { "variable": "severity", "defaultTarget": "low" },
"position": { "x": 0, "y": 300 }
},
{
"id": "high",
"type": "response",
"label": "Escalate",
"config": { "messageTemplate": "Connecting you to an agent now." },
"position": { "x": -150, "y": 400 }
},
{
"id": "low",
"type": "response",
"label": "Self-serve",
"config": { "messageTemplate": "Here is what you can try first." },
"position": { "x": 150, "y": 400 }
}
],
"edges": [
{ "id": "e1", "source": "start", "target": "classify" },
{ "id": "e2", "source": "classify", "target": "normalise" },
{ "id": "e3", "source": "normalise", "target": "route" },
{ "id": "e4", "source": "route", "target": "high", "label": "high" },
{ "id": "e5", "source": "route", "target": "low", "label": "low" }
]
}

retries starts at 0 on the first turn (the default, since nothing has set it yet), and is a real number, so a later condition like retries >= 3 works without surprises. Context variables are not carried from one turn to the next, so this counter is per turn unless the graph pauses with an await_reply node, which keeps the context across the pause.