17 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, a bitcoin wallet, 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, cliamp, cliamp-audio, desktop,
vault — plus a sidecar registration socket.
terminalis a byte relay onto the pty sidecar's own listener, not a translating bridge;vaultis the same shape onto Vaultwarden's notifications hub. - 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-anthropic-proxy, officer-agent,
officer-opencode, officer-email, officer-pty, officer-vnc, officer-music, officer-vault,
officer-slskd, officer-headscale, officer-transmission, officer-invoiceshelf, officer-wallet,
officer-photos, officer-notify, officer-caldav, officer-memos, officer-jellyfin, officer-gitea
— twenty as of 2026-08-06, and a list that goes stale every time a sidecar lands. pm2 jlist is the
source of truth.
officer-anthropic-proxy and officer-agent are not the same thing. The proxy holds the Anthropic
credential and forwards API traffic; the agent is the process that spawns claude. They were one entry
named officer-claude until the sidecar-isolation work — which is exactly how the false claim that
"restarting officer doesn't disturb the agent" survived so long. Every sidecar is a PM2 peer of
officer, so no sidecar is a child of the server and restarting the server does not kill one.
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/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── 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, Dashboards, Wallet…) 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,
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 the per-account email SQLite
stores — those are the email sidecar's, and nothing in the platform opens them. 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, which diffs the schema code against the live
database and alters it directly. drizzle-kit migrate has never been run here — there is no
__drizzle_migrations table. Change the schema, run bun db:push, done.
bun db:gen writes files to officer_db/migrations/, but nothing applies them; the old numbered
history was deleted because it had drifted from the real schema. Treat the schema code, not those
files, as the source of truth.
Declare multi-column uniqueness as uniqueIndex('uq_…').on(a, b), never unique('uq_…').on(a, b) —
drizzle-kit mis-diffs named composite unique constraints and re-creates them on every push, which used
to stop db:push on an unanswerable truncate prompt. Same for any foreign key whose generated name would
exceed Postgres's 63-character identifier limit: name it explicitly. See src/databases/CLAUDE.md →
"Composite keys" before adding either.
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 every dirty file — see the note below before running it
bun db:push # apply the schema to Postgres
bun setup # guided install (writes .env, incl. PUBLIC_BUILD_ENV=production)
Sidecar control is PM2, not npm scripts: pm2 restart officer-<name>, pm2 logs officer-<name>.
See docs/working-on-officer.md for which process a given change needs restarted.
Installs are frozen. Never resolve a dependency you did not ask for.
bunfig.toml sets [install] frozenLockfile = true, so bun install resolves from bun.lock and
nothing else — it fails rather than quietly picking up a newer version, including a transitive one
nobody chose. Verified on bun 1.3.10: a lockfile that no longer satisfies package.json exits 1 with
error: lockfile had changes, but lockfile is frozen.
It is config rather than a habit because a supply-chain compromise does not wait for the one time
somebody forgets a flag. On 2026-08-04 eleven cache packages — keyv, flat-cache,
file-entry-cache, cacheable-request, cache-manager, the @cacheable/* scope, ecto — were
published with a preinstall dropper that harvested npm and GitHub tokens, AWS and Kubernetes
credentials, SSH and PEM keys, .env files and .claude/settings.json, then republished itself
through any npm token it found. It reached 434 further packages across 1,381 versions. This machine was
unaffected only because nothing had installed since 2026-08-02.
To change a dependency: edit package.json, run bun install --no-frozen-lockfile deliberately,
read the lockfile diff, and commit it. The friction is the point — an unexplained lockfile change
in a diff is the signal this exists to produce.
Do not add --no-frozen-lockfile to a script, a Dockerfile or CI to make an error go away. The
error means the lockfile and package.json disagree, and that is worth a human look every time.
Format your own files, not the whole dirty tree. bun format globs git diff --name-only HEAD,
so it rewrites every uncommitted file — including work in progress that isn't yours, which then shows
up as unexplained whitespace churn in someone else's diff. Run bunx prettier --write <paths> on the
files you actually touched. bun format is only safe when the tree is otherwise clean.
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.
Frontend route conventions (apply to EVERY new dashboard route)
Worked examples: /soulseek, /music, /chat.
- Workspace/Panel framework, always — never a standalone single screen. The screen renders
<WorkspaceView workspace={ws} locked />wherews = useDashboardState<LayoutNode>('screens/<name>', defaultLayout), guarded by anormalizeLayoutthat pinsappTypes to an allow-list. Panels are windowed apps undersrc/workspaces/officerdev/src/apps/<Feature>/, each exportingappRegistryMetas({ key, name, icon, component, availableOnPanel: false }) and registered inAppRegistry.tsx. The Workspace/Panel framework is orthogonal to routing — it contains no route navigation, and panels live inside the Route element tree, so they can calluseParams/useSearchParamsdirectly. - Page title by route. Add a rule to
RULESinsrc/apps/officer-web/state/usePageTitle.ts({ match: (p) => p.startsWith('/<name>'), title: '<Name>' }, most-specific first);usePageTitleSyncdoes the rest.startsWithmeans nested routes are covered.
The URL is the source of truth for selection
docs/navigation-audit.md is the authority here — read it before building a screen that selects
things. It names the "opaque click" anti-pattern: an element that opens something addressable but
keeps the id in an onClick closure instead of the DOM, leaves the URL unchanged, holds the selection
in usePanelChannel/useGlobal, and has no anchor semantics (no cmd-click, no middle-click, not
link-focusable). Half the app still does this; none of the new code should.
- Addressable state goes in the URL (
useParams/useSearchParams), never in a channel.usePanelChannelis for genuine signals and refresh buses (preview:refresh,files:refresh-signal,SLSKD_REFRESH_CHANNEL,MUSIC_RESYNC_CHANNEL,FILE_VIEWER_CHANNEL). "Which thing is open" is a URL. Panels each read the URL rather than passing it between themselves. - Rows and nav items are real links.
<Link>for rows (exemplar: the/chatsession list,f35c145); react-router's<NavLink>for nav chrome, so active state comes from the router. The hand-rolledisActiveinDock/Headeris scheduled for replacement (audit Phase 4) — don't copy it. A disabled entry renders as a<span>; a disabled<a>is not a thing. A control that mutates rather than navigates stays a<button>. - Route pairs. A bare screen route plus a param route rendering the same component:
/chat+/chat/:sessionId,/jobs+/jobs/:id,/email+/email/:emailId,/headscale+/headscale/:section. One<Navigate … replace />guard in the screen, placed after all hooks, canonicalises both the bare route and a bogus param. - Master list + live preview uses
?selected=<id>, not the detail route — linking rows straight to/x/:iddestroys preview-on-list, because that route is the full page. Rows link to/x?selected=idon-page and/x/:idoff-page; list, mobile panel and preview all read the param. Action buttons are siblings of the anchor, never nested inside it. src/workspaces/components/NavLink.tsxis not react-router'sNavLink— it's a<Link>wrapper that appendsuseGlobalQueryString()and provides no active state. Import react-router's when you wantisActive.- Route helpers shared between an
officerdevpanel app and anofficer-webscreen must be re-exported fromsrc/workspaces/officerdev/src/index.ts(named exports only — the barrel deliberately avoidsexport *for app modules to keepappRegistryMetasfrom colliding).
Further Reading
docs/navigation-audit.md— authoritative on routing/navigation: the opaque-click anti-pattern, a severity-ranked findings table, the channel-selection map and the four-phase plandocs/workspace-panels.md— how the Workspace/Panel framework works: the layout tree, how a panel is mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the persistence key families. Read before building a panel app. Its defect list isdocs/workspace-panel-todo.md.docs/sidecar-topology.md— where the sidecar architecture is going, and what was considered and droppeddocs/working-on-officer.md— how to run, restart and check your work on this machinedocs/wallet-key-custody.md— what the platform can and cannot see of the walletTODO.md— current direction and deferred work; takes precedence over this file where they disagreesrc/apps/CLAUDE.md— shared frontend patternssrc/databases/CLAUDE.md— database patternsCONVENTIONS.md— component organisation, state management and React patterns, with rationale;src/workspaces/officerdev/APP_CONVENTIONS.mdandHOOK_CONVENTIONS.mdfor panel apps and hooks
Treat the code as the source of truth where anything here disagrees with it.