Chapter 3 of 6 · 15 min read
Prompt argument declaration and validation
On this page
A prompt that takes arguments is a form somebody fills in, and the declaration is what the host draws that form from. Four fields describe each field of it. One is the key the value arrives under, one is the label above the box, one is the help text beside it, and one decides whether the box has an asterisk. Nothing in that list describes the value.
The consequence is easiest to see in traffic. A month of prompts/get parameters for a
reply_to_review prompt — 1,842 invocations, grouped by the value of its tone argument —
contained 47 distinct values. The prompt was designed around three tones: warm, neutral,
firm, with a handler holding a small dictionary mapping each to a paragraph of style
guidance. The three intended values covered 1,691 invocations. The rest included Warm and
WARM, professional, warm but not sycophantic, apologetic, firm., thirty-one empty
strings from a host that submits blank optional fields rather than omitting them, and one
value that was 380 words long because a user had pasted the review they wanted answered into
the wrong box. Every one of those 151 invocations produced a mystery for whoever read the
output, because the handler did TONES.get(tone), fell through to None, and the template
interpolated None into the sentence "Write in a tone that is None." The model, being
accommodating, wrote something.
Those figures close on each other, which is worth checking because it changes what they can be
used for. Subtract the 1,691 invocations carrying an intended tone from the 1,842 total and
you get the 151 exactly — no remainder, no third group. There is no bucket of values that were
nearly right and got partially handled, because the handler's dictionary lookup has two
outcomes and no others: the key is present, or the result is None. So the 151 is a count
rather than an estimate, and the defect rate is simply the complement of the intended-value
rate. That is the property that makes the traffic log usable as evidence at all; a
classification with a "partly worked" column in it would have told you much less.
The shape of the tail matters as much as its size, and it is what rules out the obvious cheap repair. Of the 47 distinct values, three were intended and forty-four were not, so the argument attracted more than fourteen unanticipated spellings for every value the designer had in mind. Thirty-one of the 151 invocations are the empty string, which leaves a hundred and twenty invocations spread across forty-three remaining values — under three occurrences apiece on average, and most of them occurring once. A team looking at that log will reach for a mapping table of common variants, and the arithmetic says a table would have to be almost as long as the tail to catch most of it, while catching none of the next month's. The repair that scales is not a longer table.
The declaration for that argument could not have prevented any of it:
{
"name": "tone",
"title": "Tone",
"description": "One of: warm, neutral, firm.",
"required": false
}There is no type, no enum, no pattern, no minLength, no default. The
PromptArgument type in the specification has four fields, one of which is display copy and
one of which is a boolean. Anything about the shape of the value has to be said in the
description, to a reader who may be a person, may be a model summarising the menu, and may
be nobody at all.
Why the tool side got a schema and this side did not
A tool declares its inputs with JSON Schema, the standard vocabulary for stating in machine-readable form what type an argument holds, which values it may take, and which arguments a caller must supply; the host and the model both read that declaration before a call is made. A prompt argument has nothing of the kind. The contrast is sharp enough to be worth putting side by side, because the same server declares both and the difference is easy to read as an oversight.
| Tool input | Prompt argument | |
|---|---|---|
| Declaration | full JSON Schema in inputSchema | name, title, description, required |
| Value types | objects, arrays, numbers, booleans, enums | strings only |
| Constraints expressible | types, enums, ranges, patterns, required sets | none |
| Who fills it | the model, from context | a person, in the host's UI |
| Client-side validation | possible, and hosts do it | nothing to validate against |
| Assistive affordance | schema-driven argument generation | completion/complete suggestions |
| Where enforcement lives | client may pre-check; server still must | server only |
The asymmetry follows from who fills the value in. A tool's arguments are produced by a model
that has never seen the tool before and needs a machine-readable contract to construct a valid
call; a schema is the cheapest way to get a limit that is an integer rather than the string
"forty". A prompt's arguments are produced by a person typing into whatever the host puts on
screen, and the transport carries them as a map of string to string, so 40 arrives as "40"
and there is no layer that could have made it otherwise.
That reasoning explains the design without making it comfortable. In practice a prompt argument frequently does have a constrained domain — a tone from a set, an environment from a list of four, a report format — and the format offers no way to state it. The gap is well known and it is one of the places the prompts primitive is visibly younger than the tools primitive; the specification's own answer is the completion mechanism, which is help rather than constraint.
Completion is a suggestion, not a gate
A server that wants to assist the user declares the completions capability during
initialization, and then answers completion/complete for its prompts' arguments. The
reference identifies which prompt and which argument, and the partial value the user has typed
so far:
{
"jsonrpc": "2.0",
"id": 8,
"method": "completion/complete",
"params": {
"ref": { "type": "ref/prompt", "name": "reply_to_review" },
"argument": { "name": "tone", "value": "w" },
"context": { "arguments": { "review_text": "Waited 40 minutes…" } }
}
}{
"jsonrpc": "2.0",
"id": 8,
"result": {
"completion": { "values": ["warm"], "total": 1, "hasMore": false }
}
}The context.arguments field carries the arguments the user has already filled in, which lets
a completion depend on an earlier answer — a repo argument narrowing the suggestions for
branch, for instance. Responses are capped at 100 values, with hasMore to signal
truncation.
Three things keep this from being validation. The host decides whether to render suggestions
at all, and plenty of hosts show a plain text field regardless of what the server offers. A
user who ignores the dropdown and types Warm gets Warm sent. And nothing forces the value
that reaches prompts/get to have passed through any UI — a client can construct the call
directly, and in a host that lets a user re-run an earlier invocation from history, the value
comes from a stored record rather than from the picker.
The same holds for required. A well-behaved host will not let a user submit with a required
field empty, but required: true is a hint to the presentation layer, and the presentation
layer is the stage of the trace from chapter 2 that you have the least control over. The
thirty-one empty strings in that log came from a host that treated "the user left it blank" as
"" rather than as absent, which is a defensible reading and one your handler has to survive.
Follow that one step further and it stops being a matter of taste. The arguments arrive as a flat map of string to string, and that structure has exactly two states available for a field nobody filled in: the key is missing, or the key is present with an empty value. Neither state carries the distinction anyone actually cares about, which is whether the user considered the field and declined it or never saw a field at all. The two states are not two meanings; they are two hosts. So any handler that behaves differently for absent and for empty has made its output depend on which client is connected, and it will produce two different results for two users who did the same thing.
The one stage you cannot delegate
Everything above is advisory, which puts all enforcement in a single place: the prompts/get
handler, in the schema-handling stage of that same trace. It is the only stage of the five
that lives entirely on your side of the wire and cannot be shared with the client.
TONES = {
"warm": "Open with genuine acknowledgement of the frustration.",
"neutral": "State facts plainly; no apology language.",
"firm": "Correct the factual claims directly and without hedging.",
}
DEFAULT_TONE = "neutral"
def resolve_tone(raw: str | None) -> str:
if raw is None or raw.strip() == "":
return DEFAULT_TONE
tone = raw.strip().lower()
if tone not in TONES:
permitted = ", ".join(sorted(TONES))
raise ValueError(
f"Invalid value for 'tone': {raw!r}. Must be one of: {permitted}."
)
return toneFour decisions are packed into those lines and each of them is a choice you could get wrong.
Absent and empty are treated the same, and both fall back to a default. There is no default
field in the declaration, so a default is something you implement, and the way you advertise
it is required: false plus a sentence in the description saying which value applies when the
field is left alone. A user who leaves the field blank and a host that omits it entirely
should not get different postmortems.
Normalisation happens before the membership test, which is what turns Warm, WARM and
warm from three errors into one accepted value. How far to go is a judgement — lowercasing
and trimming are almost always right, stripping trailing full stops is usually right, and
fuzzy-matching warm but not sycophantic to warm is not, because that value means the user
wanted something the prompt does not offer and silently giving them the nearest option hides a
design gap.
An unrecognised value raises rather than falling back to the default. This is the decision the
original handler got wrong in the other direction by falling through to None, and falling
through to "neutral" would have been wrong too — quieter, and still wrong, because the user
asked for apologetic, would have received neutral, and would have had no way to learn that
their choice was discarded.
The error message names the offending value and lists the permitted set, because that message
is not for a model. It goes back as a -32602 and lands in front of the person who clicked, in
whatever dialog the host uses for errors. "Invalid params" tells them nothing; "Must be one of:
firm, neutral, warm" tells them what to do next.
A useful test of whether validation is complete is to call prompts/get directly with
arguments no UI would produce — a missing required argument, an empty string, a value with a
newline in it, a 100 KB value — and check that each comes back as an error rather than as a
rendered message. Anything that renders is a value your template will happily interpolate the
day a host sends it.
An argument list is a contract with a version nobody wrote down
Discovery is a request a client makes when it feels like it, which in practice means at
connection time and rarely again. The specification's answer is
notifications/prompts/list_changed, which a server that declares listChanged: true sends
when its prompt set changes, and which a client is expected to respond to by re-listing. That
notification helps a client that is listening, is still connected, and has implemented the
handler; it does nothing for a session opened an hour ago against a server that has since
restarted behind a load balancer, and nothing at all for a host that stores a user's favourite
invocations and replays the arguments it recorded last week.
| Change to a shipped prompt | What a client with a stale list does | Deployable on its own |
|---|---|---|
| New optional argument with a server-side default | omits it; the handler fills the default | yes |
| New required argument | omits it; every invocation errors | no |
| Adding a value to a documented set | keeps suggesting the old set | yes |
| Removing a value from the set | keeps offering it; the handler must reject it by name | only after a deprecation window |
| Renaming an argument | sends the old name; the handler sees an unknown key | no |
| Editing a description or title | shows stale help text | yes |
The same staleness makes silent tolerance expensive in a second way. When an argument arrives
with a value the handler does not recognise, the useful response names the value and lists the
permitted set, exactly as for tone; a handler that ignores unknown keys entirely will accept
product-area, productArea and area from three different hosts and quietly write generic
guidance for all three. Unknown argument keys are worth logging even when you choose not to
reject them, because that log is the only place a rename shows up before a user reports that
the output got worse.
The split in that incident is worth reading as arithmetic, because it explains the shape of
the bug report as well as the bug. A fifth of invocations failed and four fifths succeeded,
and those two account for all of the traffic, so there is no partial-failure class to look
for. More usefully, the boundary between them is not random: it is exactly the boundary
between sessions that acted on listChanged and sessions that did not. Staleness is a
property a session has or does not have, and it does not change while the session lives. So a
given user failed on every attempt and a different user succeeded on every attempt, and
"sometimes it fails" is what that looks like when the reports are pooled. The diagnostic
follow-up is therefore not "how often", it is "does it fail every time for you" — a question
whose answer separates a per-session cause from a per-request one in a single exchange.
A closed set is not a safety property
Validation of this kind establishes that a value is in a permitted set. It does not establish
that the value is safe, and for the constrained arguments it is easy to conflate the two: once
tone is provably one of three known strings, the string that reaches your template is one you
wrote, so there is nothing an attacker can put there. That property belongs to the closed set,
and it does not transfer to review_text or incident_id or any other argument whose domain
is open. Chapter 4 is about exactly those.
The strongest form of what the closed set does buy is worth stating exactly, because it is the
form a reviewer can check. After resolve_tone returns, the string about to be interpolated
is one of three that appear literally in the server's own source. That means the set of
possible rendered templates is finite and small enough to print, and a reviewer can read all
three and know there is no fourth. No property of that kind is available for an argument whose
domain is open — not with a length limit, not with a character allowlist, not with a
well-tested handler — because "the set of things that can appear here" is unbounded by
construction. The line between the two arguments is therefore not how carefully each is
validated. It is whether the validation ends with the server holding a value it wrote.
Nor does the string-only transport justify smuggling structure through it. The pattern shows
up quickly — declare one options argument, document it as JSON, parse it in the handler —
and it moves the schema problem rather than solving it. You now own the parse errors, the
error messages are worse, the completion mechanism has nothing sensible to suggest, and the
host still renders a single-line text box in which a user is expected to type balanced braces.
If a prompt needs five constrained inputs, five arguments with five descriptions is the version
a person can fill in.
There is also a limit to how much the description can carry. It is read by a human in a menu and by whatever the host chooses to show, and a description that lists twelve permitted values in prose has already failed as UI regardless of how correct the handler is. When the domain gets large, completion earns its place, and when it gets large enough that neither prose nor completion helps, the input probably belongs to a tool the model calls rather than to a field a person fills.
Four fields, then, and a handler that behaves as though they were a schema. The declaration tells a host what to draw and tells a person what to type; completion makes the right value easy to reach; and neither of them constrains what arrives, so the handler normalises, tests membership, defaults the absent and the empty identically, and raises with a message written for the human who clicked. Changing that contract afterwards is a deployment question rather than a code question, because the client holding the old version of it is a cached listing you cannot reach.
None of this touches the values whose domain is the set of all strings. review_text will be
validated as present and non-empty and will still arrive carrying whatever a stranger typed
into a review form, and it is about to be interpolated into a template that ends with a line of
instructions to a model.
Prompts — read the current version at https://daviddominguez.dev/learn/en/prompts/prompt-argument-declaration-and-validation