Structured Choice UX in AI Chatbots: Replace Free-Text with Tappable Answers
Overview
When an AI chatbot asks a question with a known, finite set of valid answers, requiring the user to type a response is the wrong interaction model. Structured choice UI — selectable buttons, chips, or cards rendered inline in the conversation — eliminates the friction of the keyboard entirely and produces cleaner, more reliable input at no cost to the user.
- Why it matters: Free-text input for constrained answers forces users to recall options, avoid typos, and match exact expected strings. Structured choices solve all three problems simultaneously. On mobile, the improvement is especially pronounced: tapping a visible option is fundamentally faster and more accurate than summoning a keyboard.
- What you will learn: The design principle behind structured choice selection, a real before-and-after implementation in Claude Code, and concrete implementation patterns across seven major chat platforms.
The Core Problem
When an AI asks "Which option do you prefer — A, B, C, or D?" and then opens a free-text input box, there is a mismatch between the answer space and the input method. The answer space is closed (four known choices). The input method is open (any string). That gap creates unnecessary work:
- The user must remember all options after reading the question
- The user must type accurately — a typo or wrong case requires re-entry
- The AI must parse and validate the raw string before it can act on it
- On mobile, the user must open a keyboard, type one or two characters, and dismiss it — for input that a single tap could have handled
This is not hypothetical friction. In practice, constrained free-text inputs generate a disproportionate share of chat errors: mistyped letters, extra spaces, invalid combinations, and "I wasn't sure what format you wanted" clarification rounds. Every one of those is avoidable.
The Design Principle
Only use free-text input when the answer is genuinely open-ended.
If you can enumerate the valid responses before sending the question, you should render those responses as tappable choices rather than leaving the input field empty. This rule applies whether there are two options or ten, whether the interface is a consumer chatbot or a developer tool.
The corollary: structured choice is not always appropriate. Use free-text when you need a name, a date, an explanation, or any answer that cannot be anticipated. The goal is not to eliminate the text input — it is to stop using it where selection is the correct primitive.
Benefits
Reduced cognitive load. The user does not need to hold options in working memory while forming a reply. The choices are visible alongside the question.
Elimination of input errors. A tapped option is always a valid input. No parsing, no validation, no "please type A, B, C, or D."
Faster completion. Selection is a single gesture. Typing requires at minimum three actions on mobile: tap field, type character(s), submit.
Cleaner conversation history. Structured inputs keep the transcript readable. "User selected: B" is a cleaner log entry than "User typed: b " (trailing space included).
Accessibility. Larger tap targets and visible labels reduce the cognitive and motor demands on users with attention or dexterity constraints.
Mobile UX: The Specific Case for Tap Targets
On desktop, free-text for a letter is a minor annoyance. On mobile, it is a genuinely poor experience. Opening the software keyboard shifts the visible area of the page, often hiding the question the user just read. The user then types one or two characters, submits, and dismisses the keyboard — a multi-step sequence for input that carries essentially zero information entropy.
Apple's Human Interface Guidelines specify a minimum tap target size of 44x44 points. Google's Material Design specifies 48x48dp. Any structured choice implementation that meets these minimums will outperform free-text on mobile for constrained inputs. The button is always faster than the keyboard when the answer is already known.
The "Question in the Box" Principle
When rendering a selection component, place the full question text inside the component — not above it as disconnected plain text.
The common first implementation looks like this:
What is the correct order of LWC lifecycle hooks? ← plain text above the box
[Select an answer:] ← widget prompt
A) constructor → connectedCallback → renderedCallback
B) render → constructor → connectedCallback
...
The question appears twice: once as floating text, once implied by the widget's generic label. The selection box feels detached from the context.
The correct version:
[What is the correct order of LWC lifecycle hooks?] ← inside the widget
A) constructor → connectedCallback → renderedCallback
B) render → constructor → connectedCallback
...
The question is the widget's prompt. The component is self-contained. The user reads once, selects, and moves on. Any contextual header line (question number, topic, session progress) can still print as plain text above — that information belongs in the transcript. But the actual question text should live inside the selection component where the choices are anchored to it visually.
A Real Example: AI Quiz in Claude Code
The pattern above came out of a real implementation: an interactive quizzing program running inside Claude Code, where the AI presents multiple-choice questions and waits for the user to select an answer.
Before
The quiz loop worked like this:
- AI printed the question number, objective, and topic as a header line
- AI printed the full question text as plain text
- AI printed the answer choices as plain text (
A) ... B) ... C) ... D) ...) - AI printed "Choose 1 answer." and waited for a typed letter
- User typed
A,B,C, orD(or comma-separated letters for multi-select questions)
On desktop this was workable. On mobile — used frequently via the Claude Code iOS app — it required opening the keyboard to type a single character, then dismissing it again, for every question in a session.
After
The quiz loop was updated to use Claude Code's AskUserQuestion tool for the answer collection step:
- AI prints only the header line as plain text: question number, objective, topic
- If the question has a code block, it prints that as indented text
- AI calls
AskUserQuestionwith:- The full question text as the
questionparameter (appears as the prompt inside the selection widget) - Each answer choice as a selectable option:
label= the letter,description= the full choice text - A "Skip" option as the final entry (maps to incorrect, exam-realistic)
multiSelect: truefor questions that require multiple correct answers,falseotherwise
- The full question text as the
- User taps their answer — no typing required
- AI reads the selected label(s) and proceeds with scoring, explanation, and mastery tracking
The first iteration still printed the question text as plain text above the widget and used a generic "Choose your answer:" string as the widget prompt — causing the question to appear twice. The fix was to move the question text directly into the question parameter and remove the plain-text duplicate. One iteration, clean result.
The Key Change in the Instruction
# Before (in the quiz command definition)
Wait for input: single letter, comma-separated letters, or `skip`.
# After
Call AskUserQuestion with:
- question: the full question text
- header: "Answer"
- multiSelect: true if multi_select == true, otherwise false
- options: one entry per choice (label = letter, description = full choice text)
plus a final "Skip" option
Parse the user's selected label(s) back to letter(s) for scoring.
The rest of the quiz flow — scoring, session logging, mastery tracking — required no changes. Only the input collection step changed.
Platform Implementations
Claude Code: AskUserQuestion
When to use: Any time the AI needs to collect a selection from a known set of options in a Claude Code conversation or skill.
{
"tool": "AskUserQuestion",
"questions": [
{
"question": "What is the correct order of LWC lifecycle hooks?",
"header": "Answer",
"multiSelect": false,
"options": [
{ "label": "A", "description": "constructor → connectedCallback → renderedCallback" },
{ "label": "B", "description": "render → constructor → connectedCallback" },
{ "label": "C", "description": "connectedCallback → constructor → render" },
{ "label": "Skip", "description": "Skip this question (counts as incorrect)" }
]
}
]
}
Limits: 2–4 options per question, 1–4 questions per call. The UI automatically adds an "Other" free-text fallback on every call. On mobile, renders as a full-screen bottom sheet with scrollable tap targets.
Gotcha: The header field is capped at 12 characters and renders as a small chip label — use it for category context, not the question itself.
React / Next.js: Quick Reply Chips
The standard web pattern is a row of pill-shaped buttons rendered above the chat input, which disappear after one is tapped.
interface QuickReply {
label: string;
value: string;
}
function QuickReplies({ options, onSelect }: {
options: QuickReply[];
onSelect: (value: string) => void;
}) {
return (
<div className="quick-replies">
{options.map((opt) => (
<button
key={opt.value}
className="reply-chip"
onClick={() => onSelect(opt.value)}
>
{opt.label}
</button>
))}
</div>
);
}
.quick-replies {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
}
.reply-chip {
padding: 8px 16px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
color: inherit;
cursor: pointer;
font-size: 0.875rem;
min-height: 44px; /* iOS tap target minimum */
transition: background 0.15s;
}
.reply-chip:hover {
background: rgba(255, 255, 255, 0.08);
}
In the parent component, store quickReplies: QuickReply[] | null in state. Set it when the AI returns a message that includes a choices payload. On selection, inject the selected value into the conversation and set quickReplies back to null so the chips disappear.
Gotcha: Chips should disappear after selection. If they persist, users may re-tap them and submit duplicate messages.
WhatsApp Business API
WhatsApp supports two structured choice types: buttons (up to 3) and list sections (up to 10 items across up to 3 sections).
{
"messaging_product": "whatsapp",
"to": "{{recipient_phone}}",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Which area needs attention this week?"
},
"action": {
"buttons": [
{ "type": "reply", "reply": { "id": "opt_a", "title": "Pipeline review" } },
{ "type": "reply", "reply": { "id": "opt_b", "title": "Forecast update" } },
{ "type": "reply", "reply": { "id": "opt_c", "title": "Deal escalation" } }
]
}
}
}
For more than 3 options, use "type": "list" with a sections array instead of buttons.
Limits: Button title max 20 characters. List item title max 24 characters. No more than 3 buttons; no more than 10 items per list section.
Gotcha: Emojis in button titles are not supported. Titles that exceed 20 characters are silently truncated on some devices — test on the platform.
Slack Block Kit
Slack's actions block renders interactive buttons inside a message. Each button sends a block_actions event to your webhook on click.
{
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "Which deployment strategy should we use for this release?"
}
},
{
"type": "actions",
"elements": [
{
"type": "button",
"text": { "type": "plain_text", "text": "Blue-green", "emoji": false },
"value": "blue_green",
"action_id": "deploy_blue_green"
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Canary", "emoji": false },
"value": "canary",
"action_id": "deploy_canary"
},
{
"type": "button",
"text": { "type": "plain_text", "text": "Rolling", "emoji": false },
"value": "rolling",
"action_id": "deploy_rolling"
}
]
}
]
}
Limits: Max 5 elements per actions block. Button text max 75 characters. action_id must be unique within the message.
Gotcha: Slack does not remove buttons after they are clicked — your app must update or delete the original message via chat.update to prevent users from clicking the same option twice.
Telegram Bot API
Telegram offers two keyboard types. InlineKeyboardMarkup renders buttons directly below the message — the preferred pattern for constrained choices because the buttons are anchored to their context.
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
keyboard = [
[
InlineKeyboardButton("A) connectedCallback", callback_data="A"),
InlineKeyboardButton("B) constructor", callback_data="B"),
],
[
InlineKeyboardButton("C) renderedCallback", callback_data="C"),
InlineKeyboardButton("D) disconnectedCallback", callback_data="D"),
],
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text(
"Which hook fires when an LWC is removed from the DOM?",
reply_markup=reply_markup
)
ReplyKeyboardMarkup is an alternative that replaces the user's keyboard with a persistent button grid — appropriate for navigation menus, less appropriate for per-question choices because the buttons outlive their context.
Limits: callback_data max 64 bytes. Button text max 64 characters. Recommended max 3–4 buttons per row for mobile readability.
Gotcha: Inline buttons remain visible after selection unless you call edit_message_reply_markup to remove them. For quiz-style flows, remove or replace the keyboard immediately after receiving the callback.
Facebook Messenger
Messenger's quick_replies array renders tappable chips above the composer, similar to the React pattern — except the platform handles rendering and the chips disappear automatically after selection.
{
"recipient": { "id": "{{user_psid}}" },
"message": {
"text": "Which integration approach fits your current architecture?",
"quick_replies": [
{
"content_type": "text",
"title": "REST callout",
"payload": "APPROACH_REST"
},
{
"content_type": "text",
"title": "Platform Event",
"payload": "APPROACH_PLATFORM_EVENT"
},
{
"content_type": "text",
"title": "Change Data Capture",
"payload": "APPROACH_CDC"
}
]
}
}
Limits: Max 13 quick replies per message. Title max 20 characters. Payload max 1000 characters.
Gotcha: The title (displayed) and payload (received by webhook) are separate fields. The user sees the title; your bot receives the payload. Don't put internal IDs in titles.
Microsoft Teams / Bot Framework (Adaptive Cards)
Teams Adaptive Cards render rich interactive cards with Action.Submit buttons. This is the most structurally verbose option but also the most flexible for complex layouts.
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.4",
"body": [
{
"type": "TextBlock",
"text": "Select the deployment environment for this build.",
"wrap": true,
"weight": "Bolder"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Development",
"data": { "selection": "dev" }
},
{
"type": "Action.Submit",
"title": "Staging",
"data": { "selection": "staging" }
},
{
"type": "Action.Submit",
"title": "Production",
"data": { "selection": "prod" }
}
]
}
Limits: Teams renders a maximum of 5 actions; additional actions appear in an overflow menu. Action title max 64 characters. Total card payload max 28KB.
Gotcha: Adaptive Cards render differently across Teams desktop, Teams mobile, Outlook, and the Bot Framework Web Chat emulator. Always test on Teams specifically. The card does not automatically update after a submit action — send a new card or update the existing one via the Bot Framework's updateActivity API to remove the buttons.
Common Pitfalls
Double-printing the question. The most common first-iteration mistake: printing the question as plain text and then using a generic "Choose your answer:" string as the widget prompt. The user reads the question twice. Fix: put the question text directly into the widget's prompt/question parameter. Reserve plain text above for contextual metadata only (question number, topic, session progress).
Exceeding platform character limits. WhatsApp button titles are capped at 20 characters; Messenger at 20; Telegram callback data at 64 bytes. Long choice text that looks fine in development will be truncated silently in production. Audit your longest possible choices against each platform's limit before shipping.
Buttons that persist after selection. On Slack, Telegram, and Teams, choice buttons remain visible in the conversation after the user clicks them unless your application explicitly removes or replaces them. Persistent buttons invite accidental re-submission. Update the message immediately after receiving the selection event.
Missing the free-text escape hatch. Not every question in a structured flow has a fully known answer space. Always provide a "Other" or "None of these" option that falls through to free-text if your question set might have gaps. Claude Code's AskUserQuestion adds this automatically; on other platforms you need to add it manually.
Multi-select without visual indication. If a question allows multiple selections, the UI must clearly communicate that — checkboxes, "Select all that apply" text, or a multiSelect mode. A single-select UI presented for a multi-answer question will produce consistently wrong answers with no error to debug.
Too many options. Six or more tappable options on a mobile screen requires scrolling and defeats the cognitive load benefit. If your answer space exceeds five options, consider a list section (WhatsApp), a multi-row inline keyboard (Telegram), or restructuring the question into a two-step flow.
iOS keyboard zoom. If your structured choice falls back to a text input on any code path, that input's font size must be at least 16px. iOS Safari zooms into any input below 16px, disrupting the layout for the rest of the interaction. This is a CSS property, not a logic concern — set it once and forget it.
When Not to Use Structured Choices
Structured choice UX is the right tool when the valid answer space is known and enumerable. It is the wrong tool when:
- You need a name, description, date, or other free-form value from the user
- The user is asking a question rather than selecting an answer
- The choices depend on data that isn't available at render time
- The question is exploratory ("tell me more about your situation")
In these cases, free-text input is correct. The goal is not to eliminate text input from chat interfaces — it is to stop using text input as a lazy default when selection is the accurate primitive.
A well-designed AI chatbot uses both: structured choices to collect constrained input efficiently, and free-text input to handle everything the choices cannot.