Calling LLMs from Code: The API Basics
Make your first API call, understand the messages array and system prompt, handle streaming responses, and know the three failure modes every programmer encounters on day one.
- Estimated time
- ~14 min
- Difficulty
- intermediate
- Sources
- 4 sources
In the previous lesson — Evaluating and Iterating on LLM Outputs — you built a diagnostic framework for bad model outputs: distinguishing prompt failures (fixable by rewriting the prompt) from model limitations (requiring architecture changes like retrieval or tool use). That framework assumed you were prompting through a UI. Now you’ll wire that same reasoning directly into code.
You can craft a perfect prompt in a chat window and the model responds brilliantly — but the moment you try to call that same model from code, you hit a wall of jargon: messages array, role, streaming, tokens, 429. The API is simpler than it looks once you see the shape of a single request.
Your First API Call
Start with the smallest possible working example — nothing beyond what the spec requires.
Minimal working call (TypeScript / Node 18+)
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const response = await client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: 256,
messages: [
{ role: "user", content: "What is 2 + 2?" }
],
});
console.log(response.content[0].text);
// → "4" Same call in Python
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from env
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=256,
messages=[
{"role": "user", "content": "What is 2 + 2?"}
],
)
print(response.content[0].text)
# → "4" Three required fields: model, max_tokens, and messages. Everything else is optional.
The response object carries more than just the text:
{
id: "msg_01XFDUDYJgAACzvnptvVoYEL",
type: "message",
role: "assistant",
content: [{ type: "text", text: "4" }],
model: "claude-3-5-sonnet-20241022",
stop_reason: "end_turn", // or "max_tokens" if you hit the ceiling
usage: {
input_tokens: 14,
output_tokens: 2
}
} Check stop_reason before using the output
If stop_reason === "max_tokens", the response was cut mid-sentence. For structured output like JSON, truncation means the output is malformed. Always check before assuming the text is complete.
An HTTP POST to the provider’s /messages (or /chat/completions) endpoint carrying a JSON body that specifies the model, a token budget, and an ordered array of role-tagged message objects. The server returns the model’s reply under content[0].text.
Check your understanding
You call the API with max_tokens: 10 and ask for a 200-word summary. What will stop_reason be?
Anatomy of the Messages Array
The messages field is the entire conversation history — every user turn and every assistant turn, in order. The model has no memory beyond what you send in this array on every call.
sequenceDiagram participant App as Your Code participant API as LLM API App->>API: Call 1: [user] API-->>App: assistant reply A1 App->>API: Call 2: [user, assistant A1, user] API-->>App: assistant reply A2 Note over App: Your code owns the history
Each element has exactly two fields:
role— one of"system","user", or"assistant".content— the string (or array of content blocks) for that turn.
The system prompt is the behavioural anchor. It tells the model who it is and what rules govern it — the role framing, output format constraints, and grounding rules you refined in the prompting lesson. Pass it as a top-level system string (Anthropic convention) or as the first messages element with role: "system" (OpenAI convention). Either way, the model treats it the same.
Analogy — system prompt is like job description
The system prompt is the standing brief you hand to a contractor before any work begins. The user turns are the individual tasks. The assistant turns are the contractor’s completed work. All three accumulate in the messages array — the contractor can see everything agreed and done so far.
Because the messages array is stateless — you reconstruct the full conversation on every call — you own the conversation history. That means you can trim it, summarise it, inject retrieved documents mid-thread, or fork it for A/B testing. The API has no notion of a “session.”
Try adding, removing, and reordering turns in the widget below. Notice how the JSON panel updates instantly — that exact JSON travels over the wire on every call.
Common misconception
The API remembers previous conversations — I just need to send the new message each time.
What's actually true
Every call is stateless. The API has no memory of prior calls. If you want multi-turn behaviour, you must send the entire conversation history in the messages array on every request. Your application code is the session store, not the server.
Check your understanding
A user asks a follow-up question ('Now double it.'). What does your messages array look like for the second call?
Streaming Responses
By default, the API buffers the entire response and returns it when the last token is sampled. For a 200-token reply at ~40 ms/token, that’s an 8-second blank screen. Streaming fixes this by sending each token to your client the moment it’s sampled.
When to stream
Stream whenever a human is watching. Use complete mode when a downstream process needs the full response before it can act — for example, parsing structured JSON output or chaining into another call.
The widget below simulates both modes. Drag the slider to a longer response and press Run comparison — the latency gap becomes viscerally obvious.
In code, streaming requires one structural change: iterate over the event stream instead of awaiting a single response object.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const stream = await client.messages.stream({
model: "claude-3-5-sonnet-20241022",
max_tokens: 512,
messages: [{ role: "user", content: "Explain photosynthesis in 3 sentences." }],
});
for await (const chunk of stream) {
if (
chunk.type === "content_block_delta" &&
chunk.delta.type === "text_delta"
) {
process.stdout.write(chunk.delta.text); // arrives token-by-token
}
}
const final = await stream.getFinalMessage();
console.log("\nTokens used:", final.usage.input_tokens + final.usage.output_tokens); import anthropic
client = anthropic.Anthropic()
with client.messages.stream(
model="claude-3-5-sonnet-20241022",
max_tokens=512,
messages=[{"role": "user", "content": "Explain photosynthesis in 3 sentences."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
final = stream.get_final_message()
print(f"\nTokens: {final.usage.input_tokens + final.usage.output_tokens}") Show the raw SSE event format the SDK parses for you
The server sends Server-Sent Events (SSE). Each event is a line starting with data: followed by a JSON payload:
[Anthropic Streaming Documentation]
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Photo"}}
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"synth"}}
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"esis"}}
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":47}}
data: [DONE]The SDK handles this parsing. If you use a language without an official SDK or hit the raw HTTP endpoint directly, parse this stream with any SSE library.
Check your understanding
You're building a background job that summarises a document and stores the result in a database. Should you use streaming?
The Three Day-One Failure Modes
Nearly every programmer hits these three errors within their first hour of API work. Understanding them before you encounter them means you can write defensive code from the start.
Click each tab in the widget below to see the raw error signal and the correct defensive handler.
| HTTP status | Root cause | Defensive pattern | |
|---|---|---|---|
| 429 Rate Limit | 429 Too Many Requests | Requests-per-minute or tokens-per-minute exceeded | Exponential backoff + jitter on Retry-After header |
| Context Overflow | 400 Bad Request | messages array + max_tokens exceeds the model's context window | Sliding-window trim: drop oldest turns, always keep system prompt |
| Malformed JSON Output | 200 OK (model succeeded; you failed to parse) | Model prefixed prose, added markdown fences, or used trailing commas | Extract JSON substring; prefer structured outputs / JSON mode |
Structured outputs eliminate the malformed-JSON problem
Both Anthropic (via tool use with tool_choice: {type: 'tool'}) and OpenAI (via response_format: {type: 'json_schema'}) let you constrain the model’s output to a schema at sampling time. The model cannot generate text that fails to match your schema — making this a permanent fix rather than a parse-and-retry loop.
[Anthropic Messages API Reference]
Show a complete error-handling wrapper (TypeScript)
This wrapper composes all three defences into one reusable function:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const MODEL_CONTEXT = 200_000;
const MAX_RESPONSE = 4_096;
const BUDGET = MODEL_CONTEXT - MAX_RESPONSE;
function estimateTokens(msgs: Anthropic.MessageParam[]) {
return msgs.reduce((n, m) => {
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
return n + Math.ceil(text.length / 4);
}, 0);
}
function trimMessages(
system: string,
messages: Anthropic.MessageParam[]
): Anthropic.MessageParam[] {
const systemTokens = Math.ceil(system.length / 4);
let turns = [...messages];
// Drop oldest user+assistant pairs until we fit
while (estimateTokens(turns) + systemTokens > BUDGET && turns.length > 1) {
turns = turns.slice(2);
}
return turns;
}
function parseModelJson(raw: string): unknown {
const stripped = raw
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/, "")
.trim();
const match = stripped.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (!match) throw new Error("No JSON found in model output");
return JSON.parse(match[1]);
}
export async function callLLM(
system: string,
messages: Anthropic.MessageParam[],
maxRetries = 4
): Promise<string> {
const trimmed = trimMessages(system, messages);
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const res = await client.messages.create({
model: "claude-3-5-sonnet-20241022",
max_tokens: MAX_RESPONSE,
system,
messages: trimmed,
});
return res.content[0].type === "text" ? res.content[0].text : "";
} catch (err: unknown) {
const e = err as { status?: number; headers?: Record<string, string> };
if (e.status === 429) {
const retryAfter = e.headers?.["retry-after"] ?? String(2 ** attempt);
const wait = Number(retryAfter) * 1000 + Math.random() * 500;
await new Promise(r => setTimeout(r, wait));
continue;
}
throw err; // non-retryable
}
}
throw new Error("Max retries exceeded");
} Check your understanding
Your chat app appends every user and assistant turn to a local array and sends it on each call. After 400 turns, calls start failing with HTTP 400. What is the cause and fix?
Putting It Together: A Minimal Chat Loop
These four concepts — messages array, system prompt, streaming, and failure handling — compose into a minimal but viable chat loop. The code is just three concerns: accumulate history, trim when needed, call and display.
import Anthropic from "@anthropic-ai/sdk";
import * as readline from "node:readline/promises";
const client = new Anthropic();
const SYSTEM = "You are a concise assistant. Answer in 2–3 sentences unless asked for more.";
const history: Anthropic.MessageParam[] = [];
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log("Chat started. Ctrl-C to quit.\n");
while (true) {
const userInput = await rl.question("You: ");
history.push({ role: "user", content: userInput });
// Rough context trim — keep system prompt intact, drop oldest pairs
while (history.reduce((n, m) => n + (m.content as string).length, 0) > 600_000) {
history.splice(0, 2);
}
process.stdout.write("Assistant: ");
const stream = await client.messages.stream({
model: "claude-3-5-sonnet-20241022",
max_tokens: 512,
system: SYSTEM,
messages: history,
});
let assistantReply = "";
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
process.stdout.write(chunk.delta.text);
assistantReply += chunk.delta.text;
}
}
process.stdout.write("\n\n");
history.push({ role: "assistant", content: assistantReply });
} Check your understanding
In the chat loop above, why does the trim splice starting at index 0 always remove 2 elements at a time?
Check your understandingQ 1 / 5
Which HTTP status code does an LLM API return when you exceed its rate limit?
Synthesis prompt. From memory, sketch the callLLM wrapper function: write just the signature and the three if-branches it needs — rate-limit retry, context trim before the call, and JSON extract on the result. You don’t need working code; sketch the skeleton. Owning the structure (not just recognising it) is the goal.