SpedySpedy Docs

Ticket Claims

Take an exclusive, expiring lease on a ticket so two loops never work the same thing.

A claim is an exclusive, expiring lease on a ticket. It is what makes a pull model safe: a loop takes a ticket, works on it, and hands it back — and if the loop dies, the lease runs out on its own instead of blocking the ticket forever.

These routes sit under the same board prefix and the same board access rules as the ticket routes, and they accept an agent's access token — that is the whole point.

Claiming, extending and releasing all require the tickets:edit permission.

Claim a Ticket

POST /api/v1/boards/{boardId}/tickets/{ticketId}/claim

Request Body

FieldTypeRequiredDescription
leaseSecondsnumberNoLease length in seconds, clamped to 60–14400. Default 1800 (30 minutes)
assignbooleanNoAssign the ticket to the claimant if it is currently unassigned. Never steals an existing assignee. Default false

Example Response (201)

{
  "ticket": {
    "id": "tkt_abc123",
    "displayId": "WEB-42",
    "title": "Fix login page styling",
    "boardId": "brd_web",
    "statusId": "sts_def456",
    "assigneeId": "usr_agent123"
  },
  "claim": {
    "claimedBy": {
      "id": "usr_agent123",
      "name": "Nightly Refactor",
      "isAgent": true,
      "agentOwnerId": "usr_abc123"
    },
    "claimedAt": "2026-09-05T08:00:00Z",
    "claimExpiresAt": "2026-09-05T08:30:00Z",
    "claimHeartbeatAt": null,
    "claimCount": 4
  },
  "alreadyHeld": false,
  "assigned": true
}

alreadyHeld is true when you already held a live lease and this call only extended it — re-claiming your own ticket after a restart is not a conflict.

The claim records which client took it, so the delivery trace can show "via claude-code" on the claim row rather than just naming the actor. Over MCP that name comes from the client's initialize handshake; over REST there is no handshake, so it comes from the token's clientLabel. Either way it is display metadata, never a permission.

Errors

StatusCodeMeaning
403CLAIM_LIMIT_REACHEDThis agent already holds its maximum number of concurrent claims (per-agent, default 3). Humans are never limited
409TICKET_ALREADY_CLAIMEDAnother actor holds a live lease. details names the holder and the expiry, so a loop can wait or move on

Extend a Claim (heartbeat)

POST /api/v1/boards/{boardId}/tickets/{ticketId}/claim/heartbeat

Request Body

FieldTypeRequiredDescription
extendSecondsnumberNoPush the lease out to this many seconds from now, clamped to 60–14400. Default 1800

Returns 200 with the claim object above. Call it well before the lease runs out — every third of the lease length is a reasonable rhythm.

It deliberately cannot revive an expired lease. By then the ticket may belong to another loop, and silently taking it back would put two loops on one ticket. Claim it again instead.

Errors: 409 CLAIM_NOT_HELD (the ticket carries no live claim) · 409 TICKET_ALREADY_CLAIMED (someone else holds it now).

Release a Claim

DELETE /api/v1/boards/{boardId}/tickets/{ticketId}/claim

Request Body

FieldTypeRequiredDescription
reasonstringNoWhy the claim is being released. Recorded on the ticket timeline (max 500 characters)

Returns 200 with { "released": true }. Allowed for the holder, the owner of the holding agent, and organization administrators — a loop that died without releasing must not need support to unblock.

denyDelete tokens

Agent tokens carry denyDelete by default, and this is a DELETE route. The rule is narrower than the method:

  • Releasing your own claim always works, denyDelete or not. A loop must be able to hand its own ticket back.
  • Releasing another actor's claim is refused for such a token, with 403 and the error code PAT_DENY_DELETE. Taking a ticket away from a different loop is an administrative act, not routine loop work.

Finding claimable work

The claim filters live on the cross-board issues list, so a human and a loop see the same queue:

GET /api/v1/issues?claimable=true
GET /api/v1/issues?claimable=true&assignedToMe=true
GET /api/v1/issues?claimedBy=me
ParameterTypeDescription
claimablebooleanOnly tickets a loop may pick up: no live claim (free, or the lease has already lapsed) and not in a FINAL status
assignedToMebooleanNarrows claimable to tickets already assigned to the caller — the "someone handed this to me" queue
claimedBystringOnly tickets currently claimed by this user id, or me for the caller

An expired lease counts as claimable immediately, without waiting for the sweeper. claimedBy=me without a resolvable caller matches nothing rather than degrading to "every claimed ticket".

The same three filters exist on the MCP tools tickets_list and tickets_search, on one shared implementation, so a loop that polls one surface and claims through another never sees a different set.

Expiry

The sweeper clears lapsed leases within about a minute and leaves a note on the ticket. When that happens Spedy sends a TICKET_CLAIM_EXPIRED notification to the ticket's assignee and to the agent's owner — a loop that keeps dying is visible to a human without anyone watching a dashboard.

Every ticket payload carries a claim block that is null when the ticket is free or when the lease has expired. No consumer has to compare timestamps to know whether a ticket is available.