Migrate from the legacy API
The public API moved and its resource shapes changed. This page walks through the migration for a person; the agent brief at the bottom is the same content as a ready-made task prompt you can paste into any coding agent.
The fastest path
Create a current API key
Legacy personal tokens did not migrate. Create an organization-scoped key in Settings → API — as yourself, or as a service user for least-privilege automation.Hand the brief to an agent
Copy the agent brief below and give it to your coding agent together with access to your integration's repository.Review the report
The brief requires the agent to report changed files, removed endpoints, unsupported capabilities, and the checks it ran. Review that report before cutting over.
Base URL and authentication
https://capy.ai/api/v1 is retired; the current base is https://api.capy.ai/api/v1 (don't append another /api or /v1). Authentication is unchanged in shape — Authorization: Bearer capy_... — but the key itself is new: legacy personal tokens were user-scoped across organizations and never migrated, so create an organization-scoped key in Settings → API. Keys have no per-endpoint scopes; for least-privilege automation, create a service user with the role and project access you want and mint the key for it.
A safe smoke test for a new key:
curl -sS "https://api.capy.ai/api/v1/projects" \
-H "Authorization: Bearer $CAPY_API_TOKEN"Project IDs carried over from the legacy system, and project discovery is public: GET /projects lists the projects your key can access and GET /projects/{projectId} reads one, so a configured project ID keeps working and new ones resolve through the API instead of the app.
What changed
| Flow | Legacy | Current |
|---|---|---|
| Discover projects | GET /projects with limit/offset paging | Same paths, unpaged: GET /projects returns every project your key can access as { items }, GET /projects/{projectId} reads one. Items carry id, name, description, code, repos (repoFullName, baseBranch), and timestamps |
| Create thread | POST /threads with { projectId, prompt } | POST /threads with { requestId, projectId, message } plus optional title, model, machineSize, authorId (attribute the thread to an organization member). requestId (1–191 chars) is your idempotency key: reusing it on a retry returns the original thread instead of a duplicate billable run |
| Poll status | runState, waitingOn, blockedOn, pendingWakeups | One status field: working, waiting (resumes by itself), idle (stopped, waits for your message), failed, archived. Poll while it reads working |
| Send message | POST /threads/{id}/message with { message, mode } | Same path with { text, delivery } — interrupt (default), queue, or steer — returning a receipt { id, deduped }. Pass clientKey as the idempotency key: a retry with the same key returns the first receipt with deduped: true instead of posting twice |
| Queued messages | — | POST .../messages/{eventId}/cancel dequeues, POST .../messages/{eventId}/send-now promotes; eventId is the receipt's id |
| Stop work | POST /threads/{id}/stop; archive stopped active work | POST /threads/{id}/interrupt; archive no longer stops anything — call both when you want both |
| Read transcript | content field, nextCursor + hasMore | text field with source of user/assistant/tool; pages are { items, cursor } with after, null cursor = caught up, cursors are opaque |
| Tasks | Create/message/start/stop/PR routes | Read-only subagents: GET /threads/{id}/subagents, GET /subagents/{subagentId} (statuses working, waiting, idle, done, failed), also served at GET /threads/{id}/tasks and GET /tasks/{taskId}; drive work by messaging the root thread |
| Usage | Legacy GET /usage shape | GET /usage returns the current UTC month through now; pass ISO from/to timestamps for another window. Billed LLM, image, machine, and total dollars; token totals; per-member, per-model, and per-image-endpoint splits. Per-thread three-kind totals stay on Thread.usage (one credit = $0.001) |
Creating a thread end to end:
curl -sS -X POST "https://api.capy.ai/api/v1/threads" \
-H "Authorization: Bearer $CAPY_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "requestId": "deploy-fix-1", "projectId": "'$CAPY_PROJECT_ID'", "message": "Investigate the failing integration" }'Errors
The legacy { "error": { "code", "message" } } wrapper is gone. Errors are an HTTP status plus a tagged object — branch on the status first, then _tag:
{ "_tag": "capy/ThreadNotFound", "threadId": "jam_..." }Inaccessible and absent resources intentionally share the same 404, so a thread in another organization is indistinguishable from one that doesn't exist.
Retired capabilities
| Legacy capability | What to do instead |
|---|---|
| Tags, setup, snapshots, browser snapshots, personal environment variables | Manage in the app; no public replacement |
GET /threads/{threadId}/session-token, POST /sessions/verify | No public replacement |
Task mutation routes (create, message, start, stop, PR) and GET /tasks/{taskId}/diff | Drive work through the root thread |
GET /models | Omit model to use the configured default; pick models in the app |
Thread and message attachmentUrls | Upload with POST /files and attach by fileId on a message send; list and download with GET /threads/{threadId}/attachments |
If your integration depends on one of these, that dependency is a migration blocker to resolve product-side, not something to paper over in code.
Cutover checklist
- No code, tests, or configuration reference
https://capy.ai/api/v1 - Thread creation sends a stable
requestIdand handles the currentThreadshape - Stop paths call
/interrupt; pagination trustscursorand treatsnullas done - Error handling branches on HTTP status and
_tag - The old key is revoked after the new organization-scoped key is live
Agent brief
The button copies the full agent brief to your clipboard (or downloads it where clipboard access is unavailable). Paste it into your coding agent as the task prompt.
Copy the migration brief# Migrate an integration to the current Capy API
Use this brief as the task prompt for an agent migrating customer code from the legacy Capy API to the current public API.
## Goal
Update the integration so it uses the current Capy API safely, preserves existing project configuration, handles the new resource shapes, and no longer calls retired endpoints.
Do not expose API keys, make unapproved production mutations, or claim success without running the integration's relevant checks.
## Authoritative API facts
- Current base URL: `https://api.capy.ai/api/v1`
- Retired base URL: `https://capy.ai/api/v1`
- Authentication: `Authorization: Bearer capy_...`
- Create a current organization-scoped key in **Settings → API**. Legacy personal tokens never migrate — they were user-scoped across organizations, and current keys are organization-scoped. Legacy service-user tokens are planned to carry over unchanged, but do not depend on one until it verifies against the current API.
- The current OpenAPI document is the one published with this documentation site. Do not generate a client from the legacy OpenAPI document.
- Public resources cover projects (discovery and shared environment variables), threads, messages, file uploads, thread attachments, read-only subagents (formerly tasks), folders (including pinning), Captains, automations, reviews, review rules, review filters, users, and usage reporting.
- Project IDs from the legacy system are preserved, and project discovery is public: `GET /projects` lists the projects the key can access and `GET /projects/{projectId}` reads one. A configured project ID keeps working. `GET`, `PUT`, and `DELETE /projects/{projectId}/environment-variables` manage the project's shared environment variables, and reads return names and descriptions, never values. The rest of project configuration, such as setup and snapshots, stays app-only.
Use environment variables rather than literals:
```bash
CAPY_API_BASE=https://api.capy.ai/api/v1
CAPY_API_TOKEN=capy_xxxx
CAPY_PROJECT_ID=your-project-id
```
Never print `CAPY_API_TOKEN` in logs or test output.
## Required migration process
1. Read the repository's agent instructions and identify every Capy API client, endpoint string, request/response type, retry policy, polling loop, fixture, test, and customer-facing configuration reference.
2. Search for at least: `capy.ai/api`, `/v1/projects`, `/v1/threads`, `/v1/tasks`, `nextCursor`, `hasMore`, `prompt`, `messageId`, `mode`, `runState`, and legacy error codes.
3. Record which retired capabilities the integration uses. If a required capability has no current public replacement, do not silently remove behavior; report it as a blocker with the exact call site.
4. Implement the endpoint, payload, response, pagination, status, and retry changes below.
5. Update relevant fixtures and existing tests. Add tests only when the repository's instructions permit them.
6. Run the smallest typecheck, lint, formatting, and test commands covering the changed code.
7. Report changed behavior, unsupported dependencies, checks run, and any production cutover steps.
## Base URL and authentication
Replace:
```text
https://capy.ai/api/v1
```
with:
```text
https://api.capy.ai/api/v1
```
Do not append a second `/api` or `/v1` segment.
Current API keys do not have per-endpoint scopes. For least-privilege automation, use a service user with the required role and project access, then create the key for that service user.
A safe read-only smoke request is:
```bash
curl -sS "$CAPY_API_BASE/projects" \\
-H "Authorization: Bearer $CAPY_API_TOKEN"
```
A `401` response uses this shape:
```json
{ "_tag": "capy/Unauthorized" }
```
## Endpoint crosswalk
| Legacy API | Current API or required action |
| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /projects` | Same path, unpaged: `{ items }` of every project the key can access, with no `limit`/offset parameters. Items carry `id`, `name`, `description`, `code`, `repos` (`repoFullName`, `baseBranch`), `createdAt`, and `updatedAt`. |
| `GET /projects/{projectId}` | Same path, same item shape. Inaccessible and deleted projects answer `404` like absent ones. Shared environment variables are at `/projects/{projectId}/environment-variables`. Setup and snapshots stay app-only. |
| `GET /threads` | Same path. Supported query fields are `projectId`, `status`, `limit`, and `cursor`. |
| `POST /threads` | Same path with a new request body; see below. |
| `GET /threads/{threadId}` | Same path with the new `Thread` response. |
| `POST /threads/{threadId}/message` | Same path with `{ text, model?, delivery? }`. |
| `GET /threads/{threadId}/messages` | Same path with `after` pagination and the new `Message` response. |
| `POST /threads/{threadId}/stop` | Replace with `POST /threads/{threadId}/interrupt`. |
| `POST /threads/{threadId}/archive` | Same path; now returns the complete `Thread`. It does not stop active work. |
| `POST /threads/{threadId}/unarchive` | Same path; now returns the complete `Thread`. |
| `PUT /threads/{threadId}/tags` | No public replacement. |
| `GET /threads/{threadId}/session-token` | No public replacement. |
| `GET /tasks/{taskId}` | Replace with `GET /subagents/{subagentId}`, using only the opaque subagent ID returned by the current API. `GET /tasks/{taskId}` still answers with the same data. |
| Legacy task list/create/message/start/stop/PR routes | No direct replacement. List subagents with `GET /threads/{threadId}/subagents` and drive work through the root thread. |
| `GET /tasks/{taskId}/diff` | No public replacement. |
| `GET /models` | No public replacement. Omit `model` to use the configured default. |
| `GET /projects/{projectId}/automations` | Replace with `GET /automations?projectId={projectId}`. The response is cursor-paginated and includes each automation's standing configuration and enabled state. |
| `POST /projects/{projectId}/automations` | Replace with `POST /automations`, move `projectId` into the request body, and add a caller-stable `requestId`. The first successful response includes a one-time `webhookUrl` when the trigger set contains `incoming_webhook`; retries converge on the same automation without redisplaying the credential. |
| Legacy automation update or delete routes | General updates have no public replacement. Use `POST /automations/{automationId}/disable` and `/enable` to pause or resume runs, and `/delete` and `/restore` for the soft-delete lifecycle. |
| `GET /usage` | Same path with a new response: `GET /usage` returns a `UsageReport` for the current UTC month through now — pass ISO `from`/`to` timestamps to choose another window — with LLM, image, machine, and total dollars; token totals; and per-member, per-model, and per-image-endpoint splits. Per-thread three-kind credit totals remain on `Thread.usage` and `Task.usage`. |
| Setup, snapshots, tags, browser snapshots, personal environment variables, session verification | No current public replacement; manage these in the app. |
| Thread or message `attachmentUrls` | Upload with `POST /files`, PUT the bytes to each returned `url`, then pass `files: [{ fileId, name }]` on `POST /threads/{threadId}/message`. List a thread's files with `GET /threads/{threadId}/attachments` and get a short-lived download URL from `GET /threads/{threadId}/attachments/{fileId}/download`. |
## Create a thread
Replace the legacy `{ projectId, prompt, ... }` request with:
```json
{
"requestId": "caller-stable-idempotency-key",
"projectId": "configured-project-id",
"message": "Investigate the failing integration"
}
```
Optional fields:
```json
{
"title": "Custom title",
"model": {
"modelId": "model-id",
"reasoningMode": "high",
"modes": { "fast": true }
},
"machineSize": "medium",
"authorId": "user-id"
}
```
Allowed machine sizes are `small`, `medium`, `large`, `ultra`, `hyper`, and `bigguy`.
`authorId` attributes the thread to an active member of the key's organization: the thread lists under that member's threads, they are seeded as a participant, and the attribution reads back as `Thread.authorId`, while the kickoff message keeps the key's own principal as its author. The key still needs its own access to the project. An id that is not an active member answers `400` with `{ "_tag": "capy/InvalidRequest" }`, and a retried `requestId` returns the original thread with its original attribution even if the `authorId` changed or the member has since left. `GET /users` lists the organization's active members as `{ id, email, firstName, lastName }`, so resolve a person by email or name to the exact set of ids `authorId` accepts.
`requestId` must be a caller-chosen string of 1–191 characters. Reuse it only when retrying the same logical create request. This makes thread creation retry-safe and prevents duplicate billable runs.
The response is a `Thread`:
```json
{
"id": "jam_...",
"projectId": "...",
"authorId": null,
"title": "Investigate the failing integration",
"titleCustom": false,
"status": "working",
"archived": false,
"lastModelId": null,
"usage": {
"llmCredits": 0,
"imageCredits": 0,
"vmCredits": 0,
"totalCredits": 0
},
"createdAt": "2026-08-10T18:00:00.000Z",
"updatedAt": "2026-08-10T18:00:00.000Z",
"lastActivityAt": "2026-08-10T18:00:00.000Z"
}
```
One credit equals $0.001 USD. Thread usage includes the complete subagent subtree.
## Thread status
Handle every current thread status:
- `working`: the agent is running now.
- `waiting`: the agent paused and resumes by itself when something it waits on arrives, such as CI results, a timer, or a subagent finishing. No customer action is needed.
- `idle`: the agent stopped and waits for the next message. Its last reply holds the result or a question.
- `failed`: the last run ended with an error.
- `archived`: the thread is archived.
A polling loop waits while the status is `working` and stops on any other value. Right after a create or a message send, a thread can read `idle` for a moment before the agent picks up the message, so wait a few seconds before the first poll.
Do not carry forward logic based on the legacy `runState`, `waitingOn`, `blockedOn`, or `pendingWakeups` fields; they are not present on the current public `Thread`.
## Messages, interruption, and retries
Replace the legacy message body:
```json
{ "message": "Focus here", "mode": "interrupt" }
```
with:
```json
{ "text": "Focus here", "delivery": "interrupt" }
```
`delivery` is optional and defaults to `interrupt`. Allowed values:
- `interrupt`
- `queue`
- `steer`
The response is an admission receipt:
```json
{ "id": "01K...", "deduped": false }
```
Message sends take `clientKey` (1–64 chars) as the caller idempotency key. Mint one per message and reuse it when you retry a timed-out send: the retry posts nothing new and returns the first send's `id` with `deduped: true`. A send without `clientKey` is not deduplicated.
Queued and steered messages can be changed before delivery:
- `POST /threads/{threadId}/messages/{eventId}/cancel` returns `{ "outcome": "cancelled" }` or `{ "outcome": "tooLate" }`.
- `POST /threads/{threadId}/messages/{eventId}/send-now` returns `{ "outcome": "sent", "id": "..." }` or `{ "outcome": "tooLate" }`.
Use the admission receipt's `id` as `eventId`.
Replace stop calls with:
```text
POST /threads/{threadId}/interrupt
```
Archiving no longer interrupts active work. When the old behavior expected both actions, call `/interrupt` and then `/archive` explicitly.
## Transcript migration
Read a thread transcript with:
```text
GET /threads/{threadId}/messages?after={cursor}&limit=50
```
Current message fields:
```json
{
"id": "...",
"source": "user",
"text": "Message body",
"createdAt": "2026-08-10T18:00:00.000Z"
}
```
Migrate `content` reads to `text`. `source` is now `user`, `assistant`, or `tool`. Tool messages may include `tool`; assistant messages may include `model` and `attachments`; user messages may include `authorName`.
The public transcript contains rendered tool summaries, not raw tool arguments, results, or thinking. Do not depend on raw event payloads.
## Pagination migration
Remove assumptions about `nextCursor` and `hasMore`.
- Thread lists accept `cursor` and return `{ items, cursor }`.
- Subagent trees and subagent transcripts accept `after` and return `{ items, cursor }`.
- Thread transcripts and thread attachments accept `after` or `before` and return `{ items, cursor, beforeCursor }`. Pass `cursor` as `after` to read newer entries, or `beforeCursor` as `before` to read older ones.
- A `null` response cursor means the caller is caught up.
- Pass non-null cursors back unchanged. Do not parse or construct them.
## Task migration
Legacy tasks are now read-only subagents.
List a thread's subagent tree:
```text
GET /threads/{threadId}/subagents?after={subagentId}&limit=100
```
Read a subagent or its transcript:
```text
GET /subagents/{subagentId}
GET /subagents/{subagentId}/messages?after={cursor}&limit=50
```
Use the exact opaque subagent `id` returned by the subagent tree. Do not use a legacy task number or project-code identifier.
Current subagent fields include `id`, `threadId`, `parentId`, `subagentPath`, `projectId`, `title`, `status`, `usage`, and timestamps. Handle status values `working`, `waiting`, `idle`, `done`, and `failed`.
The same reads are served at `GET /threads/{threadId}/tasks`, `GET /tasks/{taskId}`, and `GET /tasks/{taskId}/messages`, with `taskPath` in place of `subagentPath` and `404 capy/TaskNotFound` in place of `capy/SubagentNotFound`. Code already using those routes keeps working; use the `/subagents` routes for new code.
To direct or stop subagent work, send a message or interrupt to the subagent's root `threadId`. Do not call task mutation routes.
## New current routes
Thread title routes:
- `PATCH /threads/{threadId}` with `{ "title": "..." }`
- Send `{ "title": null }` to return to automatic titles
- `POST /threads/{threadId}/regenerate-title`
Usage route:
- `GET /usage?from={iso}&to={iso}` returns the key-scoped `UsageReport`: billed dollar totals (`llmDollars`, `imageDollars`, `vmDollars`, `totalDollars`), window LLM token totals, per-member billed spend, per-model spend split into billed and unbilled rows, and billed image spend grouped by exact endpoint. `from` defaults to the current UTC month and `to` defaults to now; the response echoes the effective window queried.
Review routes:
- `GET` and `PUT /review-settings`
- `GET /review-billing-transfer`
- `POST /review-billing-transfer/offer`
- `POST /review-billing-transfer/accept`
- `POST /review-billing-transfer/cancel`
- `POST /review-billing-transfer/decline`
- `POST /reviews`
Do not add review integration code unless the customer already uses reviews or explicitly requests it.
## Error migration
Remove dependencies on the legacy wrapper:
```json
{
"error": {
"code": "not_found",
"message": "..."
}
}
```
The current API uses HTTP status plus a tagged object:
```json
{
"_tag": "capy/ThreadNotFound",
"threadId": "jam_..."
}
```
Branch on the HTTP status first and `_tag` second. Any operation can answer `429` with `capy/RateLimited`, `retryAfterSeconds`, and a `retry-after` header. Wait that many seconds before retrying. Do not expose whether an inaccessible resource exists in another organization; inaccessible and absent resources intentionally share the same `404` behavior.
## Acceptance criteria
- No production code, tests, examples, or configuration defaults call `https://capy.ai/api/v1`.
- Project IDs come from configuration or `GET /projects`; nothing depends on the legacy `limit`/offset pagination, because the current list is unpaged.
- A thread create supplies a stable `requestId`, uses `message`, and handles the current `Thread` response.
- Message sends use `text` and `delivery`; message retries cannot silently duplicate work.
- Stop behavior uses `/interrupt`; stop-and-archive behavior invokes both operations explicitly.
- Pagination uses the response `cursor`, `after` where required, and `null` as the completion condition.
- Transcript handling uses `text` and supports `source: "tool"`.
- Subagent handling uses `/threads/{threadId}/subagents` (or the equivalent `/tasks` routes), opaque subagent IDs, and root-thread mutations.
- Error handling uses HTTP status and `_tag`, not legacy `error.code`.
- Any dependency on an unsupported public capability is reported with file and line references.
- Relevant formatting, lint, typecheck, and existing tests pass.
- No secret value appears in source, logs, fixtures, commits, or the final report.
## Required final report
Return:
1. The files changed and the migration made in each.
2. Retired endpoints removed or replaced.
3. Unsupported capabilities that block full parity.
4. Checks run and their exact results.
5. Customer cutover steps, including configuration and key rotation.