The expr tool
The expr tool runs expressions. The model sends steps, and the browser runs
them on the state of the UI the model wrote.
So the model can change what is on screen. After a table of products, “select
the five lowest” marks five rows, and “download them” calls a download function
with those rows. By default no value goes back to the model; with
read on, a call can also read one.
It has two halves:
exprTool()from@uicast/core/tool: the tool you give the model on the server, aStandardToolV0. It has noexecuteof its own.useExprTool()from@uicast/react: runs each call in the browser and returns the answer.
What the model sends
A call holds steps, which run as a callback’s steps do: each one
computes a value and may write it to a field. With read on, it may also hold
read, an expression whose value comes back after the steps:
{
"steps": [
{
"set": "scopes.root.selected",
"expr": "scopes.root.products.items.slice(0, 5).reduce((m, p) => ({ ...m, [p.id]: true }), {})"
}
],
"read": "Object.keys(scopes.root.selected).length"
}- Steps run under the same rules as in a callback: the same budget, the same
setaddresses, and a step withconfirmasks the user first. - A step with no
setruns for its effect, such as a host-function call:downloadCsv({ rows: scopes.root.products.items.filter(p => scopes.root.selected[p.id]) }). - Only
scopes.rootis reachable. There is noevtand no row scope. - The tool changes state, not structure. A write shows on screen only where an element reads that path. To add a column, the model sends new entries instead.
Its own functions
By default the tool calls the same host functions as the UI. A UI can keep a
write behind a button and a confirm; the tool has no button to press. To give
it fewer functions, or others, pass them to both halves:
const exprFunctions = [listCustomers, getCustomer, downloadCsv]; // reads and a download, no writes
exprTool({ functions: exprFunctions }); // on the server
useExprTool({ functions: exprFunctions }); // in the browser- The server describes them in the tool’s own text, in the format of
getFunctionsPartialPrompt(), and tells the model they replace the ones in the system prompt. - The browser runs the tool’s calls on
evaluator.withFunctions(exprFunctions): a copy of the provider’s evaluator with these functions. A call that names one of the UI’s other functions getsunknown-referencebefore any step runs. - A function the UI doesn’t have works too: the tool can call it, the UI can’t.
functions: []gives the tool no functions at all.
Keep the array the same across renders, as a module-level constant: a new one makes a new evaluator.
What the model gets back
{}: the steps ran;{ value }: the value ofread, as JSON, at most 8,000 characters, only withreadon;{ declined: true }: the user cancelled aconfirm, so that step and the ones after it did not run;{ error: { reason, message } }: the reason of the step or read that failed, or of an input the schema refused, for the model to fix the call.
Reading values back
read is off by default. Turn it on in both halves:
exprTool({ read: true }); // on the server
useExprTool({ read: true }); // in the browserWithout it, the tool’s schema and text have no read, and the browser refuses a
call that sends one with invalid-entry, before any step runs. Errors still go
back, so the model can fix a call.
With read on, data on screen reaches the model. A generated screen sends
nothing back: the values its expressions read stay in the browser. The value
of read is the tool’s output, so it goes to the model’s provider and stays
in the conversation.
- Anything under
scopes.rootcan be read, including what host functions loaded into it. functionslimits what the tool can call, not what it can read.- Keep
readoff where the data on screen must not reach the model.
The server
The tool’s description holds only its own rules. The system prompt carries the
rest, as for any document: the core partials describe steps and expressions, and
getFunctionsPartialPrompt({ functions }) lists the host functions a step
may call. Pass it the same functions the browser’s Evaluator binds.
exprTool({ read, functions, note }) takes the
read switch, the tool’s
own functions, and appends note as a ## Note. With the
AI SDK as the example:
import { exprTool } from "@uicast/core/tool";
import { streamText, tool } from "ai";
const expr = exprTool(); // module scope
const tools = {
[expr.name]: tool({ description: expr.description, inputSchema: expr.inputSchema, outputSchema: expr.outputSchema }),
};
streamText({ model, system, messages, tools });expr.inputSchema and expr.outputSchema implement Standard Schema and
Standard JSON Schema, so an SDK that takes either accepts them. Calling
expr.execute() throws: the tool runs only in the browser.
The AI SDK checks each call’s input against inputSchema on the server, so an
input the schema refuses never reaches the browser. By default the model then
reads only “An error occurred.”; to let it fix the call, return the refusal’s
message from onError of toUIMessageStream, as the
example app does.
The browser
useExprTool() returns a function that runs one call and resolves with the
answer. Call it below <RendererProvider>:
import { useChat } from "@ai-sdk/react";
import { useExprTool } from "@uicast/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
const runExprTool = useExprTool();
const { addToolOutput } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
onToolCall: ({ toolCall }) => {
if (toolCall.toolName !== "expr") return;
runExprTool(toolCall.input).then((output) =>
addToolOutput({ tool: "expr", toolCallId: toolCall.toolCallId, output }),
);
},
});When useChat sits above the provider, as in the
example app, a small component under the provider passes the
runner up through a ref.
State the tool writes lives in memory, like the user’s clicks: a reload starts from the seeds again.
Where to go next
- Event handling — the steps a call holds.
- Streamdown plugin — UI inside Markdown chat replies, where the tool is most often used.
- Host functions — the functions a step may call.