Components (agent-authored)
Live agent-authored React components in the same async slot system used by code-tier and spec-tier UI.
Overview
The component system gives agents a first-class path to ship novel React UI without a
platform engineer in the loop. An agent writes TSX that imports from @/runtime,
calls manageComponent.compile, and the resulting component renders in any of six
mount surfaces — block, whole-view, entity-card, entity-detail, custom-page, or chat
ephemeral preview — through the same <SlotHost> resolver that already serves
code-tier and spec-tier UI.
The mechanism is the same one Anthropic uses for Live Artifacts:
a same-origin srcdoc iframe with sandbox="allow-scripts" (no allow-same-origin),
in-browser Sucrase compilation, and react-runner evaluation against a curated scope.
The iframe is null-origin, browser-enforced isolation; CSP connect-src 'none' closes
the network egress channel; data flows in via reactive surfaceProps, actions out via
a correlation-id-tracked postMessage allowlist.
Where this fits the platform: the four declarative Specs (BlockSpec, ViewSpec, FormSpec, LinkSpec) from ADR-0006 cover most affordances. When the long tail needs custom React — a one-off insight visualization, a domain-specific dashboard, something the Specs don't model — the component system is the path. The pure-render contract keeps the surface bounded; the curated palette keeps it on-design.
Key Concepts
- Artifact — a row in
ui_artifacts. The catalog entry. Haskind,slug,status,manifest, and one or more versions. The kind discriminator includesblock,view-spec,form-spec,link-spec(the four declarative kinds), plusmodule(the agent-authored React kind). - Version — a row in
ui_artifact_versions. Carries the actual content:manifest jsonb(per-kind validated shape) andsource_code text(TSX formodulekind, JSON-stringified spec for the spec kinds). Immutable once written; re-publishing creates a new version. Each row carriesversion_hash(SHA-256 of source) used as a cache key inside the runner. - Binding — a row in
ui_artifact_bindings. Connects an artifact version to a slot —slot_kind,slot_name,tenant_id, optionalworkspace_id,priority,enabled. The slot resolver picks the highest-priority active binding. - Surface — one of the six mount points. Each carries a typed
ArtifactSurfacePropsvariant indicating its mount kind (block-in-view,whole-view,entity-card,entity-detail,custom-page,chat-ephemeral, plus two future-proofing variants). - Payload kind — the
SlotPluginPayloaddiscriminator. Five values:view-spec,form-spec,link-spec,block,module. The first four map to declarative Specs the platform interprets;modulemaps to agent-authored TSX routed to<ArtifactHost>.
How It Works
┌─────────────────────────────────────────────────────────────────────┐
│ AUTHORING │
│ │
│ manageComponent.compile({ source: TSX, slug, slotKind?, │
│ slotName?, surfaceMatrix? }) │
│ → AST guards (eval, dangerouslySetInnerHTML, javascript:, │
│ Function(), string-arg setTimeout, global mutation, │
│ prototype mutation — all rejected with line numbers) │
│ → Sucrase syntax check (server-side, throw-on-error) │
│ → SHA-256(source) → version_hash │
│ → INSERT ui_artifacts + ui_artifact_versions │
│ → optional INSERT ui_artifact_bindings │
│ → returns { artifactId, versionId, ephemeral } │
└──────────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ RESOLUTION │
│ │
│ <SlotHost kind={"entity-card"} key={typeSlug} surfaceProps={…} /> │
│ → resolveSlot() walks tiers (code → plugin → config → │
│ default) │
│ → plugin tier reads ui_artifact_bindings + ui_renderer_* │
│ → finds payload.kind === 'module' → dispatches to │
│ <ArtifactHost source={…} surfaceProps={…} /> │
└──────────────────────────────┬──────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────────┐
│ RUNTIME │
│ │
│ <iframe srcdoc={RUNNER_HTML} sandbox="allow-scripts" /> │
│ runner: React 18 + Tailwind v4 + Sucrase + react-runner + │
│ curated @/runtime palette + bootstrap │
│ MessageChannel handshake (host → port; iframe → "ready") │
│ host posts: { type: "init", source, theme, surfaceProps } │
│ iframe Sucrase-compiles source, react-runner evaluates with │
│ scope = { ...palette, React, useState, useEffect, … } │
│ cached component reference renders into iframe React root │
│ │
│ Reactive props update: │
│ parent re-renders ArtifactHost with new surfaceProps │
│ host posts: { type: "props-update", props } │
│ iframe rebuilds element with cached component + new props │
│ React reconciles → input value, focus, scroll all preserved │
│ │
│ Action dispatch: │
│ agent calls useAction("openRecord")({ id }) │
│ iframe posts: { type: "action", id: corr-id, name, payload } │
│ host validates name against allowlist, executes, posts back │
│ { type: "action-result", id: corr-id, result | error } │
│ iframe Promise resolves │
└─────────────────────────────────────────────────────────────────────┘The runner HTML is built once at platform deploy time
(scripts/build-runner-html.ts) and committed as lib/runtime/runner-html.ts. It
is fully self-contained — no external <script src>, no external CSS link, no
fetch. The null-origin sandbox cannot resolve any external URL, so everything the
runtime needs is inline.
API Reference
manageComponent tool
The single tool surface for authoring components. Replaces manageArtifact and
manageRendererPlugin write actions. visibility: "internal" (ADR-0014) — not
exposed via external MCP / API key callers in v1.
| Action | Purpose |
|---|---|
compile | Module kind. Validates source, runs AST guards, computes hash, writes ui_artifacts + ui_artifact_versions, optionally upserts a binding, returns ephemeral envelope for chat preview. Auto-publishes. |
save | Spec kind. Stores JSON-stringified spec (block, view-spec, form-spec, link-spec) in source_code, validates per-kind schema, optionally upserts binding, returns ephemeral envelope. Stays in draft until publish. |
publish | Flips ui_artifacts.status and ui_artifact_versions.status to published together (single state machine). Until publish, only tenant-admin sees the row. |
archive | Flips ui_artifacts.status to archived. Bindings inert; rows preserved for audit. |
list | Returns artifacts filtered by kind, status, slotKind. Tenant + workspace scope applied via standard 3-tier resolver. |
get | Returns one artifact + its versions. Optional versionId to fetch a specific version. |
validate | Dry-run validate manifest against the per-kind schema. Returns { ok, errors }. No writes. |
Source size is capped at 102_400 bytes for module kind. AST guards reject:
eval(), new Function(...) / Function(...), dangerouslySetInnerHTML,
javascript: URL literals, string-arg setTimeout/setInterval, Object.assign(globalThis, ...)
or Object.assign(window, ...), Object.defineProperty(X.prototype, ...).
Mount surfaces
All six surfaces flow through the same <ArtifactHost> client component
(lib/runtime/artifact-host.tsx). The agent's TSX exports a default function that
switches on surfaceProps.surface (the discriminator) and renders the
matching variant.
| Surface | Where it mounts | How it routes |
|---|---|---|
| Block in any view | A view.blocks[i] with kind: 'module' | BlockSpecRenderer resolves the block and dispatches to <ArtifactHost> |
| Whole view | view.surface_type === 'artifact' | artifact-surface-renderer.tsx mounts <ArtifactHost> for the whole page body |
| Entity card | Slot binding for entity-card:{slug} with payload kind module | <SlotHost> plugin tier dispatches |
| Entity detail | Slot binding for entity-detail:{slug} with payload kind module | same path as entity-card |
| Custom page | custom_page.runtime === 'module' | custom-page-module.tsx mounts <ArtifactHost> |
| Chat ephemeral | EphemeralViewToolOutput.module?.source populated | <EphemeralViewCard> branches to <ArtifactHost> when module is present |
Six mount points; one host; one resolver; one tool. Adding a seventh surface is
"add a new ArtifactSurfaceProps variant + branch a renderer" — no new runtime, no
new authoring API.
For Agents
Components are agent-native. The full reference for the agent-facing API surface —
the curated @/runtime palette, the action allowlist, the pure-render contract,
worked examples, and the constraints — lives in
Components Runtime.
The shortest happy path:
manageComponent.compile({
source: `
import { Card, CardHeader, CardTitle, CardContent, KpiStat, useAction } from "@/runtime";
export default function Insights({ surface, entity }) {
const open = useAction("openRecord");
return (
<Card>
<CardHeader>
<CardTitle>{entity.name}</CardTitle>
</CardHeader>
<CardContent>
<KpiStat label="Score" value={entity.fields.score ?? "—"} />
</CardContent>
</Card>
);
}
`,
slug: "score-card",
slotKind: "entity-card",
slotName: "opportunity",
})compile validates, hashes, writes the row, upserts the entity-card:opportunity
binding, and returns an ephemeral chat preview so the next assistant turn can show
the result inline.
Design Decisions
- Same-origin srcdoc, not cross-origin. The trust model in v1 is "tenant
member with
manageComponentpermission" — the same trust tier asfeatures/custom/code today. Cross-origin (Anthropic'sclaudeusercontent.com) protects against Spectre-class side channels by isolating into a separate OS process; we don't pay that cost until the threat model includes "untrusted public artifact gallery." The runner HTML and<ArtifactHost>API don't change when we upgrade — only the iframe's origin. - Curated
@/runtimepalette, not raw@/components/ui/*. Visual drift across agent-authored components is bounded by the palette surface. Agents cannot reach intofeatures/custom/orlib/supabase/from inside the iframe — those modules don't exist in the runner bundle. - Cached component reference inside the iframe. When parent re-renders
ArtifactHost with new
surfaceProps, the iframe holds the cached component (via react-runner'simportCode) and rebuilds only the React element. Local state (input values, focus, scroll, open menus) survives. The Playwright e2e ate2e/artifact-reactive-props.spec.tsproves this. - Pure-render contract. No data-fetching hooks in the runtime palette. The host already owns React Query, Supabase realtime, RLS, auth, and data-source resolution; pushing that into the iframe would duplicate the surface and complicate auth. Props in, actions out, period.
- One catalog (
ui_artifacts), one resolver (<SlotHost>). ADR-0019's PR #1176 retired the cross-origin runner; the spec-payload tables (ui_renderer_*) stayed for the four declarative Specs. ADR-0020 addsui_artifact_bindingsalongside; consolidation is a follow-up.
The full architectural rationale lives in ADR-0020; the predecessor ADR-0019 documents the cross-origin runner that was retired and the "no escape hatch" clause this ADR replaces.
Related Modules
- Entity System — entity-card / entity-detail
surfaces consume
EntityRecord+FieldConfig[]from this module. - View System —
surface_type: 'artifact'andview.blocks[i].kind: 'module'are the two view-side mount points. - Block System —
BlockSpecRendereris the inner host for the block-in-view mount point. - Tool System —
manageComponentlives in the tool catalog withvisibility: "internal". - Components Runtime — the agent-facing API
reference for
@/runtime.
Field Rendering
The unified field-rendering substrate at features/schemas/ — one FieldDefinition shape, one set of inputs and displays, one slot-keyed extension seam. Used by entity edit forms, criteria responses, field cards, FormSpec runtime, and tool output displays.
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.