# Client contract kit

What a client (the macOS toolbox, a web page, a script) needs to read the
pipeline's state and start or answer a run without a terminal conversation.
Nothing in here is a UI and nothing here runs on its own.

| File | What it is |
|------|------------|
| `manifest.json` | Every surface: its CLI invocation, its HTTP route, its schema and version. |
| `../schemas/*.schema.json` | The schemas. The kit points at them; it never copies them. |
| `types/index.d.ts` | TypeScript declarations generated from those schemas by `build.mjs`. |
| `fixtures/*.json` | Real producer output for every state a client renders, generated by `build.mjs`. |
| `frozen/toolbox.json` | The fields the macOS toolbox decodes today, held by `test/toolbox-compat.test.mjs`. |
| `CHANGELOG.md` | What changed in the contract, by contract version. |

`node pipeline/contract/build.mjs --write` regenerates the types and fixtures;
`--check` fails on drift. `npm pack` runs `--write` first, so a published kit
always matches its schemas.

From the npm package: `@mmerterden/multi-agent-pipeline/contract` resolves to
the types (and to `manifest.json` at runtime), `/contract/*` to any kit file
and `/schemas/*` to a schema.

## Two carriers, one contract

Every surface is reachable two ways, and for the same input both print the
same bytes (`test/contract-parity.test.mjs`):

1. **CLI.** The scripts under `pipeline/scripts/` (installed: `~/.claude/scripts/`)
   print JSON on stdout. `--redact` drops every field a schema marks
   `x-sensitivity: local`.
2. **HTTP.** `contract-server.mjs`, started on demand with `/multi-agent:serve`
   or by the client itself. Routes are in `manifest.json`; `?redact=1` is
   `--redact`.

| Surface | CLI | HTTP |
|---------|-----|------|
| Runs | `runs-index.mjs --json [--group G] [--redact]` | `GET /v1/runs` |
| One run | `runs-index.mjs --json --task-id ID` | `GET /v1/runs/{id}` |
| Commands | `commands.mjs --json` | `GET /v1/commands` |
| Run questions | `launch-request.mjs questions` | `GET /v1/questions` |
| Assigned issues | `issues.mjs --json [--source S] [--project K] [--max N]` | `GET /v1/issues` |
| Worktrees | `worktrees.mjs --json [--measure] [--redact]` | `GET /v1/worktrees` |
| Launch shape | `launch-request.mjs spec` | `GET /v1/launch-spec` |
| Launch repositories | `launch-request.mjs repos [--redact]` | `GET /v1/repos` |
| Start a run | write the request file, then `launch-request.mjs plan FILE --repo P --session-id U [--channel phone]` | `POST /v1/launch?repo=P` |
| Answer a question | `answer-question.mjs STATE --question Q --answer IDS` | `POST /v1/runs/{id}/answer` |
| A run's log | `run-log.mjs ID [--tail N]` (1-2000, default 200) | `GET /v1/runs/{id}/log?tail=N` |
| Resume a run | `launch-request.mjs resume --task-id ID --repo P --session-id U [--autopilot]` | `POST /v1/runs/{id}/resume` |
| Kill a run (two steps) | `run-kill.mjs preview ID`, then `run-kill.mjs apply ID` | `POST /v1/runs/{id}/kill` |
| Garbage-collect (two steps) | `gc-plan.mjs preview [--scope S,...]`, then `gc-plan.mjs apply < items` | `POST /v1/gc` |
| Autopilot status | `autopilot-control.mjs status` | `GET /v1/autopilot` |
| Autopilot off | `autopilot-control.mjs off [--now]` | `POST /v1/autopilot/off` |

Starting a run is always the client's act. `POST /v1/launch` validates the
request, writes it (0600) and returns the argv, environment and working
directory from `launch.json`; it never starts a session (the only `claude` the
server runs is `claude stop`, for a kill). Spawn the argv without
a shell. `launch.json` is marked `verified: false` until a live launch trial, so
treat the shape as provisional.

The run starts with no permission prompts, so its input is held to a shape: a
Jira key, a GitHub issue URL, `repo#N`, `#N`, or a Jira URL on the configured
host passes as it is; free text (desktop only) becomes one quoted argument; a
newline, a control character, a leading `-` or a first token that is a pipeline
op or mode keyword is refused (`launch-request.schema.json`). `repo` must be a
configured autopilot repo or one registered with
`launch-request.mjs register-repo <path>`, else `403 repo-not-allowed`.

Every plan starts claude with `--settings` naming the unattended permission
profile (`~/.claude/multi-agent-unattended.settings.json`). The profile is
opt-in and only the installer writes it, so without it `POST /v1/launch` and
`POST /v1/runs/{id}/resume` answer `409 unattended-profile-missing`, whose
`remedy` names the install command; the server never writes the file. The
repository must also be one Claude Code trusts: when its config
(`~/.claude.json`, `projects[<repo root>].hasTrustDialogAccepted`) says it is
not, both routes answer `409 workspace-not-trusted`, whose `remedy` is to open
Claude Code once there and accept the trust prompt (`cd <repo> && claude`).
Trust of a parent folder does not count. A config that cannot be read
concludes nothing and the launch proceeds; the server never writes it. The
prompt is claude's positional argument: `--bg` starts a background session,
prints its id and returns, and refuses `--print`.

`mode: "background"` with `kind: "development"` or `"analysis"` starts
`/multi-agent <input>` or `/multi-agent:analysis <input>` unattended without
autopilot: a picker the request did not answer is not defaulted, the run parks
on it as a `pendingQuestion`, and the client answers it like any other.
Resuming is the client's act too: `POST /v1/runs/{id}/resume` returns the plan
(`mode: "resume"`, no request file) for a run recorded under the unattended log
root, `409 not-resumable` for any other, a finished or a killed one, and `409
run-active` while its session runs.

Kill and gc remove things, so each takes two requests. The first (no
`confirmToken`) returns the preview with `confirmToken` and `expiresAt`; the
second sends the token back and acts on exactly what was previewed. A token is
single use, lives 120 seconds in the server's memory only, and is bound to the
route and the run: `409 confirm-expired` for an unknown, used or expired one,
`409 confirm-mismatch` for one minted elsewhere. A kill never deletes the
remote branch or the run's logs. Turning autopilot on stays a terminal command,
because it picks repositories with the user.

To answer, read `pendingQuestion` from the run (present when `waitingFor` is
`question`) and send its `id` with one or more of its `options[].id`. Anything
else is refused, and a run that has moved to another question refuses an
answer to the old one.

## Talking to the server

- It listens on `127.0.0.1` only, on the port its ready line names, and refuses
  any other `--host`.
- The ready line (stdout, one JSON object) names `tokenFile`, a 0600 file under
  `~/.claude/logs/multi-agent/contract-server/` holding the token. A client
  that spawned the server can pass `--print-token` to get it on the ready line.
- Every request sends `Authorization: Bearer <token>` and a `Host` of
  `127.0.0.1:<port>` or `localhost:<port>`.
- A browser page is allowed only from an origin passed with `--allow-origin`;
  CORS is off without one. A request carrying an `Origin` is always served
  redacted.
- Bodies are JSON, at most 64 KiB, and must satisfy the route's schema.
- Errors follow `contract-error.schema.json`; switch on `error`, never on
  `message`.
- The server exits after 15 idle minutes (`--idle-minutes`) or on SIGTERM. It
  is never a service; start it when you need it.

A client that relays anything off this machine uses `redact=1` (or
`--redact`) for every read.

## Phone routes

A phone does not use the bearer token. It holds an Ed25519 key enrolled locally
with `phone-devices.mjs add` and signs every request; `manifest.json` `phone`
names the headers, the signed fields, the 60 second window and the scopes, and
`phone-signed-request.schema.json` describes the signed text.

| Surface | Scope | HTTP |
|---------|-------|------|
| Runs, redacted | `read` | `GET /v1/phone/runs` |
| One run, redacted | `read` | `GET /v1/phone/runs/{id}` |
| Answer a question | `answer` | `POST /v1/phone/runs/{id}/answer` |
| Queue a launch (off until `phone-devices.mjs launch on`) | `launch` | `POST /v1/phone/launch?repo=P` |

The bearer token does not open these routes and a signature opens no other
route. Responses are always redacted; `redact=0` is refused. There is no pause,
resume, stop, steer, shell, prompt, run, merge or ready verb; each is `404
unknown-verb`. Threat model, transport and residual risks:
`multi-agent-refs/features/phone-api.md`.

## Versioning and breaking changes

The kit carries one `contractVersion` (`manifest.json`, and the
`X-Contract-Version` header on every response); each surface also stamps its
own version in its output.

- **Adding** a field, a surface, a query parameter or an error code is a
  **minor** bump. Decoders ignore keys they do not know, so nothing breaks.
- **Removing** or renaming a field, changing a field's type, narrowing what a
  request accepts, or changing what an error code means is a **major** bump.
  `frozen/toolbox.json` makes the toolbox's fields fail a test before such a
  change can land by accident.
- **After a major bump the previous major stays served for one minor**,
  selected with `?v=<major>` on any route. The minor after that removes it.
  `?v=` with a major the server does not serve is a 400 `unsupported-version`
  whose `supported` lists the ones it does. Without `?v=` the current major is
  served.
- Every version change gets a section in `CHANGELOG.md` saying what a client
  has to do.
