Sprinter Platform

Rendering System

Seven render families, three-tier fallback, UIIntent, auto-renderers, and custom component registration.

Overview

The rendering system bridges the headless core (types, stores, builders) to visual output. It is designed so that the core never depends on React or any UI framework, while the presentation layer can render any entity type generically.

Seven Render Families

Every piece of visual output falls into one of seven families:

FamilyPurposeExamples
blockContent blocks in layoutsStat block, text block, chart block
cardEntity summary cardsEntity card, compact card, kanban card
tableTabular data viewsEntity list, data table, comparison table
formData inputEntity create form, field editor, settings
chartData visualizationBar chart, line chart, pie chart
graphRelationship visualizationEntity graph, dependency graph
customEscape hatchAnything that does not fit the above
import type { RenderFamily } from "@sprinterai/core";

const family: RenderFamily = "card";

Three-Tier Fallback

When rendering an entity or field, the platform checks three levels:

Tier 1: Custom renderer

A registered custom component for the specific entity type or block type:

// Custom card for "opportunity" entity type
import type { RendererDescriptor } from "@sprinterai/core";

const opportunityCard: RendererDescriptor = {
  id: "opportunity-card",
  family: "card",
  label: "Opportunity Card",
  match: { entityTypeSlug: "opportunity" },
};

Tier 2: Schema-driven auto-renderer

The platform reads the entity type's json_schema and renders fields automatically. Text fields render as text, select fields render as dropdowns, currency fields render with formatting, dates render as date pickers.

Tier 3: Generic fallback

A minimal key-value display that works for any entity regardless of schema.

UIIntent

A UIIntent describes what the user wants to see without prescribing how to render it:

import type { UIIntent } from "@sprinterai/core";

// "Show me opportunities as a kanban board"
const intent: UIIntent = {
  family: "card",
  entityTypeSlug: "opportunity",
  layout: "kanban",
};

// "Show me company details"
const detailIntent: UIIntent = {
  family: "block",
  entityTypeSlug: "company",
  entityId: "uuid-here",
  layout: "bento",
};

// "Show me a revenue chart"
const chartIntent: UIIntent = {
  family: "chart",
  params: { metric: "revenue", groupBy: "sector" },
};

The rendering system takes a UIIntent and resolves it through the three-tier fallback to find the best renderer.

RendererDescriptor

Renderers register themselves with a descriptor:

interface RendererDescriptor {
  /** Unique renderer identifier. */
  id: string;
  /** What family of rendering this handles. */
  family: RenderFamily;
  /** Human-readable name for admin UI. */
  label: string;
  /** When this renderer should be used. */
  match?: {
    entityTypeSlug?: string;
    blockType?: string;
    viewLayout?: string;
  };
}

The match object determines when the renderer is selected. A renderer with match.entityTypeSlug: "opportunity" will be used whenever an opportunity entity needs to be rendered in that family.

Auto-Rendering from Schema

The platform reads the json_schema from the entity type and automatically generates:

  • Forms -- input fields matching property types (text input, select dropdown, number input, date picker, URL input, toggle)
  • Cards -- summary display with key fields highlighted
  • Detail views -- full field layout with sections
  • Table columns -- column definitions from schema properties

Field type mapping:

JSON SchemaAuto-rendered as
{ type: "string" }Text input / text display
{ type: "string", enum: [...] }Select dropdown / badge
{ type: "number" }Number input / formatted number
{ type: "number", format: "currency" }Currency input / currency display
{ type: "string", format: "date" }Date picker / formatted date
{ type: "string", format: "uri" }URL input / clickable link
{ type: "string", format: "email" }Email input / mailto link
{ type: "boolean" }Toggle / check/cross icon
{ type: "array" }Tag input / tag list

Views

Views are config-driven layouts that compose blocks and renderers:

  • List view -- table or grid of entities
  • Detail view -- single entity with blocks in a bento grid
  • Workspace view -- multi-panel layout with mixed content

Views are stored in the views table and can be created, edited, and shared. The manageView admin tool allows agents to create views programmatically.

Registry Pattern

The shadcn registry provides pre-built UI blocks:

# Install a card component
npx shadcn@latest add https://ui.sprinterai.dev/r/entity-card.json

# Install a chart block
npx shadcn@latest add https://ui.sprinterai.dev/r/chart-block.json

Registry blocks are installed into your source code, not consumed as npm dependencies. You own the code and can customize it freely.

Field Renderer Suite

The render-registry block ships a complete field-renderer suite. It covers all 12 FieldType values and exposes two public dispatcher components: FieldInput and FieldDisplay.

Install the block once:

npx shadcn@latest add https://ui.sprinterai.dev/r/render-registry.json

FieldInput and FieldDisplay

import { FieldInput, FieldDisplay } from "@/blocks/render-registry/field-renderer-dispatch";

Both components accept a FieldDefinition (from @sprinterai/core) and route to the correct leaf renderer based on field.type. Unknown types fall back to text.

// Read-only display
<FieldDisplay
  field={field}
  value={entity[field.key]}
  mode="card"
  context={ctx}
/>

// Editable input
<FieldInput
  field={field}
  value={formState[field.key]}
  onChange={(next) => setField(field.key, next)}
  mode="full"
  context={ctx}
  error={errors[field.key]}
/>

FieldRenderMode

Controls visual size and surface treatment. Every leaf renderer respects this prop.

ModeUse
fullEntity edit forms, modal sheets — default
compactTable cells, bulk editors
cardField-card blocks, hover-edit surfaces
panelKPI strips, summary blocks
inlineText-only references in chat
edit-inlineHover-to-edit in-place controls

FieldRenderContext

A context bag threaded through to all renderers. Pass it via the context prop on both FieldInput and FieldDisplay.

interface FieldRenderContext {
  surface?: "form" | "bento" | "table" | "card" | "kanban" | "canvas" | "chat";
  viewId?: string;
  entityId?: string;
  isDraft?: boolean;
  /** PATCH-style write sink. The host routes writes through its own transport. */
  onPatch?: (patch: { key: string; value: unknown }) => void;
  /** Upload a File, return the CDN URL. Required for MediaInput file uploads. */
  onUpload?: (file: File) => Promise<string>;
  /** Search related entities by type slug and query string. Required for RelationInput suggestions. */
  onSearchRelations?: (
    typeSlug: string,
    query: string,
  ) => Promise<Array<{ id: string; title: string }>>;
}

onUpload and onSearchRelations are optional. When absent, MediaInput hides its upload button and RelationInput shows no suggestions — neither component errors.

Callback injection pattern

Because the registry block has no direct dependency on Supabase Storage or any specific entity store, async operations are injected by the host:

// Host wires its own transport
const ctx: FieldRenderContext = {
  surface: "form",
  entityId: entity.id,
  onUpload: async (file) => {
    const { data } = await supabase.storage
      .from("attachments")
      .upload(`${tenantId}/${file.name}`, file);
    return supabase.storage.from("attachments").getPublicUrl(data.path).data.publicUrl;
  },
  onSearchRelations: async (typeSlug, query) => {
    const { data } = await supabase
      .from("entities")
      .select("id, title")
      .eq("entity_type_slug", typeSlug)
      .ilike("title", `%${query}%`)
      .limit(10);
    return data ?? [];
  },
};

<FieldInput field={field} value={value} onChange={onChange} context={ctx} />

Prop interfaces

interface FieldInputProps<T = unknown> {
  field: FieldDefinition;
  value: T | undefined;
  onChange: (next: T | null | undefined) => void;
  mode?: FieldRenderMode;        // default "full"
  disabled?: boolean;
  error?: string;
  context?: FieldRenderContext;
  onBlur?: () => void;
  onFocus?: () => void;
  name?: string;
}

interface FieldDisplayProps<T = unknown> {
  field: FieldDefinition;
  value: T | undefined;
  mode?: FieldRenderMode;        // default "full"
  context?: FieldRenderContext;
  className?: string;
}

Cleared-value sentinels

onChange emits these values when a field is cleared:

Field typeCleared value
text""
numbernull
datenull
enum (single)null
enum (multi)[]
relation (single)null
relation (multi)[]
media[]
booleantrue or false — never null

Composite components

ComponentPurpose
FieldDefinitionDisplayRead-only metadata chip or row with colour-coded type badge. variant="row" (default) or variant="chip".
FieldDefinitionEditorAdmin CRUD control for a single FieldDefinition: key, label, type, display type, required, read-only, description, plus per-type config panels.
FieldDefinitionFormWraps FieldDefinitionEditor in a submit/cancel shell for use inside sheets and dialogs.
import { FieldDefinitionForm } from "@/blocks/render-registry/field-definition-form";

<FieldDefinitionForm
  initialField={draft}
  onSubmit={(field) => save(field)}
  onCancel={() => setOpen(false)}
  typeSlugOptions={["company", "contact", "deal"]}
/>

Format utilities

field-renderer-utils.ts exports pure formatting helpers with no dependencies:

FunctionWhat it does
humanize(key)"first_name""First Name"
formatAbsoluteDate(value)ISO string → "May 6, 2026". Handles date-only strings without timezone shift.
formatNumber(value, precision?)Locale-formatted number with optional decimal precision.
formatCurrency(value)USD currency with no decimal places.
formatPercentage(value)"42.0%"
formatPhone(value)10-digit US → "(555) 867-5309", 11-digit with country code → "+1 (555) 867-5309".
formatUrl(value)Strips https://www. prefix and trailing slash. Unsafe protocols return just the filename.
isUnsafeUrl(value)Returns true for file:, javascript:, data:, vbscript: schemes.
guessMediaKind(url)"image" or "video" based on file extension.
normalizeUrls(value)Coerces a string or string array to string[], filtering empties.

Array and object: render-props pattern

ArrayInput/ArrayDisplay and ObjectInput/ObjectDisplay accept renderItem and renderField render props respectively. The dispatcher (field-renderer-dispatch.tsx) injects itself to handle recursive field trees without creating a circular module import:

// The dispatcher does this automatically — you do not need to pass renderItem yourself
// when using FieldInput / FieldDisplay. This is only relevant if you import
// ArrayInput or ObjectInput directly.
<ArrayInput
  field={arrayField}
  value={values}
  onChange={setValues}
  renderItem={({ field, value, onChange, mode, disabled, context }) => (
    <FieldInput field={field} value={value} onChange={onChange}
                mode={mode} disabled={disabled} context={context} />
  )}
/>