LLM Batch Jobs skill for Claude Code
Every input accounted for, at batch prices. Claude runs bulk LLM work so nothing is lost or double-written. It pilots the prompt first, builds a manifest with deterministic custom IDs, estimates cost before submitting, chunks work into batches, saves raw results, and sorts failures into retryable and needs-a-human. A bundled script reconciles the manifest against every results file and writes the retry list automatically.
- name
llm-batch-jobs - size208 lines · ≈2,150 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Runs LLM work over thousands to millions of items with batch APIs and bounded-concurrency workers - job manifests, deterministic custom IDs, chunking under batch size limits, cost estimates before submit, polling, result download, retry of errored and expired requests, output validation, idempotent writes, and reconciliation so every input ends with exactly one accounted-for result. Use when classifying, extracting, summarizing, translating, or embedding a large dataset, backfilling LLM fields, using the Message Batches API, or when a bulk run lost items, double-wrote results, or cost more than planned. Try asking
- Classify all 400k rows in this table with the batch API and write labels back safely
- Our backfill finished but some items have no result and others were written twice. Reconcile it
- Estimate what it costs to summarize 2 million support tickets and run it in batches
Use it when
- Processing thousands of items with an LLM where results within hours are fine
- Backfilling LLM-generated fields into a database
- A bulk run lost items, double-wrote results, or blew the budget
- You need retries and an audit of what was processed
Skip it when
- You need responses in real time for users — use llm-app-builder
- The main goal is cutting per-request cost of live traffic — use prompt-caching-costs
How to check the result
Run scripts/reconcile.py over the manifest and all result files: unaccounted IDs must be zero, retries capped, and actual token cost close to the pre-submit estimate.
The common mistake it prevents
Matching batch results to inputs by position instead of custom_id, which silently assigns outputs to the wrong rows because results arrive in any order.
A batch job is done when the reconciliation report shows every input ID with exactly one final state (succeeded and written, permanently failed with a reason, or deliberately skipped), the output passed validation, and actual cost matched the estimate within a few percent. “The script finished” is not done; batch results arrive out of order, some requests error or expire, and reruns double-write unless the design prevents it.
Ground rules
- Pilot on 50-200 items synchronously and check quality before submitting 100,000.
- Every request gets a deterministic ID derived from the item and the job version, so reruns and retries are idempotent.
- Results are matched by ID, never by position.
- Writes are upserts keyed by item ID plus job version. A rerun must not create duplicates or overwrite newer results with older ones.
- Keep a manifest of what was submitted and a state store of what came back. Never rely on memory or terminal scrollback to know what is left.
- Estimate cost before submitting and get the user’s go-ahead for large spends.
Batch API or concurrent workers?
| Need | Use |
|---|---|
| Results within minutes, interactive | Synchronous calls with bounded concurrency |
| Thousands+ items, results within hours are fine | Batch API (Anthropic Message Batches: roughly half price, most batches finish within an hour, hard limit 24 h) |
| Item N depends on item N-1’s output | Sequential pipeline; batch per stage |
| Very large job with a deadline | Batch API, chunked; monitor and resubmit stragglers |
Anthropic Message Batches facts to plan around (check the docs for current limits): up to 100,000 requests or 256 MB per batch, results downloadable for 29 days, results in any order, every Messages API feature allowed inside a request (tools, images, PDFs, structured outputs, prompt caching). Some request parameters, such as server-side fallbacks, are not accepted in batches.
Workflow
- [ ] 1. Freeze the prompt, model, and output schema; pilot synchronously
- [ ] 2. Build the manifest with deterministic custom IDs
- [ ] 3. Estimate cost and get approval
- [ ] 4. Submit in chunks; record batch IDs
- [ ] 5. Poll; download results when ended
- [ ] 6. Validate outputs; classify failures
- [ ] 7. Retry what is retryable (new batch, same IDs)
- [ ] 8. Write idempotently; reconcile; report
1. Pilot
Run 50-200 representative items through the exact request you will batch. Grade them
(see llm-evals). Fix the prompt now; changing it after submitting means a new job
version and paying again.
2. Manifest
import hashlib, json
JOB = "ticket-labels-v3" # bump when prompt, model, or schema changes
def custom_id(item_id: str) -> str:
# letters, digits, hyphen, underscore; keep it short (the API caps the length)
return hashlib.sha256(f"{JOB}:{item_id}".encode()).hexdigest()[:40]
with open("manifest.jsonl", "w") as out:
for item in items:
out.write(json.dumps({"custom_id": custom_id(item["id"]), "item_id": item["id"],
"job": JOB}) + "\n")
Store custom_id -> item_id because the API only echoes custom_id.
3. Estimate cost
- Count tokens for 100 sampled requests with the token counting endpoint; extrapolate
input tokens. Estimate output from the pilot’s actual
usage.output_tokens. - Apply the batch discount and expected cache hits (shared system prompt placed first
with
cache_control; cache hits inside batches are best-effort, so budget conservatively). - Show the user: items, estimated input and output tokens, estimated cost, and model. Large spends need an explicit yes.
4. Submit (Anthropic Python SDK)
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic()
SYSTEM = [{"type": "text", "text": INSTRUCTIONS, "cache_control": {"type": "ephemeral"}}]
def to_request(item):
return Request(
custom_id=custom_id(item["id"]),
params=MessageCreateParamsNonStreaming(
model="claude-haiku-5-5",
max_tokens=512,
system=SYSTEM,
messages=[{"role": "user", "content": f"<ticket>{item['text']}</ticket>"}],
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
),
)
def submit(chunk):
batch = client.messages.batches.create(requests=[to_request(i) for i in chunk])
state.record_batch(JOB, batch.id, [custom_id(i["id"]) for i in chunk])
return batch.id
- Chunk at 10,000-50,000 requests per batch rather than the maximum: failures are smaller, results arrive sooner, and you stay well under the size cap with large inputs.
- Record the batch ID and the custom IDs it contains before doing anything else.
- Skip items whose ID already has a succeeded result for this job version.
5. Poll and download
import time
def wait(batch_id, every=60):
while True:
b = client.messages.batches.retrieve(batch_id)
if b.processing_status == "ended":
return b
time.sleep(every)
def download(batch_id, path):
with open(path, "w") as out:
for r in client.messages.batches.results(batch_id):
out.write(r.model_dump_json() + "\n") # keep raw results on disk
Save raw results before processing them, so a parsing bug never forces a re-run.
6. Validate and classify
| Result | Meaning | Action |
|---|---|---|
succeeded, output valid | Done | Write |
succeeded, stop_reason = max_tokens | Truncated | Retry with higher max_tokens |
succeeded, stop_reason = refusal | Model declined | Record reason; human review, do not loop |
succeeded, output fails schema or business rules | Bad output | Retry once; then mark failed |
errored, invalid request | Your request is malformed | Fix the request builder; do not blind-retry |
errored, server/overloaded | Transient | Retry in a new batch |
expired | Not processed within 24 h | Resubmit |
canceled | You canceled | Resubmit if still wanted |
7. Retry
Build a retry manifest from failures (the bundled script does this), submit as a new batch with the same custom IDs, and cap retries per item (2-3). Items that fail the cap go to a dead-letter file with their last error.
8. Write, reconcile, report
python3 scripts/reconcile.py manifest.jsonl results/*.jsonl \
--require-json --retry-out retry.jsonl
The script joins the manifest against all downloaded result files (later files win for
the same custom_id), counts each final state, lists missing IDs and unknown IDs
(results not in the manifest), flags succeeded results whose text is not valid JSON
(--require-json), sums token usage, and writes a retry manifest for errored, expired,
truncated, invalid, and missing items. It exits 1 if anything is unaccounted for.
Write results with an upsert keyed by (item_id, job), storing model, prompt version,
and batch ID alongside the output for lineage.
Concurrency workers (when not using batch)
- Bounded concurrency (semaphore) sized to your rate limits; start low and raise.
- Respect
retry-afteron 429; exponential backoff with jitter on 5xx and overloaded errors; the SDKs retry a few times by default. - Persist progress per item so a crash resumes instead of restarting.
- Same deterministic IDs, validation, and reconciliation as the batch path.
Gotchas
- Changing the prompt mid-job silently mixes two versions in one dataset. Bump the job version and keep both labeled.
- Results files and batch metadata expire; download promptly and keep raw copies.
- Huge inline inputs (PDFs, images) hit the batch size cap fast; upload once with a files API where available and reference by ID.
- A request that is malformed fails in every retry. Separate invalid-request errors from transient ones before retrying.
- Rate limits for batch queues are separate from synchronous limits; very large jobs may queue. Chunk and stagger submissions.
- Writing results as they stream in without the custom-ID map creates orphans you cannot attribute.
Output contract
**Job:** <name/version> | model <id> | prompt <hash>
**Items:** <n> in manifest | batches <ids>
**Final states:** succeeded+written <n>, failed permanent <n>, refused <n>, skipped <n>, unaccounted 0
**Retries:** <n> items retried, <n> dead-lettered (file <path>)
**Tokens:** input <n> (cached <n>), output <n> | **Cost:** est <$> vs actual <$>
**Quality:** pilot grade <x>; post-run sample <y> of <n> checked Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/reconcile.py
#!/usr/bin/env python3
"""Reconcile an LLM batch job: every manifest ID must end with exactly one final state.
Usage:
python3 reconcile.py manifest.jsonl results1.jsonl [results2.jsonl ...]
[--require-json] [--retry-out retry.jsonl] [--show 10]
manifest.jsonl: one line per submitted request, at least {"custom_id": "..."}
(extra fields such as item_id are copied into the retry file).
results*.jsonl: raw batch results, one per line, shaped like the Message Batches API:
{"custom_id": "...", "result": {"type": "succeeded", "message": {"content": [...],
"stop_reason": "end_turn", "usage": {...}}}}
{"custom_id": "...", "result": {"type": "errored", "error": {"type": "...", ...}}}
{"custom_id": "...", "result": {"type": "expired"}} (or "canceled")
Files are applied in order; a later file overrides an earlier one for the same custom_id
(so pass the original run first and retry runs after it).
States reported: ok, truncated (max_tokens), refused, invalid_json (with --require-json),
errored_invalid_request, errored_transient, expired, canceled, missing.
Retry file gets every state except ok, refused, and errored_invalid_request (those need
a human or a request fix, not a blind retry).
Exits 1 if any manifest ID is not ok, 2 on input errors.
"""
import argparse
import json
import sys
from collections import Counter, defaultdict
def read_jsonl(path):
with open(path, encoding="utf-8") as fh:
for n, line in enumerate(fh, 1):
if not line.strip():
continue
try:
yield json.loads(line)
except json.JSONDecodeError as exc:
print(f"error: {path}:{n}: invalid JSON ({exc})", file=sys.stderr)
sys.exit(2)
def text_of(message):
return "".join(b.get("text", "") for b in message.get("content", []) if b.get("type") == "text")
def classify(result, require_json):
rtype = result.get("type")
if rtype == "succeeded":
msg = result.get("message", {})
stop = msg.get("stop_reason")
if stop == "max_tokens":
return "truncated", msg
if stop == "refusal":
return "refused", msg
if require_json:
try:
json.loads(text_of(msg))
except (json.JSONDecodeError, TypeError):
return "invalid_json", msg
return "ok", msg
if rtype == "errored":
etype = (result.get("error") or {}).get("type", "")
inner = (result.get("error") or {}).get("error") or {}
etype = inner.get("type", etype)
if etype in ("invalid_request", "invalid_request_error"):
return "errored_invalid_request", None
return "errored_transient", None
if rtype in ("expired", "canceled"):
return rtype, None
return "errored_transient", None
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("manifest")
ap.add_argument("results", nargs="+")
ap.add_argument("--require-json", action="store_true")
ap.add_argument("--retry-out")
ap.add_argument("--show", type=int, default=10)
args = ap.parse_args()
manifest = {}
for row in read_jsonl(args.manifest):
cid = row.get("custom_id")
if not cid:
print("error: manifest row without custom_id", file=sys.stderr)
sys.exit(2)
if cid in manifest:
print(f"error: duplicate custom_id in manifest: {cid}", file=sys.stderr)
sys.exit(2)
manifest[cid] = row
final = {}
unknown = set()
seen_counts = Counter()
for path in args.results:
for row in read_jsonl(path):
cid = row.get("custom_id")
if cid not in manifest:
unknown.add(cid)
continue
seen_counts[cid] += 1
final[cid] = classify(row.get("result", {}), args.require_json)
states = defaultdict(list)
usage = Counter()
for cid in manifest:
if cid not in final:
states["missing"].append(cid)
continue
state, msg = final[cid]
states[state].append(cid)
if msg:
for k in ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"):
usage[k] += (msg.get("usage") or {}).get(k) or 0
total = len(manifest)
print(f"manifest ids: {total} result files: {len(args.results)}")
order = ["ok", "truncated", "refused", "invalid_json", "errored_invalid_request",
"errored_transient", "expired", "canceled", "missing"]
for s in order:
if states.get(s):
print(f" {s:26} {len(states[s]):7} ({len(states[s]) / total:.1%})")
if unknown:
print(f" {'unknown (not in manifest)':26} {len(unknown):7}")
retried = sum(1 for c in seen_counts.values() if c > 1)
if retried:
print(f" ids seen in more than one file (retried): {retried}")
if any(usage.values()):
print("\ntokens (succeeded results): " + ", ".join(f"{k}={v:,}" for k, v in usage.items()))
for s in order[1:]:
ids = states.get(s, [])
if ids:
print(f"\n{s}: " + ", ".join(ids[: args.show]) + (" ..." if len(ids) > args.show else ""))
retry_states = {"truncated", "invalid_json", "errored_transient", "expired", "canceled", "missing"}
if args.retry_out:
n = 0
with open(args.retry_out, "w", encoding="utf-8") as out:
for s in [x for x in order if x in retry_states]:
for cid in states.get(s, []):
out.write(json.dumps({**manifest[cid], "retry_reason": s}) + "\n")
n += 1
print(f"\nwrote {n} retry rows to {args.retry_out}")
needs_human = len(states.get("refused", [])) + len(states.get("errored_invalid_request", []))
if needs_human:
print(f"{needs_human} ids need a human or a request fix (refused / invalid request); not in retry file")
not_ok = total - len(states.get("ok", []))
if not_ok:
print(f"\nFAIL: {not_ok} of {total} ids not ok")
sys.exit(1)
print("\nPASS: every manifest id has a valid result")
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.