Your First LLM Call in TypeScript
9 min read · updated August 4, 2026
A model call is an HTTP POST with a JSON body. No SDK is required, and writing the first one without an SDK is the fastest way to understand every SDK you use afterwards. Here it is in TypeScript, on Node, with types you wrote and a key that stays on the server.
The shortest thing that works
Node 18 and later ship fetch globally, so there is nothing to install for the request itself. This file runs as-is with node --experimental-strip-types call.ts on Node 22+, or through tsx on anything older.
// call.ts
const BASE = "https://api.multigrid.ai/v1";
async function main() {
const res = await fetch(BASE + "/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + process.env.LLM_API_KEY,
},
body: JSON.stringify({
model: "openai/gpt-4o-mini",
messages: [
{ role: "system", content: "Answer in one sentence." },
{ role: "user", content: "Why is output slower than input?" },
],
max_tokens: 200,
}),
});
if (!res.ok) {
throw new Error(res.status + " " + (await res.text()));
}
const data = await res.json();
console.log(data.choices[0].message.content);
}
main();That is the whole protocol. A model name, a list of messages each with a role and some content, and a ceiling on how much comes back. The OpenAI-shaped /chat/completions body above is the closest thing the industry has to a common format, which is why most gateways and most self-hosted servers accept it — but the field set is not universal, and provider-specific parameters are where portability stops.
Typing the request and the response
res.json() returns any. That is the single biggest source of runtime surprises in TypeScript AI code, because every field access past that point type-checks and none of them are checked. Give the boundary a shape.
// types.ts
export type Role = "system" | "user" | "assistant";
export type Message = { role: Role; content: string };
export type ChatRequest = {
model: string;
messages: Message[];
max_tokens?: number;
temperature?: number;
stream?: boolean;
};
export type Usage = {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
export type ChatResponse = {
id: string;
model: string;
choices: {
index: number;
message: Message;
finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null;
}[];
usage?: Usage;
};Two of those fields earn their place immediately. finish_reason is how you tell a complete answer from a truncated one: "length" means the model hit your max_tokens mid-sentence and the string you are about to render is an unfinished thought. Almost no first tutorial checks it, and almost every application eventually ships a bug that is exactly this. usage is what you bill on and what you log; the response is the only place the real token counts exist, because a local count is an estimate.
A cast is not validation. (await res.json()) as ChatResponse tells the compiler a story it cannot check. For a first call that is an acceptable trade; the moment the response shape matters to more than one caller, parse it — a Zod schema at the boundary costs three lines and turns a mystery undefined two modules away into an error at the point of entry.
Why the key cannot go in the browser
An API key in client-side code is public. Not “discoverable by a determined attacker” — public, in the same sense the page title is public. It is in the JavaScript bundle, which is served to anyone who asks for it, visible in the network tab, and cached by every proxy in between. Bundler prefixes make this explicit rather than preventing it: in Next.js only variables prefixed NEXT_PUBLIC_ are inlined into client bundles, and in Vite only VITE_. Those prefixes exist so that shipping a secret is something you have to type deliberately.
The consequence is architectural and there is no way around it: the browser calls your server, and your server calls the model. That extra hop is not overhead you are paying for security theatre. It is the only place you can authenticate the user, decide whose balance to spend, enforce a rate limit, cap max_tokens, and log what happened. A key handed to the browser gives away all five at once.
# .env.local — never committed LLM_API_KEY=sk-... # .gitignore .env*.local
The four errors you hit first
| Symptom | Description |
|---|---|
| 401 invalid_api_key | Nine times in ten process.env.LLM_API_KEY is undefined and you have sent the literal string "Bearer undefined". Log process.env.LLM_API_KEY?.length — the length, never the value — before blaming the provider. |
| 404 model_not_found | A model id is an exact string, usually vendor/model-version, and it is case-sensitive. A 404 here is nearly always a typo or a model your key has no access to, not a missing endpoint. |
| 429 | Two different things wear this code: requests-per-minute and insufficient balance. Read the body, not the status. Retry the first with backoff; retrying the second just burns connections. |
| ECONNRESET, or a hung request | fetch has no default timeout in Node. A request can hang until the socket dies. Always pass signal: AbortSignal.timeout(30_000) — see cancelling in-flight requests. |
Running it
mkdir llm-first && cd llm-first && npm init -y, thennpm i -D typescript tsx @types/node.- Put the key in
.env.localand add.env*.localto.gitignorebefore you paste the key anywhere. - Save the file as
call.tsand runnode --env-file=.env.local node_modules/.bin/tsx call.ts. Node’s--env-fileis built in from Node 20.6, so nodotenvis needed. - Add
console.log(data.usage)and run it twice. The token counts are the units your bill is denominated in, and seeing them on call one is worth more than reading about them later. - Change
max_tokensto10and look atfinish_reason. That is what truncation looks like from the outside, and you now know how to detect it.
From here the two next moves are getting tokens into a UI as they arrive and wrapping this in a client that survives more than one provider.