SpedySpedy Docs

Agent Loops

Connect your own coding agent to Spedy — it claims tickets, works, and reports back over MCP.

Spedy does not run agents. You do. A loop on your machine (or your CI) polls Spedy over MCP with its own access token, claims a ticket so nobody else picks it up, works, and reports back. Spedy is where that work arrives: the ticket, the comment, the pull request, the time, the attribution.

Nothing in Spedy ever calls out to your agent. If your loop stops, nothing happens — which is exactly the property that makes it safe to leave running.

The loop, end to end

users_me                      → what am I, what may I do, how many claims do I hold
tickets_list  claimable: true → what can I pick up
tickets_claim                 → take an exclusive, expiring lease on ONE ticket
timers_start                  → (optional) track the time
   … work …
tickets_heartbeat             → keep the lease alive while you work
tickets_report                → comment, PR link, status, timer, release — in one call

1. Create the agent

Settings → Agents → Create agent.

An agent is a user with isAgent = true. It has no login of any kind — no password, no OAuth, no invitation — and authenticates exclusively with a personal access token. It is seat-free (agents are excluded from your billed seats) and receives no e-mail. Everywhere a member appears — assignee pickers, comments, the time approval list — an agent appears too, marked as an agent.

Every agent belongs to a human owner. The owner can manage the agent's settings and tokens without holding agents:manage; changing the owner needs the admin permission.

2. Give it access to projects

An agent reaches a project the same way a person does: project membership. Add the agent under Project → Members. An agent that is a member of nothing can read nothing.

3. Mint a scoped token

Settings → Agents → <agent> → Access tokens → Create token. The plaintext token is shown exactly once.

Scope it as narrowly as the job allows:

OptionWhat it does
allowedBoardIdsRestricts the token to specific projects, regardless of the agent's memberships. Empty = every project the agent is a member of.
readOnlyRead tools only. No writes at all.
denyDeleteBlocks every destructive tool. On by default for agent tokens.
clientLabelA name for this token's loop (e.g. nightly-refactor on ci-1). It is what shows up as the author's client on comments, time entries and status changes — and it outranks whatever the MCP client announces about itself.

Scope enforcement is fail-closed: a token restricted to two projects cannot reach a third even if the agent is a member of it.

4. Point your MCP client at Spedy

{
  "mcpServers": {
    "spedy": {
      "type": "http",
      "url": "https://<your-org>.spedy.ai/api/v1/mcp",
      "headers": { "Authorization": "Bearer pat_…" }
    }
  }
}

Call users_me first. It reports the actor, the owner, the token's scope, the effective MCP time factor, the weekly hours cap, and how many claims the agent holds out of how many it may — everything a loop needs to decide whether to start at all.

5. Find work

tickets_list and tickets_search take claimable: true: tickets no live claim is held on (free, or the previous lease has already lapsed) and not in a final status.

FilterWhat it narrows to
claimable: trueEverything this loop may pick up right now
assignedToMe: trueNarrows claimable to tickets already assigned to this actor — the "a human handed me this" queue, and the filter to poll if you only want work someone deliberately gave the agent
claimedBy: "me"What this loop is already holding

The same three filters exist on the web UI's issue list ("Claimable only") and on GET /issues, on one shared implementation — so a human and a loop always see the same queue.

6. Claim it

tickets_claim { ticket, leaseSeconds?, assign? } takes an exclusive, expiring lease.

  • Exclusive. Of two loops claiming the same ticket at the same moment, exactly one wins. The other gets a clear conflict naming the current holder and when the lease expires, so it can wait or move on.
  • Expiring. The lease runs out (default 30 minutes, maximum 4 hours). A loop that crashes mid-run does not block the ticket forever — Spedy clears the lapsed lease within a minute and leaves a note on the ticket.
  • Never stealing. A claim never takes a ticket from someone else, and assign: true only fills an empty assignee.

Re-claiming a lease you already hold extends it rather than failing — a restarted loop re-taking its own ticket is not a conflict.

Agents have a ceiling on how many tickets they may hold at once (default 3, configurable per agent). Humans have none.

7. Stay alive

tickets_heartbeat { ticket, extendSeconds? } pushes the lease out. Call it well before the lease runs out — say, every third of the lease length.

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

8. Report back

tickets_report is the end of the run. One call:

Argument
ticketdisplayId (AG-12) or id
outcomedone · blocked · needs_review · partial
summaryWhat you did, in prose
changesWhat changed, one bullet each
openPointsWhat is still open — this is what a human reads first
prUrl, branchWhere the code is
statusId / statusKeyWhere the ticket should go
stopTimerStop your running timer on this ticket (default true)
releaseClaimGive the ticket back (default true)
isSecretPost as internal (default true — a run report is engineering detail)

It posts one comment in a fixed layout (Result / Changes / Open / PR), links a pull request Spedy already knows to the ticket, moves the status, stops the timer and releases the claim.

It never half-applies and then fails. The comment is written first and is the only step whose failure aborts the call; everything after it is best-effort. Anything that could not be done comes back in issues[] with a reason — a PR Spedy has not seen yet, a status move the backlog gate refuses, a policy that reserves that status for a human. Your prose is never lost because a later step did not work out.

Moving the status is also a capability question, not just a policy one: tickets_report needs the tickets:approve capability — the same one tickets_move_status costs — to move a ticket. Without it the comment, the PR link, the timer and the release all still happen, and the status step alone lands in issues[] with code: "PERMISSION_DENIED". A narrowly scoped agent can therefore report perfectly well; it just cannot decide where the ticket goes.

Policies: what an agent may not do alone

Settings → Agents → Agent policies. These apply to every agent in the organization; humans are unaffected.

Status changes that need approval

Default: DONE.

When an agent tries to move a ticket into one of these statuses, the move does not happen. Instead Spedy records a pending suggestion on the ticket, which a team member accepts in the normal suggestion banner. tickets_report says so in issues[] (code: "APPROVAL_REQUIRED"), and the same rule applies to tickets_move_status.

There is no second door. The plain REST route is gated identically: an agent calling PATCH /boards/{boardId}/tickets/{ticketId}/status on a gated status gets 403 with the code AGENT_APPROVAL_REQUIRED, and the pending suggestion is recorded there too. Dropping MCP for raw HTTP buys an agent nothing.

If the board has Explainable Status turned off, no suggestion can be recorded. The move is still refused, and the response says so explicitly, so a loop is never left believing something happened that did not.

Agent pull requests need a human review

Default: on. Set an agent's git identity (its GitHub/GitLab login or commit e-mail) under Settings → Agents → <agent>. Pull requests whose author matches are marked "needs human review" in the PR list and detail.

This is informational. Spedy cannot block a merge on GitHub, GitLab or Bitbucket, and does not pretend to — use your provider's branch protection for that. The badge exists so a reviewer scanning a list knows which PRs a machine opened.

Time

Time an agent tracks is a draft: it lands with PENDING approval, scaled by the MCP time factor (org-wide, overridable per agent), and a human approves, adjusts or discards it under Time → Approvals before it is billed. A per-agent weekly hours cap stops a timer start once reached (AGENT_HOURS_CAP_REACHED).

Like the status policy, this follows the actor, not the transport: the time factor, the weekly cap and the PENDING state apply to an agent user over the REST API exactly as they do over MCP.

Stop polling: wake the loop instead

Polling is the simple option, not the good one. Assign a ticket to the agent user and subscribe an outbound webhook to ticket.assigned (Settings → Webhooks): your side learns there is work the moment a human hands it over, and runs one pass instead of waking every minute. The loop still claims the ticket — the assignment is the signal, the claim is what makes the work exclusive.

Four more events round out the picture when several loops share a project:

EventFires when
ticket.assignedSomeone hands a ticket to the agent — the wake-up
ticket.claimedA lease was taken (by any actor)
ticket.claim_expiredA lease ran out unreleased; the ticket is free again
ticket.reportedA loop finished a run (outcome, summary, prUrl)
pr.merged / pipeline.updatedThe work landed, or CI has a verdict

Payloads carry ids and state, never content. Full reference: Webhooks.

Seeing it from the human side

  • The ticket detail and the kanban cards show who holds the claim and how long is left on the lease. The holder, the agent's owner and administrators can release it.
  • The issues sidebar has a Claimable only filter — the same queue your loop polls.
  • Comments, time entries, status changes and claims carry the client they came through, and agent authors are labelled as agents everywhere.

Two notifications you do not have to watch a dashboard for

NotificationFires whenWho gets it
TICKET_CLAIM_EXPIREDA lease ran out unreleased and the sweeper cleared it — the ticket is free again, and something probably diedThe ticket's assignee and the agent's owner
AGENT_RUN_REPORTEDA run reported blocked or needs_review — the two outcomes that are explicitly asking for a humanThe ticket's assignee and the agent's owner

done and partial deliberately do not notify: a loop that works is not news. These two are what a person needs to hear.

Delivery trace (per ticket)

The ticket's Activity tab has a collapsible Delivery trace: one ordered list of how that ticket got delivered — claims taken, released or expired, the run reported and its outcome, the first agent and first human comment, status moves, the branch, the pull request with its reviews and merge, pipeline runs, and every booked minute (drafts included, marked as drafts). Each row names the actor, whether it is an agent, and which client it came through — a claim records the MCP client that took it, so a claim row reads "via claude-code" rather than just naming the machine.

Where a source is not recorded at all the trace says so in gaps instead of leaving a silent hole — a reader has to be able to tell "no agent touched this" from "we never wrote it down". Customers cannot read it; they see time through the coarser Zeiten & Budget view.

Your loop can read the same story with tickets_delivery_trace before deciding whether to start.

Agents overview (per organization)

Settings → Agents → Übersicht answers the operational question. Per agent: last seen, open claims with their expiry, claims that expired unreleased in the last 7 days, runs reported in the last 7 and 30 days by outcome, time drafts waiting for approval, approved minutes this month, and pull requests with the "needs human review" count. Plus the organization totals: the agent share of tickets closed and of hours booked this month.

A share with nothing to divide comes back empty, not 0 %. Needs agents:manage — it carries org-wide numbers.

Tool reference

ToolCapabilityWhat it does
tickets_claimtickets:updateTake (or extend) an exclusive, expiring lease
tickets_heartbeattickets:updatePush the lease out; cannot revive an expired one
tickets_releasetickets:updateGive the ticket back
tickets_reporttickets:update + comments:createComment, link the PR, move the status, stop the timer, release
tickets_list / tickets_searchtickets:readclaimable / assignedToMe / claimedBy filters
work-context_gettickets:readWhat needs attention, plus "my claims"
tickets_delivery_tracetickets:readWhat already happened on this ticket, in order
users_meActor, owner, token scope, limits, claims held

REST equivalents

Everything above is also plain HTTP, for a loop that would rather not speak MCP:

POST   /api/v1/boards/{boardId}/tickets/{ticketId}/claim
POST   /api/v1/boards/{boardId}/tickets/{ticketId}/claim/heartbeat
DELETE /api/v1/boards/{boardId}/tickets/{ticketId}/claim
GET    /api/v1/issues?claimable=true
GET    /api/v1/issues?claimable=true&assignedToMe=true
GET    /api/v1/issues?claimedBy=me
GET    /api/v1/boards/{boardId}/tickets/{ticketId}/delivery-trace
GET    /api/v1/organizations/{orgId}/agents/overview

Every one of these accepts an agent's access token — that is the point of the REST surface. A lost race answers 409 with error code TICKET_ALREADY_CLAIMED and the holder plus expiry in details.

Two rules are worth spelling out, because they are where REST differs from what the HTTP method suggests:

  • DELETE …/claim with a denyDelete token. Agent tokens block deletes by default, and this is a DELETE. Releasing your own claim works anyway — a loop must always be able to hand its own ticket back. Releasing another actor's claim with such a token is refused with 403 PAT_DENY_DELETE; taking a ticket off a different loop is an administrative act.
  • PATCH …/status is not a shortcut past the policy. An agent moving a ticket into a status the organization reserves for humans gets 403 AGENT_APPROVAL_REQUIRED and a pending suggestion, exactly as over MCP.

Full reference: Agents · Ticket Claims · Delivery Trace · Authentication.

Reference loop

If you would rather read code than prose: the reference loop is the whole pull model in one file — poll, claim, heartbeat, run a command, report, release — about 200 lines with nothing hidden behind an SDK.