Example scenario: a support agent must rebook a delayed order. It has the order ID. It does not have a new date. The reviewer sees "Rebook order" and an empty date field. The support lead approves anyway, because the button is there. The consequence is a wrong date on a real shipment. That stays reversible until dispatch. After dispatch, it does not.
So the agent should ask one question there. Most agents ask five. Each extra question costs attention, and it invites a guess. A clarification request is a design object, not a chat message. It needs a blocking field, a reason, an owner, and a stop condition.
In this tutorial you build that contract from zero to a working local endpoint. Every stage ends with a check you can run yourself. The code below is illustrative. Treat it as a starting shape, not a tested release.
Stage 0: Name the owner of the missing evidence
Before any code, write the decision down. Who must supply the date? Who eats the cost if it is wrong? In the scenario, the support lead owns it. The agent can request the field, but it cannot own the answer.
Also write the reversibility point. Here it is dispatch. Before dispatch, a wrong date costs one edit. After dispatch, it costs a return.
Check: open the ticket. It should name one owner and one reversibility point. If either is missing, stop and fix the ticket, not the prompt.
Stage 1: Write the blocking set, not the form
A form lists what you want. A blocking set lists what stops the run. These are different lists, and mixing them is where agents start interviewing users.
// clarify.ts — illustrative; not executed here
export type Field = "order_id" | "new_date" | "refund_reason" | "customer_note";
type Rule = { why: string; onSilence: "stop" | "draft" };
export const blockingSet: Record<Field, Rule> = {
order_id: { why: "the order cannot be located", onSilence: "stop" },
new_date: { why: "a rebooking needs a target date", onSilence: "stop" },
refund_reason: { why: "refund copy depends on the reason", onSilence: "draft" },
customer_note: { why: "tone only, not a blocker", onSilence: "draft" },
};
Read onSilence out loud in review. "Stop" means the agent halts and changes nothing. "Draft" means it may proceed with a labeled assumption.
Keep this file under human review. A model must not rewrite its own blocking set. That is a policy decision with a cost attached.
Check: every field has an onSilence value, and a named human approved it. A missing value is a bug, not a default.
Stage 2: Build one question per gap
The builder takes the missing fields. It returns either a proceed signal or exactly one request. When two fields block, it asks the first and stops.
export type Clarification =
| { kind: "proceed" }
| {
kind: "ask";
field: Field;
question: string;
why: string;
owner: "requester";
onSilence: "stop";
stopCondition: string;
};
export function buildClarification(missing: Field[]): Clarification {
const blockers = missing.filter((f) => blockingSet[f].onSilence === "stop");
if (blockers.length === 0) return { kind: "proceed" };
const field = blockers[0];
return {
kind: "ask",
field,
question: `What ${field.replace(/_/g, " ")} should I use?`,
why: blockingSet[field].why,
owner: "requester",
onSilence: "stop",
stopCondition: `no ${field} answered -> halt, do not guess`,
};
}
Now guard the output. One question mark is a contract. Two is a form.
export function assertSingleField(question: string): void {
const marks = (question.match(/\?/g) ?? []).length;
if (marks !== 1) throw new Error(`expected one question, got ${marks}`);
if (/\bor\b[^?]*\?/i.test(question)) {
throw new Error("question offers a menu; ask for one field");
}
}
This is where a hosted model helps. You can generate the question text with free model access, then run it through assertSingleField. A model writes warm phrasing. Your guard decides whether the phrasing ships.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The project is open source, and its operator states that free model access comes with 10 million free tokens plus a free server option. I have not verified current quotas, so check the project page before you plan capacity around them. For this tutorial, the free server is useful for one reason: the endpoint is small, and a small endpoint is easy to keep honest.
Check: call the builder with ["customer_note"]. It must return { kind: "proceed" }. Call it with ["new_date", "order_id"]. It must ask for new_date and set onSilence: "stop".
Stage 3: Verify the contract with three illustrative cases
Write the cases before you wire the UI. Compile first, then run the test file.
npx tsc clarify.ts --module nodenext --target es2022
node --test clarify.test.mjs
// clarify.test.mjs — illustrative cases, run them yourself
import test from "node:test";
import assert from "node:assert/strict";
import { buildClarification, assertSingleField } from "./clarify.js";
test("two blockers -> ask one field and stop on silence", () => {
const r = buildClarification(["new_date", "order_id"]);
assert.equal(r.kind, "ask");
assert.equal(r.field, "new_date");
assert.equal(r.onSilence, "stop");
assert.equal(r.owner, "requester");
});
test("non-blocking gaps -> proceed without asking", () => {
assert.deepEqual(buildClarification(["customer_note"]), { kind: "proceed" });
});
test("guard rejects a two-question request", () => {
assert.throws(() => assertSingleField("What date? And which address?"));
});
Check: you should see three passing assertions. Confirm that output on your own machine. Do not quote it as a result until you have seen it.
Stage 4: Render the pending state, then audit it
The request needs a visible record, or reviewers cannot audit it later. Send exactly these fields to the UI.
{
"asked_field": "new_date",
"why_blocking": "a rebooking needs a target date",
"on_silence": "the agent halts and leaves the order unchanged",
"owner": "requester",
"stop_condition": "no date within the configured window"
}
Now audit it like a product pattern, not a component. Tab to the field: focus lands on the input, and the label is bound to it. Read the reason aloud: it uses the user's words, not model jargon. Trigger silence: the halted state is announced, not shown by color alone. Remove any countdown that discards typed work.
Check: complete the flow once with a keyboard only, then once with a screen reader. If the halt is silent in either pass, fix it before you enable the endpoint.
Stage 5: Set measures and stop measures
Collect four numbers, and one is a gate. Questions per task, fields per question, answer time, and proceeds-after-silence. The last one is the gate. Any sampled run where the agent proceeds without an answer halts the rollout.
Keep the sample small and read it by hand. Comment fields and review notes tell you whether the question reduced work or moved it. A fast answer rate can still hide a wrong field.
Boundaries and who should skip this
This pattern suits flows with one or two hard blockers and a clear owner. It fits poorly where the blocking set needs legal review each sprint. It also fits poorly where stopping is worse than asking, such as safety alerts.
Do not let a free model tier blur the policy line. The model can phrase the question. It cannot choose what blocks the run. Terms for free tokens and free servers can change, so verify them before you depend on them.
If you try this, keep the blocking set in review, not in the prompt. The question is short. The decision behind it is not.
Top comments (0)