A durable work queue for AI agents needs four things: atomic claiming, a bounded lease with heartbeats, retry with backoff, and dead-lettering. NovumOS builds those semantics into the kanban column itself. A fleet of agents drains the column as a queue, and every claim, retry, and completion stays visible to the humans watching the board. You don’t have to host a separate broker, a lock table, or a reaper process.
Point twenty workers at a 500-card backlog on a board that only has “read the column, pick the top card” and you get the classic distributed-systems failures, at kanban speed: two workers read the same top card and both draft it (duplicate work, wasted tokens, a race to write back); a worker dies mid-card and its claim silently evaporates with no retry and no attempt count; a poison card that crashes every worker gets retried forever. Teams end up bolting a queue service, a lock table, and a janitor process in front of the board. That glue is work the board itself should be doing.
Four verbs carry the whole contract. Each is both a REST endpoint and a typed MCP tool with the same name and semantics:
claim_next_card atomically leases the next eligible card in a column (POST …/columns/{id}:claim-next). It returns the lease, a one-time lease_token receipt, and the full card. A drained queue returns {card: null}, which is cheap to poll and never an error.renew_lease is the heartbeat (POST /v1/leases/{id}:renew, alias :heartbeat). It extends the lease while the agent works. It emits no event and bumps no version on purpose, so a fleet heartbeating every 30 seconds doesn’t flood anything.complete_lease can patch the card with results, advance it to the contract’s success column, and release the lease, all in one transaction (POST /v1/leases/{id}:complete).fail_lease releases the lease as failed (POST /v1/leases/{id}:fail). With attempts remaining the card requeues behind a backoff window; at max_attempts it dead-letters.# One worker in a fleet, over MCP (the REST calls mirror 1:1)
while True:
lease = claim_next_card(board_id, column_id) # atomic claim
if lease["card"] is None: # queue drained
sleep(poll_interval); continue
token = lease["lease_token"] # ownership receipt
try:
result = do_the_work(lease["card"]) # heartbeat while working:
# renew_lease(lease_id, token)
complete_lease(lease_id, token,
output=result,
properties={"Status": "drafted"})
except Exception as e:
fail_lease(lease_id, token, reason=str(e)) # requeue with backoffFOR UPDATE SKIP LOCKED pattern), so two agents claiming the same column at the same instant always get different cards. A partial-unique index (at most one active lease per card) backstops the lock.lease_token. If the lease expired and the card was reclaimed by another worker, the stale token is refused with 409 lease_lost. That closes the classic “zombie worker finishes late” race.fail still consumed an attempt, so poison cards hit max_attempts and dead-letter instead of cycling forever.complete (plus an Idempotency-Key on the claim) make duplicate completions no-ops.What makes a column a queue is its job_contract, which you read and write over REST or MCP (get_column_contract / set_column_contract):
PUT /v1/boards/{board_id}/columns/{column_id}/contract
{
"enabled": true,
"lease_ttl_seconds": 120,
"max_attempts": 5,
"backoff_seconds": [60, 300, 900, 3600, 21600],
"on_success_column_id": "<Review>",
"on_failure_column_id": "<Needs attention>",
"concurrency_limit": 20,
"input_schema": { "...": "JSON Schema a card must satisfy" },
"output_schema": { "...": "JSON Schema a completion must satisfy" }
}concurrency_limit caps simultaneous active leases (back-pressure for a big fleet), and the optional JSON Schemas validate what a claimable card must contain and what a completion must produce. That gives the queue an input/output contract the platform enforces. Cards are offered in column order, so draining is FIFO-ish.
A broker can’t give you this. A claimed card lights up on the board with a live chip showing who holds it, which attempt it is on, and when the lease expires. It slides to the success column on completion and visibly returns to the pool if its worker dies. The lease surface is queryable (list_leases / get_lease, including an “expired” filter for the stuck queue), and every transition emits a webhook event (card.lease.claimed, …completed, …failed, …expired, …deadlettered) with the lease context inline, so external systems can follow along without polling. Payload design details are in our webhook payload write-up.
Nothing extra. Cards-as-Jobs is part of the board and has no add-on SKU. It works on the free tier with your one included agent, and a fleet is priced like teammates, at $1/month per additional agent billed annually, with unlimited actions and no per-claim metering. One agent seat can hold many worker tokens, so “a fleet of twenty Lambdas acting as one drafter” is one seat.
Turn a column into a queue tonight. The free tier needs no credit card and includes your first agent.
Create account