Calculator

A single-use calculator the agent builds from inputs and formulas. It never writes code.

Pattern

≈ 2,018 tokens per use (tool 1,404 · guidance 353 · typical payload 261)How token costs are estimated

Example: Calculator

How much could my Roth IRA grow?

Illustrative Roth IRA growth

Projected balance after 30 years$416,129
Your contributions
$180,000
Investment growth
$236,129
Monthly contribution
Years invested
Assumed annual return

Fixed return, compounded monthly. Not investment advice.

When to use

  • The answer depends on numbers the user may want to adjust: headcount, budget, rate, duration.
  • The user is exploring a trade-off, such as a budget split, a recipe scale or a loan term.

When not to use

  • All the inputs are known and fixed. Calculate the answer and say it in a sentence.
  • You're unsure of the right formula. Explain the method in text instead.
  • The numbers have to come from live data that the formulas can't see. Fetch the data first.

How it works

The agent sends inputs, each with a control (stepper, slider, number, choice or toggle), and outputs, each with a formula. Every output updates as the user changes an input. Later outputs can use earlier ones:

{
  "inputs": [{ "id": "bill", "label": "Bill", "default": 186.4 }],
  "outputs": [
    { "id": "tipAmount", "label": "Tip", "formula": "bill * tip / 100" },
    { "id": "total", "label": "Total", "formula": "bill + tipAmount" }
  ]
}

Formulas run in a small, safe language, not JavaScript. There's no eval, no property access and no strings, so a model can't run code through a formula.

Supported
Operators+ - * / % ^, plus < > <= >= == != (comparisons return 1 or 0)
Functionsmin, max, round(x, digits), floor, ceil, abs, sqrt, pow, clamp(x, lo, hi), if(condition, then, else)
NamesInput IDs and earlier output IDs

If a formula refers to something unknown, that one output shows a formula error instead of a wrong number. Results that aren't defined, like division by zero, show "—".

Layout

The title sits small and quiet. Outputs are tiles on a grid that never leaves one tile alone on a row: up to four share a row, more split evenly (5 → 3+2, 6 → 3+3), and on a narrow card they go two to a row with the primary output on its own row when the count is odd. Each tile is a large number with its label as a short caption above it. An output label can include an input ID in braces, which shows that input's current value, so "Projected balance after {years} years" reads "Projected balance after 30 years" and follows the slider.

A breakdown splits the primary output into two or three parts, drawn as a stacked bar with each part's label and value under it (first on the left, last on the right). The labels carry the meaning, so the bar never relies on color alone:

{
  "id": "balance",
  "label": "Projected balance after {years} years",
  "formula": "if(rate == 0, monthly * 12 * years, monthly * ((1 + rate / 1200) ^ (12 * years) - 1) / (rate / 1200))",
  "primary": true,
  "breakdown": {
    "parts": [
      { "label": "Your contributions", "formula": "monthly * 12 * years" },
      { "label": "Investment growth", "formula": "balance - monthly * 12 * years" }
    ]
  }
}

Bands name the ranges an output falls into. The tile shows the current band as a small tag, and a segmented scale under the tiles fills the current band and marks the value. A band's optional tone (good, caution, bad) colours it; the band names stay visible, so colour is never the only signal. Use bands instead of an output that encodes categories as numbers ("1 under, 2 healthy…"):

{
  "id": "bmi",
  "label": "Your BMI",
  "formula": "weight / (height / 100) ^ 2",
  "primary": true,
  "bands": [
    { "label": "Under", "below": 18.5, "tone": "caution" },
    { "label": "Healthy", "below": 25, "tone": "good" },
    { "label": "Over", "below": 30, "tone": "caution" },
    { "label": "Obese", "tone": "bad" }
  ]
}

Part formulas use the same language and can read any output. Negative parts get no width in the bar but still show their value. Below a divider come the inputs. A slider shows its label on the left, its value in bold on the right, a thick track, and ticks under it: min, a round middle and max by default (0–625 shows "$0 $300 $625"), or the values in ticks.

How the agent should use it

  • Start from what the user said. Default every input to their numbers, and only expose inputs they might change.
  • Mark one output as primary. It's shown large at the top of the card, above the inputs, and it should be the answer to their question. Its label is the caption, so write it as one short line.
  • Use a breakdown when the answer is a sum of parts (contributions and growth, principal and interest) instead of adding more outputs.
  • Use bands when the value falls into named ranges (a BMI category, a risk level), not a separate coded output.
  • Keep output labels to 2–4 words (40 characters at most). Never put a legend or a list of codes in a label.
  • Prefer sliders. Any value with a sensible range (weight, height, age, amounts, rates) gets a slider with min, max and step around the user's value; use a number field only when no range makes sense.
  • Give every output a format or a unit, and round sensibly. Inputs take decimals too, e.g. 0 for whole dollars.
  • Keep assumptions to one short line. They're joined into one quiet line at the bottom of the card, so state only what changes the decision (e.g. "Assumes ROAS stays flat as spend grows."). Present results as estimates, not financial advice.
  • description is not shown. It's kept for screen readers; put anything the user must see in the title or the inputs.
  • Add submitLabel when the numbers should feed what you do next, like "Use this split" before a Change Preview.
  • Lifecycle: usually nothing to set. If defaults come from live data (rates, balances), set fetchedAt and staleAfterMs.

Installation

npx shadcn@latest add https://your-site.com/r/calculator.json

This adds the component, its schema and calculator-formula.ts, the formula engine.

Usage

app/api/chat/route.ts
import { tool } from "ai"
import { calculatorTool } from "@/components/genui/calculator.schema"

const tools = {
  [calculatorTool.name]: tool({
    description: calculatorTool.description,
    inputSchema: calculatorTool.inputSchema,
  }),
}
components/message.tsx
import { Calculator } from "@/components/genui/calculator"

function ToolPart({ part, addToolResult }) {
  if (part.type !== "tool-build_calculator") return null

  return (
    <Calculator
      {...part.input}
      status={part.state === "input-streaming" ? "streaming" : undefined}
      result={part.state === "output-available" ? part.output : undefined}
      onResult={(result) =>
        addToolResult({
          tool: "build_calculator",
          toolCallId: part.toolCallId,
          output: result,
        })
      }
    />
  )
}

Without submitLabel, the calculator is the whole answer, so give the tool an execute that returns immediately. With it, the agent receives:

{
  action: "submit",
  inputs: { budget: 10000, metaShare: 70, metaRoas: 2.4, googleRoas: 3.1 },
  outputs: { metaSpend: 7000, googleSpend: 3000, revenue: 26100, blended: 2.61 },
}

With LangGraph

Push it from a graph node with ui.push({ name: "build_calculator", props: { toolCallId, ...args } }, { message }), and render the thread's UI messages with the Renderer.

Restoring from history

When a conversation is reloaded, pass the stored result back as result. The component renders its finished state (the numbers that were sent) and never calls onResult again, so a decision can't be made twice. With the AI SDK, that's the tool call's output:

<Calculator
  {...part.input}
  result={part.state === "output-available" ? part.output : undefined}
/>

Schema

PropTypeNotes
titlestringWhat it calculates
descriptionstring?Screen readers only, e.g. where the defaults come from
inputsInput[]1–6 inputs
outputsOutput[]1–8 outputs
assumptionsstring[]?Up to 4, joined into one line at the bottom. Keep it to one short line
submitLabelstring?Adds a button that sends the numbers back

Input

FieldTypeNotes
idstringName used in formulas
labelstring
controlstepper | slider | number | choice | toggleDefault slider. toggle is 1 (on) or 0 (off)
defaultnumber
min, max, stepnumber?A slider without bounds brackets its default, e.g. 175 → 0–400
ticksnumber[]?Sliders only. 2–5 values marked under the track. Default min, a round middle and max
formatnumber | currency | percentpercent shows 12.5 as 12.5%
currencystring?ISO 4217 code
decimalsnumber?0–4 digits shown
unitstring?e.g. "people"
options{ label, value }[]2–6, required for choice

Output

FieldTypeNotes
idstringLater formulas can use it
labelstring2–4 words, 40 characters at most. May include an input ID in braces, e.g. {years}, filled with its current value
formulastringSee the table above
format, currency, decimals, unitDisplay
primaryboolean?Mark exactly one
breakdown{ parts: { label, formula }[] }?2–3 parts of the primary output, shown as a stacked bar. Only drawn on the primary output
bands{ label, below?, tone? }[]?2–6 named ranges, lowest first; below is where each ends (omit on the last); tone is good, caution or bad. Shown as a tag and a scale; one scale per calculator

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

Source

On this page