Mandeck, for agents
The whole of AGENTS.md, the file a running deck serves at GET /agents.md. The pages under Drive the deck are this document, split.
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).
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/mandeckviascripts/install-cli.sh). Bots must call that path, not a shell alias namedmandeck(on some machines that alias is something else entirely).mandeckwith 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|reportis the bot lifecycle;job spawnprints onejob<TAB>session<TAB>paneline, 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:
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 legacyinitialize. Register:
{ "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:
- Spawn with a
prompt(either a prompt or an explicitspawneropens the contract), and identify yourself: passspawner(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 passcheck_afterminutes (default 10). - While it runs, poll
GET /v1/events?since=<seq>or checkmandeck waiting— a pane that bells needs an answer, not patience. - When it stops (or your
check_afterpasses), read itsscreen/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:
POST /v1/jobs/:idwith{"state":"done"}or{"state":"failed"}/opt/homebrew/bin/mandeck job done/job fail(same POST; uses$MANDECK_JOBif you omit the id)- The agent prints the exact token
[mandeck job-done]or[mandeck job-failed]alone at the start of its own line (e.g. viaecho; 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:
/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:
/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 spawnopens 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.swiftroute change.