Documentation source
Custom Workspaces
Tenant-defined multi-page workspaces rendered through existing platform primitives — no platform routes added per tenant.
## Overview
A **custom workspace** is a tenant-defined workspace whose entire route tree, nav, and data-fetching shape lives inside the tenant module (or in DB rows installed via the bundle pipeline). The platform renders it through three existing primitives:
- `customPageRegistry` — code-resident page renderers keyed by `<workspaceSlug>/<pageSlug>`
- `custom_pages` — workspace-scoped per-route rows in the DB (4-tier scope resolver)
- `EntityDataSource` — the same named data-source mechanism used by blocks and views
No per-tenant code in `app/(app)/`. No new platform routes. One `TenantModule.workspaces` declaration installs a complete multi-page surface.
The **DOC360 Clinic** (`features/custom/tenants/docs/clinic/workspace.ts`) is the canonical reference: four surfaces (Floor, Daily Wrap, Protocol Board, Mobile Floor) reachable at `/t/docs/w/clinic/p/{floor,wrap,board,mobile}`.
The **OCI AI Retainer Command Center**
(`features/custom/tenants/oci/command-center/workspace.ts`) is the reference
for client-retainer modules: five protected workspace pages reachable at
`/t/oci/w/command-center/p/{portfolio,process,opportunity,loop,weekly}`. It
uses code-resident fixtures today and keeps every product-specific component
under the OCI tenant module.
## The pattern in 30 seconds
```
TenantModule.workspaces
└─ registerWorkspaceModule (boot-time)
├─ customPageRegistry entries (keyed "clinic/floor", etc.)
│ └─ matches: src => src.workspaceSlug === "clinic" && src.tenantSlug === "docs"
└─ BundleManifest (workspace + custom_pages + workspace_nav_overlay sections)
installWorkspaceFromTemplate("docs/clinic") ──► DB rows
├─ workspaces row (slug="clinic", tenant_id=<docs-uuid>)
├─ custom_pages rows (slug="floor", "daily-wrap", "mobile", "board")
└─ workspace nav overlay
Request: /t/docs/w/clinic/p/floor
└─ app/(app)/p/[[...slug]]/page.tsx (catchall)
├─ getCustomPageBySlug("floor") → custom_pages row
├─ getCustomPageRendererEntry(page) → registry entry
├─ resolveCustomPageDataSources(page, entry, ctx) → ResolvedDataSources
└─ <ClinicFloorSurface page={page} data={data} />
```
## Authoring a custom workspace
Create a `workspace.ts` file under `features/custom/tenants/<tenant>/` and use the provided helpers:
```ts
// features/custom/tenants/acme/clinic/workspace.ts
import { defineCustomWorkspace } from "../../lib/define-custom-workspace";
import type { PageModule } from "@/features/custom/lib/tenant-module";
import { AcmeFloorSurface } from "./surfaces/floor";
import { getAcmeDataSource } from "./data-sources";
const floorPage: PageModule = {
title: "Floor",
component: AcmeFloorSurface as unknown as PageModule["component"],
layout: "wide",
dataSourcesSchema: floorDataSourcesSchema,
// Factory: ctx.nowISO is the REQUEST date, not server boot date.
dataSources: (ctx) => ({
todaysItems: getAcmeDataSource({ name: "todays-items", dateISO: ctx.nowISO }),
activePatients: getAcmeDataSource({ name: "active-patients" }), // time-independent
}),
};
export const acmeClinicWorkspace = defineCustomWorkspace({
slug: "clinic",
title: "ACME Clinic",
description: "Daily clinical ops for ACME coaches",
nav: [
{ slug: "floor", label: "Floor", icon: "activity", path: "/p/floor", order: 10 },
],
pages: { floor: floorPage },
});
```
Wire it into the tenant module:
```ts
// features/custom/tenants/acme/index.ts
export const acmeTenantModule: TenantModule = {
tenantSlug: "acme",
entityTypes: { ... },
workspaces: { clinic: acmeClinicWorkspace },
};
```
At boot, `registerWorkspaceModule` processes each entry in `workspaces` and populates the registry + manifest store. Call `installWorkspaceFromTemplate("acme/clinic")` during tenant provisioning to write the DB rows.
## AI Retainer command-center pattern
Use this shape when a tenant needs a weekly retainer review cockpit rather than
a generic workspace:
| Page | Purpose |
|------|---------|
| `portfolio` | AI opportunity portfolio, value proof, SOP coverage, human decisions, and agent work in flight |
| `process` | Process map with living SOP extraction, lineage, owner, and pending agent drafts |
| `opportunity` | Ranked AI opportunities with scoring dimensions, required records, gates, and next actions |
| `loop` | Live agent loop health, trace, lessons, metrics, and promotion evidence |
| `weekly` | Client-ready summary of proof, deliverables, asks/decisions, and next-cycle goals |
Boundary rules:
- Product-specific UI and seed fixtures stay under
`features/custom/tenants/<slug>/command-center`.
- The tenant module owns `workspaces: { "command-center": workspace }` and
spreads the workspace page entries into `TenantModule.pages`.
- Page `source` metadata must include the tenant slug, workspace slug, and a
tenant-module renderer key such as
`tenant-module:oci:command-center:weekly`.
- Do not move helpers into `features/custom/lib/` until at least two tenants
consume the same type or component contract.
- Live data should enter through page-local `dataSources` factories only after
the tenant's entity graph exists. Do not persist `dataSources` into
`custom_pages.source`.
Adoption notes:
- **Marbella:** the shared `command-center` workspace IS Marbella's operating
surface — its pages read from Marbella's automation queue, SOP library,
reporting pack, and the canonical entity graph (workstreams / SOPs / tasks).
The old seed-driven operating-partner cockpit (`/p/operating-partner`) was
retired; the only remaining Marbella-specific custom page is the
tenant-root `context-engine` page. Only extract shared helpers after Marbella
and OCI have the same weekly-goal or proof-summary shape.
- **IMS/Nathan:** start with `portfolio`, `process`, and `weekly` pages backed
by IMS visualizer/product-catalog records and Nathan retainer decision asks.
Add `opportunity` and `loop` once the first repeatable agent loop is seeded.
## Authoring a page module
Each page in `WorkspaceModule.pages` is a `PageModule`. The key fields:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `title` | `string` | yes | Display name |
| `component` | `CustomPageRenderer` | yes | React component; `"use client"` for interactive surfaces |
| `layout` | `"default" \| "wide"` | no | Defaults to `"default"` |
| `dataSources` | `DataSourcesFactory<TSources>` | no | Factory or static value; omit for pages with no data |
| `dataSourcesSchema` | `z.ZodType<DataSourceShape>` | no | Required when `dataSources` is provided |
| `permission` | `AppPermission` | no | Reserved — defaults to workspace membership in v1 |
### `dataSources` as a factory
When any source depends on the current date, user, or workspace slug, use the factory form:
```ts
dataSources: (ctx: PageRequestContext) => ({
todaysVisits: getClinicDataSource({
name: "todays-visits",
dateISO: ctx.nowISO, // request-time date — never server boot time
}),
}),
```
`PageRequestContext` carries `{ nowISO, tenantSlug, workspaceSlug, userId }`. The resolver calls the factory at SSR time. Time-independent sources may be a static literal but are conventionally placed inside the factory alongside date-dependent peers for visual consistency.
### Component props
Surfaces receive `{ page: CustomPage; data?: ResolvedDataSources<TSources> }`. The `data` prop is **optional** — surfaces that do not use named data sources omit it. `ResolvedDataSources<TSources>` maps each key to `{ entities, total, relationColumnDefs, entityType }`.
```tsx
// features/custom/tenants/docs/clinic/surfaces/floor.tsx
"use client";
type FloorSources = {
todaysVisits: EntityDataSource;
awaitingReview: EntityDataSource;
};
export function ClinicFloorSurface({
page,
data,
}: {
page: CustomPage;
data?: ResolvedDataSources<FloorSources>;
}) {
const visits = data?.todaysVisits?.entities ?? [];
// ... render
}
```
Read entity field values via `createEntityFieldReader(entity, data.todaysVisits.entityType)` when schema validation is needed.
## How it renders
```
GET /t/docs/w/clinic/p/floor
│
├─ Middleware: sets x-tenant-slug=docs, x-workspace-slug=clinic
│ └─ PostgREST hook: validates membership, pins app.workspace_id GUC
│
└─ app/(app)/p/[[...slug]]/page.tsx
│ params.slug = ["floor"]
│ joinedSlug = "floor"
│
├─ getCustomPageBySlug("floor")
│ └─ custom_pages row (workspace_id = <clinic-uuid>, tenant_id = <docs-uuid>)
│
├─ Workspace-presence guard: page.workspaceId ≠ null && getActiveWorkspaceSlug() ≠ null
│ └─ no workspace in URL → notFound()
│
├─ getCustomPageRendererEntry(page)
│ └─ customPageRegistry lookup: matches src.workspaceSlug + src.tenantSlug + src.renderer
│
├─ resolveCustomPageDataSources(page, entry, await buildPageContext())
│ ├─ dataSources factory called with { nowISO, tenantSlug, workspaceSlug, userId }
│ └─ executeEntityDataSourceQuery() per named source, in parallel
│
└─ return <ClinicFloorSurface page={page} data={resolvedData} />
└─ Server Component renders the JSX subtree;
"use client" surface boundary handled by Next.js serialization
```
Multi-segment paths (`/p/patients/intake/step-1`) work transparently — `params.slug` becomes `["patients", "intake", "step-1"]`, joined to `"patients/intake/step-1"`, and matched against `custom_pages.slug`. `SlugPath` validation enforces lowercase segments separated by `/` with no leading or trailing slash.
## For agents
Agents have two authoring paths for custom workspaces:
### Code-resident (compile-time)
Use when the workspace logic requires TypeScript type safety, server-only data utilities, or tightly-typed `ResolvedDataSources` props.
1. Author a `workspace.ts` in the tenant module folder.
2. Add it to `TenantModule.workspaces`.
3. Run `installWorkspaceFromTemplate("<tenant>/<workspace>")` to seed DB rows.
This is the standard path for first-party tenant modules.
### DB-resident (agent vibe-coded)
Use when authoring a one-off dashboard or insight surface without a code deployment:
```ts
// Agent calls manageBundle with a custom_pages section
await manageBundleAction({
manifest: {
workspaces: [{ slug: "my-dash", title: "My Dashboard", ... }],
custom_pages: [
{
slug: "my-dash/overview",
title: "Overview",
runtime: "module", // uses a compiled artifact
source: {
renderer: "artifact:<artifactId>",
workspaceSlug: "my-dash",
tenantSlug: "acme",
},
install_scope: "workspace",
},
],
},
});
```
DB-resident pages use `runtime: "module"` and reference a compiled artifact via `manageComponent`. Named data sources (`dataSources`) are NOT supported for DB-resident pages in v1 — module-kind components fetch their own data inside the iframe via `useAction("refreshData")`.
### Available tools
| Tool | Action |
|------|--------|
| `installWorkspaceFromTemplate` | Idempotent install of a code-resident workspace manifest |
| `manageBundle` | Install/update DB-resident workspace rows (custom_pages, workspace sections) |
| `manageComponent` (compile action) | Compile + publish a TSX surface for a `runtime: "module"` page |
## Design decisions
### Why bare slug in the DB, not `<workspace>/<slug>`
The `matches` predicate on the registry entry gates by `source.workspaceSlug` and `source.tenantSlug`, so a `slug = "floor"` row in workspace A cannot match the renderer registered for workspace B. The DB slug is the path segment the catchall receives (`"floor"`), not a composite key. This means `getCustomPageBySlug("floor")` returns the workspace-scoped row when the active workspace GUC is set — the 4-tier resolver handles workspace > tenant precedence automatically.
If the slug were `"clinic/floor"` in the DB, the URL would have to be `/p/clinic/floor` (the workspace is already in the URL as `/w/clinic/`). One extra redundant segment per page is not acceptable.
### Why `dataSources` is a factory
A static module-level object captures values at server boot, not at request time. `nowISO: todayISO()` evaluated at boot is correct for the first request of the day, then stale for every request thereafter. The factory pattern — `(ctx) => ({ todaysVisits: getSource({ dateISO: ctx.nowISO }) })` — is evaluated per-request. The resolver detects `typeof dataSources === "function"` and calls it with the live `PageRequestContext`. The cost is one function call per page load; the benefit is always-correct data for date-sensitive queries.
### Why `source.dataSources` is stripped on install
`dataSources` is a TypeScript value — it may be a function, a Zod-inferred type, or a closure. None of these serialize to JSON. Even if the static form were serializable, writing it to the DB creates a second source of truth that drifts from the code. The invariant is: code owns configuration, DB owns identity (tenant, workspace, slug, renderer key). The strip happens at two layers — the TS `installBundle` wrapper removes `source.dataSources` before the INSERT, and `install_bundle()` SQL function never reads it.
## Reference implementation — Structure Map
The **Structure Map** (`features/custom/workspaces/structure-map/`) is the canonical reference for a code-defined workspace that ships its own entity types, seeds, server actions, and a fully interactive canvas — all as a single installable unit.
### What it is
An Orca-style interactive ownership chart for family offices. It visualizes the full beneficial ownership and governance structure of a family group: natural persons, holding entities, and asset positions, connected by ownership edges (with % interest) and role edges (trustee, settlor, director, etc.).
### Atlas data model
Three entity types drive the chart:
| Entity type | Represents |
|---|---|
| `atlas-person` | Natural person (beneficial owner, trustee, officer) |
| `atlas-entity` | Legal entity (trust, LLC, LP, company, foundation) |
| `atlas-asset` | Asset position (real estate, investment, bank account) |
Edges are stored in `entity_relations`:
- **Ownership edge** — `relationship_type: "owns"`, `metadata: { ownershipPct, ownershipKind, shareCount, shareClass, since }`
- **Role edge** — `relationship_type: "role:<role>"` where `<role>` is one of `trustee | settlor | protector | beneficiary | director | officer | manager | member | advisor`
Server-side invariants (`lib/atlas-invariants.ts`) enforce structural correctness at write time: no self-links, assets cannot own, people cannot be owned, roles are person→entity only, duplicate and ownership-cycle detection.
### Canvas components
The workspace ships a `@xyflow/react` canvas with:
- Dagre top-down layout with collapsible subtrees (hidden-count badge)
- Orthogonal ownership edges with circular %-pill labels; dashed role edges
- Re-root ("view from here"), node picker, search + kind/role filters
- Detail rail — fields, holdings with %, roles, valuation roll-ups, and **effective (look-through) ownership** for indirect holders
- **"As of" point-in-time toolbar control** — filters the chart to edges in effect on a chosen date
- Sentence-builder add bar — natural-language quick-add with inline node picker
- PNG export via `components/lib/export-png.ts`
### Effective (look-through) ownership
`computeEffectiveOwnership` (`components/lib/atlas-effective-ownership.ts`) computes each upstream holder's true economic interest in a selected record by walking the ownership graph upward from the target. For each distinct directed path the fraction is the product of the per-edge ownership percentages; contributions from multiple paths to the same holder are summed.
```
Trust → 70% → Holdings → 85% → OpCo
effective ownership of OpCo by Trust = 0.70 × 0.85 = 59.5%
```
The detail rail surfaces this section only for indirect holders (chains of 2 or more edges). Direct ownership is already visible on the chart edge itself. When any edge on a contributing path has a missing or invalid `ownershipPct`, the holder is flagged `incomplete: true` and the figure is annotated — the implementation never silently understates a holding.
Key types:
```ts
interface EffectiveOwner {
holderId: string;
pct: number; // 0–100
incomplete: boolean; // true when a contributing path crossed an invalid edge
minDepth: number; // 1 = direct, 2+ = indirect
}
```
Cycle-safe: a path-local visited set prevents the walker from re-entering any node already on the current path.
### "As of" point-in-time filter
`filterGraphAsOf` (`components/lib/atlas-as-of.ts`) accepts an ISO date string and returns a new graph containing only edges in effect on that date. Nodes are never removed — only edges.
| Edge kind | Exclusion rule |
|-----------|----------------|
| `owns` | excluded when `startDate` (the "since" date) is **after** the cutoff |
| role | excluded when `startDate > cutoff` (not yet started) **or** `endDate < cutoff` (already ended) |
Edges without the relevant date field are kept — absence of a date means the edge cannot be proved absent. An unparseable cutoff string returns the original graph unchanged.
The toolbar control renders as a clearable date badge and composes downstream with re-root, collapse, and kind filters. Dagre re-runs the layout against the filtered edge set so the chart correctly reflects the point-in-time structure.
### Installing on a tenant
```bash
# During tenant provisioning or via agent tool:
installWorkspaceFromTemplate("marbella/structure-map")
```
This is idempotent — re-running skips already-installed rows. The marbella seed ships 13 nodes and 16 edges as a worked example.
### Authoring pattern
Structure Map follows the same four-file pattern as the DOC360 Clinic:
```
features/custom/workspaces/structure-map/
manifest.ts ← BundleManifest (entity types + seeds)
workspace.ts ← WorkspaceModule (pages, nav)
types.ts ← shared TypeScript types (AtlasNode, AtlasEdge, AtlasGraph)
seeds.ts ← marbella demo seed (13 nodes, 16 edges)
register.ts ← registers workspace + bundle at boot
server/
atlas-data.ts ← fetchAtlasGraph (use server, tenant-scoped)
atlas-actions.ts ← createAtlasPerson, createOwnershipEdge, etc.
components/ ← canvas, toolbar, detail-rail, node-picker, etc.
pages/
structure-map.tsx ← client-slot page component (React Query)
```
The page is a **client slot** (not a server component): `fetchAtlasGraph` is a `"use server"` action, so the page fetches via the client slot registry and React Query to support optimistic mutations.
## Related modules
- `content/docs/features/workspaces.mdx` — workspace mechanics, URL-as-truth, `workspace_memberships`, scope resolver
- `content/docs/features/custom-pages.mdx` — `custom_pages` table, 4-tier resolver, `customPageRegistry`, `EntityDataSource`
- `content/docs/features/bundles.mdx` — `BundleManifest` schema, `installBundle`, pgTAP auth boundary
- `content/docs/features/agent-system.mdx` — agent authoring tools including `manageComponent` and `manageBundle`
- `.claude/rules/custom-workspaces.md` — invariants, anti-patterns, PR regression checklist
- `.claude/rules/tenant-modules.md` — general tenant module authoring rules