Sprinter Docs

Block System

The universal rendering primitive. Every visual surface in the platform -- entity details, dashboards, chat messages, list views -- renders through the same BlockConfig to ResolvedBlock to component pipeline.

Overview

The block system is the rendering layer of the Sprinter Platform. Rather than building bespoke components for each page type, every visual surface renders through a unified pipeline: BlockConfig[] (serializable configuration) is resolved server-side into ResolvedBlock[] (config + data), which are then rendered by registered block components on the client.

This architecture means the same block types work everywhere: in entity detail pages, on dashboard views, inside chat messages, in tool output displays, and on standalone workspace pages. Adding a new block type -- say, a Gantt chart -- makes it immediately available across all these surfaces.

Blocks are orthogonal to surface types. A ResolvedBlock[] array is handed to whichever surface component the view declares (grid, sequence, slides, form, etc.) — the surface controls arrangement, the block controls rendering. See Surface Types in the View System doc for how blocks compose with surfaces.

The block system lives in features/blocks/ and has zero domain knowledge. It does not know about opportunities, people, or any specific entity type. It renders whatever data is resolved for it.

Key Concepts

Block Types

The platform ships with 35 block types:

Block TypeDescriptionDefault Size
stat-cardsKPI cards showing counts and metricsfull
tableSortable data table with rowsfull
chartBar, pie, donut, line, area, or stacked-bar chart (Recharts)half
radarRadar/spider chart with interactive scoringhalf
rankingScore-based ranking barshalf
kanbanKanban board grouped by an enum fieldfull
summaryKey-value pairs rendered as a summary cardfull
field-cardSingle entity field with smart displayvaries
connection-listLinked entities from a connection fieldhalf
data-tableFull data table with pagination and filtersfull
activityRecent activity timelinefull
entity-cardCompact entity cardhalf
entity-feedRanked entity feed with filteringfull
entity-graphEntity relationship graph (@xyflow/react)full
rich-textMarkdown/rich text contentfull
status-bannerStatus message bannerfull
child-entity-listChildren of a container entityfull
customPassthrough for custom renderersvaries
imageImage displayhalf
videoVideo embedfull
excalidrawExcalidraw whiteboardfull
react-flowReact Flow diagramfull
bubble-chartBubble chart visualizationhalf
form-flowMulti-step form wizard with validation and progress trackingfull
canvasInteractive node-based canvas with drag-and-drop (@xyflow/react)full
entity-filterSchema-driven filter controls with inline search resultsfull
toolInteractive tool with input form and output rendered inlinefull
notesEditable rich-text notes attached to an entityfull
documentsUploaded documents list for an entityfull
pdf-viewerInline PDF document viewerfull
response-formCriteria scoring form that submits a scored responsefull
menuNavigation menu or action listfull
resource-downloadDownload CTA shown after form completionfull
task-treeNested drag-to-reorder task list with inline add and checkbox togglefull
checklistFlat checkbox list with inline add and Cmd+Enter keyboard flowfull
plannerDay-planner composite: unscheduled task list + vertical hour-rail timeline with drag-to-schedule, drag-to-resize, and drag-to-unschedulefull

Task Blocks

Task-oriented blocks are designed to render inside saved views, not only on the standalone task pages:

  • checklist renders a compact checkbox list with inline add and hover states.
  • task-tree renders a nested task hierarchy with drag/drop, keyboard indent/outdent, checkbox completion, and inline add.
  • planner renders a day planner with a backlog column, scheduled task list, hour rail, drag-to-schedule, drag-to-unschedule, and drag-to-resize.

These blocks use semantic work-model tokens (human, agent, and related background tokens) so task views inherit tenant theme changes instead of hardcoding product colors.

BlockDefinition Interface

Every block type is described by a BlockDefinition — a single self-contained object that replaces the previous pattern of editing 5+ files to add a block type. Definitions live in features/blocks/definitions/ (one file per type) and are registered into blockDefinitions (BlockDefinitionRegistry) on import.

interface BlockDefinition<TConfig = Record<string, unknown>, TData = unknown> {
  type: BlockType;
  meta: BlockMeta;
  configSchema: z.ZodType<TConfig>; // machine-readable contract — drives agents + editor
  dataRequirement: DataRequirement;
  resolve: DataResolver<TConfig, TData>;
  component?: ComponentType<{ block: ResolvedBlock }>;
  editComponent?: ComponentType<{
    block: ResolvedBlock;
    onSave: (data: unknown) => void;
  }>;
  configPanel?: ComponentType<unknown>;
  validate?: (config: TConfig) => { valid: boolean; errors?: string[] };
}

BlockMeta carries static metadata:

interface BlockMeta {
  label: string;
  description: string;
  category: BlockCategory; // "data" | "entity" | "content" | "forms" | "activity" | "interactive" | "tools"
  defaultSpan: number; // 1-12 column span
  minSpan?: number;
  responsiveStack?: boolean;
  source: BlockSource;
  display: BlockDisplay;
  role?: "display" | "input" | "both"; // drives response mode behaviour
  emitsFilters?: boolean; // can emit cross-view filters (e.g. chart clicks)
  acceptsFilters?: boolean; // responds to cross-view filters
}

Use defineBlock() to create a frozen definition:

import { defineBlock } from "@/features/blocks/definition";

export const chartDefinition = defineBlock({
  type: "chart",
  meta: { label: "Chart", category: "data", defaultSpan: 6, ... },
  configSchema: z.object({ chartType: z.enum(["bar", "pie", ...]), groupBy: z.string(), ... }),
  dataRequirement: "entity-list",
  resolve: async (ctx, config) => { /* pure resolver using ctx */ },
});

The configSchema is the single source of truth for what an agent can configure and what the editor auto-generates as a config panel.

BlockDefinitionRegistry

blockDefinitions is the global registry, exported from features/blocks/definition.ts:

blockDefinitions.get(type); // → BlockDefinition | undefined
blockDefinitions.listAll(); // → BlockDefinition[]
blockDefinitions.listByCategory(cat); // → BlockDefinition[]
blockDefinitions.introspect(type); // → BlockIntrospection (simplified for agents)
blockDefinitions.introspectAll(); // → BlockIntrospection[]

resolveView() reads from this registry; so does the listBlockTypes agent tool and the block palette in the view editor. All 35 block definitions are registered at module init via the features/blocks/definitions/index.ts barrel.

FilterEngine

FilterEngine (features/blocks/server/filter-engine.ts) centralises all filter logic that was previously duplicated across individual block resolvers.

class FilterEngine {
  // Apply AND-chained filter rules to in-memory rows
  apply(rows: DataRow[], filters: FilterRule[]): DataRow[];

  // Extract FilterRule[] from block config.filters (handles {field: value} shorthand)
  parseFromBlockConfig(
    config: Record<string, unknown>,
    schemaProps: Record<string, unknown>,
  ): FilterRule[];
}

export const filterEngine = new FilterEngine(); // singleton

All resolvers that previously re-implemented filter parsing (chart, data-table, kanban, stat-cards, entity-filter) now call filterEngine.apply() on their resolved rows.

BlockConfig

The serializable configuration stored in views.blocks JSONB:

interface BlockConfig {
  id?: string;
  type: BlockType;
  label?: string;
  config?: Record<string, any>;
  size?: "full" | "half" | "third"; // legacy size preset
  span?: number; // 12-column grid span (1-12); preferred over size
  source?: BlockSource;
  display?: BlockDisplay;
  workflow?: BlockWorkflow;
  dataSourceId?: string; // references a named data source on the view
}

The config object is type-specific. For example, a chart block might have { chartType: "area", groupBy: "status", series: ["count", "value"] }, while a table block might have { limit: 20, sortBy: "created_at" }.

Legacy config keys such as field-card.config.fieldName and stat-cards.config.stats are canonicalized to the modern fields: string[] shape during normalization and view parsing. Runtime resolvers assume the canonical shape.

Data Sources

Views carry a named dataSources map (Record<string, DataSourceConfig>) that defines reusable entity queries. Blocks reference a data source by setting dataSourceId. This avoids repeating the same entity type, filter, sort, and limit config across multiple blocks on the same view.

interface DataSourceConfig {
  entityTypeSlug: string;
  filters?: FilterRule[];
  sort?: { field: string; order: "asc" | "desc" };
  limit?: number;
}

interface FilterRule {
  field: string;
  operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "contains";
  value: unknown;
}

When resolveView() processes a block with a dataSourceId, it looks up the data source and merges its entityTypeSlug, filters, limit, sort, and relation-column config into the block config before delegating to the resolver. Block-level config values take precedence over data source defaults. For data-table, the source limit is also treated as pageSize.

Data Requirement

Every block type declares a dataRequirement in features/blocks/lib/block-metadata.ts. The unified resolver uses this to classify blocks into resolution buckets:

RequirementDescriptionExample types
entity-listNeeds a collection of entity rowstable, chart, kanban, entity-feed
entity-singleNeeds one entity's full datafield-card, summary, radar, connection-list
aggregationNeeds computed counts or statsstat-cards
activityNeeds the activity logactivity
noneCarries its own data in config (passthrough)rich-text, tool, form-flow, image

Block Compatibility Groups

Block types are organized into four compatibility groups. Types within the same group share a data source shape and can be swapped without losing semantic meaning. This powers the visual editor's "swap type" feature.

GroupBlock TypesData Shape
collectiontable, data-table, kanban, entity-feed, ranking, child-entity-list, connection-listEntity rows with fields
chartchart, radar, bubble-chartNumeric series / axes
singleentity-card, field-card, summarySingle entity or field
contentrich-text, status-bannerStatic text / markup

Chart Block Config

The chart block type accepts a richer config object for both inline datasets and entity-backed aggregations:

interface ChartData {
  items: Array<{
    name: string;
    value: number;
    key?: string;
    [key: string]: unknown;
  }>;
  chartType?: "bar" | "pie" | "donut" | "line" | "area" | "stacked-bar";
  orientation?: "vertical" | "horizontal"; // bar only; vertical = horizontal bars
  colorThresholds?: Array<{ min: number; max: number; color: string }>;
  footerStats?: Array<{ label: string; value: string }>;
  series?: string[]; // stacked-bar only: data keys to stack
}
chartTypeDescription
barVertical bars (default). orientation: "horizontal" flips to horizontal bars.
piePie chart with percentage labels on each slice.
donutPie chart with a 50% inner radius cutout.
lineLine chart with dots at each data point.
areaArea chart with a gradient fill beneath the line.
stacked-barStacked bar chart. Pass series to name each stack layer.

colorThresholds drives per-bar color in bar mode: bars whose value falls in a threshold range get that threshold's color instead of the default palette. This is useful for RAG (red/amber/green) status bars.

footerStats renders a small grid of label/value pairs below the chart — useful for showing summary totals alongside a distribution chart.

When a chart is entity-backed, the block config can derive items from records instead of storing them inline:

interface ChartBlockConfig {
  entityTypeSlug?: string;
  groupBy?: string;
  dataSourceField?: string;
  aggregation?: "count" | "sum" | "avg" | "min" | "max";
  sortBy?: "value" | "label";
  sortOrder?: "asc" | "desc";
  maxGroups?: number;
  labelMap?: Record<string, string>; // custom display label per group key
  items?: Array<Record<string, unknown>>; // optional inline data override
  footerStats?: Array<{ label: string; value: string }> | "auto";
  clickAction?: "list" | "detail";
  clickFilterField?: string;
  clickEntityTypeSlug?: string;
  // Display options (see ChartDisplayOptions below)
  paletteId?: string;
  xAxisLabel?: string;
  yAxisLabel?: string;
  showLegend?: boolean;
  showDataLabels?: boolean;
  showGrid?: boolean;
  valueFormat?: "number" | "currency" | "percent" | "compact";
  valuePrefix?: string;
  valueSuffix?: string;
  legendPosition?: "top" | "bottom" | "left" | "right";
  xAxisTickAngle?: number;
  yAxisMin?: number;
  yAxisMax?: number;
  chartHeight?: number;
  referenceLines?: Array<{
    value: number;
    label?: string;
    color?: string;
    style?: "solid" | "dashed";
  }>;
}
  • aggregation + dataSourceField let the same block power count charts, value rollups, and grouped KPI visuals from any record type.
  • labelMap maps raw group key values to custom display labels rendered in the chart and legend. For example { "under_3_months": "< 3 mo" } without changing the underlying data. Sort-by-label uses the display label.
  • items is respected when present, so agent-generated or saved transient views can keep custom chart datasets.
  • footerStats: "auto" generates footer stat cards automatically from the resolved chart items — one card per group with a non-zero count, formatted as "<value> · <count>". Pass an explicit array to override with hand-crafted stat pairs.
  • clickAction: "list" turns grouped charts into drill-down entry points that navigate to filtered list views with exact-match URL filters.

Chart Display Options

ChartDisplayOptions controls visual presentation and is read from block.config at render time:

interface ChartDisplayOptions {
  xAxisLabel?: string; // Label rendered below the X axis
  yAxisLabel?: string; // Label rendered to the left of the Y axis (rotated)
  showLegend?: boolean; // Default: true for pie/donut/stacked-bar; true for all types
  showDataLabels?: boolean; // Default: true for bar; false for line/area/pie
  showGrid?: boolean; // Default: true for Cartesian charts
  valueFormat?: "number" | "currency" | "percent" | "compact";
  valuePrefix?: string; // Prepended to formatted value (e.g. "$")
  valueSuffix?: string; // Appended to formatted value (e.g. "%")
  legendPosition?: "top" | "bottom" | "left" | "right"; // Default: "bottom"
  xAxisTickAngle?: number; // Rotation in degrees (e.g. -45 for angled labels)
  yAxisMin?: number; // Override Y-axis minimum (useful for non-zero baselines)
  yAxisMax?: number; // Override Y-axis maximum
  paletteId?: string; // Named color palette (see Color Palettes section)
}

Y-axis tick values auto-format to compact notation (e.g. 42K, 1.2M) when values exceed 1,000. This is applied automatically regardless of valueFormat to prevent unreadable axis labels.

Value formatting is handled by formatChartValue() in features/blocks/lib/chart-format.ts, shared between chart renderers and export utilities.

Chart defaults (2026-04-17)

ChartDisplayOptions above lists every available knob, but callers rarely set them all. resolveDisplayOptions() in features/blocks/lib/chart-defaults.ts merges user config over sensible defaults so {chartType: "bar", groupBy: "stage"} alone produces a production-quality chart.

  • xAxisLabel / yAxisLabel — Auto-derived from groupBy (via groupByLabel()) and aggregation + dataSourceField (via metricLabel()). For horizontal bar charts the axes are swapped. groupByLabel strips common field suffixes: owner_id → "Owner", priority_slug → "Priority", created_at_month → "Month", closed_at_quarter → "Quarter". metricLabel handles aggregation modes: count → "Count"; sum + amount → "Total amount"; avg + score → "Average score"; min/max + field → "Minimum/Maximum {field}".
  • showLegendtrue by default for pie/donut and multi-series stacked-bar; false for single-series bar/line/area. Explicit false always wins.
  • tooltipLabel — Auto-populated from metricLabel(aggregation, dataSourceField) so the tooltip never reads a misleading "Count" when the chart is actually summing amounts.
  • showGridtrue for Cartesian charts; skipped for pie/donut.

Every default is override-able. Passing an explicit value (even "", false, 0) suppresses the corresponding default. The merge is key-wise with undefined as the "unset" sentinel.

Grouping dimension in tooltips

ChartTooltipContent now accepts a dimensionLabel prop threaded through SharedProps from ChartBlock. When set, the tooltip renders the dimension as an uppercase subtitle row above the category label, so readers can tell what's being grouped without having to read the title. Pie/donut, bar, stacked-bar, line, and area all show this row.

Single-color bars by default

For single-series bar charts with no colorMap and no colorThresholds, every bar now shares palette[0] via a single fill prop on <Bar> — no per-item <Cell> rendering. Rainbow-per-bar (previous default) was the reported "looks AI-generated" complaint. Users who want per-category color distinction opt in via colorMap: { "Closed Won": "…", "Closed Lost": "…" } or via value-banded colorThresholds. Pie/donut keep palette rotation (their whole purpose is categorical distinction).

Default item caps + truncation footnote

buildGroupedChartItemsWithMeta() in features/blocks/lib/chart-data.ts returns {items, totalGroups, truncatedCount}. When config.maxGroups is unset, defaultMaxGroups(chartType, orientation) applies a sensible cap:

Chart typeOrientationDefault maxGroups
bar / stacked-barhorizontal20
bar / stacked-barvertical50
line / area50
pie / donutnone (user-configured only)

When the cap trims groups, ChartBlock renders a "Showing top N of M" footnote below the chart so readers know they're looking at a truncated view. Scroll-within-card was rejected as a known trackpad scroll-trap UX anti-pattern.

Stacked-bar diagnostic

Setting chartType: "stacked-bar" without populating series: string[] previously produced a silent fake-stacked bar (internally series ?? ["value"]). Now:

  1. diagnoseChartBlock() emits a level: "warning" diagnostic with message "Stacked bar needs series; showing a plain bar chart. Add series fields or switch chart type to 'bar'.".
  2. ChartBlock.renderChart() detects the empty-series case and calls renderBarChart() instead.
  3. The warning appears as a banner above the fallback chart, not hidden behind the previous items.length === 0 gate.

This applies to both supervised (chat-driven) and autonomous (heartbeat-driven) chart generation via manageView / manageTasks.

Color Palettes

Six named palettes are available via CHART_PALETTES in lib/chart-colors.ts:

IDLabelDescription
defaultDefaultPlatform CSS variables (--chart-1 through --chart-8)
blue-scaleBlue ScaleMonochromatic blue progression
warmWarmOrange/red/amber tones
coolCoolTeal/cyan/green tones
earthEarthBrown/tan/olive tones
vividVividHigh-chroma mixed hues

All non-default palettes use oklch() values for perceptual uniformity. Use getPaletteColors(paletteId) from lib/chart-colors.ts to resolve a palette to a string[] of color values, with automatic fallback to the default palette for unknown IDs.

The config panel renders a palette picker with a live color swatch preview (5 swatches per palette).

Reference Lines

Bar, line, area, and stacked-bar charts support horizontal reference lines for targets, benchmarks, and thresholds:

referenceLines?: Array<{
  value: number;       // Y-axis position
  label?: string;      // Text displayed alongside the line (e.g. "Target")
  color?: string;      // CSS color string; defaults to muted-foreground
  style?: "solid" | "dashed"; // Default: "dashed"
}>

Reference lines are added and removed via the chart config panel. They are rendered using Recharts' ReferenceLine component inside the same coordinate system as the chart data. Reference lines are not supported on pie or donut charts.

Chart Schema Healing

The healing system detects misconfigured chart blocks and provides auto-fix suggestions. It lives in features/blocks/lib/chart-healing.ts.

// Analyze a chart block config against the entity type's schema
const report = diagnoseChartBlock(block, schemaProperties);
// report.isHealthy — false when any error-level diagnostic exists
// report.diagnostics — array of { level, message, field, suggestion }

// Apply all auto-fix suggestions to produce a corrected config
const fixedConfig = applyChartHealing(block.config, report);

Diagnostic levels:

LevelMeaningExample
errorChart cannot renderNo data type selected, group-by field missing from schema
warningChart may produce misleading outputAggregation set to sum but no measure field selected
infoImprovement suggestionNo axis labels configured

Surfaces:

  • Empty state — When a chart block has no data items, the empty state runs diagnoseChartBlock() and renders each diagnostic message with its level icon. Warnings and errors include the suggested fix value.
  • Config panel — A diagnostics banner appears at the top of the chart config panel when any error or warning exists. A one-click "Auto-fix" button calls applyChartHealing() and applies the result.

Field inference — The healing engine uses pattern matching against known category-field names (status, stage, category, type, priority, etc.) and numeric-field names (value, amount, cost, score, etc.) to suggest sensible groupBy and dataSourceField values when fields are missing or renamed.

Bubble Chart Block Config

bubble-chart is now a first-class configurable block for opportunity-map style views:

interface BubbleChartBlockConfig {
  entityTypeSlug?: string;
  labelField?: string;
  xField?: string;
  yField?: string;
  zField?: string;
  xLabel?: string;
  yLabel?: string;
  zLabel?: string;
  tooltipFields?: string[];
  limit?: number;
  sortBy?: string;
  sortOrder?: "asc" | "desc";
  height?: number;
  items?: Array<Record<string, unknown>>; // inline data override
  clickAction?: "list" | "detail";
  clickFilterField?: string;
  clickEntityTypeSlug?: string;
}

When xField/yField are configured, the server block resolver loads records for the selected type, maps the chosen fields into bubble coordinates, attaches entityId, and passes through any requested tooltip fields. This makes bubble charts reusable across any view, not just hardcoded dashboards.

Block types not in any group (stat-cards, activity, image, video, excalidraw, react-flow, entity-graph, custom) are standalone and cannot be swapped.

Swapping preserves shared config. When swapping between compatible types, the swapBlockType() function copies shared config keys (entityTypeSlug, limit, filters, relationshipType, mode) from the old block to the new one and resets renderer-specific config. This means swapping a table to kanban keeps the entity type and filter settings but drops table-specific column config.

import {
  swapBlockType,
  getCompatibleTypes,
} from "@/features/blocks/lib/compatibility";

// Find what a table block can be swapped to
getCompatibleTypes("table");
// → ["data-table", "kanban", "entity-feed", "ranking", "child-entity-list", "connection-list"]

// Swap a table block to kanban, preserving shared config
const kanbanBlock = swapBlockType(tableBlock, "kanban");

The compatibility module is at features/blocks/lib/compatibility.ts.

ResolvedBlock

A BlockConfig augmented with server-fetched data, ready for rendering:

interface ResolvedBlock extends BlockConfig {
  data: unknown;
  mode: "view" | "edit";
  status?: BlockStatus;
}

The data field contains whatever the block type needs to render. For a chart block, this is { items: [{name, value}], chartType }. For a field-card, it includes the field value, display type, and schema metadata.

BlockStatus

Blocks can carry a status that reflects their position in a workflow:

type BlockStatus =
  | "empty" // No data yet
  | "pending" // Waiting to be populated
  | "blocked" // Depends on another block
  | "in-progress" // Currently being worked on
  | "waiting-human" // Needs human input
  | "failed" // Population failed
  | "complete"; // Successfully populated

Block Sizes and the 12-Column Grid

Every block controls its width through one of two systems:

Legacy size presets (backward-compatible):

  • full -- Spans the entire row
  • half -- Takes half the row (two blocks per row)
  • third -- Takes one-third of the row (three blocks per row)

Numeric span (preferred for new views):

Set span to any integer from 1 to 12. When any block in a view has a span value, BlockGrid switches to a native 12-column CSS grid (grid grid-cols-12 gap-4) and assigns each block a col-span-{n} class.

// Examples
{
  span: 12;
} // full width (equivalent to size: "full")
{
  span: 6;
} // half width (equivalent to size: "half")
{
  span: 4;
} // one-third (equivalent to size: "third")
{
  span: 3;
} // one-quarter
{
  span: 8;
} // two-thirds

resolveSpan() in features/blocks/lib/layout.ts maps a block to its effective column count — it prefers span, falls back to the size preset mapping (full→12, half→6, third→4), and defaults to 12. hasSpanBlocks() returns true when at least one block in the array uses numeric span, which is the condition that activates the 12-column grid.

ViewLayout

The BlockGrid component arranges blocks according to a layout option:

const VIEW_LAYOUTS = ["stack", "grid-2", "grid-3", "bento", "single"] as const;
  • stack -- Single column, every block is full width
  • grid-2 / grid-3 -- Fixed-column grids ignoring block size hints
  • bento -- Responsive grid respecting each block's size property
  • single -- Only renders the first block, full width

Block Workflow Metadata

Blocks can carry workflow metadata that drives orchestration when blocks are used as work units:

interface BlockWorkflow {
  instructions?: string;
  assignee?: {
    type: "human" | "agent";
    agentSlug?: string;
  };
  dependsOn?: string[]; // IDs of blocks that must complete first
  required?: boolean;
  output?: {
    type:
      | "response"
      | "entity"
      | "entities"
      | "relation-entity"
      | "document"
      | "status"
      | "none";
    fields?: string[];
    entityTypeSlug?: string;
    key?: string;
  };
}

This metadata is handed to the action/session runtime for action-enabled detail blocks.

How It Works

The Rendering Pipeline

ViewRecord (view.blocks + view.dataSources)
    |
    v
resolveView()          -- unified server-side resolver
    |  classifies blocks by dataRequirement, applies dataSources
    |  delegates to entity-single / entity-list / aggregation / activity resolvers
    v
ResolvedBlock[]        -- config + data, in original block order
    |
    v
BlockGrid / BlockRenderer  -- client-side rendering
    |
    v
Registered Components  -- type-specific React components

Unified Server-Side Resolution

resolveView() in features/blocks/server/resolve-view.ts is the single entry point for all view resolution. It accepts a ViewRecord and a ResolveViewContext, builds a ResolveContext (tenant ID, entity context, shared FilterEngine), then dispatches to each block's BlockDefinition.resolve() function from the BlockDefinitionRegistry. All blocks resolve in parallel.

ViewRecord (view.blocks + view.dataSources)


buildResolveContext()  — entity data, FilterEngine, mode

    ▼  (per block, parallel)
BlockDefinitionRegistry.get(block.type).resolve(ctx, config)


ResolvedBlock[]        — config + data, in original block order

Blocks with dataRequirement: "none" (rich-text, tool, form-flow, image, etc.) have passthrough resolvers that return block.config directly as data. Collection and aggregation resolvers delegate to Supabase queries using the shared FilterEngine to apply filters. Entity-single resolvers use the entity context from ResolveContext.

When a block has a dataSourceId, resolveView() merges the data source's entityTypeSlug, filters, limit, sort, and relation-column config into the block config before delegation. Block-level config values override data source defaults.

Data is pre-fetched once per entity type when multiple blocks need it, avoiding N+1 queries.

Schema-Driven Config Panels

SchemaConfigPanel in features/blocks/components/schema-config-panel.tsx generates a config form automatically from a Zod schema. Any Zod object schema with .describe("Label") annotations on its fields produces the appropriate form control without writing bespoke form UI.

// Define a config schema with describe() labels
const myBlockConfigSchema = z.object({
  entityTypeSlug: z.string().optional().describe("Record Type"),
  limit: z.number().optional().describe("Max Items"),
  showFooter: z.boolean().optional().describe("Show Footer"),
});

// SchemaConfigPanel renders the right control for each field type:
// string → StringField, number → NumberField, boolean → BooleanField, enum → EnumField
<SchemaConfigPanel schema={myBlockConfigSchema} value={config} onChange={setConfig} />

Ten field widgets live in schema-config-fields.tsx: StringField, NumberField, BooleanField, EnumField, FieldSelectorField, FieldMultiSelectField, EntityTypeSelectField, EntityTypeMultiSelectField, RelationTypeMultiSelectField, CriteriaSetSelectField, JsonField.

Block Registration (Plugin Pattern)

New block types use defineBlock() (see BlockDefinition Interface) — one object per type under features/blocks/definitions/. The definition includes both the resolver and the rendering component.

Legacy block components are also registered via registerBlock() in features/blocks/registry.ts for backward compatibility:

interface BlockRegistration {
  component: ComponentType<{ block: ResolvedBlock }>;
  editComponent?: ComponentType<{
    block: ResolvedBlock;
    onSave: (data: unknown) => void;
  }>;
  defaultSize?: "full" | "half" | "third";
}

registerBlock("chart", {
  component: ChartBlock,
  defaultSize: "half",
});

The BlockRenderer component looks up the registration for a block's type and dispatches to the appropriate component. When mode="edit" and an editComponent is registered, the edit variant is rendered instead.

All block components are registered in the client-side barrel file at features/blocks/components/index.ts. Import this barrel once in any page that renders blocks. For the embed route, app/embed/v/[token]/embed-view-client.tsx imports this barrel alongside features/custom/tools/ui to ensure both block types and custom tool UIs are available in the public iframe context.

BlockGrid Layout

BlockGrid arranges ResolvedBlock[] according to a ViewLayout:

  • In bento mode, blocks flow into a responsive CSS grid and use their size property to span 1/3, 1/2, or full width.
  • An onSave callback can be passed to BlockGrid to persist inline edits (e.g., field-card editing on detail pages).

Bridge Functions

Bridge functions convert data from other systems into blocks:

FunctionSourceLocation
entityToBlocks()Entity fields + contentfeatures/blocks/lib/from-entity.ts
toolOutputToBlocks()Tool output sectionsfeatures/blocks/lib/from-tool-output.ts
chatOutputToBlocks()Chat tool outputfeatures/blocks/lib/from-chat.ts
transientSpecToBlocks()Agent-generated transient viewsfeatures/blocks/lib/transient-view.ts
migrateDashboardSections()Legacy dashboard configfeatures/blocks/server/migrate.ts
filterConnectionEntities()Connection field datafeatures/blocks/lib/from-entity.ts

entityToBlocks() is particularly important. It converts an entity type's JSON schema properties and an entity's content into field-card and connection-list blocks. It handles:

  • Smart sizing based on field content length and type (long text gets full, enums get third, arrays get half)
  • Connection field detection (emits connection-list blocks instead of field-card)
  • Extraction status mapping to BlockStatus
  • Empty field visibility control

toolOutputToBlocks() converts ToolOutputSection[] definitions into the corresponding block types. This is how tool results render as rich visual blocks in both the tool page and chat panel: a summary section becomes a summary block, a table section becomes a table block, and so on.

The field-card Block

The field-card block is the workhorse of entity detail pages. It renders a single field value with smart display logic powered by classifyValue():

  • currency -- Formatted with locale-specific currency symbols
  • percentage -- Shown with % suffix
  • date -- Parsed and formatted
  • boolean -- Rendered as a badge
  • tags -- Rendered as a tag list
  • url -- Rendered as a clickable link
  • array -- Rendered as a comma-separated list or bullet list

In edit mode, field-card uses an inline edit UI with the same styling as view mode (avoiding Card component padding mismatch).

The radar Block

The radar block unifies entity scoring with radar chart visualization. In view mode, it displays scoring criteria as a radar/spider chart. In edit mode, it shows sliders for each criterion alongside a live radar chart preview. Saving persists scores to entity.metadata.

The form-flow Block

The form-flow block provides multi-step form wizards with built-in validation, progress tracking, and step navigation. In view mode, it shows a progress bar and step checklist. In edit mode, it renders a full interactive wizard with Back/Next navigation, per-step field validation, and a completion summary.

Config shape (FormFlowData):

  • config -- FormFlowConfig with steps, fields, completion message
  • values -- collected field values keyed by field name
  • completedStepIds -- array of completed step IDs
  • currentStepIndex -- index of the current step
  • completed -- whether the form flow is done

Field types: text, textarea, rich-text, number, slider, date, select, multi-select, tag-input, entity-picker, file-upload.

file-upload field type (FileUploadField in features/blocks/components/form-flow-file-upload.tsx):

Config keyTypeDescription
acceptstringComma-separated MIME types or extensions (e.g. ".pdf,.docx,image/*")
maxFilesnumberMaximum number of files allowed (default: unlimited)
maxSizeMbnumberMaximum size per file in megabytes

Files are converted to base64 data URLs by FileReader and stored in FormFlowData.values[fieldName] as FileUploadMeta[]:

interface FileUploadMeta {
  name: string;
  size: number;
  type: string;
  url?: string; // base64 data URL
}

The component supports drag-and-drop and click-to-browse. When maxFiles is reached, the drop zone becomes disabled. Files can be individually removed from the list.

FormFlowConfig.output — when set, completing a form-flow triggers entity creation via POST /api/form-flow/complete:

interface FormFlowConfig {
  steps: FormFlowStep[];
  completionMessage?: string;
  allowSkipOptional?: boolean;
  /** When set, form completion creates an entity from collected values. */
  output?: {
    type: "entity";
    entityTypeSlug: string;
    /** Template for entity title. Use {fieldName} for interpolation. */
    titleTemplate?: string;
  };
}

The titleTemplate supports {fieldName} interpolation against the collected values. For example "AI Enablement Intake: {full_name}" produces a title like "AI Enablement Intake: Jane Smith". The entity creation call is guarded by the publishToken of the embed — callers without a valid published view token receive a 403.

Templates: Two built-in templates (discovery-workshop, ai-readiness-assessment) are available via createFormFlowFromTemplate() in features/blocks/components/form-flow-templates.ts. Custom configs can be created via createFormFlowFromConfig().

The tool Block

The tool block renders any registered tool — its input form on the left and its output panel on the right — inline inside any view. It is the mechanism that allows tool surfaces to appear in entity detail pages, standalone views, and public embeds without navigating to /tools/[slug].

Config shape:

KeyTypeDescription
toolSlugstringSlug of the registered tool to render.
defaultInputsRecord<string, unknown>Pre-filled form values shown when the block first loads.

Data resolution:

The tool block has dataRequirement: "none" in block metadata, meaning resolveView() classifies it as a passthrough block. The server passes block.config directly as block.data. No server-side tool data is pre-fetched — the tool metadata (including jsonSchema) is loaded client-side when the user first views the block.

On the client, ToolBlock reads block.data.tool (a ToolMeta object with jsonSchema). When tool data is unavailable, it shows an EmptyState with the configured toolSlug as context. When tool data is present, it renders a two-column layout:

  • Left columnCustomForm from the tool's UI registration, or GenericToolForm derived from the tool's jsonSchema.
  • Right columnCustomOutput, SectionedOutput (if the tool declares outputSections), or GenericToolOutput.

Execution calls POST /api/tools/[slug]/run with the submitted input and shows a toast with duration on success.

Embed use case:

The tool block is the primary content type in published views created via publishToolAsView(). That server action creates a view with a single tool block (size: "full", layout: "single") and immediately publishes it, returning the embed snippet and preview URL in one call.

The resource-download Block

The resource-download block renders a completion screen with an optional file download CTA. It is designed as the final slide/page of a guest quiz flow — shown after the form-flow block completes.

Config shape (ResourceDownloadConfigSchema):

KeyTypeDefaultDescription
titlestring"Download Resource"Heading text
descriptionstring"Thank you for completing this survey!"Body text
downloadUrlstringURL of the file to download
fileNamestringDisplay file name shown below the button
buttonLabelstring"Download"CTA button label

When downloadUrl is present, the component renders a large download button using the <a download> attribute. When omitted, only the completion message and icon are shown (useful as a "thank you" slide without a file).

Config values can also come from block.data — the component merges block.config and block.data so agent-generated or dynamically resolved data can override static config at runtime.

The stat-cards Block

The stat-cards block renders a responsive grid of KPI cards. Each card in the items array can carry a change string and/or an explicit trend field to show a directional indicator:

interface StatItem {
  label: string;
  value: string | number;
  format?: "number" | "currency" | "percent" | "compact"; // display format
  change?: string; // e.g. "+12%", "-0.2", "flat"
  trend?: "up" | "down" | "flat"; // explicit override
  description?: string;
  icon?: string;
  iconColor?: string;
  iconBg?: string;
}

When a stat-cards block is entity-backed (using entityTypeSlug + fields), two additional config options control how numeric fields are aggregated and displayed:

interface StatCardsConfig {
  entityTypeSlug?: string;
  fields?: string[];
  fieldAggregations?: Record<string, "sum" | "avg" | "min" | "max" | "count">;
  fieldFormats?: Record<string, "number" | "currency" | "percent" | "compact">;
  limit?: number;
}
  • fieldAggregations sets the aggregation mode per field. Defaults to avg for numeric fields. Use sum for portfolio-level totals (e.g. total annual value across all opportunities), count for cardinality.
  • fieldFormats sets how the aggregated value is displayed per field. Values are formatted via formatChartValue()currency renders with locale currency symbol, percent appends %, compact uses magnitude suffixes (K, M, B).

The stat-cards config panel (stat-cards-config.tsx) exposes per-field aggregation and format selectors in the UI when fields are selected, so users can configure these without editing JSON directly.

Trend direction is inferred automatically from the change string prefix by inferTrend() in features/blocks/lib/trend-utils.ts:

  • Strings starting with + or "up" (green arrow icon)
  • Strings starting with -, , or (minus sign) → "down" (red arrow icon)
  • An explicit trend field overrides the inference
  • When neither can be determined, no icon is shown

Shared Chart Primitives

All Recharts chart components in the platform share constants from lib/chart-colors.ts:

ExportValuePurpose
CHART_COLORS["var(--chart-1)", …, "var(--chart-8)"]Default ordered palette for bars, lines, and pie slices
CHART_PALETTESChartPalette[]Six named palettes (Default, Blue Scale, Warm, Cool, Earth, Vivid)
getPaletteColors(id?)string[]Resolve a palette to its color array; falls back to CHART_COLORS
CHART_ANIMATION{ duration: 800, easing: "ease-out" }Entrance animation applied to every data series
CHART_TOOLTIP_STYLECSS properties objectConsistent tooltip card appearance
CHART_AXIS_TICK{ fontSize: 12, fill: "var(--muted-foreground)" }Standard axis label style

The chart block's renderer functions are split into individual files under features/blocks/components/chart-renderers/:

FileExports
bar-chart.tsxBarChartRenderer (vertical + horizontal)
line-chart.tsxLineChartRenderer
area-chart.tsxAreaChartRenderer
pie-chart.tsxPieChartRenderer (pie + donut)
stacked-bar-chart.tsxStackedBarChartRenderer
axis-helpers.tsxXAxisTick, YAxisTick, shared axis utilities
shared.tsxTooltipContent, LegendContent, shared primitives
types.tsChartRendererProps, shared render types
index.tsRe-exports all renderers

Import via the index for convenience: import { BarChartRenderer } from "@/features/blocks/components/chart-renderers". Each renderer is independently reusable on any surface.

All Cartesian charts (bar, line, area, stacked-bar) include Recharts' accessibilityLayer prop, which injects ARIA attributes and keyboard navigation into the SVG output.

Shared utilities extracted from chart components:

ModuleExportsPurpose
features/blocks/lib/chart-format.tsformatChartValue(value, format?, prefix?, suffix?)Numeric formatting for chart labels and tooltips (number, currency, percent, compact)
features/blocks/lib/trend-utils.tsinferTrend(change?, trend?)Infer trend direction from a change string or explicit field
features/blocks/lib/chart-healing.tsdiagnoseChartBlock(), applyChartHealing()Chart config diagnostics and auto-fix
features/blocks/lib/chart-export.tsexportChartDataAsCsv(), exportChartAsPng()Client-side CSV and PNG export from chart DOM

The kanban Block — Column Configuration

The kanban block groups entities by an enum, boolean, or string field into draggable columns. Column computation lives in features/entities/components/kanban/group-by.ts via computeColumns().

An optional columnConfig in the block config controls column-level behavior:

interface ColumnConfig {
  visibleColumns?: string[]; // Only show these column IDs (enum values)
  columnLabels?: Record<string, string>; // Override column display titles
  wipLimits?: Record<string, number>; // Max items per column
}
  • Visibility filtering — When visibleColumns is set, only those columns plus the built-in UNCATEGORIZED and OTHER columns are shown. This lets users hide irrelevant pipeline stages.
  • Custom labelscolumnLabels maps column IDs (enum values) to display titles. Useful when the enum value is a slug but the user wants a friendlier label.
  • WIP limits — When wipLimits[columnId] is set and the column's item count exceeds it, a destructive Badge shows WIP {count}/{limit} on the column header.

The kanban config panel (kanban-config.tsx) exposes these options in the UI when the group field has enum values — visibility checkboxes, label inputs, and WIP limit number inputs per column.

Pipeline Kanban — Per-Column Value Aggregation

When valueField is set in the kanban block config, a summary row appears above each column showing an aggregated value for the cards in that column.

interface KanbanBlockConfig {
  groupByField?: string;
  columnConfig?: ColumnConfig;
  valueField?: string; // Entity content field to aggregate
  valueAggregation?: "sum" | "avg" | "count"; // Default: "sum"
  valueFormat?: "number" | "currency" | "percent" | "compact";
}

The aggregation is computed client-side from the resolved entity list. When cards are dragged between columns (which triggers an updateEntity() call to change the grouped field value), the summary row updates reactively without a full data refetch.

This is intentionally generic — any entity type with a status/stage field and a numeric value field can show pipeline value summaries. The valueField and valueAggregation are configured via the kanban config panel.

Status Transition Triggers

Entity types can define status triggers — agent actions that fire automatically when a kanban card moves from one column to another. Triggers are configured per entity type in Admin > Data Types > Triggers tab.

// Stored in entity_types.config.statusTriggers
interface StatusTrigger {
  id: string;
  fromStatus: string; // Source column enum value (or "*" for any)
  toStatus: string; // Destination column enum value
  agentSlug: string; // Agent to dispatch
  taskInstructions?: string; // Optional prompt injected into the agent task
}

When a drag-drop move completes:

  1. updateEntity() patches the entity's grouped field to the new column value
  2. POST /api/entities/[id]/trigger-status-transition evaluates config.statusTriggers for matching (fromStatus, toStatus) pairs
  3. Matching triggers dispatch an Inngest event: entity/status-transition
  4. The Inngest handler invokes the named agent with the entity and transition context as task input

The dispatched agent receives:

{
  entity: EntityRecord,
  fromStatus: string,
  toStatus: string,
  instructions?: string,
}

Triggers are read-only from the agent's perspective — agents don't configure them. Configuration is done by admins via the entity type admin UI or via the updateEntityTypeSchema admin tool.

External Data Sources for Dashboard Blocks

Stat-card and chart blocks can fetch data from tenant-registered external HTTP endpoints rather than (or in addition to) the Supabase entity store. This enables dashboard blocks to show data from CRMs, ERPs, or custom APIs alongside platform records.

External data sources are registered in Admin > Data Sources and stored in the external_data_sources table with HMAC-signed request authentication.

interface ExternalDataSource {
  id: string;
  tenant_id: string;
  name: string;
  endpoint_url: string;
  auth_type: "hmac" | "bearer" | "none";
  auth_secret?: string; // Used for HMAC signing
  response_schema: "stat-items" | "chart-items" | "entity-list";
}

To wire a block to an external source, set DataSourceConfig.external on the view's dataSources map:

{
  dataSources: {
    crm_pipeline: {
      entityTypeSlug: "",         // Empty — no entity query
      external: {
        sourceId: "ext-source-uuid",
        cacheTtlSeconds: 300,      // Optional client-side cache
      }
    }
  }
}

The block resolver calls the external endpoint server-side, validates the response against the declared response_schema using Zod, and returns the data in the same ResolvedBlock.data shape as entity-backed blocks. Failed external calls surface as BlockStatus.failed rather than crashing the entire view resolution.

HMAC authentication: Requests include an X-Sprinter-Signature header computed as HMAC-SHA256(secret, timestamp + "." + body). The receiving endpoint validates the signature and timestamp (±5 minutes) before processing.

Click Callbacks on Charts

Every chart component in the platform accepts an optional click callback. When the prop is provided, the chart sets cursor: pointer on interactive elements and fires the callback on click. When the prop is omitted, the chart behaves as a read-only visualization.

ComponentCallback propArguments
ChartBlockonItemClick(name: string, value: number) => void
EntityDistributionChartonBarClick(name: string, count: number) => void
ScoreDistributionChartonBarClick(title: string, score: number) => void
ScoreProgressionChartonPointClick(responseId: string, score: number) => void
ChartSection (tool output)onItemClick(name: string, value: number) => void
MultiSeriesRadarChartonSegmentClick(name: string, values: Record<string, number>) => void
BubbleChartonPointClick(point: BubbleChartDataPoint) => void
CategoryChartonBarClick(category: string, value: number) => void
EffortValueChartonBarClick(effort: string, value: number) => void
PaybackBandChartonBarClick(band: string, count: number) => void

ChartEmptyState

ChartEmptyState (in features/charts/components/chart-empty-state.tsx) replaces silent null returns from charts that have no data. It renders a dashed-border placeholder card with an icon, a primary message, and an optional description sub-text.

<ChartEmptyState
  message="No data to display"
  description="Data will appear here once available"
  icon={TrendingUp}
  className="h-[200px]"
/>

All chart blocks now return ChartEmptyState when their data array is empty, so dashboard layouts maintain consistent structure regardless of data state.

Inspector tabs in BlockConfigPanel

When the author clicks a block in the editor, BlockConfigPanel opens in the right-side inspector. Rather than a single long scroll, the panel is organised as:

  • Fixed header — block name + close X
  • Common strip — Label + Description, always visible
  • Tabs
    • Block — block-specific config (BlockSpecificConfig)
    • Data — data source picker (only rendered when hasDataBinding(block.meta.source) returns true; rich-text / content-only blocks hide this tab)
    • Layout — width toggle group (full / half / third / quarter)
    • JSON — the existing JsonToggle raw editor
  • Live Preview — sibling Collapsible anchored to the bottom of the panel (outside the tab panels), with aria-controls="live-preview-region" for screen-reader navigation

The active tab resets when the selected block changes — implemented by keying the inner panel on block.id so local state unmounts / remounts without an effect. hasDataBinding(source) lives at features/blocks/lib/has-data-binding.ts and is driven by BlockDefinition.meta.source.

API Reference

Registry (features/blocks/registry.ts)

FunctionSignatureDescription
registerBlock(type, registration)(type: BlockType, reg: BlockRegistration) => voidRegister a block component with optional edit variant.
getBlock(type)(type: BlockType) => BlockRegistration | undefinedLook up a registered block.
getRegisteredBlockTypes()() => BlockType[]List all registered block types.

Unified Resolver (features/blocks/server/resolve-view.ts)

FunctionSignatureDescription
resolveView(view, ctx)(view: ViewRecord, ctx: ResolveViewContext) => Promise<ResolvedView>Single entry point — classifies blocks by dataRequirement, applies dataSources, delegates to sub-resolvers, returns results in original block order.

ResolveViewContext accepts: tenantId, mode ("view" | "edit"), entity (for entity-single blocks), entityType, related, fieldConfigs, workflowStatusMap.

Layout Utilities (features/blocks/lib/layout.ts)

ExportSignatureDescription
resolveSpan(block)(block: { span?: number; size?: string }) => numberReturns column count (1-12). Prefers span, falls back to size preset, defaults to 12.
hasSpanBlocks(blocks)(blocks: Array<{ span?: number }>) => booleanTrue when any block uses numeric span — triggers 12-column CSS grid.
GRID_12_CLASSstring"grid grid-cols-12 gap-4" — applied to BlockGrid when span blocks are present.

Block Metadata (features/blocks/lib/block-metadata.ts)

FunctionSignatureDescription
getBlockMetadata(type)(type: BlockType) => BlockMetadataReturns label, icon, description, source, display, role, and dataRequirement for a block type.
getDataRequirement(type)(type: BlockType) => DataRequirementReturns "entity-list" | "entity-single" | "aggregation" | "activity" | "none".

Server Resolution (features/blocks/server/resolve-server-blocks.ts)

FunctionSignatureDescription
resolveServerBlocks(blocks, ctx)(blocks: BlockConfig[], ctx: ResolveServerBlocksContext) => Promise<ResolvedBlock[]>Hydrate block configs with server-fetched data. Called internally by resolveView() for entity-list, aggregation, and activity blocks. Prefer resolveView() for new call sites.

Bridge Functions

FunctionSignatureDescription
entityToBlocks(properties, content, mode, options?)Returns ResolvedBlock[]Entity fields to field-card + connection-list blocks.
toolOutputToBlocks(sections, output)Returns ResolvedBlock[]Tool output sections to typed blocks.
filterConnectionEntities(config, relatedEntities, content, fieldName)Returns filtered entitiesFilter related entities for a connection field by mode (auto/curated).

Chart Utilities

FunctionSignatureDescription
diagnoseChartBlock(block, schemaProperties?)(block: BlockConfig, props?: Record<string, unknown>) => ChartHealthReportAnalyze chart config against entity schema, return typed diagnostics.
applyChartHealing(config, report)(config: Record<string, unknown>, report: ChartHealthReport) => Record<string, unknown>Apply all auto-fix suggestions from a health report to produce a corrected config.
formatChartValue(value, format?, prefix?, suffix?)(value: number, ...) => stringFormat a number for chart display. Formats: number, currency, percent, compact.
inferTrend(change?, trend?)(change?: string, trend?: string) => "up" | "down" | "flat" | nullInfer trend direction from change string prefix or explicit trend field.
getPaletteColors(paletteId?)(id?: string) => string[]Resolve a named palette to its color array (lib/chart-colors.ts).
exportChartDataAsCsv(items, filename?)(items: ChartItem[], filename?: string) => voidExport chart data as RFC 4180-compliant CSV download.
exportChartAsPng(containerEl, filename?)(el: HTMLElement, filename?: string) => Promise<void>Export chart SVG as 2x retina PNG download.

Zod Schemas

SchemaDescription
BlockConfigSchemaValidates a single block config (type, label, config, size, workflow).
BlockConfigArraySchemaValidates an array of block configs.
BlockWorkflowSchemaValidates block workflow metadata (instructions, assignee, dependsOn, output).
ChartConfigSchemaPer-block config schema for chart blocks. Includes labelMap, footerStats ("auto" or static array), and all display options.
StatCardsConfigSchemaPer-block config schema for stat-cards blocks. Includes fieldAggregations and fieldFormats for entity-backed stat cards.
KanbanConfigSchemaPer-block config schema for kanban blocks. Includes columnConfig with visibleColumns, columnLabels, and wipLimits.
ColumnConfigSchemaNested schema for kanban column configuration (visibility, labels, WIP limits).

For Agents

Agents create blocks indirectly through several tools:

  • manageView -- Agents compose BlockConfig[] arrays when creating or updating views. This is the primary way agents author visual layouts.
  • generateView -- Creates a TransientViewSpec with inline block data for ephemeral chat displays. For chart blocks, the full config surface is available: chartType (bar, pie, donut, line, area, stacked-bar), orientation, series, colorThresholds, and footerStats.
  • updateEntityTypeSchema -- Changes to JSON schema affect how entityToBlocks() generates field-card blocks.
  • updateFieldConfig -- Changes to field configs affect block rendering (connection fields, extraction status, display types).

When authoring chart blocks, agents should pick the chart type that best matches the data shape:

  • bar / area / line — time-series or ordered categorical data with a single value per point
  • stacked-bar — multi-series composition data (pass series with the data keys to stack)
  • pie / donut — part-to-whole distribution with fewer than 8 categories
  • colorThresholds — use for RAG-status bars where color signals a range (e.g., red for score < 30, amber for 30–70, green for 70+)

Agents can also set display options directly in block.config to improve readability of generated charts:

// Example: agent-generated chart with display polish
{
  type: "chart",
  label: "Pipeline by Stage",
  config: {
    entityTypeSlug: "opportunity",
    groupBy: "stage",
    chartType: "bar",
    xAxisLabel: "Stage",
    yAxisLabel: "Count",
    valueFormat: "number",
    showLegend: false,
    paletteId: "blue-scale",  // one of: default, blue-scale, warm, cool, earth, vivid
    referenceLines: [{ value: 10, label: "Target", style: "dashed" }]
  }
}

When agents return tool results in chat, the chatOutputToBlocks() bridge converts tool output into blocks for rich rendering in the chat panel.

Cross-Block Filtering

The entity-filter block and ViewFilterProvider together enable interactive filtering that updates sibling blocks in the same view.

ViewFilterProvider (features/views/hooks/use-view-filters.tsx) wraps a view renderer and provides a React context for filter state keyed by dataSourceId. entity-filter blocks call setFilters(dataSourceId, rules) when the user applies filters.

useFilteredEntities (features/views/hooks/use-filtered-entities.ts) is a React Query hook for blocks that need to re-fetch when view-level filters change. The cache key includes the active filter state, so changing filters in a sibling block automatically triggers re-fetches for any block sharing the same dataSourceId. When no active filters exist, the hook is disabled and blocks use their SSR-resolved initialData — zero extra fetches on first render.

// In a block component that should react to cross-block filters:
const { data: entities } = useFilteredEntities(
  block.dataSourceId,
  block.config?.entityTypeSlug,
  { initialData: block.data?.entities, limit: 50 },
);

The entity-filter Block

The entity-filter block provides interactive schema-driven filtering of any entity type. It is the UI counterpart to the filterEntities AI tool: both use the same FilterFieldConfig shape derived from the entity type's JSON schema.

Config properties:

KeyTypeDescription
entityTypeSlugstringEntity type to filter. Can also be provided via block.data.entityType.slug.
visibleFieldsstring[]Limit which schema fields appear as filter controls. Omit to show all filterable fields.

Block data shape (resolved server-side):

{
  entityType?: EntityTypeRecord,  // Resolved entity type with json_schema
  entities?: EntityRecord[],      // Initial result set (pre-search)
  total?: number,                 // Total count for initial result set
  filterFields?: FilterFieldConfig[], // Pre-computed filter fields (overrides client derivation)
}

Rendering pipeline:

  1. resolveServerBlocks() hydrates the block with the entity type record and optionally a default result set.
  2. On the client, schemaToFilterFields() derives FilterFieldConfig[] from entityType.json_schema.properties, unless filterFields was already resolved server-side.
  3. EntityFilterControls renders the fields as a responsive grid (sm:grid-cols-2 lg:grid-cols-3).
  4. On "Search", active filter values are serialized and passed to GET /api/entities?typeSlug=&filters={} — the same endpoint used by data tables.
  5. Results render as a linked card list (up to 20 rows), with tags shown as badges.

Filter control types (from features/blocks/lib/filter-schema.ts):

ControlSchema conditionComponent
textstring with no enumInput
number / rangenumber or integerInput[type=number] with optional min/max
selectstring with enum (≤6 options)shadcn Select
multi-selectenum >6 options or array with items.enumTogglable Badge group
togglebooleanshadcn Switch

Active filter count is shown as a badge in the card header. A "Clear" button resets all filter values.

Design Decisions

Single unified resolver, not separate entry points per page type. resolveView() is the single entry point for all view resolution. Previously, entity detail pages, standalone views, and list views each had their own resolution code paths that diverged over time. A single resolver makes it easier to add features like data sources and cross-block filtering that need to work everywhere.

Data sources as named references, not per-block copies. dataSourceId is a reference into view.dataSources. The query config lives once on the view, and multiple blocks reference it by name. Changing the entity type or filter for a set of blocks only requires updating the data source, not each block individually. Block-level config can still override data source defaults when needed.

12-column grid coexists with legacy size presets. The old full/half/third size presets continue to work. resolveSpan() maps both to a column count (full→12, half→6, third→4). The 12-column grid only activates when at least one block uses a numeric span. This avoids a breaking change for existing views while enabling precise layout control for new ones.

Schema-driven config panels eliminate bespoke form UI. Block type config schemas annotated with Zod .describe() labels automatically get form controls via SchemaConfigPanel. This means adding a new config field to a block type requires only updating its Zod schema — no separate form component.

useFilteredEntities uses initialData from SSR by default. When no active view-level filters exist, the React Query hook is disabled and blocks use the server-resolved data they received as props. The hook only fires a client-side fetch when a sibling entity-filter block sets active filters. This keeps first-render performance at SSR speed for views without interactive filtering.

Blocks never fetch data. All data is resolved server-side via resolveView() and its internal helpers. This keeps block components pure renderers with predictable behavior, eliminates client-side waterfall requests, and ensures data is tenant-scoped through server-side auth.

Single registry, not a component hierarchy. The plugin pattern (registerBlock()) avoids deep component inheritance trees. Each block type is an independent component with a simple contract: receive a ResolvedBlock, render it. This makes adding new block types trivial.

Config is a loose Record<string, any>. Block config is intentionally untyped beyond the basic BlockConfigSchema. Each block type interprets its config object as needed. This keeps the schema flexible and avoids version migration issues when block types evolve.

Bridge functions as the integration seam. Rather than having tool output, entity data, and chat messages directly produce blocks, bridge functions provide an explicit conversion layer. This keeps the block system decoupled from its data sources and makes it easy to test each bridge independently.

Smart sizing in entityToBlocks(). The bento layout requires each block to declare its size. Rather than making users configure this for every field, entityToBlocks() uses heuristics: long text fields get full, enum fields get third, arrays get half. This produces a sensible layout automatically.

Chart renderers are functions, not components. The five renderer functions in chart-block-renderers.tsx return JSX directly rather than wrapping in a React component. This allows ChartBlock to pass a single root element to Recharts' ResponsiveContainer, which requires exactly one child. Wrapping each renderer in a component would add an extra DOM node that breaks ResponsiveContainer's measurement.

Click callbacks are opt-in, not opt-out. Charts that have no onItemClick prop render identically to the previous behavior. This avoids a breaking change for existing code that renders charts without interaction intent, and keeps the visual weight of cursor changes and hover states tied to surfaces where clicking is actually wired up.

ChartEmptyState replaces null returns. Previously, chart components returned null when data was empty, which caused blank gaps in bento grids. The empty state communicates to the user that the chart area is intentional and will be populated — it also prevents layout shifts when data arrives asynchronously.

Chart healing separates diagnosis from repair. diagnoseChartBlock() returns a typed ChartHealthReport rather than mutating the config in place. This allows the empty state and the config panel to both display diagnostics independently while applyChartHealing() is only called on explicit user action ("Auto-fix"). The separation also makes each function individually testable.

Color palettes use oklch() rather than hex. All non-default palette entries use oklch(lightness chroma hue) values. This ensures perceptual uniformity across palette variants and stays consistent with the platform's design system which expresses its primary color in oklch. Hex-defined palettes would require separate dark-mode overrides; oklch palettes look correct in both modes without duplication.

Bar chart legend uses a custom payload, not Recharts' default Cell indexing. When showLegend is enabled on a bar chart, the renderer explicitly builds a payload array from the resolved items ([{ value: item.name, type: "rect", color: … }]). Without this, Recharts derives legend entries from Cell indexes (0, 1, 2, …) which produces numeric labels instead of group names. The custom payload is the only way to guarantee correct name/color pairing when per-bar colors are driven by colorMap or colorThresholds.

labelMap is applied at resolution time, not render time. Substituting display labels in buildGroupedChartItems() rather than in the chart component means sort-by-label ordering uses the display label, which matches user expectation (e.g. "< 3 mo" sorts before "> 12 mo"). If the substitution happened only at render, the data array would still be sorted by raw key values.

footerStats: "auto" avoids seed config divergence. A static footerStats array must be kept in sync with the chart's live data — if groups change, the footer goes stale. The "auto" sentinel delegates generation to the resolver, which always reflects the current chart items. The static array form remains supported for views where the footer labels or values need to diverge from the chart items.

fieldAggregations defaults to avg, not sum. For most numeric fields (score, margin, growth rate), the average across a portfolio is more meaningful than the sum. Fields that represent totals (annual value, investment) require explicit sum in config. Defaulting to avg prevents accidental nonsense KPIs for non-additive measures.

Trend direction is extracted to a pure utility. inferTrend() in trend-utils.ts is a pure string → enum function with no React dependencies. This makes it trivially testable, allows reuse in future stat-card variants, and avoids importing component logic into non-component contexts like export utilities.

Chart value formatting is a shared utility, not inline logic. formatChartValue() in chart-format.ts was extracted from chart-block-renderers.tsx so the same formatting logic can be used in CSV export, tooltip formatters, axis tick formatters, and any future chart surface without duplication. The alternative — maintaining parallel implementations — caused subtle divergence in how large numbers were formatted in exports vs. in-chart labels.

Container-mode safety

When <BlockGrid> runs in responsive: 'container' mode (queued for a follow-up PR — see documents/work/2026-04-28-mobile-responsiveness-pr2-surfaces/decisions.md), any ancestor with container-type: inline-size re-anchors position: fixed descendants to the container instead of the viewport. Block authors adding floating UI need to know which patterns survive that flip.

Safe — Radix Portal usage. Anything that teleports content via Radix's Portal primitive (Popover, Tooltip, Dialog, DropdownMenu, ContextMenu, HoverCard) lands at document.body, outside the container. Examples in features/blocks/: block-config-panels/multi-combobox.tsx, canvas/canvas-entity-picker.tsx, canvas/materialize-popover.tsx, canvas/nodes/ink/pen-picker.tsx, canvas/nodes/shape/shape-picker.tsx, canvas/canvas-toolbar.tsx, pdf-viewer/ai-action-bar.tsx.

Safe — Recharts internal overlays. Recharts positions its internal <Tooltip> and <Legend> relative to the chart's bounding box, not the viewport. Container-typed ancestors don't break it. Empirically verified across all chart blocks in features/blocks/components/chart-renderers/.

Not safe — coordinate-driven position: fixed. Components that anchor floating UI at raw clientX/Y from a pointer event (without going through a portal) snap to the wrong location under container-type. Known case: features/blocks/components/canvas/canvas-context-menu.tsx renders a virtual <span> trigger at the contextmenu's clientX/Y. Fix path is Floating UI's useFloating with a virtual element via client-point, or Radix Popover anchor=... with a real DOM element. Until that fix lands, canvas blocks stay in viewport mode.

When you add a new block that uses floating UI, route it through Radix's portal primitive or document the exception here with the same shape as Recharts above (why it's safe even without a portal).

  • View System -- Views store BlockConfig[] and pass them through the block pipeline
  • Entity System -- Entity data is the primary input to block resolution
  • Tool System -- Tool output sections map to block types via bridge functions
  • Agent System -- Agents author block configs through view management tools

On this page