# REST API

Every project answers under `https://app.switch-yard.dev/v1/projects/<project>`; the paths below are relative to it. Send your key as a header, `Authorization: Bearer <key>`: an admin key for the project, a task key for one task, an agent group key for many agents at once, or a signed-in person's session from the app. Answers are JSON and carry `seq`, the position in the project's history; errors carry `{ "error": { "code", "message", "hint" } }` as the [errors](/errors) page lists. A 503 with a `Retry-After` header means wait that long and send the same request again; nothing you sent is lost.

Paths say `workspace` where the product says task; the ids are the same (`W8`, `8` or `task 8` are all accepted wherever a task is named). This page is rendered from the design document's route table, so it changes when a route does.

| Method | Path | What it does |
| --- | --- | --- |
| GET | (the project itself) | `{ seq, project }`: its id, name, branch, main, remote and, when it follows one, its GitHub repository; `POST /v1/projects` answers the same `project` with the admin key |
| GET | `/me` | Which principal the key or session is: `{ kind, workspace?, user?, fleet? }` (`kind` is `fleet` for an agent group key, or `workspace` with `fleet` for the MCP session of one that claimed a task) |
| POST | `/admin-key/rotate` | Issue a new admin key for the project (project admin or the operator); every earlier admin key stops at once, with no grace period, since a rotation is usually for a leaked key. Answers `{ seq, adminToken, revoked }`, with the key shown once, and records `project.admin_key` (the count only, never the key), which cannot be undone; the hosted product also writes it to the organization's audit log. The board's settings page has the button and keeps itself signed in with the new key. A holder of a leaked admin key can rotate it too and lock the owners out for a moment; an owner rotates again from a signed-in session, and the audit log names who rotated |
| POST | `/fleets` | Issue an agent group key (project admin): `{ name, tags?, permission?, maxWorkspaces?, ratePerMinute?, expiresInDays? }`; answers `{ seq, fleet, token }` with the key, which is shown once, and records `fleet.create`; `fleet` carries `expiresAt`, and once known `lastUsedAt`, `disabledAt`, `rotatedAt` and `previousValidUntil` |
| POST | `/fleets/:fleet/rotate` | Issue a new secret for the agent group (project admin), `{ graceMinutes?, expiresInDays? }`: the old secret works for `graceMinutes` (60, at most 10,080); answers `{ seq, fleet, token }` and records `fleet.rotate`, which cannot be undone |
| POST | `/fleets/:fleet/disable`, `/fleets/:fleet/enable` | Stop or restart an agent group key and its tasks' keys without revoking it (project admin); answers `{ seq, fleet }` and records `fleet.disable` or `fleet.enable` |
| GET | `/fleets` | The project's agent group keys (project admin), each with the tasks it created and how many are active |
| POST | `/fleets/:fleet/revoke` | Revoke an agent group key (project admin), `{ abandon? }`: records `fleet.revoke` once, and with `abandon` abandons the agent group's active tasks; answers `{ seq, fleet, abandoned }` |
| POST | `/workspaces/claim` | The MCP session of an agent group key claims its task: `{ task, plan?, tags?, agent?, model?, resume? }`; answers `{ seq, claimed, workspace, credentials, resume }` without a task key |
| POST | `/workspaces` | Create a task (project admin, or an agent group key), with optional `tags` and `permission` (the project's `taskPermission` when absent, `submit` unless changed); returns remote URL, key, permission; 409 while main has no commits yet or the host's default branch is not the project's. The copy of the repository and its key are made outside the coordinator's lock, and the task starts at the commit the copy of the repository copied. The same `requestId` sent again within a day returns the task it created, with fresh credentials, instead of a second task (409 if the request differs, tags included, or that task has finished) |
| POST | `/workspaces/:workspace/tags` | `{ set?, remove? }`: merge tags into the task's and drop the keys named; answers `{ seq, workspace, tags }`. Records `workspace.tag` unless nothing changes; 400 past 16 tags or 1,024 characters, or for a key both set and removed |
| PUT | `/workspaces/:workspace/plan` | Set or update the plan; returns the task's intent warnings once the judge has answered about the new plan (or the comparison fell back to wording), waiting for it outside the coordinator's lock |
| GET | `/status?min_seq=&workspace=&tag=` | The `ProjectState`: every active task and the 20 most recently finished, with `olderFinished` when there are more; the inbox lists active tasks only. With `workspace`, the same shape for one task: the task, the tasks it is stacked on and every active task built on it, with the tasks in between (finished ones included), the warnings involving it and its inbox items. With `tag=key:value` (repeated, ANDed; not with `workspace`), the same shape for the tasks carrying those tags: the tasks, the warnings involving any of them and their inbox items, with the whole merge queue. The MCP `yard_status` without a task lists a line per task and the warnings grouped by kind and tasks (MCP tools) |
| GET | `/workspaces?status=&before=&limit=&tag=` | `status=active` (the default): every active task. `status=finished`: finished tasks older than the cursor, newest first, up to 100, with the next cursor. Both answer `{ seq, workspaces }` and take the status's `tag` filter |
| GET | `/workspaces/:workspace/warnings` | Warnings involving a task, with suggestions |
| GET | `/workspaces/:workspace/notice` | Switchyard's note on a task, the one its pushes print as `remote: switchyard:` lines and every MCP reply to its key carries as `notice`: what Switchyard last changed on it and how to catch up, its merge queue state or why it was sent back, its conflicted files and up to two warnings; absent when there is nothing to say |
| POST | `/workspaces/:workspace/claims` | Claim paths or symbols; returns the task's intent warnings like `PUT /plan` |
| POST | `/workspaces/:workspace/observe` | Read the task's branch now, after a push, instead of waiting for the push event (which stays as the backstop); a merged or abandoned task answers 409, since its pushes are not tracked |
| POST | `/workspaces/:workspace/ops` | Run an engine operation (sync, absorb, split, …); with `expectHead`, only if the task's branch is still at that commit (409 otherwise), after catching up with a push the coordinator has not seen yet |
| POST | `/workspaces/:workspace/credentials` | Mint a fresh Git key for the task remote; 409 once the task has merged or been abandoned |
| POST | `/main/credentials` | Mint a read-only Git key for `main` (one hour), for anyone in the project, a merged task's read-only key included |
| POST | `/workspaces/:workspace/submit` | Freeze the task at its head and enqueue it on the merge queue (refused with nothing to merge, unresolved conflicts or an abandoned parent); `expectHead` names the head the caller pushed, and a task elsewhere is refused with 409 after the coordinator catches up with pushes it had not seen; the key of a task with `commit` permission gets 403 |
| GET | `/changes/:change?version=` | A change at its latest version, or the one `version` names: `ChangeDetail` with its task, subject and commit, every version recorded for the `Change-Id` (number, commit, task, when it was recorded, subject, and `unavailable` once its commit is known to be gone), the diff against its parent (`files`, `base`, `truncated`) and the reason behind it (`agent`, `task`, and the task plan's summary as `plan`). A version whose commit is gone answers with no files and `unavailable` saying why; an unknown change or version answers 404 naming the change; a version that is not a positive integer 400. Readable like `/status` |
| GET | `/changes/:change/interdiff?from=&to=` | What changed between two versions: `{ seq, change, from, to, rebased, truncated, files }`. 400 unless `from` and `to` are two different version numbers; 404 for an unknown change or version, or one whose commit is gone, naming it. Readable like `/status` |
| GET | `/conflicts` | `{ seq, conflicts }`: a `ConflictView` per conflicted path in each active task's stack, and the ones fixed or resolved within the last 50 operations. Readable like `/status` |
| POST | `/conflicts/:change/resolve` | Climb the conflict steps; `expectHead` leases it like a task operation, and the answer names the task's new head. In a task with two commits carrying the Change-Id it resolves the conflicted one, and answers 409 when both are conflicted. `{ "rung": "agent" }` accepts the resolver step's proposals for the change's current version: the task's own key or a person (an admin or member) may, an agent group key that has not claimed the branch gets 403, and a version with no proposal answers 409. A version an agent accepted waits for a person's approval in the merge queue like a recipe fix (unless `fixes` is `auto`); a person's accept is the approval. `{ "rung": "human", "files": [...] }` is refused with 400 `invalid` when a file still holds a full conflict block in order (`<<<<<<<`, `=======`, `>>>>>>>`, each alone on its line or, for the outer two, followed by a space and a name), naming the file and line, hint "remove them and send it again" |
| POST | `/review/:change/:version` | Approve, comment or request a fix; the version names one commit, even in a task with two commits carrying the Change-Id. The project admin, the operator and members of the project's team review; a task key gets 403, its own changes included. The approver's teams are recorded with the approval; when the task's facts are known and touch owned paths, an approval answers with `owners` (each team, its paths, the approvals it needs and has, and whether this approval `counted`) and, while a team is still short, a `note` saying why |
| GET | `/why?path=&line=&workspace=` | Change, task, agent and plan that last wrote a line: a task key blames its own task head, an admin the live integration or the task it names |
| POST | `/live` | A single-use ticket (60 s) for a live WebSocket connection |
| GET | `/oplog?since=` or `/oplog?last=` | Read the log: the actions after `since`, or from the newest `last` on (the board's first read, sent before the status has said how long the log is; `last` wins when both are given); at most 500 a page, with `seq` the newest |
| GET | `/attestations/:commit` | The attestation of a merged commit (7 or more hex digits): the signed text, signature, signer, whether it verifies and whether its note is on `main` |
| GET | `/signer` | The project's signing key and its `allowed_signers` line |
| GET | `/insights?days=` | Insights over the last 1, 7 or 30 days (default 7; another value 400), computed from the operation log (project admin or a member of its team): tasks started, submitted, sent back and merged, merges, approvals, submit to merge, start to merge and the wait for a review (median and the slowest tenth), the typical change size, counts per day, and per agent and model the same counts, send-back rate, conflict rate and submit to merge |
| GET | `/settings` | Everything the project is set to, for the settings page (project admin or a member of its team; tasks get 403): the project (name, main branch, storage), the GitHub connection when it follows a repository (the app's granted permissions and events against what Switchyard needs, each gap with what it unlocks, links to fix it on GitHub, and when the last push and pull request event arrived), the rules file on main as read at which commit (`found`, `missing` or `unreadable`), the check network, and the values fixed for now. When the Git engine does not answer the rules read in 10 s, the page still answers: with the last rules read and `rules.unread` "…, so these are the rules as of main @ abc1234; reload to read the newest", or with empty lists and "…, so the rules on main are not read yet; reload to try again" |
| PUT | `/pause` | Pause merging, `{ "reason": "incident 42", "until"?: "2026-10-16T09:00:00Z" }` (project admin; tasks get 403; an empty reason, an unreadable time, a time past or more than 30 days ahead get 400). Answers `{ seq, paused }`. Records `project.pause`, which undo reverts, and wakes the merge queue |
| DELETE | `/pause` | Resume merging (project admin). Answers `{ seq, paused: null }`; nothing is recorded when merging is not paused |
| PUT | `/fixes` | Whether fixes Switchyard makes wait for a person, `{ "fixes": "approve" \| "auto" }` (project admin; tasks get 403, another value 400; default `approve`). With `approve` the merge queue holds a change version the recipe step wrote until that version is approved; `auto` lets it merge once its checks pass. `POST /v1/projects` takes `fixes`; settings report it. Records `project.fixes`, which undo reverts, and wakes the merge queue |
| PUT | `/task-permission` | Who submits a finished task, `{ "taskPermission": "submit" \| "commit" }` (project admin; tasks get 403, another value 400; default `submit`). It is the permission a new task gets when its request names none: `submit` lets the agent submit its task when the work is done, `commit` leaves submitting to a person. Tasks made earlier and agent group keys keep their own permission. `POST /v1/projects` takes `taskPermission`; settings report it. Records `project.task_permission`, which undo reverts |
| PUT | `/conflict-data` | Whether the project's conflict records (the conflicting lines, the fix and whether a person accepted it) may help improve Switchyard's resolver, `{ "conflictData": "private" \| "share" }` (project admin; default `private`). Nothing uses shared records yet; the setting records the project's choice before anything could. Records `project.conflict_data`, which undo reverts |
| GET | `/webhooks` | The project's webhook endpoints (project admin; others 403): `id`, `url`, `events`, `failing` (attempts failed since the last success), `dropped` (events given up on), `lastSuccessAt`, `lastError`, `lastDroppedAt`; never the secret |
| POST | `/webhooks` | Add an endpoint, `{ "url": "https://…", "events"?: [...] }` (project admin): https on a public host, no IP literal, no local name, no user info, not Switchyard's own host; at most 5 endpoints. Answers the endpoint with its signing `secret`, shown only here. Records `project.webhooks` with the URL without its query string, never the secret; it cannot be undone |
| DELETE | `/webhooks/:id` | Remove an endpoint and every event still waiting for it (project admin) |
| PUT | `/rules` | Keep the rules and checks in Switchyard, `{ "rules": "<the text of a rules file>" }`, or `""` to stop (project admin; tasks get 403, text that does not parse 400, over 64 KiB 400). While main has no `.yard/rules.toml`, the merge queue, checks and settings read these instead; a file on main always wins. `POST /v1/projects` takes the same text as `rules`. Records `project.rules`, which undo reverts, and wakes the merge queue; answers `{ seq, source }` with `source` `switchyard` or `none`, and settings report `rules.source` (`repository`, `switchyard` or `none`) and the kept text as `rules.stored` |
| PUT | `/reviews` | Turn reviews on or off, `{ "reviews": "on" \| "off" }` (project admin; tasks get 403, another value 400). Off adds a last rule that lets every change no rule covers merge once its checks pass, so rules that ask for review still apply; records `project.reviews`, which undo reverts, and wakes the merge queue so waiting work is decided again |
| PUT | `/checks/network` | Set the network the project's checks may reach, `{ "network": "open" \| "registries" \| "off" }` (project admin; tasks get 403, another value 400); records `project.check_network` unless it is already set, and answers `{ seq, network }` |
| POST | `/oplog/:seq/undo` | Undo an operation; operations on a merged task are final (409), the kinds the design document names cannot be undone (400), and an operation already undone, even by an undo running at the same time, answers 409, as does a integration rebuild whose integration never reached main |
