Backed by family, friends, and one very patient spouse

LLM inference,
expressed in TypeScript.

Nola extends TypeScript with native syntax for LLM inference, much as async / await made asynchronous code feel native.

$npm create nola@latest
// 1) A TS type is the contract. Nola derives its JSON schema; JSDoc goes in too.
interface Person {
  name: string;
  age: number;
  /** the employer, or "unknown" */
  employer: string;
  job: string;
}

// 2) `infer` declares an inference function: it returns Intent<T>, like async returns Promise<T>.
function extractPerson(text: string) {
  return __nola.intents.Intent(async (__frame) => { void text;
  // 3) ..`prompt`<T> builds the extraction Intent<T> and validates the reply against T.
  // 4) `ask` resolves it, like `await` resolves a Promise<T>.
  const person = await __nola.ask(__nola.intents.ExtractIntent<Person>({ instruction: `the person described in the text`, type: __nola_type_$2(), loc: "14:22", def: "b7b6d537457a6fbc9988b6a91ef6a1bf11dbbd5efd493ee82f50ffeb2dace4ba" }), __frame);

  return person;
  }, __nola_file_ctx().func({ fn: "extractPerson", instruction: "", args: [{ name: "text", type: __nola_type_$1(), contextual: true, value: text }] }));
}

// 5) An Intent<T> is thenable: from plain TS you would import the function and await it, here we await it in place.
const person = await extractPerson(
  "Ada Lovelace, 36, worked with Charles Babbage on the Analytical Engine as a mathematician.",
);

console.log(person);

import { __nola } from "@nola-lang/runtime";
__nola.useRuntime(16);
function __nola_file_ctx() { return __nola.context.file("playground.tsi"); }
function __nola_type_$1(): import("@nola-lang/runtime").InferType<unknown> | undefined { return __nola.types.string(); }
function __nola_type_$2(): import("@nola-lang/runtime").InferType<unknown> { return __nola.types.ref("Person", __nola_type_Person); }
function __nola_type_Person(): import("@nola-lang/runtime").InferType<unknown> { return __nola.types.object({ name: __nola.types.string(), age: __nola.types.number(), employer: __nola.types.string().describe("the employer, or \"unknown\""), job: __nola.types.string() }); }
// 1) A TS type is the contract. Nola derives its JSON schema; JSDoc goes in too.
interface Person {
  name: string;
  age: number;
  /** the employer, or "unknown" */
  employer: string;
  job: string;
}

// 2) `infer` declares an inference function: it returns Intent<T>, like async returns Promise<T>.
infer function extractPerson(.text: string) {
  // 3) ..`prompt`<T> builds the extraction Intent<T> and validates the reply against T.
  // 4) `ask` resolves it, like `await` resolves a Promise<T>.
  const person = ask ..`the person described in the text`<Person>;

  return person;
}

// 5) An Intent<T> is thenable: from plain TS you would import the function and await it, here we await it in place.
const person = await extractPerson(
  "Ada Lovelace, 36, worked with Charles Babbage on the Analytical Engine as a mathematician.",
);

console.log(person);

The playground uses hosted inference. No account or API key required.

Run it in the playground
THE VOCABULARY

Small language, real programs

You already know this grammar. Read the two functions side by side: infer stands where async stood, ask where await stood, Intent where Promise stood. Everything else keeps its shape.

users.tsCONCURRENCY
async function loadUser(id: string): Promise<User> {
const user = await fetchUser(id);
return user;
}
users.tsiINFERENCE
infer function extractUser(.bio: string): Intent<User> {
const user = ask ..`the user described`<User>;
return user;
}

Same file, one letter apart — the i in .tsi is inference.

QUICKSTART

One type, one function, one run

1 — create a project
$ npm create nola@latest

Node ≥ 22. The starter replays a committed ledger, so no API key is needed until you switch to a live provider or the Nola Platform. Already have a project? npm create nola@latest -- --add wires Nola into it.

2 — write a .tsi file and run it
// person.tsi
export interface Person { name: string; age: number; employer: string }
export infer function extractPerson(.bio: string) {
return ask ..`extract the person`<Person>;
}
// main.ts — plain TypeScript imports the .tsi directly
import { extractPerson } from "./person.tsi";
const person = await extractPerson(
"Alice Smith, 32, is a staff engineer at Acme Corp.",
);
console.log(person);
// { name: "Alice Smith", age: 32, employer: "Acme Corp" }
$ npm start # nola run src/main.ts
3 — open it in your editorPick VS Code at the scaffold’s editor step (or pass --ide vscode) for F5 debugging and the extension recommendation.
VS Code Zed · soon JetBrains · soonall editors
WHY SYNTAX BEATS A LIBRARY

One type. One prompt. Zero glue.

An SDK call spreads one decision across a schema, a prompt, and a type that must stay in sync. Nola makes it a single typed expression that the compiler, editor, and runtime all understand.

THE USUAL — classify, then reply
import { generateText, Output } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
const Triage = z.object({
category: z.enum(["billing", "refund", "fraud", "other"]),
orderIds: z.array(z.string()),
});
const { output: triage } = await generateText({
model: openai("gpt-5.6"),
instructions: "You are a support classifier.",
prompt: message,
output: Output.object({ schema: Triage }),
});
const { text: reply } = await generateText({
model: openai("gpt-5.6"),
instructions: "You are a support agent.",
prompt: `Reply to this ${triage.category} case: ${message}`,
});
Clean — and this is the code most people write today. But the contract lives in Zod: your Triage type is a z.infer of the schema, or you maintain both. And each ask is its own call, so the second one re-threads everything by hand — the category interpolated into a template, the original message passed again, because nothing carries context between calls.
WITH NOLA — classify.tsi
type Triage = {
category: "billing" | "refund" | "fraud" | "other";
orderIds: string[];
}
infer function classifyMessage(.message: string) {
const triage = ask ..`triage the customer message`<Triage>;
const reply = ask ..`a reply for a ${triage.category} case`<string>;
return { ...triage, reply };
}
Not one import: infer and ask are the language, not a package. The contract is the TypeScript type you already have, and its schema is derived from it at compile time. Chaining is ordinary interpolation - ${triage.category}; the second ask still sees .message, because context belongs to the function, not to one call.

Same task, same model, on every tab. Samples follow each SDK’s current documentation, checked August 2026. The snippets are trimmed to the decision; the repo has each stack as a complete installable project.

Full runnable projects for every tab →
THE FEATURES

There isn’t much to learn

Six ideas, and you’ve seen the whole language — tap through, none of them needs more than a dozen lines.

Context is a parameter

infer function declares an LLM-backed function the way async declares a concurrent one. Dot-prefixed params (.bio) are context the model sees; plain params stay ordinary values. Import it from plain TS like anything else.

The dot is the whole API. .bio’s value is composed into the prompt of every ask in the call; team stays an ordinary JavaScript argument — its name and type reach the model, its value never does.

role.tsiCONTEXT PARAMS
// one dot in: .bio is context the model sees.
// team is a plain value — interpolated below, never in the context block.
export infer function extractRole(.bio: string, team: string) {
return ask ..`the person's role on the ${team} team`<string>;
}

One dot in. Context is a parameter, not a string you assemble.

OBSERVABILITY

Every ask, on the record

nola console is a local trace viewer. Run it once, point an app at it, and every ask shows up as it resolves — grouped by project, by call, and by the line of .tsi that authored it.

localhost:4141 — Nola Console
The Nola Console: a list of traces on the left, one trace opened to its asks, and an ask's receipt and timeline on the right

What it records

The runtime already emits an event for each step of an ask and a receipt when it finishes. The console stores that stream in a per-machine SQLite file and lays it out as a tree:

project
the project key in nola.config.ts, defaulting to your package.json name — one console groups every app on the machine
trace
one top-level infer function call, with every nested call and ask underneath it
ask
one ask execution — the prompt as composed and as last sent, the JSON Schema it was checked against, which provider answered, the outcome, and how long it took
attempt
one provider round trip — a validation miss and its correction turn show up as two

The Asks view turns the tree sideways: every execution of one authored ask — keyed by its text and type, not its line number — with a duration chart, so a prompt you are tuning is one page.

terminalCONNECT AN APP
$ npx nola-lang console
Nola Console http://localhost:4141
storage ~/.nola/console/data/console.db
# then, in the app you want to watch
$ NOLA_CONSOLE_URL=http://localhost:4141 nola run src/main.ts

Never in the way

  • Ingestion is fire-and-forget. A console that is down warns once and never blocks, slows or fails an ask; one that comes back mid-run picks the stream up again.
  • Loopback only, and content is redacted before it leaves the process. Pointing at a non-local host prints a notice naming it.
  • No agent, no SDK. The environment variable attaches with zero config; for something permanent, wrap your provider — nola({ baseUrl }, openai(…)) — and delete the wrapper to turn it off.
hooks, receipts and the console connection →

The same events reach your own code: add a hook with any of onAskStart onInvocationEnd to nola.config.ts, or set NOLA_LOG=debug for the built-in logger. Hooks observe only — one that throws can never break an ask.

JSX made markup a language feature. Nola does the same for inference. A .tsi file reads like TypeScript — but tsc never sees it: Nola owns the parse, lowers every ask to plain TS, and a provider fills in the type at run time.

THE PIPELINE

write lower resolve

Three phases for every .tsi file, and only the first is yours. You write TypeScript with two new constructs; the toolchain and runtime do the rest. No schema DSL, no graph builder, no prompt library.

01writeYOU

You write it in .tsi

Everything you know about TypeScript still applies. Add infer functions, dot-prefixed context params (.message) the model can see, and ask ..`prompt`<T> wherever you need a value from the LLM. That’s the whole job.

02lowerNOLA · BUILD

Nola lowers it to plain TS

Before tsc, the bundler, Node, or the editor sees it, the toolchain rewrites .tsi to ordinary TypeScript with a source map — the JSX model. JSON Schemas for every <T> are derived from your types at compile time. Nothing for you to run or configure.

03resolveNOLA · RUN

A provider resolves it

Calling an infer function returns a lazy Intent<T>. Awaiting it composes the context, asks the configured provider, validates the reply against the schema (retrying if it drifts), and hands back a real T — plus a receipt for every ask.

EDITORS & AGENTS

Your editor already reads .tsi

.tsi lowers to TypeScript with a source map, so every editor is a thin client over one language server. VS Code ships today; Zed and JetBrains are on the way. Your coding agent reads it too.

AVAILABLE

VS Code

nola.nola-vscode

The full editor story today: language server, tsserver plugin and debugger, all aware of .tsi positions.

  • Highlighting for infer / ask, extractors and ask with
  • Diagnostics — Nola and TypeScript errors at .tsi positions
  • Hover, completion, go-to-definition, ${.} prompt-scope completion
  • F5 debugging: breakpoints bind in .tsi source
Install from Marketplace
IN PROGRESS

Zed

zed

Extension in progress — same language server, packaged for Zed.

  • Highlighting for .tsi
  • Diagnostics, hover, completion via the Nola LSP
Coming soon
IN PROGRESS

JetBrains

webstorm · idea

Plugin in progress — WebStorm and IntelliJ IDEA first.

  • Highlighting for .tsi
  • Diagnostics, hover, completion via the Nola LSP
Coming soon

…and so does your coding agent

One command writes the real .tsi grammar into your repo — .claude/skills/, .cursor/rules/, AGENTS.md — so your agent reads the actual rules, not whatever it guessed. The files are self-contained and version-stamped: they work for your whole team, and a re-run tells you what has gone stale.

$ npx nola-lang skill install
Claude CodeCursorCopilotAGENTS.mdwhat it writes →

Run it bare and it detects the agents your project already uses; nola init offers the same step.

Any editor: nola check reports errors at .tsi positions from the terminal, and the bundled tsserver plugin types .tsi imports from plain TS.

WHAT THE CRITICS SAY
Just plain TypeScript, as far as I can tell.
tsc
I bundled it. Didn’t notice a thing.
esbuild
Thenable. I awaited it. No further questions.
V8
Breakpoints bound on the first try.
the debugger

Stop wiring prompts. Start writing types.

Scaffold a project in one command — it runs offline out of the box, so you can read, edit and re-run .tsi before you ever paste an API key.