Luma · Public Developer Documentation
An LLM call is not a library,
it is a language construct
When LLMs entered software, the languages we already had were not designed to meet them. The gap was filled with patches: string concatenation for prompts, runtime parsing for output, a validation library bolted on beside the call, retry logic rewritten in every project. Luma treats that layer not as a patch but as the language itself — and only that layer.
Languages are being patched
Asking a model a question and getting a structured answer back works the same way in every language today: send text, receive text, and figure out the rest yourself. The sum of those workarounds has become a patch layer that repeats in every LLM project.
Today's patch layer
- Type safety stops at the model boundary. You declare that the function returns
Invoiceand the compiler believes you. What you actually hold is text that has not been parsed yet. - Validation is not part of the contract. A separate library is placed beside the call by hand. Forget it and nothing warns you.
- Retry is handwritten glue. How to explain the failure back to the model is reinvented in every project.
- Non-determinism is invisible in the source. To know whether a function reaches a model you have to follow the call chain.
- A tool call is a framework abstraction. When — or even whether — it runs cannot be known by reading the code.
- Observability is bolted on afterwards. Decorators, wrappers, hand-counted tokens.
What Luma does instead
- The type system crosses the boundary.
ask "…" -> Invoiceis a type declaration; execution does not leave that line until the result validates. - Validation is a language rule. The schema is both the contract of the request and the audit of the response. It cannot be skipped.
- Retry is the language's control flow. A validation failure is appended to the prompt and retried; the mechanism is defined by the language.
- Non-determinism is marked in the syntax.
askis a keyword, and it cannot appear inside apure fn— the parser rejects it. - A tool call is either deterministic or declared. A direct call runs where you wrote it; a native call is only possible with the tools you allowed.
- Telemetry is there from the start. Every model request, every tool execution and every validation rule emits an event.
Languages keep trying to treat an LLM like an ordinary I/O call. But an LLM is not an I/O call: it is a computation whose result may not satisfy its contract. That difference deserves to be handled at the language level, not the library level.
The pragmatic approach
Luma is not a new general-purpose language and does not try to be. It takes over only the layer where the patches pile up — the boundary where you talk to the model, and since v0.4.0 the boundaries either side of it: the database rows going in and the results going back out. The real code of your tools and your database driver stays in your own JS/TS file, and the rest of your application stays in its own language. That is the pragmatic part: it does not change everything, it changes exactly what is broken.
Concretely, that is one step of a pipeline. Something upstream schedules it; Luma pulls typed rows, reshapes them, sends the hard part to a model under a bounded fan-out, and writes the result back. Scheduling, DAG dependencies and backfill stay with your orchestrator — see Data & Integration for where that line is drawn and why.
1 · One definition, three jobs
You write a schema. The runtime uses it in three separate places:
| Where | What it does |
|---|---|
| On the outgoing request | The schema is sent to the gateway as a strict response_format.json_schema, so the shape is enforced at generation time. |
| When the response returns | The same schema drives local validation. The provider is not trusted: types, ranges, formats, where invariants and unknown field checks all run in your own runtime. |
| When validation fails | The error text is appended to the prompt and the call is retried. The model reads what it got wrong. |
Wiring this by hand means three separate pieces of code and three separate sources of failure. Here they are three views of the same definition; they cannot fall out of sync.
A rule either held or it stopped the run. Real extraction has a third case the binary could not say: the answer is usable, but one rule about it did not hold. @severity("warn") is how a rule says that.
| State | What happens |
|---|---|
| Valid | The value is used. |
| Invalid | The run stops, as before. This is still the default — a schema that does not mention @severity behaves exactly as it did. |
| Valid but flagged | The value is used and what the rule found travels with it as a finding the program can read. |
The third state is only admissible because it is visible. A flagged value gets its retry like any other; when the attempts run out it is accepted rather than fatal, the flag is recorded against the operation that produced it, and if the program never reads it the run says so on standard error instead of going quiet. It also does not travel: a flagged value fails at the next typed boundary it tries to cross, so warn widens what a producer may return without widening what a consumer must accept. → @severity
2 · The parser holds the non-determinism boundary
io fn save(path: string, content: string) -> string {
return io.writeText(path, content)
}
agent Analyst {
fn analyze(text: string) -> Analysis {
ask "Analyze: {text}" -> Analysis
}
}pure fn total(items: LineItem[]) -> float {
s = ask "Sum these: {items}" -> Sum
return s.value
}
// 'ask' cannot be used inside a pure fn.
schema Invoice {
total: float
where io.exists("@x.json")
}
// where is deterministic and cannot use IO.Whether a function reaches a model, and whether it performs IO, is visible from its body — and that separation is a parse-time rule, not a convention. This is why schema validation is always deterministic: the same data always produces the same result.
3 · Side effects are part of the type
tool db_write(request_id: string, value: string) -> DbResult {
side_effects: "write"
scope: "db:write"
credential: "DB_PASSWORD"
idempotent: true
idempotency_key: request_id
}
agent Writer {
tools: [db_write]
tool_scopes: ["db:write"]
tool_policy: "approval"
}"The model accidentally wrote to the database" is not something code review has to catch here — it is something the runtime refuses. Permission, credentials, effect class and replay safety all live in the contract itself.
4 · Observability is not added later
A single --trace-file flag gives you, without a line of instrumentation: every provider request (model, duration, tokens, cost, correlation IDs), every tool execution (cache hit, timeout, idempotency) and every validation rule — including the ones that passed.
jq -r '.events[] | select(.type=="validation" and .ok==false)
| "\(.name) [\(.metadata.rule)] \(.error)"' trace.jsonYou read why an extraction failed instead of guessing. → Telemetry & Tracing
5 · The type system does not stop at one shape
A schema takes type parameters, and a union can be resolved by a tag instead of by trial. Both exist for the same reason: at the model boundary, a shape you had to write twice is a shape that can disagree with itself, and a branch chosen by guessing is a wrong answer reported as a correct one.
schema Invoice {
invoice_number: string
total: float
}
schema Receipt {
reference: string
total: float
}
schema Document = Invoice | Receipt tagged by kind
schema Page<T> {
items: T[]
count: int
}
agent Desk {
fn list() -> Page<Document> {
ask "List the documents in this batch." -> Page<Document>
}
}Page<T> is written once and expanded while the file is parsed: Page<Document> becomes an ordinary schema named Page_Document, and no genericity whatsoever reaches the provider — the request carries a plain object schema. Pair<A, B>, Box<int> and Page<Envelope<LineItem>> work the same way, each instantiation expanded once under a name built from its arguments.
tagged by kind is the part that changes answers rather than ergonomics. Invoice and Receipt above both carry total, so a value holding only a total validates against both. An untagged Invoice | Receipt tries members in order, the first fit wins, and the run reports success — the value became an invoice because of the order the members were typed in. Under a tag, the kind field names exactly one member and nothing is tried. → Type system
There is no hidden planner, no automatic "ReAct loop", no layer chaining prompts on your behalf. What it offers is a deterministic skeleton: where the model is called, where deterministic code runs, and which tool may run when are all visible by reading the source. Surprises move from runtime to parse time.
A complete Luma program
The file below is an entire runnable program — no framework to import, no class to set up, no glue code to write.
input text: string
schema Analysis @desc("Structured analysis of a piece of text") {
summary: string @length(20, 400)
sentiment: enum["positive", "neutral", "negative"]
tags: string[] @minItems(1) @maxItems(5) @unique
confidence: float [0..1]
}
model Primary {
provider: "openai-compatible"
base_url: "https://gateway.example/v1"
model: "text-model-l"
api_key: getEnv("api_key")
capabilities: ["json_schema"]
}
agent Analyst {
model: Primary
role: "Be brief, neutral, and rely only on the text you are given."
temperature: 0
retry: 2
fn analyze(text: string) -> Analysis {
ask "Analyze the following text:\n\n{text}" -> Analysis
}
}
analyst = Analyst()
result = analyst.analyze(text){
"summary": "The delivery was two days late, but the support team resolved it quickly.",
"sentiment": "neutral",
"tags": ["shipping", "delay", "support"],
"confidence": 0.82
}What runs in this single file:
- The
Analysisschema was converted to JSON Schema and sent to the gateway withstrict: true. - The response was validated locally: the length of
summary, enum membership ofsentiment,minItems/maxItems/uniquefortags, and the[0..1]range forconfidence— and a field not present in the schema would have been an error too. - Had validation failed, the error text would have been appended to the prompt and retried up to two more times (
retry: 2). - The API key lives in the environment, not the code; nothing but
getEnvcan reach it, and it never leaks into traces, prompts or the cache. - The model call, its duration, its token usage and every validation rule were recorded as telemetry events.
The result: when you write result.confidence you know that value really is a float between 0 and 1 — because if it were not, the program would never have reached that line.
Execution model
The path a .luma file takes when it runs:
.luma source
│
├─ comment strip → import resolution → block extraction
├─ schema / model / run_policy / tool / agent / pure fn parse
├─ static analysis (unreachable code, unknown schema field,
│ dependency cycle, infinite loop, unused variable)
↓
program model
│
├─ model credential resolution (getEnv)
├─ tool registry + tool module loading
├─ capability probe (GET /models → json_schema, tool_calling…)
↓
operation execution (in source order)
│
├── deterministic steps assign · map/filter/reduce · if/switch/for/while
├── direct tool tool calculate({...}) → policy → validate → run
├── ask schema → JSON Schema → provider adapter
│ └── native tool loop tool_calls → run → send result back → 2nd turn
├── run policy fallback · parallel · race · vote · fusion · combo · escalate
├── parallel blocks fan-out → fan-in (nested/flat) → merge
├── query / command bridge → row limit → schema validation per row
├── parallel over one body per element → input-order result store
├── http / state subprocess request · atomic key/value write
└── a telemetry event at every step
↓
stdout JSON + trace.json / trace.jsonl / otel.jsonCapability map
Type system
primitives · enum · array · tuple · map<T> · json<T> · union · nullable · nested schema references · array-length and numeric-range shorthands.
Schemas & validation
17 field annotations, where invariants, when/else when/else conditional rules, conditional defaults, schema composition and deterministic reflection.
Agents & orchestration
Deterministic / LLM / mixed methods, 30 configuration keys, dependencies (uses), 4 memory scopes, parallel fan-out/fan-in, token and cost budgets.
Tool system
Typed tool contracts, direct calls and native tool calling, scope/credential/approval policies, retry & backoff, idempotency store, cache, circuit breaker, strict sandbox.
◉Models & providers
Model profiles, OpenAI-compatible / Anthropic / Gemini / Azure adapters, capability probe, response cache, streaming, reasoning, multimodal and 8 run-policy strategies.
∑Standard library
math · string · date · collections · regex · numeric · json · schema · agent · io — all deterministic pure fns, callable from where and @valid.
IO & multimodal
File access confined to the project root, naturally sorted listFiles, image/PDF marker generation and provider-specific content mapping.
Telemetry
llm · tool · agent · validation · http · query · command · parallel_over events, correlation ID chain, token/cost metrics, JSON · JSONL · OpenTelemetry export.
Data & integration
Typed datasource/query/command contracts with SQL injection closed in the grammar, std/http, incremental std/state, parallel over data fan-out and the row/byte ceilings.
Design principles
| Principle | What it means in practice |
|---|---|
| The schema is the single source of truth | One definition drives JSON Schema generation, local validation, retry feedback and TypeScript tool types. It is never restated somewhere else. |
| The provider is not trusted | Even when a strict: true JSON Schema is sent, the returned response is validated from scratch locally. An unknown field is an error too. |
| What is deterministic stays deterministic | pure fn and where cannot use the LLM, tools, IO or external state — the parser blocks this at parse time. |
Secrets enter only through getEnv | api_key: getEnv("api_key") is the only way. The key is never written into prompts, schema metadata, cache keys or traces, and it is redacted from error messages. |
| Side effects are declared | Tools declare themselves with side_effects, scope, credential and needs; the agent constrains them with tool_policy. |
| Every behavior has a test | npm test runs 550 tests: demo programs, invalid-syntax scenarios, tool/runtime behavior and the provider contract through a mock gateway. |
Writing conventions
schema InvoiceLine {
unit_price: float
quantity: int
}
agent Extractor {
fn extract(markdown: string) -> InvoiceLine {
ask "..." -> InvoiceLine
}
}Schema invoiceLine { // keywords are not capitalized
Unit_Price: Float // built-in type names are lowercase
}
Agent extractor { // declaration names start with a capital
Fn Extract() -> ... {}
}Keywords are always lowercase (schema, agent, tool, fn, ask); declaration names start with a capital letter (Invoice, Analyst); built-in types are lowercase (string, int, float, bool).
Where to go next
I want to learn the language
Statement grammar, the expression language, control flow, input/context and the import system.
I need data extraction
Annotation reference, where, conditional rules and the validation order.
I want to wire up my own tool
Tool contracts, the registry, module loading, security policies and idempotency.
I process documents or images
The multi-page invoice pipeline: PNG → Markdown → typed extraction.
I need resilience
Fallback, race, vote, fusion, combo and cost-aware escalate run policies; the capability probe.
I am getting an error
25 normalized error codes, validation messages and a diagnosis table.
I am building a pipeline step
Typed database contracts, HTTP, incremental runs with a watermark, parallel over and the volume ceilings.