Styx is an Electron app in a pnpm monorepo. One rule shapes almost everything: the main process owns all state and does all the work, and the window you see only shows a copy of it. Once you know that, and the two walkthroughs below, you can find your way around most of the code.
CLAUDE.md is the full set of conventions. It is written for coding agents and applies to people too. This page explains the shape behind it.
The monorepo
| Path | What lives there |
|---|---|
apps/desktop | The Electron app: src/main (Node, the source of truth), src/preload (the typed bridge), src/renderer (React 19) and e2e/ (Playwright). |
apps/website | This site: Next.js, exported as static files. |
packages/core | The domain: entities and zod schemas, the session and grant state machines, the policy engine, selectors, the IPC contract and every user-facing string (copy.ts). Pure TypeScript: no Electron, DOM, file system or network. |
packages/ui | The component library, with CSS Modules, design tokens and Storybook. |
packages/tokens | Colours, sizes and motion as tokens.json, generated into CSS and TS, plus the bundled fonts. |
packages/broker | The local broker protocol agents talk to, and styx mcp, the MCP server that exposes it as tools. |
packages/cli | The styx command and the provider shims (gh, vercel, aws, gcloud, supabase, ssh). |
Inside apps/desktop/src/main, container.ts builds every service once with its dependencies (clock, keychain vault, CLI runners, windows) passed in. Nothing is a singleton at import time, which is what lets tests build the same graph in memory.
Main is the source of truth
The main process holds the SQLite database, runs git, spawns agent CLIs, talks to the network and reads the keychain. The renderer never does any of that, not even in development, and never imports electron or node:*.
The renderer keeps a read-only mirror of the data in a Zustand store:
- On connect it asks for
store.snapshot, the whole read model. - After that, main sends batches of changes (deltas), each batch with a sequence number. The publisher in
store/publisher.tscollects changes for a short moment and sends them as one batch to every window. - The renderer applies them in order (
state/sync.ts, using the pureapplyDeltasinpackages/core/src/deltas.ts). If a sequence number is skipped, it throws its copy away and takes a fresh snapshot.
There are no optimistic updates to domain data. A screen sends a command and waits for the delta that comes back. View state (which tab is open, a draft in the composer) lives in a separate ui-store in the renderer.
Every change is a named command
The renderer changes nothing directly. It calls window.styx.command(name, input), and every command is declared once with zod schemas for its input and output in packages/core/src/ipc/contract.ts. There are no ad-hoc IPC channel names.
- The preload script (
src/preload/index.ts) exposes exactly one object,window.styx, typed byStyxApiinpackages/core/src/ipc/api.ts. - In main, the command bus (
ipc/bus.ts) checks the sender is a known Styx window and validates the input against the schema before any service runs. - Handlers live in
ipc/commands/<domain>.ts. They are thin: they call a service and return{ ok, value }or{ ok: false, error }. They never throw across IPC.
Business logic goes in packages/core (machines, policy, selectors) or in a main-process service, never in a React component or a handler.
State machines and policy are pure
Sessions and grants each have a state machine in packages/core/src/machines:
- A session is
idle,working,needs-you,pausedordone. - A grant is
requested,active,denied,revokedorexpired.
Each machine is a function, transition(state, event, ctx), that returns the next state plus a list of effects, or null for a transition that isn't allowed. Effects are plain data (append this audit row, notify, and so on) that main carries out. Time comes in as ctx.now; core never calls Date.now().
Access decisions work the same way. evaluatePolicies() in packages/core/src/policy takes the target, the requested scope, the session, the app's rules, the project's rules and any standing grants, and returns a decision: allow automatically, ask the person, or deny. Main applies the decision and writes it to the audit log.
Because these are pure, they are tested table by table with full coverage, and the rest of the app can trust them.
Agent runners
Each agent CLI is driven in the way that gives Styx the most structure. runnerFor() picks stream (Styx drives the CLI over pipes and renders a native chat) or pty (the CLI's own terminal UI in xterm). Stream sessions go through runner-mux.ts, which routes each one to a backend:
- Claude Code: newline-delimited stream-json, in
stream-runner.ts. - Codex: JSON-RPC over
codex app-server, inapp-server-runner.ts. Codex's terminal UI can't take messages through a pty, so it is never routed there. - Gemini CLI and Cursor's agent: the Agent Client Protocol, in
acp-runner.ts. Cursor falls back to its--printstream-json mode when ACP isn't available. - Shell: a pty.
Every backend turns its CLI's events into the same StreamEffects (transcript rows, tool steps, permission asks, usage, end of turn). The launch details for each CLI (arguments, environment, how the first message goes in) are one file per agent in src/main/agents/. The decisions behind this are ADR-0010, ADR-0016 and ADR-0017.
Lanes
A task (internally a lane) is one session on its own git worktree, on a branch named agent/<name>-<n> by default. Several services in src/main/services look after lanes:
worktree-servicecreates and removes worktrees.lane-sync-servicekeeps a lane current with its base branch.lane-ledger-servicetracks what every live lane is doing and which files it touched, so lanes can be told when they overlap (ADR-0025).land-serviceandmerge-resolve-servicebring a lane's work into the base branch and settle conflicts.publish-servicecommits, pushes and opens a pull request.checkpoint-servicerecords every agent turn as hidden git refs underrefs/styx/checkpoints/, so a turn can be viewed or undone.
Styx merges the base into a lane and never rebases, because a rebase would orphan the checkpoints.
The broker, styx mcp and the shims
Agents reach Styx through a local broker: newline-delimited JSON-RPC over a Unix socket (a named pipe on Windows), defined and zod-validated in packages/broker/src/protocol.ts. Main answers every call in broker/host.ts. There are two ways in:
styx mcp, an MCP server over stdio that each agent CLI is configured to start. It offers tools such asrequest_access,check_grant,get_credential,ask_user,report_statusandland.- Shims: small scripts named
gh,vercel,aws,gcloud,supabaseandssh, put first on the session'sPATH(shim-service.ts). Each runsstyx wrap <tool>(packages/cli/src/wrap.ts), which asks the broker for permission and then runs the real CLI.
Every session gets a random token when it starts. Main stores only its hash, and a broker connection is bound to one session after it says hello. The broker itself never holds secrets.
Storage and secrets
- SQLite through better-sqlite3 and Drizzle. The schema is
db/schema.ts, with numbered SQL migrations beside it indb/migrations/. Generated migrations get CHECK constraints and triggers added by hand (pnpm db:generate, then edit). - The audit log (
audit_entries) is append-only: database triggers refuse updates and deletes, and each row carries the hash of the one before it. Revoking a grant adds a row; it never edits one. Rows are only written throughAuditService.append. - Secrets live only in the OS keychain (macOS Keychain, Windows Credential Manager, the system keyring on Linux), through
@napi-rs/keyringincredential-vault.ts. Everything else refers to a secret by itscredentialRef. Secrets are never in SQLite, logs, IPC payloads, deltas, renderer state, fixtures or.styx/project.json. .styx/project.jsonis the committed, versioned project file, validated by zod inpackages/core/src/project-file.ts.
IDs are ULIDs with branded types (packages/core/src/ids.ts) and every timestamp is epoch milliseconds.
A message, from the composer to the agent and back
What happens when you type in a task's chat and press send:
- The renderer sends a command. The chat (
features/chat/ChatPane.tsx) callscommand('session.sendMessage', { sessionId, body, attachments })throughstate/commands.ts. A failed result becomes an error toast. - Main validates and dispatches. The bus checks the input against the
session.sendMessageschema and calls the handler inipc/commands/session.ts, which callsSessionService.sendMessage. - The session service decides what to do with it. In
session-service.ts: if the agent is mid-turn and can't take the message now, it is queued. Otherwise the text is stored as a transcript row (scrubbed of anything shaped like a secret), the checkpoint service records the worktree as it is before the turn, and the session is relaunched first if its process has stopped. - The message goes to the CLI. For a stream session it goes through the runner mux to the right backend, which writes it to the CLI in that protocol. For a pty session it is typed into the terminal.
- The agent's output comes back as effects. The backend parses what the CLI prints and emits
StreamEffects.SessionService.onStreamEffectturns them into transcript rows throughTranscriptService, and into session events (activity, and the end of the turn) that go through the core session machine. - Changes become deltas. As services write to SQLite, they tell the publisher what changed. The publisher sends a numbered batch to every window.
- The renderer shows it. The sync loop applies the batch to the mirrored store, and React re-renders the chat. When the turn ends, the session goes back to
idle, the checkpoint for the turn is settled, and the next queued message, if any, goes out.
An access request, from the agent to your approval and back
Say an agent in a task runs supabase db push against a production database:
- The shim catches the command.
supabaseon the session'sPATHis Styx's shim, which runsstyx wrap supabase db push.wrapconnects to the broker with the session's credentials and callsexec_authorizewith the tool, its arguments and the working directory. An agent can also ask directly through therequest_accessMCP tool. - Main works out what the command needs. In
broker/host.ts, the provider adapter for Supabase (src/main/providers/) classifies the arguments as scopes (read,write,deploy,delete;db pushiswrite) and picks the project's matching target. If a live grant already covers it, the command goes ahead. - Policy decides. Otherwise
GrantService.requestingrant-service.tsruns the pure policy engine. It writes arequestedgrant and an audit row, then either issues it automatically, denies it, or asks the person. A prod target whose adapter can't issue a narrowly scoped credential is always asked, never auto-approved. - The ask appears everywhere at once. Asking means a pending ask row, an access-request row in the chat transcript, and a session event that moves the session to
needs-you. Those writes become deltas, so the request shows up in the chat, on the Tasks board, in the Approvals inbox and as a notification. The broker holds the shim's call open while it waits. - You decide. The grant sheet (
features/grant-sheet/GrantSheet.tsx) sendsgrant.approve(orgrant.deny) with a duration and, optionally, a narrower scope. - Main verifies and issues.
GrantService.approveworks out again from the database whether this grant needs OS authentication; it never takes the renderer's word for it. Production write, deploy and delete ask for Touch ID, Windows Hello or the system password on Linux (mfa-service.ts). Then the provider adapter issues a short-lived credential, and the grant becomesactive, with an audit row for the decision. - The agent carries on. The broker host resolves the held
exec_authorizecall with the credential's environment.wrapruns the realsupabasewith it, reports the exit code when it finishes, and that use is audited too. The session leavesneeds-youand goes back to work.
Grants end when they expire, after an hour without use (the default idle limit), when the session ends, or when you revoke them. A revoke adds an audit row, and the broker tells the session the grant is gone.
Where to go next
- Add an agent and Add a deploy target are the two most self-contained ways to contribute, each with a worked example.
- Decisions (ADRs) explains why things are the way they are.