Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

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 frontend entries the server keeps schema only — render and execute are client concerns.
  • backend is the only kind that produces the server-only secrets boundary; its execute leaf imports server-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 execute stays server-only, so a forgotten marker can't leak — you opt into the client with "use client". A frontend execute'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, and execute of frontend tools.
  • Drop execute of backend tools (and its now-unused imports).
  • Prepend "use client" when any render remains.

server target

  • Keep description, parameters, and execute of backend tools.
  • Drop every render (and its now-unused imports).
  • Drop execute of frontend tools.
  • Prepend import "server-only" when any backend execute remains.

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-server ON) → server build (schema + execute, guarded by server-only)
  • client component, SSR + browser (react-server OFF) → 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.generative in 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-sdk converts it into a ToolSet whose execute runs in the route, merging in the frontend-uploaded tools.
  • client: import ./x.generative in 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)

  1. A leading "use generative" directive.
  2. A single export default defineToolkit({ ... }) (the wrapper is required; optionally inside satisfies / as). No other exports.
  3. 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. type is never authored.
  4. render / execute must 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=server query rather than layer inference. Clear .next after changing the loader.