Skip to content

Tools and live data

Let a generated UI read your data with @query and change it with @mutation, and keep the two kinds of tool apart.

Updated View as Markdown

A model can write an answer with the numbers baked in. It can also write an answer that reads its data from your app: a dashboard that refetches when a filter changes, a table that refreshes every minute. That is what tools and mutations are for.

Two kinds of tool

tools mutations
Called by @query("name", args) @mutation("name", args) through @run
Runs As soon as the UI renders, and again when its arguments change Only when a person presses a button
Must be Read-only Anything: this is where changes go

The split is a safety rule, not a convention. A program is untrusted, and rendering it must never change anything. So only functions you list under mutations can be reached by a button, and nothing under tools should have a side effect.

Give the renderer your functions

<GistUI
  library={ui}
  stream={stream}
  tools={{
    get_sales: async ({ range }) => db.sales(range), // read-only
  }}
  mutations={{
    update_goal: async ({ goal }) => db.setGoal(goal), // runs on a button press
  }}
  onToolCall={(call) => {
    audit.log(call); // { name, args, kind, nodeId }
    return call.name !== "delete_everything"; // false blocks the call
  }}
/>

tools can also be an MCP-style client: any object with callTool({ name, arguments }).

Tell the model they exist

const system = prompt({
  tools: [
    { name: "get_sales", args: "{range:str}" },
    { name: "update_goal", args: "{goal:num}" },
  ],
}).text;

The tool list goes at the end of the prompt, so the part before it stays cacheable.

What the model writes

$range = "30d"
root = Page(Header("Sales"), filter, Card(Chart(sales, type:line)), goal)
filter = Select("range", "Range", ["7d", "30d", "90d"], bind:$range)
sales = @query("get_sales", {range:$range}, default:[], every:60)
save = @mutation("update_goal", {goal:$goal})
goal = Row(Slider("goal", "Goal", min:0, max:100, bind:$goal), Button("Save", do:[@run(save), @run(sales)]))
  • sales re-runs whenever $range changes. Results are cached per argument set.
  • default:[] is what renders until the first result arrives.
  • every:60 refreshes every 60 seconds (the minimum interval is 5 seconds).
  • A query never runs while its own statement is still streaming.
  • save.status is idle, pending, success or error, so the program can show a spinner or a message.
  • @run(sales) after a save re-fetches the query.

Expressions over the data

A query result (or a pipe table) is a list of rows, and the language has a small set of built-ins to work with it:

total = "Total: " + @fmt(@sum(sales.total), "$")
big = @each(@filter(sales, s => s.total > 1000), s => Tile(s.customer, value:@fmt(s.total, "$")))

sales.total plucks one field from every row. The full list is in State and expressions.

Limits

The runtime bounds what a program can do, whatever it writes: at most 60 tool calls in any 10 seconds, at most 20 queries with a refresh timer, at most 5,000 items in one @each. The full table is in the format’s limits.

Pausing

paused stops timed refreshes without unmounting the UI. @gistui/chat uses it so only the latest answer in a thread keeps refreshing.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close