Option Builder
Pick a combination of options where each choice carries metrics, and see the totals, budgets and score update before continuing.
≈ 2,664 tokens per use (tool 1,624 · guidance 307 · typical payload 733)How token costs are estimated
Example: Option Builder
Which APIs should we combine for the widget?
Combine APIs for the widget
- Load time
- 368 msof 500 ms
- Size
- 329 KBof 512 KB
Score 89 out of 100, grade B
When to use
- The user is assembling a combination where every choice has a cost or impact: APIs and their load time, plan add-ons and their price, PC parts within a budget, integrations and their monthly cost.
- Seeing the combined total, and whether it fits a budget, should shape the choice.
When not to use
- The options have no numbers to combine. Use the Entity Picker or the Question Stepper.
- The agent doesn't have real metrics for every option. Fetch them first; never estimate prices, sizes or timings.
- It's one product with variants (size, color). Use Product Options.
How the agent should use it
- Every metric value and budget comes from a tool result or the user, never from the model.
- Order metrics by importance. The first two are the big header numbers; on each row they sit on the right, the first in body text and the second quieter.
- Use
requiresfor real dependencies andexcludesfor real conflicts, and set a group'smax: 1for one-of choices like a CPU. - Only add
scorewhen budgets exist, and say what the budget is in the reply. - Over budget is allowed. On submit, read
totalsand point out any overrun before moving on. - On
dismiss, drop the flow and ask what the user would like instead.
How it works
| Behavior | Details |
|---|---|
| Totals | Each metric combines the selected options with its aggregate: sum, max, min or avg. Options without a value for a metric don't take part. An empty selection sums to 0; max, min and avg show "—" |
| Budgets | With better: "lower" the budget is a ceiling ("of 500 ms", then "Over budget by 81 ms"); with "higher" it's a floor ("Target 99.9", then "Below target by …"). The warning is text plus an icon, never color alone |
| Score | Every budgeted metric scores 100 at or under half its budget, falls linearly to 70 at the budget and to 0 at twice the budget (inverted for "higher"). The score is the rounded average; grades are A ≥ 90, B ≥ 80, C ≥ 70, D ≥ 60, F below. Exactly on budget is a C |
| Requires | Selecting an option selects everything it requires, transitively ("Adds Auth service"); the dependency shows "Included with Session store". Removing a dependency removes what needed it. Cycles are safe |
| Excludes | Works both ways. A conflicting option is faded and disabled, with the reason in a tooltip ("Can't combine with Algolia Search"), including conflicts its own dependencies would bring in |
| Limits | selection.max caps the whole selection, dependencies included; a group's max caps that group. max: 1 turns options into radios, and picking another replaces the previous one |
| Continuing | The primary button is disabled until selection.min and every group's min are met; the footer says what's missing ("Choose CPU"), and once that's met it sums up the choice ("3 selected · 368 ms · 329 KB"). It reads "Next" while steps.current < steps.total, otherwise "Done", unless submitLabel is set. Cancel dismisses |
Selections persist across tabs, and each change is announced in a polite live region, e.g. "Added Mapbox GL. Load time 582 ms, over budget by 82 ms. Size 611 KB, over budget by 99 KB. Score 57, grade F." The pure helpers (aggregation, budgets, score, dependency resolution, formatting) live in option-builder-utils.ts so the host can reuse them, e.g. to recheck totals on the server.
Installation
npx shadcn@latest add https://your-site.com/r/option-builder.json
Usage
import { tool } from "ai"
import { optionBuilderTool } from "@/components/genui/option-builder.schema"
const tools = {
// No execute(): the call is completed by the user's choice in the UI.
[optionBuilderTool.name]: tool({
description: optionBuilderTool.description,
inputSchema: optionBuilderTool.inputSchema,
}),
}import { OptionBuilder } from "@/components/genui/option-builder"
function ToolPart({ part, addToolResult }) {
if (part.type !== "tool-build_selection") return null
return (
<OptionBuilder
{...part.input}
status={part.state === "input-streaming" ? "streaming" : undefined}
result={part.state === "output-available" ? part.output : undefined}
onResult={(result) =>
addToolResult({
tool: "build_selection",
toolCallId: part.toolCallId,
output: result,
})
}
/>
)
}The agent receives one of:
{
action: "submit",
selected: ["northwind-vision", "contoso-speech", "fabrikam-search"],
totals: { load: 368, size: 336896 },
score: { value: 89, grade: "B" },
}
{ action: "dismiss" }selected includes dependencies that were added automatically. totals leaves out metrics with no value (e.g. max of nothing).
With LangGraph
Push it from a graph node with ui.push({ name: "build_selection", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer.
Restoring from history
Pass the stored result back as result. The builder shows the submitted totals, score and selected options (or the closed state) read-only, and never calls onResult again.
<OptionBuilder
{...part.input}
result={part.state === "output-available" ? part.output : undefined}
/>Schema
| Prop | Type | Notes |
|---|---|---|
kind | "apis" | "add-ons" | "hardware" | "other"? | What is being built. Shown as a coloured icon beside the title |
title | string | What the user is building |
description | string? | One short line under the title. Budgets already show on the totals, so don't repeat them here |
metrics | { id, label, unit?, format?, currency?, aggregate, budget?, better }[] | 1–4. format: number, currency, bytes (values in bytes, 1 KB = 1,024), duration-ms. budget is data-bound |
score | { label?, formula: "budget" }? | 0–100 score and grade from the budgets |
groups | { id, label, min?, max?, options }[] | 1–6; tabs when more than one |
groups[].options | { id, name, logo?, description?, metrics, badges?, disabled?, requires?, excludes? }[] | metrics (value per metric id) is data-bound. logo is { url, darkUrl?, alt? } from the catalog, shown in a tile beside the name (white in light mode; dark, with darkUrl, in dark mode); badges ≤ 2; disabled is the reason |
selection | { min?, max? } | min defaults to 1 |
steps | { current, total }? | Shows "Step 1 of 3" and picks Next vs Done |
defaultSelected | string[]? | Option ids to start with |
submitLabel | string? | Primary button label |
Host props: status (streaming, ready, submitting, submitted, dismissed), onResult, result and className.