Sessions no longer take their title from the first message: once the first exchange is sent, the text model writes the title and summary, which the rail and sessions list show. The summary records how many messages it covers so a stale one is rewritten before it is carried into the next session. The sessions hook now reloads on live message and session events, so a session started by the first message, a rollover or another tab appears without a reload. New sessions are pushed as server:bot-session, both from the process that changed them and through the live relay.
72 KiB
Agent Instructions
This is Cobble (cobble.moduletrace.org): an open-source crew of small AI worker bots. The design handoff in assets/handoff/ (gitignored locally: PRODUCT.md, IMPLEMENTATION.md, tokens/) is the starting point for the core concepts and visual language, not the end product. The app is expected to move away from it; when it and the current code or the user disagree, the code and the user win. Unit noun is bot, never "agent" or "expert".
Stack
- Runtime: Bun - not Node. Use
buncommands, nevernpm/npx. - Framework:
@moduletrace/bunext- not Next.js. Routing is filesystem-based undersrc/pages/, served byBun.serveinsrc/server.ts. - Database:
@moduletrace/bun-sqlite- SQLite database. Schema indb/schema.ts, generated types indb/types/db.ts. Config inbun-sqlite.config.ts. - UI: Tailwind CSS v4 + the
@moduletrace/twuicomponent library (installed package) + shared components insrc/components/general/. There is no shadcn/ui, notailwind.config, and nocomponents.json- twui is the only component system. CSS chain:src/pages/__root.tsximportssrc/styles/main.css, which imports@moduletrace/twui/base.css(@import "tailwindcss"). Theme tokens (colors/radius/fonts) are overridden via the@theme inlineblock insrc/styles/main.css. - Icons:
lucide-react. - Validation:
zodv4 ([email protected]). - Branding: Site name/slug live only in
src/data/site-data.ts. Logo source of truth ispublic/logos/logo.svg(the ember stone; rendered inline viaBotStoneinsrc/components/general/logo-icon.tsx); browser favicon/assets are generated from it. Handoff brand assets (stones, coordinator, wordmarks, app icon) live inpublic/brand/.
Entrypoint
src/server.ts - turns on SQLite foreign keys (enableForeignKeys, so the schema's cascades fire; off by default per connection), runs bunextInit(), starts Bun.serve, then starts cron:
/media/*short-circuits to the media server handler/wsupgrades only afteruserAuthsucceeds (session frozen into the socket at upgrade time); thewebsockethandler object lives insrc/websocket/- everything else goes to
bunext.bunextRequestHandler ensureDockerNetwork()makes sure the computers' docker network (AppData.DockerNetworkName) exists before the server starts; without docker the process exits (computers are containers, so nothing works without it). Creating a computer's container checks it again firstcron()and the bots queue handler (botsQueue(),src/bots-queue/) start after the server is up, thenliveRelay()(src/live-relay/, every process, ungated); in production cron and the queue are gated to theMAIN_SERVERcontainer
Commands
| Purpose | Command |
|---|---|
| Dev server | bun run dev (runs bun src/server.ts) |
| Production | bun run start (NODE_ENV=production bun src/server.ts) |
| Type-check only | bun run typecheck |
| Run tests | bun run test |
| Regenerate favicon/brand/PWA assets | bun run gen:favicon (runs src/scripts/generate-brand-assets.ts) |
| DB schema (tables) | bun run db:schema |
| DB admin UI | bun run db:admin |
| DB backup | bun run db:backup |
| Seed super admin user | bun run seed (runs src/scripts/seed-super-admin.tsx) |
| Seed sample bot crew | bun run seed:bots [email] (runs src/scripts/seed-bots.ts, idempotent: adds sample messages to the existing coordinator and the sample workers, skips users who already created workers; assigns the sample workers to them) |
| Query DB (CLI) | bun llm-code-assistant/query-db.ts "<sql>" |
| Install | bun install |
No linter or formatter configured. bun run typecheck and bun run test are the verification steps. Tests are colocated .test.ts/.test.tsx files - run one with bun test <file>.
Querying the Database
Inspect live data with bun llm-code-assistant/query-db.ts "<sql>" - a read-only SQL CLI that loads the schema from db/schema.ts automatically and prints a normalized JSON envelope (success, payload, count, insert_return, error, msg). Exit code 1 on failure. Use it instead of fishing through ORM code when you need to know what's actually in the database.
Path Alias
@/* resolves from repo root. Import like @moduletrace/twui/layout/Button, @/src/components/general/logo, @/src/data/site-data, @/db/types/db.
Architecture
Auth
- Auth guard:
src/pages/__root.server.ts- server function that runs on every request.- No public pages: anything outside the live admin sections (
AdminSectionsinsrc/dict/admin-sections-dict.ts:/admin/bots,/admin/workspaces,/admin/routines,/admin/logs,/admin/help,/admin/settings) and/authredirects toAdminHomePath(/admin/bots)./adminitself only redirects there - No users at all: everything redirects to
/auth/signup(first-run super admin setup,grabHasUsers); once a user exists/auth/signupredirects away - Redirects unauthenticated
/admin/*→/auth/login - Unknown
/admin/settings/*pages (not inSettingsPages,src/utils/is-unknown-settings-path.ts) redirect to/admin/settings/general - Already-authenticated users hitting
/auth/*(except paths containing/logout) are redirected to/admin/bots; login and signup land there too
- No public pages: anything outside the live admin sections (
- Session: encrypted auth cookie (AES-256-GCM, PBKDF2-derived key from
ENCRYPTION_KEY/ENCRYPTION_SALT) plus a CSRF cookie.userAuth(src/functions/backend/auth/user-auth.ts) decrypts, checks expiry (AuthExpiryDays, 60) against the embeddeddate, verifies the CSRF cookie against the token embedded in the session (constant-time,src/utils/safe-compare.ts), and re-readsuser_typesfrom the DB every request (revoked users lose access immediately). - Login: local install, so email or username plus password only (
api/auth/password-login.ts, rate limited), no emailed code step and no emailed password reset. Google login (api/auth/google-login.ts) is for existing accounts only and shows whenGOOGLE_CLIENT_IDis set; it never creates accounts. Sessions are issued bysrc/functions/backend/auth/login-user.ts(supportsreauthfor silent refresh without audit logging). - Signup: first-run only.
api/auth/signup.tsxrefuses once any user exists, creates the super admin (create-super-admin-user.ts, so user #1) with a required password, and logs them in. No Google signup.bun run seed(needsSEED_SUPER_ADMIN_PASSWORD) is the headless alternative. - Passwords: bcrypt hashes live in the separate
user_passwordstable (never inusers; blocked from the CRUD API viasrc/dict/private-tables.ts). Set/change at/admin/settings/change-password. - Auth layout:
/auth/*(except/auth/logout) renders insrc/layouts/auth/with no site header/footer. - User types (
UserTypesinsrc/dict/user-types-dict.ts, the only place they're defined):super_admin(the first account, runs the install),admin(manages members; only the super admin grants it),member(a regular user: new users start with it),read_only,revoked(can't sign in). A user needs at least one to sign in. Admins grant and removemember,read_onlyandrevokedon anyone but admins and the super admin. - RBAC:
src/utils/check-user-access.ts(includes/excludes primitive,super_adminbypass) plus the declarative engine insrc/utils/rbac/-userTypeAssignmentinterpretsUserTypeAssignmentMatrix/UserTypeDeletionMatrixinsrc/dict/user-types-dict.ts.read_onlyusers are blocked from non-GET CRUD in the router. - Audit trail:
src/functions/backend/logger.ts-logEvent/logErrorwrite to thelogstable. Signup, login, Google login, logout, cron runs, and WebSocket connects are all logged.
WebSocket
- Upgrades on
/wswithuserAuthvalidation (401 if not logged in);WebSocketSession(src/websocket/types.ts) carries{ user, user_types }. - Rooms declared in
src/dict/websocket-rooms.ts-> typedWebsocketRoomType. Store insrc/websocket/(data)/rooms.ts:WeakMapfor per-client metadata,Map<room, Set<ws>>for lookups, empty rooms deleted. Join/leave/broadcast insrc/websocket/(functions)/. - Message dispatch in
handle-message.ts:client:ping→server:pong, unknown events →error. The twuiuseWebSockethook sends a raw"twui::ping"keepalive string - non-JSON messages are ignored. - Client handlers:
src/websocket/client-requests/<event>/index.ts(folder-per-event), replying throughwsSendData.
Cron
src/cron/index.ts is the single registration point called from server.ts. Tasks live in one folder each (src/cron/<name>/index.ts) as default-exported async functions, scheduled with in-process Bun.cron via registerCronJob({ title, schedule, task, run_on_start, quiet }) (src/cron/register-cron-job.ts), which logs before each run (unless quiet) and catches/logs errors. Schedules are cron expressions or nicknames (@hourly) interpreted in UTC; runs never overlap. Gated in production by MAIN_SERVER. The bundled heartbeat task doubles as a DB-liveness probe; sync-harness-models refreshes the model catalog hourly; run-cron-jobs (every minute, quiet) queues the bots' due schedules (see Schedules).
Admin CRUD API
api/admin/crud/[[...paths]].ts dispatches GET/POST/PUT/DELETE by table name: userAuth → read_only GET-only gate → per-table switch → generic fallback (super admin only). Tables without custom handlers fall through to generic CRUD with zero code. Customize a table by adding src/pages/api/admin/crud/(tables)/<table>/ with index.ts + per-method files, then add a case in the router.
Bots (the chat shell)
- Access (the crew is shared): bots, workspaces and computers belong to the install, not to a user (their
user_idis only who created them, not a foreign key). Nobody reaches a bot until a super admin or admin grants it:bot_users(a worker assigned to a user, for its own chat) andworkspace_users(a user added to a workspace, to chat with every worker in it there).crewRole(src/utils/rbac/crew-role.ts):super_adminreaches everything and is the only one who ever sees, chats with or edits the coordinator;adminreaches every worker and workspace; anyone else (member) only their grants.canManageCrew(super admins and admins) creates, edits and deletes workers and workspaces, sets their models and pictures, and hands out access (the Bots and Workspaces groups on a user's settings page,user-access-groups.tsx, each added or taken away at once byapi/admin/users/add-accessandremove-accessthroughsetUserGrant); the structural DB helpers check it themselves (assertCrewManager,src/functions/backend/db/access/). The SQL conditions live insrc/utils/crew-access/(chatBotsSql,workspacesSql,workspaceBotsSql,computersSql, tested against SQLite) and backgrabChatBot(its own chat),grabUserBot(anywhere: its own chat or a workspace),grabUserWorkspace(s),grabWorkspaceBot,grabBotsandgrabUserComputer(s). Conversations are per user (bot_sessions.user_id): a job runs for the user whose session it's in (runBotJobreads it), with that user's memory and only what they reach. A bot's face and status line are per user too (bot_user_faces, set bysetBotFacefor the job's user,grabJobUserId; no row is idle), so needs-you or working only shows to the user the job is for; every bot read for a page overlays the viewer's (botFaceSql,src/utils/bot-face-sql.ts). Live updates go to the session's user (pushToSessionUser), a face change to its user, and a bot change (its computer) to everyone who sees it (pushBotUpdated,grabBotAudience, each with their own face and active task); its status line never names a task (BotTaskStatusLine). Bot and workspace pictures are served to whoever sees them (grabSharedPictureMedia). A member with nothing granted lands on an empty/admin/bots; a workspace's dashboard is open to everyone in it, its settings link and computers' settings links only tocanManageCrew.src/scripts/share-crew.ts(safe to rerun) moved an install from one crew per user to this. - Tables:
bots(kindworker|coordinator,hue,role,system_prompt(the bot's core instructions, markdown)),bot_user_faces(face,status_lineper bot and user),bot_sessions,bot_messages,bot_tasks,bot_task_steps. Enums insrc/dict/bot-dict.ts. - Threads and sessions: each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into
bot_sessions(user_id,bot_id,workspace_idnull for its own chat,statusactive|closed,titleandsummarywritten by the bot from the first exchange,summary_message_count(how many messages the summary covers),previous_session_id,unread,last_activity_at);bot_messages,bot_tasksandbots_queuecarrysession_idandworkspace_id. Helpers insrc/functions/backend/db/bot-sessions/:ensureBotSession(the active one, else a new one;rolloverstarts the next once it hasAppData.BotSessionMaxMessages),startBotSession,reopenBotSession,grabBotSessions(search over title, summary and message text),grabUserConversation(access and membership),grabConversationProps(the conversation pages' props). Once a session's first exchange is in,name-bot-session.tshas the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (grab-session-carry-over.ts), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (src/components/general/bot-sessions-modal.tsx) lists, searches, starts and reopens sessions, and the new-session icon beside it ((partials)/new-session-button.tsx) starts one (api/admin/bots/list-sessions,new-session,reopen-session). The rail lists the open bot's latestAppData.RailRecentSessionssessions under its row (rail-bot-sessions.tsx), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - Coordinator: the install has exactly one
kind: coordinatorbot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (bun run seed) and, as a fallback, by the super admin's/admin/bots, all viaensureCoordinatorBot(src/functions/backend/db/bots/ensure-coordinator-bot.ts, idempotent). Name inCoordinatorBotName(src/dict/bot-dict.ts). Its default prompt isCoordinatorSystemPrompt(src/data/coordinator-system-prompt.ts): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - Routes:
/admin/botsensures the coordinator and redirects the super admin to its chat (/admin/bots/1on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page;/admin/bots/[bot_id]renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel);/admin/bots/[bot_id]/modelsis a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat);/admin/workspaces/[workspace_id]is a workspace's dashboard ((sections)/workspace-dashboard.tsx: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into/admin/settings/workspaces/[workspace_id], whoseworkspace-overview.tsxhas the editable groups) and/admin/workspaces/[workspace_id]/bots/[bot_id]is the sameConversationsection scoped to the workspace (pageProps.workspace; only the coordinator and member bots). All render in the full-bleedsrc/layouts/bots/layout (rail of conversations + user popover), which__root.tsxuses for every/admin/*page. - Server:
src/functions/backend/db/bots/(grab-bots,grab-bot-messages,grab-active-bot-task,grab-user-bot,create-bot,insert-user-message).api/admin/bots/send-message.tsposts a user message to a bot the session user chats with (zod:src/schemas/bot-schema.ts), queues the bot's reply (enqueueBotReply) and wakes the queue (src/bots-queue/wake-bots-queue.ts; never importsrc/bots-queue/index.tsfrom a page or API route, it pulls the bunext runtime into the page bundles).upload-attachment.tsstores one file for the next message (privatemediarow, paradigmbot-attachment, type fromattachmentFileKind, documents gettext_content, limits inAppData.BotAttachment*) andsend-messagetakes theirmedia_ids(checked to be the user's own bygrabAttachableMedia), linked inbot_message_media; messages carryattachments(BotMessageWithAttachments).mark-read.tsclears a session's unread when a message arrives in the open conversation;session-messages.tsreturns a session's latest messages, which the open conversation polls everyAppData.BotReplyCatchUpMswhile a reply is due (use-reply-catch-up), so a reply whose live push was missed (socket down) still lands without a reload (replayed as aserver:bot-message, so the rail and unread take it too);create-bot.tsadds a worker (and puts it in a workspace when given one);save-harness-settings.tssaves a worker bot's models. - Message rendering: bot messages are markdown, rendered with
react-markdown+remark-gfm(raw HTML never rendered) through the twui-based map in[bot_id]/(data)/message-markdown-components.tsx(markdown-link,markdown-list,markdown-code-blockpartials). User messages stay plain text. - Attachments and thinking: the composer uploads files as they're picked, pasted or dropped (
use-composer-attachments), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the lastBotReplyAttachmentMessagesmessages with any (load-attachment-parts.ts: images and audio as parts, documents as their text up toBotAttachmentMaxTextChars, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (BotWaitingStatusLine) or a task for its computer (BotComputerBusyStatusLine),BotThinkingRowsits under the last message; while a task runs it reads " is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (use-reply-progress). A coordinator with no workers getsCoordinatorSoloNoteinstead ofCoordinatorReplyNote, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay. - Pictures: a bot or workspace can have a picture (
image_media_id, a privatemediarow, paradigmbot-image/workspace-image), set or removed throughapi/admin/set-profile-image.ts(setProfileImage, the old one deleted; served atprofileImageUrls).BotAvatarshows a bot's picture as a rounded square, else its stone; its hue still applies.WorkspaceAvatarshows a workspace's, else the workspace icon. A bot is edited from the header avatar ((partials)/bot-profile-modal.tsx, the sameBotFormas adding one,api/admin/bots/update-bot.ts; the coordinator keeps its name and stays hue-less). - Avatars:
BotStone(src/components/general/bot-stone.tsx) renders the stone inline fromsrc/data/stone-geometry.ts, hue as a fill token, face (idle|working|done|asleep|needs-you) drawn as eyes. The coordinator is hue-less (ink body, paper eyes; inverts in dark). Never put a stone in a circle/badge or add outline/shadow. Wordmark is vector (Wordmark,src/data/wordmark-paths.ts), never live text. - Copy helpers:
src/utils/crew-status.ts(3 working · 1 needs you, "Three bots are working. One needs you."),src/utils/relative-time.ts(relative timestamps only). - Prompt and memory (self-learning): a bot has
system_prompt(core instructions) and a separate corememory(markdown, general knowledge about its job, the same for every user it works for, never about one user). What it remembers about a user lives inbot_user_memories(one row per bot and user, seen only by that user's runs;grab-bot-user-memory,update-bot-user-memory), like workspace memory stays in its workspace. The bot rewrites them as it adapts (update-bot-system-prompt,update-bot-memoryinsrc/functions/backend/db/bots/; whole-text replace, capped atAppData.PromptMaxLength).grab-bot-promptbuilds the full prompt for the run's user viacomposeBotPrompt(src/utils/compose-bot-prompt.ts): core instructions, then (in a workspace only)# Workspace: <name>with its description andsystem_prompt, the bot's# Your instructions in <name>(workspace_bots.system_prompt),# Memory,# Memory about the user, then# Memory in <name>(workspace_bots.memory).update_memorysaves touserby default (workspacein a workspace) and tocoreonly for what holds for everyone. In a workspace the bot learns into the workspace's instructions and memory by default (scope: workspaceonupdate_instructions/update_memory,WorkspaceLearningNote) and into its core ones only when told to, or after suggesting it.src/scripts/move-bot-memory-to-owners.ts(run once per install) moved core memory written before this into the owner's. - Queue: every bot job goes through
bots_queue, withkind(BotJobKinds):reply(answers the user in chat, no task row) ortask(long-running work, typically on a computer). Long-running work goes throughbots_queue, a bun-sqliteisQueuetable worked with the nativeBunSQLite.queueAPI (statuspending|processing|completed|failed,attempts,available_at,locked_at/locked_by,last_error; options inAppData.BotQueue*). Not every message needs a job: the bot replies right away that it's on it andenqueueBotJobqueues the brief (instructions,params, optionalmessage_id/computer_id, the computer checked against the bot's access; abot_tasksrow is created unlesstask_idis given). DB helpers insrc/functions/backend/db/bots-queue/:claimBotJobs,updateBotJob(renews the lock, savesprogress/checkpoint; false once lost or cancelled),completeBotJob(result message, task done, face done/needs-you for the job's user),failBotJob(backoff retry, orfatal; out of attempts posts a needs-you message vianotifyBotJobFailed),cancelBotJob(failed withBotJobCancelledError,src/dict/bot-queue-dict.ts),releaseWorkerBotJobs. Every model turn of a job is a step inbots_queue_updates(kindtool|note, one-linetext, JSONmetadata; tool names, never arguments;describe-job-step.ts), written through the job context'sreport(the text loop'sonStep), which also setsprogressand pushesserver:bot-job-update(BotJobUpdateData). Steps are deleted with their job when it's pruned. The active task's latestAppData.BotJobUpdatesShownsteps ride onBotTaskWithSteps.updates(grabBotJobUpdates); the conversation (bot_taskpage prop,use-task-progress) shows the latest one underBotThinkingRow's " is working…" while a task runs. Step text is the first line the model wrote, else plain labels for its tools (JobStepToolLabelsinsrc/dict/bot-queue-dict.ts; other tools in words, an MCP tool as "Using Exa search (web search exa)"). - Queue handler:
src/bots-queue/index.ts, started fromserver.tsafter cron and gated the same way. It releases its own cut-off jobs on startup (stable workerAppData.BotQueueWorker), then polls everyBotQueuePollMs: reports jobs bun-sqlite failed on a lock timeout (report-lost-bot-jobs), claims per kind (claimBotJobsmirrors bun-sqlite's atomic claim pluskind, sincequeue.claimhas no filter) up toBotQueueReplyConcurrencyreplies andBotQueueConcurrencytasks on separate slots, and runs each withprocessBotJob(heartbeat at a quarter of the lock timeout, abort on cancel/loss, one task per computer and one reply per session viacreateKeyLock, so a bot's other sessions answer side by side; a task waiting for its busy computer shows faceasleepwithBotComputerBusyStatusLine; faceworkingonce it starts, outcome pushed live; a job finishing while others for that user still run puts back the running one's face,grabRunningBotJobFace). The actual work isrun-bot-job.ts(BotJobRunner, context intypes.ts), which dispatches torun-bot-reply.tsandrun-bot-task.ts. ThrowBotJobFatalErrorfor failures retrying can't fix. When every model is resting (HarnessError.resting, nothing was tried) the job is put back withdeferBotJobuntil the first comes back, its attempt refunded and the bot's faceasleep; other retryable harness errors retry no sooner than theirretry_at. Theprune-bots-queuecron deletes finished jobs afterBotQueueRetentionDays. - Live updates: server push only.
sendToUser(src/websocket/(functions)/send-to-user.ts) sends to one user's sockets;pushBotNoticesendsserver:bot-message(the message row, user messages too, for other tabs) andserver:bot-updated(BotUpdatedData: face, status line, active task). The bots layout opens one socket (twuiuseWebSocket); components listen withuseServerEvent(src/hooks/), anduseLiveBotskeeps the rail and header current. The conversation dedupes messages by id. Every process also runsliveRelay(src/live-relay/): everyAppData.LiveRelayPollMsit reads newbot_messages, newbots_queue_updates, changed faces and changed bots from the database and pushes them to its own sockets, so work done by another process still shows live; clients drop what they already have.server:bot-updatedcarries the bot'scomputer_idwhen known, which keeps the header's computer in step across tabs. A bot that joins the crew or a workspace is pushed asserver:bot-created(BotCreatedData: its rail row, the workspaces it's in, and whether it belongs in that user's own chat), from the tool that created it and from the live relay for other processes, so the rail lists it without a reload. A workspace a bot created is pushed the same way asserver:workspace-created(WorkspaceCreatedData: its rail row), from the tool and from the live relay. A deleted bot is pushed asserver:bot-removed(BotRemovedData) to the audience read before the delete, since its assignments go with it, so its rail row goes at once. A rail row's subtitle is the bot's status line while it has one, else (coordinator) the crew counts while a worker is busy or needs you, else its last message as plain text (src/utils/message-preview.ts). - Workspaces:
workspaces(name,description,system_promptshared by every bot in it,computer_idits default computer (not a foreign key),archived), with bots assigned throughworkspace_bots(plus that bot'ssystem_promptandmemorythere; the coordinator is an implicit member) and computers throughworkspace_computers. The first computer linked becomes the default; the user (workspace settings, chat header), any bot there (set_workspace_computer) or the coordinator can switch it. A workspace with no computer has none: tasks needing one fail. Workspace models (workspace_harness_settings) win over a bot's own, then the defaults. Helpers insrc/functions/backend/db/workspaces/(create-workspace,update-workspace,delete-workspace,add/remove-bot-*,update-workspace-bot,add/remove-computer-*,set-workspace-computer,grab-user-workspaces,grab-workspace-bots,grab-workspace-computers,grab-workspace-activity), all access-checked against the session user, the structural ones for super admins and admins only; API inapi/admin/workspaces/. The coordinator sets a member's instructions there withset_bot_workspace_instructions, creates a workspace withcreate_workspace, and changes any workspace's name, description or context withupdate_workspace(chat replies, and its own tasks so findings it was asked to record land in the workspace; a chat-created one is pushed asserver:workspace-created). - Computers: a computer is an AIO sandbox in its own docker container on the
AppData.DockerNetworkNamenetwork.containersrecords every docker container the app knows about except the main app container (namedoubles as the hostname, plusdocker_id,image,network,port,status,metadata).computers(the install's,kindmain|sandbox,container_id) andbot_computers(direct access). The app creates and manages every computer's container over the docker socket (src/functions/backend/docker/;DOCKER_SOCKETorAppData.DockerSocketPath):createComputerrecords it andprovisionComputer(in the background, statuscreating, then the container's orfailedwithmetadata.error, pushed asserver:computer-updated) pullsAppData.ComputerImage, binds the home dir and starts<ComputerContainerPrefix>-<id>limited to the computer'scpusandmemory_mb(set at creation, defaultAppData.ComputerDefaultCpus/ComputerDefaultMemoryMb: 1 CPU, 2 GB), attached to the network in the same create call, with a docker health check (AppData.ComputerHealthcheck: the sandbox API onComputerPortand its browser); the computer stayscreatinguntil it passes (waitForHealthyContainer), then records its IP address on the network (containers.ip_address, refreshed bysync-computers).updateComputer(api/admin/computers/update-computer) saves name, description and resources; new resources stop, remove and recreate the container (provisionComputer), the home dir kept. A computer's home (/home/gem) isDATA_DIR/computers/<id>(AppData.ComputerDataDir, owned by uid 1000); deleting the computer (deleteComputer, never the main one) removes the container and keeps that directory. Run in docker, the app resolves host paths from its own container's mounts and joins the network; outside docker (dev) it can't resolve container hostnames, so it reaches them at their IP address (pickReachableAddress: hostname first, then the IP, the pick cached forAppData.ComputerAddressCacheMs). Only super admins create, delete or retry computers (api/admin/computers/). Thesync-computerscron records container states every five minutes. The install has one main computer, created at signup (bun run seed) and as a fallback by the super admin's/admin/botsviaensureMainComputer(src/functions/backend/db/computers/ensure-main-computer.ts, idempotent), which grants the coordinator access; withMAIN_COMPUTER_URLset (dev) it isn't provisioned. That one grant can't be removed (isLockedComputerAccess, enforced inremove-bot-from-computerand thebot_computersCRUD handler); every other grant can. A bot's effective computers (grab-bot-computers): in a workspace, the workspace's computers, its default first; otherwise its direct grants (the coordinator's are every computer), the one it's on first:bots.computer_id(not a foreign key; null or a lost grant means its main one), socomputers[0]is where its tasks run by default. The user switches it from the chat header ((partials)/bot-computers-modal.tsx, a ghost icon beside the model icon; the header names the current computer and the text model and provider under the bot's name) or by clicking a computer the bot named in a message: bots write[computer:ID](src/utils/computer-ref.ts, turned into#computer-IDlinks bylink-computer-refs.ts, rendered by(partials)/computer-ref-chip.tsx), both sharingBotComputersContext/use-bot-computers, saved byapi/admin/bots/set-computer.ts(setBotComputer). Enums insrc/dict/computer-dict.ts. - Opening a computer: the app serves each computer to whoever sees it (
computersSql) under/computers/<id>/(ComputerProxyPrefix):src/server.tshands those paths tohandleComputerProxy(src/functions/server/computers/:userAuth, access viagrabUserComputer, then the sandbox's address fromgrabComputerMcpUrl), which fetches plain requests (app session cookies dropped,X-Forwarded-Host/Proto/Prefixset so the sandbox builds its links and CDP address under the prefix, framing headers and redirects fixed) and bridges websockets (computerProxySocket, dispatched fromsrc/websocket/whenws.data.proxyis set).ComputerView(src/components/general/computer-view.tsx) shows a computer in one ofComputerViewModes(browser/browser-ui, terminal/terminal, code/code-server/, desktop: the sandbox's noVNC at/vnc/vnc.html, its socket pointed back through the proxy by the{prefix}placeholder thatcomputerViewUrlfills in; desktop is the only view with Chrome's toolbar (extensions) and a clipboard panel, since the browser view streams pages over CDP and hideschrome-extension://targets; more of the sandbox's apps can be added there), keeping visited modes loaded. In the chat,(partials)/computer-viewer-modal.tsxshows it, opened from a computer card's open button in the computers modal or a take-over button (openComputeronBotComputersContext). - Take-over: a bot asks the user to take over a computer with
ask_user_to_take_over(src/harness/tools/computer/ask-to-take-over-tool.ts, chat replies and tasks, defaulting to the task's computer). It writes[take-over:ID:mode](src/utils/take-over-ref.ts;withTakeOverRefsadds it if the bot left it out and flags the message needs-you), linked bylink-computer-refs.tsand rendered by(partials)/take-over-chip.tsx. The viewer then offers "Hand back to ", which sends a chat message so the bot carries on; a task stops and reports instead of waiting. - Health:
containers.statusis docker's state withrunningnarrowed by the health check (starting,unhealthy;inspectDockerContainer). Only arunningcomputer is reached:grabComputerMcpUrlreturnsready: falseotherwise (re-reading a stale state from docker first,refreshComputerContainer, whichsync-computersuses too), and callers say why (computerNotReadyMessage). - Schedules (routines in the UI):
cron_jobs(per user and bot,workspace_idnull for its own chat,title,instructions(the brief),schedule(aBun.cronexpression, UTC),computer_id(not a foreign key; null runs on the bot's current one),enabled,max_runs(1 for a one-off),run_count,next_run_at,last_run_at,last_job_id,last_error). Helpers insrc/functions/backend/db/cron-jobs/(create-cron-job,update-cron-job,delete-cron-job,grab-cron-jobs,grab-user-cron-job,fire-cron-job), all ownership-checked, and withbot_idlimited to that bot's own.nextCronRun(src/utils/next-cron-run.ts,Bun.cron.parse) validates a schedule and refuses one that runs more often thanAppData.CronJobMinIntervalMs; a bot has at mostAppData.CronJobMaxPerBot. Every bot getscreate_schedule,list_schedules,update_schedule,delete_schedule(grabBotTools, every run; descriptions viadescribe-cron-job.ts). Therun-cron-jobscron fires due ones (fireCronJob: claims the run atomically by movingnext_run_at, skips it while the last run's job is still open, queues the brief as a task withenqueueBotJobin the bot's active session there, turns off aftermax_runsor when there's no next run, keeps why it couldn't inlast_error, logs each run undercron). A run missed while the server was down runs once, then carries on from now./admin/routineslists them (pause/resume, edit, delete;api/admin/routines/update-routine,delete-routine); bots create them from chat. - Not built yet: task steps (nothing writes
bot_task_stepsyet).
Production container
deploy/run-app-container.sh builds the image and (re)runs the app's container (deploy/deploy-prod.sh calls it on the server); rerun it to update. The app container and every computer container share the cobble network. Mounts: .env at /app/.env (read only; bun loads it), the host data dir (COBBLE_DATA_DIR, default /data/cobble) at /data, and the docker socket. Everything that must survive an update lives on /data: media and computers' home dirs (DATA_DIR=/data), the database with its backups and exports (DB_DIR=/data/db, read by bun-sqlite.config.ts; the schema always comes from db/schema.ts), and bun-sqlite's live schema record (entrypoint.sh links /app/.bun-sqlite there). MAIN_SERVER=1 is set on that one container.
Envs
Envs are set in Settings, under Environment (super admin only), not in .env. Table envs (private): key, encrypted value, description, paradigm (EnvParadigms: general, workspace, computer) with workspace_id/computer_id, secret, expose_to_computers. One per key and scope, enforced in saveEnv (src/functions/backend/db/envs/), since SQLite unique constraints treat NULL ids as distinct. Decrypted rows are cached per process (grabAllEnvs; saveEnv/deleteEnv clear it).
- App settings (
EnvDictinsrc/dict/env-dict.ts:MAIN_COMPUTER_URL, theVAPID_*Web Push keys and every provider preset'skey_env) are what the app reads, throughgrabEnv({ key }): the general value, elseprocess.env, so an existing.envstill works. General only, never on computers.ProcessOnlyEnvKeys(ENCRYPTION_KEY/SALT,PORT,DATA_DIR,MAIN_SERVER) can't be saved: they encrypt the table or are read before the database opens. - Bot and computer envs are any other key.
grabComputerEnvsresolves one run's set (resolveScopedEnvs): computer beats workspace beats general; workspaces are the non-archived ones the computer shares with the run's bot, oldest winning a tie.openComputerToolswrites them to~/.cobble/envandenv.jsonon the computer (owner only, rewritten every run) over the sandbox's REST API (writeComputerEnv); the sandbox's shells read no profile, sowithComputerEnvmakessandbox_execute_bashsource the file andsandbox_execute_code(Python, JavaScript) load the JSON, invisibly to the model. Bots get keys and descriptions in the task prompt, never values, and every computer tool result is passed throughredactSecrets(each secret value shown as[KEY]). - API:
api/admin/envs/(save-env,delete-env), super admin only, logged by key, each returninggrabEnvsOverview(secret values left out; a secret is only replaced, never read back).
PWA and notifications
- Generated assets:
public/manifest.webmanifest,public/icons/*.pngandpublic/offline.htmlare generated frompublic/logos/logo.svgand the theme tokens bybun run gen:favicon(src/scripts/generate-brand-assets.tsreadssrc/styles/main.css, so the manifest colors and the offline page never drift from the theme). The outputs are committed like the favicons.src/data/pwa-data.tsholds the shared paths, icon list, start path (/admin/bots) and shortcuts. The apple touch icon is opaque on paper (a transparent one renders black on iOS). - Head and install:
src/pages/__root.tsxHeadlinks the manifest, sets the apple/mobile meta tags and rendersPwaInitScript, which capturesbeforeinstallpromptbefore hydration (twui's hook reads it later), pointstheme-colorat--c-paper(following the theme without repeating the color), and rewrites bunext's fixed viewport meta to addviewport-fit=coverandinteractive-widget=resizes-content.use-install-promptexposes installable/standalone/iOS state; the rail user menu shows Install app while installable, hides once installed, and opensinstall-app-modalwith the Safari Share steps on iOS. - Service worker:
public/sw.js, served bysrc/server.tsthroughhandlePwaFilewithno-storeandService-Worker-Allowed: /(bunext's one-hour public cache would delay every deploy, including through Cloudflare). Navigations are network-only withpublic/offline.htmlas the fallback; authenticated HTML is never cached./.bunext/,/public/, icons and logos are stale-while-revalidate;/api/,/ws,/media/,/computers/are never intercepted.ServiceWorkerRegistrarin__root.tsxregisters it in production only (development HMR is never cached) and offers a reload when a new version waits; it never reloads on its own, so a running reply isn't cut off. - Phone shell: below
mdthe rail is a 280px slide-over with a scrim, Escape to close, and a close on every route change (RailDrawerContext); it is a 64px icon rail atmdand 260px atlg. Rail internals use the@container@rail:variant (--container-railinsrc/styles/main.css), not viewportlg:, so the drawer shows the full rail.RailDrawerButtonsits in both pane headers. The shell ish-dvhwith safe-area insets, and the headers/thread/composer use mobile padding. - Connection recovery: twui's
useWebSocketstops retrying after three attempts, soLiveSocketis remounted ononline,pageshowand a hidden tab becoming visible (use-connection-epoch).useOnlineStatusdrives the offline strip and disables sending: nothing is queued offline. - Web Push:
push_subscriptions(one row per endpoint and user; the endpoint is unique and a capability URL, never sent to the client) anduser_presence(one row per user, refreshed by the socket keepalive insrc/websocket/index.ts). VAPID keys are app settings in theenvstable (VAPID_PUBLIC_KEYnot secret,VAPID_PRIVATE_KEYsecret), generated byensureVapidKeyson the main server's first start; the subject isSiteData.SiteURL.pushBotNoticeis the one trigger: bot messages (never the user's) and a needs-you face change without a message. It is skipped whileuser_presence.last_seen_atis withinAppData.PushPresenceWindowMs, so an app open anywhere wins.src/dict/push-dict.tsowns payload limits and tags (one conversation replaces its own notification); sending never blocks or fails the job, 404/410 removes the device, and other failures are recorded on the subscription. API inapi/admin/push/(subscribe, unsubscribe, test, remove-device, overview); Settings > Notifications (notifications/) has the per-device switch, a test send, and every device this user has. Permission is only asked from the switch (a denial is hard to undo, and iOS requires the gesture); iOS also needs the installed app. Development has no service worker, so the page says notifications need the installed production app. - Risks and notes: push needs HTTPS (or localhost). Cloudflare never caches
/sw.js(no-store). The viewport rewrite is a workaround for bunext's fixed meta and stays defensive (no-op if the tag is gone); iOS Safari ignoresinteractive-widget, so its on-screen keyboard overlays the composer rather than resizing the page. Notification bodies are short message previews, so they can appear on a lock screen; there is no per-device mute beyond turning notifications off. Presence is per user, not per device: an open desktop suppresses a phone until it has been closed forPushPresenceWindowMs(deliberate). Notifications are best effort: no retry queue, and a failed send is logged and recorded on the subscription. Live events missed while disconnected are not replayed, except the conversation's reply catch-up for a pending reply; opening the conversation refetches. The drawer is not a focus trap.
Harness (the model layer)
src/harness/ is the brain: every model call goes through it. Install-wide, managed by super admins at /admin/settings/models.
- Harnesses (
Harnessesinsrc/dict/harness-dict.ts):text(chats, plans, calls tools, drives computers; always on),vision("Media reading": images/scans/screenshots for a text model that can't see),transcription(speech to text),speech(text to speech),image(image generation). Each has oneharness_settingsrow (no row = automatic, on, with fallback): aprovider_id(aharness_providersrow the harness is limited to, for its pick and its automatic picks; null for any provider; with no row, the default provider, OpenRouter,grabDefaultHarnessProviderId), a pinnedmodel_id(aharness_modelsrow from that provider, not a foreign key) or null for automatic,fallback(also try the automatic picks),enabled,options(voice). - Providers (
HarnessProviderKindsinsrc/dict/harness-provider-dict.ts): OpenRouter (default, created byensureDefaultHarnessProvider), OpenAI, Anthropic, Gemini, DeepSeek, Groq, Mistral, xAI, Ollama (local, its models count as free), any OpenAI-compatible server. Rows inharness_providers(a private table: the key is encrypted withsrc/utils/crypt.ts, never sent to the client; falls back to the preset's env, e.g.OPENROUTER_API_KEY, viagrabEnv). One adapter today,src/harness/providers/openai-compatible/(chat,transcribe,speak,generateImage,listModels), chosen ingrab-harness-adapter.ts; a provider needing a native API gets its own adapter there (HarnessAdapterinsrc/harness/types.ts). - Catalog:
harness_modelsper provider (modalities, tools support, voices, Artificial Analysis scores,free,available). Thesync-harness-modelscron (hourly, on start) refreshes every enabled provider (syncAllHarnessModels); unlisted models are kept but marked unavailable. OpenRouter lists every modality with capabilities; other providers list ids, so capabilities are guessed (guess-model-modalities). Free on OpenRouter means:freeoropenrouter/free, never zero token prices. - Automatic picks (
rank-auto-models): free models that can do the job (on the setting's provider when it has one; a provider without free models needs a picked model) (model-serves-harness: text needs tools), by vendorAutoModelVendorPreference(DeepSeek, Qwen, NVIDIA, Liquid), then other vendors that look competent (AppData.HarnessAutoMin*), then routers; within a vendor by intelligence index, then full-size before small, newest first. Transcription puts real speech-to-text models first. - Calling:
resolveHarnessCandidates(pinned, then automatic) ->withCandidatestries them in order with process-wide cooldowns (resolve/harness-cooldowns.ts): rate limits and outages rest the model (untilRetry-Afterwhen given), 401/402 rest the whole provider, 403/404 rest the model for an hour. Errors areHarnessError(codeprovider|unavailable|aborted,retryable), classified into-harness-error.ts. Entry points:text/generate-text.ts(one turn),text/run-text-loop.ts(tool loop, sticks to one model, wraps up afterHarnessMaxToolSteps, trims the oldest tool output before each turn to fit the model's context,fit-transcript-to-context.ts),media/read-image.ts,media/read-media.ts(documents locally viafileToText, images and audio through their harnesses),audio/transcribe-audio.ts,audio/speak-text.ts,image/generate-image.ts. A text model that can't take an image or audio part gets it read by the vision/transcription harness first (prepare-messages-for-model.ts). - Per-bot models: in a workspace, its
workspace_harness_settingsrow wins (source: "workspace"). Otherwise a worker bot inherits the defaults per harness unless it has abot_harness_settingsrow (same fields asharness_settings, unique per bot and harness).grabHarnessSetting({ harness, bot_id })returns the bot's row (source: "bot") or the default; the coordinator always gets the default. Every entry point takesbot_idand passes it on (text loop -> media reading/transcription), so a bot's run uses its own models throughout. Saved bysaveBotHarnessSetting(inheritdeletes the row); the page data isgrabBotHarnessOverview. A bot's chat header has a ghost model icon ((partials)/header-icon-button.tsx; the text model and provider show under the bot's name) that opens a modal with every harness ((partials)/bot-models-modal.tsx); the modal and/admin/bots/[bot_id]/modelsboth use(partials)/bot-harness-form.tsx. On the coordinator the modal edits the defaults themselves (api/admin/harness/save-settings, the same settings as Settings, under Models, without the providers): super admins only, the icon is disabled for everyone else. Every one of these wrapssettings/models/(partials)/harness-settings-form.tsx: per harness a provider select first (harness-provider-select.tsx: any provider, one provider, or on a bot "Same as the default"), then the model select narrowed to it. Removing a provider (deleteHarnessProvider) puts default and bot settings that picked it or its models back on any provider (saveprop; "Same as the default" shows when the overview item hasdefault). - Tools:
HarnessTool(built per run with its bot bound): own tools validate with a zodschema; tools described elsewhere pass a JSON schema asparametersand validate themselves.run-tool-call.tsreports bad calls and failures back to the model asError: .... Bot tools insrc/harness/tools/bot/:update_memory,update_instructions(grabBotTools, every run),start_taskand, for the coordinator with workers,delegate_task(grabBotChatTools, chat replies only; the job'srequested_by_bot_idis the coordinator, andrelayJobToRequesterposts the worker's result or failure in its conversation, pushed asrelaybypushBotNotice),ask_user_to_take_over(see Take-over), plus for the coordinatorcreate_workspaceandupdate_workspace(a new workspace with its shared context, or changing one; both also in its tasks, so a task that gathers material can write it into the workspace),create_bot(add a worker with its name, role and instructions, and join it to a workspace straight away) and, in its own chat,add_bot_to_workspace(put an existing worker in one of the user's workspaces) andassign_computer(grant or revoke a worker's direct access,add/remove-bot-to/from-computer; access through a workspace stays and the result says so).search_computers(src/harness/tools/computer/search-computers-tool.ts, chat replies and tasks) runs one read-only command on all of a bot's computers at once over the sandbox REST API (run-computer-command.ts), each output under its[computer:ID], secrets redacted, capped byAppData.ComputerSearch*. - MCP:
src/harness/mcp/:connectMcpServer(Streamable HTTP, SSE fallback),grabMcpTools(each server tool becomes aHarnessToolwith its JSON schema),mcpResultToText(text as is, images read by the vision harness,isErrorthrown). Computers are reached this way:openComputerTools(src/harness/tools/computer/) connects to the computer's AIO sandbox atgrabComputerMcpUrl(theMAIN_COMPUTER_URLapp setting for the main computer, for a dev server outside docker, e.g.http://localhost:8170, elsehttp://<hostname>:<port>/mcp, or its IP address when the hostname can't be reached), leaves outComputerMcpExcludedTools, and sets the computer's envs (see Envs). - MCPs and custom tools (extensions): installs add their own MCP servers (
mcp_servers, Settings, under MCPs) and tools (custom_tools, Settings, under Tools). A bot gets the ones given to it (bot_mcp_servers,bot_custom_tools) and, in a workspace, that workspace's (workspace_mcp_servers,workspace_custom_tools);botExtensionsSql(src/utils/bot-extensions-sql.ts, tested) resolves a run's,grabBotExtensionsreads them.all_bots("every bot by default") only decides what bots are given: turning it on gives it to every bot (attachToAllBots, the coordinator included) and every new bot gets it (attachDefaultExtensions, fromcreateBotandensureCoordinatorBot); turning it off takes it from no one, and a bot that drops one keeps it dropped (src/scripts/attach-every-bot-extensions.ts, safe to rerun, moved installs from the old "reaches every bot" meaning). Super admins define them. Super admins and admins (the super admin alone for the coordinator,assertBotExtensionManager) give and take them per bot on the bot's settings page and under its chat's composer (composer-tools.tsx: chips for everything it can use there, a workspace's not removable, and an Add modal; before themcomposer-computer-button.tsxopens the bot's current computer in the viewer), and per workspace on the workspace's settings page; the settings pages sharesettings/(partials)/extensions-group.tsx, all throughuseExtensions(src/hooks/),grabBotExtensionsOverview/grabWorkspaceExtensionsOverview(bot_extensions/workspace_extensionspage props, both ongrabExtensionsOverview) andapi/admin/bots/andapi/admin/workspaces/add-extension,remove-extension(setBotExtension,setWorkspaceExtension); the MCPs and Tools pages' Access modal sets bots and workspaces in bulk (setExtensionAccess, an admin never touches the coordinator's orall_bots). Helpers insrc/functions/backend/db/extensions/, API inapi/admin/mcps/andapi/admin/tools/, zod insrc/schemas/extension-schema.ts.openBotExtensions(src/harness/tools/extensions/) turns them into harness tools for chat replies and tasks (built-ins win a name clash;close()in the runners'finally;notetells the bot about a server it can't use). Templates (fillTemplate,src/utils/fill-template.ts):{{args.x}}and{{env.KEY}}, envs fromgrabRunEnvs(workspace beats general, app settings never, no need to be set on computers); secret values are redacted from every result.- Custom tools (
CustomToolKindsinsrc/dict/custom-tool-dict.ts): a name (checkCustomToolName: snake_case, never a built-in's,ReservedToolNames), a description, a JSON-schemaparametersand aconfigper kind, built bybuildCustomTool(src/harness/tools/custom/):http(runHttpTool: the app fetches, arguments URL-encoded in the URL, no body template sends the arguments as JSON),script(runScriptTool: bash/python/javascript on the run's computer, the task's or in chat the bot's current one, over the sandbox REST API in a subshell, computer envs set, arguments as JSON inCOBBLE_ARGSand on stdin; left out without a computer),prompt(runPromptTool: onegenerateTextturn with the bot's models). Super admins try one from the page (testCustomTool, the main computer for scripts): a saved one from its row (Try), or the form as it is, saved or not (draft, nothing recorded), from the Test panel in its modal (custom-tool-test-panel.tsx). Every run (a bot's call or a try; not bad arguments or a cancel) is saved as the tool'sstatus,last_errorandlast_checked_at(recordCustomToolRun); editing resets it tountested. - MCP servers (
McpTransportsinsrc/dict/mcp-dict.ts):remote(URL plus templated headers,connectMcpServer) orstdio(a command run with the host's shell,hostShell, inDATA_DIR/mcp/<id>(grabMcpDir), after itssetup_command, whichrunMcpSetupreruns whenever it changes (marker file) or on Reinstall; its env is only its ownenvmap plus the shell basics,grabMcpStdioEnv, never the app's). Their tools are listed intotools_cache(syncMcpServer: on save, Refresh, and hourly for remote ones by thesync-mcp-serverscron) and named<slug>__<tool>(mcpToolName); the modal's Test connection checks the form as it is, saved or not, outside the pool (testMcpDraft,testMcpServer,api/admin/mcps/test-mcp, a new stdio server's setup runs inmcp/0); a run that can't connect, or connects again after failing, updates itsstatustoo (recordMcpServerConnection); runs build tools from the cache (mcpHarnessTool) and connect on the first call (openMcpServer). stdio servers live in a per-process pool (src/harness/mcp/mcp-stdio-pool.ts), stopped afterAppData.McpStdioIdleMsidle, on save or delete.
- Custom tools (
- Replies and tasks:
run-bot-reply.tsanswers from the lastBotReplyHistoryMessagesmessages (botMessagesToHarness) withChatReplyNote, the bot's computers (compose-computers-note.ts: what they are and that they're reached throughstart_task) and the chat tools, at mostBotReplyMaxToolStepssteps, and finishes silently if the bot already had the last word (enqueueBotReplyreuses a pending reply, so a burst of messages gets one).run-bot-task.tsruns the brief (compose-job-brief.ts) withTaskRunNote, the bot tools and the job's computer tools, closing the MCP connection after. Retryable harness errors go back to the queue's backoff, the rest areBotJobFatalError. - API:
api/admin/harness/(save-provideralso syncs,sync-provider,delete-provider,save-settings), super admin only, each returning the freshgrabHarnessOverview()page data.
Admin Route Hierarchy
Live routes under /admin (see AdminSections): /admin/bots, /admin/workspaces (the index opens the first workspace), /admin/routines, /admin/logs, /admin/help and /admin/settings. /admin redirects to /admin/bots; a workspace's dashboard is /admin/workspaces/<id>. The routes in detail:
/admin/bots(live) - the Cobble chat shell, plus[bot_id]//admin/routines(live) - every schedule the user's bots have (see Schedules), opened from "Routines" in the rail's user popover/admin/logs(live) - Activity, the audit log: super admins only (others are redirected; the popover's "Activity" item shows to super admins only). Rendered inSettingsShellwithwide/admin/help(live) - familiar problems and their fixes, oneSettingsGroupper topic ((data)/help-topics.ts, markdown rendered with the chat'sMessageMarkdownComponents, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (HelpSignInTopicIdinsrc/dict/help-dict.ts;ask_user_to_take_overpoints bots to it)- Routines, Activity and Help are the
RailPages(src/dict/rail-pages-dict.ts), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside ((partials)/rail-pages-list.tsx): a back link to the bots, then those pages, like settings /admin/settings/backups- super admin only (listed inSettingsPages): create/list/download database snapshots indb/.backups, plusrestore/(restore a listed backup, or upload a SQLite database file which is saved to.backupsthen restored). Shells out to thebun-sqliteCLI (backup, andimport <file>sincerestoreis interactive) viasrc/functions/backend/backups/; API routes inapi/admin/backups//admin/settings(live) - redirects toSettingsDefaultPath(/admin/settings/general). Sub pages:general/(theme: System/Light/Dark per device viauseThemePreference, stored inlocalStorage[AppData.ThemeStorageKey], the same keyThemeInitScriptand twui'sColorSchemeSelectorread; applies instantly, no save bar),profile/(current user profile),notifications/(this device's Web Push switch, a test send, and every device the user subscribed),change-password/(set/change own password)models/(super admin only,super_admin_onlyin the dict hides it from the rail: model providers and which model each harness uses) andenvironment/(super admin only: app settings and the envs bots and computers use, per scope), each wrapped inSettingsShell((partials)/settings-shell.tsx: 64px pane header like the conversation header, then one centered column). Content is built fromSettingsGroupblocks (label + description left, controls right, hairline-separated, no cards) and ends with the stickySettingsSaveBar(Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state:useFormInitclearsstatuswhenever the form changes.bots/(the crew: chat, edit withBotForm, models, delete a worker viaapi/admin/bots/delete-bot),workspaces/(open, create through the Add flow, delete),computers/(state and resources, live; super admins create, edit withAddComputerForm, retry, delete),mcps/andtools/(see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access,extension-access-modal.tsx; add, edit, access and try open in modals (AddModalShell)), each row with a status line under it (extension-status-line.tsx: connected or not, last run worked or failed, when, and why), those five forcanManageCrewonly (grab-crew-settings-server-props.ts,can_viewin the dict) andusers/(forcanListUsers: super admins and admins; the dict'scan_viewgates it in the rail; add with a password (canCreateUser), edit (canUpdateUser), both with the oneUserForm(admin/settings/users/(partials)/user-form/,useFormInit, photo included, saved in one request), revoke (addsrevoked,canRevokeUser), delete (canRevokeUser's rules, never yourself), pick their user types fromUserTypes(user-types-select.tsx: only the onesfilterAssignableUserTypesallows, neverUneditableUserTypes; at least one, since login needs a type;saveUserchecks each grant and removal withuserTypeAssignment; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice,SaveUserRequestSchemachecks they match), delete with everything theirs;api/admin/users/save-user,delete-user). The lists shareSettingsList,SettingsListRow,SettingsRowActionanduse-settings-list. Sub pages are declared once insrc/dict/settings-pages-dict.ts; on every/admin/settings/*page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every /admin/* page renders in the bots layout (src/layouts/bots/); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow ((partials)/add-flow.tsx, AddFlowContext): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew ((partials)/rail-crew-list.tsx, needs bots and workspaces in page props): the coordinator, the latest AppData.RailRecentBots workers (plus any open, unread or needing you, pick-rail-bots), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On /admin/workspaces/* it shows the workspace's crew instead ((partials)/rail-workspace-list.tsx: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on /admin/settings/* where it shows "Back to bots" plus the settings sub pages ((partials)/rail-settings-list.tsx). Page heroes use the shared AdminHero component (src/components/general/admin-hero.tsx).
Page Directory Rules
For route-local code under src/pages, use non-routed meta folders:
(sections)- page sections(partials)- components used by sections(data)- page-local data(functions)- page-local functions(utils)- page-local utilities(hooks)- page-local hooks
Keep route files (e.g. index.tsx) thin - compose from route-local meta folders.
Dynamic routes use [param] syntax (e.g. [id], [user_type], [key]). Catch-all routes use [[...paths]].
Server functions use index.server.ts alongside index.tsx (e.g. single-document/[id]/index.server.ts).
Shared Code Organization
src/hooks/- Shared React hooks:use-admin-crud-get,use-fetch-get,use-form-init,use-local-user,use-users-get,use-server-event,use-live-botssrc/functions/- Shared functions split by target:src/functions/backend/- Server-only: auth (backend/auth/), DB queries (backend/db/incl.bots/), email client + templates (email/), media upload,logger.ts(audit logging)src/functions/frontend/- Client-safe: CRUD handlers, fetch handlers, media upload/delete handlerssrc/functions/server/- Server utilities (media server handler etc.)
src/utils/- Utility modules: cookies, crypt, ejson, money, safe-compare, text-to-html, and more. Most have corresponding.test.tsfiles.src/utils/rbac/- RBAC engine:user-type-assignment(matrix interpreter),is-pass-through-user,can-*permission helpers,filter-assignable-user-types- withbun:testsuites driven by fixture matricessrc/dict/- Dictionaries/constants: main links (main-links-dict.ts), user types + assignment matrices, media types, document statuses (document-dict.ts), websocket rooms, log categories, company datasrc/data/- Static data:site-data.ts(site name/slug - the only place brand literals live),app-data.ts(app constants - cookie names, limits, defaults, Paystack endpoint)src/types/- Type definitions:index.ts(incl. WebSocket event/room types),sql-joins.ts,paystack.ts,search-api.tssrc/scripts/- CLI scripts:seed-super-admin.tsx(idempotent super admin seed) +generate-brand-assets.ts(favicon/brand asset generator)llm-code-assistant/-query-db.ts(read-only SQL CLI for coding assistants)
Component Systems
| Directory | Description |
|---|---|
@moduletrace/twui |
The design system - external package. Contains layout primitives, elements, editors (TinyMCE), form components, hooks, SVGs, and utilities. Import from @moduletrace/twui or a deep path such as @moduletrace/twui/layout/Button |
src/components/general/ |
Shared page components built on twui: logo, logo-icon, wordmark, bot-stone (+ bot-stone-face, stone-eye-*), user-avatar (round photo or initials, `sm |
UI & Styling Rules (MANDATORY)
- Always build UI with the twui component library and the shared
src/components/general/components. Do not hand-roll HTML for layout, typography, or controls.- Layout:
Section,Container,Stack,Row,Center,Span,P,H1-H5,Button,Divider,Img,Link,ArrowedLink,Main,Header,Footer,Spacer - Elements:
Card,Paper,Tag,HeaderNav(+MainLinksdict),LinkList,Tabs,Table,Breadcrumbs,Modal,Dropdown,CheckBulletPoints,EmptyContent,Loading,LoadingBlock,Toggle,Toast - Forms:
Form,Input,Select,Textarea,Checkbox,Radios,SearchSelect,FileUpload,ImageUpload - Shared:
Logo,LogoIcon,InnerPageHeroSection,AdminHero,LoginShell(auth pages/forms),TaglineSection
- Layout:
- UNCHANGEABLE RULE - twui components always win over raw JSX elements. Use twui components (
H1-H5,P,Span,List,Img,Row,Stack,Container,Section,Card,Button,Tag,Link, etc.) before basic JSX elements (<h1>-<h6>,<p>,<span>,<ul>,<li>,<img>,<a>,<button>,<div>-as-card, etc.). Never introduce or reintroduce a raw JSX element where a twui equivalent exists. Override twui defaults viaclassNamerather than reaching for the raw element. - Never create new CSS classes, CSS files, or inline
style={{...}}hacks. Style exclusively by composing existing component props/classNames, existing twui classNames (twui-*), semantic theme-token utilities (bg-dark,bg-light,text-dark,text-primary,bg-primary,text-accent,bg-accent,text-secondary,border-slate-200, etc.), and utility classes already used elsewhere in this codebase. Build up from what exists; extend component code only when the existing API cannot express the need. - Theming: colors, radii, and fonts change only in
src/styles/main.css: Cobble tokens as CSS vars on:root(light) and.dark, exposed in the@theme inlineblock aspaper,surface,sunken,ink,ink-secondary,ink-disabled,hairline,hairline-strong,on-ink,hue-<name>, plusshadow-popoverand--m-fast/base/slowmotion vars (zeroed under reduced motion). Font is Lato (stand-in). Never hardcode hex colors, radii, or font stacks inside components. Hues are bot identity only, never state; usesrc/dict/bot-hue-classes-dict.tsfor hue classes. - Branding:
public/logos/logo.svgis the single source of truth for the logo mark;logo-icon.tsxrenders it inline (viaBotStone) andlogo.tsxrenders the stone beside the vectorWordmark. Whenever the logo changes, runbun run gen:faviconto regeneratepublic/favicon.ico,public/favicon.png,public/apple-touch-icon.png, andpublic/logos/logo.png(the last is used in emails). Font + favicon links live in__root.tsxHead(). - Site name: brand literals live only in
src/data/site-data.ts. Page meta titles, emails, and UI copy must interpolateSiteData["SiteName"]- never hardcode a brand name in page files.
Conventions
- Prefer one function/component per file over monolithic files with multiple exports.
- Modularization is non-negotiable: prioritize modularization over monolithic functions/components. Repeated UI blocks must be extracted into their own reusable components driven by props (e.g. an info row component taking
{ icon, text }), never repeated inline markup. - Every rendered array must have a separate component for each array item. Always extract the item JSX into its own component and render it via
items.map((item) => <ItemComponent {...item} />)- never inline the item's JSX inside the map callback. - No em dashes (U+2014) anywhere in the repo: code, comments, docs, markdown, or user-facing strings. Use a regular hyphen (
-) or restructure the sentence instead. - Always use object parameters (
{ foo, bar }) instead of comma-separated positional parameters. - Prefer existing patterns over new abstractions.
- Do not patch
@moduletrace/twui- it is a general, publishable library shared across projects. Do not edit the installed package innode_modules; compose or override withsrc/components/general/components andsrc/styles/main.cssinstead. .envis gitignored but exists locally with secrets. Do not commit it. It is also excluded by.dockerignore; production passes it withdocker run --env-file.- Rate limiting:
src/utils/rate-limit.ts(in-memory fixed window, per process). Unauthenticated endpoints that send email or check secrets (password-login,signup,contact) must be rate limited. - Validation: validate untrusted request bodies with the zod schemas in
src/schemas/viaparseWithSchema. ThrowAppErrorfor messages that are safe to show; catch blocks returnsafeErrorMessageso internal errors never reach the client. - HTML columns: any
html: truecolumn that accepts user input must be sanitized withsanitizeRichTextHtml(orsanitizeUserBioforusers.bio) before it is stored or rendered withdangerouslySetInnerHTML. - Outgoing email outside production goes through
resolveEmailRecipient(src/functions/backend/email/resolve-email-recipient.ts) and is redirected toDEV_EMAIL_RECIPIENT. Never hardcode recipient addresses. - Never trust ids from the request body for ownership or sessions. Use the session
user.id, and gate acting on other users with thesrc/utils/rbac/helpers (canUpdateUseretc.). secrets/directory is also gitignored.external/binary directories (**/models,**/bin,**/x-86,**/arm-64) are gitignored.