Skip to Content
The expr tool

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, a StandardToolV0. It has no execute of 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 set addresses, and a step with confirm asks the user first.
  • A step with no set runs for its effect, such as a host-function call: downloadCsv({ rows: scopes.root.products.items.filter(p => scopes.root.selected[p.id]) }).
  • Only scopes.root is reachable. There is no evt and 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 gets unknown-reference before 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 of read, as JSON, at most 8,000 characters, only with read on;
  • { declined: true }: the user cancelled a confirm, 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 browser

Without 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.root can be read, including what host functions loaded into it.
  • functions limits what the tool can call, not what it can read.
  • Keep read off 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

Last updated on