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 shapeA 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 fineWhere 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’ssetpath:{ "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 itemWindows 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` existsA 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
- Renderer — the evaluator and the rest of the mount setup.
- Entry fields —
seedandcallbacks, where functions are called. - The expression evaluator — the language the calls are written in.