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 Type | Description | Default Size |
|---|---|---|
stat-cards | KPI cards showing counts and metrics | full |
table | Sortable data table with rows | full |
chart | Bar, pie, donut, line, area, or stacked-bar chart (Recharts) | half |
radar | Radar/spider chart with interactive scoring | half |
ranking | Score-based ranking bars | half |
kanban | Kanban board grouped by an enum field | full |
summary | Key-value pairs rendered as a summary card | full |
field-card | Single entity field with smart display | varies |
connection-list | Linked entities from a connection field | half |
data-table | Full data table with pagination and filters | full |
activity | Recent activity timeline | full |
entity-card | Compact entity card | half |
entity-feed | Ranked entity feed with filtering | full |
entity-graph | Entity relationship graph (@xyflow/react) | full |
rich-text | Markdown/rich text content | full |
status-banner | Status message banner | full |
child-entity-list | Children of a container entity | full |
custom | Passthrough for custom renderers | varies |
image | Image display | half |
video | Video embed | full |
excalidraw | Excalidraw whiteboard | full |
react-flow | React Flow diagram | full |
bubble-chart | Bubble chart visualization | half |
form-flow | Multi-step form wizard with validation and progress tracking | full |
canvas | Interactive node-based canvas with drag-and-drop (@xyflow/react) | full |
entity-filter | Schema-driven filter controls with inline search results | full |
tool | Interactive tool with input form and output rendered inline | full |
notes | Editable rich-text notes attached to an entity | full |
documents | Uploaded documents list for an entity | full |
pdf-viewer | Inline PDF document viewer | full |
response-form | Criteria scoring form that submits a scored response | full |
menu | Navigation menu or action list | full |
resource-download | Download CTA shown after form completion | full |
task-tree | Nested drag-to-reorder task list with inline add and checkbox toggle | full |
checklist | Flat checkbox list with inline add and Cmd+Enter keyboard flow | full |
planner | Day-planner composite: unscheduled task list + vertical hour-rail timeline with drag-to-schedule, drag-to-resize, and drag-to-unschedule | full |
Task Blocks
Task-oriented blocks are designed to render inside saved views, not only on the standalone task pages:
checklistrenders a compact checkbox list with inline add and hover states.task-treerenders a nested task hierarchy with drag/drop, keyboard indent/outdent, checkbox completion, and inline add.plannerrenders 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(); // singletonAll 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:
| Requirement | Description | Example types |
|---|---|---|
entity-list | Needs a collection of entity rows | table, chart, kanban, entity-feed |
entity-single | Needs one entity's full data | field-card, summary, radar, connection-list |
aggregation | Needs computed counts or stats | stat-cards |
activity | Needs the activity log | activity |
none | Carries 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.
| Group | Block Types | Data Shape |
|---|---|---|
collection | table, data-table, kanban, entity-feed, ranking, child-entity-list, connection-list | Entity rows with fields |
chart | chart, radar, bubble-chart | Numeric series / axes |
single | entity-card, field-card, summary | Single entity or field |
content | rich-text, status-banner | Static 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
}chartType | Description |
|---|---|
bar | Vertical bars (default). orientation: "horizontal" flips to horizontal bars. |
pie | Pie chart with percentage labels on each slice. |
donut | Pie chart with a 50% inner radius cutout. |
line | Line chart with dots at each data point. |
area | Area chart with a gradient fill beneath the line. |
stacked-bar | Stacked 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+dataSourceFieldlet the same block power count charts, value rollups, and grouped KPI visuals from any record type.labelMapmaps 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.itemsis 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 fromgroupBy(viagroupByLabel()) andaggregation+dataSourceField(viametricLabel()). For horizontal bar charts the axes are swapped.groupByLabelstrips common field suffixes:owner_id → "Owner",priority_slug → "Priority",created_at_month → "Month",closed_at_quarter → "Quarter".metricLabelhandles aggregation modes:count → "Count";sum + amount → "Total amount";avg + score → "Average score";min/max + field → "Minimum/Maximum {field}".showLegend—trueby default for pie/donut and multi-series stacked-bar;falsefor single-series bar/line/area. Explicitfalsealways wins.tooltipLabel— Auto-populated frommetricLabel(aggregation, dataSourceField)so the tooltip never reads a misleading "Count" when the chart is actually summing amounts.showGrid—truefor 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 type | Orientation | Default maxGroups |
|---|---|---|
bar / stacked-bar | horizontal | 20 |
bar / stacked-bar | vertical | 50 |
line / area | — | 50 |
pie / donut | — | none (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:
diagnoseChartBlock()emits alevel: "warning"diagnostic with message"Stacked bar needs series; showing a plain bar chart. Add series fields or switch chart type to 'bar'.".ChartBlock.renderChart()detects the empty-series case and callsrenderBarChart()instead.- The warning appears as a banner above the fallback chart, not hidden behind the previous
items.length === 0gate.
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:
| ID | Label | Description |
|---|---|---|
default | Default | Platform CSS variables (--chart-1 through --chart-8) |
blue-scale | Blue Scale | Monochromatic blue progression |
warm | Warm | Orange/red/amber tones |
cool | Cool | Teal/cyan/green tones |
earth | Earth | Brown/tan/olive tones |
vivid | Vivid | High-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:
| Level | Meaning | Example |
|---|---|---|
error | Chart cannot render | No data type selected, group-by field missing from schema |
warning | Chart may produce misleading output | Aggregation set to sum but no measure field selected |
info | Improvement suggestion | No 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 populatedBlock 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-thirdsresolveSpan() 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
sizeproperty - 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 componentsUnified 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 orderBlocks 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
bentomode, blocks flow into a responsive CSS grid and use theirsizeproperty to span 1/3, 1/2, or full width. - An
onSavecallback can be passed toBlockGridto persist inline edits (e.g., field-card editing on detail pages).
Bridge Functions
Bridge functions convert data from other systems into blocks:
| Function | Source | Location |
|---|---|---|
entityToBlocks() | Entity fields + content | features/blocks/lib/from-entity.ts |
toolOutputToBlocks() | Tool output sections | features/blocks/lib/from-tool-output.ts |
chatOutputToBlocks() | Chat tool output | features/blocks/lib/from-chat.ts |
transientSpecToBlocks() | Agent-generated transient views | features/blocks/lib/transient-view.ts |
migrateDashboardSections() | Legacy dashboard config | features/blocks/server/migrate.ts |
filterConnectionEntities() | Connection field data | features/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 getthird, arrays gethalf) - Connection field detection (emits
connection-listblocks instead offield-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--FormFlowConfigwith steps, fields, completion messagevalues-- collected field values keyed by field namecompletedStepIds-- array of completed step IDscurrentStepIndex-- index of the current stepcompleted-- 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 key | Type | Description |
|---|---|---|
accept | string | Comma-separated MIME types or extensions (e.g. ".pdf,.docx,image/*") |
maxFiles | number | Maximum number of files allowed (default: unlimited) |
maxSizeMb | number | Maximum 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:
| Key | Type | Description |
|---|---|---|
toolSlug | string | Slug of the registered tool to render. |
defaultInputs | Record<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 column —
CustomFormfrom the tool's UI registration, orGenericToolFormderived from the tool'sjsonSchema. - Right column —
CustomOutput,SectionedOutput(if the tool declaresoutputSections), orGenericToolOutput.
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):
| Key | Type | Default | Description |
|---|---|---|---|
title | string | "Download Resource" | Heading text |
description | string | "Thank you for completing this survey!" | Body text |
downloadUrl | string | — | URL of the file to download |
fileName | string | — | Display file name shown below the button |
buttonLabel | string | "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;
}fieldAggregationssets the aggregation mode per field. Defaults toavgfor numeric fields. Usesumfor portfolio-level totals (e.g. total annual value across all opportunities),countfor cardinality.fieldFormatssets how the aggregated value is displayed per field. Values are formatted viaformatChartValue()—currencyrenders with locale currency symbol,percentappends%,compactuses 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
trendfield 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:
| Export | Value | Purpose |
|---|---|---|
CHART_COLORS | ["var(--chart-1)", …, "var(--chart-8)"] | Default ordered palette for bars, lines, and pie slices |
CHART_PALETTES | ChartPalette[] | 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_STYLE | CSS properties object | Consistent 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/:
| File | Exports |
|---|---|
bar-chart.tsx | BarChartRenderer (vertical + horizontal) |
line-chart.tsx | LineChartRenderer |
area-chart.tsx | AreaChartRenderer |
pie-chart.tsx | PieChartRenderer (pie + donut) |
stacked-bar-chart.tsx | StackedBarChartRenderer |
axis-helpers.tsx | XAxisTick, YAxisTick, shared axis utilities |
shared.tsx | TooltipContent, LegendContent, shared primitives |
types.ts | ChartRendererProps, shared render types |
index.ts | Re-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:
| Module | Exports | Purpose |
|---|---|---|
features/blocks/lib/chart-format.ts | formatChartValue(value, format?, prefix?, suffix?) | Numeric formatting for chart labels and tooltips (number, currency, percent, compact) |
features/blocks/lib/trend-utils.ts | inferTrend(change?, trend?) | Infer trend direction from a change string or explicit field |
features/blocks/lib/chart-healing.ts | diagnoseChartBlock(), applyChartHealing() | Chart config diagnostics and auto-fix |
features/blocks/lib/chart-export.ts | exportChartDataAsCsv(), 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
visibleColumnsis set, only those columns plus the built-inUNCATEGORIZEDandOTHERcolumns are shown. This lets users hide irrelevant pipeline stages. - Custom labels —
columnLabelsmaps 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 destructiveBadgeshowsWIP {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:
updateEntity()patches the entity's grouped field to the new column valuePOST /api/entities/[id]/trigger-status-transitionevaluatesconfig.statusTriggersfor matching(fromStatus, toStatus)pairs- Matching triggers dispatch an Inngest event:
entity/status-transition - 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.
| Component | Callback prop | Arguments |
|---|---|---|
ChartBlock | onItemClick | (name: string, value: number) => void |
EntityDistributionChart | onBarClick | (name: string, count: number) => void |
ScoreDistributionChart | onBarClick | (title: string, score: number) => void |
ScoreProgressionChart | onPointClick | (responseId: string, score: number) => void |
ChartSection (tool output) | onItemClick | (name: string, value: number) => void |
MultiSeriesRadarChart | onSegmentClick | (name: string, values: Record<string, number>) => void |
BubbleChart | onPointClick | (point: BubbleChartDataPoint) => void |
CategoryChart | onBarClick | (category: string, value: number) => void |
EffortValueChart | onBarClick | (effort: string, value: number) => void |
PaybackBandChart | onBarClick | (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
JsonToggleraw editor
- Block — block-specific config (
- 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)
| Function | Signature | Description |
|---|---|---|
registerBlock(type, registration) | (type: BlockType, reg: BlockRegistration) => void | Register a block component with optional edit variant. |
getBlock(type) | (type: BlockType) => BlockRegistration | undefined | Look up a registered block. |
getRegisteredBlockTypes() | () => BlockType[] | List all registered block types. |
Unified Resolver (features/blocks/server/resolve-view.ts)
| Function | Signature | Description |
|---|---|---|
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)
| Export | Signature | Description |
|---|---|---|
resolveSpan(block) | (block: { span?: number; size?: string }) => number | Returns column count (1-12). Prefers span, falls back to size preset, defaults to 12. |
hasSpanBlocks(blocks) | (blocks: Array<{ span?: number }>) => boolean | True when any block uses numeric span — triggers 12-column CSS grid. |
GRID_12_CLASS | string | "grid grid-cols-12 gap-4" — applied to BlockGrid when span blocks are present. |
Block Metadata (features/blocks/lib/block-metadata.ts)
| Function | Signature | Description |
|---|---|---|
getBlockMetadata(type) | (type: BlockType) => BlockMetadata | Returns label, icon, description, source, display, role, and dataRequirement for a block type. |
getDataRequirement(type) | (type: BlockType) => DataRequirement | Returns "entity-list" | "entity-single" | "aggregation" | "activity" | "none". |
Server Resolution (features/blocks/server/resolve-server-blocks.ts)
| Function | Signature | Description |
|---|---|---|
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
| Function | Signature | Description |
|---|---|---|
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 entities | Filter related entities for a connection field by mode (auto/curated). |
Chart Utilities
| Function | Signature | Description |
|---|---|---|
diagnoseChartBlock(block, schemaProperties?) | (block: BlockConfig, props?: Record<string, unknown>) => ChartHealthReport | Analyze 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, ...) => string | Format a number for chart display. Formats: number, currency, percent, compact. |
inferTrend(change?, trend?) | (change?: string, trend?: string) => "up" | "down" | "flat" | null | Infer 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) => void | Export 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
| Schema | Description |
|---|---|
BlockConfigSchema | Validates a single block config (type, label, config, size, workflow). |
BlockConfigArraySchema | Validates an array of block configs. |
BlockWorkflowSchema | Validates block workflow metadata (instructions, assignee, dependsOn, output). |
ChartConfigSchema | Per-block config schema for chart blocks. Includes labelMap, footerStats ("auto" or static array), and all display options. |
StatCardsConfigSchema | Per-block config schema for stat-cards blocks. Includes fieldAggregations and fieldFormats for entity-backed stat cards. |
KanbanConfigSchema | Per-block config schema for kanban blocks. Includes columnConfig with visibleColumns, columnLabels, and wipLimits. |
ColumnConfigSchema | Nested schema for kanban column configuration (visibility, labels, WIP limits). |
For Agents
Agents create blocks indirectly through several tools:
manageView-- Agents composeBlockConfig[]arrays when creating or updating views. This is the primary way agents author visual layouts.generateView-- Creates aTransientViewSpecwith inline block data for ephemeral chat displays. Forchartblocks, the full config surface is available:chartType(bar,pie,donut,line,area,stacked-bar),orientation,series,colorThresholds, andfooterStats.updateEntityTypeSchema-- Changes to JSON schema affect howentityToBlocks()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 pointstacked-bar— multi-series composition data (passserieswith the data keys to stack)pie/donut— part-to-whole distribution with fewer than 8 categoriescolorThresholds— 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:
| Key | Type | Description |
|---|---|---|
entityTypeSlug | string | Entity type to filter. Can also be provided via block.data.entityType.slug. |
visibleFields | string[] | 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:
resolveServerBlocks()hydrates the block with the entity type record and optionally a default result set.- On the client,
schemaToFilterFields()derivesFilterFieldConfig[]fromentityType.json_schema.properties, unlessfilterFieldswas already resolved server-side. EntityFilterControlsrenders the fields as a responsive grid (sm:grid-cols-2 lg:grid-cols-3).- On "Search", active filter values are serialized and passed to
GET /api/entities?typeSlug=&filters={}— the same endpoint used by data tables. - 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):
| Control | Schema condition | Component |
|---|---|---|
text | string with no enum | Input |
number / range | number or integer | Input[type=number] with optional min/max |
select | string with enum (≤6 options) | shadcn Select |
multi-select | enum >6 options or array with items.enum | Togglable Badge group |
toggle | boolean | shadcn 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).
Related Modules
- 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
View System
Config-driven views that compose flat block layouts. Persisted list and workspace views, plus a schema-first record detail surface with optional workspace overlays.
Data Table
Generic, reusable data table component with inline editing, keyboard navigation, virtualization, and domain adapters