Documentation source
Components Runtime — the @/runtime palette for agent-authored components
Agent-facing reference for the curated @/runtime palette, the action allowlist, the pure-render contract, and the constraints.
## Overview
This is the agent-facing reference for the `@/runtime` palette — the curated
surface of imports available inside `manageComponent.compile` (module kind). The
palette is intentionally small: shadcn primitives, a handful of platform
composites, the action hook, formatting utilities, and the React core hooks. If a
component you want to write needs something that isn't in the palette, the
component should be expressed as a [declarative Spec](/docs/features/interactivity)
or extended through the Spec system. The palette is the contract.
The palette is typed via `lib/runtime/runtime-types.d.ts`. This file ships with
the platform and is the single source of truth for what compiles inside the
iframe.
## Hard rules
These constraints are enforced by the runtime, the AST guards, and the iframe's
CSP. Code that violates them is rejected at `manageComponent.compile` time, or
rendered as an error inside the host.
1. **Pure-render only.** Data flows in via `surfaceProps` (one of the
`ArtifactSurfaceProps` variants). No data-fetching hooks in the palette — no
`useQuery`, no `useSWR`, no `fetch()`, no `XMLHttpRequest`. The iframe's
`connect-src 'none'` CSP makes network access impossible anyway.
2. **No router, no global navigation.** No `next/link`, no `next/router`, no
`useRouter`, no `Link`. Navigation is an action: `useAction("navigate")`.
3. **No global access.** No `window.X = …`, no `Object.assign(window, …)`,
no `Object.defineProperty(Object.prototype, …)`. AST guards reject these
patterns at compile time.
4. **No persistent storage.** No `localStorage`, no `sessionStorage`, no
`IndexedDB`. The iframe is rebuilt on every mount; persistent state belongs
to the parent (which owns React Query, the database, and the URL).
5. **Action allowlist.** Only six actions can be invoked via `useAction`:
`navigate`, `updateField`, `submitForm`, `openRecord`, `refreshData`,
`copyToClipboard`. Calling `useAction("anything-else")` returns a function
that rejects.
6. **Source size cap.** TSX source maxes out at **102_400 bytes**. Larger
components must split into two artifacts and compose.
7. **Default export.** The agent's TSX must `export default function …` — that is
the entry point react-runner mounts. Named exports are ignored.
## Worked example
The component receives the active surface variant via `surfaceProps`. It composes
palette primitives + composites and uses `useAction` to dispatch user intent
back to the host.
```tsx
import {
Card,
CardHeader,
CardTitle,
CardContent,
KpiStat,
DataTable,
Button,
useAction,
useState,
useMemo,
formatCurrency,
} from "@/runtime"
// surfaceProps is typed as the ArtifactSurfaceProps discriminated union;
// for entity-card mounts, the variant is { surface: 'entity-card', entity, fields }.
export default function OpportunityCard({ surfaceProps }) {
// Local UI state — survives parent re-render via cached component reference.
const [filter, setFilter] = useState("")
// Bound to the host's "openRecord" handler — opens the record's detail page.
const open = useAction("openRecord")
// Bound to the host's "refreshData" handler — re-fetches the entity's data
// sources (parent React Query owns the cache).
const refresh = useAction("refreshData")
// Pure-render: derived state from props.
const opportunities = useMemo(() => {
if (surfaceProps.surface !== "entity-list") return []
return surfaceProps.entities.filter((e) =>
e.name.toLowerCase().includes(filter.toLowerCase()),
)
}, [surfaceProps, filter])
return (
<Card>
<CardHeader>
<CardTitle>Pipeline</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<KpiStat
label="Open"
value={opportunities.length}
trend="up"
delta="+12%"
/>
<KpiStat
label="Won"
value={formatCurrency(opportunities.reduce(
(sum, o) => sum + (o.fields.value ?? 0),
0,
))}
/>
<KpiStat label="Avg cycle" value="42d" />
</div>
<DataTable
rows={opportunities}
columns={[
{ key: "name", header: "Name", accessor: (r) => r.name, sortable: true },
{ key: "value", header: "Value", accessor: (r) => formatCurrency(r.fields.value ?? 0) },
{ key: "stage", header: "Stage", accessor: (r) => r.fields.stage ?? "—" },
]}
onRowClick={(row) => open({ id: row.id })}
/>
<Button onClick={() => refresh({})}>Refresh</Button>
</CardContent>
</Card>
)
}
```
What this example demonstrates:
- **Imports come exclusively from `@/runtime`.** Anything else won't resolve in
the iframe scope.
- **`surfaceProps` is the only data input.** When the parent's data updates, the
parent re-renders the host with new `surfaceProps`, the iframe receives a
`props-update` message, and React reconciles. Local state (`filter`,
selection, scroll) is preserved.
- **`useAction` is the only side-effect channel.** It returns a function that
posts to the host and resolves with the result (or throws on error). Validate
the action name against the allowlist; unknown names reject.
- **No data fetching.** Refresh is requested via `useAction("refreshData")`;
the parent owns React Query and re-fetches.
## Reference
Full surface from `lib/runtime/runtime-types.d.ts`:
### Primitives
shadcn-style primitives. Same visual contract as the host's `components/ui/*`,
but compiled into the runner with no provider dependencies.
| Export | Purpose |
| --- | --- |
| `Button` | Click target. Variants `default | destructive | outline | secondary | ghost | link`. Sizes `default | sm | lg | icon`. |
| `Card`, `CardHeader`, `CardContent`, `CardTitle`, `CardDescription` | Surface container with header + body slots. |
| `Input` | Text input. Controlled (`value`, `onChange`). |
| `Label` | Form label. Pairs with `htmlFor`. |
| `Badge` | Compact status pill. Variants `default | secondary | destructive | outline`. |
| `Skeleton` | Pulse placeholder for loading states. |
| `Separator` | Horizontal or vertical divider (orientation prop). |
### Composites
Platform-curated higher-order components. Pure-render variants of the host's
composites with zero context dependencies.
```ts
export interface KpiStatProps {
label: string
value: string | number
delta?: string
trend?: "up" | "down" | "flat"
className?: string
}
export function KpiStat(props: KpiStatProps): JSX.Element
export interface DataTableColumn<T> {
key: string
header: string
accessor: (row: T) => any
sortable?: boolean
}
export interface DataTableProps<T> {
rows: T[]
columns: DataTableColumn<T>[]
pageSize?: number
onRowClick?: (row: T) => void
}
export function DataTable<T>(props: DataTableProps<T>): JSX.Element
export interface EntityListItem {
id: string
name: string
description?: string
href?: string
icon?: any
}
export interface EntityListProps {
items: EntityListItem[]
onItemClick?: (item: EntityListItem) => void
emptyState?: any
className?: string
}
export function EntityList(props: EntityListProps): JSX.Element
export interface BarChartProps {
data: Array<Record<string, number | string>>
xKey: string
bars: Array<{ key: string; color?: string }>
height?: number
}
export function BarChart(props: BarChartProps): JSX.Element
export function LineChart(props: BarChartProps): JSX.Element
```
### Hooks
Host-channel hooks. Distinct from React hooks — these dispatch into the parent
via the postMessage protocol.
```ts
export type ActionName =
| "navigate"
| "updateField"
| "submitForm"
| "openRecord"
| "refreshData"
| "copyToClipboard"
export function useAction<TPayload = unknown, TResult = unknown>(
name: ActionName,
): (payload: TPayload) => Promise<TResult>
```
### Utils
Pure helpers — same shapes as the host equivalents, no host dependencies.
```ts
export function cn(...args: any[]): string
export function formatCurrency(n: number, currency?: string): string
export function formatDate(input: string | Date): string
export function formatRelativeTime(input: string | Date): string
export function slugify(s: string): string
export function humanize(s: string): string
```
### React core
Re-exported from React 18 (the version bundled in the runner). Only the
listed hooks; no `useContext`, no `useReducer`, no `useImperativeHandle`,
no `useDeferredValue`.
```ts
export { useState, useEffect, useMemo, useCallback, useRef, Fragment } from "react"
```
### Theme tokens
The runner ships the full Sprinter Platform CSS variable surface so any
`bg-*`, `text-*`, `border-*` utility backed by a host-defined token works
inside agent-authored TSX. Useful tokens beyond the obvious primary /
muted / destructive family:
| Class | Token | Use |
| --- | --- | --- |
| `bg-input-background` | `--input-background` | Surface-relative form-input fill (matches the host `<Input>` / `<Textarea>` / `<SelectTrigger>` background; ~1.5% darker than the page surface in light mode, 8% alpha overlay in dark mode). Prefer over `bg-transparent` for any text-input you build. |
| `bg-card` | `--card` | Card surface — slightly lifted from `bg-background`. |
| `bg-muted` | `--muted` | Subtler container surface — use for empty states, disabled regions. |
| `bg-popover` | `--popover` | Floating surface — use only inside `<Card>` overrides for popover-shaped UI. |
| `text-foreground` / `text-muted-foreground` | `--foreground` / `--muted-foreground` | Primary / secondary body text. |
| `text-primary` / `bg-primary` | `--primary` | The single hue in the system. Use sparingly. |
| `border` / `border-input` | `--border` / `--input` | Borders. `--input` is the form-control border specifically. |
Tokens auto-flip between light and dark via the `.dark` variant. The
runner's `@theme inline` block (see `lib/runtime/runner-html.ts`) maps each
`--color-*` to its host counterpart, so any token added to
`app/globals.css` is available in agent TSX after the next runner re-bundle.
## Action allowlist
Six actions can be invoked via `useAction(name)`. The host validates the name
against `lib/runtime/protocol.ts` `ACTION_ALLOWLIST` and rejects unknown names
with a clear error.
| Action | Payload shape | Effect |
| --- | --- | --- |
| `navigate` | `{ href: string }` | Pushes `href` onto the host router. |
| `updateField` | `{ entityId: string, field: string, value: unknown }` | Updates a field on an entity via the entity write pipeline. Triggers re-fetch. |
| `submitForm` | `{ entityType: string, values: Record<string, unknown> }` | Submits a form via the response-session pipeline. |
| `openRecord` | `{ id: string, entityType?: string }` | Opens the record's detail surface (sheet or full-page depending on context). |
| `refreshData` | `{ scope?: string }` | Invalidates React Query for the parent surface; data flows back via `surfaceProps`. |
| `copyToClipboard` | `{ text: string }` | Writes to clipboard via the host. |
Each action returns a Promise; resolve on success, reject on error. The agent
should handle both states.
## Block-scoped capabilities (`actionSchema` + `invoke`)
The six-action allowlist is **frozen** — it is the sandbox `postMessage`
security boundary and gains nothing per-feature (ADR-0037). When a surface
needs interactions the six actions do not model (e.g. a quiz runner's
"advance", "go back", "the host decides what this CTA means"), the Block
declares them as **Block-scoped capabilities** — a SECOND, narrower ring on top
of the frozen global allowlist.
A `BlockDefinition` declares its capabilities in an optional `actionSchema`,
mapping each capability name to the Zod schema for its args:
```ts
actionSchema: {
quizContinue: z.void(),
quizBack: z.void(),
quizCtaAction: QuizCtaActionContextSchema, // { cta, archetypeId, protocolId }
}
```
`defineBlock()` THROWS at module load if any capability name collides with a
member of `ACTION_ALLOWLIST` — a Block can never shadow `navigate` et al., and
`actionSchema` is never a way to widen the global allowlist.
| Tier | Channel | Validated against | Purpose |
| --- | --- | --- | --- |
| Global ring | `action` message / `useAction(name)` | `ACTION_ALLOWLIST` (frozen 6) | Platform side effects an untrusted sandbox may trigger |
| Per-Block ring | `invoke` message / `useInvoke(name)` | the resolved Block's OWN `actionSchema` | Surface-orchestration callbacks scoped to one Block |
- **Host-render Blocks** (most platform/tenant Blocks, including quiz steps)
call `context.invoke(name, args)` directly — no `postMessage`, no allowlist.
The mounting surface supplies the handlers on `context.actions`; `<MountBlock>`
builds the `invoke` proxy and validates each call against the Block's
`actionSchema` ∩ the surface handlers.
- **Sandbox Blocks** reach the same proxy through a single validated `invoke`
channel message (`useInvoke(name)` in the runner). The host validates
`capabilityName` + `args` against the resolved Block's `actionSchema` before
dispatch. A sandboxed Block can only invoke capabilities **its own
definition declares** — it cannot invent capability names, cannot reach
another Block's capabilities, and cannot widen the global allowlist.
```tsx
import { useInvoke } from "@/runtime"
export default function MyStep() {
const next = useInvoke("quizContinue")
return <Button onClick={() => next()}>Continue</Button>
}
```
## Common pitfalls
- **Forgetting `export default`.** react-runner mounts the default export. A
component declared as `export function Foo(...)` won't render. Make it
`export default function Foo(...)`.
- **Importing from `@/components/ui/*` or `@/features/...`.** These don't exist
in the runner. Only `@/runtime` resolves.
- **Calling `useAction` outside a render.** It must be called at the top of the
component body (React hook rules). Calling it inside a click handler returns
`undefined`.
- **Treating `surfaceProps` as a single shape.** It's a discriminated union; the
available fields depend on the `surface` value. Switch on `surfaceProps.surface`
before reading `entity`, `entities`, `view`, etc.
- **Trying to fetch from inside the iframe.** CSP `connect-src 'none'` means
every `fetch`, `XMLHttpRequest`, WebSocket, and beacon will fail. Use
`useAction("refreshData")` to ask the parent to re-fetch.
- **Mutating props.** `surfaceProps` is read-only. Derive state via `useMemo`
or local `useState`; never mutate the input.
- **Big inline data.** Source is capped at 102_400 bytes. If the component
needs a lookup table, either pass it via `surfaceProps` (host owns the data) or
split into two artifacts.
- **Long-running effects.** The iframe is bounded by its parent; long
intervals or recursive `setTimeout` chains accumulate across re-mounts.
Clean up in `useEffect` returns.
## Constraints
| Constraint | Value | Enforced by |
| --- | --- | --- |
| Source size | 102_400 bytes | `manageComponent.compile` validation |
| AST: `eval` | Rejected | `lib/runtime/ast-guards.ts` |
| AST: `Function()` | Rejected | `lib/runtime/ast-guards.ts` |
| AST: `dangerouslySetInnerHTML` | Rejected | `lib/runtime/ast-guards.ts` |
| AST: `javascript:` URL literal | Rejected | `lib/runtime/ast-guards.ts` |
| AST: string-arg `setTimeout/setInterval` | Rejected | `lib/runtime/ast-guards.ts` |
| AST: `Object.assign(globalThis | window, …)` | Rejected | `lib/runtime/ast-guards.ts` |
| AST: `Object.defineProperty(X.prototype, …)` | Rejected | `lib/runtime/ast-guards.ts` |
| Network egress | None | iframe CSP `connect-src 'none'` |
| Cookie / localStorage / parent.document | None | iframe `sandbox="allow-scripts"` (no `allow-same-origin`) |
| Sucrase compile budget | ~5 ms typical | Cached per `version_hash` after first render |
The contract is bounded but not minimal. Within these constraints, the agent has
the full React 18 hook surface for local UI state, the curated palette for
composition, and the action channel for side effects. That is the surface area
for one-off insights, custom dashboards, and domain-specific visualizations.
## Code-tier surface props (2026-05-13)
The iframe-tier `ArtifactSurfaceProps` described above governs **agent-authored
TSX** running inside the runner sandbox. There is a parallel code-tier
contract for **in-process tenant + product components** that live at
`features/custom/tenants/<slug>/components/**`,
`features/custom/tenants/<slug>/details/**`, and the various
`features/entities/components/entity-*/` mount points.
That code-tier contract lives in `lib/runtime/code-surface-props.ts` and exports
a parallel discriminated union (`CodeSurfaceProps`):
- `EntityCardSurfaceProps` — `surface: "entity-card"`
- `EntityListSurfaceProps` — `surface: "entity-list"`
- `EntityDetailSurfaceProps` — `surface: "entity-detail"`
- `EntityDetailTabSurfaceProps` — `surface: "entity-detail-tab"` (extends
`EntityDetailSurfaceProps` with a `tabId`)
- `EntityFormSurfaceProps` — `surface: "entity-form"` (legacy
`EntityFormProps` still allowed for `mode === "edit"` narrowing during
the Branch C migration window)
- `EntityShareSurfaceProps` — `surface: "entity-share"`
Use `CodeSurfacePropsOf<K>` to extract a single variant from the union:
```ts
import type { CodeSurfacePropsOf } from "@/lib/runtime/code-surface-props";
type CardProps = CodeSurfacePropsOf<"entity-card">;
```
### Why two unions
The iframe-tier `ArtifactSurfaceProps` payload crosses a `postMessage`
boundary into a sandboxed iframe — it must be JSON-serializable and may
not carry React-hook closures (router, query client). The code-tier
`CodeSurfaceProps` lives in the same React tree as its consumer and CAN
carry a typed `actions: EntitySurfaceActions` instance built from
`useRouter()` / `useQueryClient()`. The discriminant strings overlap
(both have `entity-card`, `entity-detail`, …) but the two unions never
co-mingle in one narrowing site. `artifact-host.tsx` is the single
place that converts between them at the postMessage boundary.
### `EntitySurfaceActions` — one vocabulary, two transports
Member names match `ACTION_ALLOWLIST` exactly so module-tier
`useAction("navigate")` and code-tier `props.actions.navigate(...)`
share one vocabulary. Adding a new action requires extending BOTH the
allowlist AND the `EntitySurfaceActions` interface together — one
without the other is a drift bug.
### Resolver hierarchy
The same `<SlotHost>` that mounts module-tier artifacts also mounts
code-tier components via the unified slot registry. The resolution
order (default `overridePolicy = "allow-db-override"`) is:
1. **Workspace DB binding** — `ui_artifact_bindings` row with
`workspace_id = active_workspace_id`, joined to an approved
`ui_artifact_versions` row whose parent `ui_artifacts` row is published.
2. **Tenant DB binding** — `ui_artifact_bindings` row with
`workspace_id IS NULL` for the active tenant, using the same
artifact-version status checks.
3. **Tenant code-tier registration** — `registerEntity{Card,List,Detail,Form,Share}(slug, Component, { tenantSlug })`.
4. **Platform code-tier registration** — same helpers called with no
`tenantSlug` option (cross-tenant default).
5. **Fallback** — the platform's built-in renderer.
> **ADR-0020 Phase 7 — legacy table drop (2026-05-15, PR #1473):** The
> `ui_renderer_plugins` / `ui_renderer_plugin_versions` / `ui_renderer_bindings`
> table family has been dropped. The canonical store for all slot bindings —
> both spec-kind and module-kind — is now exclusively `ui_artifacts` /
> `ui_artifact_versions` / `ui_artifact_bindings`. The write path
> (`manageComponent.compile`) and the read path (`findActiveSlotBinding`) now
> point at the same table family. If you see references to `ui_renderer_*` in
> older migrations, ADRs, or backlog ideas, treat them as historical — the
> tables no longer exist in the schema.
`code-locked` policy reverses the order so code-tier always wins over DB
bindings; this is the path the migration uses while testing a new
component before letting DB bindings override it.
### Mount points + EntitySlot
Server-side `<EntitySlot kind="entity-{surface}" name={typeSlug} data={…} />`
is the canonical mount for list / detail / form / share. It resolves the
active tenant + workspace from URL context, calls `findActiveSlotBinding`
for the DB tier, and dispatches to `<SlotHost>`. The caller constructs
`data` to match the canonical surface-props shape.
The historical `/api/renderers/[versionId]/payload` route remains the
payload URL; `versionId` refers to `ui_artifact_versions.id`. Runtime
payloads are derived from the artifact manifest (module source or
view/form/block specs). The legacy `ui_renderer_plugin_versions` payload
path was removed in PR #1473 (ADR-0020 Phase 7).
For client-side card rendering the mount is `<EntityCard>` in
`features/entities/components/entity-card/entity-card.tsx` — it
constructs `EntityCardSurfaceProps` and threads a fully-wired
`actions: EntitySurfaceActions` through to the registered component.
`actions` is OPTIONAL across surface-props variants because server
mounts can't serialize router/queryClient closures across the RSC
payload boundary. Client mounts (EntityCard) provide it; server mounts
omit it; interactive client subtrees call
`useEntitySurfaceActions({ tenantSlug })` directly when they need
actions.
### Authoring tenant components
See `.claude/rules/tenant-modules.md` for the authoring rule (declarative
`TenantModule.entityTypes` map first, imperative `register()` second;
type against `CodeSurfacePropsOf<K>`; no cross-tenant imports; no
`server-only` modules inside `components/**` or `details/**`).