Sprinter Docs

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. 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.

ActionPurpose
compileModule 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.
saveSpec 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.
publishFlips ui_artifacts.status and ui_artifact_versions.status to published together (single state machine). Until publish, only tenant-admin sees the row.
archiveFlips ui_artifacts.status to archived. Bindings inert; rows preserved for audit.
listReturns artifacts filtered by kind, status, slotKind. Tenant + workspace scope applied via standard 3-tier resolver.
getReturns one artifact + its versions. Optional versionId to fetch a specific version.
validateDry-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.

SurfaceWhere it mountsHow it routes
Block in any viewA view.blocks[i] with kind: 'module'BlockSpecRenderer resolves the block and dispatches to <ArtifactHost>
Whole viewview.surface_type === 'artifact'artifact-surface-renderer.tsx mounts <ArtifactHost> for the whole page body
Entity cardSlot binding for entity-card:{slug} with payload kind module<SlotHost> plugin tier dispatches
Entity detailSlot binding for entity-detail:{slug} with payload kind modulesame path as entity-card
Custom pagecustom_page.runtime === 'module'custom-page-module.tsx mounts <ArtifactHost>
Chat ephemeralEphemeralViewToolOutput.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 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; the predecessor ADR-0019 documents the cross-origin runner that was retired and the "no escape hatch" clause this ADR replaces.

  • Entity System — entity-card / entity-detail surfaces consume EntityRecord + FieldConfig[] from this module.
  • View Systemsurface_type: 'artifact' and view.blocks[i].kind: 'module' are the two view-side mount points.
  • Block SystemBlockSpecRenderer is the inner host for the block-in-view mount point.
  • Tool SystemmanageComponent lives in the tool catalog with visibility: "internal".
  • Components Runtime — the agent-facing API reference for @/runtime.

On this page