The old version described a repo that no longer exists: apps/dashboard and apps/editor, a tracking server on port 5001, an ephemeral_db, and a dashboard and API running as separate processes. None of that is true. Replaces it with what the code actually does — one Bun process serving the SPA, the API and eight WebSocket providers; sidecars for the privileged work; the split between Postgres and the file-backed items store; and the single-user invariant stated as an invariant rather than a migration in progress. Also records two things that are easy to get wrong from reading alone: the database is maintained with db:push and has never had a migration applied, and agents run unsandboxed with permissions bypassed on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.7 KiB
CLAUDE.md
Project Overview
Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent, a terminal, a file browser, a code editor, email, chat channels, a remote desktop and customisable dashboards behind a single web app.
Single-user is a hard invariant, not a stage. There is exactly one account, created once by
POST /auth/bootstrap while the user table is empty. There are no roles, no invitations, no
sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change
seems to need "which user is this", the answer is always the owner.
Architecture
One Bun process (src/server.tsx) serves everything:
- the React SPA, via Bun's HTML import of
src/apps/officer-web/index.html(HMR in dev) - the REST API, a Hono app mounted at
/api(src/servers/hono.ts) - eight WebSocket providers — terminal, chat, task-runner, pipeline, dev-server proxy, cliamp, cliamp-audio, desktop — plus a sidecar registration socket
- a browser relay on its own port (
BROWSER_RELAY_PORT, default 18792)
Long-running and privileged work lives in sidecars: separate processes that dial back in over
/api/sidecar/register and are tracked in src/servers/sidecar-registry.ts. PM2 runs them
(ecosystem.config.cjs): officer (the server), officer-claude, officer-opencode,
officer-email, officer-pty, officer-vnc.
Agents run unsandboxed as the server owner, with --dangerously-skip-permissions. This is
deliberate — it is the owner's own machine. Do not add a jail without being asked.
Repository Layout
src/
├── server.tsx # the single entrypoint: routes, WS upgrades, static files
├── apps/
│ ├── officer-web/ # the SPA shell — Screens/Authentication + Screens/Dashboard
│ └── landing/ # marketing landing page
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, origin validation, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # Telegram / WhatsApp / Discord bridges
│ ├── queue/ # background job engine
│ └── sidecar/ # sidecar implementations + the wire protocol
├── databases/officer_db/ # the only database (Postgres + Drizzle)
├── extensions/browser-relay/
└── workspaces/ # shared packages, each a bun workspace
src/workspaces/officerdev is the biggest of these: the windowed "apps" (FileBrowser, Chat,
Terminal, CodeEditor, Desktop, Projects, Dashboards…) that the shell hosts, behind an AppRegistry.
src/apps/officer-web is only the shell — screens, routing and settings.
Path aliases (tsconfig.json): @/* → src/apps/officer-web, @/components/* →
src/workspaces/components, @@/* → src/servers, @/public/* → public. Workspace packages are
imported by their package name (officerdev, hooks, state, types, helpers, …).
Tech Stack
- Runtime: Bun (Node 22 is enforced by a
preinstallcheck) - Language: TypeScript, strict.
bunx tsgois clean — keep it that way. - Frontend: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- Backend: Hono
- Database: one Postgres database via Drizzle
- Agents: Claude Code and opencode, driven through sidecars; tools exposed over MCP
Data
Two stores, and the split matters:
Postgres (src/databases/officer_db) holds the account, passkeys, settings, dashboards,
projects, email accounts, queue and pipeline jobs. Schema in src/schema/, hand-written queries in
src/queries/, types inferred from the schema in src/types.ts.
The filesystem holds everything the agent authors. OFFICER_ITEMS_DIR contains one directory
per item under skills/, tools/, tasks/, processes/, extensions/ — no database rows, no
scope tiers. DATA_PATH/<email>/ holds the managed home, attachments and per-account email SQLite
stores. Path helpers live in src/servers/data-path.ts; note getHomeDir (the managed home under
DATA_PATH) versus getOwnerHomeDir (the owner's real login home when HOME_DIR is set, which is
where terminals, chats and task runs actually execute).
Schema changes use push, not migrations
This database is kept in sync with bun db:push. drizzle-kit migrate has never been run here
— there is no __drizzle_migrations table, and the files in officer_db/migrations/ are historical
residue that does not describe the live database. Change the schema, run bun db:push, done.
Security Model
The perimeter is one credential, so the guards matter:
IS_DEV_BUILD(src/servers/build-env.ts) is true only whenPUBLIC_BUILD_ENVis explicitlydev/development. Everything else, including unset, is hardened. Origin validation, rate limiting and password rules all key off it — they fail closed.- Allowed origins come from
PUBLIC_URL. Officer always sits behind an HTTPS reverse proxy, so the forwardedHostmust equalPUBLIC_URL's authority exactly. - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (
passwordChangedAt). - A panic lockdown (
src/servers/api/auth/panic.ts) is in-memory only and refuses every authenticated request until the server restarts.
Commands
bun dev # the whole app — SPA + API + WebSockets, watched
bun start # production
bunx tsgo # typecheck (not tsc)
bun test # tests
bun format # prettier over changed files only
bun db:push # apply the schema to Postgres
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
Sidecar control: bun run start:sidecar / stop:sidecar / restart:sidecar / logs:sidecar.
Note: build:editor* in package.json points at scripts/build/editor.ts, which does not exist.
Code Style
- Paradigm: functional — pure functions, immutability, composition
- TypeScript: strict, no
any. Type-only imports are required (verbatimModuleSyntax). - Comments: minimal, and about why. Don't narrate what the code already says.
- Async: always async/await
- Exports: named only, no defaults
- Files:
PascalCase.tsxfor components,kebab-case.tsfor everything else - Prettier: single quotes in JS, double in JSX, semicolons, trailing commas, 120 cols
Imports
Order: types → external → workspace → relative.
import type { User } from 'types';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { formatDate } from 'helpers/formatters';
import { useWebsites } from '../useWebsites';
In app code import types from 'types', which re-exports the database types. Only server and
database code imports from officerdb/types directly.
Functions
Arrow functions for one-liners, regular functions for anything with real logic. No multiline parameter lists — extract a params type instead.
type ProcessDataParams = { body: Record<string, unknown> | undefined; query: Record<string, string> };
export function processData({ body, query }: ProcessDataParams): Result { ... }
Components and hooks
type ExperimentCardProps = { experiment: Experiment; onSelect: (id: number) => void };
export const ExperimentCard = ({ experiment, onSelect }: ExperimentCardProps) => { ... };
Complex hooks return objects; simple state hooks return as const tuples. Event parameters are
always named ev, never e.
API calls
useClient() returns typed verbs — client.get<Foo>('/foo') really does return Foo, so pass the
type parameter and let inference flow from it.
Working With Me
- Ask first — confirm the approach before a significant change
- Explore thoroughly — read the related files before editing
- Keep it simple — no over-engineering, no premature abstraction
- Be explicit — no magic, no implicit behaviour
- Stay focused — note unrelated problems, don't fix them uninvited
- Report honestly — say what you verified and what you didn't
Commit messages: simple lowercase, no prefixes.
Further Reading
CONVENTIONS.md— component organisation, state management, React patterns, with rationaleTODO.md— current direction and deferred work; takes precedence over this file where they disagreesrc/apps/CLAUDE.md— shared frontend patternssrc/databases/CLAUDE.md— database patterns
The other markdown files in the repo root (OFFICERDEV_*.md, MARKETING_WEBSITE.md, PHONE_APP.md,
SECURITY_AUDIT.md, SETUP_*.md, …) are older design notes. Treat the code as the source of truth.