# REST API

Every project answers under `https://app.switchyard.so/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; a fixed conflict names the step that fixed it (`resolvedBy`) and, when a person or an agent resolved it or accepted a proposal, who did (`resolvedActor`); an open conflict waiting on a person carries `suggestion`, `ask` when a person can ask for an AI suggestion (again) and `off` when AI fixes are off for the team. 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. A person (an admin or the operator) sending `{ "rung": "agent" }` for a version with no proposal asks for an AI suggestion instead and gets 202 `{ "asked": true, "paths": [...] }`. `"ask": true` makes the call ask and nothing else, and a member of the project's team may send it too (accepting stays with an admin, the operator or the task's own key): a suggestion already there answers 409 instead of being accepted unseen, lockfiles alone answer 409, a task or agent group key gets 403, and `ask` with another step or with files is 400. The board's conflicts page and the overview's needs-you rows always ask this way. `{ "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. Each action carries `sentence`, the board's plain words for it (`@login`, task numbers, the merge preview), written from the record when it is read (the board and the server share one writer) and never stored, so a wording fix reaches old actions too |
| 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`), its checks (a kept check turned off is listed with `off: true`, where it stands in the kept text), 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`, and as `proposedRules` text checked the same way but not kept: settings report it as `rules.proposed` (the `rules` sent at creation when there is no `proposedRules`), so the board's editor starts from the checks and rules agreed at set-up while none are kept. With `"tryChecks": true`, every check that is new or whose command changed starts off (in the same `project.rules` operation, so undo reverts both) and is tried once on main; one that passes there, or that Switchyard's runner could not run, is turned on, and one that fails stays off with its log, as at creation. 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` |
| GET | `/checks/tried` | The one trial of the kept checks on main (any key of the project): `{ seq, state, main, checks, off, turnedOff, at }`. A project made with `rules` tries each kept check once on main as soon as main is known, before any task, on the check runner and the checks' network, and records `project.checks_try` (an action of Switchyard's, not a counted check minute). `state` is `none` (no kept checks were tried), `waiting` (main not read yet), `running` or `done`; each check carries its status, duration, size and, when it failed, the log's tail. A check that failed or timed out on main itself is turned off (`turnedOff`), so the first task never waits on a check that cannot pass; one that failed only because the checks' network blocked a host names it as `blocked` and is turned off too, and one Switchyard's own runner failed to run is tried once more after the others (a new project's engine can take minutes to start) and, failing again, is `unrun` and stays on. `off` lists the kept checks that are off now |
| PUT | `/checks/off` | Turn kept checks off or back on, `{ "off": ["<check name>", …] }`, the whole list of what should be off (project admin; others 403; a name not kept 400; a project whose rules live in `.yard/rules.toml` on main 409, since a file's checks change in the file). An off check stays in the kept text and leaves the merge queue's and the settings' checks; records `project.checks_off` with `turnedOff` and `turnedOn`, which undo reverts, and wakes the merge queue |
| 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 }`. A project starts on `registries`, or on the `checkNetwork` `POST /v1/projects` names; a project made before that kept no value and reads as `open`. Every check, including those started ahead for a test build behind, runs under the stricter of this and the rules file's `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 |
