Download
Docs/More/AGENTS.md

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.

Raw markdown

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:

  • CLIscripts/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:
curl -so /opt/homebrew/bin/mandeck http://127.0.0.1:7717/cli/mandeck \
  && chmod +x /opt/homebrew/bin/mandeck
  • MCPscripts/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:
{ "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:

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

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

RouteBody / queryDoes
GET /v1/sessionsevery 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/focusbrings the pane to the human's screen
DELETE /v1/sessions/:id?force=1closes 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/jobstasked-spawn jobs, newest first
GET /v1/jobs/:idone 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/agentsinstalled 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/workspacestabs: {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/projectsrecent project folders
GET /v1/accountsprovider 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:

statemeans
runningtasked work is in flight (or we cannot tell yet)
waiting-youthe pane's attention bell is up (needsYou) and the job is not terminal
donethe tasked work finished — only via an explicit signal
failedthe 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:

/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 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.