name: create-app description: Use when building a new built-in Domus app. Guides the full process from requirements through types, tests, components, and registry registration. Invoke with /create-app [app-name]. argument-hint: [app-name] disable-model-invocation: true
Create a new built-in Domus app from scratch — types, tests, components, registry — following the exact contract and patterns used by existing apps.
This skill is self-contained. Do NOT read additional files unless something seems wrong. Everything you need is inlined below.
1. Input Parsing
Parse $ARGUMENTS into:
- APP_NAME — PascalCase (e.g.,
Timer,Pomodoro,Kanban) - APP_TYPE — kebab-case (e.g.,
timer,pomodoro,kanban) - Description — a one-sentence summary of what the app does
If $ARGUMENTS is empty or unclear, ask the user for a name and one-sentence description before proceeding.
2. Requirements Discovery
Before writing any code, ask these questions one at a time. Wait for each answer.
- Main interactions — "What can the user do in this app?" (click, type, toggle, drag, etc.)
- Persisted state — "What should the app remember between sessions?" → derives the
stateshape - Child entities — "Does this app create/manage its own data items?" (like calendar events, kanban cards) → determines hidden entity pattern
- Card mode — "What should the compact card preview show?" → drives
summarizeand card component - Header actions — "Does the app need mode switching or controls in the title bar?" → determines
windowActions
After all questions, present a requirements summary:
## Requirements Summary
Name: [APP_NAME]
Type: [APP_TYPE]
State: { field1: type, field2: type, ... }
Actions: [list of reducer actions with params]
Child entities: [yes/no — if yes, child type name + state shape]
Card preview: [what the card shows]
Window actions: [yes/no — what they do]
Singleton: [yes/no]
Wait for user confirmation before proceeding.
3. The BuiltInApp Contract
This is the exact interface from apps/_types.ts. Your app's exported constant must satisfy it.
// apps/_types.ts
import type { ComponentType } from 'react'
import type { EntitySize, Presentation } from '@/lib/types'
export interface ToolSchema {
name: string
description: string
inputSchema: Record<string, unknown>
}
export interface BuiltInApp {
source: 'built-in'
type: string // unique identifier, matches entity.type
name: string // human-readable display name
icon: ComponentType<{ className?: string }> // icon component (lucide-react)
component: ComponentType<AppProps> // the React UI
windowActions?: ComponentType<{ entityId: string }> // optional title-bar controls
defaultPresentation: Presentation // 'window' | 'card' | 'folder' | 'hidden'
defaultSize: EntitySize // { width: number; height: number }
maxInstances?: number // 1 = singleton, undefined = unlimited
// Frontend-only: (current state, action name, params) => new state
// User interactions only. The agent NEVER calls reduce — it writes state directly.
reduce: (
state: Record<string, unknown>,
action: string,
params: unknown,
) => Record<string, unknown>
// Frontend-only: generates a one-line summary when the user changes state.
// The agent writes its own summaries. Both write to entity.summary.
summarize: (state: Record<string, unknown>) => string
// Optional: exposes tools the agent can call via POST /api/entities/{id}/call.
// Return value varies with state (e.g. omit 'remove_child' when folder is empty).
// If present, the app is self-describing — the agent discovers capabilities at runtime.
getSchema?: (state: Record<string, unknown>) => ToolSchema[]
}
export interface AppProps<TState = Record<string, unknown>> {
entityId: string
state: TState
dispatch: (action: string, params: unknown) => void
mode?: 'window' | 'card' | 'sheet'
}
Field-by-field:
| Field | Notes |
|---|---|
source |
Always 'built-in' |
type |
Kebab-case, matches entity.type in the DB |
name |
Human-readable, shown in App Dock |
icon |
A Lucide React icon component (e.g., Clock, Timer, Kanban) |
component |
The root React component. Receives AppProps. |
windowActions |
Optional component rendered in the window title bar (mode switchers, transport controls) |
defaultPresentation |
Usually 'window'. Use 'card' for image-heavy apps |
defaultSize |
{ width, height } in pixels. Start with { width: 480, height: 520 } and adjust |
maxInstances |
Omit for unlimited. Set to 1 for singleton apps (chat, calendar) |
reduce |
Pure function. Must handle empty {} state (apply defaults first). Unknown actions return state by reference |
summarize |
One-liner for cards + agent context. Must handle empty {} state without crashing |
getSchema |
Optional. Implement if the agent needs to call tools on this entity. Schema can vary with state |
Entity interface (from lib/types.ts):
export type Presentation = 'window' | 'card' | 'folder' | 'hidden'
export interface Entity {
id: string
space_id: string
user_id: string
type: string
presentation: Presentation
position: { x: number; y: number; locked: boolean }
size: { width: number; height: number }
z_index: number
content: string
state: Record<string, unknown>
summary: string
created_by: 'user' | 'agent'
archived: boolean
created_at: string
updated_at: string
}
Entity store API (from core/entityStore.ts):
// Reading entity state in a component:
const state = useEntityStore((s) => s.entities[entityId]?.state as MyState)
// Store methods (used in hooks, not directly in components):
useEntityStore.getState().upsert(entity) // create or update
useEntityStore.getState().getEntity(id) // read by ID
useEntityStore.getState().updateState(id, patch) // partial state update
ID generation (from lib/id.ts):
import { ulid } from '@/lib/id'
const newId = ulid() // returns a ULID string
4. Architecture Rules
These are non-negotiable. Violating any one will cause review failure.
Self-contained folders. Your app lives entirely in
apps/<app-type>/. Zero imports from otherapps/folders. Import from@/core/,@/lib/, and external packages only.Entity state = persisted, useState = ephemeral. Anything the user should see after a page refresh goes in entity
state(viadispatch). UI-only concerns (which dropdown is open, hover state, local input buffer) use ReactuseState.Reducer handles empty
{}state. When the entity is first created,stateis{}. Your reducer must apply defaults before processing any action:reduce(state, action, params) { const s = { ...defaults, ...state } // now process action on `s` }Unknown actions return
stateby reference. If the action name doesn't match any case, return the originalstateobject — not a copy, not{ ...state }. This prevents unnecessary re-renders.Hidden entities for child data. If your app manages sub-items (like calendar events), store them as separate entities with
presentation: 'hidden'and the parent'sspace_id+user_id. Query them with a custom hook (seeuseCalendarEventspattern).Entity IDs use
crypto.randomUUID(). Theentities.idcolumn isuuidin Postgres —ulid()strings silently fail on upsert. Usecrypto.randomUUID()for any entity ID that touches Supabase. Reserveulid()(from@/lib/id) only for local-only identifiers that never hit the DB (e.g., chat context items, ephemeral UI state).No toasts, no modals — except destructive action confirmations (using
Dialogfrom@/core/ui/dialog). Errors go inline. Success is visual (the entity updates).data-testidon interactive elements. Every button, input, and meaningful container gets adata-testidfor testing.'use client'directive. Every.tsxfile that uses hooks, event handlers, or browser APIs needs'use client'at the top. Theindex.tsdefinition file does NOT (it's imported at build time).Entities need
space_idanduser_id. When creating child entities, inherit these from the parent entity. Never hardcode them.
5. Design Rules
Color Tokens (Tailwind classes — never use raw hex/rgb)
| Role | Class |
|---|---|
| Default / inset background | bg-surface |
| Entity chrome (windows, cards) | bg-surface-lowest |
| Canvas background | bg-surface-dim |
| Secondary surfaces | bg-surface-low |
| Primary text | text-on-surface |
| Secondary text | text-on-surface-muted |
| Subtle borders | border-outline-variant |
| Strong borders | border-outline |
| Accent (sparingly) | text-primary, bg-primary |
| Error | text-error |
Typography (Tailwind classes)
| Token | Use |
|---|---|
text-body |
Default for all UI text (14px) |
text-body-sm |
Same as body (14px) |
text-body-md |
Slightly larger body (16px) |
text-label |
Metadata, timestamps (12px) |
text-title-sm |
Section headers (16px) |
text-title-md |
Main titles (24px) |
font-display |
Display font — titles only |
No bold body text. No italic emphasis.
Spacing
Multiples of 4px only: p-1 (4px), p-2 (8px), p-3 (12px), p-4 (16px). Same for gap-*, m-*.
Border Radius
| Token | Size | Use |
|---|---|---|
rounded-xs |
4px | Small elements, chips |
rounded-sm |
6px | Buttons, inputs |
rounded-md |
10px | Cards, dropdowns |
rounded-2xl |
20px | Windows, bottom sheets |
Forbidden
- Gradients
- Noise textures
- Raw hex colors
- Custom scrollbars
- Bold or italic body text
Available UI Components
import { Button } from '@/core/ui/button'
// variants: 'pill-base' | 'pill-secondary' | 'pill-active'
// size: 'pill'
import { Input } from '@/core/ui/input'
import { Dialog } from '@/core/ui/dialog'
// destructive confirms only
import { WindowHeaderOptions } from '@/core/entity/WindowHeaderOptions'
// pill button group rendered in the window header's top-right area
Scroll & Surface Rules (critical)
Your app component renders inside the Window chrome's scroll container. The Window provides:
overflow-auto scroll-fade pt-10 px-4 pb-10 bg-surface-lowest rounded-b-2xl
--scroll-fade-size: 2.5rem
What this means:
- Do NOT add
overflow-autoat your root level — the Window already scrolls - Do NOT add
bg-surface-loweston your app root div — the Window already has it - The
scroll-fademask dissolves content at edges. Don't add fixed headers inside the scroll area pt-10padding pushes content below the floating WindowHeader- Internal scroll areas are fine — sub-regions (like a time grid) can use
overflow-autoon an inner container - Edge-to-edge content: use negative
mx-4margins - For apps with their own fixed header: use
flex flex-col h-fullat root, put the header outside scrolling, let the flex child scroll
6. Code Templates
Replace all __APP_TYPE__, __APP_NAME__, __ICON__ placeholders. These are starting skeletons — adapt to your app's requirements.
apps/__APP_TYPE__/types.ts
// State shape for __APP_NAME__ entities
export interface __APP_NAME__State {
// Define your state fields here
// Example: count: number
}
export const __APP_NAME__Defaults: __APP_NAME__State = {
// Default values for every field — applied when state is {} (empty)
// Example: count: 0
}
apps/__APP_TYPE__/index.ts
import { __ICON__ } from 'lucide-react'
import type { BuiltInApp } from '@/apps/_types'
import { __APP_NAME__App } from './__APP_NAME__App'
import { type __APP_NAME__State, __APP_NAME__Defaults } from './types'
export const __APP_TYPE__App: BuiltInApp = {
source: 'built-in',
type: '__APP_TYPE__',
name: '__APP_NAME__',
icon: __ICON__,
component: __APP_NAME__App,
defaultPresentation: 'window',
defaultSize: { width: 480, height: 520 },
reduce(state, action, params) {
const s = { ...__APP_NAME__Defaults, ...state } as __APP_NAME__State
const p = params as Record<string, unknown>
switch (action) {
// Add cases for each action
// Example:
// case 'increment':
// return { ...s, count: s.count + ((p.amount as number) ?? 1) }
default:
return state // return original reference for unknown actions
}
},
summarize(state) {
const s = { ...__APP_NAME__Defaults, ...state } as __APP_NAME__State
// Return a one-line summary
// Example: return `Count: ${s.count}`
return '__APP_NAME__'
},
// Uncomment and implement if the agent needs to call tools on this entity:
// getSchema(state) {
// const s = { ...__APP_NAME__Defaults, ...state } as __APP_NAME__State
// return [
// {
// name: 'my_tool',
// description: 'What this tool does',
// inputSchema: { type: 'object', properties: { ... }, required: [...] },
// },
// ]
// },
}
apps/__APP_TYPE__/__APP_NAME__App.tsx
'use client'
import type { AppProps } from '@/apps/_types'
import type { __APP_NAME__State } from './types'
import { __APP_NAME__Defaults } from './types'
// import { __APP_NAME__Card } from './__APP_NAME__Card'
export function __APP_NAME__App({ entityId, state: rawState, dispatch }: AppProps<__APP_NAME__State>) {
const state = { ...__APP_NAME__Defaults, ...rawState }
// Uncomment if you have a card mode:
// if (mode === 'card') return <__APP_NAME__Card state={state} />
return (
<div className="flex flex-col gap-3" data-testid="__APP_TYPE__-app">
{/* App content here */}
</div>
)
}
apps/__APP_TYPE__/__APP_NAME__Card.tsx (if applicable)
'use client'
import type { __APP_NAME__State } from './types'
import { __APP_NAME__Defaults } from './types'
export function __APP_NAME__Card({ state: rawState }: { state: __APP_NAME__State }) {
const state = { ...__APP_NAME__Defaults, ...rawState }
return (
<div className="flex flex-col gap-1 p-3" data-testid="__APP_TYPE__-card">
{/* Card preview content */}
</div>
)
}
apps/__tests__/__APP_TYPE__.test.ts
import { describe, expect, it } from 'vitest'
import { __APP_TYPE__App as app } from '@/apps/__APP_TYPE__'
// --- Definition ---
describe('__APP_TYPE__ app definition', () => {
it('has correct type and source', () => {
expect(app.type).toBe('__APP_TYPE__')
expect(app.source).toBe('built-in')
})
it('has required fields', () => {
expect(app.name).toBeDefined()
expect(app.icon).toBeDefined()
expect(app.component).toBeDefined()
expect(typeof app.reduce).toBe('function')
expect(typeof app.summarize).toBe('function')
})
})
// --- Reducer ---
describe('__APP_TYPE__ reducer', () => {
it('returns state by reference for unknown action', () => {
const state = {}
expect(app.reduce(state, 'unknown', {})).toBe(state)
})
// Add test cases for each action:
// it('handles <action_name>', () => {
// const result = app.reduce({}, '<action_name>', { <params> })
// expect(result.<field>).toBe(<expected>)
// })
})
// --- Summarizer ---
describe('__APP_TYPE__ summarizer', () => {
it('handles empty state', () => {
const summary = app.summarize({})
expect(typeof summary).toBe('string')
expect(summary.length).toBeGreaterThan(0)
})
// Add test cases for meaningful state:
// it('summarizes state with data', () => {
// const summary = app.summarize({ <fields> })
// expect(summary).toContain('<expected>')
// })
})
// --- getSchema (only if your app implements it) ---
// describe('__APP_TYPE__ getSchema', () => {
// it('returns tools for default state', () => {
// const schema = app.getSchema?.({}) ?? []
// expect(schema.length).toBeGreaterThan(0)
// })
// })
7. Process (TDD, 12 steps)
Execute these steps in order. Each step that involves tests must run the suite and confirm the expected result.
Step 0: Scenario check
→ Check docs/scenarios/ for a relevant scenario
→ If none exists, write a brief one (3-5 user actions) before continuing
Step 1: Feature branch
→ git checkout -b feat/<app-type>-app
Step 2: Create directory
→ mkdir apps/<app-type>/
Step 3: Write types.ts
→ State shape, defaults, action schemas
Step 4: Write tests FIRST
→ apps/__tests__/<app-type>.test.ts
→ Run: npx vitest run apps/__tests__/<app-type>.test.ts
→ ALL tests must FAIL (proves they test real behavior)
Step 5: Write index.ts
→ reduce + summarize + app definition
→ Run tests → reducer + summarizer + definition tests PASS
Step 6: Write root component
→ <AppName>App.tsx
→ Run tests → component tests PASS
Step 7: Card component (if applicable)
→ <AppName>Card.tsx
Step 8: WindowActions component (if applicable)
→ Export from index.ts as windowActions
Step 9: Child entity hook (if applicable)
→ Follow the useCalendarEvents pattern:
- Query entities with type filter
- Create with presentation: 'hidden'
- Inherit space_id and user_id from parent
Step 10: Register in apps/_registry.ts
→ Import and add to allBuiltInApps AND the dockApps array
→ All apps built with this skill are dock-visible
Step 11: Update apps/__tests__/_registry.test.ts
→ Add getAppType('<app-type>') assertion
→ Bump getDockApps count by 1
Step 12: Full verification
→ npx vitest run
→ npx biome check .
→ Both must pass with zero errors
8. Common Mistakes
Check your code against every item before considering the app done.
Missing
'use client'— Every.tsxfile with hooks or event handlers needs it.index.tsdoes NOT.Reducer returns new object for unknown actions — Must
return state(the original reference), not{ ...state }or{ ...defaults, ...state }.Reducer/summarizer crashes on empty
{}state — Both must handle{}gracefully by applying defaults first.Raw color values — No
bg-gray-100, no#hex, norgb(). Use semantic tokens only:bg-surface,text-on-surface, etc.Bold or italic in body text — Not allowed. Use
text-on-surface-mutedfor de-emphasis,text-title-smfor emphasis.Cross-app imports — Zero imports from other
apps/folders. If you need shared logic, it belongs in@/lib/or@/core/.Forgetting registry test update —
_registry.test.tsmust be updated with the new app type and dock count.Creating entities without
space_id/user_id— Child entities must inherit these from the parent entity, not hardcode them.Storing ephemeral state in entity state — UI-only state (dropdown open, input focus, hover) uses React
useState, not entity state.Missing
data-testidattributes — Every interactive element and meaningful container needs one.Adding
overflow-autoat app root — The Window chrome already provides scrolling. Only useoverflow-autoon inner sub-regions.Using
bg-surface-loweston app root div — The Window container already has this background. Your content sits on it.
9. Reference Tiers
Use these to calibrate complexity:
Simple (settings-tier)
- 2 files:
types.ts+index.ts(component inline or minimal) - No-op or trivial reducer (1-2 actions)
- Static or near-static summarizer
- No child entities, no window actions
- Example: a settings panel, a clock display
Medium (chat-tier)
- 3-4 files:
types.ts+index.ts+App.tsx+ optionalCard.tsx - Reducer with 2-4 actions
- Dynamic summarizer
- May have window actions (mode switching)
- No child entities
- Example: a timer, a notepad, a counter
Complex (calendar-tier)
- 5+ files: types, index, App, Card, custom hook, layout utils
- Reducer with 4+ actions or complex state transitions
- Child entities with
presentation: 'hidden' - Custom hook for querying child entities
- Multiple sub-views or modes
- Window actions for view switching
- Example: calendar, kanban board, file manager
10. Canonical Reference
If anything in this skill seems wrong or outdated, check these files — they are the source of truth:
| Concern | Canonical Source |
|---|---|
| App contract (BuiltInApp, AppProps) | apps/_types.ts |
| App registry (builtInApps, getAppType) | apps/_registry.ts |
| Entity interface | lib/types.ts |
| Design tokens | tokens/tokens.css |
| Window chrome (scroll, padding, bg) | core/entity/Window.tsx |
| UI components (Button, Input, Dialog) | core/ui/ |
| Reference app (most complete) | apps/calendar/ |
If a canonical source file exists and contradicts this skill, the source file wins — update the skill.
$ARGUMENTS