What is Prompt Template Registry?
Prompt Template Registry is an AI design pattern for LLM apps and agents, in the Composition group. Store prompts as versioned artifacts with variables and history.
Registro de Templates
Prompt Template Registry is an AI design pattern for LLM apps and agents, in the Composition group. Store prompts as versioned artifacts with variables and history.
Store prompts as versioned artifacts, with a name, declared variables, history, and a publishing process. It is for systems with several prompts in production, edited by more than one person or more than one team.
A library with edition control: every book has an edition number, and nobody rewrites a page of a volume already printed.
Henrique leads the AI platform at a retail company. There are 23 prompts in production, spread across four services: SDR, support, product descriptions, and review summaries. Some live in constants in code, others in a config table edited through a panel.
Three problems come up every week. Nobody knows which version produced the answer behind a complaint. The marketing team wants to adjust the tone without waiting for a deploy, but there is no way to test before publishing. And two services use different copies of the same persona, which drifted apart over time.
On Tuesday a customer files a complaint about an SDR message that promised a Pix discount (Pix is Brazil’s instant payment system) in the first conversation. Henrique opens the trace: it has the generated text, the model, the timestamp. It does not have the prompt. The config panel was edited Wednesday morning. Tuesday’s text is gone.
What is missing is not a better prompt. It is a home for prompts, with the same guarantees code has: versions, review, testing, and rollback.
The question this pattern answers: how do you manage dozens of prompts in production with traceability and safe changes?
Build a template registry. Each template has:
production, staging, canary, pointing to specific versions.Code asks for the template by label (or a pinned version), renders it with the variables, and records name and version on the trace. Publishing to production means moving the label after the eval passes. Rolling back means moving the label back.
This treats Unversioned Prompt and Scattered Prompt Strings. The refactoring that lands here is Extract Versioned Template.
flowchart LR
E((("1. Author edits"))):::human --> V["2. New immutable version"]:::tool
V --> EV["3. Eval runs on the new version"]:::tool
EV --> R[("4. Registry: versions and labels")]:::store
R -->|"production label"| S["5. Service renders with variables"]:::tool
S --> M(["6. Model"]):::llm
M --> T[("7. Trace with name and version")]:::store
The complaint points to conversation conv-4412, Tuesday at 4 p.m. The trace now has prompt.name=sdr_first_message and prompt.version=14. Henrique opens the registry:
# prompts/sdr_first_message/labels.yaml
production: 14
staging: 15
Version 14, immutable, is still on disk. The body says: “Do not offer a discount in the first message. If the lead asks about price, point them to the current price list.” Version 15, in staging, relaxed that rule for a conversion test. The eval for 15 dropped 0.3 on the tone rubric and has not earned the production label yet.
Henrique answers legal with the exact text the model received. Marketing keeps the test in staging. Nobody rewrote the past.
Compare the two ways of changing:
| Approach | What remains of the old version | How you roll back |
|---|---|---|
| Edit the constant or the database row | Nothing. The previous text is gone | Hope someone remembers |
| Publish a new version and move the label | Every version, with its eval | Point production back to 14 |
schema Template { name: string, version: int, model: string, variables: map[string, type],
body: string, author: string, reason: string, eval_result: EvalSummary? }
function publish(name, body, variables, author, reason):
v = registry.create_version(name, body, variables, author, reason) # 2. immutable
v.eval_result = eval_harness.run(name, v) # 3
if v.eval_result.regressed(baseline=registry.get(name, label="production")):
return reject(v)
registry.set_label(name, "staging", v.version)
return v
function promote(name, version):
registry.set_label(name, "production", version) # rollback = point back
function render(name, label, values):
t = registry.get(name, label=label) # 5
assert set(values) == set(t.variables) # nothing missing or extra
trace.span("prompt", name=t.name, version=t.version) # 7
return t.render(values)
A registry does not replace the eval. Without an Eval Harness at publish time, the production label becomes a “ship and hope” button.
Start with the repository. YAML files with labels.yaml and one vN.yaml per version cover most cases. Only move to a service with a panel when marketing or legal need to edit without waiting for a merge, and even then the eval stays the gate.
{{ "formal" if segment == "industry" else "casual" }} looks harmless until it has eight branches. Pull the decision into code; leave the template with already-resolved variables.coupon sometimes appears and sometimes does not, declare two versions or a field that is always present (empty string). Half-rendering is the bug the schema exists to catch.canary label receives a share of traffic before becoming production. It pairs with the idea of a shadow deploy: the new version answers in parallel, without reaching the customer, until the production eval matches staging.| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Latency | Loading the template adds milliseconds; use a local cache | short prompt span |
| Cost | No extra model cost; the eval on publish costs | eval cost per publish |
| Failure: missing variable | Half-rendered prompt | a render error, never a call |
| Failure: wrong label | Untested version in production | version on the trace with no eval result |
| Failure: registry down | Services cannot load the prompt | load errors; keep a local cache of the last version |
| Failure: logic in the template | Complex conditions inside the text | rendered prompts differ from what was expected |
The eval on publish is not cheap, and it does not need to be. Run the small set (cases that catch tone, discount refusal, required variables) on every version. The large set runs overnight on the staging label.
{ "template": "sdr_first_message", "candidate": 15, "baseline": 14, "eval": "evals/sdr.jsonl", "block_if": "tone_rubric < baseline - 0.2" }
A concrete eval case: the message must not mention price or Pix in the first turn. If version 15 breaks that, the production label does not move.
prompt.name and prompt.version attributes on every model span.Rehearse the rollback. Moving production: 15 to production: 14 has to be a documented step, tested once in staging, not an invention in the middle of an incident.
Templates are text, and text attracts shortcuts. An API key pasted into the body, “just so the model can call the CRM”, becomes the Secret in the Prompt smell. The registry should not accept that: credential variables do not exist; tools receive secrets in the backend. See Move Secret Out of Prompt.
Who can publish to production is a separate role from who can propose a version. The marketing panel writes to staging. Engineering (or an eval bot) moves the label.
Template output still goes to the model along with lead data. Treat the rendered body as what it is: your instruction plus outside data. Structured Output on the response side reduces the damage of a template that asks for too much.
| Pros | Cons |
|---|---|
| Every answer points to the exact text | One more piece of infrastructure |
| Rollback in seconds | Takes discipline to publish through the registry |
| People outside engineering propose changes safely | An eval per publish costs |
| Route-by-route model migration | Logic hidden in templates is still possible |
Both tabs implement a simple registry on versioned files, with a production label and the version recorded on the trace.
Python. Use this when templates live in the repository and you want a registry with no new infrastructure.
from dataclasses import dataclass
from pathlib import Path
import yaml
from jinja2 import Environment, StrictUndefined
ROOT = Path("prompts")
jinja = Environment(undefined=StrictUndefined) # a missing variable becomes an error
@dataclass(frozen=True)
class Template:
name: str
version: int
model: str
variables: tuple[str, ...]
body: str
def render(self, **values) -> str:
missing = set(self.variables) - set(values)
extra = set(values) - set(self.variables)
if missing or extra:
raise ValueError(f"{self.name}@{self.version}: missing {missing}, extra {extra}")
return jinja.from_string(self.body).render(**values)
def load(name: str, label: str = "production") -> Template:
labels = yaml.safe_load((ROOT / name / "labels.yaml").read_text()) # {"production": 14, "staging": 15}
version = labels[label]
data = yaml.safe_load((ROOT / name / f"v{version}.yaml").read_text()) # files never edited after creation
return Template(name, version, data["model"], tuple(data["variables"]), data["template"])
# usage
tpl = load("sdr_first_message")
prompt = tpl.render(lead_name="Ana", company="Sunrise Bakery", segment="retail",
pricing_policy="no price in the first message", campaign="launch")
span.set_attributes({"prompt.name": tpl.name, "prompt.version": tpl.version}) # 7What you gained: immutable versions reviewed through pull requests, and rollback by editing one line of labels.yaml. What you lost: changes require a merge; for edits without a deploy, put the same format behind an API.
TypeScript. Use this when Node services load templates from a central registry with a local cache.
type Template = { name: string; version: number; model: string; variables: string[]; body: string };
const cache = new Map<string, { tpl: Template; fetchedAt: number }>();
export async function load(name: string, label = "production"): Promise<Template> {
const key = `${name}:${label}`;
const hit = cache.get(key);
if (hit && Date.now() - hit.fetchedAt < 60_000) return hit.tpl;
try {
const res = await fetch(`${process.env.PROMPT_REGISTRY_URL}/templates/${name}?label=${label}`);
if (!res.ok) throw new Error(`registry answered ${res.status}`);
const tpl = (await res.json()) as Template;
cache.set(key, { tpl, fetchedAt: Date.now() });
return tpl;
} catch (err) {
if (hit) return hit.tpl; // registry down: use the last known version
throw err;
}
}
export function render(tpl: Template, values: Record<string, string>): string {
const missing = tpl.variables.filter((v) => !(v in values));
const extra = Object.keys(values).filter((v) => !tpl.variables.includes(v));
if (missing.length || extra.length) {
throw new Error(`${tpl.name}@${tpl.version}: missing ${missing}, extra ${extra}`);
}
return tpl.body.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k: string) => values[k]);
}What you gained: changes without a deploy, with a cache that protects against the registry being down. What you lost: the eval gate must exist in the registry itself, before the label moves.
Prompt Template Registry is an AI design pattern for LLM apps and agents, in the Composition group. Store prompts as versioned artifacts with variables and history.
Model-Pinned Prompt, Scattered Prompt Strings, and Unversioned Prompt.
In the real-world scenarios Automated sales SDR, Content creation pipeline, and Support bot with human escalation.