# Mandeck, for agents

Mandeck is a macOS terminal that runs coding agents in panes and lets one agent
hand work to others. If you are an AI agent on this machine — in a Mandeck pane,
in another app, or reaching in over an approved local-exec channel — this file
is the front door. Everything here is current; if code and this file disagree,
fix this file.

A running deck serves this file itself at `GET /agents.md` (no auth), and it is
published at <https://mandeck.dev/agents> — you never need the repo to read it.
A deck on a non-default port rewrites the examples below to its own port, so
what you fetch is what you can paste. Worked, verified transcripts of every
flow here live in `docs/AGENT-EXAMPLES.md`; the docs index is `docs/README.md`.

## Drive the running deck

The app serves a local HTTP API (the **Deck API**) on `127.0.0.1:7717`
(override: `MANDECK_API_PORT`). It is on whenever the app runs. Auth is a bearer
token the app mints at `~/Library/Application Support/dev.mandeck.native/cli-token`
(`MANDECK_STATE_DIR` overrides the dir name for test instances).

```bash
TOKEN=$(cat "$HOME/Library/Application Support/dev.mandeck.native/cli-token")
curl -s http://127.0.0.1:7717/v1/health                    # no auth — is a deck here?
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:7717/v1/sessions
```

Three equivalent clients, pick one:

- **CLI** — `scripts/mandeck` (install to `/opt/homebrew/bin/mandeck` via
  `scripts/install-cli.sh`). Bots must call that path, not a shell alias named `mandeck` (on some machines
  that alias is something else entirely). `mandeck` with no
  args lists sessions — address, **pane uuid**, agent, state; act on the uuid,
  the address is a position that moves. `agents`, `jobs`, `events [since]`,
  `messages <id>` wrap the read routes so a bot never needs raw curl.
  `job spawn|send|wait|report` is the bot lifecycle; `job spawn` prints one
  `job<TAB>session<TAB>pane` line, and on 409 (that agent+cwd already live)
  still prints it for the existing pane and exits 2 — the prompt was not
  delivered. Invalid args print usage. No repo checkout? The deck serves its
  own CLI:

  ```bash
  curl -so /opt/homebrew/bin/mandeck http://127.0.0.1:7717/cli/mandeck \
    && chmod +x /opt/homebrew/bin/mandeck
  ```
- **MCP** — `scripts/mandeck-mcp.mjs` (also served at `/cli/mandeck-mcp.mjs`),
  a stdio MCP server exposing the same operations as tools, for agents that
  are MCP clients (Claude Code, Codex, Gemini CLI, Cursor) — a bot on a shell
  uses the CLI instead. Dual-era: speaks spec 2026-07-28 (stateless, per-request
  `_meta`, `server/discover`) and still answers a legacy `initialize`. Register:

  ```json
  { "mcpServers": { "mandeck": { "command": "node",
      "args": ["/path/to/mandeck-mcp.mjs"] } } }
  ```

  **Approving tools.** Consent lives in the client, not the protocol. Every tool
  carries annotations: reads (`deck_sessions`, `deck_screen`, `deck_messages`,
  `deck_jobs`, `deck_job_state`, `deck_job_wait`, `deck_job_report`,
  `deck_agents`, `deck_events`, `deck_tabs`, `deck_history`, `deck_projects`,
  `deck_accounts`, `deck_health`) are `readOnlyHint`; `deck_close`, `deck_send`,
  `deck_key` are `destructiveHint`; the rest are additive. The profile to
  install is *read = free, act = ask, close = only your own panes* — and the
  last one is enforced by the deck (403 without `force`), not by trust:

  - Claude Code `settings.json`: `"permissions": {"allow": ["mcp__mandeck__deck_sessions",
    "mcp__mandeck__deck_screen", "mcp__mandeck__deck_messages", "mcp__mandeck__deck_jobs",
    "mcp__mandeck__deck_job_state", "mcp__mandeck__deck_job_wait", "mcp__mandeck__deck_job_report",
    "mcp__mandeck__deck_events", "mcp__mandeck__deck_agents", "mcp__mandeck__deck_health"]}` —
    everything else prompts (Claude Code does not auto-approve on annotations).
  - Codex CLI `config.toml`: `[mcp_servers.mandeck] default_tools_approval_mode = "writes"`
    prompts only for tools not marked read-only.
  - Gemini CLI policy: a rule on `toolAnnotations = { readOnlyHint = true }` → `allow`.
- **Raw HTTP** — the table below.

## Deck API routes

No auth:

| Route | Returns |
|---|---|
| `GET /v1/health` | `{ok, app, docs, home, panes, permissions:{screenRecording, accessibility, fullDisk}}` |
| `GET /agents.md` | this file, from the running deck |
| `GET /cli/mandeck` | the CLI (bash), ready to save and `chmod +x` |
| `GET /cli/mandeck-mcp.mjs` | the stdio MCP server (Node, zero deps) |
| `GET /` or `/phone` | the phone PWA (HTML) |

Everything else requires `Authorization: Bearer <cli-token>`:

| Route | Body / query | Does |
|---|---|---|
| `GET /v1/sessions` | — | every pane: `id` ("2.3"), `pane` (stable uuid), `agent`, `account`, `cwd`, `project`, `title`, `kind`, `focused`, `needsYou`, `live` |
| `GET /v1/sessions/:id` | `?lines=40` (max 200) | that row + `screen`: ANSI-stripped tail of the terminal |
| `GET /v1/sessions/:id/messages` | `?limit=60` (max 200) | structured transcript `{messages:[{kind,text,at,tool?,status?}], transcript, screen, title?}`; `transcript:false` means no reader for that agent — use `screen` |
| `POST /v1/sessions/:id/message` | `{text, submit?}` | types `text`; `submit:true` (default) presses a real Return. `submit:false` types without submitting — required before slash-command menus |
| `POST /v1/sessions/:id/key` | `{key}` | `ctrl-c`\|`interrupt`, `escape`\|`esc`, `ctrl-d`\|`eof`, `up`, `down`, `enter`\|`return`, or a digit `0-9`. Digits answer TUI pickers (they must be *pressed*, not typed) |
| `POST /v1/sessions/:id/focus` | — | brings the pane to the human's screen |
| `DELETE /v1/sessions/:id` | `?force=1` | closes the pane. **Refuses (403) a pane that was not spawned through this API** — the human opened it — unless `force=1`; the reply carries the pane uuid. CLI `close <id> --force`, MCP `deck_close {force:true}`. After a deck restart every pane counts as human-opened |
| `POST /v1/spawn` | `{agent, cwd, prompt?, workspace?, account?, check_after?, spawner?, force?}` | new pane running that agent CLI. **`cwd` is required** (no default `$HOME`). Refuses a second live pane for the same agent+cwd unless `force: true` (409 `{error:"already live", session, pane, job?}`). `workspace` names a tab (created once, reused). A `shell` pane gets `MANDECK_PANE`/`MANDECK_JOB` exported into it but does **not** run `prompt` — it is recorded on the job only. Returns `{ok, session, pane, job}`. A spawn with a `prompt` or a named `spawner` opens a follow-up contract (see below) — `check_after` minutes (default 10), `spawner` = your own pane id. The job is a different object from that contract |
| `GET /v1/jobs` | — | tasked-spawn jobs, newest first |
| `GET /v1/jobs/:id` | — | one job: `{id, state, session, pane, agent, cwd, prompt, live, needsYou, createdAt, finishedAt?, report:{session, messages, screen}}`. `:id` is the job uuid, or a pane/session address (latest job on that pane). Reading a job does **not** settle the follow-up contract |
| `POST /v1/jobs/:id` | `{state: "done"\|"failed"}` | explicit finish. The only API write that marks a job terminal |
| `GET /v1/agents` | — | installed agent slugs (`claude-code`, `codex`, `gemini`, … and `shell` for a bare shell) — only offer what this Mac can run |
| `GET /v1/events` | `?since=<seq>` | append-only log of `needs-you` / `spawned` / `needs-review` / `done` / `failed`; poll with the last `seq` instead of diffing `/v1/sessions`. `done`/`failed` events carry `job` |
| `GET /v1/workspaces` | — | tabs: `{index, title, active, panes}` |
| `GET /v1/history` | `?limit=40&q=` (max 200) | past sessions by title/cwd/agent |
| `POST /v1/resume` | `{id}` | reopens a past session (ids from `/v1/history`) in a new pane, via the tool's own resume flow |
| `GET /v1/projects` | — | recent project folders |
| `GET /v1/accounts` | — | provider accounts this deck can spend |
| `POST /v1/accounts` | `{label}` | ensure an account exists (idempotent) |

**Addressing.** `:id` accepts the `"2.3"` address (workspace 2, pane 3), the
pane's uuid, or a bare lifetime pane number. Addresses are positions and move
when panes close; hold the `pane` uuid for anything longer than one command.

**Waiting on a human?** A pane with `needsYou: true` is blocked on input. Read
its `screen` to see the question, answer with `message` or a digit via `key`.

## Hand-off protocol — you spawned it, you own it

Handing a task to a pane is not done when the spawn returns; it is done when
you have **read the result, verified it, and reported back** to whoever gave
you the task. The loop:

1. Spawn with a `prompt` (either a prompt or an explicit `spawner` opens the
   contract), and identify yourself: pass `spawner` (your pane id —
   every agent pane has it in `$MANDECK_PANE`; the CLI and MCP server send it
   automatically). Estimate how long the work should take and pass
   `check_after` minutes (default 10).
2. While it runs, poll `GET /v1/events?since=<seq>` or check `mandeck waiting`
   — a pane that bells needs an answer, not patience.
3. When it stops (or your `check_after` passes), read its `screen` /
   `messages`, **verify the work actually happened** — a confident final
   message is not evidence — then report the outcome.

The deck enforces this. A spawn that carried a prompt and goes unchecked gets
collected: the deck types a `[mandeck follow-up]` reminder into the spawner's
own terminal, emits a `needs-review` event, and if still ignored (or the
spawner is gone) rings the human's attention bell on the pane. Reading the
pane's screen or transcript settles the contract.

**`needs-review` is not job `done`.** Follow-up settle means someone looked (or
the deck collected). The tasked work may still be in flight, sitting at a
prompt, or waiting on a human. Job state is the other signal — see below.

## Job lifecycle — when is the work done?

Pane liveness is the wrong question. `live: false` means the session record
ended or the process exited. A pane that answered and sits at a prompt stays
`live`. `needs-review` means the follow-up contract was collected. None of
those mean the tasked work finished.

A `POST /v1/spawn` creates a **job** (`job` in the spawn body) with one of:

| state | means |
|---|---|
| `running` | tasked work is in flight (or we cannot tell yet) |
| `waiting-you` | the pane's attention bell is up (`needsYou`) and the job is not terminal |
| `done` | the tasked work finished — only via an explicit signal |
| `failed` | the tasked work errored — only via an explicit signal |

**How `done` / `failed` are decided (do not lie):**

The deck does **not** infer completion from silence, a prompt, `live: false`,
unfocus, follow-up collection (`needs-review`), or the pane closing. The only
terminal signals are:

1. `POST /v1/jobs/:id` with `{"state":"done"}` or `{"state":"failed"}`
2. `/opt/homebrew/bin/mandeck job done` / `job fail` (same POST; uses
   `$MANDECK_JOB` if you omit the id)
3. The agent prints the exact token `[mandeck job-done]` or
   `[mandeck job-failed]` **alone at the start of its own line** (e.g. via
   `echo`; picked up on the next job GET). Mentioning the token mid-sentence
   — "I'll print [mandeck job-done] when I finish" — deliberately does NOT
   count, so instructions about the protocol can't finish a job by accident

A pane that is still `running` after it "looks finished" is honest: we do not
have the signal. `job wait` then times out. That is the correct answer, not a
Cloud-style completion fake.

Every API-spawned pane gets `MANDECK_JOB` (and `MANDECK_PANE`) exported. If
you are the tasked agent, finish with:

```bash
/opt/homebrew/bin/mandeck job done
# or print: [mandeck job-done]
```

`GET /v1/jobs/:id` includes `report.messages` / `report.screen` so a bot can
fetch the transcript without guessing routes. Reading the job does not settle
follow-up; `job report` (which hits session messages/screen) does, because
that is a real check.

**Waiting and the follow-up contract.** A long `job wait` polls only the job,
so it does not settle the spawn's follow-up contract — if the work runs past
`check_after` (default 10 min), the spawner still receives the
`[mandeck follow-up]` reminder mid-wait. Set `check_after` comfortably longer
than your wait timeout; the contract settles the moment you `job report`.
Collection sweeps on a ~30s tick, so even a tiny `check_after` fires within
about half a minute, not instantly.

## Path for a bot on a shell

A bot with shell access on the Mac (a local-exec channel, a cron job, another
agent runtime) reaches the **already-running Mandeck.app** — not a second
Mandeck, not a cloud sandbox. The door is the installed binary, never a shell
alias:

```bash
/opt/homebrew/bin/mandeck job spawn claude-code --dir /path/to/repo -p "task"
/opt/homebrew/bin/mandeck job wait <job> --timeout 600
/opt/homebrew/bin/mandeck job report <job>
```

`job wait` exits `0` done, `2` failed, `3` waiting-you, `4` timeout. On
waiting-you, read the screen, answer (`job send` or `key`), wait again.
`job spawn` exits `2` when that agent+cwd is already live: the id line is
still printed, the prompt was not delivered — `job send` it, or `--force`.
Close, send and screen by the pane uuid `ps` prints (`p70`), never by the
`1.x` address from a script: the address moves when any pane opens or closes.
`close` refuses a pane you did not spawn through the API; `--force` exists, and
a pane the human opened is almost never yours to close.

The stdio MCP server (`mandeck-mcp.mjs`) offers the same lifecycle as tools —
`deck_job_wait`, `deck_job_report`, `deck_job_state` — over the same loopback API.

Rules:

- The Mandeck app must stay running. The Deck API exists only while it does.
- Loopback only (`127.0.0.1:7717`). Do not expose the port to the LAN.
- Never read the user's secret store. The cli-token is
  possession-of-the-machine auth; do not copy it off the Mac.
- `job spawn` opens a paid GUI pane on the user's deck. That is the point.
- No plugin is needed. The CLI is the path.

## Security model (deliberate)

Loopback-only + token file = *possession of the user's machine is the auth*.
Any process running as the logged-in user can read the token and therefore
fully drive the deck — including spawning a `shell` pane and typing into it,
which is arbitrary command execution. That is the product: the deck is an
execution surface for agents the user already lets onto the machine. Do not
expose the port beyond loopback; there is no rate limit, scoping, or token
rotation (delete the token file and restart the app to re-mint). The one
guard is against accidents, not attackers: closing a pane the human opened
needs `force`, because addresses move and a bot's stale `1.x` has hit a live
session.

## Working on this codebase

- Generate + build: `xcodegen generate && xcodebuild -project Mandeck.xcodeproj -scheme Mandeck -configuration Debug build`
- **Never kill or relaunch a running Mandeck** to test a change — it hosts the
  user's live agent sessions. Use an isolated instance: `scripts/devtest.sh`
  (own state dir, own port).
- API server: `Sources/Sync/DeckAPI.swift`. Jobs: `Sources/Sync/DeckJob.swift`.
  CLI: `scripts/mandeck`. MCP: `scripts/mandeck-mcp.mjs`.
  Job/CLI tests: `node scripts/job-test.mjs`.
- This table is the canonical route list — update it in the same commit as any
  `DeckAPI.swift` route change.
