Common flows

Seven flows you'll actually build, each complete enough to paste into a script. All of them assume:

Terminal
export CAPY_API_KEY="capy_..."
export API="https://api.capy.ai/api/v1"
auth() { curl -sS -H "Authorization: Bearer $CAPY_API_KEY" "$@"; }

Drive a thread end to end⁠

Create with a caller-minted requestId so a timed-out create can be retried without starting a second run:

Terminal
THREAD_ID=$(auth -X POST "$API/threads" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "'"$(uuidgen)"'",
    "projectId": "'"$PROJECT_ID"'",
    "message": "Upgrade the CI pipeline to Node 24 and open a PR."
  }' | jq -r .id)

Poll until the thread is no longer working:

Terminal
while :; do
  sleep 10
  STATUS=$(auth "$API/threads/$THREAD_ID" | jq -r .status)
  echo "$STATUS"
  [ "$STATUS" = working ] || break
done

The loop sleeps before its first read because a thread can read idle for a moment after a create or a reply, before the agent picks up the message. The status that ends the loop tells you what to do next:

  • idle: the agent stopped and waits for you. Its last reply holds the result or a question.
  • waiting: the agent paused and resumes by itself when something it waits on arrives, such as CI results, a timer, or a subagent finishing. Run the loop again to wait for its next result.
  • failed: the last run ended with an error. The transcript shows how far it got.
  • archived: someone archived the thread.

In every case, the transcript holds the agent's latest reply:

Terminal
auth "$API/threads/$THREAD_ID/messages" | jq -r '.items[] | "\(.source): \(.text)"'

Assistant entries carry the agent's replies, including the PR link when it opened one. Tool entries are one-line activity summaries, never raw arguments or results. Page with after set to the response's cursor until it's null.

Reply, and the loop starts again:

Terminal
auth -X POST "$API/threads/$THREAD_ID/message" \
  -H "Content-Type: application/json" \
  -d '{"text": "CI is green but the lockfile changed; explain why."}'

A message interrupts the agent's current work by default. Pass "delivery": "queue" to let the current work finish first, and the receipt's id is what you'd pass to POST $API/threads/$THREAD_ID/messages/{id}/cancel if you change your mind while it's still waiting.

Inspect a thread's subagent tree⁠

Agents fan work out to subagents, which either share the parent's machine or run on a fresh one. The subagent endpoints are read-only observation: a subagent is driven through its thread, so if you want a subagent to change course, message the thread.

List the tree; pages walk depth-first, so parents always precede children and every page prefix is a coherent subtree:

Terminal
auth "$API/threads/$THREAD_ID/subagents" | jq -r '.items[] | "\(.subagentPath)  \(.status)  \(.title)"'

subagentPath is the dotted address from the thread root: 1.2 is the second subagent under the first subagent. status is the run state: working, waiting, idle, done, or failed. Each subagent's usage is its own subtree's spend, so you can see where the credits went.

Read one subagent and its transcript with the same Message shape threads use:

Terminal
auth "$API/subagents/$SUBAGENT_ID"
auth "$API/subagents/$SUBAGENT_ID/messages" | jq -r '.items[] | "\(.source): \(.text)"'

The same reads are served at /threads/{threadId}/tasks, /tasks/{taskId}, and /tasks/{taskId}/messages, the API's original names for these routes. Those responses carry taskPath in place of subagentPath, and a missing id answers 404 capy/TaskNotFound instead of capy/SubagentNotFound.

Start a review on a pull request⁠

POST /reviews starts a Capy review round on any PR in a repository your organization's GitHub installation covers:

Terminal
auth -X POST "$API/reviews" \
  -H "Content-Type: application/json" \
  -d '{
    "repo": "acme/checkout",
    "prNumber": 481,
    "idempotencyKey": "release-gate-481"
  }'
JSON
{
  "reviewId": "review_01...",
  "requestId": "01K...",
  "threadId": "jam_01...",
  "headSha": "b1c2d3...",
  "adopted": false
}

reviewId names the pull request's review, which every round on that pull request shares. requestId names this round. Save it as ROUND_ID, then read the round's status and findings:

Terminal
auth "$API/reviews/rounds/$ROUND_ID" \
  | jq '{status, title, findings: [.findings[] | {severity, file, line, summary}]}'

status is pending, running, completed, failed, or stale. A requestId your organization can't read answers 404 capy/ReviewRoundNotFound.

A review round is keyed to the PR's exact head and base commits, so retriggering the same code answers the existing round with adopted: true and runs nothing new. Push a commit and the next call opens a fresh round. Omit idempotencyKey and this scope keying is the whole dedup; pass forceRefresh: true for a confirmed manual re-run of an already-completed round.

If one of your threads should hear the verdict, pass its id as sourceThreadId: the round's outcome lands in that thread as an ordinary input and wakes it, instead of you polling. The response's sourceRecorded tells you whether that notification was actually recorded (false when the adopted round already reports elsewhere or already finished; read the findings in the app instead of waiting).

A closed or draft PR, or a PR GitHub won't let Capy read, answers capy/ReviewRefused (422) with the reason.

Create, inspect, and pause automations⁠

Create an enabled schedule automation and retain its id. Keep the same request id when retrying an ambiguous response:

Terminal
REQUEST_ID=$(uuidgen)
AUTOMATION_ID=$(auth -X POST "$API/automations" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "'"$REQUEST_ID"'",
    "projectId": "'"$PROJECT_ID"'",
    "name": "Daily dependency audit",
    "prompt": "Audit dependencies for security advisories and open a PR for required upgrades.",
    "triggers": [{ "type": "schedule", "cron": "0 3 * * *", "timezone": "UTC" }]
  }' | jq -r .id)

If you create an incoming_webhook trigger, save the response's webhookUrl at this point. The capability URL is returned only by the first successful request; an idempotent replay returns the existing automation with webhookUrl: null, and later reads and mutations omit the field.

A human key can create automations. A service-user key needs read_write or admin, and its project allowlist applies to the requested projectId; read_only receives 403.

List the automations visible to the API-key principal. Pass projectId when the integration should inspect one project only:

Terminal
auth "$API/automations?projectId=$PROJECT_ID" \
  | jq -r '.items[] | "\(.id)  \(if .enabled then "enabled" else "disabled" end)  \(.name)"'

The response is cursor-paginated. Pass a non-null cursor back unchanged to read the next page.

Pause the automation that's causing unwanted runs:

Terminal
auth -X POST "$API/automations/$AUTOMATION_ID/disable" \
  | jq '{id, name, enabled}'

The response's enabled field is false after the schedules and provider routes have been removed. Resume it with the inverse transition:

Terminal
auth -X POST "$API/automations/$AUTOMATION_ID/enable" \
  | jq '{id, name, enabled}'

Enabling revalidates the stored run-as principal before rebuilding schedules and provider routes. Human API keys and service-user keys with read_write or admin can change state; a read_only service key can inspect automations but receives 403 for either mutation.

Delete and restore use the same authorization and return the resulting deleted state:

Terminal
auth -X POST "$API/automations/$AUTOMATION_ID/delete"
auth -X POST "$API/automations/$AUTOMATION_ID/restore"

Create a thread for a teammate and put it on their board⁠

A service-user key can attribute work to a member so it shows up as theirs in the app. First resolve the member: GET /users lists your organization's active members, and every id it returns is a valid attribution target:

Terminal
MEMBER_USER_ID=$(auth "$API/users" | jq -r '.items[] | select(.email == "jordan@example.com") | .id')

Create the thread with authorId, then pin it to that member's board with the same id:

Terminal
THREAD_ID=$(auth -X POST "$API/threads" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "'"$(uuidgen)"'",
    "projectId": "'"$PROJECT_ID"'",
    "message": "Triage the overnight Sentry alerts and summarize what needs a human.",
    "authorId": "'"$MEMBER_USER_ID"'"
  }' | jq -r .id)

auth -X POST "$API/threads/pin" \
  -H "Content-Type: application/json" \
  -d '{"threadIds": ["'"$THREAD_ID"'"], "userId": "'"$MEMBER_USER_ID"'"}'

Both calls validate the id against your organization's active members and answer 400 capy/InvalidRequest for anyone else. The thread now lists under the member's threads, they're seeded as a participant, and the attribution reads back as Thread.authorId; the kickoff message stays attributed to your key's principal. A human key can omit userId to pin as itself; a service key must pass it, because a service principal has no board of its own. Pinned is per-member and private, so your service key can write to it but not read it back: confirm through the mutation's outcomes (ok per thread).

Shared folders are readable and writable by any key. List what your key can see, file the thread, and page the folder's threads in board order:

Terminal
auth "$API/folders" | jq '.items[] | {id, name, kind, visibility}'

auth -X POST "$API/folders/$FOLDER_ID/threads" \
  -H "Content-Type: application/json" \
  -d '{"threadIds": ["'"$THREAD_ID"'"]}'

auth "$API/folders/$FOLDER_ID/threads?limit=50" | jq '{ids: [.items[].id], cursor}'

Filing answers one outcome per thread: ok (re-filing an already-filed thread is also ok), not_found (absent, another organization's, or outside a service key's project access, deliberately indistinguishable), or not_allowed (a personal thread refused by a shared folder). A read_only service key can list and page folders but receives 403 for every mutation. Folders are created and managed in the app; the API files into them.

Trigger an automation webhook⁠

An automation with an incoming-webhook trigger exposes a capability URL. Note this one is not under /api/v1 and takes no bearer header, because the URL itself is the credential:

Terminal
curl -sS -X POST "https://api.capy.ai/webhooks/automations/$WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: incident-7231" \
  -d '{"service": "checkout", "alert": "p99 latency 4x baseline", "since": "2026-08-11T14:02:00Z"}'

Idempotency-Key is your retry safety: deliveries with the same key start one run, so your alerting system can fire the hook at-least-once without fanning out runs. A 202 body of accepted means a run was admitted, duplicate means your key already ran, and no-match means the automation's trigger filters rejected this body: a valid delivery that starts no run, so don't retry it. 404 means the URL is wrong or the automation is disabled, deliberately indistinguishable.

The body reaches the agent as quoted, untrusted event context under the automation's stored prompt: send data for the agent to act on, not instructions.

Export usage by day, member, model, or thread⁠

GET /usage/series returns the numbers on the Usage page as rows you can load into a spreadsheet or warehouse. Each row covers one UTC bucket (granularity=day or hour) for one group. Group by user, thread, or billing item, and repeat groupBy to combine them: groupBy=user&groupBy=item answers which models and machines each member used. Leave groupBy out for one total per bucket.

Narrow any query with userIds, projectIds, threadIds, and models. Repeat a filter to pass several values. Filters only narrow what your key can already read.

Pin from and to so every page reads the same window, then follow cursor until it is null:

Terminal
FROM="2026-10-01T00:00:00Z"; TO="2026-11-01T00:00:00Z"; CURSOR=""
while :; do
  PAGE=$(auth -G "$API/usage/series" \
    --data-urlencode "from=$FROM" --data-urlencode "to=$TO" \
    -d granularity=day -d groupBy=user -d groupBy=item -d limit=1000 \
    ${CURSOR:+--data-urlencode "cursor=$CURSOR"})
  echo "$PAGE" | jq -r '.items[] | [.start, .dimensions.userId, .dimensions.itemType, .dimensions.itemId, .billedDollars, .tokens.inputTokens, .tokens.outputTokens] | @csv'
  CURSOR=$(echo "$PAGE" | jq -r '.cursor // empty')
  [ -z "$CURSOR" ] && break
done

A row looks like this:

JSON
{
  "start": "2026-10-14T00:00:00.000Z",
  "end": "2026-10-15T00:00:00.000Z",
  "dimensions": {
    "userId": "user_2xYz8Qm4LkP0aBcDeFgHiJkLmNo",
    "itemType": "llm",
    "itemId": "anthropic/claude-opus-5-5",
    "model": "anthropic/claude-opus-5-5",
    "label": "Claude Opus 5.5"
  },
  "llmDollars": 41.22,
  "imageDollars": 0,
  "vmDollars": 0,
  "standaloneTokenFeeDollars": 0.31,
  "billedDollars": 41.53,
  "referenceDollars": { "connectedSubscription": 0, "byok": 18.02 },
  "tokens": { "inputTokens": 9120455, "outputTokens": 84210, "cacheReadTokens": 8811020, "cacheWriteTokens": 301877 }
}

Every response carries "currency": "USD". Read the money fields this way:

  • billedDollars is usage charged against your Capy credit, the Usage page's Total billed. It equals llmDollars + imageDollars + vmDollars + standaloneTokenFeeDollars. It doesn't include plan invoices, taxes, or bills from your model providers.
  • standaloneTokenFeeDollars is the Capy token fee on model usage served by a connected subscription or your own provider key. The fee on usage billed to Capy credit is already inside llmDollars.
  • referenceDollars prices usage that Capy didn't charge, at Capy's rate: connectedSubscription for a connected subscription seat and byok for your own provider key. These aren't what your provider billed, so don't add them to billedDollars.

Read the token fields this way:

  • inputTokens counts all input, including cache reads and writes. cacheReadTokens and cacheWriteTokens are parts of it.
  • Total tokens are inputTokens + outputTokens.
  • Token counts include every route, charged or not.

Read the dimensions this way:

  • itemType is llm, review (code review), machine, or image.
  • itemId is the model id, review, the machine size, or the image endpoint.
  • model is set only for llm items.
  • threadId counts subagent spend toward its thread. Threads your key can't read are combined under private.
  • A null userId or threadId is spend with no acting user or no thread.

Read the buckets and rows this way:

  • Buckets run from start (inclusive) to end (exclusive). The first and last buckets are clipped to your from and to.
  • A row is left out when every money and token field is zero.
  • hour windows reach back at most 31 days and day windows a year. The response echoes the effective from and to.
  • Recent buckets can still change while usage settles and syncs. Re-export the last day or two and upsert rows by start and dimensions.

GET /usage returns the window totals with per-member, per-model, and per-image splits in one response. Its totals.billedDollars and members[].billedDollars equal the series sums for the same window. totalDollars is deprecated: it equals billedDollars - standaloneTokenFeeDollars.

For the credit balance and billing period shown at the top of the Usage page, use GET /usage/balance. remainingDollars is the spendable value of your plan and purchased credit, not a cash balance:

Terminal
auth "$API/usage/balance"
# {"currency":"USD","remainingDollars":1840.5,"unlimited":false,"periodStart":"2026-10-03T00:00:00.000Z","periodEnd":"2026-11-03T00:00:00.000Z"}

A key limited to some projects reads usage for those projects only. /usage/balance answers that key with 403, because the balance belongs to the whole organization. When usage analytics is briefly unavailable, /usage/series answers 503 capy/UsageUnavailable; retry the request.

Open in Capy