Skip to Content
ExpressionsHost functions

Host functions

A document reaches no API on its own. Host functions are functions you supply, called by name from expressions. Each is a standard-tool (StandardToolV0).

execute runs where the renderer runs (the browser, with the React binding), so reach your data over HTTP: a database client would ship its credentials to the client.

Anatomy of a host function

Build one with standardTool({...}):

import { standardTool } from "standard-tool"; import { z } from "zod"; const suggestPalette = standardTool({ name: "suggestPalette", description: "Given a base hex color, return a small harmonious palette of hex strings.", inputSchema: z.object({ hex: z.string() }), outputSchema: z.array(z.string()), async execute({ hex }) { return harmonize(hex); }, });
  • name: what the model calls, a valid JS identifier.
  • description: what it does; the model reads it to decide when to call.
  • title (optional): a human label printed before the description. The model can reuse it for a button or heading.
  • inputSchema (optional): the single argument, validated on each call and printed as the signature in the prompt. Omit it for no argument.
  • outputSchema (optional): the same for the return value. Omitted means undeclared, not returns nothing: see Undeclared returns.
  • execute(input): your logic, sync or async. See Errors.

Schemas are StandardSchemaV1 & StandardJSONSchemaV1: Zod 4.2+, Valibot and ArkType qualify.

Errors

A failed call is classified. Arguments your input schema rejects are invalid-arguments, a document fault the recovery loop can feed back to the model. Anything else is a host-function failure, the host’s. Both reach onError; a failed seed also shows the error slot. In a callback, later steps do not run, and earlier writes stay.

Wiring into the renderer

Bind your tools on the evaluator and pass it to <RendererProvider evaluator={...}>:

import { Evaluator } from "@uicast/expr"; import { EntriesRenderer, RendererProvider } from "@uicast/react"; import { impls } from "@uicast/shadcn-catalog/all/impls"; import type { StandardToolV0 } from "standard-tool"; const tools: StandardToolV0[] = [ listProducts, createProduct, deleteProduct, ]; const evaluator = new Evaluator({ functions: tools }); <RendererProvider implementations={impls} evaluator={evaluator} > <EntriesRenderer entries={entries} /> </RendererProvider>;

Create the evaluator once, as a module constant. It holds the parse cache, and a new instance per render re-renders the whole tree.

How the model calls them

Each tool is in scope under its name and takes one argument: what inputSchema declares, or none without a schema. An object schema means an object:

deleteProduct({ id: scopes.row.id }) // ✅ inputSchema: z.object({ id: … }) deleteProduct(scopes.row.id) // ❌ same tool, wrong shape

A scalar schema works too: with inputSchema: z.string() the call is deleteProduct(scopes.row.id). An object gives each field a name and a description the model can read.

listProducts() passes undefined, which a bare z.object rejects even when every field is optional. Declare the input as schema.optional() to allow the zero-argument call.

A tool named scopes, evt or currentValue would shadow the engine’s own bindings, so prompt assembly refuses those names, and duplicates:

standardTool({ name: "scopes", /* … */ }); // ❌ Host function name "scopes" is reserved standardTool({ name: "evt", /* … */ }); // ❌ reserved standardTool({ name: "currentValue", /* … */ }); // ❌ reserved standardTool({ name: "listOrders", /* … */ }); // ✅ anything else is fine

Where they run: seed and callbacks

standardTool wraps execute in an async function, so a call always returns a Promise. It can only be called where the engine awaits it:

  • seed: load data on mount. The element shows its skeleton until every async seed resolves:

    { "key": "table", "component": "Table", "seed": [{ "set": "scopes.root.rows", "expr": "listProducts()" }] }
  • callbacks: change something on an event. The result is written to the step’s set path:

    { "key": "suggest", "component": "Button", "callbacks": { "onClick": [ { "set": "scopes.root.swatches", "expr": "suggestPalette({ hex: scopes.root.base })" } ] } }

props, hidden, loading and each run synchronously on every render, so a host call there goes to the element’s error slot. Derived values in props, effects in seed and callbacks.

Mutate, then re-fetch

A mutation returns only what it changed, so call it, then re-read in the next step:

{ "key": "del", "component": "Button", "callbacks": { "onClick": [ { "confirm": "Delete this product?", "expr": "deleteProduct({ id: scopes.row.id })" }, { "set": "scopes.root.rows", "expr": "listProducts()" } ] } }

Step one has no set: it runs for its effect. A host-function step is a barrier, so the re-fetch waits for the delete, and confirm gates both. Ordering in full: Reactivity.

Many items at once

A host call cannot sit inside an arrow function, such as the one .map(...) takes, so one step cannot call a function once per item. For an action on many items, give the model a function that takes the list:

updateProducts({ items: scopes.root.products.map(p => ({ id: p.id, qty: p.qty + 1 })) }) // ✅ one call scopes.root.products.map(p => updateProduct({ id: p.id, qty: p.qty + 1 })) // ❌ a call per item

Windows and aggregates

A function that can return a lot should take a window, declared in its schema:

standardTool({ name: "listProducts", description: "A page of products, by name by default.", inputSchema: z.object({ limit: z.number().int().min(1).max(200).default(50).meta({ description: "Rows to return." }), offset: z.number().int().min(0).default(0).meta({ description: "Rows to skip." }), q: z.string().optional().meta({ description: "Matches the name, case-insensitive." }), }).optional(), outputSchema: z.object({ items: z.array(product), total: z.number().int().meta({ description: "Rows matching the filters, all pages." }), }), execute: (input) => fetchProducts(input ?? {}), });

The prompt prints each field’s bounds and defaults, and tells the model to fetch only the slice it renders, to refetch when the page or a filter changes, and never to filter or sort a fetched slice in an expression. Return the total with the rows: a pager needs Math.ceil(total / limit). Put other totals in a function of their own, such as getSalesSummary({ days }).

Advertising to the model

getFunctionsPartialPrompt from @uicast/core/prompt tells the model about your functions:

import { getFunctionsPartialPrompt } from "@uicast/core/prompt"; getFunctionsPartialPrompt({ functions: [ standardTool({ name: "listProducts", description: "Return every product, newest first.", outputSchema: z.array(z.object({ id: z.number().int(), name: z.string() })), execute: async () => (await fetch("/api/products")).json(), }), standardTool({ name: "deleteProduct", description: "Delete a product by id.", inputSchema: z.object({ id: z.number().int().meta({ description: "Product id." }) }), execute: ({ id }) => fetch(`/api/products/${id}`, { method: "DELETE" }), }), ], });

renders exactly:

# Available Functions listProducts, deleteProduct ## Function Details - listProducts() => { id: number /* integer */; name: string; }[]: Return every product, newest first. - deleteProduct({ id: number /* Product id. integer */; }) => unknown: Delete a product by id.

A title prints before the description: name(params) => output: title — description. A field’s comment carries its .meta({ description }), then what the type alone does not say: integer, bounds, lengths, a format, a default, as in limit?: number /* integer, ≥ 1, ≤ 200, default 50 */.

Never declare a schema for “nothing”: z.void() has no JSON Schema form and throws.

Undeclared returns

=> unknown means nobody published this shape. The prompt tells the model it may call the function for its effect but must not read fields off the result:

// what the prompt printed for the two: // deleteProduct({ id: number }) => unknown // listProducts() => { id: number; name: string }[] // ✅ delete for the effect, then refetch through the declared one [ { "expr": "deleteProduct({ id: scopes.row.id })" }, { "set": "scopes.root.products", "expr": "listProducts()" } ] // ❌ store the `unknown` result and read fields off it { "set": "scopes.root.result", "expr": "deleteProduct({ id: scopes.row.id })" } // … later: scopes.root.result.remaining — nothing ever said `remaining` exists

A step with no set is legal only in callbacks, so a function returning unknown has no place in a seed.

outputSchema matters more here than in ordinary tool calling: the model writes scopes.root.products[0].name into an expression that runs later, on data it never sees.

Shared types

A schema with an id prints once, as a type, and is referenced by name everywhere else. In Zod, that is .meta({ id }):

const Person = z .object({ id: z.string(), name: z.string().optional() }) .meta({ id: "Person" });
## Function Details - getOwners() => { owner?: Person; reviewer?: Person; }: Owners. - listTree() => { root?: Node; }: Whole tree. ## Shared Types - Person: { id: string; name?: string } - Node: { name?: string; children?: Node[] } — A folder or file.

Without .meta({ id }), Person is inlined at both sites. A recursive type needs a name: inlined, its self-reference stops at the cycle guard and prints unknown[], which says the tree is one level deep.

Components hoist their own types into a separate ## Shared Types block; nothing crosses between the two.

Where to go next

Last updated on