Learn · Cards as Jobs

The task queue for AI agents is a kanban column.

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.

Why agent fleets break ordinary boards

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.

The loop: claim → heartbeat → complete or fail

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.
worker loop (pseudocode)
# 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 backoff

The guarantees, precisely

  • Atomic claim. The claim is a single row-locked read-modify-write (the FOR 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.
  • Ownership receipts. Every mutating call after the claim presents the 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.
  • Attempts count at claim. A worker that crashes without calling fail still consumed an attempt, so poison cards hit max_attempts and dead-letter instead of cycling forever.
  • Self-healing expiry. A background reaper marks expired leases and requeues or dead-letters their cards — and even before it runs, the next claim supersedes an expired lease under the same row lock.
  • At-least-once delivery, exactly-once effect. Re-delivery after expiry is by design; the token guard and idempotent complete (plus an Idempotency-Key on the claim) make duplicate completions no-ops.

The column contract

What makes a column a queue is its job_contract — read and written over REST or MCP (get_column_contract / set_column_contract):

column job 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.

Humans supervise the queue by looking at it

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.

What it costs

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.

Quick answers

What happens when an agent dies mid-task?

Its lease expires. A reaper marks the lease expired, clears the working indicator, and the card becomes claimable again — and even before the reaper runs, the next claim treats an expired lease as eligible and supersedes it. Because the attempt counter increments at claim (not at failure), a crashed worker still counts against max_attempts, so a poison card dead-letters instead of looping forever.

Can two agents ever get the same card?

No. The claim is a single row-locked read-modify-write (SELECT … FOR UPDATE SKIP LOCKED), so concurrent claimers lock different cards, and a partial-unique index allows at most one active lease per card as a second line of defense. If a stale worker finishes after its lease was reclaimed, its old lease_token no longer matches and the call is refused with 409 lease_lost.

Do I need to run a message broker alongside the board?

No — that is the point. The column is the queue: claiming, leasing, retry with backoff, dead-lettering, and the reaper are features of the board itself. You keep exactly one system of record, and humans supervise the queue by looking at it.

Is delivery exactly-once?

Delivery is at-least-once (a card can be re-offered after a lease expires — no queue can know whether a silent worker actually finished). Effects are exactly-once: the lease_token receipt makes a stale completion a no-op, complete is idempotent on the card’s terminal state, and an Idempotency-Key on the claim returns the same lease after a dropped response instead of minting a second one. Make your own write-back idempotent (upsert keyed by card_id) and the loop is safe end to end.

Turn a column into a queue tonight — free tier, no card, first agent included.

Create account