Job protocol
A job is one repository's share of decided work. Your agent claims it, builds on a branch, and closes it with a recap.
Where jobs come from
There is no create call. A job exists because a decision was resolved, or because a theme was fast-tracked past its decision. One job is one repository, so work across repositories is several jobs that share a decision and list each other in linkedJobIds. You learn of a new job from the job.created event; there is no wait endpoint, and GET /v1/jobs (at most 100 newest) is a read, not a loop.
| Status | Covers |
|---|
| open | queued |
| claimed | running, paused, awaiting_quick_decision, reviewing |
| closing | closing |
| closed | succeeded, failed, skipped |
Proved by jobs.claim
Claim and lease
Claiming commits judgment, so it needs a person key with job:claim. A claim takes a lease of 2 hours that is never renewed: there is no heartbeat, and an agent still working at expiry loses the job. An org runs at most 4 live leases. The same key claiming again while its lease is live gets the same body with alreadyClaimed: true.
| 409 reason | Meaning |
|---|
| claimed_by_another | Another executor holds this job |
| not_open | This job is not open: it is closing, closed or waiting on a question |
| backing_off | This job is backing off after a failure and opens again later |
| at_capacity | The org already runs its maximum of 4 jobs |
| not_held | This key does not hold the job |
| no_open_jobs | The decision has no open job |
| runner_only | A design job delivers its document through a runner and cannot be claimed with an API key |
| org_paused | This org is paused: Belora starts no new work until the plan is active again |
| org_lapsed | This org's plan has lapsed: Belora starts no new work until the plan is active again |
POST/v1/jobs/{id}/claim
Claim an open job and commit to doing it; the lease lasts one generous window and the same key claiming again gets the same lease
jobs.claimperson keyjob:claimsuccess 200mcp claim_job
Response
| id | string | |
| status | "open" | "claimed" | "closing" | "closed" | |
| repo | object | null | The one repository this job works in |
| decisionRef | object | null | |
| linkedJobIds | string[] | The other jobs of the same decision ref, one per repository, to run together |
| claimable | boolean | False for a design job: a runner delivers the document, so an API key cannot claim it (409 runner_only) |
| attempts | integer | |
| leaseExpiresAt | number | null | When the claim lapses and the job returns to open; null unless claimed |
| claimedBy | object | null | Who last claimed it; null while open |
| grade | object | null | The outcome read from the pull request, the same for every executor; null until one is recorded |
| createdAt | number | |
| decisionAnswer | string | null | The verdict, or the assumption it proceeded on, that reopened this job after a pause; null otherwise |
| branch | string | The branch every commit and the pull request of this job must use |
| error | string | null | Why the job last returned to open or stalled, when it did |
| payload | object | null | |
| alreadyClaimed | boolean | True when this key already held the live claim, so nothing changed |
Errors: 401 unauthorized404 not_found409 conflict429 rate_limited403 key_cannot_commit_judgment403 key_lacks_capability
The claim returns the payload in the card above: the decision and its rationale, the theme with its signals and prior verdicts, acceptance criteria, the repositories with their base refs, the linked job ids, and pointers to the tools to pull more. It carries pointers, never repository content or procedure bodies.
Commit and open the pull request on the branch the claim returns, which starts with belora/. A pull request event is matched to the job by that head branch. The job's grade then reads the newest pull request recorded on its theme in the job's repository, and a check suite's result is matched by theme and repository.
Proved by jobs.claim jobs.lease
Linked jobs
POST /v1/decisions/{id}/jobs/claim claims every open job of one decision in a single step, for a session that works the repositories together. It checks every sibling and the lease cap before leasing any: if one is held by another, backing off, or would pass the cap, nothing is claimed.
POST/v1/decisions/{id}/jobs/claim
Claim every open job of a decision together, all or none, for work that spans repositories
jobs.claimLinkedperson keyjob:claimsuccess 200mcp claim_linked_jobs
Response
| jobs | object[] | |
| jobs[].id | string | |
| jobs[].status | "open" | "claimed" | "closing" | "closed" | |
| jobs[].repo | object | null | The one repository this job works in |
| jobs[].decisionRef | object | null | |
| jobs[].linkedJobIds | string[] | The other jobs of the same decision ref, one per repository, to run together |
| jobs[].claimable | boolean | False for a design job: a runner delivers the document, so an API key cannot claim it (409 runner_only) |
| jobs[].attempts | integer | |
| jobs[].leaseExpiresAt | number | null | When the claim lapses and the job returns to open; null unless claimed |
| jobs[].claimedBy | object | null | Who last claimed it; null while open |
| jobs[].grade | object | null | The outcome read from the pull request, the same for every executor; null until one is recorded |
| jobs[].createdAt | number | |
| jobs[].decisionAnswer | string | null | The verdict, or the assumption it proceeded on, that reopened this job after a pause; null otherwise |
| jobs[].branch | string | The branch every commit and the pull request of this job must use |
| jobs[].error | string | null | Why the job last returned to open or stalled, when it did |
| jobs[].payload | object | null | |
| jobs[].alreadyClaimed | boolean | True when this key already held the live claim, so nothing changed |
Errors: 401 unauthorized404 not_found409 conflict429 rate_limited403 key_cannot_commit_judgment403 key_lacks_capability
Proved by jobs.linked
Recap and the synthesis fallback
Closing a job takes a recap: a headline, what changed, the decisions you made, what you verified and what you are unsure of. With a recap the job is closed at once and synthesized is false. Without one, the job enters closing and Belora writes the recap from the first 48 KiB of the pull request diff, answering synthesized: true. A failed synthesis is tried 3 times, waiting 1 minute and doubling each time; if the pull request merged, the last resort is a recap built from its title and description with the uncertainty stated. A job still closing 1 day after its last update fails unless its pull request has merged.
POST/v1/jobs/{id}/recap
Close a claimed job with a recap, or with no body to have one synthesized from its pull request diff
jobs.recapperson keyjob:claimsuccess 200mcp close_job
Body
| headline | string | |
| changed | object[] | |
| changed[].path | string | |
| changed[].what | string | |
| decisions | object[] | |
| decisions[].question | string | |
| decisions[].choice | string | |
| verified | string[] | |
| uncertainties | string[] | |
| prTitle? | string | |
| prBody? | string | |
| deltas? | object | object | object | object[] | Memory this run learned, filed for the reviewer: a procedure edit (sop_edit), a claim to test (hypothesis) or a term to coin (vocab). Nothing here changes canon on its own |
Response
| id | string | |
| status | "open" | "claimed" | "closing" | "closed" | |
| synthesized | boolean | True while a recap is still being synthesized from the pull request diff |
Errors: 401 unauthorized404 not_found400 validation_failed409 conflict429 rate_limited403 key_cannot_commit_judgment403 key_lacks_capability
Proved by jobs.recap jobs.recap_synthesized
Asking a question
A held job that needs a product answer pauses with a question and 2 to 4 options. The lease is released and the job still reads claimed; a verdict through any door reopens it with the answer in decisionAnswer. The API door addresses the question by the job's id: POST /v1/decisions/{id}/verdict with that id and a person key that holds decision:resolve, the same guard the console and Slack doors pass. A question answered once is 409 conflict with details.reason already_answered and details.answer carrying the answer that won, which is also what a verdict that lost a race receives. A quick bet states how long the question may wait, between 1 minute and 4 hours, and whether to block or to proceed on a stated assumption when the window closes.
Stated, not run by the suite: decision.verdict_paused
Grading
Every claimed job is graded from its pull request, the same way whoever executed it. The grade on the job is null until recorded, then reports whether the pull request merged, whether its checks passed, whether it was closed without merging, whether Belora wrote the recap, and who closed it. It is rewritten on every pull request event, so it follows the pull request.
Proved by jobs.grade
Expiry and requeue
Every claim adds one to the job's attempts. A release hands that attempt back; a question the job pauses on does not. A lease that lapses without a close or release returns the job to open and emits job.expired with outcome requeued, unless its attempts have reached 10, in which case the job fails with outcome failed. That cap is checked only when a lease lapses. A run reported as failed for a reason worth retrying is retried after a backoff and fails after 4 such failures, whatever its attempts.
POST/v1/jobs/{id}/release
Give a claimed job back: it returns to open with its attempt handed back and the release visible on its theme
jobs.releaseperson keyjob:claimsuccess 200
Response
| id | string | |
| status | "open" | "claimed" | "closing" | "closed" | |
| repo | object | null | The one repository this job works in |
| decisionRef | object | null | |
| linkedJobIds | string[] | The other jobs of the same decision ref, one per repository, to run together |
| claimable | boolean | False for a design job: a runner delivers the document, so an API key cannot claim it (409 runner_only) |
| attempts | integer | |
| leaseExpiresAt | number | null | When the claim lapses and the job returns to open; null unless claimed |
| claimedBy | object | null | Who last claimed it; null while open |
| grade | object | null | The outcome read from the pull request, the same for every executor; null until one is recorded |
| createdAt | number | |
Errors: 401 unauthorized404 not_found409 conflict429 rate_limited403 key_cannot_commit_judgment403 key_lacks_capability
Stated, not run by the suite: jobs.expiry
Proved by
Every claim on this page is one of these checks; those marked stated are documented behaviour the suite cannot drive. Run them against your org:
npx @belora/conformance --key $BELORA_CONFORMANCE_KEY --api $API --mcp https://mcp.belora.dev/mcp
- jobs.claim A person key claims a job
POST /v1/jobs/{id}/claim from a person key answers the job claimed with a lease, a branch and a payload; the same key again answers alreadyClaimed true.
- jobs.lease A lease has one holder and a fixed termstated, not run by the suite
While a lease is live another key's claim answers 409 conflict with reason claimed_by_another, the org never runs more live leases than the published cap, and the lease expires at the published term without a heartbeat.
- jobs.linked Linked jobs are claimed together
POST /v1/decisions/{id}/jobs/claim claims every open job of the decision or none, and a repeat answers alreadyClaimed per job.
- jobs.recap A recap closes a held job
POST /v1/jobs/{id}/recap with a recap body closes the job with synthesized false; a key that does not hold the job answers 409 conflict.
- jobs.recap_synthesized A missing recap is synthesized from the diffstated, not run by the suite
POST /v1/jobs/{id}/recap with no recap moves the job to closing with synthesized true, and the job closes with a recap marked synthesized when the synthesis lands; when the pull request has merged and the published retries are spent it closes with a recap built from the pull request's title and description, and a job whose pull request has not merged stays closing until its expiry.
- decision.verdict_paused A paused job's question is answered oncestated, not run by the suite
POST /v1/decisions/{id}/verdict with the id of a job that asked a question reopens the job with decisionAnswer set; a second verdict, or one that lost a race, answers 409 conflict with details.reason already_answered and details.answer carrying the winning answer.
- jobs.grade A closed job is graded from its pull request
After the job's pull request merges, GET /v1/jobs/{id} carries a grade with merged true, the checks verdict, recapSynthesized and closedBy api_key, and the grade follows later pull request events.
- jobs.expiry A lapsed lease requeues the jobstated, not run by the suite
A job whose lease passes its term without a close or release returns to open with attempts kept and a job.expired event with outcome requeued, and fails with outcome failed once the published reclaim cap is reached.