DEV Community

Cover image for One Prompt Template, Three Engines That Disagree
Faisal Mehmood
Faisal Mehmood

Posted on

One Prompt Template, Three Engines That Disagree

Written against main at the time of publishing. Every regex and line number is cited so you can re-check with grep -n and there's a runnable reproduction at the end that needs no build.

TL;DR

A PROMPT_TEMPLATE artifact in Apicurio Registry is rendered by three
different pieces of code that don't share an engine and don't agree on what a template means beyond a plain {{name}}. Add a space, a dot, or an {{#if}} block and the same stored template renders three different ways. If you're going to build anything on top of prompt rendering a UI playground,a new client you have to know which of the three you're actually rendering against.


Storing a prompt template looks simple

You save some text with {{variable}} holes in it, hand it a set of arguments, and get a rendered string back. One artifact, one render.

Except the registry doesn't render it in one place. It renders it in three,and those three don't share an engine. A template that renders correctly over the
REST API can come back untouched over the MCP server, and be flagged as having novariables at all by the validator that gated it on the way in.

A renderer that returns a result is not the same as a renderer that returns a consistent one. This post walks through exactly where the three surfaces diverge, using the real classes and the real regexes.

The three-surface model

A PROMPT_TEMPLATE artifact is consumed by three independent pieces of code:

Surface Class Job
REST PromptRenderingService Renders a template to a string for HTTP clients
MCP PromptTemplateConverter Renders a template for Model Context Protocol clients, including {{#if}} blocks
Validator PromptTemplateContentValidator On write, decides which variables the template "declares"

They live in three different modules (app, mcp, schema-util). None of them calls the others. Each one carries its own idea of what a template is encoded in its own regular expression.

The objects involved

There is exactly one source of truth: the stored artifact content. Everything downstream is a consumer reading the same bytes.

                    ┌──────────────────────────────┐
                    │  Stored PROMPT_TEMPLATE       │
                    │  content:  "Hi {{ name }}"    │   ← one source of truth
                    └───────────────┬──────────────┘
                                    │  same bytes, three consumers
           ┌────────────────────────┼────────────────────────┐
           ▼                        ▼                        ▼
   ┌───────────────┐       ┌────────────────┐       ┌──────────────────┐
   │ REST          │       │ MCP            │       │ Validator        │
   │ PromptRender- │       │ PromptTemplate-│       │ PromptTemplate-  │
   │ ingService    │       │ Converter      │       │ ContentValidator │
   ├───────────────┤       ├────────────────┤       ├──────────────────┤
   │ \{\{([^}]+)\}\}│      │ key spliced    │       │ \{\{(\w+)\}\}    │
   │ + .trim()     │       │ unescaped      │       │  (strict)        │
   └───────┬───────┘       └───────┬────────┘       └────────┬─────────┘
           ▼                       ▼                         ▼
       "Hi Ada"            "Hi {{ name }}"            declares: (nothing)
        renders            whitespace breaks it       name not recognized
Enter fullscreen mode Exit fullscreen mode

That single picture is the whole story. The rest of this post is just proving
each of those three boxes really behaves that way.

A small template example

Take the simplest possible template and one argument:

template:  Hi {{name}}
args:      { "name": "Ada" }
Enter fullscreen mode Exit fullscreen mode

All three surfaces agree here. REST renders Hi Ada, MCP renders Hi Ada, and
the validator sees one declared variable, name. Good. This is the case everyone
tests, and it's why the divergence hides so well.

Now change one thing at a time.

Two grammars, not one

The REST renderer matches variables with this pattern, and then trims the capture:

// PromptRenderingService.java:27
private static final Pattern VARIABLE_PATTERN =
        Pattern.compile("\\{\\{([^}]+)\\}\\}");

// PromptRenderingService.java:361
String varName = matcher.group(1).trim();
Enter fullscreen mode Exit fullscreen mode

[^}]+ means "one or more characters that aren't a closing brace." That is a
generous grammar. It matches whitespace. It matches dots. It matches # and
/. And because of the .trim(), {{ name }} and {{name}} are treated as the
same variable.

The validator — the code that decides, on write, which variables a template
declares — uses a different, stricter pattern:

// PromptTemplateContentValidator.java:30
private static final Pattern TEMPLATE_VARIABLE_PATTERN =
        Pattern.compile("\\{\\{(\\w+)\\}\\}");
Enter fullscreen mode Exit fullscreen mode

\w+ is "word characters only." No spaces, no dots, no symbols. So the set of
variables the server accepts as declared and the set of variables the server
will actually substitute are defined by two different regexes in two different
modules. The write path and the render path do not speak the same language.

What happens with whitespace

Add a space:

template:  Hi {{ name }}
args:      { "name": "Ada" }
Enter fullscreen mode Exit fullscreen mode
  • RESTHi Ada. [^}]+ captures name, .trim() makes it name, substituted.
  • MCPHi {{ name }}. Unchanged. (Why, in the next section.)
  • Validator → declares nothing. \w+ never matched name.

Same template. One surface renders it, one leaves it raw, and the gatekeeper
thinks it has no variables at all.

Why MCP leaves it raw — the substitution nobody escapes

MCP declares a VARIABLE_PATTERN at the top of the file, exactly like the others:

// PromptTemplateConverter.java:25  — declared but never referenced
private static final Pattern VARIABLE_PATTERN =
        Pattern.compile("\\{\\{(\\w+)\\}\\}");
Enter fullscreen mode Exit fullscreen mode

It's never used. The real substitution doesn't compile a pattern for the whole
template. It loops over the arguments and builds a regex per key, on the fly:

// PromptTemplateConverter.java:441
String placeholder = "\\{\\{" + entry.getKey() + "\\}\\}";
// :443
rendered = rendered.replaceAll(placeholder, Matcher.quoteReplacement(value));
Enter fullscreen mode Exit fullscreen mode

Two things fall out of this:

  1. It builds a regex that matches {{name}} exactly — no [^}]+, no .trim(). So {{ name }} with spaces never matches, and the placeholder survives into the output. That's the whitespace result above.
  2. The replacement value is escaped with Matcher.quoteReplacement, but the key is spliced straight into the pattern with no Pattern.quote. The key becomes regex source. For an ordinary name that's harmless; for a key with regex metacharacters it is not the same string match REST would do.

The triple-brace and the dotted path

Two more probes, to show it isn't only whitespace.

template:  Raw: {{{name}}}     args: { "name": "Ada" }
  REST → Raw: {{{name}}}       (the greedy match lands on {name, no such arg → left literal)
  MCP  → Raw: {Ada}            ({{name}} is found *inside* the braces and replaced)

template:  Hi {{user.email}}   args: { "user.email": "a@x" }
  REST → Hi a@x                ([^}]+ matches the dot)
  MCP  → Hi a@x                (the unescaped '.' happens to match the literal dot)
  Validator → declares nothing (\w+ rejects the dot)
Enter fullscreen mode Exit fullscreen mode

The dotted-path row is the sneaky one: REST and MCP agree on the output, and
it's the validator that's the odd one out — it never considered user.email
a declared variable in the first place. So "the surfaces disagree" isn't even a
clean two-against-one; which surface is the outlier depends on the construct.

The full divergence table

This is the output of the reproduction script (verify_divergence.py), which
re-implements each surface using its exact on-main regex:

probe          REST                          MCP              validator sees
--------------------------------------------------------------------------------
plain          Hi Ada                        Hi Ada           name
whitespace     Hi Ada                        Hi {{ name }}    (none)
triple-stache  Raw: {{{name}}}               Raw: {Ada}       name
if-block       {{#if premium}}VIP Ada{{/if}} VIP Ada          name
dotted-path    Hi a@x                        Hi a@x           (none)
Enter fullscreen mode Exit fullscreen mode

Only the first row — plain {{name}} — is unanimous. Every other construct
produces at least one disagreement. And {{#if}} blocks only exist on MCP at
all: REST has no concept of them, so it emits the raw {{#if}} and {{/if}}
tags into the output.

So is this a bug?

Some of these divergences may be intentional; {{#if}} on MCP but not REST could
be a deliberate feature-gap, not an oversight. I'm not calling the code broken.
The point that survives either way is narrower and more useful:

The same stored template does not have a single, well-defined rendering. What
it means depends on which surface serves it.

That's a fact about the architecture, and it's the fact anyone building on top of prompt rendering needs to design around.

Why I care: building a UI Playground on top

I'm proposing a Prompt Template Playground in the Registry UI a panel where you edit a template, fill in variables, and see the rendered result before you save.

The table above is the reason this feature is harder than it looks, and the reason it's worth doing carefully. A playground has to render the preview somehow. If it quietly reimplements a fourth regex in TypeScript, it becomes a fourth
dialect now there are four answers to "what does this template mean." The correct design is the opposite: the playground must render against the surface the
user is actually going to call, and be explicit about which one. Anything else is a preview that lies.
That's also why the first useful contributions here are backend-shaped, not just
UI polish: before a playground can tell the truth, the surfaces have to agree on what the truth is.

What to remember

  • A PROMPT_TEMPLATE is rendered by three independent surfaces (REST, MCP, validator) that do not share an engine.
  • They encode three different grammars: [^}]++trim (REST), exact-key splice (MCP), strict \w+ (validator). Only plain {{name}} renders the same everywhere.
  • Before you build anything on top of prompt rendering, pin down which surface you're rendering against or you'll ship a fourth dialect.

Reproduce it yourself

No build required the repo re-implements each surface with its exact on-main regex and prints the table above:

git clone https://github.com/Faisal77666/prompt-template-render-divergence
cd prompt-template-render-divergence
python3 verify_divergence.py
Enter fullscreen mode Exit fullscreen mode

Source it's checking against:

  • PromptRenderingService.java REST renderer (:27, :361)
  • PromptTemplateConverter.java MCP renderer (:25, :441)
  • PromptTemplateContentValidator.java write-time validator (:30)

Line numbers reflect main at the time of writing. If they've drifted, a quick
grep -n on the patterns above will find them.

Top comments (0)