Preference Controls
The constraints behind an answer (budget, priorities, tone, length, dates, must-haves) as controls the user tunes to have the agent redo it.
≈ 2,275 tokens per use (tool 1,684 · guidance 328 · typical payload 263)How token costs are estimated
Example: Preference Controls
Make the Lisbon itinerary cheaper and less packed
Adjust
Changes apply to your 5-day Lisbon itinerary.
Ordered most to least important. Press Space or Enter to pick up an item, the up and down arrow keys to move it, Space or Enter to drop it, or Escape to cancel.
When to use
- The agent produced an answer that depends on a few constraints (a budget, a pace, a tone, a length, dates, must-haves), and the user is likely to want it redone with different ones.
- The user asks to make an answer cheaper, shorter, more casual or more relaxed, or to change what matters most.
When not to use
- The user still has to give the inputs for a first answer. Ask with a Generated Form or a Question Stepper instead.
- The numbers can be recomputed on the spot without the agent. Use a Simulation or a Calculator, which recompute locally.
- The user is choosing between finished alternatives. Use Draft Options.
How the agent should use it
- Show it next to the answer it controls, and name that answer in
appliesTo("your 5-day Lisbon itinerary"). It names the button ("Update itinerary") and is read to screen readers; it isn't shown as a subtitle. - Only expose constraints you actually used, each set to the value behind the current answer.
- Use stable ids. On
update, redo the answer with every value invaluesand say what changed;changedlists the ids that differ from what you sent. - After an update, call the tool again with the new values as the current ones, so the user can keep iterating from there.
- On
reset, go back to the answer made with the values you sent. - Set
liveonly when regenerating is cheap and fast. Otherwise the user presses Update. - Lifecycle: no
expiresAtorfetchedAt; the constraints don't go stale on their own. When the agent issues a new set of controls without the user having sent anything (they typed a new request instead), mark the old call superseded so only the latest one is usable.
Controls
controls is a list of 1–8 controls, discriminated by type. Each one shows its label and current value; they sit in one column, or two when the card is wide enough (a container query, so it adapts to the chat column, not the window).
| Type | Value | Notes |
|---|---|---|
slider | number | Shows the value ("€650", "30 min", "40%"), plus named ends when given (minLabel/maxLabel, e.g. "Relaxed" / "Packed"). Screen readers hear the formatted value, plus the end label at either end |
segmented | option value | 2–5 options as a radio group, e.g. tone: Formal / Neutral / Casual |
priorities | item ids, most important first | 2–6 items. Drag by the row (or the grip on touch), or pick up with Space, move with the arrow keys, drop with Space, cancel with Escape. Every move is announced |
toggle | boolean | A switch. The optional help line is read to screen readers only |
chips | selected options | Toggle buttons for must-haves. With max, a small "2/3" count shows and the rest turn off at the limit |
date-range | { start, end } | Two native date inputs (YYYY-MM-DD). The end never falls before the start |
Presets, changes and sending
Presets sit above the controls as a segmented row. Choosing one fills the values it lists and leaves the rest; when the values match no preset, the row shows Custom.
The footer's quiet line compares the current values with the ones the agent sent, once something differs: "Budget: €1,800 → €650 · Activities per day: 4 → 2". Priorities compare by order; chips compare as a set, so "added Vegetarian options; removed Free cancellation".
Update is off until something differs, and again once exactly those values have been sent. The component never locks: after a send the footer reads "Sent · updating" until the host passes new props (the agent's regenerated values) or moves status from submitting back to ready. Edits made in the meantime are kept. Reset discards edits; if an update was already sent, it also sends reset.
With live, there's no Update button. Each change is sent 600 ms after the user stops adjusting, and the footer shows "Updating…" while the agent works. Moving everything back to the starting values sends reset.
The diffing, change summary, preset matching, formatting and batching are pure functions in preference-controls-utils.ts.
Installation
npx shadcn@latest add https://your-site.com/r/preference-controls.json
Usage
import { tool } from "ai"
import { preferenceControlsTool } from "@/components/genui/preference-controls.schema"
const tools = {
// No execute(): each update comes back from the UI.
[preferenceControlsTool.name]: tool({
description: preferenceControlsTool.description,
inputSchema: preferenceControlsTool.inputSchema,
}),
}<PreferenceControls
{...part.input}
status={part.state === "input-streaming" ? "streaming" : undefined}
result={part.state === "output-available" ? part.output : undefined}
onResult={(result) =>
addToolResult({ tool: "adjust_preferences", toolCallId: part.toolCallId, output: result })
}
/>The agent receives one of:
{
action: "update",
values: { budget: 650, pace: 2, priorities: ["views", "food", "museums", "nightlife"], must: ["Free cancellation"] },
changed: ["budget", "pace", "priorities"],
}
{ action: "reset" }With LangGraph
Push it from a graph node with ui.push({ name: "adjust_preferences", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer. To keep one card on screen across updates, push to the same UI message id with the new values; the component rebases on the new props.
Restoring from history
Pass the stored result back as result. The component shows the values that were sent (or the original values, for reset) read-only, with the change summary, and never calls onResult. The user keeps iterating in the latest call.
<PreferenceControls
{...part.input}
result={part.state === "output-available" ? part.output : undefined}
/>Schema
| Prop | Type | Notes |
|---|---|---|
title | string | Default "Adjust" |
appliesTo | string? | What gets redone. Also names the Update button; "answer" when omitted |
controls | Control[] | 1–8, see Controls |
controls[].slider | { id, label, min, max, step, value, unit?, format?, currency?, minLabel?, maxLabel? } | format: currency, number (default), percent (0–100) |
controls[].segmented | { id, label, options: { value, label }[], value } | 2–5 options |
controls[].priorities | { id, label, items: { id, label }[] } | 2–6 items, most important first |
controls[].toggle | { id, label, value, help? } | help: screen readers only |
controls[].chips | { id, label, options, value, max? } | Up to 12 options |
controls[].date-range | { id, label, start, end, min?, max? } | YYYY-MM-DD |
presets | { id, label, values }[]? | Up to 4. values maps control ids to values |
live | boolean | Send each change after 600 ms. Default false |
Host props: status (streaming, ready, submitting, sent), onResult, result and className.