Luma public docs
Türkçe

Language

Language Reference

Luma's surface syntax is kept narrow — all of it fits on this page. That is not a shortcoming but a scope decision: the language was designed not for general-purpose computation but for describing the model boundary. In return, the grammar is line-based and comparison operators are not valid everywhere; those are the two things people trip over most often, so they are covered first.

Program structure

A .luma file consists of top-level declarations and operations. The order of the declarations does not matter; operations run in the order they appear in the source.

DeclarationFormPurpose
importimport name from "std/math"Loads a pure module.
inputinput topic: stringDeclares a CLI input (string or file).
contextcontext b = file("docs/b.md")Binds file content under a source ID.
schemaschema Name { … }Defines a data contract. → Schema
schema … = A + Bschema Admin = User + AuditSchema composition.
modelmodel Primary { … }Model/provider profile. → Model
run_policyrun_policy R { … }Multi-model execution strategy.
tooltool name(p: t) -> Out { … }Tool contract. → Tool
pure fn / io fnpure fn f(x: int) -> int { … }Deterministic function.
agentagent Name { fn m() -> S { … } }A unit that carries methods. → Agent
datasourcedatasource Name { … }A database connection contract. → Data
queryquery Name(p: t) -> Row[] { … }A typed read against a datasource. → Data
commandcommand Name(p: t) -> int { … }A typed write; it must declare side_effects. → Data

Operations (top level, run in order)

luma
x = ask "..." -> Schema [retry 2]      // direct LLM call
y = tool calculate({ a: 1, b: 2 })     // direct tool call
inst = AgentName(constructorArg)       // agent instance
z = inst.method(arg1, arg2)            // agent method call
p = parallel [options] { ... }         // fan-out / fan-in block
rows = query Orders(since)             // datasource read       (v0.4.0)
n = command MarkExported(since, tag)   // datasource write      (v0.4.0)
out = parallel over rows as row limit 4 -> Out[] {   // per-element fan-out (v0.4.0)
  return { id: row.id }
}

A program must contain at least one operation, and any of the forms above counts — a query on its own is enough. The message you get when there is none still names only the original three (ask, agent call or parallel not found.), which is a wording that predates the data forms rather than a rule about them. The result of the last operation is printed as the output; --all prints the result of every operation, keyed by the variable it was assigned to. Plain assignments (x = 42) are not operations and do not appear in either output.

parallel and parallel over are two different operations that share a word. parallel { … } fans out over branches you wrote down — a fixed set of named steps — and it may also be written inside an agent method. parallel over rows as row limit 4 -> Out[] { … } fans out over data: one body evaluated per element, a limit that is not optional, and top level only. Its own rules — input ordering, on_error, the memory-scope trap — are on the Data & Integration page.

The top level takes declarations and operations — nothing else

At the top level a line must be a declaration (import, input, context, schema, model, run_policy, tool, pure fn, io fn, agent, datasource, query, command) or an operation (ask, a tool call, an agent instance, an agent method call, parallel, parallel over, a query call, a command call). Anything else is rejected at parse time, before the program starts.

The constraint also runs the other way, and that direction is the one people do not expect: query, command and parallel over are top-level only. Put one inside an agent method or a fn body and it is refused by name — Error: 'query' can only be used as a top-level operation, not in an agent method or fn body. → the reason

The statements below are method and fn body constructs, so writing one at the top level gets its own message naming the construct:

Error: 'filter' can only be used in an agent method or fn body (line 2).

map · filter · reduce · if · while · switch · try · for

Anything else that matches no rule — including a plain assignment such as n = 42, which the top level has never supported — reports the line verbatim:

Error: Unrecognized top-level line (line 1): n = 42

The short samples on this page show statements on their own for readability; most of them belong inside an agent method or a pure fn.

The grammar is line-based

One statement per line — but a statement may be continued

Luma parses the source line by line with regular expressions, so putting two statements on the same line is a syntax error, and the same rule applies to schema fields: one field per line. A single statement, however, may be wrapped — a line is joined to the one above it when it begins with a continuation token.

✓ Correct
schema Result {
  count: int
  total: float
}

pure fn f() -> Result {
  a = 1
  b = 2
  return { count: a, total: b }
}
✗ Wrong
schema Result {
  count: int, total: float
}
// A schema line may define only one field: count: int, total: float

pure fn f() -> Result {
  a = 1  b = 2
  return {
    count: a
  }
}

A line counts as a continuation of the line before it when it begins with @, or with a binary operator, a comma or a dot — that is, one of || · && · === · !== · <= · >= · == · != · + · - · * · / · < · > · , · .. Note that => is not in that set. The @ form is what lets long annotation chains be split:

luma
schema Answer {
  summary: string
    @desc("A summary based only on the given document")
    @length(20, 400)
  confidence: float [0..1]
}
where and when wrap at operators, not at keywords

A long where or when expression can be wrapped, as long as each continuation line starts with an operator. Breaking before a keyword does not work:

// ✓ valid — the continuation line starts with ||
where a == null || b == null
   || num.approximatelyEqual(a * b, t, 0.01)
  @feedback("does not match the a * b total")

// ✗ invalid — 'require' is a keyword, not a continuation token
when kind == "company"
  require [tax_id]

The invalid form fails with Error: Invalid schema field: when kind == "company". If the expression really is getting long, move the logic into a pure fn → the where section.

This class of mistake has a tool: --fmt

Indentation, field spacing and where a continuation line sits are precisely what the built-in formatter normalises, so the layout above is something you can have applied rather than something you have to hit by hand. --fmt writes the canonical rendering to standard output and leaves the file alone; --fmt --write rewrites the file in place and reports Formatted: program.luma, or Already formatted: program.luma when there was nothing to change. --write on its own is refused with Error: --write only applies to --fmt; add --fmt to rewrite the file in place.

luma program.luma --fmt           # print the canonical form
luma program.luma --fmt --write   # rewrite the file in place

The rules it applies, all of them layout:

  • Two spaces per block level. A line that opens by closing braces — }, } else { — lines up with its header rather than with the body it ends.
  • A continuation line — an @ annotation, or a line opening with a binary operator, a comma or a dot — is indented one level under the line it continues. That is the same set of tokens the parser uses to decide the line is a continuation at all, so the indentation ends up showing you what the parser sees.
  • Fields, config entries and typed locals become name: type: no space before the colon, exactly one after it. Column alignment is not preserved — score : float and model: Primary both collapse to a single space.
  • A block header ends in one space and a {.
  • Trailing whitespace goes, a run of blank lines collapses to one, and the file ends with exactly one newline.
lumabefore — indentation, field spacing and continuations all wrong at once
// Grading schema
schema Answer   {
      title:string
   @desc("A short title")
  score  : float
 where score > 0
      && score < 100
}


agent Grader   {
      // one method
    fn grade() -> Answer {
result: Answer = { title: "ok", score: 10 }
      return result
  }
}
grader = Grader()
result = grader.grade()
lumaafter — luma grader.luma --fmt
// Grading schema
schema Answer {
  title: string
    @desc("A short title")
  score: float
  where score > 0
    && score < 100
}

agent Grader {
  // one method
  fn grade() -> Answer {
    result: Answer = { title: "ok", score: 10 }
    return result
  }
}
grader = Grader()
result = grader.grade()

What it never does is change what the program means: it does not join or split a logical line, does not reorder anything, and does not touch the contents of a string literal or a comment. So it cannot repair the two-statements-on-one-line or the break-before-a-keyword mistakes above — those stay syntax errors, and you still need the rules on this page to read code that has not been through the formatter.

Every .luma file in this repository is already in this form, so formatting them changes nothing. That makes them a dependable reference for what canonical Luma looks like — and it makes the formatter usable as a check in CI → the test strategy.

input — values that come from outside

luma
input topic: string
input document: file
TypeHow it is passedIn the program
string--topic "delivery time" or --topic=deliveryThe value becomes a variable directly.
file--document docs/contract.mdThe file content is read and its name is registered as a context source; it can be used in sources validation and in the contexts: [document] allowlist.

The path is resolved relative to the folder that contains the .luma file. If a declared input is not supplied, the program stops with Input missing: --document.

context — binding a file as a source

luma
context contract = file("docs/contract.md")
context spec     = file("docs/spec.md")

result = ask "Rely only on these documents:\n\n{contract}\n\n{spec}" -> Answer

context binds file content into the program and makes its name a source ID. If your schema has a sources: string[] field — the name is fixed, the runtime recognises sources and nothing else — then every value the model writes there must be one of the defined context names; this is the direct way to stop the model from inventing sources. To narrow access per agent, use the contexts: allowlist.

Comments

luma
// Line comment — a // inside a string literal is not affected
url = "https://example.com/v1"   // this is a comment, the one above is not

There is only the // line comment; there is no block comment (/* */).

Variables and assignment

luma
number   = 42
text     = "Luma"
flag     = true
empty    = null
list     = [1, 2, 3]
object   = { name: "Ada", active: true }
field    = object.name

Typed local variables

If you add a type to an assignment, the value is validated at the moment it is assigned and that type sticks to the variable — later assignments are checked against the same type.

luma
request: CalcInput  = { a: 4, b: 6, operation: "add" }
result: CalcResult = tool calculate(request)
items: LineItem[] = [{ amount: 10 }, { amount: 5 }]
The type syntax is limited here

A local variable annotation accepts only the form Name or Name[]. Composite types such as map<T>, unions or tuples cannot be written on a local variable — those are defined on schema fields. If you give no type, the value is carried without being validated and is checked only where it is used (a tool input, a method return).

String interpolation

Inside prompt and role texts you can write {variable} and a dotted path of any depth — {variable.field}, {pair.left.name}:

luma
ask "Summarize this topic: {topic}\n\nDocument:\n{document}" -> Summary
ask "Previous finding: {analysis.finding} (confidence {analysis.confidence})" -> Report
  • If the value is a string it is written directly; otherwise it is turned into JSON indented by two spaces with JSON.stringify(value, null, 2).
  • A reference to an undefined variable produces the error 'x' variable is not defined yet., and a path whose tail does not exist produces Field 'x.y' was not found.
  • Text between braces that is not a valid reference — { }, {1x}, {a.} — is left in the prompt exactly as written, with no error. That is deliberate: a prompt that shows the model a JSON example is full of braces, and treating them as broken placeholders would make those prompts unwritable.
  • If the agent has a contexts: [...] list, interpolating a context name that is not on the list produces the error Agent context access was denied: x.

Interpolation works only in prompt/role strings — in a string literal of an ordinary assignment {x} is plain text. Use + for concatenation there.

The expression language

Operators and precedence

PrecedenceOperatorNotes
1 (highest)( )Grouping.
2fn(...) · .field · [i]Function call, field access and indexing. They chain on a call result — see below.
3!Logical not (prefix).
4* /Left-associative.
5+ -Left-associative. + also performs string concatenation.
6== === != !== < <= > >=Valid in every expression context. Ordering comparisons require both sides to be numbers.
7&&Logical and. Binds tighter than ||. Short-circuits from left to right.
8||Logical or. Short-circuits from left to right.
9 (lowest)??Null-coalescing. v0.10.0. Cannot be mixed with &&/|| without parentheses.

Reading a call result directly

A call is an expression, so what it returns can be read on the spot — no intermediate variable required:

luma
flag  = mk(1).flag              // field
deep  = mk(1).inner.deep       // chained
first = mk(1).arr[0]           // field, then index
pick  = mk(1).arr[idx(0)]      // the index may itself be a call
name  = schema.describe(Doc).name   // a standard-library call too

This is also valid inside where, when and @valid — including the bare boolean form @valid(v => chk(v).ok). Until v0.11.0 only indexing worked on a call result; field access reported the call as an undefined variable, and the standard-library form blamed the module itself.

Two shapes stay out

Calling a method on the result — f(x).method(...) — is not supported; in @valid it is refused at parse time with @valid expression is not supported: ….

A dependency call is still a step, not an expression. dep.m().field does not become legal here; it keeps its own message, because a dependency method may reach a model and the expression evaluator is synchronous:

Error: Dependency call 'd.m' must be its own step — write 'result = d.m(...)' first, then use 'result'; a dependency method may reach a model, so it cannot run inside an expression.

A field that is not there stays an error rather than quietly becoming null — mk(1).yok reports Field 'mk(1).yok' was not found., the same rule a variable follows. Indexing an object with a number keeps its own type error: mk(1) key must be a string, got int.

Comparison is valid in every expression context

There is a single evaluator: assignment, the body of map/filter/reduce, if/while, switch case > n, where, when and @valid all share the same precedence rules.

&& and || are not the same level — && binds tighter, as it does in most languages. The witness is the only arrangement that tells the two readings apart:

true || false && false     // true  — reads as  true || (false && false)
                           // were they equal: (true || false) && false = false

?? sits below both, and mixing it with either one without parentheses is refused by name rather than silently resolved — the reading of a ?? b || c is genuinely ambiguous to a human, so the language asks you to say which you meant:

Error: '??' cannot be mixed with '&&' or '||' without parentheses: n ?? b || b

Both parenthesised forms are legal: (n ?? b) || b and b || (n ?? b).

b = a > 3
positive = filter item in items => item.amount > 0
actives  = map item in items => item.active && item.amount > 0

&& and || short-circuit from left to right; on nullable fields, the pattern of writing the "skip when there is no data" condition first relies on this behavior.

?? — a default for null

a ?? b evaluates to a when a is not null, and to b when it is. The right-hand side is evaluated only when it is needed, exactly like && and ||.

luma
amount = raw_amount ?? 0.0           // a nullable field, defaulted
label  = (title ?? subtitle) ?? "-"  // chains left to right
total  = reduce item in items with 0 => acc + (item.amount ?? 0)

It exists because || cannot do this job: || is strictly boolean in Luma, so value || 5 on a null fails with Boolean if condition expected: value rather than defaulting. Nullable fields are the normal case in extraction — T | null is how a schema lets a model say "I did not find it" — so the default is worth an operator.

It is about a null VALUE, not a missing key

?? does not rescue a field that is not there. Reading an undefined field is its own error and stays one:

Error: Field 'm.missing' was not found.

That refusal is deliberate — a typo in a field name should not quietly turn into a default. ?? answers "the value came back null", which is a different question from "the key does not exist".

Valid wherever an expression is: assignment, a pure fn body, an agent method, inside an object or list literal, where, when, @valid, map/filter/reduce, if/while. That is the same "single evaluator" promise this section rests on.

Astral characters — emoji, CJK extensions — work in every text position

Text is stored and compared as UTF-16, and since v0.11.1 a non-BMP character survives every text position: string literals, @desc and @prompt arguments, enum values, test literals, and literals inside where/@valid. Comments always worked.

Until v0.11.1 a single pasted emoji desynchronised the parser and the error surfaced somewhere else — inside a schema block as Parser block heading could not be restored. with no line at all, in a method body as an Unrecognized top-level line pointing at a later line. Copying a sentence out of a document was enough to trigger it.

It travels intact end to end: the description that reaches the provider, the --fmt output, and the transpiled artifact all carry the same code points.

Identifiers are the exception. A schema, field, function or variable name may not contain an astral character; the refusal is noisy, not silent — Out😀 as a schema name fails at parse time.

! precedence and the boolean requirement

The unary ! operator binds more tightly than && and ||: the expression !a && b means (!a) && b. If you want !(a && b), write the parentheses explicitly.

! also works on booleans only; give it a number or a string and you get the error Boolean if condition expected: ….

You can still use a pure fn to keep a complex predicate readable:

✓ Simple condition: write it inline
positive = filter item in items => item.amount > 0

large = filter item in items => item.amount > 100
  && item.active
✗ Complex condition: move it into a function
pure fn isEligible(item: LineItem) -> bool {
  if item.amount == null {
    return false
  }
  return item.amount > 100 && item.active
}

eligible = filter item in items => isEligible(item)

Literals

TypeExampleNote
Number42 · 3.14 · -7All are JS numbers; the int check happens during type validation.
String"text"Double quotes only. The escapes \n, \t, \\, \" are valid.
Booltrue · false
Nullnull
Array[1, 2, 3]Elements may be expressions.
Object{ name: "Ada", n: 1 }Keys may also be written quoted: { "name": "Ada" }.
Schema referenceInvoiceWhen a schema name appears in an expression it turns into a reference that can be passed to std/schema functions.

String concatenation

luma
root  = "@" + folder             // "@0213"
label = "count: " + length(list)  // the number is appended as a string automatically
path  = root + "/ext.json"

The + operator carries JavaScript semantics: if either side is a string, the result is a string.

Functions that need no import

The 11 functions below are part of the language and can be called anywhere (an agent method, a pure fn, where, @valid) without an import. Everything beyond them comes through the standard library.

The standard library's host implementations are also reachable unprefixed

Those 11 are the language's own. Behind them the runtime also carries the host implementations that the standard library wraps — numericRound for num.round, httpGet for http.get, numericApproximatelyEqual for num.approximatelyEqual, and so on for every module. They resolve without an import too, and since v0.9.0 that is official rather than an accident (DECISIONS.md D35): a surface that works is either supported or unreachable, and this one works.

Supported does not mean preferred. The module form is the spelling this documentation uses, the one that says which library a call came from, and the one that survives a module being renamed. Reach for the bare name when you have a reason — a where rule that would otherwise need an import for a single call — not by default.

length(value: string | any[]) → int

The length of a string in UTF-16 units, or the element count of an array. Any other type is an error. An astral character — an emoji, a CJK extension ideograph — is two units, so length("😀") is 2; @length rules measure with the same unit.

n = length("Luma")      // 4
m = length([1, 2, 3])   // 3
contains(value: string | any[], search: any) → bool

A substring search for a string, an element search for an array. In an array the comparison is reference/primitive equality.

contains("Ada Lovelace", "Ada")     // true
contains(["vip", "active"], "vip")  // true
upper(value: string) → string · lower(value: string) → string

Converts to upper or lower case.

trim(value: string) → string

Removes leading and trailing whitespace only. Whitespace in the middle is left alone.

trim("  Ada  Lovelace  ")   // "Ada  Lovelace"
startsWith(value: string, prefix: string) → bool · endsWith(value: string, suffix: string) → bool

Prefix / suffix check.

replace(value: string, search: string, replacement: string) → string

Replaces all matches (plain text, not a regex).

replace("a-b-c", "-", "/")   // "a/b/c"
join(values: string[], separator: string) → string

Joins a string array. Every element of the array must be a string.

unique(values: any[]) → any[]

Drops duplicates. The comparison goes through JSON.stringify, which means objects are compared deeply as well.

flatten(values: any[]) → any[]

Flattens nested arrays completely (unbounded depth).

flatten([["a"], ["b", ["c"]]])   // ["a", "b", "c"]

Control flow

if / else

luma
if stats.words > 3 {
  bonus = tool calculate({ a: stats.words, b: 2, operation: "multiply" })
} else if stats.words > 0 {
  bonus = tool calculate({ a: stats.words, b: 1, operation: "add" })
} else {
  bonus = tool calculate({ a: 0, b: 0, operation: "add" })
}

The condition must produce a boolean; in numeric comparisons both sides must be numbers, otherwise you get the error Numeric if comparison expected.

switch

luma
switch counter {
  case 1 {
    result = tool calculate({ a: 1, b: 0, operation: "add" })
    return result
  }
  case > 1 {
    result = tool calculate({ a: 0, b: 0, operation: "add" })
    return result
  }
  case _ {
    return { result: 0 }
  }
}
case formMeaning
case 1 · case "text"Value equality (===).
case > 10A case that begins with an operator is evaluated as a comparison against the switch expression. < <= > >= == != can be used.
case _A wildcard that always matches; the first match wins.
default { … }Runs when no case matches.

A switch must contain at least one case or default. Cases are tried in order, the first match runs, and there is no fall-through.

match

match branches on the runtime tag of a tagged union value. Each case names one member of the union and binds the value under a fresh name; inside that branch the name carries the member's own concrete type, so its fields are reached directly and are not nullable.

luma
schema Invoice {
  invoice_number: string
}

schema Receipt {
  reference: string
}

schema Document = Invoice | Receipt tagged by kind

agent Router {
  fn route(document: Document) -> Result {
    match document {
      case Invoice invoice {
        return { value: invoice.invoice_number }
      }
      case Receipt receipt {
        return { value: receipt.reference }
      }
    }
  }
}
FormRule
match value { … }value must carry a tagged union type the parser can see — a method parameter, a typed local or an ask result, for instance. A value whose type it cannot see: Match expression 'plain' requires a tagged union type.
case Member name { … }Runs when the tag equals Member, binding the value as name with that member's concrete type. Reaching a field another member owns is caught before the run: Router.route: schema field 'invoice.reference' was not found.
ExhaustiveEvery member needs its own case; a repeated one is refused with Match case 'Invoice' is duplicated.
No defaultThere is no catch-all branch — that is exactly what gives exhaustiveness its meaning.

A missing member, a default, and a case naming a schema from outside the union are all rejected at parse time:

Error: Match for tagged union 'Document' must handle member 'Receipt'.
Error: Match does not allow a default branch.
Error: Match case 'Result' is not a member of tagged union 'Document'.

What the parser can and cannot see

"A type the parser can see" is the whole rule, and it is worth being concrete about. A field path resolves at any depth as long as every hop names a declared field, so match extraction.data and match envelope.inner.data both work with no intermediate local. A for-loop binding resolves too — the element type of the collection is carried into the binding — which is what makes routing a list of documents an ordinary loop:

luma
for document in batch.documents {
  match document {
    case Invoice invoice { … }
    case Receipt receipt { … }
  }
}

What the parser refuses is an expression it cannot resolve to a union at all — a field that is not a union, or a value typed any. Then you get Match expression 'x' requires a tagged union type., and the fix is to give the value a type the parser can read rather than to restructure the loop.

Two ways to fold a list of union values

A match branch is an ordinary block: it may assign, and the assignment survives the branch, so accumulating across a list is written the way you would write it anywhere else. Only the bound member variable (invoice, receipt) is scoped to its branch and is gone afterwards.

luma
agent Desk {
  fn tally(documents: Document[]) -> Tally {
    invoices = 0
    receipts = 0
    for document in documents {
      match document {
        case Invoice invoice { invoices = invoices + 1 }
        case Receipt receipt { receipts = receipts + 1 }
      }
    }
    return { invoices: invoices, receipts: receipts }
  }
}

The other way is a pure fn, which is allowed to open a union directly: inside one, the tag and the member fields are ordinary field reads, with no match and no exhaustiveness check.

luma
pure fn label(document: Document) -> string {
  if document.kind == "Invoice" {
    return document.invoice_number
  }
  return document.reference
}

agent Desk {
  fn labels(documents: Document[]) -> Labels {
    found = map document in documents => label(document)
    return { values: found }
  }
}

The same document.kind written in an agent method is refused — Desk.labels: schema field 'document.kind' was not found. — because a union schema has no fields of its own. So the choice is about what you want checked: match in an agent method is verified exhaustive, and adding a member to the union breaks every match until you handle it. A pure fn is not checked, which is what lets it slot into map and reduce — and what means a new member will quietly fall through to its last branch. Reach for the loop when the set of members matters, and for the pure fn when the shape of the pipeline does.

switch produces, match consumes

The two look alike and are not interchangeable. switch branches on a value you already hold — a string, a number, a field — and is how you produce one result out of several possibilities. match branches on the tag a union value carries and is how you consume one; it is the only construct that opens a union into its concrete member.

So a method that returns a tagged union normally uses switch inside, and whoever receives the result uses match on it. → Union return type

for / while / break / continue

luma
for page in pages {
  files  = io.readFiles([page])
  part   = ask "Convert this page to Markdown: {files}" -> Page
  markdown = markdown + part.text + "\n\n"
}

i = 0
while i < 3 {
  i = i + 1
  if i == 2 {
    continue
  }
  if i > 2 {
    break
  }
}
ConstraintValue
for sourceMust be an array; if it is not, the error the for source must be an array.
Agent while limit100 iterations by default; changed with loop_limit. If it is exceeded, Agent loop limit exceeded.
pure fn while limitA fixed 1000 iterations.
break / continueOnly inside a loop. Used outside one, they are rejected at parse time.
while trueIf it contains no break, the parser reports infinite while loop without a break.

try / catch

luma
try {
  data   = ask "Produce 1-3 numeric items." -> Data
  clean  = map item in data.items => item.value
  total  = reduce item in clean with 0 => acc + item
  result = tool calculate({ a: total, b: 0, operation: "add" })
  return result
} catch error {
  fallback = tool calculate({ a: 0, b: 0, operation: "add" })
  return fallback
}

catch is mandatory (try cannot be used on its own). The caught variable is a structured error object:

jsoncontents of the error variable
{ "code": "TOOL_INPUT_INVALID", "message": "calculate.input.a must be a number.", "retryable": false }

Tool and model errors carry code / message / retryable (and details when there is one); other errors contain only message. These fields are reached with std/agent or std/json:

luma
import agent from "std/agent"

} catch error {
  code = agent.errorCode(error)
  if agent.isRetryable(error) {
    ...
  }
}
return and flow signals

return, break and continue inside a try block are not caught — they propagate outward normally. catch catches real errors only.

Indexing (v0.4.0)

Lists and maps are indexed with [] — reading and writing, including chains:

luma
first = rows[0]
name = rows[i].customer.name
value = m["key"]
buckets[key] = col.append(existing, row)   // write: mutation, same scope rules as x = v
xs[0].field = 3                            // ✗ Error: Unrecognized step in A.run (line 5): xs[0].field = 3

Every boundary is a named error — indexing never returns a silent null:

SituationBehavior
Read out of bounds (xs[5], length 2)xs[5] is out of bounds (length 2).
Write out of boundsxs[5] is out of bounds (length 2); use col.append to grow a list.
Map key absent on readm["missing"] is not present.
Map key absent on writeThe key is created — that is what a map is for.
Index of the wrong kindNamed type error stating what was got.
Indexing a non-container<path> cannot be indexed; only a list or a map supports [] access.
The silent-swallow closure

Before v0.4.0, an unrecognized assignment-shaped line in a method body — most famously m[k] = v — was silently ignored. That class is closed: m[k] = v works, and every other unrecognized assignment-shaped line (m[k] += 2, m.b = 3) is a named parse error.

Collection operations

map, filter and reduce are built-in statements of the language — not functions. They have their own syntax, and each one is a single statement — which may be wrapped across lines at an operator, like any other statement.

luma
actives = filter item in items => item.active
names   = map item in actives => upper(item.name)
total   = reduce line in lines with 0 => acc + line.amount
FormResult
x = filter i in COLLECTION => EXPRAn array of the elements for which EXPR is truthy. i is the current element on each iteration.
x = map i in COLLECTION => EXPRAn array made of the EXPR result for each element.
x = reduce i in COLLECTION with INITIAL => EXPRA single accumulated value. acc is a reserved variable and holds the accumulator.

If the source is not an array you get the error the map/filter/reduce source must be an array.

The body must be a single expression

Everything after => is read to the end of the logical line — continuation lines starting with an operator are included — and evaluated with the expression language, comparison and logical operators included. You cannot open a block; for a multi-step predicate, use a pure fn:

pure fn isLarge(line: LineItem) -> bool {
  if line.amount > 100 {
    return true
  }
  return false
}

large = filter line in lines => isLarge(line)

Chaining

luma
active  = filter item in items => item.active
names   = map item in active => upper(item.name)
count   = length(names)

Each step is assigned to a separate variable on its own line; there is no chaining syntax (.filter().map()).

These three are the whole of the collection statement surface, and that is where the language stops rather than where collections stop. Sorting, grouping, chunking, slicing, deduplication and aggregation are functions — 25 of them in std/collections, conventionally imported as col, which is the col behind the col.append the indexing section uses. So the answer to "how do I sort or group this" is not a missing statement; it is a named local and a col.* call on the next line.

pure fn and io fn

Top-level functions are deterministic helpers. They can be called in schema where rules, inside @valid and in agent methods.

luma
pure fn totalAmount(items: LineItem[]) -> float {
  total = reduce item in items with 0 => acc + item.amount
  return total
}

schema Invoice {
  items: LineItem[1..100]
  total_amount: float

  where totalAmount(items) == total_amount
}
Rulepure fnio fn
ask / tool / parallel / dependency call✗ forbidden✗ forbidden
std/io call✗ forbidden (rejected at parse time)✓ allowed
Use inside where✓✗ (where is deterministic and cannot use IO)
Use inside @valid✓✗
return required✓ at least one return✓
while limit10001000
Recursion✗ (Pure fn cycle found)✗

Parameters and validation

  • Parameters must be typed and are validated at call time.
  • Optional parameter: x?: string. If it is not supplied, its value is null.
  • The return value is validated against the declared type as well.
  • Field annotations can be added to parameters and they are really enforced at call time: pure fn f(code: string @match("^[A-Z]+$"), score: float @range(0, 10)) -> bool { … }. The same rule applies to io fn, agent method and tool parameters. One annotation is deliberately ignored here: @severity softens a rule only where a value is checked as a result. A parameter is an input, so the rule stays hard — a flagged value does not cross.

export and privacy

luma
// helpers.luma
export pure fn normalize(x: string) -> string {
  return lower(trim(x))
}

pure fn internal(x: string) -> string {   // no export → cannot be called from outside the module
  return upper(x)
}

Imports and modules

luma
import "std/math"              // unnamed: abs(...), sum(...) directly
import math from "std/math"    // namespaced: math.abs(...)
import "./helpers.luma"        // relative module inside the project
RuleBehavior
Module contentsOnly schema and pure fn/io fn. A file that contains an agent, tool, context, input or operation cannot be imported.
std/ resolutionMaps to the file luma/std/<name>.luma.
Relative resolutionRelative to the importing file; if there is no extension, .luma is appended. It cannot leave the project root.
Project rootBy default the entry file's own directory. Put { "project_root": ".." } in a luma.config.json beside the entry to pin it elsewhere — that is how a engine/ + package/ sibling layout becomes possible. Escaping the pinned root still refuses by name: the confinement moves, it does not disappear. std/io's root is a separate contract and is unaffected.
CacheThe same file is parsed once and shared.
CycleRejected: Import cycle found: a -> b -> a.
Name clashTwo different modules declaring one schema name refuse with Import schema conflict: Name. The same module arriving by two routes is not a clash — see the diamond note below.
A function without exportStays module-private; calling it from outside gives A private pure fn cannot be called from outside.
Any namespace alias works

In an agent method body, a line of the form x = name.function(...) could be either a module call or a dependency agent call. The parser tells them apart by the namespaces the file actually imports, so you can name an alias whatever you like:

import mat from "std/math"
schema S { d: float }
agent A {
  fn f() -> S {
    d = mat.abs(0 - 7)     // ✓ works — 'mat' is an imported namespace
    return { d: d }
  }
}

A name that is neither an imported namespace nor a declared dependency is still reported clearly: 'nosuch' is not defined as a dependency. The same distinction applies in a pure fn body.

A dependency call is a step, not an expression

Standard-library and pure fn calls can be written anywhere an expression can, return included. A call through a uses dependency cannot: it resolves in assignment position only.

r = reviewer.review(doc)      // this
return { amount: r.amount }

return reviewer.review(doc)   // not this
return { amount: reviewer.review(doc).amount }   // nor this

Both refused forms fail before the program starts, with the same message:

Error: Dependency call 'a.f' must be its own step — write 'result = a.f(...)' first, then use 'result'; a dependency method may reach a model, so it cannot run inside an expression.

The reason is the same one the std/http bridge pays for: the expression evaluator is synchronous end to end, while an agent method is not — its body may contain an ask and reach a model. A synchronous evaluator cannot wait for that, so a dependency call is a step the runtime sequences, not a value an expression produces. This is a contract, not a limitation waiting to be lifted.

A schema resolves its where and @valid rules — and a pure fn its whole body — with the aliases of the module that declared it, not with whatever the entry file happens to import, and that holds however deep the import chain is. A module can therefore own its own std dependencies without the program that eventually consumes it having to know about them, which is what keeps a package and the engine that loads it separable.

A schema and an exported fn both travel the whole chain

Everything exported reaches the program that eventually consumes it, however many modules sit in between:

rules.luma  (export pure fn check)  ──>  bundle.luma  ──>  your program
                                          ✓ schemas arrive here
                                          ✓ check() too

Until v0.7.0 the two were not symmetric — a function was visible only to the module that imported it directly, so a bundling module in between hid it. That asymmetry is gone: a layered package can put its rules in one module, its types in another, and re-export both through a single entry.

Two things are unchanged. A fn without export stays module-private however the chain is arranged. And a name that genuinely collides — two different modules exporting the same function name — still refuses by name: Import pure fn conflict: check. The same module arriving by two routes is not a collision, so a diamond import is fine.

Error: Unknown function 'helper': helper(1)

That is what you get for a call that resolves to nothing — a message about the function, not about a variable that happens to look like a call.

A diamond import is fine

Shared building blocks belong in their own module, which means two members that both use them import the same file — and the assembling program then receives that module twice:

belge.luma  ─┬─>  fatura.luma   ─┐
             └─>  pos_fisi.luma ─┴─>  ortak.luma  (Taraf)

The second delivery is a no-op, because a schema carries the identity of the module that declared it. Only two different modules declaring one name is a conflict, which is the case the check exists for. Without this the type-per-module layout collapses the moment members share anything — and sharing is exactly why the shared module exists.

Convention, not a requirement: the standard library page imports std modules with their canonical aliases (import math from "std/math", import str from "std/string", import col from "std/collections", import num from "std/numeric"). Every example on the standard library page uses these aliases.

Static analysis

Before the program starts running, the parser performs the checks below. Most of them are errors; one of them is a warning.

CheckResultExample message
Unreachable codeerrorA.f: unreachable code found.
break/continue outside a looperrorA.f: break can only be used inside a loop.
Infinite while trueerrorA.f: infinite while loop without break.
Unknown schema fielderrorA.f: schema field 'x.yok' was not found.
Field line in a method bodyerrorSchema field line in A.f (line 7): 'kod: string' — field declarations belong in a schema block.
Unterminated multi-line literalerrorUnterminated multi-line literal: 'x = { a: 1' — continuation lines must start with ','.
Bare comparisonwarningWarning: result of 'xs[0] == 1' is ignored; a bare comparison has no effect.
Agent dependency cycleerrorAgent dependency cycle found: A -> B -> A
Pure fn cycleerrorPure fn cycle found: f -> g -> f
Schema composition cycleerrorSchema composition cycle found: …
Import cycleerrorImport cycle found: …
Using ask and return togethererrorAgent method f cannot use ask and return together.
Method return type ≠ terminal ask schemaerrorfn f return schema differs from the ask schema.
Non-exhaustive matcherrorMatch for tagged union 'Document' must handle member 'Receipt'.
default inside a matcherrorMatch does not allow a default branch.
A union return that not every branch coverserrorSwitch in method f requires a default branch for tagged union 'Document'.
Direct call to a tool that is not allowederrorAgent 'A' does not allow tool 'x' to be called directly.
Unused variablewarningWarning: variable 'x' is unused in A.f.

On top of these, model, run_policy, tool, agent and schema references are resolved: an undefined model, tool or agent name causes the program never to start at all.