Skip to content
Chapter 3 of 6 · PromptsPrompt argument declaration and validation

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:

json
{
  "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 inputPrompt argument
Declarationfull JSON Schema in inputSchemaname, title, description, required
Value typesobjects, arrays, numbers, booleans, enumsstrings only
Constraints expressibletypes, enums, ranges, patterns, required setsnone
Who fills itthe model, from contexta person, in the host's UI
Client-side validationpossible, and hosts do itnothing to validate against
Assistive affordanceschema-driven argument generationcompletion/complete suggestions
Where enforcement livesclient may pre-check; server still mustserver 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:

json
{
  "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…" } }
  }
}
json
{
  "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.

python
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 tone

Four 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 promptWhat a client with a stale list doesDeployable on its own
New optional argument with a server-side defaultomits it; the handler fills the defaultyes
New required argumentomits it; every invocation errorsno
Adding a value to a documented setkeeps suggesting the old setyes
Removing a value from the setkeeps offering it; the handler must reject it by nameonly after a deprecation window
Renaming an argumentsends the old name; the handler sees an unknown keyno
Editing a description or titleshows stale help textyes

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

Back to top