Module 1 · Lesson 3 of 6
Your First LLM API Call
7 min read · July 9, 2026 · Updated July 13, 2026
Strip away the hype and an LLM provider is a stateless HTTP service: you POST JSON, you get JSON back, you pay per byte-ish (token). If you've integrated Stripe or Twilio, you already have the muscle memory for 90% of this lesson.
It's just an HTTPS POST
No SDK required. Here's a raw call to an OpenAI-compatible chat-completions endpoint:
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-5-mini",
"max_tokens": 300,
"messages": [
{"role": "system", "content": "You are a terse SQL expert."},
{"role": "user", "content": "Why is my index-only scan doing heap fetches?"}
]
}'And the same request against Anthropic's Messages API:
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-sonnet-4-5",
"max_tokens": 300,
"system": "You are a terse SQL expert.",
"messages": [
{"role": "user", "content": "Why is my index-only scan doing heap fetches?"}
]
}'Two things to notice. First, the ecosystem has converged on roughly two API dialects: OpenAI's chat-completions shape (which nearly every other vendor and open-weight serving stack clones: vLLM, Together, Groq, Fireworks) and Anthropic's Messages shape. Learn both and you can talk to essentially any model on the market. Second, the differences are cosmetic: Anthropic pulls the system prompt into a top-level system field and requires max_tokens; OpenAI keeps system as a message role. Same idea, different serialization: think Postgres wire protocol vs. MySQL wire protocol.
The call is stateless. The server keeps no session. Every request must carry the full conversation history. That's why the body is an array of messages, not a single string. We dug into the consequences of that in the context-window lesson.
Message roles: three, and they mean something
system: the deployment config. Instructions the model treats as higher-privilege than user input: persona, output format, rules, guardrails. You control this; users never should. Think of it as the server-side config file, not user data.user: the request payload. What the human (or your calling service) is asking.assistant: the model's prior responses. On turn two, you replay turn one's assistant message back in the array. You can also pre-fill an assistant message to force the response to start a certain way (e.g., start it with{to coerce JSON output, a cheap trick that works surprisingly well on Anthropic's API).
Keeping system and user content in separate roles isn't decoration. It's the API's version of parameterized queries vs. string concatenation: mixing untrusted user input into your system prompt is the SQL-injection posture of LLM apps. Not bulletproof (prompt injection is a real, unsolved problem), but role separation is the baseline.
The same call in TypeScript
The official SDKs are thin, typed wrappers over the HTTP call: retries, auth headers, streaming helpers. Here's Anthropic's (@anthropic-ai/sdk); OpenAI's SDK is symmetric enough that you can transliterate in five minutes.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const response = await client.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 300,
system: "You are a terse SQL expert.",
messages: [
{ role: "user", content: "Why is my index-only scan doing heap fetches?" },
],
});
console.log(response.content[0].text);
console.log(response.stop_reason); // "end_turn", hopefully
console.log(response.usage); // { input_tokens: 31, output_tokens: 218, ... }The parameters that matter on day one
model: which engine handles the request. The single biggest lever on cost, latency, and quality. Lesson 4 is entirely about this.max_tokens: a hard cap on output length, not a target. It's a circuit breaker: set it a comfortable margin above your longest expected response. If the model hits the cap you get a truncated response withstop_reason: "max_tokens". Treat that like a 206 you didn't ask for.temperature: output randomness, 0 to 1 (0 to 2 on OpenAI). Lower means more deterministic-ish, higher means more varied. Rule of thumb: 0 for extraction, classification, and anything you parse programmatically; default (usually 1) for creative or conversational output.
Everything else (top_p, top_k, penalty knobs): leave at defaults. They interact with temperature in non-obvious ways, and tuning them on day one is the equivalent of editing random_page_cost before you've written your first query.
Reading the response
The three fields you'll actually use:
content: the model's output. An array of blocks on Anthropic (usually one text block); achoices[0].messageobject on OpenAI.stop_reason(Anthropic) /finish_reason(OpenAI): why generation stopped.end_turn/stopmeans the model finished naturally.max_tokens/lengthmeans you truncated it. Check this in production code the way you'd check an HTTP status: a 200 withstop_reason: "max_tokens"is a partial result.usage: input and output token counts. This is your billing meter. Providers charge per million tokens, with output typically 4–5x the input price. Logusageon every call from day one, tagged with feature and tenant, exactly like you'd meter S3 bytes or DB row reads. Teams that skip this get their first surprise invoice within a month.
A first taste of streaming
A 500-token response takes several seconds to generate: the model produces tokens sequentially, and time-to-last-token scales with output length. Chat UIs feel fast anyway because they stream: the API sends server-sent events (SSE), one small delta at a time, so time-to-first-token (often 300ms–1s) is what the user perceives. It's Transfer-Encoding: chunked energy. Same reason you stream a large CSV export instead of buffering it.
- POST /v1/messagesfull transcript in the body
- PrefillTTFT 300ms–1smodel ingests the whole prompt
- Token stream30–300 tok/sSSE deltas, one chunk at a time
- Stop eventfinal usage block: log it
const stream = client.messages.stream({
model: "claude-sonnet-4-5",
max_tokens: 300,
messages: [{ role: "user", content: "Explain MVCC in two paragraphs." }],
});
stream.on("text", (delta) => process.stdout.write(delta));
const final = await stream.finalMessage(); // full message + usage when doneRule of thumb: stream anything a human watches; buffer anything a machine parses. Module 2 has a full lesson on what the stream actually contains, and there's a guide to relaying it through your own backend.
API-key hygiene
Treat the key like a database password, because economically it's worse: a leaked Postgres password lets someone read your data; a leaked LLM key lets someone spend your money at $10–75 per million output tokens until you notice.
- Env vars or a secrets manager (Vault, AWS Secrets Manager, Doppler). Never in code, never in git. Add it to your secret-scanning rules.
- Never ship a key client-side. No browser bundles, no mobile apps. Anything in a frontend is public within hours. All LLM calls go through your backend, which you want anyway for logging, rate limiting, and auth.
- Per-environment keys: separate dev, staging, and prod keys, like DB credentials. One key per service if you can; blast radius and attribution both improve.
- Set spend limits in the provider console. Every major provider supports monthly caps and alert thresholds. A dev key with a $50 hard cap turns a leak or a runaway retry loop from an incident into a Slack ping.
Key takeaways
- An LLM API call is a stateless HTTPS POST with JSON in and JSON out. The ecosystem speaks roughly two dialects (OpenAI chat-completions and Anthropic Messages), and they differ cosmetically.
- Roles are access levels:
systemis your config,useris untrusted input,assistantis prior output. Keep them separated like parameterized queries. - Day-one parameters:
model,max_tokens(a circuit breaker, not a target),temperature(0 for anything you parse). Leave the rest at defaults. - Always check
stop_reason/finish_reason: a truncated response still returns 200. Log theusageblock on every call; it's your billing meter. - Stream (SSE) when a human is watching; buffer when a machine is parsing.
- Treat API keys like DB passwords with a credit card attached: env vars only, never client-side, per-env keys, hard spend caps in the console.