AGENTS.md is the source of truth for architecture and development rules. CLAUDE.md is a symlink to this file. Edit this file only.
PostHog Code uses a layered architecture. Business logic and UI live in shared packages/*. Each apps/* host boots those packages and binds host-specific implementations. @posthog/core and @posthog/ui must run unchanged on desktop, web, and mobile.
Principle: logic is portable; hosts are thin.
| Layer | Responsibility |
|---|---|
packages/core |
Host-agnostic business logic: orchestration, retries, dedupe, sagas, parsing, domain events, domain state. Inversify services only. No React, Node, Electron, or trpcClient. |
packages/workspace-server |
Node-only capabilities behind tRPC: git, fs, process spawn, pty, watchers. |
packages/ui |
React UI shell: views, components, hooks, view-state stores, route and command contributions. No business logic, Node, Electron, or trpcClient. |
apps/<host> |
Boot, lifecycle, platform adapters, DI wiring, host transports. No business logic. |
| Package | Owns | Must not contain |
|---|---|---|
@posthog/platform |
Host-capability interfaces and DI tokens. Host-neutral, zero runtime dependencies. | Implementations, Node, DOM, tRPC, Electron |
@posthog/shared |
Zero-dependency primitives, types, Saga pattern, cloud-prompt encoding. | Internal package imports, I/O |
@posthog/api-client |
PostHog/Django HTTPS client. Constructed by factory, not DI. | UI, Node-only host syscalls |
@posthog/workspace-client |
Thin tRPC client for local or sandbox workspace-server. Runs in any JS environment. | Business logic, UI |
@posthog/workspace-server |
Node backend services and colocated tRPC routers for git, fs, watchers, processes. | UI, core, Electron |
@posthog/core |
Portable Inversify services, domain schemas/types, domain stores (zustand/vanilla). Injects platform, workspace-client, api-client. |
React, trpcClient, Node syscalls, Electron, host-router runtime |
@posthog/ui |
React components, hooks, contributions, view-state stores. Built on @posthog/quill. |
Business logic, trpcClient, Node |
@posthog/host-trpc |
Shared initTRPC base with container-bearing context for Electron main routers. |
Feature logic |
@posthog/host-router |
Electron host tRPC routers that resolve services from request context and forward calls. Exposes HostRouter type and renderer useHostTRPC. |
Service implementations |
@posthog/di |
DI and boot primitives: CONTRIBUTION, boot(), ROOT_LOGGER, setRootContainer(), bindToContainer(), useService. |
Feature code |
@posthog/electron-trpc |
tRPC-over-Electron-IPC transport. | Feature code |
@posthog/git, @posthog/enricher, @posthog/agent |
Reusable domain implementation packages. | Host-specific code |
Hosts:
apps/code: Electron desktop host.apps/web: web host and portability smoke test.apps/mobile: React Native host.apps/cli: thin shell over@posthog/cli.
- Business logic lives in
@posthog/coreservices. Use@injectable()classes, constructor injection, and host-neutral dependencies. - Stores hold state only. No async flows, retries, dedupe, clients, cross-store orchestration, or business decisions.
- Domain state lives in
@posthog/corewithzustand/vanilla. View state lives in@posthog/uiwithzustand. - Node and host syscalls live in
@posthog/workspace-serveror a host adapter.corereaches workspace-server through an injected workspace-client slice. - Components render. Hooks wrap exactly one query, mutation, subscription, or store selector. Multi-source orchestration belongs in a service method.
- Cross-feature coordination uses a service or
Contributionemitting typed events. Stores do not reach into other stores. - Runtime boundary shapes use Zod schemas in
schemas.ts. Infer TypeScript types from schemas. - Host capabilities use
@posthog/platforminterfaces plus per-host adapters underapps/<host>. - Use constructor injection only. Do not use
container.get(...)orresolveService(...)inside service methods or components.resolveServiceis allowed only in host composition seams underapps/. - Boot side effects are
Contributions bound in feature modules and started byboot(). - tRPC routers are one-line forwards over services. No inline business logic.
- Use Inversify with
@inversifyjs/strongly-typed. Define each token as a standaloneexport const TOKEN = Symbol.for("posthog.<area>.<thing>")beside itsinterface/service — never an object-literal token bag (TOKENS = { X: Symbol.for(...) }), because object properties are notunique symboland cannot key a binding map. Every composition root declares aBindingMapinterface (token → bound type) and constructsnew TypedContainer<BindingMap>(), so a mistyped bind or a resolve of an unbound token fails at compile time. Bind in the feature module. Do not use@provideor*Portnaming. - Use
@posthog/quillfor rendering-layer primitives when available. Routing is TanStack Router contributed per feature.
Hard boundary: @posthog/core and @posthog/ui never import host transports. No trpcClient, electron, or node:*.
Enforced by Biome noRestrictedImports.
platformandsharedimport no internal packages.api-clientandworkspace-clientmay importsharedand relevantplatformcontracts. No UI or Node host syscalls.workspace-servermay importshared,platformcontracts, Node modules, and workspace-server code. Nevercoreorui.coremay importshared,platform,workspace-client,api-client, and other core code. Neverui,workspace-server,electron,node:*,trpcClient, or host-router runtime.uimay importcore,platform,shared,@posthog/quill, and UI feature public files. Neverworkspace-server,electron,node:*, ortrpcClient.apps/<host>may import any package and its own host adapters.
core is portable business logic. If code touches the host, it is not core yet.
| Host dependency | Correct home |
|---|---|
node:fs, node:path, node:child_process, process.* |
workspace-server, or an injected platform/environment interface |
node:crypto for ids, hashes, PKCE, random |
injected platform crypto/random interface |
node:events emitters or async iterators |
shared event abstraction, or keep source in workspace-server |
@posthog/enricher, git/file/AST repo scans |
workspace-server owns the scan; core owns result decisions |
process.platform, process.arch |
typed host-info interface supplied by host |
Split host-tangled algorithms: pure decision in core, host access in workspace-server or a platform adapter.
For each new file or meaningful change:
- Data source:
- Git, fs, process, pty, watchers:
workspace-serverprocedure, consumed by acoreservice through workspace-client. - PostHog cloud API:
coreservice/function using@posthog/api-client. - Client-local host capability:
@posthog/platforminterface plus per-host adapter.
- Git, fs, process, pty, watchers:
- Logic:
- Real orchestration, retries, rules, sagas, or decisions:
coreservice. - Trivial passthrough or streamed value: store plus host glue.
- Real orchestration, retries, rules, sagas, or decisions:
- State:
- Domain fact read by business logic: core store.
- Pure view state: UI store.
- Business logic in store actions.
- Domain stores in
@posthog/ui. trpcClientimports in@posthog/coreor@posthog/ui.- Service-locator calls inside services or components.
- Hooks that orchestrate multiple queries.
- Platform interfaces for backend data.
- Services for trivial passthroughs.
- Business logic in platform adapters.
- tRPC routers with inline logic or no backing service.
- Object-literal DI token bags (
TOKENS = { X: Symbol.for(...) }); use standalone token consts so aBindingMapcan key on them. - Untyped
new Container()at a composition root; usenew TypedContainer<BindingMap>(). - Bespoke clients that wrap
trpcClient.xone-to-one. *Port,*_PORT, orports.tsnaming.- Business logic in
apps/<host>.
apps/code contains Electron boot, lifecycle, platform adapters, and DI wiring only. scripts/check-host-boundaries.mjs checks host thinness against scripts/host-boundary-allowlist.json.
When moving logic out of apps/code, run:
node scripts/check-host-boundaries.mjs --pruneDo not use --init to baseline new violations.
apps/code/src/
|-- main/
| |-- index.ts # composition root
| |-- bootstrap.ts # boot sequence
| |-- window.ts, menu.ts, deep-links.ts, preload.ts
| |-- di/ # container and host tokens
| |-- services/ # host-resident services
| `-- platform-adapters/ # Electron adapters
`-- renderer/
|-- main.tsx # imports wiring, boots the app
|-- desktop-services.ts # renderer host adapter bindings
|-- desktop-contributions.ts # loads core/ui modules
|-- platform-adapters/ # renderer adapters wrapping host transport
|-- features/ # host glue only
`-- trpc/client.ts # renderer trpcClient for host glue
packages/core/src/<feature>/
|-- <feature>.ts
|-- <feature>.module.ts
|-- <feature>Store.ts
|-- identifiers.ts
|-- schemas.ts
`-- <feature>.test.ts
packages/host-router/src/routers/<feature>.router.ts
packages/ui/src/features/<feature>/
|-- <Feature>View.tsx
|-- <feature>.contribution.ts
|-- <feature>.module.ts
|-- store.ts
`-- use<Feature>.ts
- Tokens are standalone
export const TOKEN = Symbol.for("posthog.<area>.<thing>")consts, defined beside the interface in the owning package. Standalone consts inferunique symbol, which is what lets aBindingMapkey on them; object-literal token bags do not and are forbidden. - Each composition root (
apps/codemain + renderer,apps/web,packages/workspace-server) owns aBindingMapinterface mapping every token it binds to the bound type, and constructsnew TypedContainer<BindingMap>()(from@inversifyjs/strongly-typed).bind/get/isBoundare then checked against the map at compile time. - Services bind in feature
.module.tsfiles withContainerModule(typed viaTypedContainerModule<BindingMap>where the root is typed). - Hosts load modules in
desktop-contributions.tsor the equivalent web/mobile composition file. - Hosts bind platform implementations in
desktop-services.ts,main/index.ts, or host equivalents. - Hosts call
setRootContainer(container)before resolving services through React or host seams. - Plain modules that must register bindings before root initialization use
bindToContainer((container) => ...). CONTRIBUTIONstarts subscriptions, commands, routes, menus, and feature boot.- React uses
useService(TOKEN)at boundaries only.
setRootContainer(container);
import "./desktop-services";
import "./desktop-contributions";
await boot(container);pnpm install: install dependencies.pnpm dev: run agent watch and desktop app.pnpm build: build all packages.pnpm typecheck: typecheck all packages.pnpm lint: run Biome lint and autofix.pnpm format: run Biome format.pnpm test: run unit tests.pnpm test:e2e: run Playwright tests.pnpm --filter <pkg> typecheck|test|build: run a scoped task.pnpm --filter code package|make: package the Electron app.node scripts/check-host-boundaries.mjs: verify host boundary allowlist.
- Prefer local code over new dependencies for simple fixes.
- Keep functions focused.
- Use Biome, not ESLint or Prettier. Use 2-space indentation and double quotes.
- No
console.*in source. InjectROOT_LOGGERasRootLoggerand call.scope(name). Logger files are exempt. - TypeScript strict mode. Use explicit types where they clarify public contracts or nontrivial values.
- Use path aliases and package public exports. Avoid deep relative imports.
- No barrel files (
index.ts). - Use Tailwind first. Keep classes sorted. Use inline
styleonly for runtime values, library configuration, or CSS variables. - Abort controllers before awaiting cleanup that depends on them.
See docs/conventions.md.
- Use SDK types from
@anthropic-ai/claude-agent-sdkand@agentclientprotocol/sdk. - Do not use Claude Code SDK
rawInput. Use Zod-validated metadata. - User approvals are tool calls with permissions. Do not model approvals as custom methods plus notifications.
- React 19, Radix UI Themes, Tailwind CSS,
@posthog/quill - TanStack Query, TanStack Router
- Zustand, InversifyJS (with
@inversifyjs/strongly-typed), Zod - xterm.js, CodeMirror, Tiptap
- Sonner
- Unit tests: Vitest.
- E2E tests: Playwright.
- Test core/UI services and stores with faked injected dependencies and explicit props.
- Prefer a parameterised test shape (
it.each/test.each) when several cases exercise the same logic with different inputs and expectations. Keep separate tests when cases differ in setup, assertions, or intent. - Colocate tests as
.test.tsor.test.tsx. - Put E2E tests in
tests/e2e/. - After touching
@posthog/platform, rebuild or typecheck itsdist/. - After touching
packages/core, runbiome lint packages/coreand verify zeronoRestrictedImports.
See docs/testing.md.