.agents/skills/workspace-api/SKILL.md
Workspace API patterns for defineTable, defineKv, versioning, migrations, data access (CRUD + observation), withActions, and extension ordering. Use when the user mentions workspace, defineTable, defineKv, createWorkspace, withActions, withExtension, defineQuery, defineMutation, connectWorkspace, or when defining schemas, reading/writing table data, observing changes, writing migrations, chaining extensions, or attaching actions to a workspace client.
npx skillsauth add epicenterhq/epicenter workspace-apiInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Type-safe schema definitions for tables and KV stores.
Related Skills: See
yjsfor Yjs CRDT patterns and shared types. Seesveltefor reactive wrappers (fromTable,fromKv).
defineTable() or defineKv().withActions().withExtension() or .withWorkspaceExtension()connectWorkspace()Use when a table has only one version:
import { defineTable } from '@epicenter/workspace';
import { type } from 'arktype';
const usersTable = defineTable(type({ id: UserId, email: 'string', _v: '1' }));
export type User = InferTableRow<typeof usersTable>;
Every table schema must include _v with a number literal. The type system enforces this — passing a schema without _v to defineTable() is a compile error.
Use when you need to evolve a schema over time:
const posts = defineTable(
type({ id: 'string', title: 'string', _v: '1' }),
type({ id: 'string', title: 'string', views: 'number', _v: '2' }),
).migrate((row) => {
switch (row._v) {
case 1:
return { ...row, views: 0, _v: 2 };
case 2:
return row;
}
});
KV stores use defineKv(schema, defaultValue). No versioning, no migration—invalid stored data falls back to the default.
import { defineKv } from '@epicenter/workspace';
import { type } from 'arktype';
const sidebar = defineKv(type({ collapsed: 'boolean', width: 'number' }), { collapsed: false, width: 300 });
const fontSize = defineKv(type('number'), 14);
const enabled = defineKv(type('boolean'), true);
Use dot-namespaced keys for logical groupings of scalar values:
// ✅ Correct — each preference is an independent scalar
'theme.mode': defineKv(type("'light' | 'dark' | 'system'"), 'light'),
'theme.fontSize': defineKv(type('number'), 14),
// ❌ Wrong — structured object invites migration needs
'theme': defineKv(type({ mode: "'light' | 'dark'", fontSize: 'number' }), { mode: 'light', fontSize: 14 }),
With scalar values, schema changes either don't break validation (widening 'light' | 'dark' to 'light' | 'dark' | 'system' still validates old data) or the default fallback is acceptable (resetting a toggle takes one click).
Exception: discriminated unions and Record<string, T> | null are acceptable when they represent a single atomic value.
Every table's id field and every string foreign key field MUST use a branded type instead of plain 'string'. This prevents accidental mixing of IDs from different tables at compile time.
Define a branded type + arktype validator + generator in the same file as the workspace definition:
import type { Brand } from 'wellcrafted/brand';
import { type } from 'arktype';
import { generateId, type Id } from '@epicenter/workspace';
// 1. Branded type + arktype validator (co-located with workspace definition)
export type ConversationId = Id & Brand<'ConversationId'>;
export const ConversationId = type('string').as<ConversationId>();
// 2. Generator function — the ONLY place with the cast
export const generateConversationId = (): ConversationId =>
generateId() as ConversationId;
// 3. Use in defineTable + co-locate type export
const conversationsTable = defineTable(
type({
id: ConversationId, // Primary key — branded
title: 'string',
'parentId?': ConversationId.or('undefined'), // Self-referencing FK
_v: '1',
}),
);
export type Conversation = InferTableRow<typeof conversationsTable>;
// 4. At call sites — use the generator, never cast directly
const newId = generateConversationId(); // Good
// const newId = generateId() as string as ConversationId; // Bad
.withActions())Actions wrap workspace operations as defineMutation (writes) or defineQuery (reads). Attach them via .withActions() on a workspace builder—the call is non-terminal, so you can chain .withExtension() after it.
import { createWorkspace, defineMutation, defineQuery, defineWorkspace } from '@epicenter/workspace';
export function createBlogWorkspace() {
return createWorkspace(blogDefinition).withActions(({ tables }) => ({
/**
* Mark a post as published and record the publication timestamp.
*
* Separated from a raw `tables.posts.update()` call because publish
* involves setting multiple fields atomically and may trigger side
* effects (notifications, RSS rebuild) in future versions.
*/
publish: defineMutation({
description: 'Publish a draft post',
input: type({ id: PostId }),
handler: ({ id }) => {
tables.posts.update({ id, published: true, publishedAt: Date.now() });
},
}),
}));
}
Every action method inside .withActions() should have a JSDoc comment. The JSDoc and the description field serve different audiences:
description — consumed by MCP servers, CLI help text, and OpenAPI specs. Keep it short and declarative ("Import skills from disk").// ❌ Parrots the description
/** Import skills from an agentskills.io-compliant directory. */
importFromDisk: defineMutation({ description: 'Import skills from an agentskills.io-compliant directory', ... })
// ✅ Adds distinct value
/**
* Scan a directory of SKILL.md files and upsert them into the workspace.
*
* Skills without a `metadata.id` in their frontmatter get one generated
* and written back to the file, so future imports produce stable IDs
* across machines.
*/
importFromDisk: defineMutation({ description: 'Import skills from an agentskills.io-compliant directory', ... })
Each app splits workspace code into an isomorphic workspace/ folder and a runtime-specific client.ts:
src/lib/
│
├── workspace/ ← 100% isomorphic (safe for Node, Bun, browser)
│ ├── definition.ts ← Schema: defineWorkspace, defineTable, branded IDs
│ ├── workspace.ts ← Factory: createWorkspace(definition) + isomorphic actions
│ └── index.ts ← Barrel: re-exports definition + workspace only
│
└── client.ts ← Runtime singleton: extensions, encryption, sync,
runtime-specific actions (browser APIs, Node fs, etc.)
┌─────────────────────────┐
│ definition.ts │
│ tables, KV, branded IDs │
└────────────┬────────────┘
│ imports
┌────────────▼────────────┐
│ workspace.ts │
│ createX() factory │
│ + isomorphic actions │
└────────────┬────────────┘
│ imports
┌─────────────────────────────┼─────────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ client.ts │ │ server-client.ts │ │ cli-client.ts │
│ (browser) │ │ (Node/Bun) │ │ (CLI) │
│ IndexedDB │ │ SQLite │ │ filesystem │
│ WebSocket │ │ TCP sync │ │ persistence │
│ Chrome APIs │ │ Node fs APIs │ │ │
└──────────────┘ └──────────────────┘ └──────────────────┘
definition.ts — Pure schema. defineWorkspace(), defineTable(), defineKv(), branded ID types and generators. Isomorphic.workspace.ts — Factory function that calls createWorkspace(definition). May chain .withActions() for isomorphic actions (table reads/writes only). Isomorphic.index.ts — Barrel that re-exports from definition.ts and workspace.ts only. Never re-exports from client.ts. This is the import path for $lib/workspace and the package.json subpath export.client.ts — Lives outside the workspace/ folder at src/lib/client.ts. Calls the factory, chains .withEncryption(), .withExtension(), and runtime-specific .withActions(). Exports the singleton as a named export (export const workspace = ...).// Components/state that need the live workspace instance:
import { workspace, auth } from '$lib/client';
// Components that only need types or the definition:
import { type Note, NoteId } from '$lib/workspace';
// Other packages in the monorepo:
import { createHoneycrisp } from '@epicenter/honeycrisp/workspace';
import { honeycrisp } from '@epicenter/honeycrisp/definition';
Each app exports a single ./workspace subpath pointing to the barrel:
{
"exports": {
"./workspace": "./src/lib/workspace/index.ts"
}
}
The barrel is 100% isomorphic, so this single subpath is safe for any consumer (server, CLI, other apps). The separate ./definition subpath is no longer needed since the barrel already re-exports everything from definition.ts.
Isomorphic actions (table reads/writes, portable logic) belong in the exported workspace.ts factory. Runtime-specific actions—whether browser APIs, Chrome extension APIs, Node/Bun filesystem calls, or Tauri commands—are chained via .withActions() in the client file closest to that runtime.
// workspace.ts — isomorphic actions (exported via barrel)
export function createMyApp() {
return createWorkspace(definition).withActions(({ tables }) => ({
devices: {
list: defineQuery({
title: 'List Devices',
description: 'List all synced devices.',
input: Type.Object({}),
handler: () => ({ devices: tables.devices.getAllValid() }),
}),
},
}));
}
// src/lib/client.ts — browser-specific actions chained at the runtime boundary
export const workspace = createMyApp()
.withExtension('persistence', indexeddbPersistence)
.withExtension('sync', createSyncExtension({ ... }))
.withActions(({ tables }) => ({
tabs: {
close: defineMutation({
title: 'Close Tabs',
description: 'Close browser tabs by ID.',
input: Type.Object({ tabIds: Type.Array(Type.Number()) }),
handler: async ({ tabIds }) => {
await browser.tabs.remove(tabIds); // Chrome API
return { closedCount: tabIds.length };
},
}),
},
}));
// OR: src/lib/server-client.ts — Node/Bun-specific at the server boundary
export const workspace = createMyApp()
.withExtension('persistence', sqlitePersistence)
.withActions(({ tables }) => ({
files: {
importFromDisk: defineMutation({
title: 'Import Files',
description: 'Import files from a local directory.',
input: Type.Object({ dirPath: Type.String() }),
handler: async ({ dirPath }) => {
const entries = await readdir(dirPath); // Node fs API
// ...
},
}),
},
}));
Extensions initialize in registration order. Each extension's factory receives a whenReady promise that resolves when all previously registered extensions have finished initializing. Whether this creates a waterfall depends on whether each extension awaits it:
| Extension | Awaits prior whenReady? | Behavior |
|---|---|---|
| filesystemPersistence | No | Starts loading SQLite immediately |
| indexeddbPersistence | No | Starts loading IndexedDB immediately |
| createCliUnlock | Yes | Waits for persistence, then applies encryption keys |
| createSyncExtension | Yes | Waits for everything before it, then opens WebSocket |
| createMarkdownMaterializer | Yes | Waits for persistence + sync, then materializes |
The standard chain is persistence → unlock → sync:
persistence starts loading ────────────────────→ done
↓
unlock waits... ──────────→ applies keys → done
↓
sync waits... ─────────────────────────────→ connects
This ordering matters because sync only exchanges the delta between local state and the server. Without persistence loading first, every cold start downloads the full document.
// ✅ Correct — persistence loads first, sync exchanges delta only
createWorkspace(definition)
.withExtension('persistence', filesystemPersistence({ filePath: '...' }))
.withWorkspaceExtension('unlock', createCliUnlock(sessions, SERVER_URL))
.withExtension('sync', createSyncExtension({ url: ..., getToken: ... }))
// ❌ Wrong — sync starts before local state is loaded, downloads full document
createWorkspace(definition)
.withExtension('sync', createSyncExtension({ url: ..., getToken: ... }))
.withExtension('persistence', filesystemPersistence({ filePath: '...' }))
connectWorkspace (CLI/Script Shortcut)For server-side Bun scripts, connectWorkspace from @epicenter/cli handles the entire persistence → unlock → sync chain automatically:
import { connectWorkspace } from '@epicenter/cli';
import { createFujiWorkspace } from '@epicenter/fuji/workspace';
const workspace = await connectWorkspace(createFujiWorkspace);
// Ready. Authenticated. Syncing. Persistence loaded.
const entries = workspace.tables.entries.getAllValid();
await workspace.dispose();
Use connectWorkspace for one-off scripts and agent-written automation. Use epicenter.config.ts for long-running daemons and materializers that need custom workspace-specific extensions.
_v Convention_v is a number discriminant field ('1' in arktype = the literal number 1)CombinedStandardSchema<{ id: string; _v: number }>defineKv(schema, defaultValue) is the only pattern_v: '1', _v: '2', _v: '3' (number literals)_v: 2 (TypeScript narrows automatically, as const is unnecessary)_v goes last in the object ({ id, ...fields, _v: '1' })Load these on demand based on what you're working on:
as const note), read references/table-migrations.mdget, set, update, observe, Svelte observer guidance), read references/table-kv-crud-observation.mdwithDocument, handle.read/write, mode bindings, handle.batch, handle.ydoc anti-pattern), read references/document-content.mdCode references:
packages/workspace/src/workspace/define-table.tspackages/workspace/src/workspace/define-kv.tspackages/workspace/src/workspace/index.tspackages/workspace/src/workspace/create-tables.tspackages/workspace/src/workspace/create-kv.tspackages/workspace/src/workspace/create-workspace.tsdocumentation
Yjs CRDT patterns, shared types (Y.Map, Y.Array, Y.Text), conflict resolution, and document storage. Use when the user mentions Yjs, Y.Doc, CRDTs, collaborative editing, or when handling shared types, implementing real-time sync, or optimizing document storage.
tools
Voice and tone rules for all written content—prose, UI text, tooltips, error messages. Use when the user says "fix the tone", "rewrite this", "sounds like AI", "sounds corporate", or when writing any user-facing text, landing pages, product copy, or open-source documentation.
documentation
Standard workflow for implementing features with specs and planning documents. Use when the user says "start a new feature", "how should I plan this", "what's the process", or when starting implementation, planning work, or working on any non-trivial task.
development
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".