Your First LLM API Call: Messages, Tokens and Limits
Make one model call from Node.js with any provider and understand every part of it: the messages list, the system prompt, tokens, output limits and why answers vary.
By Avnish Yadav
Every AI agent, chatbot and "AI feature" is built from one small thing: an API call that sends text to a model and gets text back. In this lesson you'll make that call yourself and learn what each part does, so the later lessons (structured output, tool calling, guardrails) make sense.
You'll need: Node.js 20 or later and an API key from any LLM provider. You'll write one small function, askModel, with an adapter for your provider. The rest of this track only talks to that function, so you can switch providers later by changing one file.
What a model call actually sends
A chat model does not remember anything between calls. Each request carries the whole conversation:
- a system prompt: standing instructions such as "You are a code reviewer. Answer in short bullet points.";
- a list of messages, each with a role (
userorassistant) and content; - settings such as the model name and the maximum length of the answer.
The model reads all of it and writes the next assistant message. To continue a conversation, you append its answer and your next question, and send the whole list again. Every major provider works this way; only the field names differ.
Define the function your app will use
Create model.mjs with the shape every provider adapter must return:
// askModel(system, messages) → { text, inputTokens, outputTokens, cutOff }
// messages: [{ role: "user" | "assistant", content: "..." }]
export { askModel } from "./model-anthropic.mjs"; // or "./model-openai.mjs"
Your code never imports a provider SDK directly. It imports askModel.
Write the adapter for your provider
Keep the key and the model name in environment variables, never in your code or in Git:
export LLM_API_KEY="your-key-here"
export LLM_MODEL="a model name from your provider's docs"
Anthropic (Claude) (npm install @anthropic-ai/sdk), in model-anthropic.mjs:
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.LLM_API_KEY });
export async function askModel(system, messages, maxTokens = 500) {
const reply = await client.messages.create({ model: process.env.LLM_MODEL, max_tokens: maxTokens, system, messages });
return {
text: reply.content.filter((b) => b.type === "text").map((b) => b.text).join(""),
inputTokens: reply.usage.input_tokens,
outputTokens: reply.usage.output_tokens,
cutOff: reply.stop_reason === "max_tokens",
};
}
OpenAI (npm install openai), in model-openai.mjs:
import OpenAI from "openai";
const client = new OpenAI({ apiKey: process.env.LLM_API_KEY });
export async function askModel(system, messages, maxTokens = 500) {
const reply = await client.chat.completions.create({
model: process.env.LLM_MODEL,
max_completion_tokens: maxTokens,
messages: [{ role: "system", content: system }, ...messages],
});
const choice = reply.choices[0];
return {
text: choice.message.content ?? "",
inputTokens: reply.usage?.prompt_tokens ?? 0,
outputTokens: reply.usage?.completion_tokens ?? 0,
cutOff: choice.finish_reason === "length",
};
}
Notice the differences: Anthropic takes the system prompt as its own field and returns a list of content blocks; OpenAI puts the system prompt in the messages and returns "choices". The adapter hides those details. For other providers, check their API reference for the same four things: where the system prompt goes, where the text comes back, the token counts, and the "stopped because of length" signal.
Send one message
Create ask.mjs:
import { askModel } from "./model.mjs";
const reply = await askModel(
"You are a senior developer. Answer in three short bullet points.",
[{ role: "user", content: "When should I use a queue instead of a cron job?" }]
);
console.log(reply.text);
console.log({ in: reply.inputTokens, out: reply.outputTokens });
if (reply.cutOff) console.warn("Answer was cut off: raise maxTokens or ask for less.");
Run it with node ask.mjs. You'll see the answer and the tokens it used.
Understand tokens
Models don't read characters or words; they read tokens, small chunks of text. A common English word is often one token, while code, rare words and other languages usually take more. Tokens matter for three reasons:
- Price: providers charge per input token and per output token, and output tokens usually cost more.
- Context limit: each model can read only so many tokens per request, counting the system prompt, every message and the answer.
- Speed: longer answers take longer to generate.
The maximum-tokens setting caps the length of the answer, not the input. If the answer hits the cap, it stops mid-sentence, which is why the adapter reports cutOff. To estimate tokens before you send something, paste it into the free token counter.
Keep a conversation going
Because the model is stateless, you keep the history yourself:
const history = [{ role: "user", content: "Name one benefit of TypeScript." }];
const first = await askModel("Answer in one sentence.", history);
history.push({ role: "assistant", content: first.text });
history.push({ role: "user", content: "Now give one downside." });
const second = await askModel("Answer in one sentence.", history);
console.log(second.text);
Every turn resends everything before it, so long chats get slower and more expensive. Real apps trim or summarise old turns.
Why the same question gives different answers
Models pick each next token with some randomness, so two identical requests can produce different wording. That's fine for writing help, but it means you should never test an AI feature by comparing exact text. Test the shape of the answer (is it valid JSON? does it mention the required field?) instead. The next lesson shows how to get output with a fixed shape.
Common mistakes
- Putting the key in frontend code. Anyone can read it. Call the model from your server.
- Forgetting the history. The model can't "remember" the last message unless you send it again.
- Ignoring the cut-off signal. A truncated answer looks like a strange, unfinished reply.
- Importing a provider SDK all over your code. Keep it behind
askModel, so switching providers is a one-file change.