Backed by family, friends, and one very patient spouse
Nola is a TypeScript superset with syntax that makes working with AI models feel native, much as async / await did for asynchronous code.
$npm create nola@latest
outputdone
{
name: "Ada Lovelace",
age: 36,
job: "mathematician"
}
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.
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.
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.
THE FEATURES
There isn’t much to learn
Three constructs, one config file, and the tools for your bundler and IDE.
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.
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.
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.”
“I bundled it. Didn’t notice a thing.”
“Thenable. I awaited it. No further questions.”
“Breakpoints bound on the first try.”
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.