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
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.
- Pure-render only. Data flows in via
surfaceProps(one of theArtifactSurfacePropsvariants). No data-fetching hooks in the palette — nouseQuery, nouseSWR, nofetch(), noXMLHttpRequest. The iframe'sconnect-src 'none'CSP makes network access impossible anyway. - No router, no global navigation. No
next/link, nonext/router, nouseRouter, noLink. Navigation is an action:useAction("navigate"). - No global access. No
window.X = …, noObject.assign(window, …), noObject.defineProperty(Object.prototype, …). AST guards reject these patterns at compile time. - No persistent storage. No
localStorage, nosessionStorage, noIndexedDB. The iframe is rebuilt on every mount; persistent state belongs to the parent (which owns React Query, the database, and the URL). - Action allowlist. Only six actions can be invoked via
useAction:navigate,updateField,submitForm,openRecord,refreshData,copyToClipboard. CallinguseAction("anything-else")returns a function that rejects. - Source size cap. TSX source maxes out at 102_400 bytes. Larger components must split into two artifacts and compose.
- 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.
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. surfacePropsis the only data input. When the parent's data updates, the parent re-renders the host with newsurfaceProps, the iframe receives aprops-updatemessage, and React reconciles. Local state (filter, selection, scroll) is preserved.useActionis 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 |
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 |
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.
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.ElementHooks
Host-channel hooks. Distinct from React hooks — these dispatch into the parent via the postMessage protocol.
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.
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): stringReact core
Re-exported from React 18 (the version bundled in the runner). Only the
listed hooks; no useContext, no useReducer, no useImperativeHandle,
no useDeferredValue.
export { useState, useEffect, useMemo, useCallback, useRef, Fragment } from "react"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.
Common pitfalls
- Forgetting
export default. react-runner mounts the default export. A component declared asexport function Foo(...)won't render. Make itexport default function Foo(...). - Importing from
@/components/ui/*or@/features/.... These don't exist in the runner. Only@/runtimeresolves. - Calling
useActionoutside a render. It must be called at the top of the component body (React hook rules). Calling it inside a click handler returnsundefined. - Treating
surfacePropsas a single shape. It's a discriminated union; the available fields depend on thesurfacevalue. Switch onsurfaceProps.surfacebefore readingentity,entities,view, etc. - Trying to fetch from inside the iframe. CSP
connect-src 'none'means everyfetch,XMLHttpRequest, WebSocket, and beacon will fail. UseuseAction("refreshData")to ask the parent to re-fetch. - Mutating props.
surfacePropsis read-only. Derive state viauseMemoor localuseState; 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
setTimeoutchains accumulate across re-mounts. Clean up inuseEffectreturns.
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 |
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.
Components (agent-authored)
Live agent-authored React components in the same async slot system used by code-tier and spec-tier UI.
Tool System
Standalone calculators and utilities usable by humans (form UI) and AI agents (AI SDK ToolSet). Tool registration, permission gating, execution tracking, collaborative sessions, and the AI bridge.