Sprinter Docs

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.

  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.

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.

ExportPurpose
ButtonClick target. Variants `default
Card, CardHeader, CardContent, CardTitle, CardDescriptionSurface container with header + body slots.
InputText input. Controlled (value, onChange).
LabelForm label. Pairs with htmlFor.
BadgeCompact status pill. Variants `default
SkeletonPulse placeholder for loading states.
SeparatorHorizontal 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.Element

Hooks

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

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.

ActionPayload shapeEffect
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 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

ConstraintValueEnforced by
Source size102_400 bytesmanageComponent.compile validation
AST: evalRejectedlib/runtime/ast-guards.ts
AST: Function()Rejectedlib/runtime/ast-guards.ts
AST: dangerouslySetInnerHTMLRejectedlib/runtime/ast-guards.ts
AST: javascript: URL literalRejectedlib/runtime/ast-guards.ts
AST: string-arg setTimeout/setIntervalRejectedlib/runtime/ast-guards.ts
AST: `Object.assign(globalThiswindow, …)`Rejected
AST: Object.defineProperty(X.prototype, …)Rejectedlib/runtime/ast-guards.ts
Network egressNoneiframe CSP connect-src 'none'
Cookie / localStorage / parent.documentNoneiframe sandbox="allow-scripts" (no allow-same-origin)
Sucrase compile budget~5 ms typicalCached 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.

On this page