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)]))salesre-runs whenever$rangechanges. Results are cached per argument set.default:[]is what renders until the first result arrives.every:60refreshes every 60 seconds (the minimum interval is 5 seconds).- A query never runs while its own statement is still streaming.
save.statusisidle,pending,successorerror, 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.