7.3 KiB
"use generative" — specification
Problem
A tool has three regions of code with three different deployment targets:
| region | server (registration + agent loop) | client (browser) |
|---|---|---|
description / parameters |
needed (→ LLM, parse) | needed (→ parse) |
render |
must not load (React/CSS/DOM) | needed |
execute |
depends on kind (see routing) | depends on kind |
We want to colocate all three in one source file for DX, but keep render's
client deps out of the server bundle and — more importantly — keep a backend
execute's server deps (DB handles, API keys, server SDKs) out of the client
bundle. The second direction is a security boundary, not just bundle hygiene.
"use client" cannot express this: it is whole-module, so a "use client"
generative module would also turn parameters into a client reference on the server,
making the zod schema unreadable server-side. We need sub-module, per-property
routing. That is what the "use generative" directive provides.
The directive
A module opts in with a leading directive and a single default export wrapped in
defineToolkit:
"use generative";
import { z } from "zod";
import { defineToolkit } from "@assistant-ui/react";
import { db } from "@/db"; // server-only dependency
import { Chart } from "@/ui/chart"; // client-only dependency
export default defineToolkit({
weather: {
description: "Show the weather for a city.",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => db.weather.get(city), // backend (server-only)
render: (props) => <Chart data={props} />, // client-only
},
});
The file is imported from both server and client code; the compiler emits a different module per build target so each side only loads what it needs.
Routing (by inferred kind)
A tool's kind is not authored — declaring a type field is a type error. The
compiler infers it from the execute and writes the resolved type back into
each emitted tool object (so the runtime keeps it):
| how it's authored | inferred kind | render |
execute |
|---|---|---|---|
execute with a "use client" directive |
frontend |
client | client (bundled with render) |
execute (plain) |
backend |
client | server (server-only leaf) |
execute: humanTool() |
human |
client | — (dropped; the UI resolves it) |
Consequences:
- For
frontendentries the server keeps schema only — render and execute are client concerns. backendis the only kind that produces the server-only secrets boundary; itsexecuteleaf importsserver-only, so any routing mistake that pulls it into the client build fails the build instead of leaking secrets.- Server-by-default is the safe default: a plain
executestays server-only, so a forgotten marker can't leak — you opt into the client with"use client". A frontendexecute's"use client"is stripped from the output (the module already carries it).
Compile targets
The compiler produces two self-contained rewrites of the source, selected by the bundler per build layer. Each rewrite keeps only the relevant regions and prunes the imports that became unused, so a dropped region's dependencies disappear.
client target
- Keep
description,parameters,render, andexecuteoffrontendtools. - Drop
executeofbackendtools (and its now-unused imports). - Prepend
"use client"when anyrenderremains.
server target
- Keep
description,parameters, andexecuteofbackendtools. - Drop every
render(and its now-unused imports). - Drop
executeoffrontendtools. - Prepend
import "server-only"when any backendexecuteremains.
The "use generative" directive is stripped from both outputs.
Bundler integration
Wrap the Next config with withAui from @assistant-ui/next
(no filename convention — modules are matched by the "use generative" directive,
and the loader passes non-generative files through untouched). It applies ./loader,
a webpack/Turbopack loader.
The loader rewrites a bare generative import into a facade that delegates the
build choice to a react-server-conditioned package subpath, so a single import
resolves to the right build per layer — no query, no per-app config (see
DESIGN.md for the mechanism):
- route handler / RSC (
react-serverON) → server build (schema +execute, guarded byserver-only) - client component, SSR + browser (
react-serverOFF) → client build (schema +render)
The concrete compile is keyed off an internal ?generative-env=server|client
query the facade generates — it is never authored by consumers, so no ambient
module declaration is needed. (Clear .next after changing the loader —
Turbopack caches loader output aggressively.)
Why not infer the target from the build layer inside the loader? Turbopack
compiles one output per resource path and does not give a loader a per-layer
module instance — so the split must happen at resolve time (the react-server
export condition), which is exactly what the facade routes through.
Consumption
Both sides import the module bare; the facade resolves each to the right build:
- server: import
./x.generativein a route handler — it resolves to the server build (schema +execute). With the AI SDK,await new AISDKToolkit({ toolkit }).tools({ frontend })from@assistant-ui/react-ai-sdkconverts it into aToolSetwhoseexecuteruns in the route, merging in the frontend-uploaded tools. - client: import
./x.generativein a client component — it resolves to the client build (schema +render) — and register its tool UI.
Neither side ships the other's code, and the schema is never re-uploaded from the client per request — the server owns it.
Authoring constraints (enforced, with errors)
- A leading
"use generative"directive. - A single
export default defineToolkit({ ... })(the wrapper is required; optionally insidesatisfies/as). No other exports. - Every tool must declare an
execute. Its form determines the kind:humanTool()→ human; a leading"use client"directive → frontend (needs a block body, not an expression body); otherwise backend.typeis never authored. render/executemust be inline functions that close over module imports only, so they can be routed/pruned without dragging local scope.
Known limitations (v1)
- Bare side-effect imports (e.g.
import "./styles.css") cannot be attributed to a region by reference analysis, so they are left untouched in both targets. - Output preserves TS/JSX; the loader must run before the bundler's TS/JSX pass (the default in Next).
- Turbopack honors a loader-emitted
"use client"directive (validated on Next 16.2.6), but does not give a loader per-layer module instances — hence the?generative-env=serverquery rather than layer inference. Clear.nextafter changing the loader.