Blog

What the flow builder draws is compiled twice

The flow builder canvas is compiled twice: publish resolves, validates and flattens it into nodes and tools; call start binds the caller.

A hand-drawn schematic in two halves. Left: a builder canvas with boxes for agents and a voice, a transcriber and a model, connected by lines. An arrow labelled publish leads right, where the same boxes are stacked as a flat list of nodes; each connecting line has become a small labelled tool attached to its node, and one of those tools is drawn in coral.
Bekir İşgörCo-founder
9 min read

Share

Updated 14 September 2026 with production numbers, figures, and what the compiler rejects today.

The flow you draw in the flow builder is not the flow the bot runs. There are three shapes of it. The canvas is boxes and lines with positions. The saved flow has the boxes but no lines — a handoff from one agent to another is stored as data on the agent, and the lines are redrawn from that data when you open it. And the thing the bot loads is flatter still: a list of nodes where every handoff has become a tool the language model can call, every function reference has been looked up and expanded, and anything the model should not be allowed to see has been removed. The first translation happens when you publish; the second happens when a call starts, with that call's contact in hand. Between 14 August and 12 September 2026, production did the first 59 times and the second 518 times.

The lines on the canvas are not edges

On the canvas, an agent connects to a voice, a transcriber and a model, and to an end-call node, and sometimes to another agent. None of those connections survives as a connection. The provider boxes collapse into a single services block that applies to every agent in the flow. An end-call node connected to an agent's tools becomes a builtin function on that agent. A line from one agent to another becomes a transitions entry on the source agent — a target and the keywords that should trigger it. Positions go into a separate layout blob that the runtime never reads.

This was not always so. Until April 2026 the API and the database stored nodes and edges as the builder drew them, and the compiler walked the graph. Replacing that with the definition format deleted about four thousand lines and, more to the point, removed a class of bug: there was no longer a way for the edge list and the node data to disagree about who hands off to whom. The builder is the only component that thinks in lines.

The reason the runtime wants a list of small nodes rather than one big prompt is the same reason every flow engine gives. The documentation for Pipecat Flows, an open-source library for structured conversations, states it in a sentence: "This approach solves a common problem: monolithic prompts with many tools lead to hallucinations and lower accuracy" (Pipecat Flows, Daily). Each agent in your flow becomes a node that sees only its own prompt and its own tools; the compiler's job is to produce exactly that.

Publishing resolves, checks and flattens

Publish does four things in order, and a test call on an unpublished draft does exactly the same four, through the same function — a rule we enforce so the draft you test is compiled the way the published version will be.

A sequence schematic with four lanes: builder, backend, database, bot. In a dashed group labelled publish, once per version: builder to backend publish; backend to database resolve functions; backend self-loops validate and transform; backend to database snapshot. In a second dashed group labelled call start, once per call: backend to database load version; a backend self-loop render, credentials, policy, strip; and a coral arrow from backend to bot labelled per-call copy.
The two compiles. Publish resolves, validates, transforms and snapshots once per version; call start loads that snapshot and finishes the job for one caller.

It resolves. A function attached to an agent is stored as a reference to a function you defined elsewhere in your account. The compiler looks it up, scoped to your account, and takes the name, description and parameter schema from the stored definition, not from whatever the client sent. A reference to a function that does not exist, or belongs to another account, fails the publish.

It validates. The list of what is rejected is the list of things that would otherwise go wrong on a live call: a flow with no nodes or no entry agent, two entry agents, a handoff whose target does not exist, two functions with the same name on one agent or across the flow, a function of an unknown type, a builtin other than end_call, a model the catalogue does not know or a temperature it does not allow, a system prompt on any agent other than the entry one. Each error carries a path into the definition and a code, so the builder can put the message on the right node. One thing the validator does not do is check reachability: an agent no handoff leads to publishes fine, because nothing breaks when it is never reached.

It transforms. Each agent becomes a node. Each handoff becomes a tool named after its target, with the target recorded as the node to move to. Each builtin has its parameter schema forced empty — a flow author cannot give end_call an argument that changes what it does. Each function reference becomes a tool marked as executed on the server, carrying the function's id and its parameter bindings.

It snapshots. Every version stores both the source definition and the compiled output, so a rollback is not a reinterpretation of old source by new code; it copies the old snapshot back into the draft and publishes it as a new version. History is append-only in normal operation.

A tool name has to survive the model provider

The name of a handoff tool comes from the name of the agent it goes to. In June 2026 a Turkish flow had an agent called Kapanış — "closing". The tool was named after it, the provider's schema check refused the character, and because the tools array is validated as a whole, the provider rejected every tool in the request. The bot answered the first turn and then went silent, with nothing in the transcript to say why.

The constraint is the provider's, and it is published. OpenAI's API reference says a function name "Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64" (Chat Completions reference). The compiler now transliterates the agent name to ASCII, lowercases it, drops anything outside the allowed set, clamps it to 64 characters, and appends a counter if two agents would collapse to the same name. The agent is still called Kapanış in the builder and in the prompt; only the tool's identifier changed.

What the model is never shown

A function's parameters each declare where their value comes from: the model, the call's context — the caller's number, the contact's fields — or a static value you set. The model is asked only for the first kind. The other two are bound on the server, and the model's schema is reduced in two passes so that it cannot even be asked for them.

The first pass runs at publish and removes what is the same for every call: static parameters, and context parameters that must be present or the function must not run. The second pass runs at call start, when the actual contact is known, and removes context parameters that have values — and removes whole functions whose required context field is empty for this caller. A lookup that needs a customer id is not offered to the model on a call where there is no customer id; it is not there to be called with a guess. On the server side, when the model does call a function, values it supplied for non-model parameters are overwritten by the bound ones regardless. The two layers cover each other.

The same function card drawn three times. First, labelled defined: three empty parameter slots tagged static, context and model. An arrow labelled publish leads to the second card, where the static slot is struck through and pulled out below it. An arrow labelled call start leads to the third card, where the context slot is also struck through and pulled out, and only the model slot remains, filled in coral.
One function, three parameters. Publish removes the static one; call start removes the context-bound one; the model is handed a tool with a single slot.

Call start finishes the compile

The compiled flow does not go to the bot when you publish; it goes to the database. When a call starts, the backend loads the version the phone number or the campaign is pinned to and does the work that could not be done earlier because it depends on the call. It renders the prompt templates with this caller's contact and account — the subject of an earlier post. It injects the telephony credentials. It applies the account's recording, voicemail, idle-timeout and maximum-duration policy. It strips parameters the chosen model does not support. Then it writes the result to a cache under the call's own key, where the bot picks it up with the assignment and where it expires after an hour. The shared, unrendered version is cached too, as a fallback — and never with a contact's name in it, because that key is shared by every call on the version. The prompt text itself is filled in at the same moment; see the prompt is rendered when the phone rings.

One more thing is decided here by omission. Since September 2026 a flow has exactly one system prompt, on the entry agent. The compiler writes the system_prompt key into a node only when that node has one, and the runtime keeps the last system prompt it saw until a later node overrides it — so leaving the key out of every other node is what makes them inherit. The saved definition rejects a system prompt anywhere else. Earlier, each agent carried its own role text alongside its task; the persona of the call is now written once, and each agent contributes only what it is for.

Hanging up is a transition, not a function

The one builtin, end_call, was for a while an ordinary function with a no-op handler. When the model called it, control returned to the model in the same node, with the closing prompt still active — so it said goodbye again, and called end_call again, until the caller hung up. We saw the loop on production calls, and because each goodbye was synthesised on top of the last, the audio was garbled as well as endless.

The fix is in the runtime, and it turns on a distinction the pipeline framework we build on already makes. A function whose handler returns a next node is an edge: the framework moves to that node and does not ask the model to speak. A function whose handler returns nothing is a node function: the framework hands the result back to the model for another turn. end_call with no explicit target is now treated as an edge to a silent terminal node — no prompt, no tools, an end-conversation action that fires on entry. If the author wired end_call to a specific node or to a server handler, that still wins. Since July the terminal has a grace beat as well: if the farewell was already audible, end silently; if the model called end_call without saying anything, route to a node that generates a goodbye and ends after it is spoken, with a ten-second guard.

A state schematic starting at a block end_call called. One arrow goes to explicit target, author wired a node, then to that node runs. The other goes to grace beat, no target, which fans to three blocks: silent terminal, farewell already audible, in coral; goodbye node, nothing was said, which reaches silent terminal after spoken or 10 s; and parked, teardown in flight. Silent terminal leads to ended, no speech.
What happens when the model calls end_call. An explicit target wins; otherwise the grace beat decides whether a farewell was heard, and every path ends at a node that says nothing.

Test calls compile the draft the same way

The builder's test call does not publish. It serialises the current canvas to a definition in the browser, posts it, and the server runs it through the same resolve–validate–transform function as publish, then through the per-call pass with an empty context — so any function bound to a caller field is removed, since a browser test has no caller. The result is cached for five minutes under a draft key and the bot is assigned with a version of draft. The balance check and the capacity check are the ones a real call gets. What you hear in the test is what the compiled flow does, not what the canvas looks like.

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.