Sprinter Docs

Entity System

The core data primitive of the Sprinter Platform. Every record in the system is an entity, typed by a DB-driven schema, stored in a universal table, and connected through a relationship graph.

Overview

The entity system is the foundational data layer of the Sprinter Platform. Every record -- whether it represents an opportunity, a person, a company, a research document, or a meeting -- is stored as an entity in a single entities table. Entity types are not hardcoded in application code; they are defined in the database as rows in the entity_types table, each carrying a JSON schema that describes its fields.

This design makes the platform genuinely reusable. When forking the codebase for a new product, the entity types change (via DB seeds or admin UI), but the platform code that renders, searches, and manages entities stays identical.

Key Concepts

Entity Types

An entity type is a schema definition stored in the entity_types table. Each type has:

  • slug -- URL-safe identifier (e.g., opportunity, person, technology)
  • name -- Human-readable display name
  • json_schema -- JSON Schema object describing the type's fields, their types, enums, and validation rules
  • config -- EntityTypeConfig JSONB column holding scoring criteria, UI hints, field extraction configs, relation definitions, and container settings
  • visibility -- Access level (private, shared, tenant, public)
  • tenant_id -- Nullable; null means the type is global (available to all tenants), otherwise tenant-scoped

Entity type IDs have no default in the database. When creating a new entity type, you must generate the UUID explicitly via crypto.randomUUID().

interface EntityTypeRecord {
  id: string;
  tenant_id: string | null;
  slug: string;
  name: string;
  json_schema: Record<string, any> | null;
  config: EntityTypeConfig | null;
  visibility: string;
  created_at: string;
  updated_at: string;
}

Entities

An entity is an instance of an entity type. The core fields are:

  • title -- Display name
  • slug -- URL-safe identifier (auto-generated from title + timestamp)
  • description -- Free-form markdown body (the entity's primary prose field). Indexed in FTS at weight B. Rendered via the TipTap rich editor on the detail page.
  • content -- JSONB column storing all schema-defined field values
  • metadata -- JSONB column for system data (scores, extraction status, locked fields)
  • tags -- text[] array with a GIN index for fast containment queries
  • owner_id -- User who created the entity
  • parent_id -- Self-referencing FK for parent-child hierarchies
  • visibility -- Per-entity access control (private, shared, tenant, public)
  • share_token -- Unique token for link-based sharing
  • entity_type_slug -- Denormalized slug kept in sync by a DB trigger
  • external_id -- Identifier from an external system (e.g., an OpenClaw record ID or API response ID). Enables idempotent upserts from external agents.
  • external_source -- Name of the source system that owns the external ID (e.g., "openclaw", "webhook", "api"). Together with external_id, forms a unique identity per tenant.
interface EntityRecord {
  id: string;
  tenant_id: string;
  slug: string;
  entity_type_id: string;
  title: string | null;
  content: Record<string, any> | null;
  metadata: Record<string, any> | null;
  owner_id: string | null;
  parent_id: string | null;
  tags: string[];
  visibility: EntityVisibility;
  share_token: string | null;
  external_id: string | null;
  external_source: string | null;
  // ...timestamps, joined entity_type
}

A partial unique index on (tenant_id, external_source, external_id) WHERE external_id IS NOT NULL enforces uniqueness for entities with external identity while leaving the index lean — the vast majority of platform-native entities have null external_id and are not indexed.

Entity Type Config

The config JSONB column on entity_types controls platform behavior beyond the raw schema:

interface EntityTypeConfig {
  ui?: {
    isParent?: boolean;
    cardFields?: string[];
    cardConfig?: CardConfig;
    icon?: string;
    color?: string;
    description?: string;
    hidden?: boolean;
  };
  container?: ContainerConfig;
  fields?: Record<string, FieldConfig>;
  relations?: EntityTypeRelationConfig[];
  coreFields?: string[];
  /** Custom empty state shown when no entities of this type exist */
  emptyState?: EntityTypeEmptyState;
}

/**
 * Config-driven empty state for entity list views.
 * Rendered by EntityEmptyState when the list has no records.
 */
interface EntityTypeEmptyState {
  icon?: string; // lucide icon slug from the platform icon map
  title?: string; // Heading text (default: "No {typeName} yet")
  description?: string; // Body text below the heading
  ctaLabel?: string; // CTA button label
  ctaAction?: "create" | "import" | "link";
}

Field Configs

Per-field configuration stored in config.fields. Each entry maps a JSON schema property name to a FieldConfig that controls display, relations, promotion policy, and lifecycle:

interface FieldConfig {
  label?: string;
  displayType?: string;
  /**
   * Status chip mapping — when displayType is "status", maps raw field values
   * to colored chips with optional icon. Keys are the raw field values.
   */
  statusMap?: Record<string, StatusMapEntry>;
  humanInput?: boolean;
  /** First-class relation field config — canonical replacement for legacy `connection`. */
  relation?: FieldConfigRelation;
  /** ISO timestamp set when a field is archived (soft-deleted via delete_field_cascade). */
  archivedAt?: string;
  /**
   * When to auto-promote a response value to entity.content.
   * - "always" — promote immediately on submission
   * - "if_confident" — promote only when confidence >= confidence_threshold
   * - "never" — require explicit human promotion
   */
  auto_promote_policy?: "always" | "if_confident" | "never";
  /** Confidence threshold for "if_confident" auto-promote policy (0–1). */
  confidence_threshold?: number;
}

Deprecated keys removed (as of 2026-04-15): FieldConfig.extraction, FieldConfig.connection, and FieldConfig.actions are no longer accepted. Extraction is now authored as tasks with output_type: 'response'. Connection display config was absorbed into FieldConfig.relation. Field-action triggers were removed in Phase 7 in favour of task-based triggers. The assertValidEntityTypeWrite() gate rejects writes that contain these keys.

Deprecated EntityTypeConfig keys removed: config.dashboard and config.statusTriggers are no longer accepted.

Local Fields and Drift (per-entity overlay)

Every entity (including the untyped concept) carries an optional local field overlay with split storage:

  • metadata.localFields: Record<string, LocalFieldDefinition> — per-record field definitions. Low-churn, structural. Adding a key here attaches a field to one entity only; the global type schema is untouched.
  • entity.content[key]values for those local fields, stored alongside canonical values in the unified content namespace. EntityWriter.assertValidContent() accepts a content key when it's declared in metadata.localFields — so writes are validated, but local-field-typed values pass through.

Why definitions on metadata and values on content:

  • Definitions are structural overlay (alongside field_sources, lockedFields); values are user content. Different write frequencies, different concurrency profiles. Splitting them keeps the model honest.
  • Unified value namespace means local fields ride existing infrastructure for free: FTS over entity.content, bento rendering, criteria-set / response targeting (criteria_set.dimensions[].fieldName already resolves to entity.content[*]), task-driven extraction (output_type: 'field'), per-field provenance via entity_responses.field_meta, CSV import/export, and external API key writes through PATCH /api/entities/[id]. None of those paths need branching for local vs canonical fields.

Field types supported by the overlay are intentionally narrower than the canonical taxonomy: text | long_text | number | boolean | date | url. Reserved keys (entity-row columns) are rejected at the server-action boundary.

Field state model

features/entities/lib/field-set.ts exposes resolveEntityFieldSet(entity, entityType) — a pure helper that classifies every field on an entity:

  • canonical — active, non-archived field on the entity type schema
  • local — defined in metadata.localFields for this entity only (value pulled from content[key])
  • orphaned — value in entity.content with no canonical or local definition (drift)
  • archived — value in entity.content for a canonical field marked archived (drift)
  • incompatible — content value type does not match the canonical jsonType (drift)
  • shadowed — local field key collides with an active canonical key (drift; canonical wins for the value)

Returned effectiveFields is the ordered render list (canonical first, then local by createdAt). Both pull values from entity.content.

Read features/entities/components/entity-detail/local-fields-panel.tsx for the rendered surface (Local fields + Needs cleanup + Add field). Read features/entities/server/local-fields.ts for the four server actions:

  • addLocalField — writes a def into metadata.localFields via the deep-merge apply_entity_metadata_patch RPC.
  • setLocalFieldValue — coerces via coerceLocalFieldValue then writes into entity.content[key] via the standard merge_entity_content RPC.
  • renameLocalField — two RPCs ordered metadata-first: move the def, then move the value (apply_entity_content_patch does delete-then-merge in one statement under FOR UPDATE).
  • deleteLocalField (mode: "wipe" | "archive") — wipe drops both def and value (two RPCs, metadata-first); archive drops the def only and lets the value surface as orphaned drift on the next read.

concept — the untyped-by-default system entity type

concept is the generic system entity type that backs untyped capture. A concept is just an entity without a domain-specific schema — capture first, harden into a typed entity (or promote into a new type) when the shape proves itself. The type has an empty content schema and exists so users can hit "New concept" and land in an editable record without picking a type first.

  • Slug: concept. User-facing copy: "Concept", "Untitled concept".
  • All concept-level fields are local fields (the type itself contributes none).
  • A concept can later be converted to a typed entity via type-change (deferred follow-up); unmapped local fields are preserved as local on the resulting entity.

The untyped-capture flow lives at app/(app)/new/page.tsx and is reached via the "New concept" command palette item.

Entity Relations

Directed edges between entities, stored in the entity_relations table:

  • from_entity_id / to_entity_id -- The two endpoints
  • relationship_type -- Freeform string (e.g., related_to, belongs_to, manages, depends_on)
  • tenant_id -- Scoped to tenant
  • metadata -- JSONB. First-class relation fields stamp this with { field, rank, source } (see Relation Fields below). When a relation-scoped criteria set has responses, an additional scores[criteriaSetId] key carries the per-edge aggregate (see Edge Scoring below).

Relations are queried bidirectionally. The getRelatedEntities() server action fetches both outgoing and incoming relations for a given entity.

Important: entity_relations.id has a gen_random_uuid() DB default, but all insert sites also pass an explicit crypto.randomUUID() as belt-and-suspenders protection against accidental migration regressions that drop the default. Always pass an explicit ID when inserting relations.

Relation Fields (First-Class)

A relation field declares that an entity type has a named attribute whose value is one or more references to other entities, stored in entity_relations rather than entity.content. Relation fields behave identically to regular content fields in forms, cards, filters, sort, responses, and AI tools.

Declared on FieldConfig.relation inside config.fields[fieldKey]:

interface FieldConfigRelation {
  /** Required target entity type slug. */
  targetTypeSlug: string;
  /** Relationship type written to entity_relations.relationship_type. Defaults to fieldKey. */
  relationshipType?: string;
  /** Cardinality. */
  multiple?: boolean;
  /** Maximum selectable targets when multiple. */
  max?: number;
  /** Enable drag-drop ordering — writes metadata.rank. */
  rankable?: boolean;
  /** Allow creating a new target from the picker. */
  createInline?: boolean;
  /** Narrow the picker's search scope. */
  filter?: {
    tags?: string[];
    typeSlugs?: string[];
    contentEq?: Record<string, string | number | boolean>;
  };
  // UI display config (absorbed from legacy FieldConfig.connection in Phase 2):
  /** How to render the relation list in the entity detail view. */
  display?: "list" | "data-table" | "inline";
  /** Compact or full data table variant when display is "data-table". */
  dataTableVariant?: "compact" | "full";
  /** Auto-create child entities and trigger their extraction. */
  cascadeExtraction?: {
    enabled: boolean;
    titleField?: string;
    autoExtract?: boolean;
  };
  /** Whether the relation is populated by agents (auto) or manually curated. */
  mode?: "auto" | "curated";
}

Migration note: The legacy FieldConfig.connection shape is no longer accepted by the schema or write gate. All callers must use FieldConfig.relation. The resolver getRelationFieldMap() in features/entities/lib/relation-fields.ts handles forward compatibility for any rows not yet migrated.

Example: a product_idea entity type with a ranked target-markets field.

{
  "config": {
    "fields": {
      "target_markets": {
        "label": "Target Markets",
        "relation": {
          "targetTypeSlug": "market",
          "multiple": true,
          "rankable": true,
          "createInline": true
        }
      }
    }
  }
}

Storage: Relation values never land in entity.content. They live as rows in entity_relations with metadata.field = "target_markets" and, when rankable, metadata.rank = 0, 1, 2, .... The resolver getRelationFieldMap() in features/entities/lib/relation-fields.ts merges new-style field configs with legacy config.relations[] and FieldConfig.connection entries so any shape works.

Filter and sort: The data table supports two new filter types that target relations directly:

  • { type: "relation", fieldKey, targetIds } -- filters entities whose specific relation field links to one of the given targets. Rendered via an EntityPicker in the column header.
  • { type: "connected", targetIds } -- filters entities with any relation to the given targets, in any field. Rendered via the "Connected to" button in the toolbar.

URL serialization uses f.<field>_rel=id1,id2 and f._connected_conn=id1 so filter state survives refresh and sharing.

AI ergonomics: The createEntity tool accepts relation values directly in content by UUID, slug, or title. Agents can write:

{
  "typeSlug": "product-idea",
  "title": "New Platform",
  "content": {
    "priority": "high",
    "target_markets": ["Enterprise SaaS", "Healthcare"],
    "owner": "jane-doe"
  }
}

and the runtime resolves each string to an entity UUID via resolveRelationTargets(), creating inline targets when the field's createInline is true. getEntityType and listEntityTypes({ detailed: true }) include a relationFields array summarizing each relation field so agents know what values to pass.

Rankable responses: Criteria sets support a relation-rank dimension type. When a criteria set has a relation-rank dimension, the response form renders the RelationFieldInput component with drag-drop enabled. Users can submit a new priority order as a scored response; the ordered IDs land in response.values[fieldKey].

Hierarchy (parent_id, breadcrumbs, cascade delete)

entities.parent_id is a self-referencing FK. Each entity belongs to at most one parent; the tree lives alongside entity_relations (graph). A DB trigger rejects self-parent, cycles, and depth > 10, serialized per-tenant via pg_advisory_xact_lock(hashtext(tenant_id)). Four tenant-scoped RPCs — entity_ancestors, entity_descendants, entity_descendant_counts_by_type, entity_nearest_ancestor_of_type — power breadcrumbs, children panels, and agent tools with explicit p_tenant_id args and depth caps.

Server actions

  • reparentEntity(entityId, newParentId) — validates permission on both source and destination, enforces the destination type's container.allowedChildTypes, walks ancestors for cycle detection (belt-and-braces), logs activity, fires entity.reparented analytics, and revalidates cache tags.
  • deleteEntityWithMode(entityId, { mode, cascadeConfirmed }) — the single cascade gate. When descendants exist, mode: "cascade" requires cascadeConfirmed: true; otherwise it rejects CASCADE_NOT_CONFIRMED (HTTP 409). mode: "detach-children" is atomic: the SQL function delete_entity_with_mode holds the advisory lock, FOR UPDATEs the parent, NULLs children, then deletes — closing the detach-then-delete TOCTOU window.

UI

  • EntityBreadcrumbs — ancestor chain with an inline Move icon-button.
  • EntityReparentDialog — search-based parent picker with a detach-to-root affordance.
  • EntityChildrenPanel — renders children grouped per type with inline create; auto-shows for types with container.allowedChildTypes even when empty.
  • DeleteEntityDialog — shows descendant counts and two one-call options: "Detach children, then delete" (autofocused) or "Delete everything (N items)".

Agent tools

  • getDescendants — list subtree with optional type filter.
  • getEntity accepts includeAncestors: true — response includes ancestors (nearest first).
  • updateEntity accepts parentId — routes through reparentEntity (same gate as humans).
  • deleteEntity accepts mode + cascadeConfirmed — must be true only after the user approves destruction.

Ancestor context (prompt-cache safe)

buildAncestorContextMessage(entityId) returns a separate user-role message summarizing the ancestor chain. The agent's system prompt stays entity-independent so Anthropic prompt caching keeps a stable prefix across turns. Controlled per-type via container.inheritAncestorContext (default true), container.ancestorContextFields (whitelist), and container.excludeFromAncestorContext (PHI-safe denylist).

Type config

entity_types.config.container = {
  enabled?: boolean,
  allowedChildTypes?: string[],           // restrict child picker + create
  inheritAncestorContext?: boolean,        // default true
  ancestorContextFields?: string[],        // stable fields to inject
  excludeFromAncestorContext?: string[],   // always-redacted fields
}

Configure via /admin/data-types/[slug] → Overview → Children.

Edge Scoring

When a criteria set has scope.type === "relation", submitting a scored response promotes a per-edge aggregate onto each matching row in entity_relations. The blob lives at metadata.scores[criteriaSetId] with shape:

type EdgeScoreBlob = {
  weighted: number | null;
  normalized: number | null;
  count: number;
  updatedAt: string;
};

Promotion runs inside insertEntityResponse() as a fire-and-forget call to promoteEdgeScores() after the response insert commits. The helper recomputes the aggregate from ALL non-draft responses (submitted + promoted + partially_promoted) for the (tenant, entity, criteriaSet) tuple — there is no incremental path, which keeps the blob stable under supersede/reject churn. The per-edge merge uses a set_edge_score(uuid, uuid, text, jsonb) SQL function that runs jsonb_set on the one sub-path so metadata.field, metadata.rank, and metadata.source written by the relation-sync path are preserved.

If the inline promotion throws, the response submit logs a structured warning and enqueues an edge-scores/repair Inngest event. The repairEdgeScores function retries the write with concurrency pinned to one per (entity, criteria set) tuple to avoid racing overlapping submits.

Reads: getRankedConnections({ tenantId, entityId, criteriaSetId, limit }) returns the scored edges sorted by normalized desc, nulls last. The same helper is exposed as a permission-gated AI tool (getRankedConnections, gated on entities.team.read) so agents can ask "rank X's connections by criteria set Y" in one call instead of re-aggregating responses.

Entity Type Access Control

Entity types support a visibility-based access model. The listAccessibleEntityTypes() and getAccessibleEntityTypeBySlug() helpers filter types by combining:

  • Global types (tenant_id IS NULL) visible to all tenants
  • Tenant-specific types (tenant_id = current tenant)

Entity-level visibility (private, shared, tenant, public) and an entity_shares table enable fine-grained per-record access.

How It Works

Entity Response as Universal Write Primitive

Every write to an entity field value flows through the same three-step pipeline:

Session  →  entity_response (staged value)  →  Promote  →  entities.content

This applies to all write paths:

Write pathEntry point
Inline field edit (entity detail bento)saveInlineFieldEdit() server action
Inline field edit (data table)saveInlineFieldEdit() via use-data-table-editing hook
API write (POST/PATCH /api/entities)bundleContentThroughResponses() helper
Chat tool (createEntity / updateEntity)bundleContentThroughResponses() via createEntityKeyed()
Cascade extractionbundleContentThroughResponses() in cascade handler

Each write creates a session_type='response' micro-session and one entity_response row per field. The target_type='field' row is then promoted via promote_field_value RPC which writes the value into entities.content[fieldName] and stamps entity.metadata.field_sources[fieldName].

Auto-promotion is controlled by FieldConfig.auto_promote_policy:

  • "always" (default) — promotes immediately on submission
  • "if_confident" — promotes only when entity_response.confidence >= FieldConfig.confidence_threshold
  • "never" — value sits in entity_responses until a human promotes it explicitly

The canonical promotion helper is promoteEntityResponseValue() from features/responses/server/promote-response.ts. It dispatches to promote_field_value RPC for target_type='field' and to promote_dimension_response RPC for target_type='dimension'. The function is idempotent — already-promoted responses return { promoted: false }.

Entity Type Write Gate

All writes to entity_types.config and entity_types.json_schema must flow through assertValidEntityTypeWrite() in features/entities/server/entity-type-write-gate.ts.

// Every API route, server action, and tool that modifies entity type config
const validatedConfig = await assertValidEntityTypeWrite(rawConfig);
// SchemaWriteError is thrown (caught as 400) if deprecated keys are present
// or if Zod strict validation fails

The gate runs in two phases:

  1. Pre-Zod deprecation check — inspects the raw input for connection, extraction, actions (field-level), and dashboard, statusTriggers (top-level). Produces a clear SchemaWriteError message before Zod strict mode would fire a generic "unrecognized keys" error.
  2. Zod strict parseFieldConfigSchema and EntityTypeConfigSchema use .strict(), so any unknown key causes a parse error.

The allowDeprecated: true option exists only for migration scripts that must read-and-rewrite legacy rows without failing. It uses .strip() instead of .strict() to silently drop unknown keys.

The ESLint rule no-direct-entity-type-write (in eslint-rules/no-direct-entity-type-write.mjs) is the second enforcement layer. It fires a lint error on any call to .from("entity_types").update() or .from("entity_types").insert() that includes a config or json_schema key in the payload literal. Exceptions are allowlisted for: entity-type-write-gate.ts, scripts/, supabase/migrations/, and test files.

Unified Fields Admin Tab

The Admin > Data Types > Fields tab (features/admin/components/admin/fields-tab/) provides a split-pane editor for all field configuration:

  • Left paneFieldList: scrollable list of all fields for the entity type. Each row shows the field slug, type badge, and whether it has a dedicated extraction subtask. Selecting a row loads the field into the right pane.
  • Right paneFieldEditor: 8 collapsible sections, each owning a specific slice of FieldConfig + json_schema:
SectionWhat it controls
SchemaSlug (read-only), JSON schema type (read-only), description (editable), required toggle
DisplaydisplayType picker, StatusMapEditor for status chip color/label/icon mapping
RelationRelationSection — target type slug, relationship type, display variant, mode, max, multiple, rankable; CascadeExtractionEditor for child-entity auto-create
ExtractionExtractionSection — shows linked extraction subtask (agent + edit link) or offers a one-click "Create dedicated subtask" CTA
PromotionPromotionSectionauto_promote_policy select, confidence_threshold slider
ScoringUsageShows which criteria set dimensions reference this field key
LayoutControls the field's section assignment and ordering within config.fieldLayout
LifecycleLifecycleSection — Rename and Delete dialogs with pre-flight impact preview

All sections share a single draft state object. Changes accumulate in memory and are written to the DB via assertValidEntityTypeWrite() on Save.

Field Lifecycle — Rename and Delete

Field rename and delete are destructive operations that must update every reference to the field key across the entire platform. The platform provides two SQL RPCs for this:

rename_field_cascade(p_entity_type_id, p_old_key, p_new_key, p_tenant_id)

Atomically rewrites all references to the old key:

  • entity_types.config.fields — renames the key
  • entity_types.json_schema.properties — renames the property
  • entities.content — renames the key in every entity of this type
  • tasks.output_config.fields[] — rewrites any extraction task that targets this field
  • criteria_sets.dimensions[].fieldName — rewrites scoring dimension references
  • Writes a schema_repair_log audit entry

delete_field_cascade(p_entity_type_id, p_field_key, p_mode, p_tenant_id)

Two modes:

  • 'archive' — sets FieldConfig.archivedAt to the current timestamp. The field is hidden from the UI and excluded from drift checks but its data is preserved in entities.content.
  • 'wipe' — archives the config AND deletes the key from every entities.content JSONB object.

Both modes write a schema_repair_log audit entry.

Impact preview: Before the rename or delete RPC runs, previewFieldImpact() in features/entities/server/field-lifecycle.ts returns counts of affected entities, tasks, criteria sets, and views. The Rename and Delete dialogs in FieldsTab display these counts so administrators can assess blast radius before committing.

// Returns counts of affected objects
const impact = await previewFieldImpact({ entityTypeId, fieldKey });
// {
//   entityCount: 145,
//   taskCount: 2,
//   criteriaSetCount: 1,
//   viewCount: 0
// }

Schema-Health Drift Detection

features/schemas/completeness-checks.ts runs drift scans over entity type definitions. Six new categories were added:

CategoryDescription
task_unknown_fieldAn extraction task references a fields[] key that does not exist in json_schema.properties
entity_type_no_extraction_taskA type has extractable fields but no parent extraction task
entity_type_no_fieldsA type has a config.fields map with entries but an empty json_schema.properties (schema/config mismatch)
field_deprecated_keyA FieldConfig in production data still contains a deprecated key (connection, extraction, actions)
status_map_shorthandA statusMap entry is still stored as a bare color string instead of the canonical {color, label?, icon?} object
orphan_responseAn entity_response row references a target_key (field name) that no longer exists in json_schema.properties

Archived fields (those with archivedAt set) are excluded from all drift checks.

CRUD Operations

All entity operations go through server actions in features/entities/server/actions.ts:

  1. Create -- Resolves entity type by slug, generates a slug from the title, inserts with tenant scoping, logs an activity record.
  2. Read -- getEntityById() (cached per request via React cache()), getEntityByIdentifier() (accepts UUID or slug), getEntitiesByType(), searchEntities() (full-text search with field filters, tag filters, sorting, pagination).
  3. Update -- Fetches existing content/metadata first, performs a shallow merge so partial updates do not erase sibling fields, logs activity.
  4. Delete -- Removes the entity, logs activity with the entity's former title.

entity_type_slug Denormalization

The entity_type_slug column on the entities table is kept in sync with the entity type's slug via a database trigger. This avoids the need to join entity_types just to filter entities by type slug -- a significant performance optimization for list pages and search queries.

All entity queries that filter by type use entity_type_slug directly:

.eq("entity_type_slug", typeSlug)

Tags and GIN Indexing

Tags are stored as a text[] column with a GIN index. Filtering by tag uses PostgreSQL's @> (contains) operator:

.contains("tags", [tag])

Tags are replaced (not merged) on update. The searchEntities action supports an optional tag parameter for filtering.

Value Locking

Fields can be locked to prevent extraction from overwriting human-curated values. Locked field names are stored in entity.metadata.lockedFields as a string array. During extraction, locked fields are skipped entirely.

Parent-Child Hierarchies

Entities support a parent_id self-referencing foreign key. Entity types can be configured as containers:

interface ContainerConfig {
  enabled: boolean;
  allowedChildTypes?: string[];
  defaultChildCollections?: ChildCollectionConfig[];
}

When an entity type has container.enabled = true, its detail page renders child entity collections. The getChildEntities() server action queries by parent_id, optionally filtered by child type slug.

Search and Filtering

The searchEntities() function supports:

  • Full-text search via PostgreSQL's websearch text search on the fts column
  • Field filters -- text (ILIKE), exact/not-exact, enum (IN), range/comparison (gt, gte, lt, lte on JSONB), boolean, array containment, date-relative
  • Tag filtering -- contains("tags", [tag])
  • Sorting -- by title, score (metadata->weighted_score), created_at, or any schema field (JSONB path)
  • Pagination -- offset-based with parallel count query

Field name validation prevents injection: only identifier-safe names matching /^[a-zA-Z_][a-zA-Z0-9_]*$/ are allowed in dynamic query paths.

Entity list URL parsing and serialization is centralized in features/entities/lib/entity-query-codec.ts. It owns the q, sort, order, tag, page, pageSize, and f.* URL shape, and it converts compatible table filters to/from entity-native DataSourceConfig.filters. URL range suffixes continue to support legacy _min / _max and now also accept data-source-native _gt, _gte, _lt, and _lte. Dynamic relative-date and relation-backed table filters are reported as unsupported when a caller tries to persist them as static FilterRule[].

Date-Relative Filters

Two date-relative filter types are handled server-side in searchEntities():

Filter typeShapeBehavior
before_today{ type: "before_today" }Matches records where the field value (ISO date string) is less than today's date.
within_days{ type: "within_days", days: number }Matches records where the field value falls between today and N days in the future (inclusive).

Both types compute ISO date strings at query time and apply .lt() / .gte().lte() predicates to the JSONB field path. They operate on fields that store dates as ISO strings (e.g., "2026-05-15").

Scoring

Entity types can define scoring criteria in config.scoring. Each criterion has a name, label, weight, and scale. Scores are stored in entity.metadata as individual criterion scores plus a computed weighted_score. The scoring radar chart (rendered as a radar block) provides interactive sliders for score entry.

Import/Export

CSV and xlsx import, and CSV export, are supported on entity list pages. Export serializes all schema fields for the current filtered result set.

Import Dialog (features/entities/components/import-dialog.tsx): A guided import flow that accepts both .csv and .xlsx files and maps their columns to entity fields before importing. Features:

  • File formats: Accepts .csv and .xlsx. xlsx files are parsed client-side before the column-mapping step.
  • Auto-mapping: Column headers are automatically matched to schema field names using exact, case-insensitive, and humanized matching. Unmatched columns default to "skip".
  • Preview table: Shows the first 10 rows with mapped column headers highlighted, and validation issues (errors in red, warnings in amber) per cell.
  • Validation: Checks that a title column is mapped, title values are non-empty, number fields contain valid numbers, and enum fields contain valid options.
  • Column remapping: Users can manually change any column mapping via dropdown selects before importing.
  • Upsert mode: An optional UpsertOptionsPanel lets users choose a match key (any schema field) and an overwrite toggle. When enabled, the import payload sets body.upsert = { matchKey, overwrite } and the server performs an upsert rather than a pure insert. Useful for refreshing datasets without duplication.
  • While the import request is in flight, an indeterminate progress bar appears and the Import button switches to a spinner + "Importing…" label to prevent double-submission.
  • The mapped data is sent to POST /api/entities/import for server-side processing.
  • The route enforces entities.team.create permission before any write path runs.
  • Server-side import reuses the shared keyed entity creation service so imports follow the same tenant-scoped entity-type resolution and activity/event side effects as normal record creation.

The older CsvImportDialog (features/entities/components/csv-import-dialog.tsx) is deprecated — it remains functional for backward compatibility but new callers should use ImportDialog.

Body Field and Rich Editor

Every entity has a description TEXT column that serves as its primary prose / notes field. It is:

  • FTS-indexed at weight B — body text is included in full-text search alongside the title (weight A) and structured field values (weight C).
  • Rendered above structured fields on the entity detail page using a TipTap-based editor.
  • Always available in custom views as a collapsible section — it is not part of the JSON schema, so it does not need to be declared in json_schema.properties.

EntityBodyEditor (features/entities/components/entity-body-editor.tsx) renders a rich text editor with the following capabilities:

  • [[wikilink]] autocomplete — Typing [[ opens an inline picker that searches entities via /api/search/global. Selecting an entity inserts a styled wikilink node. In view mode, wikilinks render as clickable links to the referenced entity's detail page.
  • Mention relation sync — On each body save, syncWikilinkRelations() (features/entities/server/wikilink-relations.ts) reconciles the full set of mention entity relations with the current wikilinks in the document. Stale mentions are deleted; new mentions are inserted. This is an unconditional full-reconcile on save, not an incremental diff.
  • Slash commands — Typing / opens a formatting command palette:
CommandOutput
Heading 1–3#, ##, ### headings
Bullet ListUnordered list
Numbered ListOrdered list
QuoteBlockquote
Code BlockFenced code block
DividerHorizontal rule
Link EntityOpens the wikilink picker

The editor is implemented as a pair of TipTap extensions in features/entities/components/editor/:

  • wikilink-extension.ts — Custom node type, input rule for [[, and inline suggest plugin
  • slash-command-extension.ts — Keyboard-triggered command palette with fuzzy matching

Bulk Operations

The bulk actions system enables operating on multiple entities simultaneously from the data table. Select rows using the checkbox column, then use the bulk actions bar.

API: POST /api/entities/bulk accepts a discriminated union payload validated with Zod:

  • action: "delete" — Delete selected entities (up to 500)
  • action: "add_tags" — Merge tags into selected entities (deduplicates)
  • action: "remove_tags" — Remove specific tags from selected entities
  • action: "update_field" — Set a content field value across selected entities

Server actions (features/entities/server/bulk-actions.ts): All operations are tenant-scoped via getActiveTenantId(), use the authenticated Supabase client for RLS enforcement, and log activity. Tag and field updates are batched in groups of 50 concurrent requests to balance throughput with connection limits.

UI (features/entities/components/data-table/bulk-actions-bar.tsx): Shows when rows are selected. Offers the following actions:

ButtonBehaviour
ExtractTriggers AI enrichment for all selected records (batched in groups of 10).
TagPopover with a free-text input to add a tag to all selected records.
UntagVisible only when selected records have at least one tag. Popover shows every unique tag across the selection as one-click removal buttons, plus a free-text input for removing by name.
Set FieldPopover with a schema-driven field/value selector (BulkFieldEditor).
ExportDropdown offering CSV and JSON export of the selected rows (client-side, no server round-trip).
DeleteConfirmation dialog before permanent deletion.

Export helpers (exported from bulk-actions-bar.tsx for testability):

FunctionDescription
buildSelectedCsv(entities, entityType)Serializes selected entities to RFC-4180 CSV. Columns: title, all schema fields, tags (semicolon-joined), score, created_at. Arrays are semicolon-joined; values containing " are doubled-quoted.
buildSelectedJson(entities)Returns a plain object array with id, title, slug, content, tags, score, and created_at.
collectTags(entities)Collects all unique tags across a list of entities, sorted alphabetically.

The component receives an entities prop (the current page of loaded records) so selected-row data is resolved in the browser without an extra network request.

Filter Presets

Filter presets (features/entities/components/data-table/filter-presets.tsx) let users save and restore named filter configurations. Presets are persisted in localStorage per entity type slug.

  • Save: When active filters exist, a "Save filter" button opens a name input popover. The current FilterState is serialized via serializeFilters() and stored.
  • Load: Saved presets appear in a "Presets" dropdown. Selecting one calls parseFilters() to restore the FilterState and applies it to the table.
  • Delete: Each preset has a trash button for removal.

Presets remain browser-local. Reusable named data sources use the shared entity query codec and DataSourceConfig.filters; save-from-table UX is tracked as follow-up work.

Data Table Density Modes

The data table supports three density modes that control row height, font size, and cell padding:

DensityRow heightUse case
compact32px (h-8)High-density monitoring dashboards, many-row tables
default40px (h-10)Standard usage
spacious48px (h-12)Presentation mode, accessibility

A DataTableDensityToggle component (three-icon ToggleGroup) appears in the table toolbar. The selected density is persisted in localStorage under the key density-{typeSlug} and validated on load (unknown values fall back to "default").

// features/entities/components/data-table/types.ts
type Density = "compact" | "default" | "spacious";
const DENSITY_HEAD_CLASSES: Record<Density, string>;
const DENSITY_CELL_CLASSES: Record<Density, string>;
const DENSITY_TABLE_CLASSES: Record<Density, string>;

Multi-Column Sort

The data table supports sorting by up to three columns simultaneously.

  • Primary sort: click a column header as normal (cycles through asc → desc → unsorted).
  • Secondary / tertiary sort: hold Shift and click a column header to add it to the existing sort, or toggle it within the multi-sort. A small numeric priority badge (2, 3) appears next to the sort arrow on non-primary sorted columns.
  • Sorting more than three columns is not supported — additional shift+clicks are ignored.

Sort state is serialized to the URL as comma-separated sort and order params so deep-linking and browser navigation preserve multi-sort state:

/opportunities?sort=pipeline_status,next_contact_date&order=asc,desc

The page route extracts the first sort key for the SSR fetch; the full comma-separated string is passed as initialSort.field and initialSort.order to DataTable, which parses them into a SortingState array on mount.

A Sigma (∑) toggle button in the data table toolbar shows or hides a sticky footer row below the table body.

ColumnFooter content
TitleFiltered row count ("42 records")
Numeric (number / integer schemaType)Locale-formatted SUM with count in parentheses (e.g., $4,200,000 (12))
All other columnsEmpty cell

The footer operates on TanStack Table's getFilteredRowModel(), so totals always reflect the current client-side filtered set (not the total page). The toggle state is persisted in localStorage under the key dt-summary-{typeSlug}.

Data Table Meta Interface

Column definitions that need access to shared table state (filters, setFilter, clearFilter) use the DataTableMeta interface rather than as any casts:

// features/entities/components/data-table/types.ts
export interface DataTableMeta {
  filters: FilterState;
  setFilter: (field: string, value: FilterValue | null) => void;
  clearFilter: (field: string) => void;
}

Access it via table.options.meta as DataTableMeta in column cell and header renderers.

Column Visibility and localStorage

Column visibility state is persisted in localStorage under the key column-visibility-{typeSlug}. On mount, stale keys (columns that existed in a previous schema version but no longer exist) are filtered out before initializing TanStack Table state. This prevents "unknown column ID" warnings when an entity type's schema changes between sessions.

Field Display Formatters

formatDisplayValue(value, displayType?) in features/tools/lib/format.ts formats raw field values for human display. All individual formatters are also exported for direct use.

displayTypeFormatterExample output
currencyformatCurrency$1,234,567
percentageformatPercentage67.5%
dateformatRelativeDate2 days ago, Mar 15, 2026
urlformatUrlexample.com/page (strips protocol/www)
emailuser@example.com (as-is)
phoneformatPhone(555) 123-4567, +1 (555) 123-4567
number / metricformatNumber1,234,567
bytesformatBytes1.2 GB, 456 KB
durationformatDuration3h 45m, 2d 4h

React component: FormattedFieldValue (features/tools/components/formatted-field-value.tsx) is the canonical component for rendering field values in the UI. It renders linkable types (url, email, phone) as clickable <a> elements with appropriate icons and href schemes (https, mailto, tel). All other types delegate to formatDisplayValue. Renders a muted em-dash for null/empty values.

Status Chips

When a field has displayType: "status" and a statusMap in its FieldConfig, the StatusChip component (features/entities/components/status-chip.tsx) renders a compact colored badge.

// types
type StatusColor = "success" | "error" | "warning" | "info" | "muted";
interface StatusMapEntry {
  label?: string;    // Override display label (defaults to raw value)
  color: StatusColor;
  icon?: string;     // Optional icon slug from the platform icon map
}

// usage in FieldConfig
{
  displayType: "status",
  statusMap: {
    "active":   { color: "success", label: "Active",   icon: "check-circle" },
    "archived": { color: "muted",   label: "Archived" },
    "rejected": { color: "error",   label: "Rejected", icon: "x-circle" },
  }
}

Status chips use the platform CSS variable system exclusively (--status-success, --status-error, etc.). The resolveStatusChip() helper returns null for non-status display types, making it safe to call unconditionally in cell renderers.

Config-Driven Empty States

When an entity type list has no records, EntityEmptyState (features/entities/components/entity-empty-state.tsx) renders a centered card with icon, heading, body text, and a CTA button. The content is driven by EntityTypeConfig.emptyState:

// EntityTypeConfig
emptyState?: {
  icon?: string;          // lucide icon slug
  title?: string;         // "No {typeName} yet" by default
  description?: string;   // "Create your first {typeName}…" by default
  ctaLabel?: string;      // "Create {EntityType.name}" by default
  ctaAction?: "create" | "import" | "link";
}

The searchQuery prop triggers a separate "no results" variant with a search icon and prompt to try different terms. If no emptyState config exists, sensible defaults are derived from the entity type name.

Owner Display

The OwnerBadge component (features/entities/components/owner-badge.tsx) renders the entity's owner in the detail hero as a small avatar with display name or email. When no owner is set it shows "Unassigned". Data is fetched from GET /api/entities/[id]/owner and cached for 60 seconds by React Query. The fetch is skipped entirely when initialOwnerId is null.

export function ownerQueryKey(entityId: string) {
  return ["entity-owner", entityId];
}

// Usage in entity-detail-hero.tsx
<OwnerBadge entityId={entity.id} initialOwnerId={entity.owner_id} />

The owner can be updated via PATCH /api/entities/[id]/owner with body { ownerId: string | null }. Pass null to unassign.

Entity History Timeline

The EntityHistoryTab component (features/entities/components/entity-history-tab.tsx) renders a collapsible vertical timeline of the last 10 activity entries for an entity. It is placed below Responses on the entity detail page.

The tab is lazy-loaded: the React Query fetch only fires when the user first expands the collapsible (enabled: sectionOpen). Data is fetched from GET /api/entities/[id]/activities, which returns activities with resolved actor display names. Stale time is 30 seconds.

Each timeline entry shows:

  • Action icon (creates, updates, extractions each have distinct icons)
  • Entry title and optional description
  • Actor name with a human or bot indicator
  • Relative timestamp with a full date/time tooltip

"New" Field Badges

Field cards in the entity bento grid show a "New" badge and a subtle primary-colored ring when a field was populated by extraction since the current user's last view. This draws attention to data that changed while the user was away without requiring them to compare snapshots.

The badge is determined during block assembly in EntityBento:

  1. getLastViewedAt(entityId) is called server-side in the page's Promise.all and passed down as lastViewedAt.
  2. For each field, field_sources[name].promoted_at is compared against lastViewedAt.
  3. A field is "new" only when it is non-empty, viewedThreshold is known, and promoted_at > viewedThreshold.
// EntityBento computes isNew per field during block assembly
const isNewField =
  !isEmptyFieldValue(value) &&
  viewedThreshold != null &&
  source?.promoted_at != null &&
  new Date(source.promoted_at) > viewedThreshold;

The FieldCardData.isNew boolean is passed into field-card blocks and rendered by FieldCardView as a <Badge> next to the field label plus a ring-1 ring-primary/30 bg-primary/[0.02] class on the card container.

Auto-Field Grouping

The entity detail page auto-organizes fields into named sections without requiring a persisted view configuration. groupFieldsBySection() in features/entities/lib/field-grouping.ts inspects each field's displayType and FieldConfig hints to assign it to one of three buckets:

SectionFields assigned
Key Metricscurrency, percentage, number, metric display types
Statusstatus display type; fields named status, stage, priority, phase
DetailsAll remaining fields

The resulting groups are rendered as collapsible sections in EntityBento. Fields in config.ui.cardFields are always promoted to the summary card and excluded from the grouped grid.

Agents can override grouping by setting config.fields[name].section to a custom section name. Custom sections appear after the three built-in sections.

EntitySummaryCard

EntitySummaryCard (features/entities/components/entity-summary-card.tsx) renders a compact header card at the top of the entity detail page showing the entity's most important fields.

Fields shown are determined by config.ui.cardFields on the entity type. When cardFields is not configured, the card shows the first three non-empty fields from the schema. The card is always rendered above the grouped field sections and is not editable inline — it links to the relevant field card below.

Click-to-Edit Field Affordance

Field cards in the entity bento grid show an edit icon on hover. Clicking the icon opens an inline popover editor for the field without navigating away from the detail page. The popover uses the same field input components as the create/edit form and submits via updateEntity(). This replaces the previous pattern of opening a full edit dialog to change a single field.

Cross-Entity Activity Timeline

The entity detail sidebar now shows a Related Activity section displaying activity entries from entities connected to the current record (via entity_relations). This gives a "last contacted" view for people and companies without navigating away.

The related activity feed is fetched from GET /api/entities/[id]/related-activities, which:

  1. Loads all related entity IDs via getRelatedEntities()
  2. Fetches the most recent 5 activity entries per related entity
  3. Returns a unified timeline sorted by created_at descending, annotated with the related entity's title and type

Each entry in the timeline shows a context badge (the related entity's name + type chip) alongside the standard activity fields. The badge links to the related entity's detail page.

"Last contacted" is derived from the most recent activity entry across all related entities of person or company type. The timestamp is shown as a relative date badge on the entity summary card.

First-Login Onboarding Wizard

A "What's your role?" dialog appears on a user's first login after signup. The wizard:

  1. Asks the user to select their role (e.g., Analyst, Executive, Sales, Ops)
  2. Applies a role-based nav preset (saveNavConfig(preset, "user"))
  3. Pins a role-appropriate default view on the dashboard
  4. Marks the onboarding complete in the user's profile metadata (onboarding_completed_at)

The wizard is re-runnable from Settings > Account. Completing it again re-applies the selected preset, overwriting prior user-level nav customizations.

The wizard is implemented in features/entities/components/onboarding-wizard.tsx and triggered by OnboardingGate in the app shell layout, which checks profile.metadata.onboarding_completed_at.

Schema-Driven Form Sections

Entity create/edit forms now group fields into the same sections as the detail page (Key Metrics, Status, Details). Section headings appear as dividers in the form, and fields within each section are presented in config.fieldLayout order.

A required field progress indicator appears in the form footer: "3 of 5 required fields filled". The Submit button is disabled until all required fields (marked with "required": true in json_schema) have non-empty values.

Smart defaults:

  • Fields with extraction.agentSlug show a muted placeholder ("Will be populated by AI")
  • select / multi_select fields with extraction.agentSlug don't default to the first option (they wait for extraction)
  • Without extraction.agentSlug, select fields default to their first defined option

Quick-create mode: A compact variant of the form showing only required fields, toggled by ?quick=true in the dialog URL or passed as the quickCreate prop. Optional fields are accessible via a "Show all fields" link at the bottom. Quick-create is used by the capture widget and keyboard shortcut C on list pages.

View Tracking

getLastViewedAt(entityId) (features/entities/server/view-tracking.ts) is a server action that queries user_recent_views for the current user and entity combination, returning the ISO timestamp or null:

export async function getLastViewedAt(entityId: string): Promise<string | null>;

It uses .maybeSingle() so a missing record returns null without an error. The function is called in the entity detail page's Promise.all alongside the other page-level data fetches.

Entity Form Field Ordering and Labels

The entity form (EntityFormDialogContent) now respects config.fieldLayout when rendering fields, matching the order shown in the bento detail view. Previously fields were rendered in JSON schema insertion order.

Field labels also now prefer schema.title over the raw key name:

// Before: name.replace(/_/g, " ")
// After:  schema?.title ?? name.replace(/_/g, " ")

This applies to all input types: enum selects, booleans, arrays, numbers, long text, and plain text fields.

Form Help Text, Smart Placeholders, and Keyboard Submit

FormFieldRenderer consumes the standard JSON Schema description and examples keys per field:

  • Help textschema.description renders as muted small text under the field label, and is wired to the input via aria-describedby so screen readers announce it.
  • Smart placeholder — the first stringifiable entry from schema.examples is used as the input placeholder (text, number, comma-separated array, and Markdown long-text inputs). Falls back to existing copy when examples is absent.

In every entity type's JSON schema, declare description and examples for every field that benefits from a hint:

{
  "properties": {
    "insurance_id": {
      "title": "Insurance ID",
      "type": "string",
      "description": "Member ID printed on the front of your insurance card",
      "examples": ["XJK123456789"]
    }
  }
}

EntityFormDialog adds two keyboard ergonomics:

  • Autofocus on create — opening the dialog in create mode (no entity prop) focuses the Title input. Edit mode preserves whatever focus the user already had.
  • Cmd/Ctrl+Enter to submit — pressing Cmd+Enter (Mac) or Ctrl+Enter (Win/Linux) anywhere in the form triggers requestSubmit(). The shortcut is suppressed for <textarea>, contenteditable editors, active IME composition, and AltGr+Enter on European keyboards so multi-line editors and normal typing are unaffected.

The shortcut detection lives in the pure helper isFormSubmitShortcut() in features/entities/lib/form-field-helpers.ts so it can be unit-tested without rendering React.

Entity Hover Cards and Preview API

EntityHoverCard (features/entities/components/entity-hover-card.tsx) wraps any child element with a shadcn HoverCard that lazily fetches and displays a lightweight entity preview after a 400ms hover delay.

// Usage — wrap any trigger element
<EntityHoverCard entityId={entity.id}>
  <button>{entity.title}</button>
</EntityHoverCard>

The preview is fetched from GET /api/entities/[id]/preview, which returns:

interface EntityPreview {
  id: string;
  title: string | null;
  entity_type_slug: string;
  typeName: string | null;
  imageUrl: string | null;
  fields: { label: string; value: string }[]; // Up to 3 non-empty fields
  tags: string[]; // Up to 4 tags shown
}

The API selects the entity avatar, type name, and up to three non-empty scalar fields from content, using json_schema.properties[key].title for labels (falls back to humanize(key)). Values longer than 80 characters are truncated. The response has Cache-Control: private, max-age=30.

Link to a record detail page with a Next.js <Link> to /${entity.entity_type_slug}/${entity.id} and wrap the trigger in EntityHoverCard to attach the preview:

<EntityHoverCard entityId={entity.id}>
  <Link href={`/${entity.entity_type_slug}/${entity.id}`} className="font-medium hover:underline">
    {entity.title}
  </Link>
</EntityHoverCard>

Performance: React Query caches preview data for 60 seconds (staleTime: 60_000). The fetch is deferred until the card opens (enabled: open) so no network traffic occurs on render. If the fetch fails, EntityHoverCard falls back to rendering its children directly with no card.

Reset-to-Public (Entity Type Reset)

When a tenant has forked a global entity type (creating a tenant-local override), resetEntityType() in features/entities/server/entity-type-settings.ts resets it back to inheriting the global version:

  1. Verifies the target is a global type (tenant_id IS NULL).
  2. Finds the tenant-local fork by matching slug + tenant_id.
  3. Reassigns all tenant entities from the local type ID to the global type ID (data is preserved).
  4. Deletes the tenant-local entity type row.
  5. Upserts tenant_entity_type_settings to clear forked_from_version and mark as enabled.
// Returns count of entities that were reassigned
const { reassigned } = await resetEntityType(globalEntityTypeId);

The API route POST /api/entity-types/settings exposes this via action: "reset" with body { entityTypeId: string }.

In the Admin > Data Types UI, the Reset button appears only for entity types with status: "customized". A confirmation dialog shows the action is irreversible (the local customizations will be lost).

API Reference

Write Seam (features/entities/server/entity-writer.ts)

New server-side entity mutations should call EntityWriter directly:

FunctionSignatureDescription
EntityWriter.create(input, ctx)(EntityWriterCreateInput, WriteContext) => Promise<{ record }>Creates an entity for request or background callers with schema validation, relation-field splitting, response provenance, activity, analytics, lifecycle events, and cache invalidation.
EntityWriter.update(id, input, ctx)(string, EntityWriterUpdateInput, WriteContext) => Promise<{ record }>Merges content/metadata, syncs relations, preserves previous-content event context, and emits the unified write tail.
EntityWriter.delete(id, ctx, opts?)(string, WriteContext, DeleteEntityOptions?) => Promise<EntityDeleteResult>Runs the cascade-safe delete RPC and records delete activity/analytics.
EntityWriter.reparent(id, parentId, ctx)(string, string | null, WriteContext) => Promise<{ record }>Runs the locked reparent RPC, preserves cycle/depth/allowed-child-type violations, and invalidates parent-child caches.
EntityWriter.bulkUpsert(rows, ctx)(EntityUpsertRow[], WriteContext) => Promise<{ created, updated }>Routes each row through create or update so bulk writes share the same behavior as single-row writes.

WriteContext lives in features/tenant/types.ts and is intentionally a discriminated union: { kind: "request" } for session/RLS callers, or { kind: "background", tenantId, actor } for API-key, tool, import, and Inngest callers. The writer does not expose an admin: boolean or silent flag.

The canonical write paths now include the compatibility server actions, keyed API helpers, bulk entity actions, API route update/delete flows, AI tool writes, document-processing entity status updates, and the legacy todo-to-Task-entity migration. Specialized maintenance writes that mutate fields outside the CRUD writer contract, such as embeddings, share tokens, owner assignment, media image fields, entity-type reassignment, and response-promotion RPC companions, remain in their owning domain seams.

Server Actions (features/entities/server/actions.ts)

These exports are compatibility wrappers around EntityWriter plus read helpers. Prefer EntityWriter for new server-side write paths.

FunctionSignatureDescription
getEntityTypes()() => Promise<EntityTypeRecord[]>Cached per-request. Returns all accessible entity types for the current tenant.
getEntityTypeBySlug(slug)(slug: string) => Promise<EntityTypeRecord | null>Look up a single entity type by slug.
getEntitiesByType(typeSlug, limit?)(typeSlug: string, limit?: number) => Promise<EntityRecord[]>Fetch entities of a given type, ordered by creation date.
getEntityById(id)(id: string) => Promise<EntityRecord | null>Cached per-request. Fetch a single entity by UUID.
getEntityByIdentifier(identifier)(identifier: string) => Promise<EntityRecord | null>Fetch by UUID or slug.
createEntity(input)(input: CreateEntityInput) => Promise<EntityRecord>Compatibility wrapper for EntityWriter.create(input, { kind: "request" }).
updateEntity(id, input)(id: string, input: UpdateEntityInput) => Promise<EntityRecord>Compatibility wrapper for EntityWriter.update(id, input, { kind: "request" }).
deleteEntity(id)(id: string) => Promise<void>Compatibility wrapper for EntityWriter.delete(id, { kind: "request" }).
getRelatedEntities(entityId)(entityId: string) => Promise<{relation, entity}[]>Bidirectional relation query.
getChildEntities(parentId, typeSlug?, limit?)(parentId: string, typeSlug?: string, limit?: number) => Promise<EntityRecord[]>Children of a container entity.
searchEntities(query, typeSlug?, sortBy?, sortOrder?, limit?, offset?, tag?, filters?)Full search with filtering, sorting, paginationReturns { entities, total }.
getEntityCounts()() => Promise<{slug, name, count, icon, color}[]>Aggregated counts per entity type using the get_entity_counts_by_type RPC function.

Bulk Actions (features/entities/server/bulk-actions.ts)

FunctionSignatureDescription
bulkDeleteEntities(ids)(ids: string[]) => Promise<BulkActionResult>Delete multiple entities. RLS-enforced.
bulkAddTags(ids, tags)(ids: string[], tags: string[]) => Promise<BulkActionResult>Add tags to entities (deduplicates with existing).
bulkRemoveTags(ids, tags)(ids: string[], tags: string[]) => Promise<BulkActionResult>Remove specific tags from entities.
bulkUpdateField(ids, fieldName, value)(ids: string[], fieldName: string, value: unknown) => Promise<BulkActionResult>Update a content field on multiple entities.

All functions return BulkActionResult: { succeeded: number; failed: number; errors: string[] }.

Entity Type Write Gate (features/entities/server/entity-type-write-gate.ts)

FunctionSignatureDescription
assertValidEntityTypeWrite(config, options?)(config: unknown, options?: { allowDeprecated?: boolean }) => Promise<EntityTypeConfig>Validates config against strict EntityTypeConfigSchema. Throws SchemaWriteError on deprecated keys or unknown keys. Pass { allowDeprecated: true } only in migration scripts.

Field Lifecycle (features/entities/server/field-lifecycle.ts)

FunctionSignatureDescription
previewFieldImpact({ entityTypeId, fieldKey })Returns FieldImpactCountsReturns { entityCount, taskCount, criteriaSetCount, viewCount } — how many objects reference this field key. Used to show impact preview before rename/delete.
executeFieldRename({ entityTypeId, fieldKey, newKey, tenantId })Returns voidCalls rename_field_cascade RPC to atomically rewrite all references from fieldKey to newKey.
executeFieldDelete({ entityTypeId, fieldKey, mode, tenantId })mode: "archive" | "wipe"Calls delete_field_cascade RPC. archive sets archivedAt, wipe also clears entity content values.

Inline Edit / Response Routing (features/responses/server/)

FunctionLocationDescription
saveInlineFieldEdit(entityId, fieldName, value)inline-edit-action.tsServer action called by entity bento and data table inline edits. Creates a micro-session + entity_response and promotes per the field's auto_promote_policy.
bundleContentThroughResponses(entityId, content, options)inline-edit.tsCreates one shared micro-session for a batch of field values, then fans out one entity_response per field. Used by API writes and chat tool writes.
promoteEntityResponseValue(input)promote-response.tsCanonical promotion dispatcher. Routes target_type='field' to promote_field_value RPC and target_type='dimension' to promote_dimension_response RPC. Idempotent.

Entity Type Settings (features/entities/server/entity-type-settings.ts)

FunctionSignatureDescription
listPublicTypesWithStatus()() => Promise<PublicTypeWithStatus[]>All global entity types with their enablement status for the current tenant.
toggleEntityTypeEnabled(entityTypeId, enabled)Returns voidEnable or disable a global type for the tenant.
forkEntityType(entityTypeId)Returns EntityTypeRecordCreate a tenant-local copy of a global type for customization.
resetEntityType(entityTypeId)Returns { reassigned: number }Reset a forked entity type back to inheriting the global version. Reassigns all tenant entities.
provisionEntityTypes(entityTypeIds)Returns voidProvision global types for a tenant (create settings rows, copy views from default tenant).

Upsert API (External Identity)

EndpointAuthDescription
POST /api/entities/upsertAPI key (entities:write scope)Create or update an entity keyed on (externalSource, externalId). Returns { entity, created } — HTTP 201 on create, HTTP 200 on update.

Request body:

{
  externalId: string;        // External system's record ID
  externalSource: string;    // Source system name (e.g., "openclaw")
  typeSlug: string;          // Entity type slug (required on create, used for lookup)
  title: string;
  content?: Record<string, unknown>;  // Merged into existing content on update
  tags?: string[];                    // Merged (union) with existing tags on update
  metadata?: Record<string, unknown>; // Merged into existing metadata on update
}

On update, content and tags are merged with the existing values rather than replaced. This allows external agents to write partial updates without clobbering fields they do not own. The underlying function is upsertEntityKeyed() in features/entities/server/api-actions-keyed.ts; successful creates and updates route through EntityWriter.

Vault Import/Export API

EndpointMethodAuthDescription
/api/entities/export/vaultPOSTSession (entities.team.read)Export entities as a zip of Obsidian-compatible markdown files.
/api/entities/import/vaultPOSTSession (entities.team.create)Import a zip of markdown files as entities with wikilink relation resolution.

POST /api/entities/export/vault request body:

{
  typeSlug?: string;    // Filter by entity type (omit to export all types)
  ids?: string[];       // Specific entity IDs to export (omit to export all matching type)
}

Returns a binary ZIP download (Content-Type: application/zip) where each file is named {entity-slug}.md and follows the entity markdown format.

POST /api/entities/import/vault request body:

Accepts multipart/form-data with a single vault file field containing a .zip archive of markdown files.

Import is two-pass:

  1. First pass creates all entities from frontmatter and body content, collecting title-to-ID mappings.
  2. Second pass resolves [[wikilinks]] against the title map and upserts mention entity relations.

Returns { imported: number, failed: number, errors: string[] }.

FunctionSignatureDescription
syncWikilinkRelations(entityId, wikilinkTitles)(entityId: string, wikilinkTitles: string[]) => Promise<void>Full-reconcile mention relations for an entity. Deletes stale mention relations not in the title list; creates new ones. Called on every body save.
resolveWikilinkTitles(titles, tenantId)(titles: string[], tenantId: string) => Promise<Record<string, string>>Look up entity IDs by title. Returns a { title → id } map for titles that matched. Used during vault import.

Preview API

EndpointDescription
GET /api/entities/[id]/previewLightweight entity preview for hover cards. Returns id, title, type info, image URL, up to 3 non-empty content fields, and up to 4 tags. Requires auth. Response cached 30s.

Owner API

EndpointMethodDescription
/api/entities/[id]/ownerGETReturns { ownerId: string | null, profile: OwnerProfile | null } where OwnerProfile has id, displayName, email, avatarUrl.
/api/entities/[id]/ownerPATCHUpdates owner_id. Body: { ownerId: string | null }. Returns the updated entity row.

Activities API

EndpointMethodDescription
/api/entities/[id]/activitiesGETReturns the 10 most recent activity entries for the entity, with actor_name resolved from profiles. Each entry includes id, action, title, description, actor_id, actor_name, metadata, and created_at.

View Tracking (features/entities/server/view-tracking.ts)

FunctionSignatureDescription
getLastViewedAt(entityId)(entityId: string) => Promise<string | null>Returns the ISO timestamp of when the current user last viewed this entity, or null if no record exists.

Type Helpers (features/entities/types.ts)

FunctionDescription
getEntityTypeIcon(type)Reads icon from config.ui.icon, falls back to top-level icon field.
getEntityTypeColor(type)Reads color from config.ui.color, falls back to top-level color field.
getEntityTypeDescription(type)Reads description from config.ui.description, falls back to top-level field.
isContainerType(config)Returns true if the entity type has container.enabled = true.
getAllowedChildTypes(config)Returns allowed child type slugs for a container.

For Agents

AI agents interact with entities through eight entity tools (defined in features/tools/entity-tools.ts):

ToolPermissionDescription
searchEntitiesentities.team.readSearch by title, filter by type slug and/or tag. Results include the body (description) field.
getEntityentities.team.readFull entity details by UUID. Response includes body (description) field.
createEntityentities.team.createCreate with content fields, tags, optional relation map, and optional body (markdown text).
updateEntityentities.team.updateModify title, content fields, tags, or body (content is merged; body replaces).
deleteEntityentities.team.deletePermanent deletion
createRelationentities.team.createLink two entities with a typed relationship
listEntityTypesentity_types.team.readList all types with field schemas
getEntityStatsentities.team.readCounts per entity type

Agent prompt context includes entity body text so agents have access to prose notes and narrative alongside structured fields.

TypeSpec Tools for Agents

Agents can create and evolve entity type definitions using the TypeSpec runtime API:

  • GET /api/entity-types/[slug]/typespec — Export an existing type as markdown to understand its current schema before modifying it.
  • POST /api/entity-types/from-typespec — Submit a modified or new TypeSpec markdown document to create or update an entity type definition.

This enables agents with admin permissions to design new data types, add fields, and define extraction instructions entirely via markdown — without needing direct DB access.

Agents also interact with entities through the extraction system, which uses specialized tools (searchLinkedDocuments, searchConnectedEntities, getEntityField, submitValue) to populate individual fields.

Type-Spec Markdown Format

features/entities/type-spec/ provides a bidirectional Markdown representation for entity type definitions. Specs are .md files that define a type's schema, field extraction instructions, connections, and scoring criteria in a human-readable format. They compile to the { slug, name, json_schema, config } shape used by seed scripts and the entity_types table.

Spec File Structure

---
type: opportunity
icon: lightbulb
color: amber
cardFields: [status, estimated_annual_value]
---

# Opportunity

An AI automation opportunity or use case.

---

## Fields

- status: select [identified, researching, scoped, deployed] (required)
- estimated_annual_value: currency — Estimate the annual dollar value. Return a number.
  sources: connected-entities
  dependsOn: business_case
- is_archived: boolean

## Connections

- solves → pain_point (many) — Pain Points Solved
- belongs_to → company (one) — Company

## Scoring

- Revenue Impact: weight 0.30, scale 1-10
- Ease of Implementation: weight 0.25, scale 1-10

Frontmatter keys:

KeyTypeDescription
typestring (required)Entity type slug
iconstringLucide icon name
colorstringTailwind color name
cardFieldsstring[]Fields shown on entity cards
isParentbooleanWhether the type acts as a container
hideFromDataNavbooleanHide from the sidebar navigation

Field line syntax:

- field_name: type [opt1, opt2] (constraint, constraint) — Extraction instructions
  sources: connected-entities, linked-documents
  dependsOn: other_field, another_field
  • type is one of: text, long_text, number, currency, select, multi_select, boolean, date, url, email, array
  • [options] — comma-separated enum values for select and multi_select
  • (constraints)required, min: N, max: N, integer: true, label: Display Name
  • — description — doubles as the field's extraction instruction (populates config.fields[name].extraction.instructions)
  • Indented sources: and dependsOn: lines map to config.fields[name].extraction.sources and .dependsOn

Connection line syntax:

- relationship_type → target_slug (one|many) — Label Text

Use * as the target slug for untyped connections.

Scoring line syntax:

- Label Text: weight 0.30, scale 1-10

The criterion name is auto-derived from the label ("Revenue Impact""revenue_impact").

TypeSpec Types

interface TypeSpec {
  slug: string;
  name: string;
  prose: string; // Body text between H1 and first ## section
  icon?: string;
  color?: string;
  fields: TypeSpecField[];
  connections: TypeSpecConnection[];
  scoring: TypeSpecScoringCriterion[];
  extractionOrder: string[]; // Fields with descriptions, in spec order
  cardFields?: string[];
  isParent?: boolean;
  hideFromDataNav?: boolean;
}

interface TypeSpecField {
  name: string;
  type: TypeSpecFieldType;
  required?: boolean;
  options?: string[];
  constraints?: {
    min?: number;
    max?: number;
    integer?: boolean;
    label?: string;
  };
  description?: string; // Extraction instructions
  sources?: string[];
  dependsOn?: string[];
}

API Reference

FunctionLocationDescription
parseTypeSpec(markdown)parse-type-spec.tsParse a spec string into a TypeSpec object. Throws on syntax errors.
compileTypeSpec(spec)compile-type-spec.tsConvert a TypeSpec into { slug, name, json_schema, config } ready for DB insert.
generateTypeSpec(record)generate-type-spec.tsConvert a DB entity type record back into a spec Markdown string.
typeSpecToMarkdown(spec)generate-type-spec.tsSerialize a TypeSpec to Markdown.
entityTypeToTypeSpec(record)generate-type-spec.tsConvert a { slug, name, json_schema, config } record into a TypeSpec.
loadTypeSpecs(dir)load-type-specs.tsRead all .md files from a directory and return CompiledEntityType[]. Used in seed scripts.

All functions are exported from features/entities/type-spec/index.ts.

TypeSpec Runtime API

The TypeSpec parser and compiler are also exposed at runtime via two HTTP routes, allowing agents and external tools to create or export entity type definitions as markdown without direct database access.

EndpointMethodAuthDescription
/api/entity-types/from-typespecPOSTSession (admin)Create or update an entity type from a TypeSpec markdown string. If a type with the same slug already exists, it is updated in place. Returns the created/updated entity type record.
/api/entity-types/[slug]/typespecGETSessionExport an entity type as a TypeSpec markdown string. Returns { markdown: string }.

POST /api/entity-types/from-typespec request body:

{
  markdown: string;          // Full TypeSpec markdown document
  tenantScoped?: boolean;    // If true, creates a tenant-specific type (default: global)
}

Entity Instance Markdown Format

features/entities/type-spec/entity-markdown.ts provides an Obsidian-compatible markdown representation for entity instances.

// Generate markdown for an entity
generateEntityMarkdown(entity: EntityRecord, entityType: EntityTypeRecord): string

// Parse a markdown file back into create/update input
parseEntityMarkdown(markdown: string): ParsedEntityMarkdown

interface ParsedEntityMarkdown {
  title: string;
  description?: string;           // Body text (between frontmatter and structured fields)
  content: Record<string, unknown>; // Structured fields from frontmatter
  tags?: string[];
  typeSlug?: string;               // From frontmatter `type` key
  wikilinks: string[];             // All [[wikilink]] titles found in body text
}

File format:

---
type: opportunity
tags: [ai, automation]
status: researching
estimated_annual_value: 250000
---

# Opportunity Title

This is the body text. It can reference [[Related Company]] or [[Pain Point Name]]
using wikilinks.

Additional prose here...
  • Frontmatter holds structured content fields (by their JSON schema key names) plus type and tags.
  • The H1 line is the entity title.
  • All text after the H1 (and optional frontmatter section divider) becomes the description body field.
  • [[wikilinks]] in the body are preserved in generated output and parsed back to wikilinks[] on import.

Example Specs

content/type-specs/ contains 14 ready-to-use specs covering common entity types (company, person, meeting, opportunity, goal, research, pain point, process, technology, source, agent-task, agent-goal). These serve as both working examples and the seed data for the Amble MortgageQ product.

Roundtrip Guarantee

generateTypeSpec(compileTypeSpec(parseTypeSpec(markdown))) produces semantically equivalent output to the original spec. The test suite in roundtrip.test.ts verifies this property across all 14 example specs.

Extraction (Task-Based)

Field extraction runs through the unified tasks → sessions → session-executor primitive.

How it works:

  1. Saving an entity type config calls syncSystemTasks(), which idempotently generates one parent extraction task plus one child task per extractable field (and one input-{field} per humanInput field). Child tasks use output_type: 'response' (the canonical value as of 2026-04-15). Stale children for removed fields are deleted in the same pass.
  2. When an entity is created, entity/createdtask-dispatchtriggerTask() fires a session for each matching active task.
  3. The session-executor claims each child session, resolves the agent via SprinterAgent adapter, and executes — the agent's output flows through bundleContentThroughResponses()entity_responsepromote_field_value RPC → entities.content.
  4. Every step is recorded in session_events (append-only audit log).

The ExtractionSection of the unified FieldsTab shows whether a field has a dedicated extraction subtask. If not, a one-click "Create dedicated subtask" CTA creates the child task linked to the entity type's default_extraction_task_id.

output_type migration: Legacy tasks with output_type: 'field' or output_type: 'fields' were backfilled to output_type: 'response' in migration 20260415000010_migrate_tasks_output_type.sql. New code must use 'response'.

Design Decisions

Every field write flows through entity_response, never directly to entities.content. Prior to this change, inline edits, API writes, and agent writes each took different code paths and bypassed the response system entirely. The response table is now the universal write primitive: it provides versioned audit history, confidence tracking, and configurable auto-promotion in one place. The performance cost of the extra row is negligible; the audit and promotion benefits are permanent.

FieldConfigSchema and EntityTypeConfigSchema use .strict(), not .passthrough(). The previous .passthrough() allowed arbitrary unknown keys to silently persist in the JSONB columns — deprecated keys accumulated over time without any warning. Switching to .strict() means any write with an unknown key fails immediately at the schema boundary. The allowDeprecated escape hatch exists only for migration scripts that legitimately need to strip-and-rewrite legacy rows.

Deprecation checks run before Zod parsing in the write gate. assertValidEntityTypeWrite() inspects the raw input for deprecated keys before calling EntityTypeConfigSchema.parse(). If this order were reversed, Zod strict mode would fire a generic "unrecognized keys" error that gives no guidance on what to do instead. The pre-parse check produces messages like "field status: use 'relation' not 'connection'" that tell callers exactly what to change.

FieldConfig.relation absorbs the full FieldConfig.connection shape. The legacy connection field was a parallel concept to relation that added UI-only properties (display, dataTableVariant, mode). Keeping two shapes for the same concept caused divergence between the TypeSpec runtime, the admin UI, and the resolver. Absorbing the UI properties into RelationConfig gives a single shape that works everywhere. The resolver remains backward-compatible for rows not yet migrated.

Field rename and delete use server-side RPCs, not application-layer loops. rename_field_cascade and delete_field_cascade execute atomically in a single database transaction. An application-layer loop (update entities one by one) would be non-atomic, subject to partial failure, and slow for large tenants. The RPC approach also means the cascade logic lives in a single audited place rather than scattered across multiple server actions.

Impact preview runs before the cascade, not after. previewFieldImpact() queries counts of affected objects and returns them to the UI before the administrator confirms the action. This is a SELECT-only operation — it has no side effects. The rename/delete RPCs are not called until the administrator explicitly confirms. This pattern follows the principle of showing blast radius before committing destructive operations, matching the approach used by manageTasks and deleteEntityType.

The ESLint rule is the second enforcement layer, not the first. assertValidEntityTypeWrite() is the primary gate — it runs at runtime and rejects bad writes with clear errors. The ESLint rule no-direct-entity-type-write is a second layer that catches bypass attempts at lint time. The allowlist (the gate file itself, scripts, migrations, tests) is intentionally narrow: new platform code that writes entity type config must go through the gate. The lint rule makes it impossible to accidentally add a new bypass in a PR.

auto_promote_policy defaults to "always" for backward compatibility. Existing fields without an explicit auto_promote_policy behave as they always have: value is promoted immediately. Administrators can change this per-field via the Promotion section of FieldsTab. The "if_confident" policy is useful for AI-extracted fields where confidence varies; "never" is useful for human-review workflows where an agent populates a draft and a human promotes.

Single table for all entities. Rather than creating a separate table per entity type, all entities share one table with a JSONB content column. This keeps the schema flexible and makes the platform code truly type-agnostic. The tradeoff is that field-level queries require JSONB path operators, but PostgreSQL's JSONB indexing mitigates this.

Denormalized entity_type_slug. Joining entity_types just to filter by slug was a measurable performance cost on list pages. The denormalized column, kept in sync by a trigger, eliminates this join for the most common query pattern.

Content merge on update. Partial updates merge with existing content using shallow object spread. This prevents agent-driven field extraction from accidentally clearing other fields. Tags, however, are replaced entirely on update since partial tag modification is ambiguous.

Per-request caching. getEntityTypes() and getEntityById() use React's cache() to deduplicate within a single server render. Multiple components on the same page can call these functions without additional DB hits.

Activity logging on every write. Every create, update, and delete operation logs to the activities table. This is fire-and-forget (errors are swallowed) to avoid blocking the primary operation.

Hover card fetches on open, not on render. EntityHoverCard uses enabled: open in its React Query config so no network request fires until the user actually hovers. This keeps list and grid pages fast regardless of how many entity links are rendered.

Reset requires the global type ID, not the local fork ID. resetEntityType() takes the global entity type's UUID. This makes the caller's intent explicit (you are resetting to this specific global version) and prevents accidental deletion of a non-fork type by passing the wrong ID.

Body field uses the existing description TEXT column, not a new column. Entities already had a description column that was sparsely used. Promoting it to a first-class body field with FTS indexing (weight B) and a rich editor avoids a migration that renames or moves data. The column name is intentionally generic so it works across all entity types without schema changes.

Wikilink mentions use full-reconcile on save, not incremental diff. On each body save, syncWikilinkRelations() deletes all existing mention relations for the entity and re-inserts the current set. This is simpler than tracking an "old wikilinks" state and computing a diff, and it self-heals from any out-of-sync state. The cost is a few extra DB writes per save — acceptable given body edits are infrequent relative to structured field reads.

Vault import uses a two-pass strategy. The first pass creates all entities without resolving wikilinks, so forward references work without requiring a topological sort of the import files. The second pass resolves [[wikilinks]] using the title-to-ID map built during the first pass. This handles cycles and mutual references cleanly.

TypeSpec runtime API requires admin session, not API key. Creating or modifying entity type definitions changes the platform schema — an operation that is too significant for API-key-scoped automation. Restricting it to admin sessions adds a human checkpoint and preserves the TypeSpec CLI workflow for automated seeding.

Owner badge skips fetch when owner_id is null. OwnerBadge receives initialOwnerId as a prop so it can disable the React Query fetch entirely (enabled: !!initialOwnerId) for the common case of an unowned record. This avoids unnecessary round-trips on every entity detail page load.

History tab is lazy-loaded on first expand. EntityHistoryTab sets enabled: sectionOpen in its React Query config so no network request fires until the user explicitly opens the section. Most entity views do not need the history timeline; the deferred fetch keeps initial page load fast.

isNew is computed once in the bento, not per field card. The "new field" determination is performed during block assembly in EntityBento, where the viewedThreshold Date object is created once and reused across all fields. Doing this in each FieldCardView would force each block to independently parse the timestamp.

getLastViewedAt is fetched in the page's Promise.all. The last-viewed timestamp is a page-level concern — it should be available before any blocks render so there is no content flash. Fetching it server-side in Promise.all with the other page data keeps it on the critical path without adding a waterfall.

Belt-and-suspenders UUIDs for entity relations. All insert sites for entity_relations pass an explicit crypto.randomUUID() even though the DB column has a gen_random_uuid() default. A prior migration accidentally dropped this default and caused widespread relation insert failures. The explicit ID ensures the operation succeeds even if the default is lost again.

UUID suppression in bento Default tab. The EntityBento component skips fields whose values are raw UUIDs when the field matches entity-reference naming conventions: schema.format === "uuid", name ending in _id, or name starting with in_ or has_. Entity reference fields are already rendered as connection blocks above the field grid; displaying their raw UUID value below adds no information. The heuristic is intentionally conservative — it only suppresses fields that look like foreign keys by naming convention. A future improvement would mark relation reference fields explicitly in the JSON schema (x-is-relation-ref: true) so the bento can suppress them without pattern matching.

External identity for idempotent external writes. The external_id + external_source columns allow external agents (OpenClaw, webhooks, import pipelines) to reference their own IDs without tracking internal UUIDs. upsertEntityKeyed() looks up by (tenant_id, external_source, external_id) — if a match exists it merges; if not it creates. The partial unique index (WHERE external_id IS NOT NULL) keeps the index small since only externally-sourced records have this pair set.

Upsert merges content and tags, never replaces. External agents often write partial records (they know some fields but not all). Merging rather than replacing ensures that data written by one agent (or by a human) is not silently overwritten by a second agent that only knows a subset of fields. This matches the behavior of updateEntity() for session-based writes.

  • Block System -- Entities are rendered through blocks; entityToBlocks() converts entity fields to field-card blocks
  • Field Rendering -- The unified substrate at features/schemas/ that renders every field input and display; the entity edit form delegates to <FieldInput /> via fieldDefinitionFromProperty()
  • View System -- Views compose blocks into layouts for entity list and detail pages
  • Tool System -- Entity tools expose CRUD operations to AI agents
  • Agent System -- Agents use entity tools and the extraction system to populate entity fields
  • Obsidian Interop -- Bidirectional Obsidian vault sync, wikilink graph, and TypeSpec markdown format
  • Responses -- entity_responses table is the universal write primitive for all field values; criteria sets, scored responses, promotion, and aggregation live in features/responses/
  • Sessions -- Micro-sessions created by every field write are tracked in the unified sessions + session_events tables in features/sessions/

On this page

OverviewKey ConceptsEntity TypesEntitiesEntity Type ConfigField ConfigsLocal Fields and Drift (per-entity overlay)Field state modelconcept — the untyped-by-default system entity typeEntity RelationsRelation Fields (First-Class)Hierarchy (parent_id, breadcrumbs, cascade delete)Edge ScoringEntity Type Access ControlHow It WorksEntity Response as Universal Write PrimitiveEntity Type Write GateUnified Fields Admin TabField Lifecycle — Rename and DeleteSchema-Health Drift DetectionCRUD Operationsentity_type_slug DenormalizationTags and GIN IndexingValue LockingParent-Child HierarchiesSearch and FilteringDate-Relative FiltersScoringImport/ExportBody Field and Rich EditorTipTap Editor and WikilinksBulk OperationsFilter PresetsData Table Density ModesMulti-Column SortAggregate Summary FooterData Table Meta InterfaceColumn Visibility and localStorageField Display FormattersStatus ChipsConfig-Driven Empty StatesOwner DisplayEntity History Timeline"New" Field BadgesAuto-Field GroupingEntitySummaryCardClick-to-Edit Field AffordanceCross-Entity Activity TimelineFirst-Login Onboarding WizardSchema-Driven Form SectionsView TrackingEntity Form Field Ordering and LabelsForm Help Text, Smart Placeholders, and Keyboard SubmitEntity Hover Cards and Preview APIReset-to-Public (Entity Type Reset)API ReferenceWrite Seam (features/entities/server/entity-writer.ts)Server Actions (features/entities/server/actions.ts)Bulk Actions (features/entities/server/bulk-actions.ts)Entity Type Write Gate (features/entities/server/entity-type-write-gate.ts)Field Lifecycle (features/entities/server/field-lifecycle.ts)Inline Edit / Response Routing (features/responses/server/)Entity Type Settings (features/entities/server/entity-type-settings.ts)Upsert API (External Identity)Vault Import/Export APIWikilink Relations (features/entities/server/wikilink-relations.ts)Preview APIOwner APIActivities APIView Tracking (features/entities/server/view-tracking.ts)Type Helpers (features/entities/types.ts)For AgentsTypeSpec Tools for AgentsType-Spec Markdown FormatSpec File StructureTypeSpec TypesAPI ReferenceTypeSpec Runtime APIEntity Instance Markdown FormatExample SpecsRoundtrip GuaranteeExtraction (Task-Based)Design DecisionsRelated Modules