Blog

Dynamic variables render when the phone rings

Every call gets its own prompt: the dynamic variables in a published template are resolved against the contact, the campaign and the clock at call start.

A stack of template pages feeds a hand-cranked box labelled RENDER with four inputs — contact, call, account, system — and out come three cards, one per person, with the blanks filled in coral.
Bekir İşgörCo-founder
9 min read

Share

Updated 14 September 2026: the single-resolver design, the August incident behind it, and figures.

A Talkif agent's prompt template stays a template until a call starts. When you publish a flow, its dynamic variables are not filled in. When a call begins — outbound from a campaign, inbound on one of your numbers, started from the API — the backend takes that call's contact, its campaign snapshot, your account and the clock, renders every {{…}} in the flow against them, and hands the bot a copy that belongs to that call alone. Two calls on the same flow never open the same way, and nothing personal is ever written into the copy the calls share.

That sentence has three decisions in it: when to render, where to render, and what happens when the data is bad. Each one was shaped by something that went wrong first.

Why not at publish time

The tempting design is to resolve templates when you press publish: it is simpler, and the result can be cached forever. It is also wrong for a phone call, because the data does not exist yet. The contact is chosen when the campaign dials, or identified from the caller's number when the phone rings. The date is today's. The direction is whichever way this call is going. A prompt that says Hi {{contact.firstName}}, calling about the two-bedroom you asked about on {{formatDate system.date "long"}} can only be finished once there is a first name and a today.

So the published flow is cached once per version and shared by every call that runs on it, braces and all. At call start the backend renders the agent prompts against a context with four namespaces — contact, call, account, system — and writes the result to a per-call cache entry with a one-hour life. The bot reads that entry, not the shared one. On a campaign, the contact data comes from the snapshot the campaign was built from rather than the live contact row, deliberately: the campaign was reviewed against that snapshot, and a field edited an hour later should not change what the agent says mid-campaign.

A sequence schematic with four lanes: you, backend, cache, bot. Above a dashed line: you to backend publish flow; backend to cache shared copy, marked one shared copy per version, never holds a contact. Below it: you to backend call starts; a backend self-loop build context: contact, call, account; a coral backend self-loop render every template; backend to cache per-call copy, marked one rendered copy per call; backend to bot assign call; bot reads the per-call copy from the cache.
Publish writes one shared, unrendered copy per version. Every call renders its own copy from its own context, and the bot only ever reads that one.

The bot itself does no templating. Prompts arrive as plain strings; the voice pipeline turns the system prompt into the model's standing instruction and the agent prompt into its task, and reads them as they are. That is on purpose, and it matches the advice Kwindla Hultman Kramer, Daily's CEO, gives builders of voice agents: "Make things as easy as possible for the LLM. Define as few tools as possible. Write detailed, multi-shot prompts. Don't inject extra indeterminacy if you can avoid it" (Advice on building voice AI in June 2025, 24 June 2025). A model asked to fill in blanks from a context blob is being handed indeterminacy; a model handed the finished sentence is not.

Exactly one place, enforced by a test

Rendering now happens in exactly one function, on the path every call takes just before its flow is cached. It did not start that way. Until August, resolution lived in the individual call paths, and four of the eight had it. Calls that came in through the queue or over a SIP trunk handed the bot the raw template, and the language model did what language models do with text it is given: it read "hi curly-brace contact dot name" aloud to the caller. Worse, one path rendered the template in place and wrote the result to the cache entry every call on that version shares — so a lead's name could reach the next caller who fell back to it.

The fix moved rendering to the one choke point, and added a test that walks the source tree, counts the call sites of the resolver, and fails the build if there is more than one. The comment on it says the type system cannot express "do not resolve here", so the invariant is asserted directly. It is a small thing. It is also what keeps the leak from coming back.

A block schematic. Eight blocks stacked on the left — campaign, inbound, api, queue, sip, test, schedule, lead — each with an arrow into one coral block labelled render, which has one arrow out to a block labelled per-call copy. Below render, a block labelled test points up at it. Upper right, inside a dashed boundary with no arrow into it, a block labelled shared copy.
Every way a call can start passes through the one resolver on its way to the per-call copy. The test block is the source-scanning test that counts the render sites and fails on more than one. Nothing rendered ever flows to the shared copy.

Missing variables, and helpers built for CRM data

Handlebars was made for HTML. Its escaping is on by default, its strict mode raises on a missing field, and its philosophy — in Yehuda Katz's words when he announced it in September 2010, "the base Mustache syntax was too limited for a lot of things I wanted to use it for" — is to add just enough helpers to a logic-less template. Talkif keeps the philosophy and changes the defaults, because the output is speech and the input is whatever your CRM exported.

Escaping is off: an ampersand in a company name should be spoken, not turned into &. Strict mode is off: a missing field renders as empty text, never as an error and never as the literal braces. And "empty" is defined by what real exports contain:

fn is_empty_value(s: &str) -> bool {
    let trimmed = s.trim().to_lowercase();
    trimmed.is_empty() || trimmed == "none" || trimmed == "null" || trimmed == "undefined"
}

That function sits under default, ifExists and the truthiness of #if. It exists because CRM exports carry the strings "none" and "null" in fields that are empty, and a prompt that says "I see you're with null" is worse than no prompt. So {{default contact.company "your company"}} does the right thing on the data you actually have, not the data the schema promised.

A bin of contact cards with scribbled, crossed-out and tangled fields is poured through a funnel with a gauge; a neat stack of cards comes out, with one field on the top card written in coral.
Whatever the export contains — blanks, the word null, a crossed-out field — the helpers treat it as empty and say the fallback instead. Nothing is repaired in the record; only what gets spoken is chosen.

The helpers follow the same rule — a bad input degrades, it does not fail. formatDate writes an unparseable date through as-is. match and replace treat an invalid regex as no match and no change. gt on something that is not a number is false. The full set is default, ifExists, join, formatDate, json, the comparisons eq ne gt lt gte lte, the logic and or not, the regex pair match replace, and includes for "is this tag on the contact":

{{#if (includes contact.tags "vip")}}
Thank them for being a long-standing customer before anything else.
{{/if}}
Interests on file: {{default (join contact.interests ", ") "none noted"}}.

Failure is isolated to the node, too. A flow is a set of agent nodes, each with its own prompts; if one node's template does not parse — a stray {{ in an otherwise static prompt is the usual cause — that node keeps its raw text and every other node still resolves. Before July it did not work that way: one bad node aborted the loop, and because the nodes live in a hash map, which of the others had been resolved by then was different on every call. Nondeterministic partial personalisation is a bug you only find by listening to calls.

Four agent cards in a row joined by arrows. The first, second and fourth show clean filled lines; the third has a torn corner and an unclosed curly brace scrawled beside its text, and is bracketed in coral. The arrow still passes through it to the fourth card.
A node whose template fails to parse keeps its raw text; the fault stays inside the coral brackets and every other node renders as normal.

Why Handlebars and not Jinja

The template language is a cross-stack contract, not a backend choice. The same dialect is rendered by the Rust backend, parsed in the browser editor — which runs the real Handlebars parser in a worker to flag syntax errors and warn on variables the API does not know — and written by the AI prompt generator when it drafts a flow. One grammar in three places is worth more than any one place's preferences.

It is also a logic-limited language on purpose, and we are not alone in that: Microsoft's Semantic Kernel uses Handlebars for its prompt templates, and its worked example personalises a system prompt with a customer's name and membership level — the same shape as a Talkif agent prompt (Semantic Kernel docs, updated 26 May 2026).

The strongest argument for the other side comes from Hugging Face, whose chat templates use Jinja because, as their guide puts it, "Jinja is a templating language that allows you to write Python-like code and syntax". That power is exactly what we did not want a prompt to have: a template that can compute is a template that can surprise you on a live call. Their own guide shows the cost of the extra rope — an entire section on trimming, because extra whitespace "that was not present during model training can harm performance" (Writing a chat template). A Talkif template can look things up, compare them and choose between blocks. It cannot loop over arbitrary data, call functions or execute code, and the only values it can reach are the ones in the four namespaces.

What it does not do yet

system.time is always UTC. The contact's timezone is available as contact.timezone and system.timezone is populated, but the clock is not converted; a prompt that wants "good morning" has to compare against the contact's zone itself, and today it cannot. This is a known gap, not a design choice.

Templates and regexes are parsed fresh on every call. A new engine is built per call and each helper compiles its pattern on use. Against the cost of a phone call the overhead is not measurable, which is why nobody has cached it — but it is unmeasured, not zero.

Nothing re-renders during a call. Results from a function node go back to the model as tool results, not as new prompt text, and a transition to the next agent node uses prompts that were resolved at call start. If the conversation learns the caller's name in the first sentence, the prompt for the next node does not know it; the model does, which in practice is what matters.

The variables and helpers are listed in full in the prompt writing guide. In the editor, {{ opens the list, and Preview with contact data renders a saved prompt against a real contact exactly as a call would — the same function, run once, on the server.

Read next
  1. Developers

    Turn detection that doesn't talk over you

    How our voice agent's turn detection decides a caller has finished, when an interruption is real and when to stay quiet, and the incidents behind it.

Questions about this piece? Write to us.