Structured Questions
The agent will not ask you questions. Not on its own.
You can build an askUser tool. The agent will see it, read its description, and then continue not using it. Models trained on developer chat have absorbed a lot of "let me just figure this out for you" energy. Asking feels weak to them. They'd rather guess.
The fix is in two parts. The tool, which is small, and the system prompt, which has to do the work of telling the agent that asking is the right move.
Outcome
An askUser tool that takes a question and 2 to 4 options, plus a # Handling Ambiguity section in the system prompt that scripts when to use it. Ambiguous prompts trigger the tool. Specific prompts don't.
Fast Track
- Add an
askUsertool withquestionandoptions(2 to 4 strings) - Add a
# Handling Ambiguitysection tobuildSystemPromptthat tells the agent to search first, then ask, then act - Verify with two prompts: one ambiguous, one specific
Hands-on Exercise 8.1
Build the tool, add the prompt section, and confirm both halves are doing their jobs.
Requirements:
- Add
askUsertosrc/tools.tswith the schema above - Inside
execute, format the options as a numbered list and return a string the model will pass back to the user - Add
# Handling AmbiguitytobuildSystemPrompt. Tell the agent: search first, ask second, act third - Run two prompts: an ambiguous one ("add authentication") and a specific one ("add a null check at line 42 of auth.ts"). Confirm only the first one triggers
askUser
Implementation hints:
- The
askUsertool'sexecutedoesn't actually wait for the user. It returns a string describing the question and options. The harness around it (or the user reading the output) provides the answer in the next turn - The system prompt scripting is doing more work than the tool description here. Without the prompt section, the model treats
askUseras optional even when the prompt's vague - Two examples in the prompt are usually enough to anchor the pattern. Don't overload it
The tool
import { tool } from "ai";
import { z } from "zod";
export function createAskUserTool() {
return tool({
description: `Ask the user a multiple-choice question.
WHEN TO USE: scoping ambiguous tasks, choosing between approaches,
resolving a missing detail before acting.
WHEN NOT TO USE: you already have enough context to proceed.
DO NOT USE FOR: rhetorical questions or progress updates.`,
inputSchema: z.object({
question: z.string().describe("The question to ask the user"),
options: z
.array(z.string())
.min(2)
.max(4)
.describe("Two to four options for the user to pick from"),
}),
execute: async ({ question, options }) => {
const formatted = options.map((o, i) => `${i + 1}. ${o}`).join("\n");
console.log(`\nQuestion: ${question}\n${formatted}\n`);
return `Asked: "${question}"\nOptions:\n${formatted}\n\n(Awaiting user response.)`;
},
});
}The tool prints the question and options to stdout (so the user sees it) and returns the same content as a string (so the model sees it in the message history). The model knows the question is in flight and won't try to act on the assumption it's been answered yet.
The system prompt addition
sections.push(`
# Handling Ambiguity
When the task is ambiguous or has multiple valid approaches:
1. Search the code or docs to gather context first
2. Use askUser to let the user choose. Do NOT guess.
3. Examples: "add auth" -> ask OAuth or JWT; "set up a db" -> ask Postgres or SQLite
Specific tasks (with file paths, line numbers, or precise instructions) do not
need askUser. Act directly.`);The numbered protocol matters. "Search, ask, act" gives the model a sequence it can follow. Without it, the agent either asks too early (before it has enough context to make the question useful) or too late (after it's already started building the wrong thing).
Even with the protocol, the agent reads three or four files before pulling out askUser. That's correct behavior, since step 1 is "search first." It's still worth knowing, because if you're watching it run and getting impatient, the model isn't ignoring you. It's gathering context to ask a useful question.
If bash is blocked by approval, the agent can't run the commands it needs to gather that context. It might never reach step 2. The approval system and askUser are in tension, and that tension is real architectural friction, not a bug to fix.
Wire the tool in
const tools = {
read: createReadTool(sandbox),
grep: createGrepTool(sandbox),
bash: createBashTool(sandbox, createApproval({ mode: "interactive" })),
task: createTaskTool(sandbox, { read, grep }),
askUser: createAskUserTool(),
};The agent's tool list now includes askUser. The system prompt tells it when to use it. The user's terminal shows the question. The model's message history shows that the question is pending.
Try It
Run an ambiguous task and watch the agent ask:
bun run index.ts . "Add authentication to this project"You should see the agent read a few files, then call askUser with a question like "Which authentication strategy should I use?" and options like "OAuth," "JWT," "Session cookies," and so on. The terminal prints the question. The model sits and waits.
Now run a specific task and confirm the agent doesn't ask:
bun run index.ts . "Add a null check at line 42 of src/auth.ts before the database query"The agent should make the change directly. No askUser call.
npx tsc --noEmitCommit
git add src/tools.ts src/system.ts index.ts
git commit -m "feat(askUser): add structured question tool with ambiguity protocol"Done-When
askUsertool is wired and accepts 2 to 4 options# Handling Ambiguitysection is in the system prompt- Ambiguous prompts trigger
askUser - Specific prompts do not
npx tsc --noEmitpasses
Right now askUser returns a string and the model continues. In a real harness, the harness would actually pause, collect the user's choice, and pass it back as the next user message. Sketch what that pause looks like. Where does the harness intercept the tool call result? Where does the user's answer get inserted into the conversation? Module 8.2's events approach is the natural place to wire this in.
Solution
export function createAskUserTool() {
return tool({
description: `Ask the user a multiple-choice question.
WHEN TO USE: scoping ambiguous tasks, choosing between approaches,
resolving a missing detail before acting.
WHEN NOT TO USE: you already have enough context to proceed.
DO NOT USE FOR: rhetorical questions or progress updates.`,
inputSchema: z.object({
question: z.string().describe("The question to ask the user"),
options: z
.array(z.string())
.min(2)
.max(4)
.describe("Two to four options for the user to pick from"),
}),
execute: async ({ question, options }) => {
const formatted = options.map((o, i) => `${i + 1}. ${o}`).join("\n");
console.log(`\nQuestion: ${question}\n${formatted}\n`);
return `Asked: "${question}"\nOptions:\n${formatted}\n\n(Awaiting user response.)`;
},
});
}Was this helpful?