Architecture

Tau started from one design question: can Pi remain the agent runtime while a desktop shell becomes independently extensible, the way Neovim is?

Tau embeds the real @earendil-works/pi-coding-agent SDK in an Electron host. The renderer does not know Pi internals; it receives a small stream of host events. A separate desktop extension registry contributes sidebar modules, project sources, panels, commands, and tool presentation.

Core and kits#

Tau is two artifacts built from one repository.

Core (src/) is Pi in a window plus threads: the transcript, the composer, the thread index and one runtime per open thread, the command palette and the two layout slots, the extension lifecycle on both sides, and one versioned protocol between the window and the host. Core and kits is the full list, and npm run start:safe runs exactly it: a usable window with no sidebar, no Git, no diff review and no package manager.

@tau/kits (kits/) is the distribution on top: Workspace Kit (the thread rail, projects, Files, Changes, worktrees, checkpoints), Review Kit, Agents, Preview, Claude Code, Antigravity, Codex, Computer Use, Signals, Access, Keybindings, Packages, Pi UI, Questionnaires, Service Tier, Thread Titles and Worktree Names. Each is a package with its own tau-extension.json, permissions and build; npm run build compiles them into dist-kits/, which is what an installer ships and what Settings → Packages lists as bundled, headed by the version in kits/package.json.

Neither half reaches into the other. A kit sees core through tau, tau/host-extension and tau/host (the three modules any package gets), and core never imports a kit; it loads them from disk the way it loads what you installed yourself. Shipping a kit is the only thing that sets it apart: no permission prompt, and in-process isolation without being asked for (ADR 0014).

Ship your own. Nothing about @tau/kits is privileged. Assemble the packages you want, publish them to npm or Git, and a user installs them with /install and approves their permissions once (Writing a package); they run beside Tau's kits, or instead of the ones switched off in Settings. A build of your own is the other road: dist-kits/ is a plain folder of built packages, so a fork that replaces it ships a different product on the same core, and ADR 0015 draws the line between what core owns and what a distribution owns. Safe mode is neither: it is recovery, and loads no extension at all. The kits are Tau's opinion about what a coding workbench should have, not a floor you build on.

The window and the host#

Tau runs as two processes. The host owns the threads: Pi, the runtimes, the host halves of the kits, the session files. The window is a client of it (Electron, the workbench, the kits' desktop halves), and it is the one that starts and watches the host (ADR 0021).

On start the window reads <userData>/host.json. If the host it names is still alive and built from this version, the window connects to it and finds its threads where they were; otherwise it starts dist-electron/main/headless.js with Electron's own binary in Node mode, on a loopback socket with the token in ~/.tau/host-token, and records the new host.json. A host that crashes is restarted (at most three times a minute, then a dialog with the log path); <userData>/logs/host-out-*.log holds what it printed, host-process.log what it logged.

One data folder has one host. A host holds <userData>/host.lock for as long as it runs (an OS lock that goes with the process, however it ends), and a second host started on the same folder leaves with exit code 75, naming the first. A window that finds a host holding the folder without answering starts no second one beside it; after 30 seconds it says which process owns the folder. Pi sessions are guarded the same way across data folders: a thread another Tau host on this machine writes opens read-only (<session>.jsonl.lock), and deleting, restoring, purging or importing it is refused with the holder's pid and data folder. Tau reads a session another process writes without Pi's repairs on open. pi typed in Tau's terminal (zsh, bash, fish) loads Tau's lock extension (pi -e $TAU_PI_SESSION_LOCK_EXTENSION): that Pi holds the lock of the session it has open, so Tau shows the thread read-only while Pi has it, and Pi does not open a session a Tau host holds. A Pi started outside Tau knows nothing of the lock unless it is started with that flag; Tau does not change your Pi setup to add it.

What follows from that:

Hosts, machines and devices covers the host as a service, on another machine, and for a browser or a phone.

The extension seam#

Electron renderer                    Node host
┌──────────────────────────┐         ┌────────────────────────────┐
│ minimal workbench shell  │ events  │ Pi AgentSession SDK        │
│ + desktop extensions     │◄────────│ skills + Pi extensions     │
│ panels / commands / UI   │────────►│ tools / models / sessions  │
└──────────────────────────┘ commands└────────────────────────────┘

Desktop extensions implement one small interface:

interface DesktopExtension {
  id: string;
  name: string;
  activate(context: DesktopExtensionContext): void | (() => void);
}

The context accepts these contribution types:

context.registerPanel(...);
context.registerSidebar(...);
context.registerProjectSource(...);
context.registerCommand(...);
context.registerSlashCommand(...);   // `/name` in the composer, run in the workbench
context.registerKeybinding(...);     // "mod+k", "ctrl+shift+p", "escape" → a command id
context.registerPromptRenderer(...); // draws Pi dialogs it recognises, e.g. by a marker in `prompt.extras`
context.registerPromptHook(...);
context.registerToolRenderer(...);
context.registerOptions(...);
context.registerRegion(...);
context.registerStatusItem(...);
context.registerOverlay(...);
context.registerComposerControl(...);
context.registerTranscriptRows(...);
context.registerDocumentSource(...);

Every contribution is stamped with the extension that supplied it, which is what lets the palette, panel headers and settings page attribute behaviour back to its source. registerOptions is the whole of the settings surface: an extension declares toggles and chip rows, and Tau renders the page from that declaration. An extension with no options shows only its on/off switch.

See src/renderer/extension-system.tsx and the kits under kits/ (Core and kits lists what each one owns). The left sidebar and right dock are empty core slots. Workspace Kit contributes the thread and project sidebar, local-folder and Git-clone sources, and Files. Review Kit contributes Changes and the diff review. Other bundled kits contribute Signals, thread title generation, and the runtime commands.

Extending Tau while it runs#

Writing a package is the full reference. tau kit new my-kit writes a package to start from, with types for your editor; Your first package has the five steps from there to a package you use. The rest of this section is the overview, with links to the details.

Tau loads desktop extensions the way Pi loads its own. Put a .tsx (or .ts) file in ~/.tau/extensions/, or in <project>/.tau/extensions/ for a project Pi trusts, and save it. The file default-exports a DesktopExtension and may import react, lucide-react and tau (the workbench hooks and types); the host compiles it with esbuild and the renderer binds those imports to its own copies. examples/desktop-extensions/hello-panel.tsx is a complete example; tau kit types gives a folder the types Tau ships (Types for a package of your own).

An extension with a host half is a package: a folder under one of those two directories with a tau-extension.json manifest (The manifest).

A desktop half cannot reach the core IPC surface: window.tau is replaced with undefined while the bundle is built, and globalThis.__tauShared is the only bridge. The compiled bundle is served by the main process under tau-ext://bundles/<id>/<hash>.js and imported from there, which is why the page's CSP allows tau-ext: and no longer allows blob:. A host command that runs longer than 30 s, or fails three times in a row, deactivates the package. Every slot a package renders sits behind an error boundary that deactivates the package and shows a toast rather than taking the workbench down. See ADR 0009 for what this does not protect against.

Settings → Inspector shows every extension both halves know (desktop registry, host registry, commands, isolation, activation failures), the package folders on disk with their versions, engines, permissions, isolation and source provenance, and the three versions the check runs against. The desktop entry is loaded like a plain desktop extension. The host entry is compiled with esbuild (Node builtins and Electron stay external, everything else is bundled) and its default export, a HostExtension ({ id?, name?, activate(context) }) or a factory returning one, is activated in the package's worker, or in the main process for an approved in-process package, where context.services is the same facade the bundled kits use (ADR 0006). context.registerCommand and context.emit reach the desktop half through context.host either way. Packages are synced when Tau starts, when the project changes, when a watched file under one of them changes and on /reload, so installing, updating or removing a folder never needs a rebuild. A project's packages load only where Pi trusts the project; an activation failure is shown on the extension's settings page, not thrown. The settings toggle of a package turns both halves off and on.

Tau's own source can be changed from inside Tau too: Make a change.

This page is docs/architecture.md in Tau's repository.