# Agent Instructions **Cobble** (`cobble.moduletrace.org`): an open-source crew of small AI worker bots. The unit noun is **bot**, never "agent" or "expert". This file is a map: it names the rules, the invariants and where things live. For detail, read the code it points to. When this file, the handoff, the code and the user disagree, the code and the user win. ## The handoff is outdated `assets/handoff/` (gitignored) is the project's foundation, not its design. Never use its layouts, `assets/screens/*.png`, right-hand "working on" panel or PRODUCT/IMPLEMENTATION briefs as reference for the UI, docs, screenshots or marketing. Still authoritative: - **Brand assets** (never change): stone icons (`public/brand/icons/*.svg`, mirrored in `src/data/stone-geometry.ts`), coordinator (`public/brand/coordinator/`), app icon (`public/brand/app-icon/`), wordmark (`public/brand/wordmark/`, mirrored in `src/data/wordmark-paths.ts`). The mark is a stone with exactly one flat cut; a bot's colour is its identity (never status); the coordinator is hue-less. - **Tokens:** `src/styles/main.css` is now the source of truth (the handoff `tokens.css` is history). For the current UI read `src/layouts/bots/` (rail, workspace rail, user popover), `src/pages/admin/bots/[bot_id]/` (header, thread, composer, modals) and `src/pages/admin/workspaces/` (dashboard). ## Stack - **Runtime:** Bun, not Node. Use `bun`, never `npm`/`npx`. - **Framework:** `@moduletrace/bunext`, not Next.js. Filesystem routing under `src/pages/`, served by `Bun.serve` in `src/server.ts`. - **Database:** `@moduletrace/bun-sqlite` (SQLite). Schema `db/schema.ts`, generated types `db/types/db.ts`, config `bun-sqlite.config.ts`. - **UI:** Tailwind v4 + `@moduletrace/twui` (the only component system: no shadcn, no `tailwind.config`, no `components.json`) + `src/components/general/`. CSS chain: `src/pages/__root.tsx` -> `src/styles/main.css` -> `@moduletrace/twui/base.css`. Theme tokens in the `@theme inline` block of `main.css`. - **Icons:** `lucide-react`. Never render a lucide icon by name (twui `LucideIcon`, a string `beforeIcon`): it ships every icon. Import the icon. - **Validation:** `zod` v4 (`zod@4.1.12`). - **Branding:** site name/slug only in `src/data/site-data.ts`. Logo source `public/logos/logo.svg` (the ember stone, rendered via `BotStone` in `logo-icon.tsx`). ## Commands | Purpose | Command | | --- | --- | | Dev server | `bun run dev` | | Production | `bun run start` (serves whatever `.bunext/` holds; build first with `NODE_ENV=production bunx bunext build`) | | Type-check | `bun run typecheck` | | Tests | `bun run test` (one file: `bun test `; tests are colocated `.test.ts(x)`) | | Bundle budget (after a production build, CI) | `bun run check:bundle` (fails if the conversation page's initial JS passes budget or the full Lucide set returns) | | Favicon/brand/PWA assets | `bun run gen:favicon` | | DB schema sync / admin UI / backup | `bun run db:schema` / `db:admin` / `db:backup` | | Seed super admin / sample crew | `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) / `bun run seed:bots [email]` (idempotent) | | Query DB (read-only, JSON envelope) | `bun llm-code-assistant/query-db.ts ""` | | Publish a release | `bun run publish:github` / `publish:gitea`; `bun run mirror:gitea` afterwards | No linter or formatter. `bun run typecheck` and `bun run test` are the verification steps. Use `query-db.ts` to see what is actually in the database instead of reading ORM code. Path alias `@/*` resolves from the repo root (`@moduletrace/twui/layout/Button`, `@/src/data/site-data`, `@/db/types/db`). ## Entrypoint (`src/server.ts`) Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDockerNetwork()` (exits without docker: computers are containers), `Bun.serve`, then `cron()`, `botsQueue()`, the file-ingest worker and `liveRelay()` (cron and the queue are gated to `MAIN_SERVER` in production; the relay runs everywhere). - `/media/*` -> media server; `/computers//` -> computer proxy; `/ws` upgrades only after `userAuth`; `/sw.js` and PWA files via `handlePwaFile`; precache gate before bunext; everything else -> `bunextRequestHandler`. - Responses go through `compressResponse` (`src/functions/server/compress-response.ts`): hashed assets cached `immutable` and compressed once (in-memory, bounded); pages/JSON gzipped as they stream; media and streams untouched. - **Keep page props small:** every page is a full server render. Anything only a modal needs loads when the modal opens (e.g. the models modal fetches `api/admin/harness/catalog` and `api/admin/bots/harness-overview`, cached in `localStorage` by `src/utils/harness-cache.ts`). ## Architecture ### Auth - **Guard:** `src/pages/__root.server.ts` on every request. No public pages: anything outside `AdminSections` (`src/dict/admin-sections-dict.ts`) and `/auth` redirects to `/admin/bots`. No users -> `/auth/signup` (first-run super admin only); unauthenticated `/admin/*` -> `/auth/login`; authenticated `/auth/*` (not logout) -> `/admin/bots`; unknown `/admin/settings/*` -> `/admin/settings/general`. - **Session:** AES-256-GCM cookie (key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus CSRF cookie, `Secure` only over HTTPS (`isSecureRequest`, honours `X-Forwarded-Proto`). `userAuth` (`src/functions/backend/auth/user-auth.ts`) checks expiry (60 days), CSRF (constant-time) and re-reads `user_types` every request. - **Login:** email/username + password (`api/auth/password-login.ts`, rate limited). No emailed codes or resets. Google login only for existing accounts, when `GOOGLE_CLIENT_ID` is set. Sessions issued by `login-user.ts`. - **Passwords:** bcrypt in `user_passwords` (never in `users`; blocked from CRUD via `src/dict/private-tables.ts`). - **User types** (`src/dict/user-types-dict.ts`, the only definition): `super_admin` (user #1), `admin` (only the super admin grants it), `member` (default), `read_only` (non-GET CRUD blocked), `revoked`. A user needs at least one type to sign in. - **RBAC:** `src/utils/check-user-access.ts` plus the matrix engine in `src/utils/rbac/` (`userTypeAssignment` over `UserTypeAssignmentMatrix`/`UserTypeDeletionMatrix`). - **Audit:** `src/functions/backend/logger.ts` (`logEvent`/`logError` -> `logs`). Auth events, cron runs, every unexpected API error (`safeErrorMessage` -> `logUnexpectedError`, not for `AppError`), every failed or lost bot job. WebSocket connects are not logged. ### WebSocket `/ws`, session frozen at upgrade (`src/websocket/types.ts`). Rooms in `src/dict/websocket-rooms.ts`, store in `src/websocket/(data)/rooms.ts`. Dispatch in `handle-message.ts`; client handlers are folder-per-event in `src/websocket/client-requests//`. Non-JSON messages (twui's `"twui::ping"`) are ignored. Server pushes: `sendToUser`; clients listen with `useServerEvent`. ### Cron `src/cron/index.ts` registers everything via `registerCronJob` (`src/cron/register-cron-job.ts`); one folder per task. In-process `Bun.cron`, UTC, never overlapping. Notable: `heartbeat`, `sync-harness-models` (hourly), `run-cron-jobs` (every minute, fires schedules), `sync-computers`, `sync-mcp-servers`, `prune-bots-queue`. ### Admin CRUD API `api/admin/crud/[[...paths]].ts`: `userAuth` -> read-only gate -> per-table handler in `src/pages/api/admin/crud/(tables)//` -> generic fallback (super admin only). Add a table handler folder plus a `case` in the router. ### Crew access (bots, workspaces, computers) - Bots, workspaces and computers belong to the install; their `user_id` is only the creator. Users reach them only through grants: `bot_users` (a worker's own chat) and `workspace_users` (every bot in that workspace). - `crewRole` (`src/utils/rbac/crew-role.ts`): `super_admin` reaches everything and is the **only** one who sees or chats with the coordinator; `admin` reaches every worker and workspace; `member` only their grants. `canManageCrew` (super admin + admin) creates/edits/deletes crew and grants access; DB helpers check it themselves (`assertCrewManager`). - Access SQL lives in `src/utils/crew-access/` (tested against SQLite) and backs `grabChatBot`, `grabUserBot`, `grabUserWorkspace(s)`, `grabWorkspaceBot`, `grabBots`, `grabUserComputer(s)`. - Conversations, faces and status lines are **per user** (`bot_sessions.user_id`, `bot_user_faces`). A job runs for its session's user, with that user's memory and reach. Page reads overlay the viewer's face (`botFaceSql`). Status lines never name a task. ### Bot types `bot_types` is to bots what `user_types` is to users (`src/dict/bot-types-dict.ts`): `coordinator` (install-wide, written only by `ensureCoordinatorBot`), `admin` (scoped to one workspace), `worker` (no row). `BotTypePermissionMatrix`, `BotTypeTargetMatrix`, `BotTypeAssignmentMatrix`/`DeletionMatrix` are read by the generic interpreter `src/utils/rbac/type-assignment.ts`. Every run reads `grabBotRunPowers` once and gates tools on its `is_coordinator`, `admin_workspace_ids` and `can(permission)`, **never on `bots.kind`** (`kind` is only the legacy marker of the coordinator for visibility SQL). A run's powers are its type's AND its user's. A workspace admin gets `grabWorkspaceAdminTools` (delegate to members, set their workspace instructions, edit that workspace). Set via `api/admin/bots/set-bot-type`. Admins are marked with `AdminShield` (green, beside the name, never on the stone). ### Bots, sessions, chat - **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`. - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at `AppData.BotSessionMaxMessages`, titled and summarised by `name-bot-session.ts`, summary carried into the next session). Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. - **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. - **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`. Parsers in `src/utils/*-ref.ts`. - **Replies/reactions:** `reply_to_message_id` (not an FK); reactions from the fixed `BotMessageReactions`, toggled by `react-message.ts` and the `react_to_message` tool, pushed as `server:bot-reaction`. - **Attachments:** uploaded on pick (`upload-attachment.ts`, private `media`, text extracted when possible), linked via `bot_message_media`. Replies read files of the last `BotReplyAttachmentMessages` messages. Tasks don't get files yet. - **Thinking row / stop:** `bot-busy-state.ts` decides the row's line and whether Stop shows. Stop (`api/admin/bots/stop.ts`, `stop-bot-jobs`) fails every open job of that bot for the user, aborts the running handler (`job-abort-registry`) and drops late face updates. - **Voice:** mic -> `api/admin/bots/transcribe.ts`; play -> `GET speak-message` streamed into `