# Introduction (/docs) The Pragma desktop app Pragma is a desktop app for **running teams of coding agents**. Switch projects from the rail, launch any agent — Claude Code, Codex, opencode, Cursor, and more — and get told the moment it finishes. Every agent runs in an isolated git worktree, in a real terminal, so parallel work never collides. Pragma targets macOS, Linux, and Windows, plus iOS, iPadOS, and Android through the Pragma Go mobile client and the browser build served by the gateway. ## Where to start [#where-to-start] ## How the pieces fit [#how-the-pieces-fit] The **desktop app** owns the UI: the project rail, worktree tree, terminals, agent board, files, diffs, and pull requests. It talks to a persistent **host server** over a local Unix socket; the server owns PTY sessions, scrollback, agent status, and sidecars. A **gateway** exposes the same host over HTTP for the mobile client, the browser build, and the SDK. **pragma-cli** is installed into your terminals automatically so agents can operate the workspace they run in. Each documentation section covers one layer of that story — start with the [user guide](/docs/user-guide) and go deeper from there. # Automations (/docs/automations) Automations are small TypeScript tasks that run **on the host**, supervised by the Pragma server — not in your terminal, not on your phone. Wake a stale agent at 9am, sweep stale branches every Friday, react to a file appearing in a directory. ```ts import { defineAutomation } from "@pragma-sh/automations"; export default defineAutomation({ name: "Morning standup prep", description: "Summarize open worktrees into a scratchpad", trigger: { type: "cron", schedule: "0 9 * * 1-5" }, run: (ctx) => { ctx.log.info("collecting worktree status"); // ... }, }); ``` ## How they run [#how-they-run] * The server scans `~/.pragma/automations/` (global) and each registered project's `.pragma/automations/` (never worktrees), every 5 seconds. * Approved automations load into the **`pragma-automations` sidecar**, a Bun process the server supervises. Cron schedules are evaluated on a 20-second tick. * Everything is managed in **Settings → Automations** (global scope): the discovered list, trust approval, and **Run now**. * Automations are project-scoped for trust purposes but run with the project root as their `paths.project`, so one task can serve every worktree. Automations share a runtime with your project: keep them small and deterministic, use `ctx.log` over `console`, and treat `run` as fire-and-forget — there is no retry queue. # Lifecycle (/docs/automations/lifecycle) ## Where files live [#where-files-live] | Scope | Directory | Trust | | ------- | -------------------------------- | ------------------ | | Global | `~/.pragma/automations/` | Implicitly trusted | | Project | `/.pragma/automations/` | Requires approval | Files are discovered by scanning these directories — **never** worktree copies, which is why a project automation is approved once for the project, not once per worktree. A file's identity is the hash of its path, and its **content hash** decides whether a trust verdict still applies — edit an approved automation and it asks again. ## Trust [#trust] Every discovered automation is `trusted`, `approved`, `pending`, or `rejected`. New project automations land as **pending** and surface in the app as a one-time prompt (the `automationPending` event). Approve or reject in Settings → Automations. Rejections stick per content hash. ## Statuses [#statuses] | Status | Meaning | | ---------- | ------------------------------------------------------------ | | `pending` | Discovered, not yet trusted. | | `loaded` | Approved and loaded into the sidecar (event triggers armed). | | `running` | A `run` is executing. | | `idle` | Loaded, nothing executing (cron waits for its next tick). | | `rejected` | Trust denied. | | `error` | The sidecar reported a load or runtime error. | The list refreshes on the `automationsChanged` event; `lastRunAt` and `nextRunAt` come from the server's 20-second cron tick. ## Run now [#run-now] **Run now** in Settings → Automations (or the automations RPC `runNow`) invokes `run` immediately with no payload — the fastest way to test a new automation. ## RPC and events [#rpc-and-events] Automation management is the `automations` RPC domain, actions: `registerRoots`, `list`, `approve`, `reject`, `runNow`, `readSource`, `writeSource` (2 MiB source cap). Events: `automationPending` (one pending automation) and `automationsChanged` (full list). The Settings page is a client of exactly this surface. The `pragma-automations` sidecar speaks NDJSON over stdin (`load`, `unload`, `runNow`, `reload`) and emits (`ready`, `loaded`, `status`, `log`, `error`, `unloaded`). That contract is shared with `crates/pragma-server/src/automations.rs` — see [wiki → Server](/docs/wiki/server) for how sidecars are supervised. # Writing automations (/docs/automations/writing-automations) ## defineAutomation [#defineautomation] A file exports **one default** `defineAutomation` result: ```ts import { defineAutomation } from "@pragma-sh/automations"; export default defineAutomation({ name: "Stale branch sweep", // required description: "Comment on worktrees idle for 14 days", // required trigger: { type: "cron", schedule: "0 12 * * 5" }, run: async (ctx, payload) => { // payload is the trigger's payload, or undefined }, }); ``` Validation is strict — the sidecar rejects a file whose default export does not carry the `pragmaAutomation` marker, or whose `name`/`description` are empty, whose `trigger` is not `cron` or `event`, or whose `run` is not a function. ## Triggers [#triggers] ### cron [#cron] ```ts trigger: { type: "cron", schedule: "0 9 * * 1-5" } ``` Standard cron syntax, evaluated by the server on a 20-second tick. The computed `nextRunAt` shows in Settings → Automations. ### event [#event] ```ts trigger: { type: "event", listen: (ctx, fire) => { const timer = setInterval(() => fire({ reason: "tick" }), 60_000); return () => clearInterval(timer); // dispose — called on unload/shutdown }, }, run: (ctx, payload) => { /* ... */ }, ``` `listen` registers the trigger and returns an optional **dispose function**. Call `fire` whenever the event happens; each call invokes `run` with the payload. Disposals run when the automation is unloaded, reloaded, or when the sidecar shuts down (stdin EOF — the signal the supervisor uses even after an abrupt kill, so always clean up). ## The context [#the-context] ```ts interface AutomationContext { log: AutomationLogger; // info / warn / error — surfaced in the app paths: { project: string; // the automation's project root (or global dir) worktree: string; // same as project today global: boolean; // true for ~/.pragma automations }; fs: { find(path, { name?, minBytes? }): Promise; // bounded file search }; git: Record; // reserved for future use } ``` `log` lines appear in the automation's status in the app. `fs.find` skips generated directories (`node_modules`, `.git`, `.pragma`, `target`, …) and is bounded — use it to locate files without walking the whole tree yourself. ## Dependencies [#dependencies] Bare imports (anything that is not a builtin, a relative path, or `@pragma-sh/automations` itself) are installed automatically with `bun add` into a managed cache when the automation loads. Imports of `@pragma-sh/automations` are rewritten to the runtime shim — your file is loaded from a cache copy, and a source change produces a new cache version. Two practical rules: * Keep imports narrow; every bare import is installed at load time. * The source file is capped at 2 MiB; larger files are reported truncated. ## What automations can do [#what-automations-can-do] Anything the host can: shell out to `pragma-cli`, call the gateway with `@pragma-sh/sdk` (the terminal environment's `PRAGMA_*` variables are the sidecar's — read [Getting started](/docs/sdk/getting-started) for the client construction), or write files. An automation that needs user visibility should **publish a scratchpad**: ```sh pragma-cli scratchpad create --title "Stale branches" report.mdx ``` # Agents (/docs/cli/agents) ``` pragma-cli agent ``` This is the API agent hooks use to render status into the app. Every command reads the agent id from `--agent` and the worktree/tab context from the environment. ## start / status [#start--status] ```sh pragma-cli agent start --worktree --agent [--model ] [--prompt ] pragma-cli agent status [--worktree ] [--watch] ``` `start` launches an agent session **through the app** (brokered, so UI state stays consistent). It is not supported for plugin-defined agents — launch those from the Pragma UI so their model and argument builders run. `status` reads the current statuses directly from the server and works while the app is closed; `--watch` follows changes. ## report [#report] ```sh pragma-cli agent report --agent ``` | Subcommand | Effect | | ---------------------------- | --------------------------------------------------------------------- | | `started` | Yellow dot — running. | | `stopped` | Green dot — done. `--worktree-id` overrides the environment worktree. | | `attention` | Red dot — the user is needed (see below). | | `cleared` | Remove the indicator entirely (agent exited without a result). | | `session-name --name ` | Name the hosting tab (user renames win). | `attention` carries what the user must act on: * `--kind question --question "..." --options '[{"label":"Yes","description":"..."}]' --request-id ` — a question with tappable options (multi-question forms use `--questions` with a JSON array). * `--kind command --command "" --request-id ` — a command approval. Always pass a `--request-id` and block on the answer (below) — the same prompt renders in the desktop and on paired phones. ## ask and answer [#ask-and-answer] ```sh # agent side: publish, then block pragma-cli agent await-decision --agent --request-id [--timeout 300] pragma-cli agent await-answer --agent --request-id [--timeout 300] [--dismiss-output ] # publisher side: resolve someone else's request pragma-cli agent decide --agent --request-id (--allow | --deny) pragma-cli agent answer --agent --request-id (--text | --dismiss) # interjection (fire and forget) pragma-cli agent input --agent --text "" [--request-id ] ``` `await-decision` prints `allow`/`deny` and exits 0; on timeout it prints nothing and exits non-zero, so a harness hook can fall back to its native prompt. `await-answer` prints the reply text; `--dismiss-output` turns a dismissal into a normal exit with a value of your choosing. `agent message --agent (--payload '' | --stdin)` publishes a rich `AgentMessage`. ## verify [#verify] ```sh pragma-cli agent verify --agent [--scenario ] [--jobs ] [--headed] [--model | --pick-model-cmd ""] [--attempts ] [--fail-fast] ``` A conformance suite that launches your agent and exercises the whole reporting surface — reply, session naming, command approvals (allowed and denied), question forms, message submit, subagents, aborts, interrupts, usage limits, and stream integrity. Scenarios are skipped automatically when the agent's catalog entry declares `excludeFeatures`. Pass `--headed` to watch, `--prompts ` to override scenario prompts, `--jobs` (max 16) to parallelize. Verify is the last gate of a new agent plugin — see [Plugins → Agents](/docs/plugins/agents) and the shipped integrations in `packages/*-plugin` for the full pattern. # Fanout (/docs/cli/fanout) ``` pragma-cli fanout ``` Fanout commands talk directly to the host server, so they work even while the desktop is closed. See [Creating a worktree → Fan out](/docs/user-guide/worktrees#fan-out) for the UI side and [SDK → Fanouts](/docs/sdk/fanouts) for the programmatic API. ## create [#create] ```sh pragma-cli fanout create [PROMPT] \ [--prompt-file | --new-parent [--parent-title ] [--from ]] \ --agent ... [--reasoning ] [--jobs ] [--idempotency-key ] ``` * `--agent` is repeatable; each value is a selector of the form `agent[.model[.reasoning]]` (`--reasoning` is a shorthand for the last part). Duplicates are allowed — race the same agent against itself. * At least **two** attempts are required. * With `--new-parent `, a fresh coordination parent is branched (`--from` picks the base worktree). `--parent ` hosts the fanout on an existing worktree. * A dirty parent is refused; the base commit is captured once so every attempt's diff is comparable. * Partial provisioning exits non-zero **and** prints the persisted members, so a script can retry the failed ones. The created attempt sessions get `PRAGMA_FANOUT_ID` and `PRAGMA_FANOUT_MEMBER_ID` in their environment — an attempt agent knows it is racing. ## show / read [#show--read] ```sh pragma-cli fanout show [] [--watch] pragma-cli fanout read [] [--member |--all] [--lines N] ``` `show --watch` follows the fanout's status and every member. `read` prints attempt terminal scrollback (default 200 lines) — plain text, ANSI stripped. ## send [#send] ```sh pragma-cli fanout send [] [--member |--all] \ (--message | --message-file ] [--no-wait] ``` Delivers a follow-up message to members' agents (through each member's watcher, which types it into the live TUI). Waits for delivery unless `--no-wait`; the same `--message-id` re-delivers idempotently. ## retry / cancel [#retry--cancel] ```sh pragma-cli fanout retry [] [--member ] pragma-cli fanout cancel [] ``` `retry` relaunches a member's agent in its existing worktree (work is kept). `cancel` stops the fanout but keeps every checkout. ## pick — destructive [#pick--destructive] ```sh pragma-cli fanout pick [] --member [--yes] ``` Merges the chosen attempt into the parent, promotes its scratchpads, stops the sessions, and deletes every attempt checkout — the winner included. Without `--yes` the command prints every worktree and branch it will delete and requires a typed `yes`. A merge conflict parks the fanout with everything intact; a partial cleanup reports the survivors instead of pretending it completed. ## Ids and defaults [#ids-and-defaults] There is deliberately **no `fanout list`**. Omitted ids resolve from the environment: `PRAGMA_FANOUT_ID`, or the fanout owning `PRAGMA_WORKTREE_ID` (as parent or attempt). `--member` defaults to `PRAGMA_FANOUT_MEMBER_ID` — inside an attempt, bare `fanout pick --member` targets yourself. # pragma-cli (/docs/cli) `pragma-cli` is the control surface agents (and you) use from inside a Pragma terminal. It is installed and kept current by the app itself: production builds put it in `~/.local/bin/pragma-cli` and Pragma terminals prepend that directory to `PATH` and set `PRAGMA_CLI`. There is nothing to install. ```sh pragma-cli fanout create "Add token refresh" \ --agent claude-code --agent codex --agent opencode ``` ## How it talks to Pragma [#how-it-talks-to-pragma] * Most commands connect to the host server over its local Unix socket (`PRAGMA_SERVER_SOCKET`), verify the protocol version, and exit. If Pragma is not running you get a distinct, scriptable error: "Pragma is not running. Launch the app first." * Commands that default to the current context read the environment Pragma exports: `PRAGMA_WORKTREE_ID`, `PRAGMA_TAB_ID`, `PRAGMA_FANOUT_ID`, `PRAGMA_FANOUT_MEMBER_ID`. * Auth on the socket is filesystem permissions — the socket is owner-only, so anything running as your user may speak to it and nothing else can. ## Output formats [#output-formats] | Flag | Effect | | -------- | --------------------------------------------------------- | | *(none)* | Human output — aligned tables, short lines. | | `--json` | One JSON value per command. | | `--toon` | TOON (token-oriented notation) — compact, agent-friendly. | Both structured flags serialize the same data; they are global and mutually exclusive. Scripts and agents should prefer `--json` or `--toon`. ## Command groups [#command-groups] The CLI is one-shot: connect, act, exit. For anything long-running — watchers, dashboards, integrations — use [@pragma-sh/sdk](/docs/sdk), which speaks HTTP to the gateway instead. # Scratchpad (/docs/cli/scratchpad) ```sh pragma-cli scratchpad create --title <MDX_FILE|-> ``` The only command in the group, and the **only supported way to create a scratchpad**. It: 1. Reads MDX from the file, or from stdin with `-`. 2. Writes a managed document under the current worktree's `.pragma/scratchpads/`, creating the managed frontmatter (id, title, created timestamp, and the agent tab attachment from `PRAGMA_TAB_ID`). 3. Opens a scratchpad tab in the app so the user sees it immediately. ```sh # publish a report the user can read, edit, and comment on pragma-cli scratchpad create --title "Refactor plan" plan.mdx # stream a document from another command generate-notes | pragma-cli scratchpad create --title "Release notes" - ``` <Callout title="Why not write the file directly?"> A scratchpad is a contract, not a text file: the managed frontmatter is what lets the app list it, attach an agent, promote it in a fanout pick, and route comment threads. Files written by hand are ignored by design. </Callout> Requirements: the current tab must be a registered agent tab and the app must be connected. Reading and commenting on scratchpads programmatically is the [SDK's](/docs/sdk/scratchpads) job — see [@pragma-sh/scratchpad-contract](/docs/sdk/scratchpads#the-contract-underneath) for the file format. # Tabs, splits & browser (/docs/cli/tabs) ## tab [#tab] ``` pragma-cli tab <command> ``` | Command | What it does | | ------------------------------------------------------------------------- | ------------------------------------------------------------ | | `tab list [--worktree <ID>] [--all]` | List the worktree's tabs. | | `tab read <tab> [--lines N] [--offset N] [--bytes N] [--plain] [--watch]` | Read scrollback. | | `tab open [--worktree <ID>] --kind <kind> [...targets] [--title <T>]` | Open a new tab. | | `tab close <tab>` | Close a tab. | | `tab rename <tab> <title>` | Rename a tab. | | `tab exec [--worktree <ID>] -- <command...>` | Run a command **without** a tab; capture stdout/stderr/exit. | `tab open --kind` accepts `terminal`, `browser`, `editor`, `diff`, `log`, `pr-review`, with `--command`, `--url`, `--file`, and `--diff-side <committed|staged|unstaged| worktree>` filling in the target. `tab read` streams the session's scrollback: `--lines` (default 1000) and `--bytes` cap the read, `--plain` strips ANSI styling, and `--watch` follows the output live. Reads talk to the server directly, so they work **while the app is closed**. `tab exec` is the scripting escape hatch — run a command in the worktree's environment and get structured output back, no terminal created. ## split [#split] ``` pragma-cli split <command> ``` | Command | What it does | | ------------------------------------------------------------------------------------- | --------------------------------------------- | | `split set --worktree <ID> <JSON\|->` | Set a recursive split layout (JSON or stdin). | | `split add-tab --worktree <ID> --side <left\|right\|top\|bottom> --kind <kind> [...]` | Add a tab into a split side. | | `split clear --worktree <ID>` | Reset to a single pane. | Splits persist per worktree, so a script can stage a worktree's whole layout — dev server left, browser right — before you even look at it. ## browser [#browser] ``` pragma-cli browser <command> ``` Drive a browser tab: | Command | What it does | | ----------------------------------------------------------- | ------------------------------------------- | | `browser navigate <tab> <url>` | Go to a URL. | | `browser back <tab>` / `forward <tab>` / `reload <tab>` | History. | | `browser scroll <tab> [--x N] [--y N] [--to top\|bottom]` | Scroll the page. | | `browser focus <tab> <selector>` / `click <tab> <selector>` | Interact by CSS selector. | | `browser screenshot <tab> [--out FILE]` | Capture the pane. | | `browser exec <tab> <js\|->` | Evaluate JavaScript (`-` reads from stdin). | | `browser close <tab>` | Close the tab. | Together with `tab read`, this is enough for an agent to verify its own web work: make a change, reload, screenshot, read the console-visible result. # Whiteboard (/docs/cli/whiteboard) ```sh pragma-cli whiteboard create [--title <TITLE>] <SCENE|-> pragma-cli whiteboard list pragma-cli whiteboard search <QUERY> pragma-cli whiteboard get <ID> pragma-cli whiteboard edit <ID> --title <TITLE> <SCENE|-> pragma-cli whiteboard view <ID> <PNG_PATH> pragma-cli whiteboard delete <ID> [--yes] ``` Whiteboards are durable Excalidraw scenes the host owns, scoped to one worktree — see [Whiteboards](/docs/user-guide/whiteboards) for the user-facing tour. Every command talks directly to `pragma-server`, so it works whether or not the desktop window is open. The worktree defaults to `$PRAGMA_WORKTREE_ID`; pass `--worktree <id>` to act elsewhere. | Command | Notes | | -------- | --------------------------------------------------------------------------------- | | `create` | Reads scene JSON from a file or stdin (`-`). Prints the durable id and version. | | `list` | Every board in the worktree. | | `search` | Case-insensitive match against titles **and** the scene's text elements. | | `get` | One board, including its full scene JSON. | | `edit` | Replaces title and scene; the stored version must still be the one you read. | | `view` | Writes a natively rendered PNG to the given path — no browser or canvas involved. | | `delete` | Prompts for confirmation unless `--yes` is passed. | ## Scenes [#scenes] A scene is complete Excalidraw JSON: `type: "excalidraw"`, a `version`, `elements`, `appState`, and `files`. It is stored losslessly, so **preserve unknown fields** when you revise a board — read it with `get`, change what you mean to change, and write it back: ```sh pragma-cli --json whiteboard get wb_123 > scene.json # edit scene.json pragma-cli --json whiteboard edit wb_123 --title "Request flow v2" scene.json ``` `edit` supplies the version it just read, so a concurrent writer makes the write fail rather than clobbering their work. On a conflict, re-read, reconcile, and retry. <Callout title="Embedding in a scratchpad"> Create the board first, then put its id in the MDX as `<Whiteboard id="…" />` — the component keeps the live board on screen. Don't paste scene JSON into a scratchpad and don't substitute a rendered PNG. See [Scratchpad](/docs/cli/scratchpad). </Callout> For long-lived code, use the SDK's [`client.whiteboards`](/docs/sdk/whiteboards) instead of shelling out. # Worktrees (/docs/cli/worktrees) ``` pragma-cli worktree <command> ``` ## list [#list] ```sh pragma-cli worktree list [--worktree <ID>] ``` Lists the worktrees of the project owning the given (or current) worktree. The main worktree row comes first; each row carries its id, title, branch, and `is_main` flag. ## create [#create] ```sh pragma-cli worktree create [--parent <ID>] --branch <BRANCH> [--title <TITLE>] ``` Creates a child worktree under `--parent` (default: the current worktree, from `PRAGMA_WORKTREE_ID`). The app performs the `git worktree add`, registers the row, and runs the project's `setup` scripts — the same pipeline as the UI dialog. There is no prompt parameter: launching an agent session is a separate step (`agent start` or the UI), so a script can create the worktree and decide on sessions later. ## rename [#rename] ```sh pragma-cli worktree rename <id> <title> ``` Sets the display title (empty string clears it back to the branch name). ## hide / unhide [#hide--unhide] ```sh pragma-cli worktree hide <id> pragma-cli worktree unhide <id> ``` Hidden worktrees stay registered but disappear from the sidebar tree. ## delete [#delete] ```sh pragma-cli worktree delete <id> [--delete-branch] [--force] ``` Refuses when the worktree has uncommitted changes unless `--force` is given. Project `teardown` scripts run first and a failure blocks the deletion. `--delete-branch` also removes the branch. Confirm before scripting this — it deletes the checkout. # Agents & watchers (/docs/plugins/agents) Registering an agent makes it launchable from Pragma's new-tab menu, agent menus, the board, fanouts, and phones — with your icon, your models, and status reporting. ## defineAgent [#defineagent] ```tsx import { definePlugin, defineAgent } from "@pragma-sh/plugin"; const myAgent = defineAgent({ id: "my-agent", name: "My Agent", icon: AgentIcon, iconPath: "./assets/agent.svg", // optional: browser URL, absolute path, or plugin-relative launch: { command: ["my-agent", "chat"] }, models: async (ctx) => [{ id: "m-1", name: "Model 1" }], permissionModes: [{ id: "default", name: "Default" }], args: { model: (id) => ["--model", id], reasoning: (id) => ["--reasoning", id], permissionMode: (id) => ["--permission", id], }, startupInput: [{ delayMs: 500, data: "/init\r" }], prefillMode: "bracketed", // or "plain" prefillSubmit: "\r", prefillDelayMs: 200, excludeFeatures: ["usageLimits"], // skip matching `agent verify` scenarios }); ``` The `args` builders translate the user's selections (model, reasoning, permission mode) into launch arguments. `startupInput` writes keystrokes after the TUI starts; prompt **prefill** sends the user's typed prompt and its submit key as two PTY writes (bracketed mode waits for the terminal's alt screen, bounded by a generous timeout, so agents that redraw themselves don't eat the prompt). ## Watchers — status and round trips [#watchers--status-and-round-trips] A watcher attaches to a launched session, reads its output, and reports status through the [SDK](/docs/sdk/agents): ```tsx import { definePlugin, defineWatcher, reportStarted, reportAttention, reportStopped } from "@pragma-sh/plugin"; watchers: [ defineWatcher({ agent: "my-agent", watch: async (ctx) => { await reportStarted({ agent: ctx.agentId, client: ctx.sdk }); for await (const chunk of ctx.output) { if (/awaiting your approval/.test(chunk)) { await reportAttention({ agent: ctx.agentId, kind: "command", command: matchedCommand, requestId, client: ctx.sdk, }); const approved = await ctx.sdk.agents.awaitDecision({ agent: ctx.agentId, requestId, }); await ctx.sendKeys(approved ? "y\r" : "n\r"); } } await reportStopped({ agent: ctx.agentId, client: ctx.sdk }); }, }), ], ``` `WatcherContext` gives you: | Member | Meaning | | -------------------- | --------------------------------------------------------- | | `sdk` | A ready `PragmaClient` with gateway credentials. | | `agentId` / `config` | The plugin-qualified agent id and your validated config. | | `session` | `{ id, tabId, worktreeId }`. | | `output` | Async iterable of decoded terminal output chunks. | | `sendKeys(data)` | Write into the live session (answer prompts, press keys). | | `reportMessage(msg)` | Publish a rich agent message. | | `signal` | Aborts when the session exits or the watcher is stopped. | The host supervises one `pragma-watch` sidecar per live session — it re-establishes itself with fresh gateway credentials after a gateway restart and backs off on crash loops. Interjections from the app and phones (`AgentInput`) are always delivered through the watcher, because submit keys and timing are TUI-specific. ## Verifying [#verifying] `pragma-cli agent verify --agent <your-id>` runs the conformance suite — status transitions, session naming, question forms, approvals, aborts, and stream integrity. Catalog `excludeFeatures` entries skip the scenario groups your agent genuinely lacks. See [CLI → Agents](/docs/cli/agents#verify). # Getting started (/docs/plugins/getting-started) ## Scaffold [#scaffold] ```sh bun packages/create-pragma-plugin/dist/cli.js my-plugin \ --name my-plugin --pm bun --capabilities ui,commands,agents ``` Capabilities: `ui` (sidebar tab), `commands`, `agents`. The generator produces a self-contained Vite project: a single ESM bundle at `./dist/index.js` (`main` points at it), React aliased to `@pragma-sh/plugin/react` / `@pragma-sh/plugin/react-dom` / `@pragma-sh/plugin/jsx-runtime`. ```sh cd my-plugin bun install bun run build ``` `@pragma-sh/…` is a reserved package scope — rename your plugin before publishing. ## Register [#register] Add the plugin to the global `~/.pragma/config.json` or the project's `.pragma/config.json`: ```json { "plugins": [{ "path": "./my-plugin", "config": { "level": 3 } }] } ``` * `path` — relative paths resolve against the config file's directory; absolute paths (`/…`, `~/…`, `C:\…`) work as given. npm specifiers are not supported for hand-written entries — install official plugins from the [plugin gallery](https://pragma.sh/plugins) or in-app instead. * `config` — optional object validated by the plugin's own zod schema. Changing it does not need a reload. * There is no `enabled` flag: presence installs, deletion removes. Restart Pragma (or rely on dev hot-reload, which invalidates a changed bundle by mtime) and the plugin's contributions appear. Failures are per-plugin and surface as a toast plus a `failed` entry — one broken plugin never takes down the app. ## Manifest fields the host reads [#manifest-fields-the-host-reads] | `package.json` field | Meaning | | --------------------------------- | ---------------------------------------------- | | `name` | Plugin id (also the storage scope). | | `version` | Used for module caching and API compatibility. | | `main` | Path to the ESM bundle. | | `pragma.pluginId` / `pragma.main` | Optional explicit overrides. | The manifest is read **without executing your code**. ## API versioning [#api-versioning] `definePlugin` stamps the compiled `PLUGIN_API_VERSION` into the definition; the host checks compatibility (semver) before importing your bundle. Regenerating against an updated `@pragma-sh/plugin` picks up the new stamp — a mismatching bundle is reported, not loaded. # Plugin development (/docs/plugins) Pragma's own agent integrations are plugins. Everything they use is public, so anything you build sits at the same level as what ships. ```tsx import { definePlugin, defineSidebarTab, useProject } from "@pragma-sh/plugin"; function Queue() { const project = useProject(); return <div>Queue for {project?.name ?? "none"}</div>; } export default definePlugin({ name: "Review Queue", description: "Everything waiting on me, across every worktree.", ui: { sidebarTabs: [defineSidebarTab({ id: "queue", title: "Queue", component: Queue })], }, }); ``` <Cards> <Card title="Getting started" description="Scaffold a plugin, register it, and load it." href="/docs/plugins/getting-started" /> <Card title="UI contributions" description="Sidebar tabs and cards, settings pages, topper items, commands, and web views." href="/docs/plugins/ui" /> <Card title="Agents & watchers" description="defineAgent, model lists, prefill, and the watcher that drives status." href="/docs/plugins/agents" /> <Card title="Usage limits" description="defineUsageLimitProvider — power the usage-limits popover for your agent." href="/docs/plugins/usage-limits" /> <Card title="Runtime & themes" description="Hooks, storage, events, the SDK handle, and defineTheme." href="/docs/plugins/runtime" /> </Cards> ## The model [#the-model] * A plugin is **one self-contained ESM bundle** (`main` in its `package.json`). * The host injects React, React DOM, the JSX runtime, zod, UI primitives, and icons — **never bundle them**. `@pragma-sh/plugin` is a compile-time stub that delegates to the host bridge (`globalThis.__PRAGMA__`). * Registration is declarative: a `plugins[]` entry in `.pragma/config.json` ([global](/docs/user-guide/core-model#where-things-live) or per project). Presence installs it; project scope wins over global. * The **agent catalog**, usage-limit providers, and server-side hooks run in the `pragma-plugins` host sidecar; UI contributions run in the desktop webview. One `definePlugin` call contributes to both. <Callout title="Browser-safe code"> A static `node:` import or a module-scope `process.*` read in the plugin entry fails the webview load (`status: "failed"`). Server-side work belongs in the sidecar hooks (`onInstall`, `onPragmaLoad`) which run under Bun. </Callout> # Runtime & themes (/docs/plugins/runtime) ## Hooks (React) [#hooks-react] Available inside any contributed component: | Hook | Returns | | -------------------------------------- | --------------------------------------------------------- | | `usePluginConfig()` | Your validated config object. | | `useSdk()` | The ready `PragmaClient`. | | `useProject()` | `{ id, name, path }` or `null`. | | `useTheme()` | `"light" \| "dark"`. | | `useWebViewPayload<T>()` | The payload passed to `openWebView`. | | `useNotify()` | `(message, { variant?, description?, native? }) => void`. | | `useStoredState<T>(key, v)` | Persisted, plugin-scoped state. | | `useSdkQuery(fn, deps)` | `{ data?, error, loading, refetch }` wrapper. | | `useEvent(name, handler)` | Subscribe to an app event. | | `useWorktreeChanges(root)` | Git changes for a worktree. | | `useBranchStatus(root)` | Ahead/behind status. | | `useDirEntries(root, path)` | File listings. | | `useFileContents(root, path)` | File contents. | | `useAgentStatuses(worktreeId)` | Live agent statuses (`running/attention/done`). | | `useAgentMessages(worktreeId, tabId?)` | Rich agent messages. | | `useSessions()` | Live sessions (`{ id, cwd }`). | Non-React equivalents live in the runtime module: `getTheme()`, `subscribeTheme()`, `subscribeEvent()`, `listSessions()`. ## The context object [#the-context-object] Lifecycle hooks receive a `PluginContext`: ```ts interface PluginContext<TConfig = unknown> { pluginId: string; // derived from package.json name pluginDir?: string; config: TConfig; // parsed by your zod schema project: PluginProject | null; sdk: PragmaClient; // configured with gateway credentials notify: (message, options?) => void; storage?: PluginStorage; // get / set / delete, host-bound } ``` ```tsx export default definePlugin({ /* ... */ onInstall: async (ctx) => { /* once per installation (host side) */ }, onPragmaLoad: async (ctx) => { /* once per server boot (host side) */ }, activate: (ctx) => { // in-process setup; return a dispose function const off = ctx.sdk.events.subscribe("agentStatus", () => {}); return () => off(); }, }); ``` `onInstall` and `onPragmaLoad` run **host-side** in the `pragma-plugins` sidecar (under Bun), keyed by boot id so they run once — that is where Node-only work belongs. ## Events [#events] `useEvent(eventName, handler)` / `subscribeEvent(name, handler)` listen to application events such as `agent.report` (typed `AgentReportPayload`) and your plugin's own **deep links**: `pragma://plugin/<pluginId>/<path>?params` arrives as `PluginDeepLinkEvent { pluginId, path, url, params }`. ## Theming [#theming] ```tsx import { defineTheme } from "@pragma-sh/plugin"; themes: [ defineTheme({ id: "paper", name: "Paper", description: "Warm light palette", colors: { light: { canvas: "oklch(0.98 0.01 90)", primary: "oklch(0.2 0.02 90)" }, dark: { canvas: "oklch(0.18 0.01 90)" }, }, }), ], ``` Token keys omit the `--` prefix and follow the [token groups](/docs/user-guide/theming#token-groups). Applying a theme copies its values into the user's `.pragma/theme.json` — it is a starting point they own, never a forced override. ## UI primitives [#ui-primitives] `@pragma-sh/plugin/ui` exports the host's `Button` and `Kbd`; `@pragma-sh/plugin/icons` the host icon map. Use them so your UI tracks the app's theme — and reach for the theme CSS variables in your own styles rather than literal colours. # UI contributions (/docs/plugins/ui) UI contributions live on `definePlugin({ ui: ... })`. Every contribution accepts an optional `when?: (ctx) => boolean` render guard, evaluated by the host against your config. ## Sidebar tabs [#sidebar-tabs] ```tsx import { definePlugin, defineSidebarTab } from "@pragma-sh/plugin"; export default definePlugin({ name: "Review Queue", ui: { sidebarTabs: [ defineSidebarTab({ id: "queue", title: "Queue", icon: MyIcon, // optional PluginIcon component: Queue, // receives { webViewPayload? } when: (ctx) => ctx.config.enabled, }), ], }, }); ``` Tabs render in the project sidebar and receive the project context through the [hooks](/docs/plugins/runtime). ## Sidebar cards [#sidebar-cards] ```tsx sidebarCards: [defineSidebarCard({ title: "Alerts", component: AlertsCard })]; ``` Collapsible cards below the worktree tree, next to the built-in Ports and Scratchpads cards. ## Settings pages [#settings-pages] ```tsx settingsPages: [ defineSettingsPage({ id: "queue", title: "Review Queue", component: QueueSettings }), ]; ``` Adds a page to Settings, available in both global and project scope like the built-ins. Pages are not their own navigation section: they appear nested under their plugin in the Plugins list, and open from there. ## Topper items [#topper-items] ```tsx topper: [defineTopperItem({ align: "right", component: SyncBadge })]; ``` Small components in the top toolbar's left or right cluster (next to the agents menu and usage-limits gauge). ## Commands [#commands] ```tsx import { definePlugin, defineCommand } from "@pragma-sh/plugin"; commands: [ defineCommand({ id: "review-queue.open", title: "Open review queue", defaultBinding: "mod+shift+r", run: (ctx, args) => ctx.notify("Review queue opened"), }), ]; ``` Commands appear in the [command palette](/docs/user-guide/keybindings#command-palette) (command mode) and are bindable like built-ins; `defaultBinding` merges with the user's keybinding files. `hidden` keeps a command out of the palette while keeping it callable. ## Web views [#web-views] ```tsx import { definePlugin, defineWebView } from "@pragma-sh/plugin"; const webView = defineWebView({ id: "docs", title: "Docs", component: DocsPage }); // open it from a command or hook: webView.open({ title: "Project docs", payload: { page: "intro" }, dedupeKey: "docs" }); ``` `openWebView(webViewOrId, options)` opens by handle or unambiguous id. `payload` reaches the component via `useWebViewPayload()`, and `dedupeKey` focuses an existing instance instead of opening a second one. Web views render as their own tab kind. ## Config [#config] ```tsx import { z } from "@pragma-sh/plugin"; // the host's zod — never bundle your own config: z.object({ enabled: z.boolean().default(true), level: z.number().optional() }), ``` The schema validates every `plugins[].config` entry at load; `usePluginConfig()` and `ctx.config` hand your components the parsed value. # Usage limits (/docs/plugins/usage-limits) The toolbar's [usage-limits popover](/docs/user-guide/usage-limits) is fed by plugin providers. A provider polls a source — typically your agent CLI's usage command — and reports structured limits. ```tsx import { definePlugin, defineUsageLimitProvider, runProviderCommand, CLI_MISSING_STATUS, } from "@pragma-sh/plugin"; usageLimits: [ defineUsageLimitProvider({ id: "my-agent", title: "My Agent", iconPath: "./assets/agent.svg", dashboardUrl: "https://my-agent.dev/dashboard", primaryLimitId: "messages", refreshIntervalMs: 30_000, // minimum 15s; backoff on failure up to 15min load: async (ctx) => { const outcome = await runProviderCommand(ctx, ["my-agent", "usage", "--json"]); if (outcome.kind === "missing") { return { status: "unavailable", reason: "not-configured", message: "Install the my-agent CLI to see usage.", }; } if (outcome.kind === "failed") { return { status: "unavailable", reason: "error", message: outcome.stderr }; } const data = JSON.parse(outcome.stdout); return { status: "ready", observedAt: Date.now(), summary: data.messages, // the collapsed-row limit limits: [data.messages, data.tokens], }; }, }), ], ``` ## Shapes [#shapes] ```ts interface UsageLimit { id: string; title: string; used: number; // percent 0–100 limit: number | null; // null = unlimited → "Unlimited" resetsInMs?: number; // drives the "Resets in" countdown } type UsageLimitsResult = | { status: "ready"; observedAt: number; summary?: UsageLimit; limits: UsageLimit[] } | { status: "unavailable"; reason: "not-configured" | "authentication-required" | "unsupported" | "error"; message: string; }; ``` `runProviderCommand` executes a command and classifies the result: `{ kind: "missing" }` when the binary is absent (exit status `CLI_MISSING_STATUS` = 20), `{ kind: "failed"; stderr }`, or `{ kind: "ok"; stdout }`. Report unavailable states honestly — the popover renders them instead of fake numbers. The app renders the summary when collapsed, every limit when expanded, colours the progress bar (warning from 50%, destructive from 75%), and links `dashboardUrl`. <Callout title="Not for UI work"> A provider is data-only and runs host-side. UI goes in `ui` contributions; this API exists so the same popover covers every agent, including the ones you ship. </Callout> # Agents (/docs/sdk/agents) `client.agents` is how a program — usually an agent integration — reports into the app and talks back with the user. The desktop's coloured dots, the notification, the approval toast, and the phone push are all renderings of these reports. ## Status reports [#status-reports] ```ts await client.agents.report({ agent: "claude-code", worktreeId: "wt_123", status: "running", // "running" | "attention" | "done" | "cleared" }); ``` Convenience wrappers set the status for you: ```ts import { reportStarted, // status: "running" reportStopped, // status: "done" reportAttention, // status: "attention" reportCleared, // status: "cleared" reportSessionName, reportMessage, } from "@pragma-sh/sdk"; ``` `reportSessionName({ agent, sessionName })` names the hosting tab (a user rename always wins). `reportMessage({ agent, message })` publishes a rich message shown in the app. ## Questions — round trips, not just events [#questions--round-trips-not-just-events] An attention report can carry a **question** with options, or a **command approval**, and the agent blocks until the user answers: ```ts import { reportAttention, awaitAgentAnswer, awaitAgentDecision } from "@pragma-sh/sdk"; // A question with tappable options const requestId = "q-1"; await reportAttention({ agent: "my-agent", kind: "question", question: "Which database for the fixture?", options: [ { label: "Postgres", description: "matches prod" }, { label: "SQLite", description: "fastest" }, ], requestId, client, // required for the standalone helpers }); const answer = await awaitAgentAnswer({ agent: "my-agent", requestId, client }); // string reply, or null when dismissed / timed out // A command approval await reportAttention({ agent: "my-agent", kind: "command", command: "rm -rf dist", requestId: "q-2", client, }); const approved = await awaitAgentDecision({ agent: "my-agent", requestId: "q-2", client }); // true (allow), false (deny), or null on timeout ``` The standalone helpers no-op (or resolve `null`) unless a `client` is passed **or** the full `PRAGMA_*` environment is present. ## The connection — full duplex [#the-connection--full-duplex] `client.agents.connect()` opens the agent event stream filtered to one agent + tab and exposes every direction: ```ts const conn = await client.agents.connect({ agent: "my-agent", tabId }); for await (const event of conn) { // agent status changes, messages, decisions, answers, inputs, interrupts } await conn.send("also add tests"); // interject input into the TUI await conn.answer(requestId, "Postgres"); // answer a question await conn.decide(requestId, true); // approve / deny a command await conn.interrupt(); // send ESC into the session conn.close(); ``` Pass `prompt` in `ConnectOptions` to attach **and** deliver an initial prompt. `awaitDecision`/`awaitAnswer` are convenience wrappers over this stream. ## Catalog and launching [#catalog-and-launching] * `client.agents.catalog()` — `GET /v1/agents/catalog`: every registered agent with its models, reasoning levels, and permission modes (assembled by the plugin host). * `client.agents.launch(payload)` — `POST /v1/control/agentSessionLaunch`: create a worktree/tab and start an agent session, optionally `headless: true` (works while the desktop is closed). Returns `{ worktreeId, tabId }`. * `client.agents.markAgentsSeen({ tabId })` — clear the "seen" latch for a tab, the same call the desktop makes when you view it. # Fanouts (/docs/sdk/fanouts) A **fanout** runs one prompt in several isolated attempts under a coordination parent worktree. The SDK drives the same RPC the desktop and the CLI use — see [Creating a worktree → Fan out](/docs/user-guide/worktrees#fan-out) for the UI side. ## Create [#create] ```ts const result = await client.fanouts.create({ prompt: "Add token refresh and tests", members: [ { selector: "opencode.grok-3" }, // agent[.model[.reasoning]] { selector: "claude-code" }, ], parent: { type: "new", branch: "fan/token-refresh" }, }); ``` * `create` resolves on **partial provisioning** (`partial: true` with per-member `failures`) instead of throwing — the record exists either way. * A fresh coordination parent is always branched from an existing worktree; an existing worktree can also host the fanout (`FanoutExistingParent`). * One parent holds **at most one active fanout**. ## Observe [#observe] ```ts for await (const event of client.fanouts.subscribe({ fanoutId })) { // { type: "snapshot", fanout } then { type: "delta", fanout } } ``` The subscription yields a snapshot followed by deltas (v1 deltas are full replacements). `client.fanouts.get(reference)` fetches once. Member status flows from the attempts' agent reports: `pending → provisioning → running → (attention) → done | failed | interrupted | cancelled | selected`. ## Message, retry, cancel [#message-retry-cancel] ```ts await client.fanouts.send({ fanoutId, target: { type: "all" }, // or one member message: "Also cover the edge case in the tests", }); await client.fanouts.retry({ fanoutId, memberId }); // relaunch in the existing worktree await client.fanouts.cancel({ fanoutId }); // keeps every checkout ``` `send` waits for delivery per member (re-tried via each member's watcher) unless `waitForDelivery: false`; the same `messageId` re-delivers idempotently. ## Read attempt output [#read-attempt-output] ```ts const { targets } = await client.fanouts.read({ fanoutId, target: { type: "all" }, lines: 200, }); // each target carries base64-decoded bytes of the attempt's terminal scrollback ``` ## Pick the winner — destructive [#pick-the-winner--destructive] ```ts const pick = await client.fanouts.pick({ fanoutId, memberId }); ``` `pick` merges the chosen attempt into the parent, promotes its scratchpads, stops the sessions, and deletes every attempt checkout. The host executes it as durable stages — `validating → committingWinner → merging → promotingScratchpads → stoppingSessions → cleaningUp → completed` — and a retry resumes at the first incomplete stage. A merge conflict parks the fanout in `needsResolution` with everything intact; a partial cleanup reports `cleanupFailed` with the survivors. The SDK performs **no confirmation** — the caller (UI or CLI) owns that. # Getting started (/docs/sdk/getting-started) ## Installation [#installation] `@pragma-sh/sdk` ships as part of the Pragma workspace. Reference it as a workspace dependency in a monorepo that vendors Pragma, or install a published release: ```sh bun add @pragma-sh/sdk ``` The package is dual ESM/CJS with TypeScript definitions; `@pragma-sh/scratchpad-contract` is bundled in. ## Constructing the client [#constructing-the-client] ```ts import { PragmaClient } from "@pragma-sh/sdk"; const client = new PragmaClient({ baseUrl: "http://127.0.0.1:54321", token: "<gateway token>", }); ``` `PragmaClientConfig`: | Field | Type | Fallback | | ------- | ------------------------ | --------------------------- | | baseUrl | `string` | `PRAGMA_GATEWAY_URL` | | token | `string` | `PRAGMA_GATEWAY_TOKEN` | | fetch | `FetchLike` | global `fetch` | | headers | `Record<string, string>` | — (sent with every request) | Missing baseUrl or token throws `PragmaTransportError` at construction. The SDK reads **no other files** — discovering the gateway is the app's job (the gateway writes `gateway.json` beside its socket; Pragma terminals receive both values as environment variables). Every request sends `Authorization: Bearer <token>`. `204`/`202` responses return `undefined`; any other non-OK response throws. ## The namespaces [#the-namespaces] | Namespace | Purpose | | -------------------- | ------------------------------------------------------------------------- | | `client.sessions` | Spawn, attach (event stream), write, resize, kill terminals. | | `client.agents` | Status reports, messages, questions, approvals, the agent event channel. | | `client.fanouts` | Create/get/read/send/retry/cancel/pick fanouts + subscriptions. | | `client.scratchpads` | List, read, comment on, attach agents to, and prompt through scratchpads. | | `client.workspace` | Subscribe to the full workspace snapshot (projects, worktrees, tabs). | | `client.events` | Subscribe to any protocol event (`agentStatus`, `fanouts`, …). | | `client.fs` | Filesystem operations scoped to a worktree root. | | `client.git` | Worktree changes, staging, commits, branches, GitHub sync helpers. | | `client.exec` | Run commands on the host. | | `client.theme` | Read merged theme overrides (global ← project). | | `client.assets` | Fetch plugin assets by content hash (`toDataUri` included). | | `client.push` | Register device push tokens, presence, test pushes. | | `client.health` | Unauthenticated `GET /v1/health` — liveness + versions. | Plus two one-off methods on the client itself: * `client.rpc(method, payload)` — any protocol RPC method, typed loosely. * `client.createBoardDraft(payload)` — create an agent-board draft card (returns the created `KanbanPromptCard`). ## Environment helpers [#environment-helpers] ```ts import { PRAGMA_ENV_KEYS, hasPragmaEnvironment, readEnv } from "@pragma-sh/sdk"; ``` `hasPragmaEnvironment()` is true when **all four** keys are present: `PRAGMA_GATEWAY_URL`, `PRAGMA_GATEWAY_TOKEN`, `PRAGMA_TAB_ID`, `PRAGMA_WORKTREE_ID`. The standalone report helpers (see [Agents](/docs/sdk/agents)) use this to decide whether they can act. ## Errors [#errors] ```ts import { PragmaGatewayError, PragmaTransportError } from "@pragma-sh/sdk"; ``` * **`PragmaGatewayError`** — the gateway answered. Fields: `code`, `httpStatus`, `details`. For fanout failures, `details` carries the typed `FanoutFailure` (failure code, member, finalize stage). * **`PragmaTransportError`** — construction misconfiguration, network failure, or a non-JSON response. Every method also accepts a trailing `{ signal?: AbortSignal }` options object. # @pragma-sh/sdk (/docs/sdk) `@pragma-sh/sdk` is a TypeScript client for the **Pragma gateway** — the HTTP/JSON surface a running host exposes. The mobile app, the browser build, and agent plugins all speak through it; anything you build can too. ```ts import { PragmaClient } from "@pragma-sh/sdk"; const client = new PragmaClient(); // config from the environment for await (const event of client.workspace.subscribe()) { console.log(event.snapshot?.projects.length, "projects"); } ``` ## What it does [#what-it-does] <Cards> <Card title="Getting started" description="Construct the client, configure the gateway, and handle errors." href="/docs/sdk/getting-started" /> <Card title="Sessions" description="Spawn, attach, write to, resize, and kill terminal sessions — with scrollback replay." href="/docs/sdk/sessions" /> <Card title="Agents" description="Report status, ask questions, request approvals, and await the answers." href="/docs/sdk/agents" /> <Card title="Fanouts" description="Create, watch, message, retry, cancel, and pick fanouts programmatically." href="/docs/sdk/fanouts" /> <Card title="Scratchpads" description="Read, comment on, and send prompts through agent-authored MDX documents." href="/docs/sdk/scratchpads" /> <Card title="Whiteboards" description="Create, search, edit, and render durable Excalidraw boards." href="/docs/sdk/whiteboards" /> <Card title="Workspace & more" description="fs, git, exec, events, theme, push, and asset namespaces." href="/docs/sdk/workspace" /> </Cards> ## Design notes [#design-notes] * **Fetch-based, streaming with NDJSON.** Subscriptions and session event streams are newline-delimited JSON over `fetch` + `ReadableStream` — no sockets, no SSE, so it runs in Node, Bun, React Native (the mobile client uses it), and browsers. * **Dual ESM/CJS** with `.d.ts`, built with bunup. Types for every wire shape (`Fanout`, `KanbanPromptCard`, …) are re-exported from `@pragma-sh/constants`, so TS and Rust agree by construction. * **Typed errors.** `PragmaGatewayError` (the gateway answered with a code and details) vs `PragmaTransportError` (network, config, or non-JSON response). * **The gateway is the only transport.** The SDK never talks to the Unix socket directly and never shells out to `pragma-cli` — that is the [CLI](/docs/cli)'s job. <Callout title="Agents inside Pragma terminals"> If your code runs in a Pragma terminal, the environment is already configured (`PRAGMA_GATEWAY_URL`, `PRAGMA_GATEWAY_TOKEN`, `PRAGMA_TAB_ID`, `PRAGMA_WORKTREE_ID`) — construct the client with no arguments. </Callout> # Scratchpads (/docs/sdk/scratchpads) `client.scratchpads` composes the filesystem, agent, and [@pragma-sh/scratchpad-contract](https://github.com/pragma-sh/pragma) APIs so the mobile client and scripts get the same scratchpad experience as the desktop. See [Scratchpads](/docs/user-guide/scratchpads) for the user-facing tour. ## List and read [#list-and-read] ```ts const scratchpads = await client.scratchpads.getScratchpads({ root: "/path/to/worktree", }); // ScratchpadFile { id, title, filePath, contents, agentTabId, agentId, createdAt } ``` Only managed documents (created with `pragma-cli scratchpad create`) are listed; the host parses the frontmatter. ## Comments [#comments] ```ts const comments = await client.scratchpads.getComments({ root, filePath, }); const comment = await client.scratchpads.comment( { root, filePath }, { index: 2, quote: "The relevant paragraph…" }, "Can you expand on the cache invalidation?", ); // or replace the whole thread await client.scratchpads.setComments({ root, filePath }, comments); ``` Comment ids: when you don't supply one, a Hermes-safe fallback id is generated — the contract avoids `crypto.randomUUID` on purpose for React Native compatibility. ## Attach an agent and send the thread [#attach-an-agent-and-send-the-thread] ```ts await client.scratchpads.attachAgent({ root, filePath, tabId: "tab_123", agentId: "opencode", }); const result = await client.scratchpads.sendAttached({ root, filePath, worktreeId, text: "Please address the comments", }); // { delivered: boolean, agent?, tabId? } // delivered: false means nothing was attached — nothing was sent ``` `sendAttached` re-reads the managed frontmatter at call time, so an agent that detached in the meantime is respected. Deliveries are addressed to the agent's **runtime id** — the last `.`-segment of the qualified `plugin.agent` catalog id, which is what the event stream keys on (`runtimeAgentId` is exported for exactly this). ## The contract underneath [#the-contract-underneath] `@pragma-sh/scratchpad-contract` (re-exported through the SDK bundle) owns the file format: `parseScratchpadDocument`, `replaceScratchpadBody`, `attachScratchpadAgent`, `parseScratchpadComments`, `createScratchpadComment`, `unresolvedCommentsPrompt` — the shared wording both the desktop and the mobile client send to agents. Never write scratchpad files by hand; create them with the CLI and edit them through this API. # Sessions (/docs/sdk/sessions) A **session** is one live PTY the host server owns. `client.sessions` drives it. ## Spawn [#spawn] ```ts const { sessionId } = await client.sessions.spawn({ cwd: "/path/to/worktree", cols: 120, rows: 40, // optional shell profile, command, agent id, title, environment }); ``` `spawn` posts `POST /v1/sessions` and returns the session id immediately; output flows through the event stream. Agent sessions (spawned with an agent id from the catalog) carry startup/prefill handling and get `PRAGMA_FANOUT_*` variables inside fanout attempts. ## Attach — the event stream [#attach--the-event-stream] ```ts for await (const event of client.sessions.attach(sessionId)) { switch (event.type) { case "replay": // scrollback replay; event.cursor is the absolute output-byte cursor break; case "output": // event.data: Uint8Array of raw PTY output break; case "title": case "exit": } } ``` The stream is NDJSON over `GET /v1/sessions/{id}/events`. The first event is a **replay**: the server re-sends as much scrollback as it can cover, and `reset: true` means the connection's cursor fell outside the retained window. Reconnecting clients send their last cursor back on attach to resume seamlessly. ## Input, resize, rename, kill [#input-resize-rename-kill] ```ts await client.sessions.write(sessionId, new TextEncoder().encode("cargo test\r")); await client.sessions.resize(sessionId, { cols: 100, rows: 30 }); await client.sessions.rename(sessionId, "build"); // renames the hosting tab await client.sessions.kill(sessionId); await client.sessions.killForCwd("/path/to/worktree"); // kill every session in a cwd ``` `write` posts raw octet-stream bytes to `POST /v1/sessions/{id}/input` — no JSON, no escaping. ## Use cases [#use-cases] * **Headless agents**: spawn a session per attempt and read its output from a script. * **Custom clients**: the mobile app is built exactly on these routes. * **Supervision**: `killForCwd` is how tooling tears down everything a worktree started. # Whiteboards (/docs/sdk/whiteboards) `client.whiteboards` is the typed namespace over the host's `whiteboards` RPC — the same boards the desktop tab, the CLI, and scratchpad embeds use. See [Whiteboards](/docs/user-guide/whiteboards) for the user-facing tour. ## Create, read, search [#create-read-search] ```ts const board = await client.whiteboards.create({ worktreeId, title: "Request flow", scene: { type: "excalidraw", version: 2, elements: [], appState: {}, files: {} }, }); // Whiteboard { id, worktreeId, title, scene, version, createdAt, updatedAt } const boards = await client.whiteboards.list({ worktreeId }); const hits = await client.whiteboards.search({ worktreeId, query: "gateway" }); const one = await client.whiteboards.get({ worktreeId, id: board.id }); ``` `search` matches titles and the scene's text elements. Every id-based call also takes the `worktreeId` — boards are worktree-scoped and a mismatched pair is rejected. ## Edit [#edit] ```ts const next = await client.whiteboards.edit({ worktreeId, id: board.id, title: "Request flow v2", scene: revisedScene, version: board.version, }); ``` Edits are optimistic: the `version` you pass must still be the stored one, so a concurrent writer fails the call instead of losing work. Re-`get`, reconcile, retry. Scenes are stored losslessly — preserve fields you don't understand rather than dropping them. ## Render [#render] ```ts const png: Uint8Array = await client.whiteboards.view({ worktreeId, id: board.id, dark: true, // Excalidraw's dark export palette }); ``` The host renders natively and the client decodes the response's base64 `data` into PNG bytes for you. ## Delete [#delete] ```ts await client.whiteboards.delete({ worktreeId, id: board.id }); ``` Nothing warns you that a scratchpad embeds the board, so check before deleting one. Scene and input types (`Whiteboard`, `ExcalidrawScene`, `WhiteboardCreateInput`, …) come from `@pragma-sh/constants` and are re-exported by the SDK, so TypeScript and Rust agree by construction. # Workspace & more (/docs/sdk/workspace) ## Workspace subscription [#workspace-subscription] ```ts for await (const event of client.workspace.subscribe()) { const { projects, worktrees, tabs } = event.snapshot ?? event.delta; } ``` The `workspace` event is the full host picture — projects, worktrees, tabs — snapshot first, then full-replacement deltas. It is how a headless client knows where to launch an agent, and how the phone renders its launcher. ## Generic events [#generic-events] ```ts for await (const event of client.events.subscribe("agentStatus", { worktreeId })) { // { type: "snapshot" | "delta", subscription, payload } } ``` `subscribe(event, { cursor?, worktreeId?, cwd?, signal? })` works for every protocol event kind: `agentStatus`, `worktreeChanged`, `kanbanChanged`, `tabsChanged`, `fileChanged`, `automationPending`, `automationsChanged`, `workspace`, `fanouts`. `fileChanged` requires `worktreeId` and an absolute trusted `cwd`. ## fs — filesystem in a worktree [#fs--filesystem-in-a-worktree] All paths resolve inside the given worktree root (containment enforced by the host): ```ts await client.fs.listDir({ root, path: "src" }); await client.fs.readFile({ root, path: "src/index.ts" }); // { text, binary, truncated } await client.fs.writeFile({ root, path, contents }); await client.fs.createFile({ root, path }); await client.fs.createFolder({ root, path }); await client.fs.pathExists({ root, path }); await client.fs.rename({ root, from, to }); await client.fs.delete({ root, path }); ``` ## git — worktrees and branches [#git--worktrees-and-branches] The worktree operations live here (there is no `client.worktrees` namespace): ```ts // create / remove await client.git.createWorktree({ parentRoot, branch, path }); await client.git.createWorktreeAt?.({}); // fanout base commits use the host's own path await client.git.removeWorktree({ repoRoot, worktreePath, force: false }); await client.git.deleteBranch({ repoRoot, branch }); // inspect const changes = await client.git.worktreeChanges({ root, parentBranch }); const dirty = await client.git.isDirty({ root }); // stage / commit await client.git.stageAll({ root }); await client.git.commitStaged({ root, message }); await client.git.mergeWorktreeToParent({ root }); // remote sync await client.git.githubFetchAndSync({ root }); // pull then push await client.git.githubPushBranch({ root }); ``` Also here: file/pr diffs, discard helpers, `githubRepoInfo`, default PR titles, `ensurePragmaExcluded` (writes the `.pragma/` exclusions into the repo's git exclude). ## exec [#exec] ```ts const results = await client.exec.run({ root, commands: [{ command: "bun run test" }], maxConcurrent: 2, }); // CommandResult[] { stdout, stderr, exitCode } ``` ## theme, assets, push, health [#theme-assets-push-health] ```ts const theme = await client.theme.get({ root }); // overrides only, global ← project const asset = await client.assets.toDataUri(hash); // plugin icon assets, authed fetch await client.push.register({ token }); // device push token await client.push.presence({ focused: true }); // suppress phone pushes while focused const health = await client.health.check(); // { status, protocolVersion, gatewayVersion, apiVersion? } ``` `client.assets.fetch(hash)` returns `{ bytes, mime }` — the token rides the request header, so asset URLs are never bare `<img>` links. # Agent board (/docs/user-guide/agent-board) <video src="/media/agent-board.mp4" /> The agent board is a high-level alternative to managing agents through terminal tabs. Toggle it with **Board** in the toolbar (Escape exits, the header shows **Exit agent board**). The board is **project-scoped** and replaces the center workspace; the sidebar stays. ## Columns [#columns] | Column | Meaning | | ----------------- | ------------------------------------------------------------- | | **Drafts** | Prompts written down but not started. "New draft" lives here. | | **In progress** | Cards whose agent session is running. | | **Review needed** | Cards whose agent reported done and is waiting on a human. | | **Completed** | Cards finished — optionally with a PR attached. | Column headers carry counts. Moves the board does not allow are rejected with "That move isn't available — use the card's actions to advance it." ## Cards [#cards] A card shows its **branch name**, its agent icon and model, its prompt (three-line clamp), and a live status dot while in progress. Completed cards show a **PR #N** badge, **Merged** when the PR lands, or a spinner while the PR is being opened. * **Drafts** open a draft dialog: edit the markdown prompt, pick agent and model, and choose the branch — an existing one, or type a new name and pick `Create {branch}`. * **In progress** cards open the card's worktree and session. A "Back to agent board" control returns you. ## Starting a card [#starting-a-card] Moving a draft into **In progress** (or **Start** on the card) launches in the background: Pragma creates or reuses the worktree — with the same main-behind sync gate as the normal dialog — and spawns the agent PTY **without mounting a terminal**. The board stays up and usable while the agent works. Open the card later and the full scrollback is there. The in-progress → review-needed transition is **automatic**: it fires when the agent reports done. ## Completing a card [#completing-a-card] Dragging a card into **Completed** (or using the card's action) opens the completion dialog with two choices: * **Commit all and open PR** — commits everything with AI-planned commits, pushes the branch, drafts the pull request, and runs entirely in the background. The card keeps the PR number. * **Go to worktree** — marks the card complete and opens the worktree in the normal workspace. Completed cards are read-only; delete one with the hover **Delete card** button. ## Cards and everything else [#cards-and-everything-else] The board sits on top of the same worktrees, sessions, and PR views as the rest of the app — a card is a view onto a worktree, not a separate system. The completion pipeline is the same one behind the AI **Commit & PR** button, and the PR it opens lands in the same [Pull Request views](/docs/user-guide/github). # Integrated AI (/docs/user-guide/ai) <video src="/media/ai.mp4" /> Pragma's own AI is small on purpose: it writes the commit, drafts the pull request, edits the file under your cursor, and answers the quick question. It is not another agent — your [agents](/docs/user-guide/core-model#sessions-and-agents) do the heavy lifting. ## Setup [#setup] The first launch shows **Enable AI features**: connect a provider by **subscription sign-in** (browser OAuth) or by pasting an **API key**. Featured providers: * Claude Code (Claude Pro/Max) * Codex (ChatGPT Plus/Pro) * GitHub Copilot * OpenRouter * Google AI (Gemini) * opencode Go **More options** lists the rest (Anthropic, OpenAI, and others). Connect as many as you like; **Add another provider** chains them. Skip is always available, and Settings → **AI Providers** lists connected accounts with sign-out. Your credentials, your accounts, your usage — nothing is proxied through a Pragma service. ## Inline edit [#inline-edit] In any code editor, select lines and press **<Keys mac="⌘K" other="Ctrl+K" />**: 1. A pill opens under the selection; describe the change. 2. The result lands as **red/green hunks** with accept/reject bars. 3. Accept hunks one at a time (**<Keys mac="⌘↵" other="Ctrl+↵" />**, or +Shift for all), reject with **<Keys mac="⌘⌫" other="Ctrl+Backspace" />**, walk hunks with **Alt+↑/↓**, or **Abort** while running. The model gets read-only tools — read, grep, find, list — so it understands context, but **nothing touches disk until you save**. Inline edit is available on local worktrees, not over SSH. ## Commit messages and pull requests [#commit-messages-and-pull-requests] * The Changes tab's commit box: **Shift+Tab** generates a commit message from the staged diff. * The PR create view: **Shift+Tab** drafts a title and body from the branch diff. * The **Commit & PR** button (right sidebar header) plans and makes all the commits, pushes, and drafts the PR in one action. * The [agent board's](/docs/user-guide/agent-board) "Commit all and open PR" and a fanout pick's final commit message use the same pipeline. ## Ask from the palette [#ask-from-the-palette] Type a question in the [command palette](/docs/user-guide/keybindings#command-palette) and the top row becomes `Ask AI {message}`: a streamed answer scoped to the current project and worktree, with read-only tools. Escape cancels. Not available over SSH. # Browser (/docs/user-guide/browser) Open a browser tab from the new-tab menu (**Browser**, <Keys mac="⌘B" other="Ctrl+B" />). It is a real native web view, rendered above the app, so dev servers and preview builds live beside the terminal serving them. ## Toolbar [#toolbar] * **Back**, **Forward**, **Reload** (<Keys mac="⌘R" other="Ctrl+R" />). * **Address bar** — schemeless public hosts get `https://`; localhost and loopback addresses get `http://`. * **Dev tools** (<Keys mac="⌘⇧I" other="Ctrl+⇧I" />), **Open externally**, **Screenshot**, and **Find on page**. * **More options** — **<Keys mac="⌘⇧C" other="Ctrl+⇧C" />** copies the URL. ## Design mode [#design-mode] <Callout title="Local servers only"> The design-mode toggle is promoted onto the toolbar for loopback URLs with an explicit port — it exists to fix the UI your agent is building, not to scrape the web. </Callout> Click the paintbrush to enter design mode, then: 1. **Hover** — the nearest element highlights. 2. **Click** — a pill input opens on the element. 3. **Type a change and press +** — the change is staged. Staged changes accumulate in the design-mode popover. Hand them all to an agent in one click: Pragma launches a **background agent session** (it never steals focus from your work) with a prompt containing the origin and port, each element's HTML and route, and your words. A failed page load (timeout or an empty document) shows a retry instead of a broken pane. ## Splits and agents [#splits-and-agents] A browser tab is a first-class tab: split it beside a terminal, run it in a worktree, and let an agent read a screenshot of its own output. Combined with [project scripts](/docs/user-guide/project-scripts), a common layout is a split of `run` script, agent terminal, and the browser pointed at the port. # Core model (/docs/user-guide/core-model) Pragma's UI is small because everything hangs off five concepts. Learn them once and every screen reads the same way. <Callout title="A useful definition"> A **git worktree** is a second checkout of the same repository, in its own directory, on its own branch. Pragma leans on them heavily: agents work in worktrees so they never step on each other — or on you. </Callout> ## Projects [#projects] A **project** is a git checkout you have registered — opened from disk, cloned from a remote URL, or connected over SSH. Projects live in the **project switcher** strip at the bottom of the left sidebar, each with an aggregated agent status dot. Hold the modifier key to see <Keys mac="Ctrl+1…9" other="Alt+1…9" /> shortcuts and jump straight to a project. Right-click a project to remove its registration — that never deletes the checkout files. Every project's settings are its own `.pragma/config.json`, checked into the repo, with a global `~/.pragma/config.json` behind it. See [Core model files](#where-things-live). ## Worktrees — including nested ones [#worktrees--including-nested-ones] The main checkout is labelled **main**; everything else Pragma creates is a child worktree in `<project>/.pragma/worktrees/`, git-excluded so the repo stays clean. * **Every worktree gets a branch.** Pragma creates it; you name it. * **Worktrees nest.** A child worktree can be the parent of another — a follow-up task branches off the work in progress, not off main. Nested worktrees render indented in the sidebar with expand/collapse carets. * **Diffs follow the chain.** A worktree's changes resolve against its Pragma parent branch, so reviewing a nested task means reviewing *that task only*. * **Merge state is visible.** When a worktree is merged into its parent — or its PR is merged — the sidebar row's glyph becomes a merge icon. Worktrees can be **pinned** to the top of the tree (newest pin first), **hidden** (kept but out of the way, listed under "Show N hidden"), and **renamed** with a double-click on the row. See [Creating a worktree](/docs/user-guide/worktrees). ## Tabs [#tabs] A worktree opens into a **tab strip**. Tab kinds: | Kind | What it is | | ------------ | ------------------------------------------------------------------ | | `terminal` | A real PTY — an agent's TUI, a shell, a dev server, a test watch. | | `browser` | A native web view with an address bar, dev tools, and design mode. | | `editor` | Edit any file in place, with explicit saves and inline AI edits. | | `scratchpad` | An agent-authored MDX document you can read, edit, and comment on. | | `whiteboard` | A durable Excalidraw canvas scoped to the worktree. | | `diff` | A read-only side-by-side diff view. | | `log` | The Pragma server log (Troubleshooting → Open Server Logs). | | `pr-review` | A pull-request review view: files, threads, and inline comments. | | `plugin` | A web view contributed by a plugin. | Tabs can be **split** horizontally or vertically (<Keys mac="⌘/" other="Ctrl+/" /> and{" "} <Keys mac="⌘⇧/" other="Ctrl+⇧/" /> ), dragged to reorder, renamed, and closed. Splits persist per worktree across restarts. ## Sessions and agents [#sessions-and-agents] A **session** is one live terminal process the host server owns — with scrollback, a title, and an owner worktree. An **agent** is a coding-agent CLI (or anything else) running in one. Agent integrations are plugins that report status into the app: | Status | Dot colour | Meaning | | ----------- | ---------- | ------------------------------------------------ | | `running` | Yellow | The agent is working. | | `done` | Green | The agent finished. | | `attention` | Red | The agent needs you — a question or an approval. | | `cleared` | None | The status was seen or the agent exited quietly. | Dots aggregate upward: tab → worktree → project. Viewing a tab clears its green; an attention state always survives until answered. Configuring the integrations themselves is a [plugin](/docs/plugins) topic — the shipped ones cover Claude Code, Codex, opencode, Cursor, Copilot, Grok, Junie, Kimi, Pi, and Prime Agent. ## Fanouts [#fanouts] A **fanout** runs one prompt in several isolated attempts under a coordination parent worktree, so you can compare them side by side and merge the one that actually worked. Attempts are ordinary worktrees with their own branches, grouped under the parent in the sidebar. See [Creating a worktree → Fan out](/docs/user-guide/worktrees#fan-out). ## Where things live [#where-things-live] | Location | Purpose | | ------------------------------------------ | ------------------------------------------------------------------------- | | `<project>/.pragma/config.json` | Project settings: shell, plugins, agent status. Checked in. | | `<project>/.pragma/worktrees/` | Created worktrees. Git-excluded. | | `<project>/.pragma/scratchpads/` | Agent-authored MDX documents. Git-excluded. | | `<project>/.pragma/scripts.json` | [Project scripts](/docs/user-guide/project-scripts). | | `<project>/.pragma/theme.json` | Per-project colour overrides. | | `<project>/.pragma/keybindings.json` | Per-project keybinding overrides. | | `<project>/.pragma/automations/` | Project [automations](/docs/automations). | | `<project>/.pragma/assets/sounds/` | Project agent alert clips. | | `~/.pragma/config.json` | Global settings: plugins, tunnel, agent alerts, GitHub, updates. | | `~/.pragma/keybindings.json`, `theme.json` | Global overrides for the same files, merged per value with the project's. | | `~/.pragma/automations/` | Global automations (implicitly trusted). | | `~/.local/bin/pragma-cli` | The CLI, installed and updated by the app so agents can use it. | <Callout title="Deep dive"> The full story — server, gateway, protocol, and every file on disk — is in the [wiki](/docs/wiki). </Callout> # Files & Git changes (/docs/user-guide/files) <video src="/media/files.mp4" /> The right sidebar has three subtabs — **Files**, **Changes**, and **Pull Request** — plus anything plugins contribute. This page covers the first two; [Pull Request](/docs/user-guide/github) has its own page. ## Files [#files] A lazy-loading file tree per worktree. Create files and folders inline; right-click for **Rename** (inline input) and **Delete**. Deleting is unconfirmed by design — it is a git checkout — and non-empty directories are refused. The shortcut is{" "} <Keys mac="⌘⌫" other="Ctrl+Delete" />. ### The editor [#the-editor] Click any text file to edit it in place, with highlighting for its language. **<Keys mac="⌘F" other="Ctrl+F" />** opens find-and-replace: fuzzy matching, match counts, case sensitivity, and replace-one or replace-all. Saves are deliberate — **<Keys mac="⌘S" other="Ctrl+S" />**, and nothing touches disk until you do, so half-finished experiments stay local. Files that aren't text (binaries, very large files) open in a viewer or a placeholder instead of garbage. With [integrated AI](/docs/user-guide/ai) enabled, select lines and press **<Keys mac="⌘K" other="Ctrl+K" />** for an inline edit: describe the change, review it as red/green hunks, accept or reject. ### Markdown [#markdown] `.md` files open in a **WYSIWYG** mode — tables and task lists included — that you can switch to **Raw** for the source. Both share one buffer, so you can flip between them freely. <Keys mac="⌘K" other="Ctrl+K" /> inline edits work in either; in WYSIWYG the shortcut jumps to Raw with the selection held. ### Viewers [#viewers] * **PDF** opens in a built-in viewer. Zoom with{" "} <Keys mac="⌘+/−/0" other="Ctrl+/−/0" />. * **Images, video, and audio** open in a viewer with zoom and drag-to-pan. SVG stays in the code editor, where it belongs. ### Open in your editor [#open-in-your-editor] The toolbar's editor launcher opens the worktree in **VS Code, Cursor, Windsurf, Zed, Sublime Text, IntelliJ IDEA**, or the system file explorer. The button remembers your last pick; the chevron's **Choose editor** menu changes it. Also available per worktree from the sidebar context menu. Disabled for SSH-remote worktrees. ## Changes [#changes] The **Changes** tab is the worktree's git state, polled live: * **Staged changes**, **Unstaged changes**, and **Committed changes** — clicking any file opens a unified diff beside the terminal. * Diffs resolve against the worktree's **Pragma parent branch** (main's baseline is its upstream), so a moving main never scrambles the review. Per file: **Stage changes** / **Unstage changes**, **Discard changes** (unstaged only; confirmed — it is irreversible). Per group: stage all, unstage all, discard all unstaged. ### Commit and push [#commit-and-push] The commit box generates a message with **Shift+Tab** when AI is available, or takes a typed one. Committing stages-and-commits the staged files. Once no changes remain, the commit controls give way to lifecycle actions: * **Sync with remote** — pull (a conflict aborts the sync cleanly) then push, with ahead/behind counts. * **Delete worktree** — offered for a fully clean child worktree. ### Reviewing the whole branch [#reviewing-the-whole-branch] The **Committed changes** group is the branch's commits against its parent. Before pushing, read it as one diff — generated changes cannot hide between commits. The same diff powers the PR pre-flight and the sidebar's merge glyph. # GitHub (/docs/user-guide/github) <video src="/media/github.mp4" /> ## Signing in [#signing-in] Settings → **GitHub**, or the first time you open a PR view. Pragma uses the OAuth **device flow** — a browser opens at `github.com/login/device` with a code — requesting the `repo` scope only. If you already use the `gh` CLI, Pragma can adopt its token instead. The token is stored in a `0600` file; nothing else reads it. ## Creating a pull request [#creating-a-pull-request] The right sidebar's **Pull Request** tab opens the create view when the worktree has no open PR: * AI drafts the **title and body from the actual diff** — regenerate with **Shift+Tab**, edit freely in a markdown WYSIWYG. * Pre-flight checks catch an uncommitted or unpushed branch before you submit. * A **"Created with Pragma"** footer (a heading link, an "Open worktree" link, and the opt-out line) is appended. Disable the signature under Settings → GitHub → Pull requests — global scope, applies everywhere. The AI **Commit & PR** button in the sidebar header does the whole pipeline at once: commits all changes as AI-planned commits, pushes, drafts the PR, and switches you to the view. ## Reviewing a pull request [#reviewing-a-pull-request] The **PR Review** tab (opened from a PR view) is the full review surface: * **Checks** — pass/fail/pending counts with a per-check dropdown, plus **Sync with Base Branch** and, on conflicts, a confirmed **Abort Merge**. * **Changed files** — read every file as a side-by-side diff against the base, with a per-file done-toggle. * **Review threads** — reply inline in markdown, resolve and unresolve optimistically, right from the app. ## Fixing with AI [#fixing-with-ai] Every review thread offers: * **Fix** — launch an agent on that one comment. * **Add to fix it list** — collect several comments. The tab header's **Address fix it list** hands the whole list to an agent — in the same worktree, or on a fresh one it creates for the fix. The agent receives every comment with its file and context in one prompt. ## Stacks [#stacks] A PR can sit on top of another. When GitHub reports a stack, Pragma links it to your local ancestor chain: * Missing layers get tracking worktrees, created bottom-to-top. * **Sync stack** runs the stack sync in a terminal. * **Merge stack** merges the top PR asynchronously, then offers to delete the matched stack worktrees and branches. Each piece stays reviewable on its own — no more one giant branch. # Welcome to Pragma (/docs/user-guide) Pragma is a desktop app for running teams of coding agents. Launch Claude Code, Codex, opencode, Cursor, and other agents from one window, each in **its own git worktree** with a real native terminal — then keep working while they do. <img alt="The Pragma desktop app" src="__img0" /> ## The short version [#the-short-version] * **Many projects, one window.** A rail of projects at the bottom-left; switching projects never closes a session. * **Every agent in its own worktree.** Pragma creates the git worktree and branch for you. A worktree can nest under another, so a follow-up task branches off work in progress, not off main. * **Real terminals, native TUIs.** Agents run their own CLI exactly as it ships, in a GPU-accelerated terminal alongside your dev servers and test runs. * **Status you can trust.** Plugins report running, done, and needs-you as coloured dots, a chime, and a system notification — an idle agent never waits unnoticed. * **Everything around the agent.** An agent board, git and GitHub views, editors, a browser, project scripts, integrated AI, automations, and a plugin API. ## What to read next [#what-to-read-next] <Cards> <Card title="Quick start" description="Create your first worktree, run an agent, and ship the result." href="/docs/user-guide/quick-start" /> <Card title="Core model" description="Projects, nested worktrees, tabs, sessions, and agent status — how Pragma is put together." href="/docs/user-guide/core-model" /> <Card title="Creating a worktree" description="Single worktrees and fanouts, agent prompting, pinning, and everything else on the left sidebar." href="/docs/user-guide/worktrees" /> <Card title="Agent board" description="Turn tasks into cards and take them from prompt to pull request." href="/docs/user-guide/agent-board" /> <Card title="Theming" description="Override any colour per user or per project, or apply a built-in palette." href="/docs/user-guide/theming" /> <Card title="Mobile app" description="Pair Pragma Go and run your workspace from the couch." href="/docs/user-guide/mobile" /> </Cards> <Callout title="Agents, this is for you too"> If a coding agent is reading this: append `.md` to any docs URL to fetch that page as raw markdown (for example, `/docs/user-guide/core-model.md`), or read every page at once from [/llms-full.txt](/llms-full.txt). </Callout> # Keybindings & command palette (/docs/user-guide/keybindings) ## Default keybindings [#default-keybindings] Overrides live in `~/.pragma/keybindings.json` (global) and `.pragma/keybindings.json` (project, wins). Settings → **Keybindings** edits both with a record button and a per-action reset. Shortcuts are shown for your platform. | Action | Shortcut | | ------------------------- | -------------------------------------------------------------- | | New terminal tab | <Keys mac="⌘T" other="Ctrl+T" /> | | New browser tab | <Keys mac="⌘B" other="Ctrl+B" /> | | New whiteboard | <Keys mac="⌘⇧W" other="Ctrl+⇧W" /> | | Close tab | <Keys mac="⌘W" other="Ctrl+W" /> | | Next / previous tab | <Keys mac="Ctrl+Tab / Ctrl+⇧Tab" other="Alt+Tab / Alt+⇧Tab" /> | | Clear terminal | <Keys mac="⌘K" other="Ctrl+K" /> | | Split horizontal | <Keys mac="⌘/" other="Ctrl+/" /> | | Split vertical | <Keys mac="⌘⇧/" other="Ctrl+⇧/" /> | | Command palette | <Keys mac="⌘P" other="Ctrl+P" /> | | Command mode | <Keys mac="⌘⇧P" other="Ctrl+⇧P" /> | | Browser reload | <Keys mac="⌘R" other="Ctrl+R" /> | | Browser dev tools | <Keys mac="⌘⇧I" other="Ctrl+⇧I" /> | | Copy browser URL | <Keys mac="⌘⇧C" other="Ctrl+⇧C" /> | | Delete file (editor) | <Keys mac="⌘⌫" other="Ctrl+Delete" /> | | Scroll terminal to bottom | <Keys mac="⌘End" other="Ctrl+End" /> | | Switch to project N | <Keys mac="Ctrl+1…9" other="Alt+1…9" /> | | Switch to worktree N | <Keys mac="⌘1…9" other="Ctrl+1…9" /> | | Switch to tab N | <Keys mac="Alt+⇧1…9" other="Alt+⇧1…9" /> | Number badges appear on sidebar rows and tabs while the modifier is held. ## Command palette [#command-palette] **<Keys mac="⌘P" other="Ctrl+P" />** opens the palette. It searches the selected project's worktrees and their tabs immediately; PR discovery and filename/code search hydrate as results arrive. Selecting a worktree **scopes** the palette to it (Backspace clears the scope). Type `>` (or press **<Keys mac="⌘⇧P" other="Ctrl+⇧P" />**) to enter **command mode**: every registered command, including navigation, remote access, "Restart Server", "Open Server Logs", [automations](/docs/automations), and plugin commands. The palette also surfaces live state: * `Ask AI {message}` — the first row when you type a question ([integrated AI](/docs/user-guide/ai)). * **Open ports** and **running project scripts** — Enter focuses them, Shift+Enter closes them. # Mobile app (/docs/user-guide/mobile) <video src="/media/go.mp4" /> Pragma Go is the mobile client (iOS, iPadOS, Android, and the web). It talks to the same host as the desktop through the **gateway** — a localhost HTTP server the desktop starts — exposed over a tunnel you configure. ## Installing Pragma Go [#installing-pragma-go] * **iOS** — [download Pragma Go on the App Store](https://apps.apple.com/us/app/pragma-sh-go/id6804842149). * **Android** — install the APK with Obtainium, below. * **Web** — nothing to install; see [Pragma Go on the web](#pragma-go-on-the-web). ### Android with Obtainium [#android-with-obtainium] Pragma Go for Android is not on the Play Store. Every `pragma-go` release on GitHub carries a signed APK instead, and [Obtainium](https://github.com/ImranR98/Obtainium) installs it straight from there and notifies you when a new one ships. 1. **Install Obtainium** from its [releases page](https://github.com/ImranR98/Obtainium/releases/latest) or F-Droid. Android asks you to allow installs from that source the first time. 2. In Obtainium, tap **Add app** and enter the source URL: ```text https://github.com/pragma-sh/pragma ``` 3. **Set the additional options.** The same repository publishes the desktop app and every agent plugin, so Obtainium needs to be told which releases are Pragma Go's: | Option | Value | | ----------------------------------------------- | -------------------------------------- | | **Filter release titles by regular expression** | `^pragma-go` | | **Filter APKs by regular expression** | `\.apk$` | | **Fallback to older releases** | On | | **Version detection** | **Use release date as version string** | 4. Tap **Add**, then **Install**, and accept Android's install prompt. 5. Open Pragma Go and [pair it with your desktop](#pairing-a-device). <Callout title="Why release date for the version?"> A `pragma-go` release tag numbers the mobile release, not the app's own version string, so the two do not match. Tracking by release date means Obtainium offers exactly one update per new release instead of reporting an update that never goes away. </Callout> Updates arrive the same way: Obtainium checks in the background, notifies you, and installs the new APK over the old one with your data intact. ## Pairing a device [#pairing-a-device] Settings → **Pragma Go**: 1. Turn on **Remote access**. Pragma starts the tunnel and shows a **QR code**. 2. Scan it with Pragma Go. The payload contains the tunnel URL, the gateway token, and the gateway API version, which the app checks before it stores the connection. Prefer manual? Expand **Manual** for copyable **URL** and **Token** rows. The tunnel command lives in `~/.pragma/config.json` under `tunnel`; the default uses `ngrok http {port}`. Any command that prints a URL works — `urlPattern` tells Pragma how to read it back. **Regenerate token** invalidates every paired device. ## Pragma Go on the web [#pragma-go-on-the-web] **Enable web access** serves the Pragma Go web build from the same gateway. When the tunnel is active you get a **Web app link** — it carries the token in the URL *fragment* (#), which browsers never send to a server, so the link can be pasted into a browser on any machine. The warning is real: the link contains the token, treat it like a password. ## What you can do from the phone [#what-you-can-do-from-the-phone] * Create worktrees and launch agents — sessions appear on the desktop as background tabs. * Watch running sessions and read scrollback. * **Answer questions and approve commands** — the same prompts the desktop shows, answered from wherever you are. * Read and comment on [scratchpads](/docs/user-guide/scratchpads), including embedded [whiteboards](/docs/user-guide/whiteboards), which the host renders for the phone. * Your custom agents and their icons resolve exactly as they do on the desktop. ## Notifications [#notifications] Agent status drives push notifications to paired devices, using the same wording and sound choices as the desktop. If the desktop is focused, pushes are suppressed — one device needs the answer, not both. <Callout title="Security model"> The gateway binds to localhost. Only the tunnel you configure exposes it, every `/v1` route requires the bearer token, and the web bundle itself is served unauthenticated — it is public code, the data routes are not. </Callout> # Project scripts (/docs/user-guide/project-scripts) Project scripts are commands the project itself declares, checked into `.pragma/scripts.json` at the project root: ```json { "setup": ["bun install", "cargo fetch"], "teardown": ["docker compose down"], "runScripts": { "run": [{ "command": "bun run dev" }], "build": [{ "command": "bun run build" }], "test:watch": [{ "command": "bun run test --watch" }] } } ``` <Callout title="Setup and teardown are headless; run scripts are tabs"> `setup` and `teardown` run without a visible terminal. `runScripts` open as real terminal tabs you can watch and interact with. </Callout> ## Setup and teardown [#setup-and-teardown] * **`setup`** runs headlessly after a worktree is created — it is the **"Running scripts"** step of the creation screen. New worktrees arrive with dependencies installed and caches warm. * **`teardown`** runs headlessly before a worktree is deleted. **A failure blocks the deletion** — use it to stop containers, release ports, or clean caches that must not leak. Commands run concurrently, capped at four at a time. ## Run scripts [#run-scripts] Every key under `runScripts` becomes a toolbar button on the tab strip: * **`run`** and **`build`** are reserved defaults — Play for run, Hammer for build. * Any other key is a custom button ("Run test:watch"), with an optional Iconify `icon`. * While active, the button turns into a stop control; stopping restores the worktree's previous split layout (a run script temporarily replaces it). * Config changes hot-reload — edit `.pragma/scripts.json` and the buttons update within a moment. Parse errors surface in the button tooltip. Running scripts also appear in the command palette: Enter opens the script tab, Shift+Enter closes it. <Callout title="Where did my buttons go?"> Scripts are defined per project. A worktree created before you added `runScripts` gets the buttons as soon as the file is saved — no restart needed. </Callout> A typical split: `run` on the left, the agent terminal in the middle, [browser](/docs/user-guide/browser) pointed at the dev server on the right. # Quick start (/docs/user-guide/quick-start) This guide walks you through the shortest useful loop: add a project, create a worktree with an agent in it, review what the agent did, and ship it. ## 1. Add a project [#1-add-a-project] Open the **Add project** menu at the bottom of the left sidebar. Two tabs: * **Local** — open an existing git checkout from disk, or paste a remote URL and **Clone remote** into Pragma's projects directory. * **Remote** — connect a project over SSH (see [SSH support](/docs/user-guide/ssh)). <Callout title="Git required"> Pragma drives real git worktrees, so the folder you add must be a git checkout (or a remote URL git can clone). </Callout> The project appears in the project switcher at the bottom of the sidebar, with the repository's main checkout shown as the **main** worktree. ## 2. Create a worktree with an agent in it [#2-create-a-worktree-with-an-agent-in-it] Click **New worktree off main** (the plus button at the top of the sidebar): 1. Give it a **branch name** — spaces become dashes. 2. Optionally set a **display title** so the sidebar reads nicely. 3. Pick an **agent** and, where the agent supports it, a model. 4. Type a **prompt** describing the task. A rich markdown editor, so paste specs freely. Press **<Keys mac="⌘↵" other="Ctrl+↵" />**. Pragma creates the git worktree, opens a terminal, and launches the agent with your prompt. Leaving the prompt empty still creates the worktree — with an empty terminal and no agent session. <Callout title="Main is behind its remote?"> If main has commits to sync, Pragma asks whether to sync before creating the worktree. Syncing first keeps your new branch up to date; you can also create without syncing. </Callout> ## 3. Watch it work [#3-watch-it-work] The worktree appears in the sidebar under its parent. Its agent dot tells you the story without opening anything: * **Yellow** — running. * **Green** — done. * **Red** — needs you: a question, or a command approval. When the agent finishes you get a chime and a system notification (configurable under [Agent status settings](/docs/user-guide/theming#agent-alert-sounds)). ## 4. Review the changes [#4-review-the-changes] Open the **Changes** tab on the right sidebar. Staged, unstaged, and committed changes are listed separately, and clicking any file opens a unified diff beside the terminal the agent wrote it in. * Diffs resolve against the worktree's **parent branch**, so a moving main never scrambles the review. * Stage files, commit with a generated message (**Shift+Tab** to let AI write it), and push — no second git client. * Before pushing, review the branch diff against its parent so generated changes never hide between commits. See [Files & Git changes](/docs/user-guide/files) for the full tour. ## 5. Ship it [#5-ship-it] Open the **Pull Request** tab (right sidebar), draft the title and body — AI drafts them from the actual diff — and open the PR. From there you can read review threads inline, flag comments into a fix-it list, and hand the list to an agent. ## 6. Merge and clean up [#6-merge-and-clean-up] When the PR is merged, the worktree's icon becomes a merge glyph in the sidebar. Delete the worktree from its context menu — optionally deleting the branch too. If the project defines `teardown` scripts in `.pragma/scripts.json`, they run before the checkout is removed. ## Where to go from here [#where-to-go-from-here] * [Fan out one prompt to several agents](/docs/user-guide/worktrees#fan-out) and keep the winner. * Manage prompts as cards on the [agent board](/docs/user-guide/agent-board). * Pair your phone with [Pragma Go](/docs/user-guide/mobile). # Scratchpads (/docs/user-guide/scratchpads) A scratchpad is an agent-authored MDX document. Instead of a wall of terminal text, an agent publishes something you can read at your own pace, edit, comment on, and hand back. ## Where they live [#where-they-live] `.pragma/scratchpads/` in the worktree, git-excluded. The sidebar's **Scratchpads** card lists the selected worktree's documents; each opens as its own tab. A scratchpad is two files: `<name>.mdx` (the document, with a managed frontmatter line Pragma maintains) and `<name>.mdx.comments.json` (the comment thread). Don't author scratchpad files by hand — the managed frontmatter is what makes the tooling work. ## Reading and editing [#reading-and-editing] The scratchpad tab has two modes sharing one buffer: * **Editor** — rich markdown editing; MDX components render live in sandboxed iframes. Agents can ship interactive blocks (questions, diff reviews, progress) using the scratchpad UI components. * **Raw** — CodeMirror with inline <Keys mac="⌘K" other="Ctrl+K" /> AI edits, for tweaking the source directly. If the file changes on disk (the agent updated it), a **Changed on disk — reload** button appears. ## Diagrams [#diagrams] An agent can embed a [whiteboard](/docs/user-guide/whiteboards) with `<Whiteboard id="…" />`: a live, theme-matched render of a durable Excalidraw board in the same worktree. Click it in the desktop app to open the interactive board in its own tab — editing happens there, not inside the document. ## Commenting [#commenting] Select a block and leave a comment. Comments collect in the thread; sending submits as **one prompt to the attached agent** — the agent re-reads the document and addresses each comment in order. If no agent is attached, Pragma offers a picker of the worktree's agent sessions. ## Creating them [#creating-them] Agents create scratchpads from their own terminal: ```sh pragma-cli scratchpad create --title "Architecture" result.mdx ``` The command writes the managed frontmatter, registers the document, and opens a scratchpad tab for you. When a fanout pick promotes a winner, the winner's scratchpads are promoted with it — the comparison's scratchpad columns stay with the work that survives. <Callout title="Mobile and browser"> Paired devices read scratchpads through the same host — comments and agent handoff work identically from the phone. See [Mobile app](/docs/user-guide/mobile). </Callout> # SSH support (/docs/user-guide/ssh) Add a project over SSH from **Add project → Remote**: host, port (default 22), user, and the project path on that machine. Authentication is whichever your machine supports: * **Agent** — your running `ssh-agent` identities. * **Key** — a key file with an optional passphrase. * **Password**. ## How it works [#how-it-works] Pragma bootstraps its own `pragma-server` on the remote machine and connects to it through an SSH channel. The remote server owns the remote terminals, git operations, file tree, and worktrees; your desktop renders them. Connection details (host, port, user, auth method) persist locally; agent authentication reconnects automatically. <Callout title="One model, no sync layer"> There is no file syncing and no polling: everything — terminals, diffs, file views — is served live from the remote host, exactly like a local project. </Callout> ## What works unchanged [#what-works-unchanged] * Terminals and agent sessions, with status dots, notifications, and chimes. * Worktrees — create, nest, fan out, merge, delete on the remote host. * The file tree, editor, diffs, PDF and media viewers. * Git operations and the GitHub views. ## What differs [#what-differs] Remote worktrees disable the features that must run on your machine: * **Open in editor** launchers (VS Code, Cursor, …). * **Inline AI edit** (<Keys mac="⌘K" other="Ctrl+K" />) and the palette's **Ask AI**. Everything else behaves as it does locally. Fanouts, the agent board, scratchpads, and [usage limits](/docs/user-guide/usage-limits) are all host-driven, so they work the same on a remote project. # Theming & customization (/docs/user-guide/theming) ## Colour overrides [#colour-overrides] Every colour in the app is a token, and every token can be overridden in an optional `.pragma/theme.json`: ```json { "colors": { "dark": { "canvas": "oklch(0.16 0.01 260)" }, "light": { "canvas": "oklch(0.98 0.005 260)" } } } ``` Two scopes, merged per token — defaults ← global (`~/.pragma/theme.json`) ← project (`<project>/.pragma/theme.json`). Switching projects re-applies instantly, so a project can carry its own look. Settings → **Theme** edits either scope with live previews and the built-in palette picker. ### Token groups [#token-groups] | Group | Tokens | | ----------------- | ------------------------------------------------------------------------------------------ | | **Surfaces** | canvas, background, foreground, elevated, card, popover | | **Controls** | primary, secondary, muted, accent (each with foreground variants, primary adds hover) | | **Edges & focus** | border, input, ring, selection, overlay | | **State** | destructive, success, warning, skill, diff-added, diff-removed | | **Sidebar** | sidebar, sidebar-foreground, sidebar-primary, sidebar-accent, sidebar-border, sidebar-ring | Values are `oklch(...)` strings. The app renders dark-only; the light ramp exists for the preview and for future use. ## Built-in palettes [#built-in-palettes] Settings → **Theme** ships with: **Pragma** (default), **GitHub**, **Vercel**, **VS Code**, **Atom One**, **Solarized**, **Gruvbox**, **Catppuccin**, **Rosé Pine**, **Tokyo Night**, **Ayu**, and **Zed**. Applying a preset replaces only the selected scope's `colors` block — your other settings stay. Plugins can contribute further palettes with `defineTheme` (see [Plugins → Themes](/docs/plugins/runtime)). ## Agent alert sounds [#agent-alert-sounds] When an agent reports **done** or **needs attention**, Pragma plays a chime and posts a system notification. Per scope (Settings → **Agent Status**, global and project): * `notificationsEnabled` — on/off. * `soundName` — a clip from `.pragma/assets/sounds/` in the home directory (global) or project root (project). Import your own: up to 5 seconds, mp3/wav/ogg/m4a/aac/flac/ webm, 5 MB. Notification text follows templates — "`{agent}` finished", "`{agent}` needs attention", "`{agent}` is waiting for an answer", "`{agent}` wants to run a command" — and macOS notifications deep-link to the exact worktree and tab. ## Terminal appearance [#terminal-appearance] The terminal uses JetBrainsMonoNL Nerd Font at 14px with a 5000-line scrollback limit. Shell selection per project is a settings topic — see [.pragma/config.json](/docs/user-guide/core-model#where-things-live) and Settings → **Terminal** / **WSL**. # Updates (/docs/user-guide/updates) Pragma checks for a new version in the background (every few minutes and when the window regains focus). When one is available, **Install Update** appears above the project switcher. Turn the automatic check off, or point it at another update server, under Settings → **Other**. ## Two kinds of update [#two-kinds-of-update] | Kind | What changes | What happens when you install it | | ----------- | --------------------- | ------------------------------------------------------------------------------- | | **Reload** | Only the interface | The window reloads in place. Terminals and agents are untouched. | | **Restart** | The app or its server | Pragma quits, installs the new version over the old one, and relaunches itself. | A restart update asks first, because it can restart the Pragma server that runs your terminals: open terminal sessions stop. After relaunching, Pragma replaces the server only if the update shipped a different server binary. ## How a restart update installs [#how-a-restart-update-installs] | Platform | What Pragma does | | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | | macOS | Mounts the disk image out of sight, copies the new `Pragma.app` over the one you are running (wherever it lives), and reopens it. | | Windows | Runs the installer silently after Pragma quits, then reopens Pragma. | | Linux (deb / rpm) | Asks for your password with the system prompt, installs the package, then reopens Pragma. | If Pragma cannot install in place, for example because it runs from a read-only location such as the mounted disk image, or you cancel the password prompt, it opens the installer instead and tells you why. Finish it, then relaunch Pragma. AppImage builds are not offered restart updates. Every download is checked against a SHA-256 digest and a signature from the Pragma release key before anything is installed. The helper that finishes the install writes what it did to `install.log`, next to the downloaded installers in the app data directory. # Usage limits (/docs/user-guide/usage-limits) The **Usage limits** popover (the gauge icon in the toolbar, left of the project script buttons) shows what each connected agent's provider reports about your plan or API usage. ## What it shows [#what-it-shows] One accordion section per provider: * Collapsed: the provider's icon and **primary limit** as "N% used" with a progress bar. * Expanded: every limit the provider reports, each with its percentage, a **Resets in** countdown, and a **View dashboard** button that opens the provider's own site. The bar turns warning-coloured from 50% and destructive from 75%. A plan without a finite limit reads **Unlimited**. ## Providers [#providers] Usage limits are a plugin contribution, so coverage follows the agent integrations. Shipped with Pragma: | Provider | Notes | | -------------- | ---------------------------------- | | Claude Code | Claude Pro/Max subscription usage. | | Codex | ChatGPT Plus/Pro usage. | | Cursor | | | OpenCode Go | | | Grok | | | GitHub Copilot | | | Junie | | Pi and Prime Agent do not report limits until their CLIs expose an endpoint Pragma can read. Building a provider for your own agent is part of the [plugin API](/docs/plugins/usage-limits) — `defineUsageLimitProvider`. ## Refreshing [#refreshing] Each provider declares its own refresh interval (at minimum 15 seconds), with exponential backoff up to 15 minutes on failures. Opening the popover always refreshes. Providers report their own unavailable states — "not configured", "authentication required", or a plain error — instead of fake numbers. # Whiteboards (/docs/user-guide/whiteboards) A whiteboard is a **durable Excalidraw canvas scoped to a worktree**. It is not a file in your checkout: the host owns it, so an agent can create one from its terminal, you can edit it in a tab, and a scratchpad can embed it as a live diagram — all of them looking at the same board. Reach for one when the answer is spatial: architecture, request flow, topology, a sequence, a dependency graph, a process you want to redraw together. ## Opening one [#opening-one] * **New tab menu → Whiteboard**, or <Keys mac="⌘⇧W" other="Ctrl+⇧W" />, creates a board and opens it. * The sidebar's **Whiteboards** card lists the selected worktree's boards — click to open (the tab is deduplicated, so a board already open is focused rather than duplicated), right-click to delete. The card is inventory only, and stays hidden until the worktree actually has a board. * Renaming the tab renames the board, and an agent renaming the board renames the tab. Edits save themselves: the canvas debounces writes back to the host. If another writer — the CLI, the SDK, a second window — changed the board while you were drawing, Pragma re-reads the newer version and re-applies your edits on top rather than silently overwriting them. ## Agents and whiteboards [#agents-and-whiteboards] Agents work with the same boards through [`pragma-cli whiteboard`](/docs/cli/whiteboard): ```sh pragma-cli --json whiteboard create --title "Request flow" scene.excalidraw pragma-cli --json whiteboard search "gateway" pragma-cli whiteboard view <id> /tmp/request-flow.png ``` The scene is complete Excalidraw JSON, stored losslessly, so a board an agent drew stays fully editable by hand and a board you drew stays readable by the agent. The shipped `pragma` skill teaches agents the scene contract and the create/edit/verify workflow, so asking one for "a diagram of this" is usually enough. ## Inside a scratchpad [#inside-a-scratchpad] A [scratchpad](/docs/user-guide/scratchpads) embeds a board by id: ```mdx import { Whiteboard } from "@pragma-sh/scratchpad/ui"; <Whiteboard id="whiteboard-id-from-create" /> ``` The embed is a live, theme-matched PNG rendered by the host — it refreshes when the board's version changes, and it renders the same on the desktop, on the phone, and in the browser build. Clicking it in the desktop app opens the interactive board in its own tab. The board and the scratchpad must belong to the same worktree. <Callout title="Prefer a whiteboard over a Mermaid fence"> For spatial or flow diagrams, a whiteboard beats a text-diagram fence: you and the agent can both revise the same canvas. Mermaid is still the right answer when you need text-only source or rendering outside Pragma. </Callout> ## Where they live [#where-they-live] Boards live in the host server's own SQLite database (`whiteboards.db` in the [per-instance server directory](/docs/wiki/disk-layout)), keyed by worktree — never in the repository, so nothing lands in a diff or a commit. Deleting a worktree deletes its boards; deleting a board a scratchpad embeds leaves that embed with nothing to render. # Creating a worktree (/docs/user-guide/worktrees) <video src="/media/worktrees.mp4" /> Everything Pragma does starts from a worktree. This page covers the whole left sidebar: the creation dialog, fanout mode, and every affordance on a worktree row. ## The worktree tree [#the-worktree-tree] The sidebar lists the selected project's worktrees: * **main** first, then children. Nested worktrees render **indented** under their parent with expand/collapse carets. * Each row shows an agent status dot (aggregate of the worktree's tabs) and, once a worktree is merged or its PR merged, a merge glyph instead of the branch glyph. * Hold <Keys mac="⌘" other="Ctrl" /> to see `1…9` number badges and jump straight to a worktree. * Hovering a non-main row reveals three actions: **Pin**, **New child**, and **Delete worktree**. **Right-click a row** for the full menu: Rename · Pin / Unpin · Copy worktree path · Copy branch name · **Open in editor** (VS Code, Cursor, Windsurf, Zed, Sublime Text, IntelliJ IDEA, or the file explorer) · Hide · Delete. ## Creating a single worktree [#creating-a-single-worktree] Click **New worktree off main** at the top of the sidebar (or **New child** on a row to branch from that worktree instead of main). The dialog: | Field | Behaviour | | --------------- | -------------------------------------------------------------------------------------- | | **Branch name** | Required; spaces become dashes. | | **Title** | Optional display name shown in the sidebar; defaults to the branch name. | | **Agent** | Optional agent + model selection; the model list loads from the agent itself. | | **Prompt** | A markdown editor describing the task. Empty prompt = create the worktree, no session. | Submit with **Create worktree** or **<Keys mac="⌘↵" other="Ctrl+↵" />** — the shortcut works from any field. <Callout title="The prompt drives the agent"> The prompt is passed to the agent exactly as written. Write it like you would type it into the agent's own TUI — it is the same conversation, just launched for you. </Callout> While it creates, a full-frame progress screen walks through **Syncing base** (when you accepted the sync offer), **Creating worktree**, and **Running scripts** when the project defines `setup` commands in `.pragma/scripts.json`. A failure before the worktree exists offers Dismiss; a later failure offers Retry. ### Syncing main first [#syncing-main-first] If main is behind its remote, a **Main is behind remote** alert asks whether to sync before creating. Sync first for up-to-date branches, or create without syncing — for example when you are deliberately branching off an older base. ## Fan out [#fan-out] <video src="/media/fanout.mp4" /> One prompt, several isolated attempts, keep the winner. Switch the dialog to **Fan out**: * Each **attempt row** has its own agent and model selection. Mix and match — try the same task on two agents, or two models on one agent. There is no attempt-count ceiling; two is the minimum. * The branch field is always required: Pragma creates a fresh **coordination parent** worktree branched from where you opened the dialog. Attempts branch under it, each from the same captured base commit — so their diffs are directly comparable. * Submit with **Create & Fanout**. Attempts group under the parent in the sidebar, labelled by harness and model, each with its own status dot. The parent row carries a fanout indicator that opens the comparison. ### Comparing attempts [#comparing-attempts] **Compare implementations** (toolbar, with the fanout parent selected) replaces the workspace with a column per attempt: * **Scratchpads** — the attempts' scratchpads paired across columns. * **Agent sessions** — every column is a real, interactive terminal. * **Code** — each attempt's diff against the shared base commit, deletions red and insertions green. Resize columns; pick nothing until you have looked around. ### Keeping a winner [#keeping-a-winner] **Pick this implementation** (or **Pick implementation** on an attempt's toolbar) opens the `Keep {attempt}?` dialog. Pragma will: 1. Commit any uncommitted winner work under an AI-generated message. 2. Merge the winner into the parent worktree. 3. Promote the winner's scratchpads to the parent. 4. Stop sessions and delete every attempt — the winner included. This cannot be undone. A merge conflict stops before anything is deleted and leaves every attempt in place; a merge failure parks the fanout so you can retry the finalization. You can also `pragma-cli fanout pick --member <member-id>` from a terminal — agents do this too, via their skill. Other fanout actions: **Retry** relaunches a failed attempt in its existing worktree, **Cancel** keeps the checkouts and marks the fanout cancelled. ## Pinned worktrees [#pinned-worktrees] Pin the worktrees you keep coming back to: * **Pin** appears on row hover and in the context menu. * Pinned rows are **promoted to the top**, newest pin first, separated from the tree by a separator. * A filled pin glyph on the row unpins on click. Pins are per user (stored locally), not checked into the repo. ## Renaming, hiding, deleting [#renaming-hiding-deleting] * **Rename** — double-click a row (or the context menu). Enter commits, Escape cancels. Renaming changes the display title only; the branch keeps its name. * **Hide** — tucks a finished worktree out of the way. Hidden worktrees are listed under a collapsible **"Show N hidden"** section with a per-row **Show worktree** button. * **Delete** — removes the checkout. Refuses when the worktree has uncommitted changes unless you force it. Project `teardown` scripts run first and can veto the deletion by failing. Optionally delete the branch too. ## Ports and scratchpads cards [#ports-and-scratchpads-cards] Below the tree, the sidebar shows two cards when they have content: * **Ports** — open TCP listeners grouped by worktree and terminal tab; click to focus the tab that owns the process. Only processes started from Pragma terminals are listed. * **Scratchpads** — the selected worktree's agent-authored documents. See [Scratchpads](/docs/user-guide/scratchpads). # Architecture (/docs/wiki/architecture) ## Processes on a host [#processes-on-a-host] | Process | Spawned by | Role | | -------------------- | ----------------- | ------------------------------------------------------------------------------------------------- | | `pragma-server` | Desktop app | Persistent host: PTY sessions, scrollback, agent status, RPC, subscriptions, sidecar supervision. | | `pragma-gateway` | Desktop app | Localhost HTTP/JSON API in front of the server socket. | | `pragma-automations` | Server | Automation discovery/execution sidecar (long-lived, NDJSON stdin). | | `pragma-plugins` | Server | Plugin catalog sidecar: catalog, assets, usage limits, server-side plugin hooks. | | `pragma-watch` | Server | **One per live agent session** — types phone/app interjections into agent TUIs. | | `pragma-ai` | Server (one-shot) | AI helpers — e.g. commit messages during a fanout pick. | | `pragma-github` | Staged/bundled | GitHub helper sidecar (keeps credentials host-side). | | tunnel (`ngrok` …) | Server | Remote access; lifetime = server lifetime, not app lifetime. | | `pragma-cli` | App (installed) | CLI in agent terminals; connects to the socket per command. | | Desktop app | You | The controller: UI + control broker. | Sidecar staging is keyed in `tauri.conf.json` `bundle.externalBin`; on Windows the NSIS installer stops sidecars explicitly (`installer-hooks.nsh`) because a running image cannot be overwritten. ## Socket, channel, and auth [#socket-channel-and-auth] * The server listens on exactly one **`daemon.sock`** per *channel*, in a channel-scoped directory (`server_paths()` in `crates/pragma-server/src/main.rs`): macOS `~/Library/Application Support/com.pragma.app/<channel>`, Linux `$XDG_RUNTIME_DIR/<channel>` (fallback app data), Windows `%APPDATA%\com.pragma.app\<channel>`. * Production channel is `pragma`; dev builds derive `pragma-dev-<hash>` from the workspace root, so a dev instance never fights a production install. * **There is no in-band auth.** The socket is bound then `chmod 0600` (Unix) or given an owner-only ACL (Windows) — filesystem permissions are the entire access-control story. This is also why the WSL bridge relays over stdio instead of forwarding a TCP port. ## Launch sequence [#launch-sequence] 1. The app resolves its channel, opens its SQLite store. 2. `PtyClient::connect_with_spawn()` probes the socket: connect, read the `Hello` frame, compare `protocolVersion` and — in a bundled release — `buildId`, the hash of the server binary, against the `pragma-server` the app ships. 3. No server, or a mismatch (an update installed a different server) → `kill_stale_server()` reads the pid from `server.lock`, verifies the process name, and kills the **whole process tree** (sidecars, watchers, PTYs, tunnel) — then `spawn_server()` starts a fresh one (`--detach`, logs appended to `server.log`). 4. The server raises its open-file limit, `flock`s `server.lock` (pid inside), takes the socket path (live server refuses; dead socket file unlinks), binds owner-only, and writes `Hello` as the first frame of every accepted connection. 5. The **first request decides the connection class**: `RegisterController` makes it the app's control connection; anything else is a plain client. 6. The app ensures the gateway (`gateway.json` health check, else spawn + wait for the discovery file) and starts one **agent event stream per connected host** — local plus each SSH remote — with a 500 ms reconnect loop. 7. The frontend publishes workspace snapshots to the server (debounced) so headless and phone launches keep working while the app is closed. ## One server per channel [#one-server-per-channel] `flock` + socket liveness probe cover each other's blind spots, and nothing ever deletes `server.lock`. Replacement works because the app always kills the old tree first. The same discipline applies to the gateway: a live same-version gateway is reused; a live *different*-version gateway is killed by verified pid and replaced. ## The controller split [#the-controller-split] Two request kinds make the split real: * **Control requests** (`POST /v1/control/*` from the SDK, or `Control` frames) are forwarded to the connected controller — the desktop — whose `ControlResult` answers them. Creating an agent-board draft, launching a session "through the app", and plugin UI state live here. If no controller is connected, launch-style control requests degrade gracefully: the server creates the worktree itself and the desktop adopts it from disk on next start (`adopt_headless_worktrees`). * **Everything else** — spawn, attach, RPC, subscriptions — the server answers alone. # Client & bridges (/docs/wiki/client-bridges) `crates/pragma-client` is the synchronous transport library the desktop (and future CLI code) uses to talk to hosts. It is endpoint-agnostic above the transport: an endpoint is either a **managed local server** or a bare **socket path** backed by a bridge. ## PragmaClient [#pragmaclient] * A small idle **connection pool** (4) so concurrent RPCs run in parallel on their own connections; a transport error retries once on a fresh connection. * `rpc(method, payload)` waits without a deadline while host work runs (a user's `git push` hooks can be slow), restoring timeouts before returning the connection to the pool. * `control(method, payload)` waits unbounded on purpose — a timeout-plus-retry here could duplicate an `agentSessionLaunch` that already succeeded. * **Input writer**: a dedicated thread with a bounded queue (256 messages / 4 MiB, frames split at 64 KiB) and capped backoff, so a saturated PTY never blocks callers. * Server bootstrap: `connect_with_spawn` / `connect_compatible` (Hello protocol + build-id probe), `kill_stale_server` (verified-pid process-tree kill), `spawn_server` (channel + data dir + resource dir env; logs to `server.log`), `restart()`, `read_log()`, `server_protocol_version()`. ## Event streams [#event-streams] `open_event_stream` connects, writes the request, reads frames until the matching `Response`, then **clears the read timeout** and hands the socket to a pump. The 5 s read timeout exists for request mode only — an idle subscription must not look like a dropped connection. Used for attach (with cursor), subscriptions, and the agent stream. ## SSH bridge (`ssh.rs`) [#ssh-bridge-sshrs] A remote project is served by a `pragma-server` running **on the remote host**, reached through SSH streamlocal forwarding: 1. Authenticate (agent → key+passphrase → password), run a bootstrap command that ensures the remote server (right binary, right version), and bind a **local** owner-only socket. 2. Each local connection opens one `channel_open_direct_streamlocal` to the remote socket; a pump copies raw bytes. **No reframing, no interpretation** — the local endpoint behaves exactly like the remote socket. `ssh_exec` runs one-shot remote probes (paths, git version). Readiness timeout is 10 s. Non-secret route metadata (host, port, user, auth method) persists in the client's local router SQLite (`router.rs`). ## WSL (`wsl.rs`) [#wsl-wslrs] Two separate problems, only the first shipped: * **Shell selection (built)**: a session can launch `wsl.exe -d <distro>` on the host's own ConPTY. The chosen shell travels as a `ShellProfile` and resolves through `pragma_platform::shell::resolve_profile_launch`. * **Host-level WSL (not built)**: `start_wsl_bridge` exists but nothing calls it. The design is deliberate: the Linux `pragma-server` runs *unchanged inside the distribution*, and the bridge relays over the stdio of `wsl.exe -d <distro> --exec pragma-server --relay` instead of forwarding a TCP port — because socket owner-only permissions are the whole access-control story, and a localhost listener would trade that for a user setting. The WSL server uses its own channel (`pragma-wsl`) so its socket lands at `$HOME/.pragma/pragma-wsl/daemon.sock` inside the distro. ## Relay mode [#relay-mode] `pragma-server --relay` turns the server into a full-duplex byte pipe between stdin and its socket: 32 KiB buffers, two threads, no interpretation, exit when either side closes. It is the half of the WSL design that already ships, and the transport-agnostic way to reach a server across any stdio-capable channel. # Core (/docs/wiki/core) `crates/pragma-core` is the host's business-logic boundary: synchronous, no Tauri, no I/O surprises. `Core::handle_rpc` implements exactly five RPC domains — `filesystem`, `git`, `exec`, `scratchpads`, `whiteboards` — and returns `UnsupportedMethod` for everything else (server- or desktop-owned). `rpc.rs` maps `CoreError` onto protocol error codes. ## git (git.rs) [#git-gitrs] The worktree engine. Highlights: * **CreateWorktree** — `git worktree add -b <branch> <path>` from the parent, or `sourceBranch` to materialize an existing `origin` branch as a tracking worktree (fetch refspec + `--track`) — how stacked PR layers get local worktrees. * **CreateWorktreeAt** — `git worktree add -b … <commit>` with commit validation: fanout attempts all branch from one captured base so their diffs are comparable. * **Worktree location** — `<project>/.pragma/worktrees/<id>`; headless/fanout checkouts use uuid names. * **Git exclusions** — `ensure_pragma_excluded` writes `.pragma/worktrees/` and `.pragma/scratchpads/` into the repo's real `$GIT_DIR/info/exclude` (asking git for the common dir rather than assuming `.git`), migrates a legacy broad `.pragma/` entry, and is idempotent. * **Inspect/stage/commit/discard** — worktree changes (merge-base diffing), staging helpers, commits, merge-to-parent, PR file diffs, GitHub push/pull/sync/abort helpers, branch deletion, dirtiness. * Every git subprocess goes through `process_env::git()` so a GUI-launched host finds `git` on `PATH`. ## filesystem (fs.rs) [#filesystem-fsrs] `ListDir, CreateFile, CreateFolder, PathExists, HomeDir, ReadFile, WriteFile, ListFileNames, ReadBytes, ReadBytesRange, WriteBytes, Rename, Delete, PaletteSearch`. * **Containment**: every path resolves through `resolve_in_worktree`, canonicalizing both sides with `pragma_platform::path::canonicalize` (never `std`'s, whose Windows `\\?\` verbatim output breaks git and prefix checks). * Binary reads are chunked (4 MiB) with caller-driven paging — how the PDF viewer streams. * `PaletteSearch` is the command palette's file finder: bounded filename + smart-case literal search over `git ls-files` output, with deadlines and cancellation. ## exec (exec.rs) [#exec-execrs] Bounded, concurrent command execution for setup/teardown scripts and the CLI's `tab exec`. Setup/teardown runs get `PRAGMA_WORKTREE_PATH`, `PRAGMA_PROJECT_PATH`, and `PRAGMA_WORKTREE_ID` in their environment. ## scratchpads (scratchpads.rs) [#scratchpads-scratchpadsrs] The host-side parser the desktop, gateway, and CLI all agree on: * Lists managed MDX documents under `.pragma/scratchpads/` — files **without** the managed `pragmaScratchpad` frontmatter line (or with an unknown version) are skipped, which is exactly why hand-written files are invisible. * `comments_path(file)` is the sibling `<file>.mdx.comments.json`. * `detach_agent` nulls the attachment fields — used by fanout promotion before attempt worktrees are deleted. ## whiteboards (whiteboards.rs) [#whiteboards-whiteboardsrs] Durable, worktree-scoped Excalidraw boards, owned by the host rather than the checkout: * SQLite storage (`whiteboards.db` in the server directory) with create, get, list, search (title **and** text elements), edit, and delete. * Scene JSON is stored **losslessly** — unknown Excalidraw fields survive a round trip — and edits carry the version they read, so a concurrent writer is rejected instead of overwritten. * `view` renders a PNG natively here, never through a canvas in a webview, which is what lets scratchpad embeds and the mobile client show the same diagram. ## fanout (fanout.rs) [#fanout-fanoutrs] The pure rules the server's state machine calls: `resolve_selector` (`agent[.model[.reasoning]]` against the catalog), `attempt_branch` (`fanout/<fanoutShortId>/<memberShortId>`), `short_id`, `derive_title` (first prompt line), `aggregate_status` (roll-up that never overrides finalize-owned states), `is_active`, and `promotion_path` (scratchpad promotion naming on collision). ## Project config resolution [#project-config-resolution] `.pragma/config.json` reads are **tolerant by design** — absent, malformed, or typo'd files fall back to defaults rather than failing a session. Terminal fields (`terminal.shell`, `backend`, `distro`, `hiddenDistros`) resolve per field: project scope first, then the home file, so a project that pins only `shell` still inherits the global `backend`. # Disk layout (/docs/wiki/disk-layout) ## Per-instance server directory [#per-instance-server-directory] Scoped by channel. macOS: `~/Library/Application Support/com.pragma.app/<channel>/`; Linux: `$XDG_RUNTIME_DIR/<channel>` (fallback: app data dir); Windows: `%APPDATA%\com.pragma.app\<channel>`. Production `<channel>` is `pragma`; dev builds use `pragma-dev-<hash>`. | File | Purpose | | ------------------------ | --------------------------------------------------------------------- | | `daemon.sock` | The owner-only Unix socket. | | `server.lock` | `flock` target; contains the server pid. Never deleted. | | `server.log` | Server stdout/stderr (Troubleshooting → Open Server Logs). | | `gateway.json` | `{ port, token, pid, protocolVersion }` discovery file (0600). | | `gateway-token` | Persistent bearer token (0600). | | `gateway-devices.json` | Authenticated mobile installs + push tokens. | | `gateway.log` | Gateway stdout/stderr. | | `workspace.json` | Persisted workspace snapshot (headless launches with the app closed). | | `plugin-roots.json` | Registered project roots for the plugin catalog. | | `fanouts.json` | Durable fanout records (0600, atomic writes). | | `whiteboards.db` | Worktree-scoped Excalidraw boards (SQLite; scenes stored losslessly). | | `automations-state.json` | Per-content-hash trust verdicts for automations. | | `bin/pragma-cli` | Dev-channel CLI install target. | The desktop keeps its own SQLite state (kanban cards, settings, split layouts, worktree rows, selection) in the app data directory — tab *agent metadata* lives in the server's `workspace.json`, everything else client-side. ## `~/.pragma/` [#pragma] | Path | Purpose | | ------------------- | ----------------------------------------------------------------------------------------------------------- | | `config.json` | Global settings: `plugins[]`, `tunnel`, `agentStatus`, `github.prSignature`, `gateway.webEnabled`, `other`. | | `keybindings.json` | Global keybinding overrides. | | `theme.json` | Global colour overrides (merged: defaults ← global ← project). | | `automations/` | Global automations (implicitly trusted). | | `assets/sounds/` | Global agent alert clips (≤5 s, ≤5 MB). | | `automation-cache/` | The automations sidecar's managed dependency + entry cache. | | `plugins/npm/` | Official plugins installed through the app. | | `pragma-wsl/` | Inside WSL distros: the WSL server's socket directory. | The production CLI installs to `~/.local/bin/pragma-cli` (Windows: alongside the app data dir); that directory is prepended to `PATH` in Pragma terminals. ## `<project>/.pragma/` [#projectpragma] Checked in (worktree copies are never scanned for automations; the worktrees and scratchpads directories are git-excluded via `$GIT_DIR/info/exclude`). | Path | Purpose | | ------------------ | --------------------------------------------------------- | | `config.json` | Project settings: `plugins[]`, `terminal`, `agentStatus`. | | `keybindings.json` | Project keybinding overrides (win over global). | | `theme.json` | Project colour overrides (win over global). | | `scripts.json` | `setup` / `teardown` / `runScripts` definitions. | | `automations/` | Project automations (approval required). | | `assets/sounds/` | Project agent alert clips. | | `worktrees/` | Created worktrees (git-excluded). | | `scratchpads/` | Agent-authored MDX + comment threads (git-excluded). | ## `PRAGMA_*` environment variables [#pragma_-environment-variables] Set in every Pragma terminal session (plus extras noted): | Variable | Meaning | | ---------------------------------------------- | ---------------------------------------------------------------------------- | | `PRAGMA_TAB_ID` | Current tab/session id. | | `PRAGMA_WORKTREE_ID` | Owning worktree id. | | `PRAGMA_SERVER_SOCKET` | Server socket path (CLI target). `PRAGMA_DAEMON_SOCKET` is the legacy alias. | | `PRAGMA_GATEWAY_URL` / `PRAGMA_GATEWAY_TOKEN` | HTTP gateway endpoint + bearer token (SDK target). | | `PRAGMA_CLI` | Installed CLI binary path. | | `PRAGMA_FANOUT_ID` / `PRAGMA_FANOUT_MEMBER_ID` | Set inside fanout attempt sessions. | | `PRAGMA_WORKTREE_PATH` / `PRAGMA_PROJECT_PATH` | Set for setup/teardown script execution. | Process-level (not terminal sessions): `PRAGMA_APP_DATA_DIR`, `PRAGMA_SERVER_CHANNEL` (legacy `PRAGMA_DAEMON_CHANNEL`), `PRAGMA_RESOURCE_DIR`, `PRAGMA_AUTOMATIONS_CACHE`, `PRAGMA_WEB_ROOT`. # Gateway (/docs/wiki/gateway) `crates/pragma-gateway` is a tiny `tiny_http` server on `127.0.0.1` (ephemeral port by default) that translates HTTP/JSON into server-socket frames. It depends on socket I/O only — no `pragma-core`, no business logic. ## Routes [#routes] ``` GET /v1/health (no auth) GET /v1/version POST /v1/rpc/{method} — pass-through for protocol RPC methods POST /v1/sessions GET /v1/sessions/{id}/events POST /v1/sessions/{id}/input (octet-stream) POST /v1/sessions/{id}/resize DELETE /v1/sessions/{id} DELETE /v1/sessions?cwd=… — kill every session in a cwd POST /v1/agents/{reports,messages,decisions,answers,inputs,interrupts} GET /v1/agents/events (NDJSON) GET /v1/agents/catalog POST /v1/tabs/{tabId}/agents/seen GET /v1/subscriptions/{event} (NDJSON snapshot + deltas) GET /v1/theme?root=… GET /v1/scratchpads?root=… GET /v1/assets/{hash} — plugin assets by content hash POST /v1/control/{method} — brokered to the desktop controller POST|GET|DELETE /v1/push/tokens POST /v1/push/test POST /v1/push/presence GET /web/{*path} (no auth) — the web client bundle ``` Every `/v1` route requires the bearer token; only `/v1/health` and `/web` are public. `/v1/health` and `/v1/version` both answer with `gateway.apiVersion` alongside the daemon `protocolVersion` and the gateway's own crate version. Because health needs no token, a remote client can check the `/v1` contract before it stores a connection — which is how a hand-typed host, with no QR payload to read, is version-checked at all. ## Auth and discovery [#auth-and-discovery] * The token is 48 alphanumeric characters, persisted in `gateway-token` (0600), stable across restarts; the app's **Regenerate token** kills the gateway and deletes the file. `--token` overrides. * `gateway.json` beside the socket records `{ port, token, pid, protocolVersion }` (0600) — the discovery file everything reads. Terminal sessions get `PRAGMA_GATEWAY_URL`/`PRAGMA_GATEWAY_TOKEN` from it. * Bearer comparison is constant-time. Device identity rides headers (`x-pragma-device-id`, `-name`, `-platform`, `-app-version`) into a persisted device registry. * Startup conflict policy: live same-version gateway → reuse; live different-version → kill by **verified** pid and replace; dead file → remove. ## The web bundle [#the-web-bundle] Serving `/web` requires both `gateway.webEnabled: true` in the global config and a staged bundle (`--web-root` / `PRAGMA_WEB_ROOT`); missing either is a 404/503, never a half answer. * The bundle ships a **`manifest.json`** mapping every path to `{ file, contentType, etag, gzip, immutable }`. A request path is a **map key** — it is never joined onto a filesystem path, so path traversal is not expressible rather than merely blocked. * Text assets are stored gzip-only and served gzip-only. Content-hashed files are `immutable`; `index.html` is `no-cache`. Unmatched extension-less paths fall back to `index.html` (SPA routes); file-looking paths 404. * `/web` is deliberately **unauthenticated** — a browser cannot attach a bearer token to a `<script src>`. The bundle is public code; the data routes are not. The pairing link carries the token in the URL **fragment**, which browsers never send to a server. ## Push notifications [#push-notifications] Two background threads (worker + workspace mirror) subscribe to the server's agent status stream and push to registered Expo devices: * A **latch** per `worktree+tab+agent(+requestId)` mirrors the desktop's alert-once rule (`running`/`cleared` releases it). * A desktop **presence heartbeat** (`POST /v1/push/presence`, 90 s TTL) suppresses pushes while the desktop is focused — one device needs the answer, not both. * Notification wording is the same template set the desktop uses, rendered by a Rust twin of the app's text module. * `DeviceNotRegistered` from Expo drops the token. # Wiki (/docs/wiki) This section documents how Pragma actually works: the processes on a host, the wire protocol between them, and where every byte lands on disk. It is written for contributors and the curious — user-facing behaviour lives in the [user guide](/docs/user-guide). ## The one-paragraph version [#the-one-paragraph-version] The **desktop app is a controller, not a host**. It renders UI and brokers control decisions, but a persistent **`pragma-server`** owns the terminals, scrollback, agent status, and sidecars, listening on an owner-only Unix socket. A small HTTP **`pragma-gateway`** fronts the same server for remote clients — the mobile app, the web build, and `@pragma-sh/sdk`. **`pragma-cli`** is installed into agent terminals to speak to the socket directly. The desktop can therefore be closed, restarted, or updated while sessions keep running. <Cards> <Card title="Architecture" description="Processes, launch sequence, and the one-server-per-channel rule." href="/docs/wiki/architecture" /> <Card title="Protocol" description="Frames, requests, RPC methods, and subscription events." href="/docs/wiki/protocol" /> <Card title="Server" description="Sessions, scrollback, agent status, fanouts, and sidecars." href="/docs/wiki/server" /> <Card title="Client & bridges" description="The transport library, the SSH bridge, WSL, and relay mode." href="/docs/wiki/client-bridges" /> <Card title="Core" description="Pure host business logic: git, filesystem, exec, scratchpads." href="/docs/wiki/core" /> <Card title="Gateway" description="HTTP routes, auth, the web bundle, and push notifications." href="/docs/wiki/gateway" /> <Card title="Platform" description="The OS seams and why every platform difference lives there." href="/docs/wiki/platform" /> <Card title="Disk layout" description="Every file Pragma writes, and every PRAGMA_* variable." href="/docs/wiki/disk-layout" /> </Cards> ## Where the code lives [#where-the-code-lives] | Crate / package | Role | | ------------------------ | ----------------------------------------------------------- | | `crates/pragma-server` | Persistent host server: PTYs, status, fanouts, sidecars. | | `crates/pragma-client` | Client transport library + SSH/WSL bridges. | | `crates/pragma-core` | Pure host business logic (git, fs, exec, scratchpads). | | `crates/pragma-gateway` | Localhost HTTP gateway. | | `crates/pragma-platform` | OS seams: IPC, paths, perms, processes, shells, WSL. | | `crates/pragma-protocol` | Wire frames and shared names. | | `crates/pragma-cli` | The agent-facing CLI. | | `apps/pragma` | The Tauri desktop app (controller). | | `packages/*` | SDK, plugin API, automations, sidecars, agent integrations. | Shared constants flow from one JSON schema (`packages/constants/schema.json`) into both TypeScript and Rust, so the two languages cannot disagree about a wire name. # Platform (/docs/wiki/platform) `crates/pragma-platform` owns every OS difference. The rule: a platform difference is a **missing implementation that returns an error naming it** — never a bare `#[cfg(unix)]` at a call site with a silently-empty `#[cfg(not(unix))]` twin. That pattern is how a security guarantee once quietly disappeared (a GitHub token written world-readable on Windows), and the crate exists so it cannot happen again. ## The seams [#the-seams] | Module | Unix | Windows | What it owns | | --------- | ------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ipc` | `std::os::unix::net` (AF\_UNIX) | `uds_windows` (AF\_UNIX, Win10 1803+) | The local socket: bind with owner-restricted perms, connect, shutdown. Named pipes were rejected: they lack read timeouts and socket shutdown, which the wake-a-blocked-reader design depends on. | | `path` | `std::fs::canonicalize` | same, then **strip the `\\?\` verbatim prefix** | Canonical paths external programs accept; `home_dir`. Verbatim paths make git refuse to work and break prefix comparisons. | | `perms` | `chmod 0600 / 0700` | `icacls /inheritance:r /grant:r <user>:(F)` | Owner-only files and directories; `create_private_file` restricts the *empty* file before contents exist. | | `process` | `sysinfo`, `kill`, `pkill` | `sysinfo`, `taskkill`, `tasklist` | Kill, **kill-tree**, liveness (verified by process name), the process table, and windowless child spawning (`CREATE_NO_WINDOW` — a console child of a GUI process pops a console window without it). | | `shell` | `$SHELL` else platform default | probe `pwsh.exe` then `powershell.exe` | Which shell a PTY launches **and its interactive args**: PowerShell takes `-NoLogo`, never the POSIX `-l`; `resolve_profile_launch` maps a `ShellProfile` to a native launch or `wsl.exe -d <distro>`. | | `wsl` | no distributions, ever | parse `wsl.exe --list --verbose` | Distribution listing — lives here so a host answers the `wsl` RPC about *itself* (an SSH host must report its own distros). | ## The call-site rules these enable [#the-call-site-rules-these-enable] * Canonicalize with `pragma_platform::path::canonicalize`, never `std::fs`'s. * Spawn with `pragma_platform::process::command` (or `process_env::command`, which wraps it), never a bare `Command::new`. * Name executables with `pragma_client::executable_name` — it appends `EXE_SUFFIX`, so `pragma-cli` becomes `pragma-cli.exe` on Windows. This applies to paths you *write*, not just ones you read. ## Two rules that are not seams but bite the same way [#two-rules-that-are-not-seams-but-bite-the-same-way] * **A path is not a string.** Compare with `Path` (`/` and `\` are equivalent separators on Windows), never assert on a `"dir/file"` suffix, and test absolutes with `Path::is_absolute` — `starts_with('/')` silently rejects every Windows form. * **Clear the read timeout on a long-lived stream.** `configure_stream` sets a 5 s timeout for request mode; leave it on an idle subscription and every quiet 5 s looks like a drop, so the client warns and reconnects forever. Call `set_read_timeout(None)` once the response arrives (`open_event_stream` does exactly this). # Protocol (/docs/wiki/protocol) `crates/pragma-protocol` defines everything two processes need to agree on. Any frame, tag, or binary change bumps the crate version; Release Please mirrors it into `daemon.protocolVersion`, and clients refuse a `Hello` mismatch. Two versions, two audiences. `daemon.protocolVersion` covers processes that ship in one bundle — the app, the server, the gateway, the CLI — so it moves with every desktop release and a mismatch means a stale process to replace. Remote clients (Pragma Go, the browser build) never speak these frames; they talk HTTP to the gateway and check `gateway.apiVersion`, which changes only on a breaking `/v1` change. ## Frames [#frames] Every frame is `[4-byte big-endian length][1-byte tag][body]`, capped at 16 MiB: | Tag | Meaning | Body | | --- | ------------- | --------------------------------------------------- | | 0 | JSON control | serde frames below | | 1 | Binary output | `[2-byte BE sid length][sid][raw PTY output bytes]` | | 2 | Binary input | same layout, raw PTY input | Binary frames never decode UTF-8 on the hot path — output is bytes end to end. ## Handshake and classes [#handshake-and-classes] * Server → client: `Hello { protocolVersion, buildId? }`, first frame on every connection. `buildId` is the SHA-256 of the server executable, taken at start-up; it is optional, so older peers interoperate, and the desktop replaces a server whose build is not the one it bundles. * Client → server requests carry a `requestId` plus kind and optional context (`sessionId`, `worktreeId`, `cwd`, `cols`/`rows`, `data`, `shell`, `rpc`, `subscription`, `control`, `controlResult`). * First request decides the connection class: `RegisterController` = the app's control connection (only `ControlResult` replies flow back); anything else = normal client. ## Request kinds [#request-kinds] `Spawn`, `Attach`, `Write`, `Resize`, `Kill`, `KillForCwd`, `AgentReport`, `AgentMessage`, `AgentDecision`, `AgentAnswer`, `AgentInput`, `AgentInterrupt`, `SubscribeAgents`, `MarkAgentsSeen`, `Rpc`, `Subscribe`, `RegisterController`, `Control`, `ControlResult`, `PublishWorkspace`. Server → client frames: `Hello`, `Response` (ok/error), `Rpc` response (payload or `RpcError { code, message, details }`), `Event`, `Control` (envelope to the controller), `ControlResult`. Protocol error codes: `invalidPayload`, `unsupportedMethod`, `notFound`, `staleWrite`, `permissionDenied`, `internal`. ## Attach contract [#attach-contract] An attach stream begins with `Replay { sessionId, cursor, reset }` — the absolute output-byte cursor the server resumed from. Reconnecting clients send their last cursor on `Attach`; `reset: true` means the scrollback window could not cover it and the client should clear its screen. Scrollback is capped at 10,000 frames **and** 8 MiB. ## RPC methods [#rpc-methods] `git`, `filesystem`, `database`, `kanban`, `worktrees`, `projects`, `tabs`, `settings`, `github`, `ai`, `exec`, `automations`, `plugins`, `tunnel`, `scratchpads`, `whiteboards`, `wsl`, `fanouts` — dispatched by the server (which owns some) or forwarded into `pragma-core` (`git`, `filesystem`, `exec`, `scratchpads`, `whiteboards`). The [gateway](/docs/wiki/gateway) exposes each as `POST /v1/rpc/{method}`. ## Subscription events [#subscription-events] `Subscribe { event, cursor }` yields a JSON `Snapshot` then full-replacement `Delta` frames. Kinds: `agentStatus`, `worktreeChanged`, `kanbanChanged`, `tabsChanged`, `fileChanged`, `echoMode`, `automationPending`, `automationsChanged`, `workspace`, `fanouts`. Event streams use a bounded poll loop and hang up slow writers rather than buffering without limit. ## Shared names [#shared-names] `ProtocolRpcMethod` and `ProtocolEventKind` live in `packages/constants/values.json` and generate into both Rust and TypeScript — the compiler, not a code review, catches a misspelled method. # Server (/docs/wiki/server) `crates/pragma-server` is the persistent host. One process per channel; everything below lives in `src/`. ## Sessions [#sessions] `Session` wraps a `portable_pty` pseudo-terminal: * **Spawn** (`registry.rs`): duplicate-id check outside the PTY open; default grid 80×24. Shell resolution: requested `ShellProfile` → project `.pragma/config.json` `terminal` block → global config → platform default (`pragma_platform::shell`). * **Environment** exported into every session: `TERM=xterm-256color`, `COLORTERM=truecolor`, `PRAGMA_TAB_ID`, `PRAGMA_WORKTREE_ID`, `PRAGMA_SERVER_SOCKET` (+ legacy `PRAGMA_DAEMON_SOCKET`), `PRAGMA_GATEWAY_URL` / `PRAGMA_GATEWAY_TOKEN` (from `gateway.json` when the protocol version matches), and `PRAGMA_CLI` + a `PATH` prepended with the CLI's directory. Fanout attempts add `PRAGMA_FANOUT_ID` / `PRAGMA_FANOUT_MEMBER_ID`. * **Output pipeline**: a reader thread (64 KiB reads) → `OutputCoalescer` (8 ms trailing window, 256 KiB cap) → broadcast to subscribers. Slow subscribers are disconnected with a replay cursor instead of being allowed to buffer without bound. * **Scrollback**: capped at 10,000 frames *and* 8 MiB (coalesced frames can be large). * **Input**: binary frames written straight to the PTY, never queued through coalescing. * **Titles**: OSC 0/2 titles are parsed out of the raw stream; the shell names the tab unless the user renamed it. * **Exit**: the registry removes the session and purges the tab's agent statuses. There is no server-side "respawn" — clients kill and re-spawn. **Agent sessions** (`spawn_agent_session`) are server-owned: grid 120×40, tagged with a catalog agent id, startup input and prompt prefill scheduled (bracketed prefills wait for alt-screen entry with a bounded extra wait). Headless launches (`headless: true` or no controller) create real git checkouts under `<project>/.pragma/worktrees/<uuid>` via `pragma-core` and merge them into the workspace mirror; the desktop adopts them from disk later. ## Agent status [#agent-status] `Registry::report_agent` keys entries by `(worktreeId, tabId, agent)` and merges reports (status-less `session-name` reports keep the previous status). Broadcasts: * `EventFrame::Agent` to every `SubscribeAgents` stream (the desktop bridge and the gateway's agent stream). * A full-replacement `Delta` on the `agentStatus` subscription. Rich payloads have bounded replay so late subscribers catch up: messages (200 per session), decisions/answers (5 s window / 64 entries), inputs and interrupts (no buffer — always watcher delivery). `MarkAgentsSeen` downgrades stored `done` states so a reconnecting viewer does not re-show stale green. ## Fanouts [#fanouts] `fanouts.rs` (`FanoutStore`) + `fanout_host.rs` (side effects behind a `FanoutHost` trait) + pure rules in `pragma-core::fanout`. Durable state: `fanouts.json` beside the socket, written atomically with owner-only permissions. * **Statuses** — fanout: `provisioning, active, attention, partial, ready, failed, interrupted, cancelled, finalizing, needsResolution, cleanupFailed, completed`; member: `pending, provisioning, running, attention, done, failed, interrupted, cancelled, selected`. Member status is derived from agent reports. * **Invariants**: one active fanout per parent; all attempts branch from one captured base commit (`git worktree add -b … <commit>`); a dirty parent is refused; after a restart, live members become `interrupted` and the prompt is never auto-replayed. * **Pick** runs as durable stages — `validating → committingWinner (AI commit message via pragma-ai) → merging → promotingScratchpads → stoppingSessions → cleaningUp → completed` — and a retry resumes at the first incomplete stage. A merge conflict parks in `needsResolution` with everything intact; partial cleanup reports `cleanupFailed` with survivors. Descendant worktrees block finalize. * **Ops**: `Create, Get, Read (scrollback), Send, Retry, Cancel, Pick`. `Send` delivers through the member's watcher (delivery = reached the watcher; TUI ACK is not observable). Attempt branches: `fanout/<fanoutShortId>/<memberShortId>`. ## Sidecar supervision [#sidecar-supervision] | Sidecar | Mechanism | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | automations | Scans global + project automation dirs every 5 s; cron tick 20 s; trust state in `automations-state.json`; NDJSON pump to the sidecar. | | plugins host | Lazily respawned `pragma-plugins`; RPC domain `plugins` (`catalog`, `registerRoots`, `readAsset`, `usageLimits`, `reload`); roots persisted in `plugin-roots.json`. | | watchers | `WatcherSupervisor` spawns one `pragma-watch` per live agent session (5 s reconcile; fresh gateway credentials after a gateway restart; exponential backoff 5 s→5 m on crash loops). Nothing else may spawn it. | | tunnel | Child process from `~/.pragma/config.json` `tunnel.command`; stdout scanned against `urlPattern`; status `idle/starting/active/error`; restarted at server startup. | ## Other server duties [#other-server-duties] * **Workspace mirror**: the desktop publishes snapshots (debounced); the server persists `workspace.json` and serves the `workspace` subscription so headless clients see projects/worktrees/tabs while the app is closed. * **File watching**: `notify-debouncer-full`, 150 ms debounce, `.git` filtered, bounded per-subscriber queues; the last listener tears the watcher down. * **Ports**: only listeners whose process ancestry reaches a Pragma session root are reported — the security boundary for the Ports card. * **WSL**: the server answers the `wsl` RPC for its own host, so an SSH host reports its own distributions. * **Tabs RPC**: terminal *agent metadata* only (agent id, agent title) — the desktop owns the rest of tab state.