A durable work queue for AI agents needs four things: atomic claiming, a bounded lease with heartbeats, retry with backoff, and dead-lettering. Novum OS builds those semantics into the kanban column itself — a fleet of agents drains the column as a queue, while every claim, retry, and completion stays visible to the humans watching the board. No separate broker, no lock table, no reaper to host.
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 — undifferentiated glue that the board should own.
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 lease the next eligible card in a column (POST …/columns/{id}:claim-next). Returns the lease, a one-time lease_token receipt, and the full card. A drained queue returns {card: null} — cheap to poll, never an error.renew_lease — the heartbeat (POST /v1/leases/{id}:renew, alias :heartbeat). Extends the lease while the agent works. Deliberately silent: no event, no version bump, so a fleet heartbeating every 30 seconds doesn’t flood anything.complete_lease — optionally patch the card with results, advance it to the contract’s success column, and release the lease, in one transaction (POST /v1/leases/{id}:complete).fail_lease — release 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 get different cards — never the same one. 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 — the classic “zombie worker finishes late” race, closed.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 — read and written 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 — the queue’s input/output contract, enforced by the platform. Cards are offered in column order, so draining is FIFO-ish.
This is what a broker can’t give you. A claimed card lights up on the board with a live chip — who holds it, which attempt, when the lease expires — 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 a feature of the board, not an add-on SKU: it works on the free tier with your one included agent, and a fleet is priced as teammates — $8/month per additional agent billed annually, unlimited actions, 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 — free tier, no card, first agent included.
Create account