.tsi → .ts → T

Inference is a language feature.
Not an SDK.

Nola is a TypeScript superset: .tsi files where ask is a keyword and your types are the contract. Extract typed values, or let the model fill a call’s arguments — your code still runs as code. Everything lowers to plain TypeScript before tsc, your bundler, or Node ever sees it; a provider fills in the T at run time.

$npm create nola@latest
Backed by family, friends, and one very patient spouse
TAP AN ASK TO SEE THE RECEIPT
CONTEXT
import { createTicket, type Triage } from "./tickets.js";
export infer function classifyMessage(.message: string) {
const triage = ask ..`triage the customer message`<Triage>;
// Calling native ts function
const ticketId = ask createTicket`file the ${triage.category} ticket`(
..`a short ticket title`<string>,
..`priority 1-5, 1 is most urgent`<number>
);
return { ...triage, ticketId };
}
resolving …
context .message = "I was charged twice for order #88 …"
ask 1 ..`triage the customer message`<Triage>
model→ { category: "refund", orderIds: ["#88"], urgent: true } — openai · 1 attempt
ask 2 createTicket`file the refund ticket`(..title, ..priority)
model→ args ("Duplicate charge on order #88", 1) — both slots, one call
your code→createTicket(…) ran → "TCK-4417"
return { ...triage, ticketId: 'TCK-4417' }

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 VOCABULARY

Small language, real programs

You already know this grammar. async made concurrency a language feature instead of a callback library; infer does the same for inference.

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

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

infer

LLM-backed functions

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.

ask

Resolved by a provider

ask resolves an intent the way await resolves a promise — and ask with chooses who resolves it. OpenAI, Anthropic, Google, any OpenAI-compatible endpoint, plus withRetry / fallback / roundRobin, a deterministic mockProvider and record/replay ledgers for tests.

Intent<T>

Lazy by design

Calling an infer function returns an Intent<T> — thenable like a Promise<T>, but nothing reaches a provider until you await it. Until then it is a value you can shape: .withRetry(2), .withProvider(…), .withParams({ temperature }), .withTimeout(30_000).

..`prompt`<T>

Typed extractors

Ask for a string, a union, an interface. The JSON Schema is derived from the type at compile time, and the runtime validates the reply, retrying once with a correction when it drifts.

fn`hint`(…)

Call intents

Let the model fill a function’s arguments, then run it. Extractors in argument slots resolve in one provider call; async callees are awaited for you.

nola run

Fits your toolchain

Lowered TS carries a source map back to .tsi, so the tsserver plugin, the LSP and the VS Code extension report hover, completion and diagnostics at .tsi positions. nola build / run / check, node --import nola-lang/register, and plugins for Vite, webpack, Rollup, Rolldown, esbuild, Rspack and Next (server-side; client bundles are rejected at compile time) — plus a shipped agent skill so your coding agent writes valid .tsi.

WHY SYNTAX BEATS A LIBRARY

One type. One prompt. Zero glue.

An SDK call spreads one decision across a schema object, a prompt string, and a type that has to be kept in sync — then makes you re-assemble all of it for the next ask. A .tsi file makes each one a single typed expression that the compiler, the editor, and the 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 a ${}; 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 PIPELINE

write lower resolve

Three phases for every .tsi file — and only the first one is yours. You write TypeScript with a couple of new constructs; the toolchain and the runtime do the rest. Nothing else to learn — 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

Because .tsi lowers to plain TypeScript with a source map, editor support is a thin client over one language server. VS Code ships today; Zed and JetBrains are on the way. And the agent writing alongside you 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

The Nola skill ships inside node_modules/nola-lang, so your agent learns the real .tsi grammar at the version you installed — not whatever it guessed.

$ 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
QUICKSTART

One type, one function, one run

1 — create a project
$ npm create nola@latest

Node ≥ 22. The starter runs offline from a committed replay ledger — no API key needed until you switch the provider. Already have a project? npm create nola@latest -- --add wires Nola into it — config plus dependencies.

2 — write a .tsi file and run it
// person.tsi
export interface Person { name: string; age: number; employer: string }
export infer function extractPerson(.message: string) {
return ask ..`the person described in the text`<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

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.