Documentation source
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](https://www.anthropic.com/engineering/desktop-extensions):
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. Has `kind`, `slug`,
`status`, `manifest`, and one or more versions. The kind discriminator includes
`block`, `view-spec`, `form-spec`, `link-spec` (the four declarative kinds), plus
`module` (the agent-authored React kind).
- **Version** — a row in `ui_artifact_versions`. Carries the actual content:
`manifest jsonb` (per-kind validated shape) and `source_code text` (TSX for
`module` kind, JSON-stringified spec for the spec kinds). Immutable once written;
re-publishing creates a new version. Each row carries `version_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`, optional `workspace_id`, `priority`,
`enabled`. The slot resolver picks the highest-priority active binding.
- **Surface** — one of the six mount points. Each carries a typed `ArtifactSurfaceProps`
variant 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 `SlotPluginPayload` discriminator. Five values:
`view-spec`, `form-spec`, `link-spec`, `block`, `module`. The first four map to
declarative Specs the platform interprets; `module` maps 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](/docs/features/components-runtime).
The shortest happy path:
```ts
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 `manageComponent` permission" — the same trust tier as
`features/custom/` code today. Cross-origin (Anthropic's
`claudeusercontent.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 `@/runtime` palette, not raw `@/components/ui/*`.** Visual drift across
agent-authored components is bounded by the palette surface. Agents cannot
reach into `features/custom/` or `lib/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's `importCode`) and rebuilds only the React element. Local state
(input values, focus, scroll, open menus) survives. The Playwright e2e at
`e2e/artifact-reactive-props.spec.ts` proves 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 adds
`ui_artifact_bindings` alongside; consolidation is a follow-up.
The full architectural rationale lives in
[ADR-0020](https://github.com/sprinterhq/amble/blob/dev/documents/adr/0020-agent-authored-components.md);
the predecessor [ADR-0019](https://github.com/sprinterhq/amble/blob/dev/documents/adr/0019-iframe-renderer-path-retired.md)
documents the cross-origin runner that was retired and the "no escape hatch"
clause this ADR replaces.
## Related Modules
- [Entity System](/docs/features/entity-system) — entity-card / entity-detail
surfaces consume `EntityRecord` + `FieldConfig[]` from this module.
- [View System](/docs/features/view-system) — `surface_type: 'artifact'` and
`view.blocks[i].kind: 'module'` are the two view-side mount points.
- [Block System](/docs/features/block-system) — `BlockSpecRenderer` is the inner
host for the block-in-view mount point.
- [Tool System](/docs/features/tool-system) — `manageComponent` lives in the tool
catalog with `visibility: "internal"`.
- [Components Runtime](/docs/features/components-runtime) — the agent-facing API
reference for `@/runtime`.