Deep reference for the patterns that the architecture rules in AGENTS.md point at. Read AGENTS.md first; this file is the long form.
The desktop app has two processes. Main is the system of record for business logic and host state. Renderer owns UI state via Zustand and renders the world the main process describes.
Main Process (Node.js) Renderer Process (React)
┌───────────────────────┐ ┌───────────────────────────┐
│ DI Container │ │ DI Container │
│ ├── GitService │ │ ├── TRPCClient │
│ └── ... │ │ └── narrow renderer svcs │
├───────────────────────┤ ├───────────────────────────┤
│ tRPC Routers │ ◄─tRPC(ipcLink)─► │ tRPC Clients │
│ (resolve services) │ │ ├── useTRPC() (hooks) │
├───────────────────────┤ │ └── trpcClient (vanilla) │
│ Services + I/O │ ├───────────────────────────┤
│ (fs, git, shell, │ │ Zustand Stores │
│ business logic) │ │ ├── pure UI state │
└───────────────────────┘ │ └── subscription caches │
├───────────────────────────┤
│ React UI │
└───────────────────────────┘
- Both processes use InversifyJS for DI with singleton scope
- Main holds all services. Renderer DI holds the tRPC client and narrow renderer services
- Zustand stores own all UI state (not in DI)
- Main services emit typed events. Renderer reacts via tRPC subscriptions wired once at boot
Both processes use InversifyJS with singleton scope. Services declare dependencies via constructor injection. No container.get(...) inside service methods.
Define a service:
// src/main/services/my-service/service.ts
import { injectable } from "inversify"
@injectable()
export class MyService {
doSomething() {
// ...
}
}Register the token and binding:
// src/main/di/tokens.ts
export const MAIN_TOKENS = Object.freeze({
MyService: Symbol.for("Main.MyService"),
})
// src/main/di/container.ts
container.bind<MyService>(MAIN_TOKENS.MyService).to(MyService)Inject dependencies via constructor:
import { inject, injectable } from "inversify"
import { MAIN_TOKENS } from "../di/tokens"
@injectable()
export class MyService {
constructor(
@inject(MAIN_TOKENS.OtherService)
private readonly otherService: OtherService,
) {}
}Test with mocks via constructor injection or container rebind:
// Direct instantiation
const mockOther = { getData: vi.fn().mockReturnValue("test") }
const service = new MyService(mockOther as OtherService)
// Or rebind in container for integration tests
container.snapshot()
container.rebind(MAIN_TOKENS.OtherService).toConstantValue(mockOther)
// ... run tests
container.restore()We use tRPC over Electron IPC via the workspace @posthog/electron-trpc package. All inputs and outputs are Zod schemas. Types are inferred from schemas, never declared separately.
Three tRPC exports, each for a different context:
| Export | Where to use | Purpose |
|---|---|---|
useTRPC() |
React components and hooks | Options proxy via React context |
trpc |
Outside React (module scope, services, stores) | Options proxy bound to the singleton queryClient |
trpcClient |
Anywhere (imperative calls) | Vanilla tRPC client for direct .query() / .mutate() / .subscribe() |
Create a router (main process). Routers are one-liners that delegate to a backing service:
// src/main/trpc/routers/my-router.ts
import { container } from "../../di/container"
import { MAIN_TOKENS } from "../../di/tokens"
import {
getDataInput,
getDataOutput,
updateDataInput,
} from "../../services/my-service/schemas"
import { router, publicProcedure } from "../trpc"
const getService = () => container.get<MyService>(MAIN_TOKENS.MyService)
export const myRouter = router({
getData: publicProcedure
.input(getDataInput)
.output(getDataOutput)
.query(({ input }) => getService().getData(input.id)),
updateData: publicProcedure
.input(updateDataInput)
.mutation(({ input }) => getService().updateData(input.id, input.value)),
})Register the router on the root:
// src/main/trpc/router.ts
import { myRouter } from "./routers/my-router"
export const trpcRouter = router({
my: myRouter,
// ...
})Use in React with TanStack Query:
import { useTRPC } from "@renderer/trpc/client"
import { useMutation, useQuery } from "@tanstack/react-query"
function MyComponent() {
const trpc = useTRPC()
const { data } = useQuery(trpc.my.getData.queryOptions({ id: "123" }))
const mutation = useMutation(
trpc.my.updateData.mutationOptions({
onSuccess: () => { /* ... */ },
}),
)
const handleUpdate = () => mutation.mutate({ id: "123", value: "new" })
}Cache invalidation uses pathFilter() or queryFilter():
const queryClient = useQueryClient()
// Invalidate all queries under a router path
queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter())
// Invalidate a specific query by input
queryClient.invalidateQueries(
trpc.git.getCurrentBranch.queryFilter({ directoryPath: repoPath }),
)
// Set cache data directly
queryClient.setQueryData(
trpc.git.getLatestCommit.queryKey({ directoryPath: repoPath }),
commitData,
)Outside React (stores, sagas, module-scope utilities):
// Imperative calls use trpcClient
import { trpcClient } from "@renderer/trpc/client"
const data = await trpcClient.my.getData.query({ id: "123" })
await trpcClient.my.updateData.mutate({ id: "123", value: "new" })
// Cache operations outside React use trpc (the module-level options proxy)
import { trpc } from "@renderer/trpc"
import { queryClient } from "@utils/queryClient"
queryClient.invalidateQueries(trpc.workspace.getAll.pathFilter())All UI state lives in the renderer. Domain state and host state live in main and are exposed via tRPC. Anything that survives a renderer reload, or that another client (mobile, web, CLI) would also need, lives in main.
// Bad - main service hoarding renderer-shaped state
@injectable()
class TaskService {
private currentTask: Task | null = null // belongs in renderer
}
// Good - main service is the system of record for task data
@injectable()
class TaskService {
async readTask(id: string): Promise<Task> { /* ... */ }
async writeTask(task: Task): Promise<void> { /* ... */ }
}
// Good - renderer state is pure UI selection
const useTaskUiStore = create<TaskUiState>((set) => ({
currentTaskId: null,
setCurrentTaskId: (id) => set({ currentTaskId: id }),
}))This keeps state predictable, easy to debug and naturally supports patterns like undo and rollback.
Main services live in src/main/services/<feature>/:
src/main/services/
└── my-service/
├── service.ts # The @injectable() service class
├── schemas.ts # Zod schemas + event constants for tRPC
└── types.ts # Internal types (not exposed via tRPC)
Zod schemas are the source of truth. Types are inferred from schemas, never declared separately.
// src/main/services/my-service/schemas.ts
import { z } from "zod"
export const getDataInput = z.object({ id: z.string() })
export const getDataOutput = z.object({
id: z.string(),
name: z.string(),
createdAt: z.string(),
})
export type GetDataInput = z.infer<typeof getDataInput>
export type GetDataOutput = z.infer<typeof getDataOutput>Services and routers import the schemas and inferred types from the same schemas.ts. The router validates at the boundary; the service consumes the inferred types.
For pushing real-time updates from main to renderer, services extend TypedEventEmitter and routers expose them as subscriptions.
Define event names and payload types in schemas.ts:
// src/main/services/my-service/schemas.ts
export const MyServiceEvent = {
ItemCreated: "item-created",
ItemDeleted: "item-deleted",
} as const
export interface MyServiceEvents {
[MyServiceEvent.ItemCreated]: { id: string; name: string }
[MyServiceEvent.ItemDeleted]: { id: string }
}Extend TypedEventEmitter in the service:
// src/main/services/my-service/service.ts
import { TypedEventEmitter } from "../../lib/typed-event-emitter"
import { MyServiceEvent, type MyServiceEvents } from "./schemas"
@injectable()
export class MyService extends TypedEventEmitter<MyServiceEvents> {
async createItem(name: string) {
const item = { id: "123", name }
this.emit(MyServiceEvent.ItemCreated, item) // typed
return item
}
}Expose as subscriptions via toIterable(). Global events broadcast to all subscribers:
function subscribe<K extends keyof MyServiceEvents>(event: K) {
return publicProcedure.subscription(async function* (opts) {
const service = getService()
for await (const data of service.toIterable(event, { signal: opts.signal })) {
yield data
}
})
}
export const myRouter = router({
// ... queries and mutations
onItemCreated: subscribe(MyServiceEvent.ItemCreated),
onItemDeleted: subscribe(MyServiceEvent.ItemDeleted),
})For per-instance events (shell sessions, workspaces, etc.), filter server-side rather than broadcasting:
export interface ShellEvents {
[ShellEvent.Data]: { sessionId: string; data: string }
[ShellEvent.Exit]: { sessionId: string; exitCode: number }
}
function subscribeFiltered<K extends keyof ShellEvents>(event: K) {
return publicProcedure
.input(sessionIdInput)
.subscription(async function* (opts) {
const service = getService()
const targetSessionId = opts.input.sessionId
for await (const data of service.toIterable(event, { signal: opts.signal })) {
if (data.sessionId === targetSessionId) yield data
}
})
}Subscribe in the renderer via the feature's subscription registrar, not in components:
// src/renderer/features/my-feature/subscriptions.ts
import { trpcClient } from "@renderer/trpc/client"
export function registerMyFeatureSubscriptions() {
trpcClient.my.onItemCreated.subscribe(undefined, {
onData: (item) => useMyStore.getState().handleItemCreated(item),
})
}Subscriptions are started once at app boot. Components do not start subscriptions ad hoc.
- Create the service in
src/main/services/<feature>/. Addschemas.tsfor Zod inputs, outputs and event types. - Add a DI token in
src/main/di/tokens.ts. - Register the service in
src/main/di/container.ts. - Create a tRPC router in
src/main/trpc/routers/<feature>.ts. Routers are one-liners that delegate to the service. - Mount the router on the root in
src/main/trpc/router.ts. - In the renderer, consume the procedures via
useQueryanduseMutation. If the feature pushes events, add a subscription registrar insrc/renderer/features/<feature>/subscriptions.tsand register it at boot.
MCP Apps let MCP servers ship interactive HTML UIs alongside their tools. When a tool has an associated ui:// resource, we render the app's HTML inside a sandboxed iframe instead of the raw tool input and output.
- Schemas live in
src/shared/types/mcp-apps.tsbecause both processes need them. McpAppsService(src/main/services/mcp-apps/service.ts) manages MCP server connections, caches resources (capped at 5MB per resource) and proxies calls between the renderer and remote servers.AgentServiceintercepts ACPsessionUpdatecallbacks formcp__tools and forwards inputs and results toMcpAppsService.- The renderer feature is
src/renderer/features/mcp-apps/.McpToolBlockalways rendersMcpToolViewand additionally rendersMcpAppHostwhen the tool has a UI resource and the server isn't disabled. - Apps run in a double-iframe sandbox. The outer iframe loads a generated proxy with
sandbox="allow-scripts allow-same-origin ..."and the inner iframe enforces a server-declared CSP meta tag. useAppBridgemanages the host side of@modelcontextprotocol/ext-apps. App requests route to tRPC mutations. Host context (theme, display mode, dimensions) flows back via the bridge.- Users can disable MCP Apps per server via
settingsStore.mcpAppsDisabledServers.
packages/agentTypeScript agent framework wrapping@anthropic-ai/claude-agent-sdk. Owns the ACP connection, worktree management, PostHog API integration, task execution and session management. The cloud agent server is exported via@posthog/agent/server.packages/gitPlatform-agnostic git saga operations (clone, branch, commit, push, stash, worktree, patch, publish), a read-write lock and a gh CLI client. Depends only on@posthog/sharedand@posthog/platform.packages/enricherAST-based PostHog flag call detection and source enrichment across languages. No workspace dependencies. Reusable from any host (Electron, mobile, CI, server).packages/platformInterface-only. Declares the host capabilities a service can depend on (ISecureStorage,IClipboard,IDialog,INotifier,IUpdater,IShell,IFileSystem, etc.). No implementations. Per-target adapters fulfill the interfaces. Electron adapters live inapps/code/src/main/platform-adapters/. Future React Native and web adapters will live in their respective apps. Domain packages and main services depend on these interfaces, never on Electron APIs directly.packages/electron-trpctRPC-over-Electron-IPC bridge.packages/sharedZero-dependency shared utilities (Saga pattern for atomic multi-step operations with automatic rollback, cloud-prompt encoding). Built with tsup, outputs ESM.apps/cliThin shell over the external@posthog/clinpm package. Command files handle argument parsing and output formatting only. No business logic. No data transformation. No tree building.