Appearance
Blueprints: how-to
Working notes on Avoca's Blueprint system, the config layer that drives voice-assistant prompts and routing. Captured from hands-on FDE work, not from canonical Avoca docs.
Unsolicited Opinion
This page documents the operating model as I understand it from working on the EAS / AutoOps rollout. Subject to correction as Avoca canonical sources or in-call observations diverge.
Where variables come from
The prompt body in a Blueprint contains variable references like {{company_location}}. At call time these get resolved against values that come from one of a few sources:
- Default variables — built into the platform.
- Knowledge Base — most per-team values (the shop's address, hours, and similar) live here. This is the primary place an FDE will set team-specific values.
Setting a Knowledge Base value (per team)
- Open the specific team's dashboard.
- Settings → Business Information → Knowledge Base.
- Set values there. Example: set
company_locationto the shop's street address.
Referencing a value in the prompt
Inside the Blueprint's prompt body, reference the variable using double curly braces:
This location's address is {{company_location}}.The reference resolves against the team's KB values at call time. You can inspect the configured prompt in the View Config view of the Blueprint.
How linked configs work
The mental model that almost works but is wrong: "each team gets its own config." That is not how it is structured.
The actual model:
- There is one canonical configuration for a given Blueprint.
- That config is versioned: v1, v2, v3, and so on.
- A linked config binds a team to a Blueprint plus a chosen version.
- Per-team variation that makes the prompt feel location-specific comes from variables, not from separate per-team configs.
So even though it looks like each team has its own config, what they actually have is a pointer to:
- The shared Blueprint config (one canonical source).
- The version of that config they should be on.
- Their own variable values (mostly via the Knowledge Base).
Why this matters in practice
- Where to edit prompts: at the top-level Blueprint config, not inside a linked-config view. Linked-config edits behave like a sandbox and do not persist downstream.
- How to roll a change out to a team: edit at the top level, then bump the linked config's version on each team that should receive the change.
- How to differ per location: use variables. Avoid per-team prompt edits, since they break the model and create drift that is hard to reason about later.
Common pitfall
If you assume each team has its own prompt and edit a linked config directly, the edits look like they save in the UI but do not propagate. The Blueprint's canonical config is unchanged, the linked config's version pointer is unchanged, and the call-time behavior does not match what you see in the editor. Edit the top-level config and bump the linked version instead.
How derived variables work
Some variables in a Blueprint resolve through more than one pass. The simple model, "variable name → value substituted at call time," only covers a subset of what is happening. The pattern that is easy to miss: a variable resolves to a string of Liquid source code, which is then evaluated by the Liquid renderer in a second pass.
The canonical example of this pattern lives in the modular blueprint reference at .indusk/sandbox/prompts/modular-blueprint-reference/actual/compiled-system.txt L1–L29.
The two-pass render
When Vapi renders a Blueprint for a call, two distinct things happen, in order:
- Pass 1: variable substitution. Variable references like
{{can_transfer_dest_assigns}}are replaced by their resolved values from the team's config, Knowledge Base, or upstream computation. The value here is not necessarily a literal. It can itself be Liquid source code. - Pass 2: Liquid evaluation. The resulting prompt body is handed to a Liquid renderer that evaluates
{% assign %}statements,{% if %}...{% endif %}blocks, filters like| date:, and any other Liquid constructs present.
The output of Pass 2 is what the LLM actually sees. Pass 1's output (which still has Liquid markup in it) is what the View Config page tends to show, which makes it look like the Liquid is "in the prompt." The LLM never reads Liquid syntax. It reads Pass 2 output.
Why this is meta-programming
Because Pass 1's output can include Liquid, a config-layer variable can effectively generate code. Concretely, the variable can_transfer_dest_assigns for a team with 7am–6pm hours resolves to a block like:
liquid
{% assign can_transfer_live_representative = currentMinutes >= 420 and currentMinutes < 1080 %}
{% assign can_transfer_safety = true %}
{% assign can_transfer_commercial = false %}The numbers 420 and 1080 are 7×60 and 18×60: minute-of-day bounds for the team's business hours. They look hardcoded in the rendered Liquid, but they are output of the team-config layer, not authored by hand. A different team with 8am–5pm hours sees 480 and 1020 in the same variable's output.
The Liquid evaluator in Pass 2 then assigns can_transfer_live_representative to a boolean by comparing currentMinutes (also computed in Pass 2 via "now" | date: "%H") against the bounds. Other parts of the prompt reference that boolean in {% if transfer_live_representative_enabled %}...{% endif %} blocks that route the call between modules.
Unsolicited Opinion
This is template meta-programming with no type safety, no static validation, and no visible separation between authored-by-hand prompt text and generated-by-config Liquid. The pattern is powerful: it lets the renderer pre-bake decisions like "is this team in transfer-hours right now" so the LLM never has to call a tool to find out. The failure modes are not obvious until they bite, and they bite in ways that are hard to trace.
Footguns
In rough order of how likely they are to surface:
- "Hardcoded" numbers may be dynamic. A teammate debugging a routing issue sees
currentMinutes >= 420and tries to edit the420directly. The edit lives in a per-team render that gets stomped the next time the team-config layer regenerates. The correct edit is in the team config (which produces the Liquid), not in the rendered prompt. - Debugging traverses two layers. When a transfer routes wrong, three suspects exist: the team-config layer produced wrong Liquid; the Liquid evaluator computed wrong against the right inputs; or the prompt template references the wrong variable. No stack trace tells you which. Build runbooks accordingly.
- No type validation across the pass boundary. Pass 1 produces a string. If the team-config layer emits unbalanced
{% endif %}, a misspelled variable name, or a missing{% assign %}, the prompt silently breaks at Pass 2. CI cannot catch this because the inputs are per-team and outside the repo. - Per-call render is critical. If Vapi caches Pass 1 output, team-config changes do not reach calls until cache invalidation. If Vapi caches Pass 2 output, time-of-day-derived booleans go stale across the window. Both flavors of staleness produce wrong routing.
- Mid-call time drift. Pass 2's
currentMinutesis computed at the start of the call. A call that begins at 5:55 PM (in business hours, transfer enabled) and continues past 6:00 PM still hastransfer_live_representative_enabled = truebaked in. The agent will try to transfer after-hours. - The team-config layer is opaque from the prompt template alone. You can see
{{can_transfer_dest_assigns}}referenced in the Blueprint, but you cannot see what generates that variable's value without access to whatever team-config service Avoca runs. That is a hidden upstream dependency for everyone debugging.
Common pitfall
The natural assumption: "I'll look at the rendered prompt in View Config to understand the routing logic." That shows Pass 1 output, not Pass 2. Variables like transfer_live_representative_enabled look unresolved in the editor, but at call time they evaluate to booleans that the LLM never sees as booleans, only as the branch that gets baked into the visible text.
To understand actual routing for a specific team and time-of-day:
- Inspect the team config (the upstream layer producing values like
{{can_transfer_dest_assigns}}) to see what Liquid it generates. - Read the prompt template's
{% if %}branches to see how each derived flag is consumed. - Trace through both passes mentally to determine what the LLM sees for a specific time-of-day and team policy.
The intuition the rendered prompt offers ("this is the prompt, the LLM reads this") is wrong. It is a snapshot of an intermediate stage, not the final input to the model.
When to reach for derived variables vs a runtime tool
The derived-variable pattern works best when the decision is:
- Deterministic given pre-call inputs (team config + current time + Knowledge Base values).
- Stable for the duration of a call (does not need to re-evaluate mid-conversation).
- Centralizable in the team config (the team owns the policy, not the prompt author).
A runtime tool call (e.g., a checkTransferWindow tool that the agent invokes at decision time) works better when the decision is:
- Sensitive to call duration (the call might span the transfer-window boundary).
- Dependent on live state (capacity at a sibling shop, technician availability, current tow-queue depth).
- Requires a safety net (you want the agent to verify "are we actually open right now" rather than trust a baked-in boolean from when the prompt was rendered).
Most Avoca patterns lean on derived variables. EAS's cross-shop work pushes against this because the cross-shop decisions are inherently runtime-dependent.