This is the single source of truth for how PostHog Code is built. Architecture rules live here. Deep reference docs are linked at the bottom. If something contradicts this file, this file wins.
Read this section before writing or modifying code. These rules are load-bearing. The goal is a renderer that is strictly UI so the same app shape works on web and mobile, and a main process that owns every byte of business logic but stays host-agnostic so it can later run in a cloud sandbox or a workspace server, not just in Electron. PRs land fast from many contributors and many agents; these rules are what keep the foundation from rotting.
The principle: three layers, each with one job.
| Layer | One job |
|---|---|
| Main process services | All business logic and I/O. Orchestration, fetching, polling, parsing, auth, side effects, system telemetry. |
| Renderer Zustand stores | Pure UI state. Subscription-fed caches. Thin action wrappers over tRPC. Nothing else. |
| React components and hooks | Render the store. Wire user input to store actions or tRPC mutations. Local component state only. |
Renderer services are a narrow escape hatch. Only for renderer-only UI mechanics shared across components (visual queues, drag-and-drop, focus rings). Never for data fetching, never for cross-store coordination on system events, never for multi-step async orchestration.
- R1 Main services own business logic.
@injectable(), singleton, exposed via a tRPC router with Zod schemas in the service'sschemas.ts. No imports fromapps/code/src/renderer/*. - R2 Zustand stores are thin: UI state, subscription caches or queues. Actions do at most one
trpcClientcall plus one state update. No multi-step flows (OAuth dances, token refresh, server sync, retry loops), no module-levelletpromises, no cross-store reach-ins, no business clients, no query-cache surgery, no system-event analytics. - R3 Renderer services are a narrow escape hatch. They live in
apps/code/src/renderer/services/, are@injectable(), and never fetch data or coordinate cross-store reactions to system events. - R4 Components use
useQueryanduseMutation, not imperativetrpcClientcalls. Custom hooks wrap a single query or a store selector. Hooks that orchestrate multiple queries to derive a result become one tRPC procedure. - R5 Cross-feature coordination happens in main. Main emits an event; each affected store reacts via its feature's subscription registrar. Stores never reach into other stores.
- R6 Every tRPC procedure has Zod
inputand (where it returns data) Zodoutput. Types are inferred from schemas, never declared separately. - R7 Persistence and platform APIs are main, but main services never import from
electrondirectly. Host capabilities (clipboard, dialog, secure storage, file system, shell, notifier, updater) flow through@posthog/platforminterfaces with per-host adapters inapps/code/src/main/platform-adapters/. The renderer persists pure UI prefs viaelectronStorage. Domain data persists in the SQLite DB via aRepository. - R8 No
container.get(...)inside service methods. Constructor injection only. A circular dep means the boundary is wrong; split or invert via events. - R9 Subscriptions are wired once per feature in
apps/code/src/renderer/features/<feature>/subscriptions.ts, started at app boot. Components do not start subscriptions ad hoc. - R10 tRPC routers are one-liners. No inline business logic. No reaching past the service to a repository. No router without a backing service.
- R11 Templates use
@posthog/quillfor everything on the rendering layer that's available. Reach for raw primitives or one-off components only when Quill has no equivalent. - R12 Routing is TanStack Router. New screens register routes with TanStack Router; do not introduce a second router or hand-rolled routing logic.
Apply on every new file or meaningful change.
- Network call, file system, git, shell, multi-step async? Main service.
- Reusable across hosts (Electron, mobile, web, CI)? Domain package (
packages/*). - Wraps a host capability (clipboard, dialog, secure storage)? Platform adapter behind a
@posthog/platforminterface. - Purely about how the UI looks right now? Store if shared,
useStateif local to one subtree. - Single user event triggers a single mutation? Component with
useMutation. - Non-trivial renderer-only UI mechanic shared across features? Renderer service.
- None of the above? Probably a main service.
These shapes exist in the codebase today. Do not copy them. Do not extend them.
- Multi-step flows in stores. Whole auth flows (OAuth dance, token refresh, server-sync), retry loops, polling, anything with
let inFlightAuthSync: Promise | nullstyle dedup. All of it belongs in a main service. The store just reflects the service's state. - Cross-store reach-ins in actions.
useOtherStore.getState().something()inside a store action. Main emits an event; each store reacts in its registrar. - Business clients held in stores.
client: createClient(region, projectId)in a store. Construct in main, store holds a serializable id. - Stores owning subscriptions.
let globalSubscription = trpcClient.X.subscribe(...)at store module scope. Use a feature subscription registrar. - Stores owning timers for domain cleanup.
window.setTimeout(() => removeClone(id), 3000). The host owns the lifecycle and emits aRemovedevent. - Custom hooks that orchestrate multiple queries. Two
useQuerycalls plus auseMemomerge. Expose one tRPC procedure that returns the merged shape. - Imperative
trpcClientfrom components for routine reads.useEffect(() => trpcClient.X.query().then(setState)). UseuseQuery. - tRPC routers bypassing their service to call a repository.
workspace.tsdoes this today; do not extend the pattern. - tRPC routers with inline business logic. Math, time arithmetic, conditional branching inside
.mutation. Move to a service method. - tRPC routers with no backing service.
os.tsis 396 lines today with noOsService. New routers always have a service. container.get(X)inside a service method to dodge a circular dep.WorkspaceServicedoes this withFileWatcherService. Split or event-ize instead.- Renderer services that fetch domain data or coordinate tRPC. The 3,796-line
sessions/service/service.tsis the canonical example. Move it to main. - Platform adapters with business logic. Adapters wrap and translate. Decisions live in services that depend on the adapter via an interface.
- Importing from
electronin service code. Services depend on@posthog/platforminterfaces, not onapp,BrowserWindow,clipboard,dialog,shell,safeStorageetc. Otherwise the service can never run in a cloud sandbox or workspace-server context.
When in doubt, push logic toward main. The renderer is being thinned out, not thickened. Imagine a web or mobile build of this app reusing the same renderer code: every business decision living in a store or component is a thing that won't port.
Renderer stores own: pure UI state (open/closed, selected item, scroll position), cached data from subscriptions, message queues and event buffers, permission display state, thin action wrappers that call tRPC mutations.
Renderer services own (narrow escape hatch only): renderer-only UI mechanics shared across more than one component (visual action queues, global drag-and-drop coordinator, focus ring manager, debounced scroll broadcaster). Logic that is awkward to express in a component AND has no domain meaning.
Renderer services DO NOT own: cross-store coordination on system events (main emits, each store reacts via a subscription registrar), multi-step state machines that orchestrate tRPC calls (that is a main service), anything that fetches data or holds business state.
Main process services own: business logic and orchestration, polling loops, retries, dedup, batching, data fetching, parsing, transformation, long-lived host state (registries, watchers, OAuth flow state), cross-service coordination, emission of typed events.
When multiple stores need to react to one event (logout clearing auth + seats + settings + navigation), main emits the event and each store reacts in its feature's subscription registrar. Stores never reach into other stores.
- Monorepo with pnpm workspaces and turbo
apps/codePostHog Code Electron desktop app (React + Vite)apps/cliCLI app, thin shell over the external@posthog/clinpm packageapps/mobileReact Native mobile app (Expo)packages/agentTypeScript agent framework wrapping the Claude Agent SDKpackages/gitGit saga operations, gh CLI client, read-write lockspackages/enricherAST-level PostHog flag detection across multiple languagespackages/platformInterface-only declarations for host capabilities (fulfilled by per-target adapters inapps/code/src/main/platform-adapters/)packages/electron-trpctRPC-over-Electron-IPC bridgepackages/sharedZero-dependency shared utilities (Saga pattern, cloud-prompt encoding)
pnpm installInstall all dependenciespnpm devRun both agent (watch) and code app via phrocspnpm dev:mprocsRun both agent (watch) and code app via mprocspnpm dev:agentRun agent package in watch mode onlypnpm dev:codeRun code desktop app onlypnpm buildBuild all packages (turbo)pnpm typecheckType check all packagespnpm lintLint and auto-fix with biomepnpm formatFormat with biomepnpm testRun tests across all packages
pnpm --filter code testRun vitest testspnpm --filter code typecheckType check code apppnpm --filter code packagePackage electron apppnpm --filter code makeMake distributable
pnpm --filter agent buildBuild agent with tsuppnpm --filter agent devWatch mode buildpnpm --filter agent typecheckType check agent
pnpm --filter @posthog/shared buildBuild shared with tsuppnpm --filter @posthog/shared devWatch mode buildpnpm --filter @posthog/shared typecheckType check shared
- Prefer writing our own solution over adding external packages when the fix is simple
- Keep functions focused with single responsibility
- Biome for linting and formatting (not ESLint or Prettier)
- 2-space indentation, double quotes
- No
console.*in source. Use the logger instead (logger files exempt) - Path aliases required in renderer code, no relative imports:
@features/*,@components/*,@stores/*,@hooks/*,@utils/*,@renderer/*,@shared/*,@api/* - Main process path aliases:
@main/*,@api/*,@shared/* - TypeScript strict mode enabled
- Tailwind CSS classes should be sorted (biome
useSortedClassesrule) - No barrel files (
index.ts). Import directly from source - Tailwind first, inline
styleonly for dynamic values, library config, or CSS-var passthrough - Use the scoped logger (
logger.scope(...)) notconsole - Abort controllers fire before awaiting cleanup that depends on them (otherwise deadlock)
See docs/conventions.md for full examples of Tailwind rules, Zustand store shape, analytics event naming, and other code conventions.
- No rawInput: Don't use Claude Code SDK's
rawInput. Only use Zod validated meta fields. This keeps us agent agnostic and gives us a maintainable, extensible format for logs. - Use ACP SDK types: Don't roll your own types for things available in the ACP SDK. Import types directly from
@anthropic-ai/claude-agent-sdk. - Permissions via tool calls: If something requires user input or approval, implement it through a tool call with a permission instead of custom methods plus notifications. Avoid patterns like
_array/permission_request.
- React 19, Radix UI Themes, Tailwind CSS
- TanStack Query for data fetching, TanStack Router for routing
- xterm.js for terminal emulation
- CodeMirror for code editing
- Tiptap for rich text
- Zod for schema validation
- InversifyJS for dependency injection
- Sonner for toast notifications
pnpm testruns unit tests,pnpm test:e2eruns Playwright.- Unit tests (Vitest) for stores, utilities, service methods with mocked deps, business logic.
- E2E tests (Playwright) for critical user journeys, IPC, Electron-API-dependent features, regressions.
- Rule of thumb: if it can be tested without Electron running, use a unit test.
- Tests are colocated as
.test.ts/.test.tsx. E2E tests live intests/e2e/.
See docs/testing.md for store testing patterns, mocking patterns, and test helpers.
apps/code/src/
├── main/
│ ├── di/ # InversifyJS container + tokens
│ ├── services/ # Services own all business logic and I/O
│ ├── platform-adapters/ # Electron implementations of @posthog/platform interfaces
│ ├── trpc/
│ │ ├── router.ts # Root router combining all routers
│ │ └── routers/ # One router per service
│ └── lib/logger.ts
├── renderer/
│ ├── di/ # Renderer DI container (tRPC client + narrow renderer services)
│ ├── features/ # Feature modules (sessions, tasks, terminal, etc.)
│ │ └── <feature>/subscriptions.ts # Subscription registrars wired once at boot
│ ├── stores/ # Zustand stores (pure UI state + subscription caches)
│ ├── services/ # Narrow renderer services (UI mechanics only)
│ ├── hooks/ # Custom React hooks
│ ├── components/ # Shared components
│ ├── trpc/client.ts # tRPC client setup
│ └── utils/ # Utilities, logger, analytics, etc.
├── shared/ # Shared between main & renderer
│ ├── types.ts # Shared type definitions
│ └── constants.ts
├── api/ # PostHog API client
└── test/ # Test utilities
- Copy
.env.exampleto.env
- docs/architecture.md Electron process model, DI, IPC via tRPC, services, events, MCP apps, package roles.
- docs/conventions.md Tailwind rules, store/component patterns, async cleanup, logger, analytics events naming.
- docs/testing.md Test patterns, store testing, mocking, test helpers.