Luma public docs
Türkçe

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.

550
passing tests
13
std modules
30
agent settings
8
run policies
18
field annotations
25
normalized error codes

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 Invoice and 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 "…" -> Invoice is 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. ask is a keyword, and it cannot appear inside a pure 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:

WhereWhat it does
On the outgoing requestThe schema is sent to the gateway as a strict response_format.json_schema, so the shape is enforced at generation time.
When the response returnsThe 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 failsThe 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.

Since v0.8.0 a validated value has three states, not two

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.

StateWhat happens
ValidThe value is used.
InvalidThe run stops, as before. This is still the default — a schema that does not mention @severity behaves exactly as it did.
Valid but flaggedThe 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

✓ The parser accepts
io fn save(path: string, content: string) -> string {
  return io.writeText(path, content)
}

agent Analyst {
  fn analyze(text: string) -> Analysis {
    ask "Analyze: {text}" -> Analysis
  }
}
✗ The parser rejects
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

luma
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.json

You 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.

luma
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

Luma is not an agent framework

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.

lumaanalysis.luma
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)
jsonoutput
{
  "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 Analysis schema was converted to JSON Schema and sent to the gateway with strict: true.
  • The response was validated locally: the length of summary, enum membership of sentiment, minItems/maxItems/unique for tags, and the [0..1] range for confidence — 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 getEnv can 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.json

Capability 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

PrincipleWhat it means in practice
The schema is the single source of truthOne definition drives JSON Schema generation, local validation, retry feedback and TypeScript tool types. It is never restated somewhere else.
The provider is not trustedEven 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 deterministicpure fn and where cannot use the LLM, tools, IO or external state — the parser blocks this at parse time.
Secrets enter only through getEnvapi_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 declaredTools declare themselves with side_effects, scope, credential and needs; the agent constrains them with tool_policy.
Every behavior has a testnpm test runs 550 tests: demo programs, invalid-syntax scenarios, tool/runtime behavior and the provider contract through a mock gateway.

Writing conventions

✓ Correct
schema InvoiceLine {
  unit_price: float
  quantity: int
}

agent Extractor {
  fn extract(markdown: string) -> InvoiceLine {
    ask "..." -> InvoiceLine
  }
}
✗ Wrong
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