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.

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.

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.

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.

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.

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.
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.
Integrations
Meta lead ads, called on arrival and reported back
Connect Meta lead ads to Talkif: each lead is called within your calling hours, and received, contacted, qualified and converted go back to Meta.
Developers
Webhook SSRF, closed by a sender that holds nothing
Webhook SSRF closed twice: the process with your secrets never sends, and the process that sends can reach only the public internet.
Developers
Voice agent function calling with bound parameters
Voice agent function calling on Talkif: describe the endpoint once, then bind each parameter to the model, the call or a fixed value no caller can spoof.
Questions about this piece? Write to us.



