` 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 Queue for {project?.name ?? "none"}
;
}
export default definePlugin({
name: "Review Queue",
description: "Everything waiting on me, across every worktree.",
ui: {
sidebarTabs: [defineSidebarTab({ id: "queue", title: "Queue", component: Queue })],
},
});
```
## 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.
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.
# 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()` | The payload passed to `openWebView`. |
| `useNotify()` | `(message, { variant?, description?, native? }) => void`. |
| `useStoredState(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 {
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//?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`.
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.
# 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: "",
});
```
`PragmaClientConfig`:
| Field | Type | Fallback |
| ------- | ------------------------ | --------------------------- |
| baseUrl | `string` | `PRAGMA_GATEWAY_URL` |
| token | `string` | `PRAGMA_GATEWAY_TOKEN` |
| fetch | `FetchLike` | global `fetch` |
| headers | `Record` | — (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 `. `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]
## 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.
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.
# 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 `
` links.
# Agent board (/docs/user-guide/agent-board)
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)
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 ****:
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 (****, or +Shift for all),
reject with ****, 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**, ). 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** ().
* **Address bar** — schemeless public hosts get `https://`; localhost and loopback
addresses get `http://`.
* **Dev tools** (), **Open externally**, **Screenshot**, and
**Find on page**.
* **More options** — **** copies the URL.
## Design mode [#design-mode]
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.
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.
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.
## 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 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 `/.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 ( and{" "}
), 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 |
| ------------------------------------------ | ------------------------------------------------------------------------- |
| `/.pragma/config.json` | Project settings: shell, plugins, agent status. Checked in. |
| `/.pragma/worktrees/` | Created worktrees. Git-excluded. |
| `/.pragma/scratchpads/` | Agent-authored MDX documents. Git-excluded. |
| `/.pragma/scripts.json` | [Project scripts](/docs/user-guide/project-scripts). |
| `/.pragma/theme.json` | Per-project colour overrides. |
| `/.pragma/keybindings.json` | Per-project keybinding overrides. |
| `/.pragma/automations/` | Project [automations](/docs/automations). |
| `/.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. |
The full story — server, gateway, protocol, and every file on disk — is in the [wiki](/docs/wiki).
# Files & Git changes (/docs/user-guide/files)
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{" "}
.
### The editor [#the-editor]
Click any text file to edit it in place, with highlighting for its language.
**** opens find-and-replace: fuzzy matching, match counts,
case sensitivity, and replace-one or replace-all.
Saves are deliberate — ****, 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
**** 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. 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{" "}
.
* **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)
## 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.
## 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]
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).
# 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 | |
| New browser tab | |
| New whiteboard | |
| Close tab | |
| Next / previous tab | |
| Clear terminal | |
| Split horizontal | |
| Split vertical | |
| Command palette | |
| Command mode | |
| Browser reload | |
| Browser dev tools | |
| Copy browser URL | |
| Delete file (editor) | |
| Scroll terminal to bottom | |
| Switch to project N | |
| Switch to worktree N | |
| Switch to tab N | |
Number badges appear on sidebar rows and tabs while the modifier is held.
## Command palette [#command-palette]
**** 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 ****) 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)
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).
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.
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.
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.
# 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" }]
}
}
```
`setup` and `teardown` run without a visible terminal. `runScripts` open as real terminal tabs you
can watch and interact with.
## 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.
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.
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)).
Pragma drives real git worktrees, so the folder you add must be a git checkout (or a remote URL
git can clone).
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 ****. 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.
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.
## 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: `.mdx` (the document, with a managed frontmatter line
Pragma maintains) and `.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 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
``: 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.
Paired devices read scratchpads through the same host — comments and agent handoff work
identically from the phone. See [Mobile app](/docs/user-guide/mobile).
# 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.
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.
## 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** () 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
(`/.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 , 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 /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";
```
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.
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.
## 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)
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 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 **** — the shortcut works from
any field.
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.
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]
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 ` 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/`, Linux
`$XDG_RUNTIME_DIR/` (fallback app data), Windows
`%APPDATA%\com.pragma.app\`.
* Production channel is `pragma`; dev builds derive `pragma-dev-` 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 ` 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 --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 ` 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 … ` with commit validation:
fanout attempts all branch from one captured base so their diffs are comparable.
* **Worktree location** — `/.pragma/worktrees/`; 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 `.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//`), `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//`;
Linux: `$XDG_RUNTIME_DIR/` (fallback: app data dir); Windows:
`%APPDATA%\com.pragma.app\`. Production `` is `pragma`; dev builds
use `pragma-dev-`.
| 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.
## `/.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 `