Backed by family, friends, and one very patient spouse

AI inference,
expressed in TypeScript.

Nola extends TypeScript with syntax that makes working with AI models feel native, much as async / await did for asynchronous code.

$npm create nola@latest


interface Person {
  name: string;
  age: number;
  job: string;
}

const person = await __nola.ask(__nola.intents.ExtractIntent<Person>({ instruction: `the described person`, type: __nola_type_$1(), loc: "9:20", def: "003caac216604817042c1c6224093f675c7dc0e320ffe2f546a096d033d3d89d" }), __nola_module_ctx());

console.log(person);

;
import { __nola } from "@nola-lang/runtime";
__nola.useRuntime(18);
function __nola_file_ctx() { return __nola.context.file("playground.tsi", 18); }
function __nola_module_ctx() { return __nola_file_ctx().module({ instruction: "Ada Lovelace, 36, worked with Charles Babbage on the Analytical Engine as a mathematician." }); }
function __nola_type_$1(): 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(), job: __nola.types.string() }); }
`Ada Lovelace, 36, worked with Charles Babbage on the Analytical Engine as a mathematician.`;

interface Person {
  name: string;
  age: number;
  job: string;
}

const person = ask `the described person`<Person>;

console.log(person);
outputdone
{
name: "Ada Lovelace",
age: 36,
job: "mathematician"
}

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

Run it in the playground →
THE CONCEPT

Three constructs. The rest is TypeScript.

infer defines an AI model context boundary,
Intent<T> is what should be inferred inside it,
ask resolves it to a T.

person.tsi
`Ada Lovelace, 36, worked with Charles Babbage on the Analytical Engine as a mathematician.`;
interface Person {
name: string;
age: number;
}
const person = ask `the described person`<Person>;

Three constructs. Watch them assemble.

QUICKSTART

A running .tsi file in a minute

One command scaffolds a project that already runs, with a provider, your editor and your coding agent set up. Follow its prompts; it ends by opening the file to run.

Node ≥ 22.18
$ npm create nola@latest

The starter runs offline from a committed replay ledger, so the first run needs no API key. Pick nola: dev in the wizard for 25 free hosted runs, or a vendor and its key.

your editorNola for VS Code: highlighting, diagnostics at .tsi positions, hover, completion, go-to-definition, and F5 debugging with breakpoints in the file you wrote.
VS Code → Zed · soon JetBrains · soon
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
`I was charged twice for order #4821. Refund one of them, please.`;
type Triage = {
category: "billing" | "refund" | "fraud" | "other";
orderIds: string[];
};
const triage = ask `triage the customer message`<Triage>;
const reply = ask `a reply for a ${triage.category} case`<string>;
console.log({ ...triage, reply });
Not one import: ask is the language, not a package. The contract is the TypeScript type you already have, and its schema is derived from it at compile time. The message at the top is the file’s context, so both asks see it without being handed it — context belongs to the file, not to one call — and chaining is ordinary interpolation, ${triage.category}. The model is named once, in nola.config.ts.
WHY A COMPILER

A library reads a string. A compiler reads your program.

Every tab on the left is a function call. At runtime it sees a schema object and a prompt string, and nothing else. Nola is a compiler: it owns the parse of your .tsi and runs on TypeScript’s own type checker, so it sees the whole program around an ask — the type it must come back as, the values in its context, the expression it sits in. That is what derives the schema from your type, tells context from plain arguments, and lights up .tsi in your editor today.

It is also what lets the language grow where a library cannot follow: an if, a switch or a loop the model decides, as constructs the compiler understands rather than prompts you assemble. Those are on the roadmap, not in the release — but the parser and the checker they need already ship.

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

Three constructs, one config file, and the tools for your bundler and IDE.

Context has a scope

infer frames what the model sees: a context window with a boundary you can point at. It exists at two levels. The file is one — its first statement, a bare string, is the instruction, and a top-level ask runs inside it. An infer function frames a context of its own, called from plain TypeScript like any other function.

Inside either, the dot is the whole API. A dot-prefixed parameter (.bio) or binding (const .tone = …) is context the model sees; a plain parameter stays an ordinary value — its name and type reach the model, its value never does.

role.tsiTWO SCOPES
// The file is a context: its first string is the instruction, a top-level ask runs in it.
`Ada Lovelace, 36, worked on the Analytical Engine.`;
const name = ask `the person's name`<string>;
// A function frames a context of its own: .bio is what 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>;
}

A file, or a function. Context is a scope, not a string you assemble.

OBSERVABILITY

Every ask, on the record

nola console is a local trace viewer. Run it, set telemetry: "http://127.0.0.1:4141" in your nola.config.ts, 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
$ nola console
Nola Console started
Local: http://127.0.0.1:4141
Home: ~/.nola/console
Enable tracing with `telemetry: "http://127.0.0.1:4141"` in nola.config.ts
# or, for one run, without touching the config:
$ NOLA_TRACING_URL=http://127.0.0.1:4141 nola run src/main.tsi

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. NOLA_TRACING_URL attaches one run with zero config; for something permanent, list the URL under telemetry — and delete the line to turn it off.
hooks, receipts and the console connection →

The same events reach your own code: list an observer with any of onAskStart … onInvocationEnd under telemetry, or set telemetry: { level: "debug" } for the built-in terminal trace. Observers only observe — 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

Every .tsi file goes through three phases, and only the first one is yours. You write TypeScript with infer and ask in it; the toolchain and the runtime take it from there. No schema DSL, no graph builder, no prompt library.

01writeYOU

You write it in .tsi

It is still TypeScript, so everything you know still applies. Put what the model should see in the file’s first string or a dot-prefixed parameter (.message), and write ask `prompt`<T> where you want a value back — at the top of a file, or inside an infer function you import from anywhere. That is your whole job.

02lowerNOLA · BUILD

Nola lowers it to plain TS

Before tsc, your bundler, Node or your editor ever sees it, the toolchain turns the .tsi into ordinary TypeScript with a source map, the way JSX is compiled away. The JSON Schema for every <T> is worked out from your types right there. Nothing to write by hand: the loader, nola build and the bundler plugins do it.

03resolveNOLA · RUN

A provider resolves it

Nothing has run yet: an Intent<T> is just the request. When ask resolves it — or plain TypeScript awaits an infer function — the runtime puts the context together, calls the provider from your config, checks the reply against the schema, gives a wrong-shaped reply one chance to correct itself, and hands you a real T. Every ask leaves a receipt.

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.