First pass of the documentation audit. Every doc was read against what the code actually does now; this commit fixes the ones worth keeping and deletes the ones that were only describing a past. Corrected: - CLAUDE.md — said seven WebSocket providers (there are eight, and terminal/vault are byte relays now, not translating bridges), listed channels/ as "Telegram / WhatsApp / Discord bridges" (they are gone; what remains is how /chat drives an agent turn), missed officer-wallet in the PM2 list and notify/ in the layout, and described the per-account email SQLite stores without saying they are the sidecar's and that nothing in the platform opens them. Further Reading pointed at four files that no longer exist and missed the four newest. - docs/working-on-officer.md — PM2 list was four sidecars short, and it still explained the officer-claude rename as news. Replaced with the thing a reader actually needs: which process to restart for which change, and why restarting officer no longer costs you a terminal or an agent session. - TODO.md — the "dead username plumbing" item was mostly resolved by deleting the channels, and two email items pointed at api/email/email-db.ts, which is sidecar/email/store.ts now. - AGENTS.md — trailing paragraph listed the design notes being deleted here. - MUSIC_API.md — playlists were entirely undocumented: seven endpoints the phone app has no reference for. Added from the sidecar's own contract. - docs/jobs-unification.md — phases 1-3 shipped, so it now says so at the top. Phase 4 (push notifications) is the only reason the file still exists, and email sync is explicitly no longer part of it. Deleted, all superseded rather than merely old: - PHONE_APP.md — a February plan for apps that now exist, with their own repo and README. - MARKETING_WEBSITE.md — a plan for a site this repo does not contain. - SECURITY_AUDIT.md + SECURITY_FIXES.md — a February audit of a codebase since restructured; it still cites queue/handlers, which is now empty. - docs/DOCKERIZATION_PLAN.md — cites pty-sidecar, whatsapp and projects, all deleted. - SETUP_GUIDE.md — documents systemd units and setup scripts replaced by PM2 and `bun setup`. Not harmless: /etc/systemd/system/officer-pty-sidecar.service is still enabled on this host, pointing at a `monorepo/` directory that no longer exists, and has been failing to start ever since. That guide is how it got there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 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-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
│ ├── notify/ # outbound notifications (Discord webhook, env-configured)
│ ├── 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.
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: bun run start:sidecar / stop:sidecar / restart:sidecar / logs:sidecar.
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.
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.
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/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.