By Sagar Shankaran, Founder of CallSphere
Code-level patterns for Claude batch processing: request factories, cache-friendly context layering, structured outputs, and self-describing custom_ids.
Key takeaways
Your first Claude batch job is a script. Your tenth is a system. Somewhere between those two, the ad-hoc create() call you copy-pasted starts to creak: the prompt assembly is duplicated across files, the cache never hits, and reassembling results has become a fragile web of string parsing. This post is a set of reusable patterns — request factories, context layering, structured output, and result joins — that turn batch processing from a one-off script into a component you can trust at scale.
output_config.format) so every result is machine-parseable JSON, eliminating brittle text scraping during reassembly.custom_id itself (a delimited key) so the result join needs no side table.custom_ids and rebuilds exactly those requests from your source data.The enemy of a maintainable batch job is duplication in request construction. Every request shares 90% of its body — same model, same system prompt, same tool list — and differs only in the user content. Centralize the constant part in a factory. This single move makes the shared prefix byte-identical across requests, which is also the precondition for caching to work.
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
SHARED_SYSTEM = [
{"type": "text", "text": "You are a precise data extraction engine."},
{"type": "text", "text": EXTRACTION_GUIDE,
"cache_control": {"type": "ephemeral"}}, # frozen, cacheable
]
def make_request(key: str, document: str) -> Request:
return Request(
custom_id=key,
params=MessageCreateParamsNonStreaming(
model="claude-opus-4-8",
max_tokens=2048,
system=SHARED_SYSTEM, # identical bytes every call
messages=[{"role": "user", "content": document}],
),
)
requests = [make_request(k, doc) for k, doc in corpus]Because SHARED_SYSTEM is constructed once and reused, every request renders the same prefix. Reorder the keys in a dict or interpolate a timestamp into that block and you would silently shatter the cache — keep the frozen prefix truly frozen.
Caching is a prefix match: any byte change invalidates everything after it. The design rule that follows is mechanical. Put everything stable — persona, reference documents, few-shot examples — at the front, marked with cache_control. Put the one thing that changes per request — the actual question or document — after the breakpoint, unmarked.
flowchart TD
A["Per-request body"] --> B["Frozen prefix:\npersona + guide + examples"]
B --> C["cache_control breakpoint"]
C --> D["Volatile suffix:\nthis item's content"]
B --> E{"Prefix byte-identical\nacross requests?"}
E -->|Yes| F["cache_read on later items\n~0.1x input price"]
E -->|No| G["cache miss: full price\nevery request"]
D --> H["Claude processes\nsuffix + cached prefix"]
F --> HThe practical test is to inspect usage.cache_read_input_tokens on a few results. If it is consistently zero across requests that should share a prefix, a silent invalidator has crept in — most often a non-deterministic JSON serialization or a per-item value that leaked into the frozen block.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
One timing nuance is worth internalizing so you do not misread the metrics. A cache entry becomes readable only after the request that writes it begins processing, and in a batch the requests sharing a prefix do not all start at once. So the very first items to run will show a cache write rather than a read, and the read rate climbs as the batch drains. If you sample only the earliest results, you may conclude caching is broken when it is simply warming up. Sample across the run, and look at the aggregate write-versus-read ratio rather than any single request.
Text scraping is the second-most-common source of batch reassembly bugs after positional joins. If you ask for "the category and confidence" in prose, you will spend the afternoon writing regexes for the model's three favorite phrasings. Constrain the output to a schema instead and parse JSON deterministically.
SCHEMA = {
"type": "object",
"properties": {
"category": {"type": "string",
"enum": ["billing", "bug", "feature", "other"]},
"confidence": {"type": "string",
"enum": ["low", "medium", "high"]},
},
"required": ["category", "confidence"],
"additionalProperties": False,
}
def make_request(key: str, text: str) -> Request:
return Request(
custom_id=key,
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5",
max_tokens=128,
output_config={"format": {"type": "json_schema", "schema": SCHEMA}},
messages=[{"role": "user", "content": text}],
),
)On the result side, the first text block is now guaranteed to be valid JSON matching your schema, so reassembly is json.loads(text) with no defensive parsing. Structured outputs compose cleanly with batches — the constraint applies per request exactly as it would synchronously.
You can push reassembly metadata directly into the custom_id and avoid a side table entirely. A delimited composite key — entity type, primary key, and a version or shard tag — survives the round trip and tells you everything you need to route the result.
def encode_id(entity: str, pk: int, shard: str) -> str:
return f"{entity}|{pk}|{shard}"
def decode_id(cid: str) -> tuple[str, int, str]:
entity, pk, shard = cid.split("|")
return entity, int(pk), shard
# On the way out:
for r in client.messages.batches.results(batch.id):
if r.result.type == "succeeded":
entity, pk, shard = decode_id(r.custom_id)
route_result(entity, pk, shard, r.result.message)Keep the delimiter out of your raw data values, and keep the whole string within length limits, but otherwise this pattern eliminates an entire class of "which row was this again?" bugs. A good rule of thumb is to pick a delimiter that cannot appear in any field you encode — a pipe or a double colon works well for numeric keys — and to validate on decode so a malformed id fails loudly rather than silently routing a result to the wrong place. When the keys themselves might contain arbitrary text, reach for a structured encoding like a short base64 blob instead of raw concatenation, so the round trip is lossless regardless of content.
Errored and expired requests are normal at scale, not exceptional. Build the retry path as a first-class function from day one. It takes the list of custom_ids that need rework, rebuilds exactly those requests from your source data using the same factory, and submits a fresh, smaller batch.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
def resubmit(failed_ids: list[str], source: dict) -> str:
retry_requests = [
make_request(cid, source[cid]) for cid in failed_ids
]
new_batch = client.messages.batches.create(requests=retry_requests)
return new_batch.idBecause the factory is the single source of truth for request shape, retries are guaranteed to match the original job's configuration. No drift, no special-casing.
SHARED_SYSTEM inside the loop risks subtle byte differences and kills caching. Build it once, outside the comprehension.output_config.format whenever the downstream consumer is code.make_request() factory.cache_control-marked prefix and an unmarked volatile suffix.output_config.format schema wherever code consumes the result.custom_id scheme that encodes your join keys.resubmit() helper that rebuilds failed requests from the same factory.| Pattern | Buys you | Costs you |
|---|---|---|
| Request factory | One source of truth, cache-friendly prefix | A little upfront structure |
| Frozen/volatile layering | Cache reads at ~0.1x input price | Discipline about what is frozen |
| Structured outputs | Deterministic, parse-free reassembly | Schema maintenance |
| Self-describing custom_id | No side table for the join | Length and delimiter care |
Yes, when many requests share a large identical prefix. The savings accrue as the batch drains rather than all at once, because cache entries become readable only after the first writing request begins, but you still pay roughly a tenth of the input price for the cached prefix on later requests — stacked on top of the 50% batch discount.
Yes. Each request carries its own model, so a factory can branch — Haiku for simple classification, Opus for the reasoning-heavy items — within a single submitted batch.
When you only need a typed JSON object back and nothing is executed, output_config.format is the lighter path: it constrains the response shape directly without the overhead of a tool-use round trip.
Put the minimal join keys you need to route the result — entity type and primary key — in the custom_id, and keep bulky context in your own store. The id is a routing label, not a payload.
These structuring patterns — factories, layered context, schema-bound outputs — are exactly what makes a Claude agent reliable in production. CallSphere applies them to voice and chat: agents that answer every call, use tools mid-conversation, and book work around the clock. See it live at callsphere.ai.
Source & attribution: This is an independent, original explainer inspired by Anthropic's coverage on the Claude blog. Claude, Claude Code, Claude Cowork, Claude Opus, and the Model Context Protocol are products and trademarks of Anthropic. CallSphere is not affiliated with or endorsed by Anthropic.

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
Anthropic's Claude Fable 5 and Mythos 5 explained: pricing, availability, frontier benchmarks, the dual-model safeguard architecture, and what they mean for AI agents.
Where Claude Code, MCP, and multi-agent systems are taking GTM engineering next, and how to prepare your team now for standing and multi-agent workflows.
Where Claude Cowork and the Claude agent ecosystem are heading next — standing agents, MCP, skills as a moat — and the concrete moves to prepare your team now.
The metrics, leading signals, and anti-metrics that prove Claude Cowork is working — acceptance rate, time-to-outcome, and why usage counts mislead.
Shipping an agentic GTM workflow is easy; proving it works is hard. The metrics, signals, and eval loops that show a Claude Code rebuild is paying off.
A realistic end-to-end Claude Cowork use case: a quarterly vendor-spend review from vague ask to shipped deliverable, with every agentic step shown.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI