- Bots have a current computer (bots.computer_id), switched from the chat header beside the model button or by clicking a computer the bot names ([computer:ID]); tasks run on it by default - search_computers runs one read-only command on all of a bot's computers; chat replies know their computers (compose-computers-note) - The coordinator can use every computer and grants or revokes crew access with assign_computer - Delegated jobs record requested_by_bot_id; the worker's result or failure is reported back in the coordinator's conversation - bots_queue_updates records each model turn of a job, pushed live and shown in the working-on panel - Live relay: every process pushes new messages, job steps and bot changes from the database to its own sockets, so work done elsewhere shows live - Bot messages render as markdown (react-markdown + remark-gfm, no raw HTML) with twui components; rail previews are plain text - Fix header button overflow in the model and computer selects - Bump @moduletrace/twui to 0.1.2 Co-Authored-By: Claude Opus 5.5 <[email protected]>
41 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 cron()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 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 have workers) |
| 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/settings) and/authredirects toAdminHomePath(/admin/bots)./adminitself only redirects there. People, logs and backups exist in code but are closed until added back toAdminSectionsand the aside dict - 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. - 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 }) (src/cron/register-cron-job.ts), which logs before each run 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.
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)
- Tables:
bots(per user;kindworker|coordinator,hue,role,face,status_line,system_prompt(the bot's core instructions, markdown),unread),bot_messages,bot_tasks,bot_task_steps. Enums insrc/dict/bot-dict.ts. - Coordinator: every user has exactly one
kind: coordinatorbot that coordinates the crew and delegates to the workers. It is created with the account (signup,bun run seed) and, as a fallback, by/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 to its chat (/admin/bots/1on a fresh install);/admin/bots/[bot_id]renders conversation + working-on panel;/admin/bots/[bot_id]/modelsis a worker bot's own model settings (the coordinator's redirects to its chat). Both 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,mark-bot-read,insert-user-message).api/admin/bots/send-message.tsposts a user message to a bot owned by the session user (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 unread for a message that arrives in the open conversation;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 a reply runs (faceworking, statusBotReplyJobTitle) or waits for a model (BotWaitingStatusLine),BotThinkingRowsits under the last message. Live notices come from the process that ran the job at once, and from every other process through the live relay. - 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 separatememory(markdown, what it must always remember). The bot rewrites both as it adapts to the user (update-bot-system-prompt,update-bot-memoryinsrc/functions/backend/db/bots/; whole-text replace, capped atAppData.PromptMaxLength).grab-bot-promptbuilds the full prompt viacomposeBotPrompt(src/utils/compose-bot-prompt.ts): core instructions, then each non-archived workspace'ssystem_promptunder# Workspace: <name>, then# Memory. - 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 for the working-on panel 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),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 working-on panel shows the active task's latestAppData.BotJobUpdatesShownsteps (grabBotJobUpdates, onBotTaskWithSteps.updates) when the task has no plannedbot_task_steps, the newest as in progress. - 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 bot viacreateKeyLock, faceworkingonce it starts, outcome pushed live). 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; the working-on panel takes the pushed task. Every process also runsliveRelay(src/live-relay/): everyAppData.LiveRelayPollMsit reads newbot_messages, newbots_queue_updatesand 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 computer select in step across tabs. The rail previews a message as plain text (src/utils/message-preview.ts). - Workspaces:
workspaces(per user,name,description,system_promptshared by every bot in it,archived), with bots assigned throughworkspace_botsand computers throughworkspace_computers. Helpers insrc/functions/backend/db/workspaces/(create-workspace,update-workspace,add/remove-bot-*,add/remove-computer-*), all ownership-checked against the session user. - 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(per user,kindmain|sandbox,container_id) andbot_computers(direct access). Each user has one main computer, recorded with the account (signup,bun run seed) and as a fallback by/admin/botsviaensureMainComputer(src/functions/backend/db/computers/ensure-main-computer.ts, idempotent), which also records its container (AppData.MainComputerContainerName, created by the installer) and grants the coordinator access. 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) are its direct grants plus every computer assigned to a non-archived workspace it's in (the coordinator's are all of the user's computers), 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-computer-select.tsx, beside the model button) 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. - Not built yet: add-bot flow, routines, help, task steps (the working-on panel shows the task title; nothing writes
bot_task_stepsyet).
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_URLand 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).
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: a worker bot inherits the defaults per harness unless it has a
bot_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 model button (the text model it runs on) 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 button is read only 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), plus for the coordinatorassign_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(containermetadata.mcp_url, else theMAIN_COMPUTER_URLapp setting for the main computer, for a dev server outside docker, e.g.http://localhost:8170, elsehttp://<hostname>:<port>/mcp), leaves outComputerMcpExcludedTools, and sets the computer's envs (see Envs). - 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 and /admin/settings. /admin redirects to /admin/bots; there is no dashboard. The rest below are built but closed for now (redirect to /admin/bots):
/admin/people- admin users list, pluscreate/,[user_id]/(profile),[user_id]/edit//admin/bots(live) - the Cobble chat shell, plus[bot_id]//admin/logs- audit log (super admin; the "Activity" item in the bots popover is disabled while this is closed)/admin/backups- super admin only: 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),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. 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 shows the crew ((partials)/rail-crew-list.tsx, needs bots in page props), 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-*), inner-page-hero-section, main-hero-section, admin-hero, login-shell, tagline-section, media/image helpers |
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.