An agent handed a task title and a write token will do something. Whether it does the right thing, once, and without colliding with the person who was already halfway through the same node, is decided before it starts — by the scopes on its key and by the claim it takes. TopDo does not run your agent. It holds the work, issues the key and arbitrates who holds what right now. Execution, prompts, models and their cost stay in your environment.

TopDo coordinates; your runtime executes
Read this before anything else in this piece. TopDo does not start a process, call a model, run a tool or hold a sandbox. Built-in execution is not enabled: EXECUTORS_ENABLED defaults to false, and with the default configuration the dispatch and jobs surfaces are absent rather than idle. Treat execution as entirely yours. The work your agent produces is your agent’s work, and TopDo records it without judging it.
What TopDo gives you is the other half: a place for an agent to read work, take an exclusive hold on one node, leave an update and close it against a known version. That surface is REST under /api/v1 and MCP at /mcp, both authenticated by the same agent key. Over MCP a non-agent principal is refused with 403 agent_required, so the coordination path has exactly one kind of caller.
The practical consequence is that the interesting failures in this article are coordination failures, not model failures. A stale claim, a scope that does not nest, a version that moved underneath you — these are the things that make two workers produce the same output twice, and they are the things a claim is designed to make loud.
Issue the smallest key, and do not assume scopes nest
An agent key is created through POST /api/v1/agents by a caller who holds role admin and scope admin — both, not either. scopes is an array drawn from read, write, dispatch and admin, with at least one entry and a default of ["read","write"]. Asking for admin additionally requires allow_admin_scope: true, otherwise the request fails 422 invalid, “Admin scope requires allow_admin_scope”. The plaintext secret is returned once.
Now the part that catches people. There is no hierarchy between scopes. Holding admin does not imply write, and write does not imply read. Authorization is a literal membership test on the exact scope string the operation declares, followed by a role check where the operation declares one. A key with admin alone can create a node type and cannot read a node.
Effective scopes are the intersection of the agent_principals.scopes array and the scopes recorded on the key itself. Attenuation follows from that: a key cannot mint a child that exceeds it, and trying returns 403 scope_required, “A child key cannot exceed its creator’s scopes”. A refusal over REST is 403 scope_required, “This key requires <scope> scope”; the same refusal over MCP arrives as JSON-RPC -32000 carrying missing_scope in its data.
readcoversget_node,list_nodes,list_queue,searchandinbox.writecovers every claim verb,comment,update_nodeandacknowledge_changes.admincovers only the type and link-type registry, and also demands roleadmin.- A read-only agent that reports on the queue needs
readand nothing else.
Delegation ends the moment the person does
A key may carry on_behalf_of, naming a workspace member the agent represents. The effective role then becomes the minimum of three things along the ladder viewer < user < admin < owner: the membership role behind the key, the role recorded on the key metadata, and the role of the represented member. An agent cannot be more powerful than the person it acts for.
When that member leaves the workspace, the membership row disappears and every request from the key fails 403 delegation_revoked, “The represented member has left this workspace”. This is not a scope change and not a gradual degradation. The key still holds its scopes and stops working, which is the behavior you want on the day someone is offboarded.
Revocation is separate and blunt. DELETE /api/v1/agents/:id stamps revoked_at, disables every matching key row and reassigns the workspace default assignee to the owner if it was that agent. Subsequent calls fail 401 unauthorized, “Agent key is revoked”. Rotation through the key endpoint mints a replacement and disables the agent’s prior enabled keys, which is the move for a leaked secret you do not want to re-plumb.
Claim the node before you touch it
claim_work takes a node id and refuses in three distinct ways. If the node is not open, or is a template, it fails 409 not_ready, “Only open work can be claimed”. If an unexpired claim already exists it fails 409 work_claimed, “Someone already holds this work”. If any live node with a non-null state other than closed points at it through a blocks edge, it fails 409 not_ready, “Complete the blocking work first”.
Read those three apart, because they call for different responses. work_claimed means wait or pick another node. The blocking refusal means the dependency graph says no, and no amount of retrying changes that. Only the first one is a state problem you might fix by reading the node again.
On success the server generates 32 random bytes, returns them base64url as claim_token, and stores only sha256(token) in nodes.claim_hash. That column is stripped from every API response, so the token is returned once and is never re-derivable. Lose it and your only routes are the five-minute lease running out or a human intervening. Claiming also strips metadata.suggested from the node.
POST /api/v1/nodes/:node_id/claim {}
-> { node: { ... }, claim_token: "..." } // returned once, never again
POST /api/v1/nodes/:node_id/heartbeat { claim_token }
POST /api/v1/nodes/:node_id/release { claim_token }
POST /api/v1/nodes/:node_id/complete { claim_token, expected_version }
// Over MCP the same four tools additionally require idempotency_key.The lease is a fixed five minutes, and nothing runs when it lapses
A claim lasts exactly five minutes. heartbeat_work does not extend it by five minutes; it sets claim_expires_at to five minutes from now, a fixed re-lease. There is no cap on heartbeats and no maximum total hold, so a two-hour job is a two-hour sequence of heartbeats. Beat well inside the window — every ninety seconds, not every four and a half minutes.
Every heartbeat, release and completion recomputes sha256(claim_token) and requires all of: an expiry that exists, an expiry in the future, claimed_by equal to the caller, equal-length hashes and a constant-time match. Any one failure returns the same 409 claim_expired, “The claim expired or belongs to another principal”. One code covers a wrong token, a lapsed lease and someone else’s work, deliberately.
Now the part to design around: nothing runs on lapse. There is no reaper, no sweeper, no notification. The stale row keeps its claimed_by and claim_hash set, and the node simply becomes claimable again, because the next claim_work overwrites an expired claim in place. The previous holder discovers this only when it next uses its token and gets claim_expired.
A lapsed lease does not fail. It stops protecting you, and the next claim takes the work.
complete_work can fail seven ways; handle them in order
Completion needs two things the other verbs do not both need: the claim token and expected_version. On update_node a version is optional and omitting it means last write wins. On complete_work it is mandatory, because closing a node against a document you have not read is the failure this whole mechanism exists to prevent.
- 422
invalid—expected_versionis absent or not a positive integer; the schema rejects the body before the node is read. - 404
not_found— the node is missing or soft-deleted. - 409
claim_expired— the token is wrong, lapsed, or belongs to another principal. - 409
not_ready— the node is no longeropen, or is a template. - 409
version_conflict— detail carriesexpected_versionandcurrent_version. - 409
changes_pending—pending_changesis non-empty; review before closing. - 422
close_gated— the type’sgated_closerule, withviaandblockers.
The order matters when you are writing the error branch. A version_conflict is answered by reading the node and completing against current_version. A changes_pending is answered by calling acknowledge_changes with a map that exactly equals the stored pending_changes — a partial map, an extra key or a stale number all fail. A close_gated is answered by closing the verifying work first, and the blockers detail names it.
One trap is worth stating plainly: the version column increments on every update of the row, so your own heartbeat invalidates a version you read before it. Read the node again immediately before calling complete_work, and take expected_version from that read. On success the state becomes closed and all three claim columns are nulled.
Rate limits are per key, per minute, and the counter does not pause
Each agent key carries a rate_tier. The three tiers map to fixed budgets in a sixty-second wall-clock window: strict is 5 requests per minute, default is 600, high is 6000. Non-agent principals sit at 100000, which is effectively unmetered, so the limit is a property of the agent rather than of the workspace.
A breach returns 429 rate_limited, “Rate limit exceeded”, with detail.limit and a Retry-After header in seconds. The counter increments before the limit is tested, so it keeps climbing while your requests are being rejected. An agent that retries immediately on 429 holds itself out of the window it is waiting for. Honor Retry-After and sleep.
Choose the tier from the shape of the loop, not from optimism. A heartbeat every ninety seconds plus a read and a write per node is comfortable at default. The strict tier at five per minute is for a key you are still reviewing, and it will not sustain a heartbeat alongside anything else.
What to check before you hand over the key
These five checks take an afternoon and they separate an agent you can leave running from one you have to watch. Each is a call you can make against your own workspace with a spare key.
- Issue a key with
readalone and confirm a write is refused 403scope_required. - Claim a node twice from two keys; the second must return 409
work_claimed. - Stop heartbeating, wait six minutes, reclaim, then use the old token:
claim_expired. - Complete with a stale version and read
current_versionout of the conflict detail. - Drive a
strictkey past five calls in a minute and check you sleep forRetry-After.
If any of those five behaves differently in your integration than it does here, the difference is in your client, and it is cheaper to find now than during an incident. The third check is the one most often skipped and the one that most often explains duplicated work.
Bound the work, not the agent
The instinct when an agent misbehaves is to constrain the agent — a longer prompt, a stricter system message, a review step. The mechanisms in this article constrain something else: what the key can reach, what the claim reserves and what the completion asserts. Those hold whatever the agent is, whoever wrote it and whichever model it runs on, because they are enforced in the database rather than requested in a prompt.
That division is the honest one. TopDo cannot tell you whether your agent did good work; the node, the comments and the closing version are the record, and a person reads them. What TopDo can tell you is that exactly one worker held this node between these two instants, that it closed against the version it had read, and which key it was. That is a smaller claim than most of what is sold around agents, and it is the part you can check.