Documentation source
Workspaces
Scoped sub-environments within a tenant — each with its own accent, sidebar overlay, landing route, and default agent.
## Overview
Workspaces are focused environments that live inside a tenant. Where a tenant represents an organization, a workspace represents a function or team within that organization — Marketing, Sales, Executive, Research, Operations, and so on.
Each workspace has:
- An **accent color** (one of eight oklch presets) that appears as a visual indicator in the sidebar rail and nav items.
- A **landing route** that controls where the workspace opens by default (`/dashboard`, `/feed`, `/command-center`, etc.).
- A **sidebar nav overlay** — an optional list of pinned items, groups to show, or items to hide on top of the platform default.
- A **featured entity type list** — up to N entity type slugs that the workspace treats as primary data types (used by sidebar ordering and future workspace-filtered views).
- A **default agent slug** pre-selected in the chat dock when entering the workspace.
A user can belong to any number of workspaces within a tenant. Membership controls visibility — which workspaces appear in the rail and switcher — not data access. RLS still enforces tenant-level data isolation.
## Key Concepts
### `Workspace`
The core row shape returned to the UI. Maps 1:1 to the `workspaces` table.
```typescript
interface Workspace {
id: string;
tenantId: string;
slug: string; // URL segment, lowercase + hyphens
name: string;
description: string | null;
icon: string | null; // Lucide icon name
letter: string | null; // Single-letter logo override
accent: WorkspaceAccentSlug;
landingRoute: string;
defaultAgentSlug: string | null;
featuredEntityTypeSlugs: string[];
navOverlay: WorkspaceNavOverlay;
boundEntityId: string | null;
boundEntityFilter: "parent" | "relation" | null;
isDefault: boolean;
sourceTemplateSlug: string | null;
createdAt: string;
updatedAt: string;
}
```
### `WorkspaceAccent`
Eight oklch accent presets. Each provides a `fg` (foreground, used for indicators and borders) and `bg` (tinted background, used for active tiles and the agent dock ring).
| Slug | Name | Foreground |
| ---------- | -------- | --------------------------------------------------------- |
| `slate` | Slate | `oklch(0.42 0.02 260)` — near-neutral blue-gray (default) |
| `navy` | Navy | `oklch(0.35 0.05 260)` — deep navy (matches `--primary`) |
| `marigold` | Marigold | `oklch(0.55 0.14 65)` — warm amber |
| `moss` | Moss | `oklch(0.5 0.12 145)` — forest green |
| `ember` | Ember | `oklch(0.55 0.18 25)` — burnt orange |
| `lagoon` | Lagoon | `oklch(0.52 0.11 200)` — teal |
| `iris` | Iris | `oklch(0.52 0.14 295)` — violet |
| `rose` | Rose | `oklch(0.55 0.15 10)` — dusty rose |
### `WorkspaceNavOverlay`
Optional sidebar customization merged as the **final layer** in the nav resolution pipeline when the workspace is active. Uses the same `NavConfigOverride` schema as tenant and user overrides — one merge pipeline, one schema across all five layers.
```typescript
// WorkspaceNavOverlay is an alias for NavConfigOverride
type WorkspaceNavOverlay = NavConfigOverride;
interface NavConfigOverride {
style?: "sidebar" | "icon-rail" | "floating" | "inset";
nodes?: NavNode[]; // Each node may set hidden: true, position, or override label/icon/config
}
```
To pin an item, add a `link` node with a `position` value. To hide a node, reference its `id` and set `hidden: true`. The workspace overlay is applied after the user's personal override so it cannot be overridden by per-user customisation.
```typescript
// Example: hide the Data group and pin a workspace-specific link
const overlay: WorkspaceNavOverlay = {
nodes: [
{ id: "data", type: "group", hidden: true },
{
id: "ws-pipeline",
type: "link",
label: "Pipeline",
icon: "bar-chart",
config: { href: "/pipeline" },
position: 0,
},
],
};
```
`AppSidebar` resolves this layer client-side so it follows the visible URL on soft navigation between workspace dashboards (both `/t/<t>/w/<a>/dashboard` and `/t/<t>/w/<b>/dashboard` rewrite to the same `/dashboard` route segment, so the layout does not re-render server-side). The shared `(app)` layout therefore calls `getResolvedNavConfig(reqs, applyWorkspaceOverlay = false)` and lets the sidebar apply the workspace overlay using `resolveWorkspaceNavConfig` (a thin wrapper around the unified `resolveNavConfig`).
### `WorkspaceTemplate`
Code-defined constructors used by the install gallery. Templates are **not** a separate primitive — they describe the values to use when inserting a `Workspace` row. Six templates ship out of the box: `default`, `marketing`, `sales`, `strategy`, `research`, `operations`. Each function template (everything except `default`) sets `navOverlay.replace: true` so installing it produces a focused mini-app rail rather than a delta on top of the tenant rail.
### `WorkspaceMembership`
Join table between users and workspaces within a tenant. Controls which workspaces a user sees in the rail and can also carry a workspace-local role. RLS and server auth always treat membership as `(workspace_id, tenant_id, user_id)` so a known workspace UUID cannot be reused across tenant boundaries.
```typescript
interface WorkspaceMembership {
workspaceId: string;
userId: string;
tenantId: string;
roleId: string | null;
createdAt: string;
}
```
### `workspaces.team.manage` permission
The RBAC gate for workspace administration. Assigned to `tenant_admin` and `system_admin` roles by default. Workspace update and member-management actions pass `{ workspaceId }` to `requirePermission()` so either a tenant-wide admin or a workspace-local admin can manage that existing workspace. Create, delete, and install-from-template stay tenant-level administrative operations because they create/destroy workspace scope rather than operating inside an existing one.
Role assignment and removal have an anti-escalation cap:
- Caller must administer the target workspace through tenant-level or workspace-level `workspaces.team.manage`.
- Target workspace must belong to the active tenant.
- Caller cannot assign, demote, or remove a peer/higher role unless operating from a higher role level.
- The final write is filtered by `(workspace_id, user_id, tenant_id)`.
## How It Works
### URL Routing
Workspace context is encoded as a path segment, not a query parameter. There are two URL shapes:
```
/t/<tenantSlug>/w/<workspaceSlug>/<rest> — explicit tenant + workspace
/w/<workspaceSlug>/<rest> — default tenant, bare path
```
Middleware (`proxy.ts`) calls `parseWorkspacePath(pathname)` and, when a workspace segment is found, sets the `x-workspace-slug` request header and rewrites the URL to strip the `/w/<slug>/` segment. The rewritten path (`/<rest>`) is what the page router and RSCs see — existing pages need no changes to support workspace context.
### Runtime read coherence
The URL pins workspace context; the runtime consumes it implicitly. The PostgREST `db-pre-request` hook (`private.set_tenant_context`) reads the `x-workspace-slug` header on every Data API request, validates membership against `workspace_memberships` (or default-workspace status), and pins the transaction-local `app.workspace_id` GUC. RLS on the 10 scope-aware tables uses the canonical equality form:
```sql
(workspace_id IS NULL OR workspace_id = get_active_workspace_id())
```
This means any reader on the auth client (`createClient()`) automatically sees the right tier — tenant defaults plus the active workspace's overrides — without an explicit filter. The hook fires only for `anon` / `authenticated` roles, so admin / impersonated callers (which use `service_role`) keep their intent-explicit filters; see `documents/work/2026-04-29-workspaces-runtime-coherence/audit-client-classes.md` for the per-reader audit.
For callers that prefer single-row resolution at the SQL boundary, `get_resolved_setting(p_key text)` returns the most-specific `tenant_settings.value` visible under the active GUCs. It mirrors `pickResolvedRow()` semantics and is supported by the composite index `idx_tenant_settings_tenant_key_workspace`. **`getSettingsForKey()` deliberately does NOT use this RPC** — it already does 4-tier resolution in TS with per-tier `unstable_cache` (1h TTL, workspace-tagged), and a DB round trip per request would regress the cache.
URL discipline:
- Client links: `<ScopedLink>` from `@/features/tenant/components/scoped-link` — calls `useScopedHref()` to preserve the active `/w/<workspaceSlug>/` prefix on all `<a>` clicks. Mandatory for all in-app navigation.
- Server `redirect()`: `getScopedHref(path)` from `@/features/tenant/lib/get-scoped-href` reads URL-pinned headers and returns the canonical scoped path.
- Workspace root (`/t/<t>/w/<w>/`): `app/page.tsx` resolves the workspace's `landingRoute` and redirects under the workspace prefix. Loop guard treats `landingRoute = "/"` as `/dashboard`.
- An ESLint rule (`no-restricted-imports`) flags raw `next/link` imports outside the public surfaces (`app/(auth)/`, `app/embed/`, `app/share/`, `app/present/`, tests) so future code stays scoped by default.
### Active Workspace Resolution
Server data access still resolves workspace context from middleware headers. `getActiveWorkspace()` in `features/workspaces/context.ts` resolves the active workspace for the current request using `React.cache()` for per-request deduplication:
1. Read the `x-workspace-slug` header (set by middleware).
2. If present, call `getWorkspaceBySlug(slug)` — returns null if the user has no visibility.
3. Fall back to `getDefaultWorkspace()` — so non-workspace pages still get default workspace context.
App-shell chrome also derives the active workspace from `usePathname()` via `resolveActiveWorkspaceForPath()`. This client-side derivation is required because the `(app)` layout persists across same-route App Router navigation: switching from `/t/acme/w/home/dashboard` to `/t/acme/w/marketing/dashboard` does not remount the server layout. The client shell uses the URL-derived workspace to update the rail selection, workspace pill, accent variables, mobile trigger, sidebar nav overlay, and last-visited beacon.
Scoped links use `buildWorkspaceScopedHref()` through the standard `useScopedHref()` hook, so clicking a regular app link from a workspace path preserves the active `/w/<workspace>` segment. Middleware also forwards the browser-visible pathname to tenant scope before rewriting; client hooks use that value for the first hydrated render, then switch to live `usePathname()` updates after mount.
### Accent CSS Variable
`<WorkspaceAccentStyle>` renders a `<style>` tag with the active workspace accent. The app shell re-renders it from the client-derived active workspace so soft navigation updates the variables without requiring a full page load:
```css
:root {
--workspace-accent: <fg value>;
--workspace-accent-bg: <bg value>;
}
```
These variables are intentionally chrome-level tokens. The active rail icon also uses the accent directly from the active workspace record for its strip and tile styling.
### Sidebar Workspace + Tenant Header
`<WorkspaceTenantHeader>` is the unified identity block at the top of the sidebar. It replaces the previous two-stack arrangement (tenant logo + tenant name on top, a separate `WorkspacePill` underneath).
**When a workspace is active:** shows the workspace name as the primary label and the tenant name as a smaller subtitle. Clicking the header opens the workspace switcher.
**When no workspace is active:** shows the tenant name and logo only — identical to the previous single-tenant header.
The component is in `features/workspaces/components/workspace-tenant-header.tsx`. The now-dead `WorkspacePill` export has been removed from `workspace-switcher.tsx`.
### Workspace Rail
`<WorkspaceRail>` renders when the user has access to 2 or more workspaces. Each workspace appears as an icon tile in a vertical strip on the left edge of the sidebar. The active workspace tile is highlighted from that workspace's accent token. Clicking a tile navigates to `workspaceUrl(tenantSlug, workspaceSlug, activeWorkspace.landingRoute)`.
### Workspace Navigation Overlay
Workspace rows can carry a small `navOverlay` object using the same `NavConfigOverride` schema as tenant- and user-level overrides — one merge pipeline, one editor surface, one storage shape across all five layers (platform → role → tenant → user → workspace).
The overlay is applied client-side by `<AppSidebar>` via `resolveWorkspaceNavConfig()` (a thin wrapper around the unified `resolveNavConfig`). It runs against the URL-derived active workspace so soft navigation between `/t/<t>/w/<a>/dashboard` and `/t/<t>/w/<b>/dashboard` (both rewrite to the same `/dashboard` route segment) updates the sidebar without a server round-trip. The shared `(app)` layout therefore calls `getResolvedNavConfig(reqs, applyWorkspaceOverlay = false)` to avoid a stale double-apply on top of the client-side layer.
Supported behaviours match `NavConfigOverride`:
- A node entry with `position` pins or reorders an item.
- A node entry with `hidden: true` removes a node (recursively) from the resolved tree.
- A node entry can override `label`, `icon`, or `config` for an existing id.
- Setting **`replace: true`** at the overlay root discards the merged tenant rail and starts fresh with this overlay's `nodes` (or empty). Subsequent layers continue to merge by id. Used by the function workspace templates (`marketing`, `sales`, `strategy`, `research`, `operations`) to ship a focused mini-app rail rather than a delta on top of the tenant rail. Default (unset / false) preserves the original merge-by-id semantics.
#### Editing the overlay
`/admin/workspaces/[id]` mounts `<WorkspaceNavOverlayEditor>` (`features/workspaces/components/admin/workspace-nav-overlay-editor.tsx`). Workspace admins can reorder, rename, hide, or add nodes; flip `replace: true` for a curated mini-app rail; and set the `pinnedRecord` (entity-type slug + URL parameter) without re-running seed scripts.
The editor reuses `<NavTreeEditor>` from `features/navigation/components/nav-tree-editor.tsx` — the same controlled visual + JSON tabbed UI that powers the tenant `/admin/navigation` page — and persists changes through the existing `PATCH /api/workspaces/:id` endpoint via `updateWorkspaceAction`. There is no separate persistence channel; the schema (`WorkspaceUpdateInputSchema.navOverlay`) is the only validator.
When the caller lacks `workspaces.team.manage`, the editor renders read-only with a banner; the page-level guard already redirects unauthenticated tenant-admins, so this is defensive only.
### Last-Visited Beacon
`<SetLastVisitedClient>` is mounted from the app shell with the client-derived active workspace id. It fires a `POST /api/workspaces/last-visited` request whenever that id changes, including soft navigation between workspace URLs. The API route calls `setLastVisitedWorkspace()`, which does a single `UPDATE user_tenants SET last_visited_workspace_id = ? WHERE tenant_id = ? AND last_visited_workspace_id IS DISTINCT FROM ?`. The `IS DISTINCT FROM` guard short-circuits the write when nothing changed.
### Query Cache Invalidation
`<WorkspaceQueryInvalidator>` is mounted from the same client-derived active workspace id. When soft navigation changes that id, it invalidates React Query caches that live above workspace chrome so dashboard/sidebar data cannot linger from the previous workspace.
### Tenant-Create Hook
When a new tenant is provisioned, the tenant-create hook calls `seedDefaultWorkspaceForTenant()` to insert a default `is_default = true` workspace named after the tenant and auto-enroll all existing `user_tenants` members. This is idempotent — re-calling it returns the existing workspace if one already exists.
### Create Blank Workspace
`/admin/workspaces/new` is a dedicated page for creating a workspace from scratch (no template). It renders `WorkspaceCreateForm` — a form with name, slug (auto-derived from name via `slugify()`, editable), description, icon, letter, accent, and landing route fields. On submit it calls `createWorkspaceAction()` and redirects to the workspace-scoped admin detail page (`/t/<tenant>/w/<workspace>/admin/workspaces/<id>`) via `useScopedHref`.
The slug `<input pattern>` attribute uses `^[a-z0-9][a-z0-9\-]*$` — the hyphen is escaped (`\-`) so Chrome's `/v` regex mode treats it as a literal rather than a range character.
### Install Gallery
The install gallery (`/admin/workspaces/gallery`) lists all `WorkspaceTemplate` entries returned by `listInstallableTemplates()`. For each template, it shows whether it is already installed in the tenant (queried via `listInstalledTemplateSlugs()`). Installing calls `installWorkspaceFromTemplate()`, which is idempotent — if a workspace with the same `source_template_slug` already exists, it returns the existing one.
## Reusable product-workspace add-ons (`addonOf`)
> Code-defined `WorkspaceTemplate` entries (`registerWorkspaceTemplate`) — not the older JSON-snapshot `workspace_templates` DB table documented in [Workspace Templates](/docs/features/workspace-templates).
A **product-workspace template** is a tenant-agnostic workspace any tenant can install from the gallery. It carries its full content as a `BundleManifest` on `__manifest` and has no `ownerTenantSlug`, so it is not scoped to a single tenant. The [Command Center](/docs/features/command-center) and the social-suite cockpit are existing examples; see also [Custom Workspaces](/docs/features/custom-workspaces) for the manifest-tier / page-tier split.
### The `addonOf` primitive
Most product templates **create a new workspace** on install. An **add-on** template instead layers additive content onto a workspace that already exists. A template becomes an add-on by declaring one field on `WorkspaceTemplate` (`features/workspaces/types.ts`):
```typescript
addonOf?: string; // slug of the HOST workspace this add-on installs into
```
When `addonOf` is set, `installWorkspaceFromTemplate()` (`features/workspaces/server/install.ts`) takes a distinct branch — `installAddonIntoExistingWorkspace()`:
1. **Resolve the host.** Look up the caller's existing workspace by `(tenant_id, slug = addonOf)`.
2. **Fail-closed preconditions.** No host workspace → a **404** "Install the `<addonOf>` workspace before adding `<add-on>`" (a client precondition, not a 500). A real DB read error → a 500 (it must not masquerade as the 404 remediation). An empty/whitespace `addonOf` → a thrown developer error before any query runs.
3. **Install the bundle into the host.** Reuse the exact `ensureTemplateBundleContent` → `installBundle()` seam the workspace-creating path uses — no parallel install system. Idempotency is delegated to `installBundle()`, which keys on `(tenant_id, workspace_id, bundle_name)`, so re-installing returns the same host workspace without duplicating rows.
An add-on **never inserts a `workspaces` row** and **never stamps `source_template_slug`** on the host — it is tracked entirely through `bundle_installations`. The workspace-creating path is byte-for-byte unchanged when `addonOf` is unset. The add-on still supports the gallery dry-run plan (it reports planned `bundle_installations` / `custom_pages` / `views` inserts against the host workspace id) and the optional `joinAsCreator` membership step.
This is the generic mechanism behind the "start blank, or add the opinionated method later" pattern: a tenant installs a foundation workspace, then optionally installs one or more add-on bundles into it on demand.
### Reference example — Venture Factory (3 layers)
The Venture Factory is the canonical reference for the `addonOf` pattern and the foundation + method + tenant-data split (ADR-three-layer-venture-factory, building on ADR-0043). It is split into three independently-installable layers, each mapped onto an existing platform primitive — no new engine:
| Layer | What it ships | Primitive | Where it lives |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------- |
| **L1 — Foundation** | Generic `venture` + `venture_metric_snapshot` types (stage/archetype are free fields); Portfolio / Pipeline / Dashboard / Venture-detail pages; flat nav. Unopinionated, ships empty, renders coherent empty states. | Product-workspace template (`registerWorkspaceTemplate` + `__manifest`) | `features/custom/workspaces/venture-factory/` |
| **L2 — Studio Method** | ~20 canvas entity types (segments, JTBD, pains, positioning, GTM, …) with Zod-derived schemas; priority-fit + 100-pt + Gate 0–6 criteria sets; Forge meta-agent + per-stage agents; dormant per-stage action templates; enriched venture-detail canvas. | Add-on template (`addonOf: "venture-factory"`), one additive `BundleManifest` | `features/custom/workspaces/venture-factory-studio-method/` |
| **L3 — Sprinter data** | 25 real ventures + canvas relations + metric snapshots + Sprinter config. Never bundled. | Guarded seed script | `scripts/seed-sprinter-venture-studio.ts` |
Both templates register in `features/custom/workspaces/templates.ts`. L1 is `registerWorkspaceTemplate({ slug: "venture-factory", __manifest: buildVentureFactoryManifest() })` — a plain product template. L2 is `registerWorkspaceTemplate({ slug: "venture-factory-studio-method", addonOf: "venture-factory", __manifest: buildVentureFactoryStudioMethodManifest() })` — the add-on. Because L2 sets `addonOf`, installing it from the gallery resolves the tenant's existing `venture-factory` workspace and installs the Studio Method bundle into it; if the tenant hasn't installed L1 yet, the install returns the 404 precondition error.
The split honors "start blank **or** batteries-included": L1-only renders a generic portfolio; L1 + L2 renders the full Sprinter Studio canvas + methodology. L1's venture-detail degrades gracefully — generic fields when L2's canvas types are absent, the full relations-first canvas when present. The L3 seed reuses the same `installBundle` path (L1 then L2) before seeding venture data, so Sprinter is just another tenant that happens to have both layers installed plus its own records.
Layer 2 also ships the orchestration substrate — `.claude/skills/venture-*/SKILL.md` (idea→exit subprocess recipes) plus per-stage cron action templates that stay **dormant** this epic (`isCronAllowed()`-gated, opt-in per tenant). Stages are a status vocabulary, gates are criteria sets, subprocesses are skills, and an autonomous push toward the next gate is a goal loop dispatched by an action — all expressed on the existing six primitives. Live firing of the Forge heartbeat and per-stage goal loops is a follow-up epic.
### Two ways to run the Venture Factory: multi-company portfolio vs. single-company playbook
The same installed surfaces serve two very different tenants, decided entirely by **which surfaces a tenant exposes** — no fork, no separate engine:
- **Multi-company portfolio (the L1 default).** A tenant running many ventures (e.g. Sprinter) surfaces the L1 `venture-factory` Portfolio / Pipeline / Dashboard rollups and treats `vf_venture` as a `cardinality:"many"` collection. This is the unopinionated template described above and is the **right default** for genuine multi-company tenants.
- **Single-company 0→1 playbook (the `praxium` reframe, ADR-0062 amendment 2026-06-19).** A tenant that is, and will only ever be, **one company** does NOT surface the portfolio rollups. Instead the studio-method + capital PM surfaces become the nav spine, and the home is a thin **single-company HQ** page. Because every row is `venture_slug`-scoped, the data model is indifferent to one-vs-many — the difference is **surface framing only**.
The single-company framing, applied to `praxium` (the DOC'S spin-out):
| Concern | Single-company playbook (`praxium`) |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The company** | Expressed as tenant/workspace context, resolved `cardinality:"one"` (`venture_slug="praxium"`). `vf_venture` stays as exactly one **hidden singleton anchor** row; portfolio-only fields (`priority_score`, `portfolio_category`, `confidence`) are dropped from the surfaces. |
| **Home** | A thin **"Praxium HQ"** custom page: a readiness-verdict sentence (`Stage · score/100 defined · next gate`), a playbook stage tracker with "Next 3 steps" CTAs, and a "Decisions you owe" strip. Replaces the retired portfolio cockpit (surface-stewardship: replace-and-remove). |
| **Nav** | Journey-ordered groups — Company → Discovery → Positioning → Product → Go-to-Market → Capital & Planning → Marketing & Content → Playbook & Decisions — every link a `/<typeSlug>` collection route. The nav literally IS the ordered 0→1 journey. |
| **Portfolio surfaces** | **Out of the playbook.** The L1 Portfolio / Pipeline / Dashboard rollups are not surfaced for this tenant. The by-stage/by-status counts and roster table are degenerate for one company and intentionally dropped. |
| **Progress truth** | The home header, the Scorecard tab, and the Playbook strip all read `computeGateProgressMatrix` on the single venture — one progress truth, they can never disagree. |
| **Seed** | The 10 venture-creation SOPs + the Praxium demo move onto the declarative `seedData.entities` install seam (fed by the pure `buildSopPlan` / `buildDemoPlan` builders), so a clone reproduces the entire playbook on install. The global `sop` system type is provisioned as a release step. |
Critically, the L1 `venture-factory` workspace is **left intact** — no portfolio code is deleted. A future genuinely-multi-company venture tenant still installs it and gets the portfolio OS. The reframe is achieved purely by **not surfacing** the L1 rollups for `praxium` and promoting the PM surfaces to the nav spine. See [ADR-0062, Amendment — Praxium is THE single company](/documents/adr/0062-venture-factory-template-tenant) and `documents/work/2026-06-19-praxium-single-company-playbook/` for the full reframe.
## API Reference
### Context helpers
| Function | Location | What it does |
| --------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------- |
| `getActiveWorkspaceSlug()` | `features/workspaces/context.ts` | Read `x-workspace-slug` header; returns `null` outside workspace-scoped pages |
| `getActiveWorkspace()` | `features/workspaces/context.ts` | Resolve full `Workspace` for the request; falls back to default workspace |
| `resolveActiveWorkspaceForPath()` | `features/workspaces/lib/url.ts` | Client-shell helper: derive active workspace from browser pathname and visible workspaces |
| `buildWorkspaceScopedHref()` | `features/workspaces/lib/url.ts` | Preserve the active `/w/<workspace>` segment for relative app links |
| `workspaceNavOverlayToOverride()` | `features/workspaces/lib/nav-overlay.ts` | Convert a workspace `navOverlay` into a navigation override |
### Server queries
All in `features/workspaces/server/queries.ts`. All are `"use server"` and use `React.cache()` for per-request deduplication.
| Function | What it returns |
| ----------------------------------- | --------------------------------------------------------------------------------------- |
| `listWorkspacesForCurrentUser()` | All workspaces visible to the caller (RLS-filtered) |
| `getWorkspaceBySlug(slug)` | Single workspace by slug; `null` if not visible |
| `getDefaultWorkspace()` | The `is_default = true` workspace for the active tenant |
| `listWorkspacesWithMemberCounts()` | Admin view — all workspaces with member counts and `isMember` flag for the current user |
| `listWorkspaceMembers(workspaceId)` | Members of a specific workspace with profile data |
| `listInstalledTemplateSlugs()` | `source_template_slug` values of all installed workspaces |
### Server actions
All in `features/workspaces/server/actions.ts`. All require the `workspaces.team.manage` permission except `setLastVisitedWorkspace`.
| Function | Permission | What it does |
| ---------------------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------- |
| `createWorkspaceAction(input)` | `workspaces.team.manage` | Create a workspace; auto-adds creator as a member |
| `updateWorkspaceAction(id, input)` | `workspaces.team.manage` | Partial update by workspace ID |
| `deleteWorkspaceAction(id)` | `workspaces.team.manage` | Delete; rejects if `is_default = true` |
| `addWorkspaceMemberAction({workspaceId, userId})` | `workspaces.team.manage` | Add a tenant member to a workspace |
| `removeWorkspaceMemberAction({workspaceId, userId})` | `workspaces.team.manage` | Remove a member |
| `assignWorkspaceRole({workspaceId, userId, roleId})` | `workspaces.team.manage` | Assign a role to a workspace member. Anti-escalation guard: caller cannot assign a role above their own level. |
| `setLastVisitedWorkspace({workspaceId})` | none | Update `user_tenants.last_visited_workspace_id` as a UX hint |
### Install actions
In `features/workspaces/server/install.ts`.
| Function | What it does |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `installWorkspaceFromTemplate(input)` | Install a code-defined template; idempotent — returns existing workspace if already installed. When the template sets `addonOf`, installs the bundle into the existing host workspace instead of creating one (see [Reusable product-workspace add-ons](#reusable-product-workspace-add-ons-addonof)) |
| `seedDefaultWorkspaceForTenant({tenantId, tenantName, createdBy})` | Called by tenant-create hook; idempotent |
| `listInstallableTemplates()` | All `WorkspaceTemplate` entries |
### URL helpers
In `features/workspaces/lib/url.ts`.
```typescript
// Build a workspace-scoped URL
workspaceUrl(tenantSlug, workspaceSlug, path): string
// → "/t/acme/w/marketing/dashboard"
// Parse workspace path segments (used by middleware)
parseWorkspacePath(pathname): { workspaceSlug: string; rest: string } | null
```
### Accent helpers
In `features/workspaces/accents.ts`.
```typescript
getWorkspaceAccent(slug): WorkspaceAccent // Returns slate if slug is null/unknown
isWorkspaceAccentSlug(slug): boolean
WORKSPACE_ACCENTS // Full palette map
WORKSPACE_ACCENT_SLUGS // Array of slug keys
```
### Template helpers
In `features/workspaces/templates.ts`.
```typescript
findWorkspaceTemplate(slug): WorkspaceTemplate | undefined
listFeaturedTemplates(): WorkspaceTemplate[] // Templates with featured !== false
```
### API routes
| Method | Route | What it does |
| -------- | -------------------------------------------- | -------------------------------------------------------------------------- |
| `GET` | `/api/workspaces` | List workspaces for the current user |
| `POST` | `/api/workspaces` | Create a workspace |
| `PATCH` | `/api/workspaces/[id]` | Update a workspace |
| `DELETE` | `/api/workspaces/[id]` | Delete a workspace |
| `GET` | `/api/workspaces/[id]/members` | List members |
| `POST` | `/api/workspaces/[id]/members` | Add a member |
| `DELETE` | `/api/workspaces/[id]/members/[userId]` | Remove a member |
| `POST` | `/api/workspaces/[id]/members/[userId]/role` | Assign a role to a member (anti-escalation: caller cannot grant above own) |
| `POST` | `/api/workspaces/last-visited` | Record last-visited workspace (UX hint) |
### Zod schemas
| Export | Use |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `WorkspaceCreateInputSchema` | Validate workspace creation payload |
| `WorkspaceUpdateInputSchema` | Partial variant for updates; omitted keys mean "leave unchanged" (no `.default()` resets) |
| `WorkspaceNavOverlaySchema` | Alias for `NavConfigOverrideSchema` — validates the `nav_overlay` JSONB column |
### Layered Agent Context
`loadAgentContextLayers({ tenantId, workspaceId?, userId? })` from `features/agents/agent-context.ts` returns each scope's `agent_context` JSONB setting as a separate value (not merged). The shape per tier is `{ markdown: string, references: { entityId, label? }[] }`:
1. **Platform** — default-tenant row (reserved; no callers populate it today)
2. **Tenant** — tenant-scoped row with `workspace_id IS NULL`
3. **Workspace** — `workspace_id = activeWorkspaceId`
4. **User** — `user_id = userId` (optionally scoped on top of workspace)
The companion `buildAgentContextPrompt(layers, { workspaceName?, tenantIdForReferences? })` renders all layers nested into the system prompt (`## Tenant Business Context` → `### Workspace: <name>` → `#### Personal Notes`) — workspace OVERLAYS tenant rather than replacing it. References are deduplicated by `entityId`, capped at 10 rendered with 600-char excerpts each, and emitted in fixed tenant→workspace→user order to keep the Anthropic prompt-cache prefix stable.
The loader is wired into `loadAgentPromptContext()` (the single entry point used by chat, heartbeat, and extraction prompt builders) and into `extractSessionSnapshot()` so eval replays freeze the layered prompt at promotion time.
### Workspace Edit Page Shell
`/admin/workspaces/[id]` is intentionally minimal: identity fields + members drawer + `<WorkspaceConfigureCards />` deep-link grid + publish stub + danger zone. It does **not** inline config editors for scope-aware surfaces (agents, webhooks, views, etc.) — those live at the workspace-scoped admin URLs (`/t/<slug>/w/<ws>/admin/<surface>`) linked from the cards. This enforces the single-editor rule: config always edits at its target scope.
### Delete Confirmation Cascade Counts
The workspace delete confirmation modal queries each scope-aware table for rows that will cascade-delete when the workspace is removed. It also explicitly clarifies which tables use `ON DELETE SET NULL` (chats, criteria_sets) so users know those items are preserved but detached, not destroyed.
### `custom_pages` Table (Stub)
`custom_pages` is the tenth scope-aware table. It holds workspace-aware static pages. A tenant-default and a workspace-scoped row can share the same `slug` — the route stub prefers the workspace-scoped row inside a workspace URL (per ADR-0013 D7). The `runtime` column is `'static'` for all v1 rows; the sandbox renderer is a deferred follow-up.
Schema shape:
```typescript
interface CustomPage {
id: string;
tenantId: string;
workspaceId: string | null; // null = tenant default
slug: string;
title: string;
source: Record<string, unknown>; // page content — format is runtime-dependent
runtime: "static"; // v1 only; future: "sandbox" | "mdx"
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
```
## For Agents
Workspaces are not yet an AI tool surface. No agent tools exist for workspace CRUD in this release.
Agents that need workspace context can call `getActiveWorkspace()` server-side to read the current workspace's `defaultAgentSlug`, `featuredEntityTypeSlugs`, and `navOverlay`. The `navOverlay` is now a `NavConfigOverride` and is actively applied as the final layer in the sidebar resolution pipeline — changes to it render immediately.
`loadAgentContextLayers()` is called automatically in supervised (chat), heartbeat, and inbox execution flows via `loadAgentPromptContext()` — agents receive workspace-scoped business context without requiring any extra tool calls.
## Curated workspace nav (`nav_overlay`)
Workspace-scoped nav lives on `workspaces.nav_overlay` JSONB — _not_ in a separate `nav_configs` row. The overlay is the final layer in the sidebar resolution pipeline; any view, divider, or pinned-record affordance you put on it appears in the rail without code changes.
The overlay is a `NavConfigOverride` with an optional `pinnedRecord` extension:
```ts
type NavConfigOverride = {
primary?: NavItem[]; // friendly view list shown first in the rail
pinnedRecord?: {
entityTypeSlug: string; // e.g. "patient"
urlParam: string; // e.g. "patient" → ?patient=<id>
};
};
```
Seed a curated nav by writing the overlay directly:
```ts
await supabase
.from("workspaces")
.update({
nav_overlay: {
primary: [
{
kind: "view",
view_slug: "todays-visits",
label: "Today's Visits",
icon: "calendar-days",
},
{
kind: "view",
view_slug: "ops-schedule",
label: "Schedule",
icon: "calendar",
},
// ...
],
pinnedRecord: { entityTypeSlug: "patient", urlParam: "patient" },
},
})
.eq("id", workspaceId);
```
This is the first-class affordance for non-technical users — the rail reads like a clinic dashboard, not a database admin. Entity type slugs never appear in nav directly.
## Pinned record context
When a workspace declares a `pinnedRecord` in its overlay, the workspace shell mounts a `<PinnedRecordPicker>` and every list block in that workspace filters to the selected record automatically. The picker is generic over `entityTypeSlug` — workspaces can pin any record type (patient, client, engagement, …) and the labels follow via `humanize(entityTypeSlug)`.
**The flow:**
1. User opens `/t/<tenant>/w/<workspace>/...`. Server reads `currentWorkspace.navOverlay.pinnedRecord` (which survives the row-mapper read because it's parsed with `WorkspaceNavOverlaySchema`, not the base `NavConfigOverrideSchema`).
2. The sidebar header mounts `<PinnedRecordPicker>` whenever `pinnedRecord.entityTypeSlug` is non-null. No hardcoded slug check — the picker renders for any pinned record type the workspace declares.
3. User picks a record → URL updates to `?<urlParam>=<id>` via `router.replace` (preserves back-button history; the param name comes from `pinnedRecord.urlParam`, not a hardcoded constant).
4. `useFilteredEntities()` reads the URL param via `usePinnedRecord()` and:
- UUID-validates the param value (defense against crafted strings).
- Filters `id = ?param` when rendering the pinned entity type itself.
- Filters `<entityTypeSlug>_id = ?param` when rendering related entity types — but **only** when the rendered type's `json_schema.properties` actually declares that FK column. Without this guard, pinning a patient in a workspace that also shows `protocol` or `therapy-plan` would silently zero out those views.
- Hyphens in the entity-type slug are normalized to underscores in the FK column name (`therapy-plan` → `therapy_plan_id`) so hyphenated slugs can't produce invalid SQL identifiers.
- Includes the URL value in the React Query cache key so switching `?patient=A → ?patient=B` invalidates the cache cleanly.
5. Stale URL IDs (record was deleted or isn't visible to the user) trigger a one-shot toast and clear the param — the picker never silently shows "Select X" with a dead reference in the URL.
6. Pinned-record detail layouts can use `key={pinnedRecordId}` so form state remounts on switch — no stale-cache leaks across record context.
**Server-to-client plumbing:**
```tsx
// app/(app)/t/[tenant]/w/[workspace]/layout.tsx (or sidebar mount point)
const currentWorkspace = await getActiveWorkspace();
return (
<PinnedRecordProvider
pinnedRecord={currentWorkspace?.navOverlay?.pinnedRecord ?? null}
>
{children}
</PinnedRecordProvider>
);
```
```tsx
// any block consuming useFilteredEntities() automatically participates
const { data } = useFilteredEntities({ entityTypeSlug: "visit" });
// → filters to visits where patient_id = ?patient
```
The picker is the only new component on the platform side — list blocks need zero changes.
The hooks (`usePinnedRecord`, `usePinnedRecordId`, `useSetPinnedRecord`) live in `features/workspaces/lib/pinned-record-context.ts`; the picker UI in `features/workspaces/components/pinned-record-picker.tsx`.
## Design Decisions
### URL path segment, not query parameter
Workspace slug is encoded as `/w/<slug>/` in the URL path rather than `?workspace=<slug>`. This keeps deep-links bookmarkable, prevents the workspace from being silently lost on navigation, and lets middleware resolve the workspace before the React tree renders — the same pattern used for tenant slugs. The rewrite strips `/w/<slug>/` so existing page routes require no changes.
### Membership = visibility, not data access
Adding a user to a workspace makes that workspace appear in their rail and switcher. It does not narrow what data they can read or write — RLS still enforces tenant-level isolation. This keeps the permission model simple: one `workspaces.team.manage` gate for workspace administration, and the existing 63-permission RBAC for data operations. Workspace-scoped data filters are a follow-up concern (`boundEntityId` / `boundEntityFilter` fields are persisted for future use but not yet applied by queries).
### Scope-aware admin overrides (ADR-0013)
All ten scope-aware tables (`agents`, `agent_connections`, `webhook_endpoints`, `inbound_webhook_endpoints`, `external_data_sources`, `views`, `criteria_sets`, `chats`, `tenant_settings`, `custom_pages`) carry a nullable `workspace_id` column and resolve via the canonical 4-tier resolver: **`user > workspace > tenant > platform`**. Most-specific tier wins for single-row resolution; list pages render every visible tier with an "Inherited from `{tier}`" chip via `<InheritanceBadge />`. A workspace-scoped admin URL (`/t/<t>/w/<ws>/admin/<surface>`) reuses the same admin pages as the tenant URL (`/t/<t>/admin/<surface>`) — the page reads `?scope=` to filter the list and shows an `<OverrideInWorkspaceButton />` on tenant rows. Override clones regenerate secrets per-resource (`agent_connections.encrypted_credentials` is force-NULLed; `webhook_endpoints.secret`, `inbound_webhook_endpoints.token + signature_secret`, `external_data_sources.token + signature_secret` are auto-regenerated) so a workspace admin never inherits a tenant credential. All ten surfaces ship in PR #997 (consolidated): surfaces 1–4 (agents, agent_connections, webhook_endpoints, external_data_sources) in PR 3a; surfaces 5–7 (views, criteria_sets, tenant_settings) in PR 3b; surface 8 (custom_pages stub) in PR 4.
Override handlers for `tenant_settings` apply a per-key allowlist. In v1, only `agent_context` is allowlisted — any attempt to override an unknown key is rejected by default. This guards against misconfigured overrides silently clobbering tenant defaults.
The **inheritance staleness banner** is integrated into `/admin/agents/[id]`: when a tenant admin is editing a tenant-scoped agent that already has workspace overrides, the banner surfaces the count and links to the audit sub-page at `/t/<slug>/admin/agents/workspace-scoped`.
### Code-defined templates, not DB rows
Workspace templates are TypeScript constants in `features/workspaces/templates.ts`. The install action is the only write path. This avoids a separate `workspace_templates` table, a migration every time a template changes, and the complexity of syncing template updates to existing installations. Templates stay in code where they are easy to version, review, and extend. The `source_template_slug` column on `workspaces` tracks which template was used, enabling gallery "Installed" status.
### `WorkspaceNavOverlay` is an alias for `NavConfigOverride`, not its own schema
The original `WorkspaceNavOverlay` type had a bespoke `showGroups / hideItems / pinnedItems` shape. That shape was never consumed by `getResolvedNavConfig()` — the overlay was stored but never rendered. Unifying onto `NavConfigOverride` (the same shape used by tenant and user overrides in `nav_configs`) means: one merge pipeline, one Zod schema, one editor surface. The `workspaces.nav_overlay` JSONB column is now safe-parsed by `row-mapper.ts` using `NavConfigOverrideSchema`; invalid or null values reset to `{}`. The old bespoke shape is fully superseded — no DB migration was required because the column was effectively unused.
### Accent CSS variable scoped to subtree
The `--ws-accent-fg` / `--ws-accent-bg` CSS variables are injected by a server component rather than a global `document.body` class or a JS context value. Scoping to the subtree means the variable resolves correctly when switching workspaces in a new tab without requiring a client-side update, and eliminates the flash-of-wrong-color on page load. Only four design tokens consume these variables — the surface area is intentionally small.
### `seedDefaultWorkspaceForTenant` bypasses `workspaces.team.manage`
The seed function runs during tenant provisioning, before the creating user's role grants exist. It uses the admin client directly and is called only from the tenant-create hook — not exposed as a user action. The idempotency guard (`WHERE is_default = true`) makes it safe to call multiple times.
### `custom_pages.runtime` is a text column, not an enum
Future runtimes (`sandbox`, `mdx`, `react`) can be added without a migration. The `static` value is the only one the renderer handles in v1. A DB `CHECK` constraint is intentionally deferred so development iteration on runtimes does not require migration churn; a constraint can be added once the set stabilizes.
### `tenant_settings` override allowlist defaults to reject
The override handler for `tenant_settings` requires each overriddable key to be explicitly allowlisted. In v1, only `agent_context` is allowlisted. Unknown keys return a 400 error rather than silently creating an override that might shadow an unrelated tenant setting. This is conservative by design; new keys are added to the allowlist per-PR as workspace-scoped use cases are validated.
### `assignWorkspaceRole` anti-escalation is caller-bound, not role-bound
The anti-escalation check compares the caller's highest-privilege membership role (across tenant and all workspace memberships) with the target role. A user with `editor` at the tenant level cannot grant `tenant_admin` to a workspace member, even if the workspace-scoped page technically allows them to manage that workspace. This prevents privilege escalation via workspace membership promotion.
### Per-tier rendering for `agent_context` (no key-level merge)
The legacy `getResolvedAgentContext` resolver merged structured object keys across tiers and replaced array fields wholesale. As of the markdown + references redesign (2026-05-14, see [Admin Context Page](/docs/features/agent-system#workspace-business-profile)), `agent_context` no longer carries discrete keys — each tier holds a free-form markdown blob plus an entity-references array. `loadAgentContextLayers` returns each tier as its own value; `buildAgentContextPrompt` renders them nested in the prompt so a workspace override SUPPLEMENTS the tenant baseline rather than collapsing into a single merged value. References from all tiers are concatenated and deduplicated by `entityId` before resolution, so workspace and user tiers extend (never replace) the tenant reference set.
### Two-column ownership on `agents` and `views` (ADR-0013 D14)
`agents` and `views` carry two distinct FK columns that must not be conflated:
- **`installed_by_workspace_id`** — bundle provenance. Set only by `install_bundle()`, never edited at runtime. Used by `uninstall_bundle()` to identify which rows belong to a bundle and should be removed. Immutable after install.
- **`workspace_id`** — runtime config scope. Set by the scoped-tenants model. Edited via "Override in this workspace". Used by the resolver and admin list UI. Nullable = tenant-wide.
A row can have one, both, or neither set independently. The override clone handler sets `workspace_id` on the clone but explicitly clears all three bundle provenance columns (`installed_by_workspace_id`, `installed_by_bundle`, `bundle_version`). Clearing only `installed_by_workspace_id` would leave the clone matched by `uninstall_bundle()`'s `installed_by_bundle` sweep predicate, silently destroying the workspace-specific override when the source bundle is later uninstalled. Both columns carry `COMMENT ON COLUMN` annotations in the migration explaining the ownership contract.
## Related Modules
- [Multi-Tenant](/docs/features/multi-tenant) — tenant resolution, URL-as-truth, `getTenantContext()`
- [Navigation](/docs/features/navigation) — sidebar config that workspace nav overlay is merged into
- [Auth & Permissions](/docs/features/auth-permissions) — `workspaces.team.manage` permission, `requirePermission()`
- [Admin](/docs/features/admin) — admin pages at `/admin/workspaces`