Skip to content

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
If a key has ever been in a client bundle, in a commit, or in a screenshot, treat it as leaked and rotate it. Removing it from the latest commit does not remove it from the history, and scrapers watch public repositories for exactly this pattern within minutes of a push.

The four errors you hit first

SymptomDescription
401 invalid_api_keyNine 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_foundA 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.
429Two 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 requestfetch 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

  1. mkdir llm-first && cd llm-first && npm init -y, then npm i -D typescript tsx @types/node.
  2. Put the key in .env.local and add .env*.local to .gitignore before you paste the key anywhere.
  3. Save the file as call.ts and run node --env-file=.env.local node_modules/.bin/tsx call.ts. Node’s --env-file is built in from Node 20.6, so no dotenv is needed.
  4. 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.
  5. Change max_tokens to 10 and look at finish_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.