Files
platform/CLAUDE.md
T
brunorezioandClaude Opus 5 0e71d87f75 rewrite CLAUDE.md against the current codebase
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>
2026-07-25 23:30:20 +01:00

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 preinstall check)
  • Language: TypeScript, strict. bunx tsgo is 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 when PUBLIC_BUILD_ENV is explicitly dev/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 forwarded Host must equal PUBLIC_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.tsx for components, kebab-case.ts for 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 rationale
  • TODO.md — current direction and deferred work; takes precedence over this file where they disagree
  • src/apps/CLAUDE.md — shared frontend patterns
  • src/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.