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.

Pattern

≈ 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.

Presets
What matters most

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.

Must-haves1 of up to 3 selected

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

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 in values and say what changed; changed lists 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 live only when regenerating is cheap and fast. Otherwise the user presses Update.
  • Lifecycle: no expiresAt or fetchedAt; 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).

TypeValueNotes
slidernumberShows 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
segmentedoption value2–5 options as a radio group, e.g. tone: Formal / Neutral / Casual
prioritiesitem ids, most important first2–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
togglebooleanA switch. The optional help line is read to screen readers only
chipsselected optionsToggle 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

app/api/chat/route.ts
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,
  }),
}
components/message.tsx
<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

PropTypeNotes
titlestringDefault "Adjust"
appliesTostring?What gets redone. Also names the Update button; "answer" when omitted
controlsControl[]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
livebooleanSend each change after 600 ms. Default false

Host props: status (streaming, ready, submitting, sent), onResult, result and className.

Source

On this page