
The answer: send a template id and a variable map instead of a prompt string. The gateway resolves the active version, fills the placeholders, and records which version ran against the request id in your dashboard logs. Every save is a new version, so a bad prompt is a rollback rather than a deploy, and two versions can be A/B tested on live traffic.
Prompts are code that lives in strings, and most teams treat them as neither code nor configuration. They get inlined as literals, copy-pasted between services, and edited under time pressure — which means a one-word fix to a system prompt travels through the whole CI/CD pipeline, and a bad prompt becomes an incident with no fast way out.
That mismatch is the real problem, and it is a cadence problem. Prompts change far more often than the code around them, because tuning a prompt is the work. Coupling a daily-cadence asset to a weekly-cadence release process makes the daily thing rare and the rare thing risky.
This post is the engineering of moving prompts to where they can be operated: what the request looks like, what versioning actually buys you, the decisions we had to make about precedence and failure, and what the gateway records so you can tell which prompt ran.
The one-word fix that needs a deploy
Here is the request every application starts with. The prompt is a literal, assembled in the service:
SYSTEM = "You are a support agent for {product}. Answer in under 100 words."
client.chat.completions.create(
model="claude-sonnet-4-5-20250929",
messages=[
{"role": "system", "content": SYSTEM.format(product="ACME")},
{"role": "user", "content": user_input},
],
)On Tuesday, someone notices the assistant is being curt to the point of rudeness. The fix is obvious and takes four seconds to write: change "Answer in under 100 words" to "Answer in under 150 words, warmly". Then it takes four hours to ship, because the four-second fix is a code change, which means a branch, a review, a test run, a build, and a deploy window.
Meanwhile the same system prompt exists — slightly differently — in the batch summariser and the internal triage tool, because it was copied there in March and edited once since. Nobody knows that. It will be found in six weeks, by accident.
And when someone asks "what did the prompt say last Tuesday, when quality dropped?", the answer is a git log archaeology exercise across three repositories, assuming the prompt was ever in version control at all rather than assembled from three f-strings at runtime.
Why inline prompt strings break
The failure is not that inlining is sloppy. It is that a string literal in a service has exactly the operational properties of code, and a prompt needs a different set. Lay them side by side:
| Property | Inline literal | What a prompt actually needs |
|---|---|---|
| Change latency | One full deploy cycle | Seconds |
| Rollback | Revert, rebuild, redeploy | One step, no build |
| History | Interleaved with unrelated code history | Per-prompt version list |
| Who can change it | Anyone with commit access to that service | Whoever owns the prompt |
| Consistency across services | Copy-paste, then drift | One referenced asset |
| Experimentation | Feature flag plus branching code | Point a test at two versions |
The row that costs the most is rollback. A change you cannot cheaply undo is a change people avoid making, so prompts get tuned in nervous batches instead of continuously — and when a batch is bad, the recovery path is the same length as the delivery path. That is precisely the property Google's SRE release-engineering practice tells you to design out, and precisely the property Martin Fowler's writing on feature toggles exists to restore: make the change reversible and it stops being frightening.
The second-most expensive row is consistency. Three copies of "the same" prompt in three services is not a tidiness problem; it is three different products behaving three different ways under one brand, and no single place to fix it.
The mechanism: reference a template, send variables
A prompt template is a named, server-stored prompt with placeholders. The application stops sending prompt text and starts sending two things: which template, and what to fill it with.
client.chat.completions.create(
model="claude-sonnet-4-5-20250929",
messages=[{"role": "user", "content": user_input}],
extra_body={
"metadata": {
"nrouter_prompt_template_id": "support-triage",
"nrouter_prompt_variables": {"product": "ACME", "tone": "warm"},
}
},
)The customer-visible contract, stated as ordering and guarantees:
- The reference is explicit. A call either names a template id or it does not. There is no ambient prompt injected into requests that did not ask for one — the seam has to be predictable or nobody will trust it.
- The gateway resolves the active version for that id at request time, fills the placeholders from the variable map, and assembles the final messages.
- The assembled prompt is what goes upstream. The
nrouter_*metadata fields are stripped before the request is forwarded, so the provider only ever receives an ordinary request. - The version that ran is recorded with the request, keyed to the
x-nr-request-idthe response returns, so the answer to "which prompt produced this output" is a lookup in your request log, not an inference from a deploy timestamp. - A missing variable is an error, not a silent gap. Sending a template that expects
{product}without supplying it fails loudly rather than shipping the literal characters{product}to a model.
The extra_body shape is deliberate: it is the escape hatch the OpenAI-compatible clients already provide for provider-specific fields, so this works through the official OpenAI Python client without a wrapper library and without an SDK rewrite. The per-language patterns are in the Python + OpenAI SDK guide and the surrounding SDK reference.
Versioning: every save is a new version
Saving a template does not overwrite it. It appends. A template is therefore a small, ordered history with exactly one version marked active:
support-triage
v4 (draft) "You are a warm, concise support agent for {product}…"
v3 (active) "You are a concise support agent for {product}…"
v2 "You are a support agent for {product}. Answer in under 100 words."
v1 "Help the user with {product}."That structure buys two operations an inline literal cannot offer at any price.
Rollback. A version that damages quality is one step back to the previous one — no branch, no build, no deploy window. The version that was good has not gone anywhere; it is still sitting in the list. The cost of a bad prompt drops from "an incident" to "a minute", and that changes how aggressively a team is willing to tune.
Attribution. "Quality dropped Tuesday afternoon" becomes "v3 went active at 14:20 Tuesday". Cause and effect are visible instead of reconstructed, and the change itself is an auditable event — who activated which version, when — which is the same append-only discipline described in Building a Tamper-Evident Audit Trail for Admin Actions.
Reversibility is the point, not the history
The reason to version prompts is not archival tidiness. It is that a reversible change is a safe change. When rolling back takes seconds and no deploy, a team iterates continuously instead of batching edits into rare, nervous releases — and continuous iteration is what actually improves output quality.
Worked example: v3 tanks quality on a Tuesday
Follow the incident end to end, with the timeline you would actually see.
14:20 v3 activated on `support-triage`
14:35 CSAT on assisted tickets starts falling
15:10 on-call notices; filters the last hour of requests in the dashboard
every one of them ran support-triage v3
15:12 rollback: v2 set active
15:13 the next request is logged against support-triage v2
15:40 CSAT recoversThree minutes from diagnosis to recovery, and the diagnosis itself took one query, because the version each request ran is recorded with that request rather than being inferred from a deploy log. Compare with the inline version of the same incident: find which service owns the prompt, find the commit, write the revert, get it reviewed, build, deploy, wait for the rollout — call it ninety minutes on a good day, with the assistant being rude to customers throughout.
The arithmetic is worth doing explicitly. At 400 assisted tickets an hour, the difference between a 3-minute and a 90-minute recovery is roughly 20 affected conversations versus roughly 600. Same bug, same fix, same engineers — the only variable is whether the prompt was a referenced asset or a string in a binary.
And then the follow-up: v3 is still in the list. It was not a bad idea, it was an untested one. Which leads to the next section.
Pointing an A/B test at two versions
Because the prompt is referenced rather than inlined, an experiment does not need branching code. You point a test at two versions of the same template and split live traffic between them.
| Variant A | Variant B | |
|---|---|---|
| Template version | v2 | v3 |
| Traffic share | 50% | 50% |
| Assignment | Deterministic per end user | Deterministic per end user |
| Measured | Resolution rate, tokens, cost, latency | Resolution rate, tokens, cost, latency |
The word doing the work is deterministic. A given end user must land on the same variant on every request for the duration of the test, or you are not measuring a prompt — you are measuring the average of two prompts alternating mid-conversation, which is a third thing that nobody shipped. That property, and why random-per-request assignment quietly invalidates the result, is the subject of Deterministic A/B Testing Across Model Variants; the configuration surface is in A/B testing.
Because each variant's requests are recorded against their own template version, the cost and token consequences separate cleanly too. A prompt that improves resolution rate by two points while adding 40% to input tokens is a trade, not a win, and you want to see both halves before promoting it — the technique for spotting that is in Cost vs Usage: Finding the Quietly Expensive Model.
Promote the winner to active. The loser stays in the version list, because you will change your mind about something eventually.
Edge cases we had to decide
-
When a request names a template id that does not exist, we fail the request. We do not fall back to sending the raw messages. A silent fallback means a typo in a template id turns into an unprompted model call that looks successful and behaves strangely — the worst possible failure, because it produces plausible output. Failing loudly costs one clear error; failing softly costs a week of confused debugging.
-
When a variable is missing, we fail before the provider call. No partial fill, no leaving
{product}in the text. The check runs pre-flight, so a malformed request never becomes a billed one. -
When a version is activated mid-flight, in-flight requests keep the version they resolved. Resolution happens once per request. There is no state in which half a conversation ran on v2 and half on v3 without anything recording it — every request resolved exactly one version, and that version is recorded against it.
-
When a template is referenced by more than one service, the change applies to all of them. That is the point, and it is also the risk. Templates are scoped like every other resource — org, team, key — so a prompt that should only affect one product is owned at the level that matches its blast radius, using the same precedence described in Org, Team, Member: Scoping Keys, Budgets, Guardrails.
-
When a template is deleted, its version history is retained. A deleted template stops resolving for new requests, but the record of what ran last month does not vanish, because "what prompt produced this output" is a question that gets asked long after someone tidied up.
-
Variables are data, never instructions. A value supplied in the variable map fills a placeholder; it does not get to add new directives to the assembled prompt. Untrusted end-user text belongs in the user message, where the inline guardrails evaluate it, not in a variable that lands inside a system prompt.
What you see from the outside
Everything above is observable from the response and the dashboard.
| Header | What it tells you |
|---|---|
x-nr-request-id | Always present; the id to quote in a ticket, and the join key to this call's log entry |
x-nr-model | Which model actually served the call |
x-nr-request-cost | USD cost of the call — absent when the cost is not known |
x-nr-cost-status | exact or unpriced, so an absent cost is never read as a free call |
x-nr-request-cost follows the same rule everywhere on the platform: when the cost is not known, the header is absent rather than reported as 0. An absent header is a fact your code can branch on; a zero would be a claim that the call was free. The reasoning is in Cost Honesty.
The template version is not one of those headers; it lives in the log entry. In the dashboard, a template shows its version list, which version is active, when each was activated and by whom, and the traffic and outcome split when a version is under test, while the request log carries the version each call resolved — joined to the x-nr-request-id its response returned. The authoring surface is documented under Prompts, the experiment surface under A/B testing, the change record under Audit log, and the full response-header table in the chat completions API reference.
Limits
A template is not a prompt-engineering strategy. Moving a bad prompt to a versioned store gives you a bad prompt with better operational properties. What versioning buys is the ability to iterate cheaply and prove an improvement; the improvement still has to come from somewhere.
Referenced prompts add a resolution step. It is small and it happens in the same pre-flight pass as every other check, but it is not zero. If you are chasing single-digit milliseconds at the time-to-first-token boundary, measure it rather than assuming — Measuring Real LLM Latency covers doing that per route.
Cross-model portability is not automatic. A prompt tuned for one model family does not necessarily transfer to another. Templates make it easy to keep a variant per model, which is usually the right answer, and comparing them is exactly what the A/B surface is for.
Templates do not make untrusted input safe. Variables are substituted, not sanitised. Content safety is a separate control on the same request path — see Inline LLM Guardrails on Every Request.
Try it
Create a template, reference it by id, and send it:
curl -i https://api.nrouter.ai/v1/chat/completions \
-H "Authorization: Bearer $NROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-5-20250929",
"messages": [{"role":"user","content":"My invoice is wrong."}],
"metadata": {
"nrouter_prompt_template_id": "support-triage",
"nrouter_prompt_variables": {"product":"ACME","tone":"warm"}
}
}' | grep -i '^x-nr-'Edit the template, send the same request, and watch the recorded version change in the dashboard's request log — without a single line of application code moving. The playground is the quickest way to iterate on wording before you point production traffic at it, and the live model catalog shows what you can point it at.
Pay as you go starts at $5 — a $5 minimum credit purchase with the platform fee on top, no subscription. Prompt management, versioning and A/B tests are on every plan; plans vary the platform fee, not the feature set. → Get started, read the Prompts guide, or compare plans on Pricing. Questions belong in the nRouter community.
See also
- Deterministic A/B Testing Across Model Variants — why per-user stable assignment is what makes a prompt experiment measure anything at all.
- Building a Tamper-Evident Audit Trail for Admin Actions — the append-only record behind "who activated v3 at 14:20".
- Org, Team, Member: Scoping Keys, Budgets, Guardrails — the scope precedence that decides a template's blast radius when several services reference it.
- Cost vs Usage: Finding the Quietly Expensive Model — reading the token and cost half of a prompt experiment, not just the quality half.
- Inline LLM Guardrails on Every Request — the content control that evaluates the untrusted text a template variable must never carry.
- Every Feature on Every Plan: We Charge a Fee, Not a Gate — why prompt management is not held behind an enterprise quote.
- Pricing — the platform-fee difference between Pay as you go and Pro.
Sources
External references for the API shapes and release practices above. Verified 2026-08-23. If a linked page has moved and we have not refreshed, email hello@nrouter.ai and we will re-check.
- OpenAI chat completions API reference: platform.openai.com
- OpenAI Python client (
extra_bodyescape hatch): github.com/openai/openai-python - Anthropic Messages API reference: docs.anthropic.com
- Martin Fowler, Feature Toggles: martinfowler.com
- Google SRE Book, Release Engineering: sre.google/sre-book
- nRouter pricing: nrouter.ai/pricing


