AI Engineering No. 246Editor's pick

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.

  • namellm-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

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.

~/.claude/skills/llm-batch-jobs/SKILL.md

--- name: llm-batch-jobs description: Runs LLM work over thousands to millions of items with batch APIs and bounded-concurrency workers - job manifests, deterministic custom… license: MIT ---

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?

NeedUse
Results within minutes, interactiveSynchronous calls with bounded concurrency
Thousands+ items, results within hours are fineBatch 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 outputSequential pipeline; batch per stage
Very large job with a deadlineBatch 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

ResultMeaningAction
succeeded, output validDoneWrite
succeeded, stop_reason = max_tokensTruncatedRetry with higher max_tokens
succeeded, stop_reason = refusalModel declinedRecord reason; human review, do not loop
succeeded, output fails schema or business rulesBad outputRetry once; then mark failed
errored, invalid requestYour request is malformedFix the request builder; do not blind-retry
errored, server/overloadedTransientRetry in a new batch
expiredNot processed within 24 hResubmit
canceledYou canceledResubmit 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-after on 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 159 lines · 6.2 KB
#!/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.

esc