ai / api
My guidelines for calling a model from an application. The examples use the Claude API as raw HTTP and JSON, with no SDK. The same rules hold for another provider or for a hosted router such as OpenRouter. The wire format and the model names change. The shape of a call stays: structured output, a budget, a timeout, and a retry.
Model selection
- Haiku (
claude-haiku-5-5): short summarization, scoring, classification. - Sonnet (
claude-sonnet-5-5): default for extraction and writing. - Opus (
claude-opus-5-5): research and high-stakes tasks. - I use model aliases rather than dated snapshots.
- I keep one constant per tier, so a model change is a one-line diff.
- I pin server tool types as shared constants. They are dated, so I re-check them when I change models.
A new model can change behavior that no prompt names. Before I move a tier, I run each call site on real inputs and compare the answers. On the move to Haiku 5.5, one summary job spent its whole budget on thinking. One extraction job missed a known answer in four of five runs. See Adaptive thinking for the fix.
Structured output
A call that needs structured data forces one output tool. The answer
arrives in the tool_use block's input.
curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-haiku-5-5",
"max_tokens": 512,
"messages": [
{
"role": "user",
"content": "Write a YC-style company headline for an AI email triage app."
}
],
"tools": [
{
"name": "record_result",
"description": "Record the result in the structured fields.",
"strict": true,
"input_schema": {
"type": "object",
"properties": {
"headline": {
"type": "string",
"description": "A Y Combinator-style company headline, 80 characters or less."
}
},
"required": ["headline"],
"additionalProperties": false
}
}
],
"tool_choice": {
"type": "tool",
"name": "record_result",
"disable_parallel_tool_use": true
}
}'
output_config.format with a json_schema returns the object as text,
which can fail to close a string and lose later fields. A tool_use
input cannot fail that way.
disable_parallel_tool_use keeps the response to one block.
Strict schemas
strict: true forces the arguments to match the schema. It accepts a
subset of JSON Schema:
type, properties, required, additionalProperties, items, enum, const,
description, title, anyOf, $ref, $defs, definitions
Any other keyword (maxLength, pattern, format, minimum,
minItems, default) returns a 400. I put the constraint in the field
description and check schemas in a test.
Adaptive thinking
I enable adaptive thinking for Sonnet and Opus calls that return prose:
"thinking": {"type": "adaptive"}
The API refuses to combine thinking with a forced strict
tool_choice, so a structured call runs without thinking. My client
rejects the combination.
Haiku 5.5 thinks by default on a request that offers a tool. My Haiku prompts were tuned on a model that thought only on request. So my client sends this on every Haiku request unless the caller asks to think:
"thinking": {"type": "disabled"}
Sonnet 5.5 and Opus 5.5 reject disabled, so the client sends it only
to the models in a list.
Research in two passes
Pass 1 runs a web search and returns prose. Code execution filters retrieved data before it enters context.
{
"model": "claude-opus-5-5",
"thinking": { "type": "adaptive" },
"tools": [
{
"type": "web_search_20260209",
"name": "web_search",
"max_uses": 5,
"allowed_callers": ["direct"]
},
{ "type": "code_execution_20260521", "name": "code_execution" }
]
}
Pass 2 shapes that prose into a schema with a separate forced tool call.
When server tools and an output tool share one request, I use
tool_choice: {"type": "auto", "disable_parallel_tool_use": true}. A
forced output tool makes the model call it on turn one, before any
search, with placeholder fields. If the model returns no tool call, I
retry once. The retry replays its text as an assistant turn, adds a
user turn that asks for the tool, and forces the tool. The input ends
on a user turn because the API does not combine a prefill with tool
use.
Direct callers
I set allowed_callers: ["direct"] on web_search and web_fetch. An
unset caller makes the request eligible for programmatic tool calling,
which the API refuses to combine with disable_parallel_tool_use or a
strict: true tool.
Fetch limits
I give web_fetch a max_uses and a max_content_tokens:
{
"type": "web_fetch_20260209",
"name": "web_fetch",
"max_uses": 5,
"max_content_tokens": 20000,
"allowed_callers": ["direct"]
}
Without a content cap, the loop accumulates whole pages and exceeds the context window. I divide the input headroom after the prompt across the allowed fetches.
System prompt
I use the system prompt only to separate instructions from untrusted input (scraped pages, user text). Otherwise I put instructions in the user prompt.
Hallucination guardrails
I apply Anthropic's guidance:
- Restrict answers to provided data.
- Allow explicit uncertainty (
"Insufficient data"). - Require inline citations (
[n]) for research claims. - Pass structured context as JSON.
I handle "Insufficient data" before a database write.
A valid answer can still be a bad one. My client rejects two kinds:
- Stub answers. A field that holds a placeholder such as "placeholder", "TBD", or tool-call markup fails the check, as a blank field does.
- Stale answers. I reuse a stored answer only when the inputs that made it match the current inputs. I keep the context in a stable order, so an unchanged record gives the same inputs and needs no new research.
My client retries a rejected answer once and keeps its research. If the retry fails too, the caller uses its fallback.
Context window budgeting
Maximum prompt size:
(context_window - max_output_tokens - buffer) * 4 chars/token
My defaults: 64k output for Haiku and Sonnet, 128k for Opus, and a 5k buffer. Sonnet supports a 1M window. I keep it at 200k.
Retries
I use two retry policies: one at the HTTP layer and one above it.
The HTTP layer retries on 502, 503, 504, 429, 529, and 500. Anthropic returns intermittent 500 errors under load, and a retry recovers most of them.
The layer above retries a successful response with no usable answer at most once. A response that hit the output budget retries with double the budget. A response with no tool call retries only when the request changes.
I count each time the model skips its output tool, by model and by tool. An accuracy score does not show these declines: the retry recovers the answer, and the extra request costs money and time. A call with a 2xx response counts once, even when its JSON is invalid. A truncated response or a validation error is not a decline.
Timeouts differ by call type. A single generation takes seconds. A server-side search loop takes minutes: a 10-search Opus run took 250 seconds. I keep the client timeout inside the caller's deadline, so the client reports the failure with more detail.