# CLAUDE.md — AI Workspace
**Read this file before doing any work in this repository.** It is the source of truth for how the project is built, what must not change casually, and how future changes should be made. Update it whenever architecture, commands, conventions or important behaviour change.
---
## 1. Project overview
A self-hosted **AI Agent Workspace**: the user gives an AI a task, the system picks an agent, plans, calls real tools (files, git, terminal, web, browser, desktop, Docker, SSH, GitHub, MCP servers), pauses for human approval on destructive actions, streams every step live to the browser, and saves the complete execution history.
The product intent, verbatim from the original specification: *"I assigned work to an AI employee and I can watch it work."* The full 46-section spec is kept verbatim in [`docs/spec.md`](docs/spec.md). Every spec section is implemented except: S3-compatible storage (local disk by the owner's decision), the Anthropic native API (reachable through an OpenAI-compatible gateway), and voice input (spec defers it).
**Two rules from the spec govern everything (§42, §32):**
1. **No fake functionality.** Never simulate browser results, MCP responses, terminal output or agent execution. If something is not built, label it `NOT IMPLEMENTED` — and remove that label the moment it *is* built.
2. **Never expose API keys to the browser. Never allow arbitrary destructive commands without permission.**
**Status:** all 12 build phases complete and verified (2026-09-13/14), then extended 2026-09-23/28 (PDF reading, task attachments, per-schedule conversations, templates, export, API tokens and webhooks, code sandbox, semantic file search). 407 tests across 12 packages. `pnpm check` exits 0. Source: https://github.com/mohanram-dev/ai-workspace (branch `main`).
---
## 2. Tech stack
| Layer | Choice | Pinned |
| --- | --- | --- |
| Runtime | Node.js ≥ 22, pnpm 10 (`corepack enable`) | `packageManager: pnpm@10.34.5` |
| Web | Next.js 16 (App Router, Turbopack), React 19, TypeScript 6 strict | `next 16.3.5`, `react 19.2.8`, `typescript 6.0.3` |
| UI | Tailwind CSS 4, shadcn/ui (style `radix-nova`, base `neutral`), Radix, lucide-react, Geist fonts, sonner toasts, next-themes | |
| Validation | Zod 4 (shared schemas used by both server and client) | `zod 4.6.4` |
| Database | PostgreSQL 17, Drizzle ORM + postgres-js | `drizzle-orm 0.45.2` |
| Auth | Better Auth (email + password, Drizzle adapter) | `better-auth 1.7.4` |
| AI | `@google/genai` (Gemini) and any OpenAI-compatible server (vLLM, Ollama, LiteLLM, self-hosted gateways) — registered twice: once as `openai-compatible` for a gateway you run, once as `openrouter`. Provider abstraction in `packages/ai` | |
| Queue | Redis + BullMQ (optional worker tier) | `bullmq 6.3.4`, `ioredis 6.0.0` |
| Browser agent | playwright-core (Chromium) behind a local SSRF egress proxy | `playwright-core 1.63.0` |
| PDF text | `unpdf` (PDF.js serverless build) in `@aiw/tools/src/pdf.ts`; a `serverExternalPackages` entry in `next.config.ts`, because PDF.js spawns workers from `import.meta.url` | `unpdf 1.8.1` |
| Scheduler | cron-parser + timezone maths | |
| Tests | Vitest 5 | |
| Lint | ESLint 9 with `eslint-config-next`, `--max-warnings 0` | |
| Deploy | Docker multi-stage images, docker-compose, Coolify-compatible | |
Dependencies are **pinned to exact versions** in most packages. Follow that when adding one — and do not add one without a clear reason (see §26).
---
## 3. Architecture
```
Browser ── fetch + SSE ──► Next.js route handlers (apps/web/src/app/api/**)
│ Better Auth session, Zod validation, same-origin check
▼
@aiw/runtime (the ONE composition root: env, providers, tools,
│ browser/computer managers, MCP, agent services, queue)
▼
@aiw/agents AgentRuntime: route → plan → model/tool loop → events
│ │ │
@aiw/tools │ @aiw/mcp @aiw/browser @aiw/computer (real tools)
▼
@aiw/database (Drizzle) ──► PostgreSQL
TaskEventBus ──► SSE streams (in-memory, or Redis pub/sub in queue mode)
```
**Two run modes, same code:**
- `REDIS_URL` **unset** → everything runs in the Next.js process: `InProcessTaskExecutor` + `InMemoryTaskEventBus`. This is the supported single-server setup, not a degraded mode.
- `REDIS_URL` **set** → the web tier only enqueues (`QueueTaskExecutor` → BullMQ); `apps/worker` runs the agents; events cross processes over `RedisTaskEventBus`; live browser/desktop frames are handed over through `RedisFrameStore`; exactly one process runs the schedule ticker (`RUN_SCHEDULER`).
**Task lifecycle:** `POST /api/tasks` → `TaskService.createTask` (capacity check, routing if no agent chosen) → executor → `AgentRuntime.execute` claims the row atomically (`claimQueuedTask`) → plan → per-step model calls with a tool loop → `TaskEventRecorder.emit` persists each event to `task_event` **then** publishes it → `finish` writes result/error/usage. Pause/stop/resume/retry/continue go through `TaskService`; stop reaches a worker over Redis.
**Permission flow (spec §16–17, §27):** tool declares `permission` (static, or a function of the input for `terminal.run` / `ssh.run`). `decidePermission(level, agent.permissions, {toolName, approvedForTask, autonomous})` → `allowed` | `needs_approval` | `denied`. READ always allowed; WRITE/EXECUTE/NETWORK need a grant; DESTRUCTIVE needs human approval unless approved-for-task or trusted in autonomous mode — and `NEVER_AUTONOMOUS` (`terminal.run`, `ssh.run`) can never be trusted. Every unattended action is an `AUTONOMOUS_ACTION` event + audit row.
---
## 4. Folder structure
```
apps/
web/ Next.js app: pages, API route handlers, features, UI
src/app/(auth)/ sign-in, sign-up
src/app/(workspace)/ every signed-in page (see §6)
src/app/api/ route handlers (see §7)
src/features// UI + hooks per area: activity agents auth chat conversations files mcp projects schedules tasks
src/components/shell/ sidebar, mobile bottom nav, nav-config.ts, user menu
src/components/ui/ shadcn primitives (generated; lint-ignored; do not hand-edit style)
src/components/ theme provider, not-implemented placeholder
src/lib/ api-client.ts (apiFetch/errorMessage), auth-client.ts, format.ts, utils.ts (cn)
src/server/ HTTP helpers, session, DTO mappers, chat service, files helpers,
and one-line re-exports of @aiw/runtime (env, providers, tools, browser, computer, agents)
src/proxy.ts Next "proxy" (middleware): cookie-presence redirect to /sign-in
src/instrumentation.ts startup: recover interrupted tasks, start scheduler (single-process mode)
next.config.ts security headers (CSP etc.), transpilePackages, BUILD_STANDALONE switch
worker/ queue consumer (tsx src/main.ts): TaskWorker + scheduler, graceful SIGTERM
packages/
shared/ Zod schemas + DTO types shared by server and client; typed event map; NEVER_AUTONOMOUS; SSE framing
database/ Drizzle schema (src/schema/*.ts), repositories (src/repositories/*.ts), migrations/, testing.ts, migrate.ts
ai/ ModelProvider interface, ProviderRegistry, GeminiProvider, pricing, JSON output helpers
agents/ BUILTIN_AGENTS, router, planner, AgentRuntime (tool loop), ApprovalService, delegation, memory tools,
events/bus, SSE stream, executor, TaskService, recovery, prompts
tools/ ToolRegistry, permissions engine, Workspace path guard, process runner, SSRF-safe fetch,
built-ins: files, git, terminal, web, docker, ssh, github
mcp/ MCP client manager, discovery, tool source, SecretBox (AES-GCM) for server secrets
browser/ Playwright manager, egress proxy, page snapshots, browser.* tools, live frames
computer/ desktop drivers (Windows PowerShell, Linux xdotool), computer.* tools
scheduler/ trigger maths (timezone aware, cron), Scheduler ticker, schedule.* tools
queue/ BullMQ QueueTaskExecutor, RedisTaskEventBus, TaskWorker, RedisFrameStore
runtime/ composition root shared by web + worker (env schema lives here: src/env.ts); tests reset the module graph and the __aiw* globals per case
docs/spec.md the original specification, verbatim (do not edit)
AGENTS.md, SECURITY.md, CONTRIBUTING.md, LICENSE (Apache-2.0) open-source front matter; AGENTS.md and CONTRIBUTING.md point here
docker/postgres/init/ creates the aiw_test database
scripts/backup.sh|restore.sh
Dockerfile, Dockerfile.worker, docker-compose.yml (dev), docker-compose.prod.yml, .dockerignore
data/workspaces/ per-user agent workspaces on local disk (gitignored)
```
---
## 5. Important files
| File | Why it matters |
| --- | --- |
| `packages/runtime/src/env.ts` | **The** environment schema (Zod). Every env var, its default and validation. Add new vars here first. |
| `packages/runtime/src/agents.ts` | Builds ApprovalService, event bus, AgentRuntime, executor, TaskService; switches single-process vs queue mode. |
| `packages/agents/src/runtime.ts` | The agent loop: claim, route, plan, steps, tool calls, permissions, approvals, events, finish. ~1,200 lines; read before touching execution behaviour. |
| `packages/agents/src/service.ts` | TaskService: create/stop/retry/continue/pause/resume with ownership + capacity checks. |
| `packages/agents/src/builtin.ts` | Default agents and their tools/permissions. **Changing tools here does not reach existing users** — see §21. |
| `packages/tools/src/permissions.ts` | `decidePermission`; the autonomous floor. |
| `packages/shared/src/events.ts` | `TASK_EVENT_TYPES` + `TaskEventDataMap`. Adding an event type also requires an icon in `activity-timeline.tsx` and, if it is a tool activity, a description in `runtime.ts`. |
| `packages/shared/src/agents.ts` | Agent config schema/DTO, `NEVER_AUTONOMOUS`. |
| `packages/shared/src/schedules.ts` | Schedule schemas. `isValidTimeZone` uses `Intl` itself, **not** `Intl.supportedValuesOf("timeZone")` — that list holds `Asia/Calcutta` but not `Asia/Kolkata`, so it would reject the name most people type. |
| `packages/database/src/schema/*.ts` | 26 tables. Change → `pnpm db:generate` → rename the migration → `pnpm db:migrate`. |
| `packages/database/src/testing.ts` | Resets the test DB; refuses any DB whose name does not end in `_test`. |
| `apps/web/src/server/http.ts` | `HttpError`, `errorResponse`, `assertSameOrigin`, `readJson(schema)`, `isUuid`. Use these in every route. |
| `apps/web/src/server/session.ts` | `requireApiSession` (routes) / `requirePageSession` (pages). |
| `apps/web/src/server/agent-dto.ts`, `dto.ts`, `project-dto.ts` | Row → DTO mappers. Never return Drizzle rows to the client directly. |
| `apps/web/src/lib/api-client.ts` | `apiFetch()` and `errorMessage()`; the only way the client calls the API. |
| `apps/web/src/components/shell/nav-config.ts` | Sidebar + mobile nav. Sidebar rows are inset `px-2`; the conversation search box and dividers must match, or the left edge steps in and out. |
| `apps/web/next.config.ts` | CSP and security headers; `BUILD_STANDALONE=true` enables standalone output (Docker only — it breaks `next start`). |
| `apps/web/src/instrumentation.ts` | Startup recovery and scheduler; does nothing in queue mode (the worker owns those). |
---
## 6. Pages / routes
All under `(workspace)` require a session (page-level `requirePageSession`; the proxy only checks cookie presence).
| Route | Purpose |
| --- | --- |
| `/` | New task / chat composer (agent picker, project picker, model picker, attachments). The model picker lists **every configured provider's** models, grouped by provider, default provider first — pick per task without touching `DEFAULT_PROVIDER` |
| `/c/[conversationId]` | A conversation with its messages and task cards. A **completed** card folds its steps away and leaves the answer, with a Show steps toggle; running, paused and failed cards stay open (that detail is the diagnosis, or there is an action to take) |
| `/conversations` | Search, rename, delete, archive, pin |
| `/agents`, `/agents/new`, `/agents/[agentId]` | Agent list and editor (tools, permissions, limits, autonomous mode) |
| `/projects`, `/projects/[projectId]` | Projects with files, memory, tasks, schedules |
| `/tasks`, `/tasks/[taskId]` | History; task page with tabs Overview · Activity · Tools · Browser · Computer · Team · Terminal · Files · Logs (shown only when relevant) |
| `/print/c/[conversationId]` | A conversation laid out for paper, **outside** `(workspace)` so no shell prints; it opens the print dialog (Save as PDF). `globals.css` has an `@media print` block that swaps `.dark` for the light tokens. Markdown exports and the Activity CSV are built in `server/export.ts`; CSV cells that start like a formula get an apostrophe |
| `/webhooks` | Inbound webhooks (`webhook` table), in `MORE_NAV`. Management routes are session-only; delivery is `POST /api/hooks/:id` (public, secret-checked, `server/webhooks.ts`). Runs use one conversation per webhook with `includeHistory: false`, like schedules |
| `/templates` | Saved prompts (`prompt_template`), in `MORE_NAV`. The composer's bookmark (`features/templates/template-picker.tsx`) lists and saves them; `{{name}}` blanks are filled through `fillTemplate` in `@aiw/shared`, used by server and browser alike. `/?template=` applies one on the new-task page, then drops the parameter |
| `/schedules` | Created from the form **or from a chat message** — `schedule.create` is DESTRUCTIVE, so the agent's proposal waits for your approval. Cron/daily/weekly/monthly/interval/once + run history. Every run of a schedule goes to **one conversation** (`schedule.conversation_id`, named after the schedule) through `startScheduleTask` in `@aiw/scheduler`, shared by the ticker and Run now; runs are created with `includeHistory: false` so they stay independent, and a busy conversation makes the run `skipped`. A run records its **outcome** (`completed` / `errored` / `cancelled`), written back by `recordScheduleOutcome` in the composition root's `onTaskEnd` — `@aiw/agents` knows nothing about schedules |
| `/files` | Workspace browser: upload, preview, search, create, download, delete. Previews text, images, PDF, audio and video |
| `/mcp`, `/mcp/new`, `/mcp/[serverId]` | MCP servers, tool discovery, per-tool permissions |
| `/activity` | Observability dashboard + live event feed |
| `/settings` | Providers, account, API tokens |
| `/sign-in`, `/sign-up` | Auth. First account becomes admin; further sign-ups need `ALLOW_REGISTRATION=true` |
Mobile bottom nav: Chat · Tasks · Agents · Activity · Settings.
The desktop sidebar's `PRIMARY_NAV` lists workspace destinations only. **Settings is deliberately not in it** — account and server configuration lives in the user menu (`components/shell/user-menu.tsx`), which also holds the theme switcher. The mobile bottom bar keeps Settings because there is no persistent user menu there.
`PRIMARY_NAV`, `MORE_NAV` and the user menu must stay **mutually disjoint**: the sidebar is visible while either menu is open, so an entry in two of them appears twice on one screen. `PRIMARY_NAV` is the always-visible rows, `MORE_NAV` is the sidebar's "More" flyout (set-up-once destinations), and the user menu holds account-scoped items only (Settings, Theme, Sign out).
The "More" flyout opens to the right on desktop and **downwards below `md`**, chosen from `matchMedia`, not left to Radix: inside the mobile sheet an animated ancestor carries a transform, so collision detection measures the wrong box and a right-opening menu ran 176px off a 390px screen instead of flipping.
---
## 7. API architecture
Route handlers live at `apps/web/src/app/api/**/route.ts`. Every handler follows the same shape:
```ts
export async function POST(request: Request) {
try {
assertSameOrigin(request, getServerEnv().APP_URL); // mutations only (CSRF)
const { user } = await requireApiSession(request); // 401 otherwise
const input = await readJson(request, someZodSchema); // 400 with issue paths
// ... ownership is always checked: getXForUser(db, user.id, id) → 404 if not theirs
return Response.json(toXDto(row), { status: 201 });
} catch (error) {
return errorResponse(error); // HttpError/AppError → status; else 500
}
}
```
- **Errors** are `{ error: { code, message, issues? } }`. Codes: `bad_request`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `internal`. Package code throws `AppError` (from `@aiw/shared`); routes throw `HttpError`; both are recognised by **shape** (`isAppError`), not `instanceof` — see §21.
- **Streaming**: `POST /api/chat` streams typed SSE events (`start → delta* → done|error`); `GET /api/tasks/:id/events` streams the task timeline with replay via `Last-Event-ID` / `?replay=1`; `GET /api/activity/events` streams every event for the user.
- **Live task controls**: `POST /api/tasks/:id/{stop,pause,resume,retry,continue}` share `server/task-control.ts`.
- **Health**: `GET /api/health` (db + queue, 503 if degraded, backs the Docker healthcheck); `GET /api/ready` (adds queue depth).
- Full route list: see `find apps/web/src/app/api -name route.ts`. The README has a table.
---
## 8. Database
PostgreSQL via Drizzle. Schema in `packages/database/src/schema/`, one file per area; all tables are exported from `schema/index.ts` and re-exported as `schema` from `@aiw/database`.
**Tables (26):** `user session account verification` · `agent task task_step` · `task_event` · `tool_call` · `approval_request` · `conversation message` · `project project_member` · `memory` · `mcp_server mcp_tool` · `schedule schedule_run` · `screenshot` · `usage_log audit_log` · `prompt_template` · `api_token webhook` · `file_chunk`.
**Conventions**
- UUID primary keys (`defaultRandom`), `created_at`/`updated_at` timestamptz, `onDelete` cascades from `user`.
- Access goes through **repositories** (`src/repositories/*.ts`): `getXForUser(db, userId, id)` is the ownership check; never query a user's rows without the userId filter.
- Aggregates use raw `sql\`\`` with `.mapWith(Number)` (Drizzle subqueries lose table qualification — Phase 5 lesson).
- Partial index predicates must use `sql.raw` (Postgres rejects bound parameters in DDL).
- Never put a `Date` inside a raw `sql` fragment in a query: postgres-js receives it unencoded and throws (`The "string" argument must be … Received an instance of Date`). Use the typed operators (`lt`, `gte`, …), which encode it through the column. Caught by a test in `integrations.test.ts`.
- **Migrations**: `pnpm db:generate` creates `NNNN_.sql`; **rename it descriptively and update the `tag` in `migrations/meta/_journal.json`**, then `pnpm db:migrate`. 24 migrations exist, `0000_init` → `0023_semantic_search_tool`. Data migrations (0013, 0015, 0016, 0021, 0023) are hand-written, with no snapshot file. The migrator runs a migration only when its journal `when` is later than the last applied one's, so a hand-written entry must get a `when` above its predecessor's.
- **Test DB**: tests reset `aiw_test` (created by `docker/postgres/init`). `testing.ts` refuses any name not ending in `_test`. **Never run package tests while a live server uses the test DB.**
---
## 9. Authentication & authorization
- Better Auth, email + password (min 10 chars), 7-day sessions refreshed daily, secure cookies when `APP_URL` is https, rate limits (5 sign-ins/min, 5 sign-ups/hour per IP).
- `user.role` is `admin | member`. **First account becomes admin**; later sign-ups are refused unless `ALLOW_REGISTRATION=true`. The role field has `input: false` so clients cannot set it.
- Admin-only today: stdio MCP servers (`isAdmin` in `server/mcp.ts`). Everything else is per-user ownership.
- **Personal API tokens** (`api_token`, SHA-256 only) authenticate through `requireApiUser` in `server/api-tokens.ts`, used by exactly three routes: `GET`/`POST /api/tasks` and `GET /api/tasks/:id`. Every other route stays session-only; do not widen that list casually — a token is a standing key, and token routes must not be able to mint tokens.
- **`/api/hooks/:id` is public** (no session): the webhook's secret is the authorisation. Check it before anything else costs money (the rate limit counts only verified deliveries).
- `proxy.ts` redirects cookie-less visitors to `/sign-in` but is **optimistic**: every page and route still validates the session server-side. Do not rely on the proxy for security.
- Trusted origin = `APP_URL`. If the server runs on a different port/host than `APP_URL`, sign-in fails with "Invalid origin" — this is intentional CSRF protection, not a bug.
---
## 10. UI & component conventions
- **Feature folders** (`src/features//`) hold the page component (`-page.tsx`), sub-components, `api.ts`, and hooks (`use-*.ts`). Pages under `src/app/` are thin wrappers that render the feature component and set `metadata`.
- `"use client"` only where hooks/events are needed; data-loading pages fetch on the server where possible.
- shadcn primitives in `src/components/ui/` are generated. Available: alert-dialog avatar badge button card dialog dropdown-menu input label progress scroll-area select separator sheet skeleton sonner switch tabs textarea tooltip. **There is no Checkbox** — use `Switch`. Add a primitive with the shadcn CLI, not by hand.
- Icons: `lucide-react` only. Class merging: `cn()` from `@/lib/utils`.
- Loading → `Skeleton`; empty → dashed-border message; errors → `role="alert"` paragraph or `toast.error(errorMessage(e))`.
- Toasts: `sonner` (`toast.success/error`).
- Placeholders for unbuilt features use `components/not-implemented.tsx` or an inert, labelled control — and must be **removed when the feature ships**. Three stale placeholders were found in an audit; do not create a fourth.
- The `"use client"` directive must be the **first line** of the file, before imports.
- Browser code must not depend on **secure-context-only** APIs (`crypto.randomUUID`, `crypto.subtle`, clipboard write, etc.) without a fallback: a LAN address or bare IP over plain http is a legitimate deployment, and those APIs are `undefined` there. `crypto.getRandomValues` is safe everywhere.
- Relative time / tokens / cost / duration / bytes: always via `@/lib/format.ts` (`formatCost` keeps 4 decimals under $1 so totals match their rows).
---
## 11. Styling
- Tailwind 4 with `@theme inline` tokens in `src/app/globals.css`; colours are CSS variables (`--background`, `--brand`, `--success`, `--warning`, `--destructive`, sidebar and chart tokens). Use token classes (`bg-card`, `text-muted-foreground`, `border-warning/40`), never hard-coded colours.
- Dark mode via `next-themes` (`attribute="class"`, default `system`); the `dark` custom variant is `&:is(.dark *)`.
- Fonts: Geist Sans / Geist Mono through `--font-geist-*`. Code, paths and tool names are `font-mono text-xs`.
- Layout rhythm used everywhere: page wrapper `mx-auto w-full max-w-4xl px-4 py-6 sm:px-6 sm:py-10`; cards `rounded-xl border bg-card`; section header `border-b px-4 py-3 text-sm font-semibold`.
- Scrollable regions get `scrollbar-thin`. Wide tables/code go inside `overflow-x-auto`; the page body must never scroll horizontally.
---
## 12. State management
No external state library. Patterns in use:
- Local `useState`/`useReducer` inside feature components.
- One React context: `features/conversations/conversations-context.tsx` (sidebar list).
- Server data is fetched with `apiFetch` inside `useEffect` using the **promise-chain pattern** (`.then(...)` with an `active` flag) — the React Compiler lint rule rejects synchronous `setState` in an effect and directly-invoked async functions. Copy `schedules-page.tsx` or `activity-page.tsx`.
- Live data comes from SSE: `use-task-live.ts` (per task: events, deltas, terminal chunks, browser/computer frames, refresh-on-event) and the Activity page's `EventSource`.
- Optimistic updates for toggles/deletes, rolled back on error (see `schedules-page.tsx`).
- `localStorage` only for per-device conveniences (remembered agent mode), always in try/catch.
---
## 13. Form validation
- **Schemas live in `@aiw/shared`** and are used on both sides: the server validates with `readJson(request, schema)`; the client relies on server messages (`errorMessage(e)` appends issue paths). There is no form library.
- Forms are controlled `useState` + `onSubmit` with `event.preventDefault()`, a `saving` flag, and an inline `role="alert"` error. Example: `features/schedules/schedule-form.tsx`. **Every `