Start your launch Sign in
APIs 6 min read

Task batches: the atomic execution claim

One POST hands your runner everything a session needs: at most three executable tasks, already drafted, leased so no other runner gets them, and a summary of the whole plan around them.

Last updated August 11, 2026

The task loop is pull, draft, write back, and it fits a serial script working one task at a time. A batch wraps that loop for runners that want stronger guarantees: a bounded set of work claimed atomically (two concurrent runners can never be handed the same task), drafted before the response returns, replayable after a crash, and delivered next to a summary of the whole plan so the runner always knows what it is not doing yet.

Nothing about the batch flow loosens the engine’s safeguards. A claim only ever selects ready tasks, so scheduled and locked work stays exactly where it is, and every write-back still runs through the same checked path as the single-task status endpoint.

01 · POST /v1/batchesThe claim

POST /v1/batches with an idempotency key and, usually, a scope:

The claim body
{
	"idempotency_key": "runner-7-2026-08-11",
	"scout_id": "5a1b2c3d-9d5a-4f42-8f6e-2a91d0c47b13",
	"limit": 3,
	"draft": true
}
  • idempotency_key (required): any string up to 128 characters, unique per intended run. The same key always returns the same batch, see the replay below.
  • warmup_id or scout_id (optional, not both): scope the claim to one account’s queue, the usual fleet pattern. With neither, the claim draws from your whole account.
  • limit (optional): 1 to 3, default 3.
  • draft (optional, default true): draft the claimed tasks before responding.

The response is the batch plus the plan around it:

The claim response (abridged)
{
	"batch_id": "9c2f5e7a-1b3d-4a6c-8e0f-2d4b6a8c0e2f",
	"idempotency_key": "runner-7-2026-08-11",
	"replayed": false,
	"created_at": "2026-08-11T21:04:11.000Z",
	"warmup_id": null,
	"scout_id": "5a1b2c3d-9d5a-4f42-8f6e-2a91d0c47b13",
	"tasks": [
		{
			"id": "b7e41d80-52c6-4f0a-9c1e-7d3a9e2f5c88",
			"status": "ready",
			"mode": "thread_comment",
			"draft_state": "ready",
			"generated_asset": {
				"kind": "reddit_comment",
				"comment": "Been running exactly this setup..."
			}
		}
	],
	"plan": {
		"total": 41,
		"ready": 9,
		"scheduled": 2,
		"locked": 0,
		"pending": 0,
		"done": 27,
		"skipped": 3,
		"next_available_at": "2026-08-12T03:15:00.000Z",
		"revision": "2026-08-11T21:04:12.000Z",
		"has_more_ready": true,
		"has_more_planned": true
	}
}

tasks holds at most limit full task objects, the same envelope the pull returns (the field reference lives in the task loop), selected in the product’s own order: value_rank ascending, created_at as the tiebreak. They arrive drafted: the couple of seconds the drafts cost happen inside this one call instead of three later ones. If a single draft fails, the claim still succeeds and that task comes back with draft_state: "lazy", so retry it on the draft endpoint rather than re-claiming.

The claim burns the draft rate budget as well as the shared one (see rate limits), one debit per claim.

Visible everywhere, executable here

All tasks are always visible: a pull with no status filter returns the whole plan, scheduled and locked included. What the batch adds is an executable slice with claim semantics. Nothing is ever flattened into ready to make that slice bigger.

02 · The summaryThe plan block

plan summarizes the batch’s whole scope (the warm-up, the scout, or your whole account when unscoped), not just the claimed tasks:

  • Per-status counts (ready, scheduled, locked, pending, done, skipped) and total.
  • next_available_at: the earliest known unlock among scheduled tasks, so a scheduler queues the next slot instead of polling. Null when nothing is scheduled or the unlock is not known yet.
  • revision: an opaque timestamp that changes whenever anything mutates the plan (a scan appends work, a status lands, a task unlocks, a removal cancels comments). Compare it to the last one you saw to know whether a cached plan is stale. The same value arrives in webhook payloads as plan_revision.
  • has_more_ready: ready tasks remain beyond this batch (and beyond other live leases). True means claim again, with a new key, once this batch is worked.
  • has_more_planned: work exists that is not executable yet (scheduled, locked or pending).

The plain pull now carries the same block: GET /v1/tasks responses include plan (without the has_more pair) covering the warmup_id/scout_id scope, whatever status or limit filters the list itself used.

03 · Claim semanticsThe lease

A claimed task is leased for six hours: other claims (different keys) skip it, so two runners working the same account cannot collide. The lease is a reservation, not a mutation. The task row itself never changes on claim, and marking it done or skipped releases it from future claims immediately and permanently, since claims only ever select ready tasks.

If a batch is claimed and never worked, its tasks become claimable again once the lease lapses. That is deliberate: a crashed runner that never comes back should not strand work forever, and a runner that does come back inside the window recovers its batch by key, not by luck.

04 · IdempotencyCrash recovery: the replay

The same (account, idempotency_key) always returns the same batch, with replayed: true and the original task_ids, whatever the other parameters say. So key your claims to the unit of work you want to be crash-safe, a runner plus a date is the common shape, and store the key before you start executing:

  1. Runner starts, claims with its key, works the tasks one by one.
  2. Runner dies after posting task two but before reporting it.
  3. Runner restarts, claims with the same key, gets the same three tasks back.
  4. GET /v1/batches/{id} shows task one done, tasks two and three ready.
  5. The runner knows task two may have posted: it checks its own logs or the thread before acting, reports what it finds, and continues with task three.

GET /v1/batches/{id} is the read-back: the batch’s tasks with their current stored status, result and completed_at, plus a fresh plan block. It is how an ambiguous moment (a timeout on a write, a dead runner) gets resolved with facts instead of guesses.

05 · ResultsThe batch write-back

POST /v1/batches/{id}/results reports several tasks in one call:

The results body
{
	"results": [
		{
			"task_id": "b7e41d80-...",
			"status": "done",
			"result": { "comment_url": "https://www.reddit.com/r/selfhosted/comments/..." }
		},
		{ "task_id": "c8f52e91-...", "status": "skipped" }
	]
}

Every entry must name a task in the batch, and each one succeeds or fails on its own:

The results response
{
	"ok": true,
	"batch_id": "9c2f5e7a-1b3d-4a6c-8e0f-2d4b6a8c0e2f",
	"results": [
		{ "task_id": "b7e41d80-...", "ok": true, "status": "done", "unlock_at": null },
		{
			"task_id": "c8f52e91-...",
			"ok": false,
			"error": {
				"code": "conflict",
				"message": "This comment is still spacing itself from the previous one."
			}
		}
	]
}

Each entry runs through the same checked path as POST /v1/tasks/{id}/status, so warm-up mechanics apply unchanged: a done on a first comment returns unlock_at for its held sibling, a not-yet-due comment refuses with a conflict, and repeating a write you already made is a no-op. Retrying the whole call after a crash is safe. Reporting per task through the single-task endpoint remains equally valid; the batch write exists so one network call can settle one run.

Outcome reports (live or removed, a day later) stay on the single-task outcome endpoint, unchanged.

06 · Putting it togetherThe batch runner loop

A crash-safe daily run
const key = `runner-${SCOUT_ID}-${new Date().toISOString().slice(0, 10)}`;
const batch = await nilkick('POST', '/v1/batches', {
	scout_id: SCOUT_ID,
	idempotency_key: key
});

// On a replay, check what already landed before acting.
const done = new Set();
if (batch.replayed) {
	const readback = await nilkick('GET', `/v1/batches/${batch.batch_id}`);
	for (const t of readback.tasks) if (t.status !== 'ready') done.add(t.id);
}

const results = [];
for (const task of batch.tasks) {
	if (done.has(task.id)) continue;
	// Your side, your infrastructure. Comment work is keyed on thread_url.
	const url = await postWithYourStack(task.target.thread_url, task.generated_asset.comment);
	results.push({ task_id: task.id, status: 'done', result: { comment_url: url } });
}

if (results.length) {
	await nilkick('POST', `/v1/batches/${batch.batch_id}/results`, { results });
}

// More executable work? Next session, next key.
if (batch.plan.has_more_ready) scheduleAnotherRun();
else if (batch.plan.next_available_at) scheduleAt(batch.plan.next_available_at);

FAQ

Common questions

Claim again with the same idempotency_key. You get the original batch back (replayed: true) with the same tasks, and the read-back endpoint shows which writes already landed, so the runner resumes instead of redoing or double-posting.