The two data stores a flow works with — the dictionary and the session — and the ${…} placeholder model that reads them into a node's fields.
Almost everything a flow 'remembers' while it runs lives in one of two places.
A single run keeps its working data in two stores. Get these two straight and most flow surprises disappear: the dictionary is the per-flow scratchpad, and the session is the run-wide shared store. Both are working memory for one run — they are not a database, and they are released when the conversation ends.
${dictionary.…}${dictionary.user.name}.${session.…}${session.userEmail} is empty unless some node wrote a userEmail key first.${…} store: you never reach it with a placeholder. A Script node can read and drive it through the system/agent resource — see the Script Nodes page.Wherever a field is interpolated, the same rules apply — and they are strict.
Many node fields are interpolated: before the node runs, the platform scans the field for ${…} markers and substitutes each one with the value it resolves to. The rules are the same in every interpolated field, and they are deliberately narrow.
${dictionary.…} and ${session.…}. There is no ${agent.…}, ${input.…}, ${env.…}, ${settings.…}, or any other root — any other root resolves to empty.${dictionary.a.b.c} walks a path through plain objects. ${session.a.b} looks up the single key named a.b — it does not nest.${dictionary.list[0]} and ${dictionary.list.0} both resolve to nothing, and a bare ${dictionary} (no dot) is not recognized either.${dictionary.x} is not there, it simply vanishes from the result.The type you get back depends on how the field is written. A field that is exactly one placeholder keeps the resolved value's type; a field that mixes text and a placeholder is always a string; and a plain value with no placeholder is stored as text — there is no automatic number or boolean conversion.
how a value resolves
${dictionary.count} → 300 (one whole-string placeholder keeps its type — a number) "Total is ${dictionary.count}" → "Total is 300" (text mixed with a placeholder is always a string) "1" → "1" (a plain value is stored as text — never the number 1) ${Array([red, green, blue])} → ["red","green","blue"] (the Array(...) form builds a real array) ${dictionary.missing} → (empty) (a value that isn't there resolves to nothing)
1 into a value field stores the string "1", not the number. To keep a real number, point at a placeholder that already holds one — ${dictionary.count} — or convert it inside a Script node.The exact fields that are interpolated — and the ones that are always literal.
Interpolation is not everywhere. This is the map of which fields read ${…} and which take a literal value exactly as typed.
${dictionary.*} and ${session.*} before the model sees it — e.g. a System turn that reads Answer for ${dictionary.user.name}.. The turn's role is literal.${dictionary.*} / ${session.*}. Leave them blank in the normal case — the agent then uses the run's natural ids. Set one only to deliberately point the turn at a specific chat slot; do not force it to an empty value.${…}). A Script node's pairs arrive to the script as its inputs; a Script node's file name is a literal.${…} — a bare dictionary.x throws. See the next section.dictionary. or session. prefix — a bare name fails the node. See "References vs Placeholders" below.dictionary. and point at an array — not a session key, not a bare name.$ { } .), and an End node's terminate option is a plain on/off — neither is interpolated.The single most common mistake in a Condition expression.
A Condition node reads one boolean expression and routes down its True or False branch. The expression is not free-form code: each data reference must be wrapped in ${…} so it is substituted with a value before the comparison is evaluated.
condition — wrapped vs bare
${dictionary.score} > 25 → runs: (30 > 25) → True branch ✅ ${dictionary.answer} === true → runs: (true === true) → True branch ✅ dictionary.score > 25 → nothing is substituted; evaluation fails ❌ error: "Member access is not allowed"
dictionary.score > 25 without the wrapper leaves the reference unsubstituted, and the evaluator rejects it with Member access is not allowed. The runnable form is always ${dictionary.score} > 25. Keep expressions simple — comparisons and logic over values and placeholders; there are no function calls, no member access, and no array literals.A few fields name a store slot instead of interpolating a value — and they need a prefix.
Most fields interpolate a value. A handful instead take a reference — the name of a store slot to read from or write into. A reference must carry a dictionary. or session. prefix (an optional ${…} wrapper is allowed), and a bare name fails.
loop & parallel context — references need a prefix
Loop · Read from : dictionary.items ✅ (the array to walk) Loop · Set to : dictionary.current ✅ (where each element lands — Read from ≠ Set to) Loop · Read from : items ❌ error: expected dictionary.<path> or session.<key> Parallel Context · Context Collection : dictionary.orders ✅ (must be a dictionary array) Parallel Context · Context Collection : session.orders ❌ (session is not allowed here)
A Loop delivers the current element into its Set to slot on every pass, so the loop body reads it back with a normal placeholder — e.g. ${dictionary.current}. A Parallel Context runs its template flow once per element of the collection, each on its own copy of that element, and merges each result back into the collection when it finishes.
${dictionary.x} > 25 against a value you typed as 1 compares a string — set numbers via a placeholder that already carries the number.a.b creates a literal property named a.b, not a nested a → b. Use simple top-level names.Keen Agents 2026
Documentation
Release 15