Luma public docs
Türkçe

Runtime

Data & Integration

A Luma program is usually one step inside a larger pipeline: something upstream schedules it, it pulls data, reshapes it, sends the hard part to a model, and writes the result back. This page is about that step — what the top-level program body can do, and the rules that shape it.

The top-level body is a straight sequence

Everything on this page — query, command, parallel over — is a top-level operation. None of them can appear inside an agent method or a fn body. The parser refuses by name:

Error: 'query' can only be used as a top-level operation, not in an agent method or fn body.
Error: Unrecognized step in Worker.go (line 9): results = parallel over rows as row limit 2 -> Out[]

This is worth stating plainly rather than discovering: you cannot query conditionally, inside a loop, or from a value you derived in a method. The data flow of a program is a flat sequence at the top level, and the branching lives in what you do with the rows after they arrive.

Why the restriction exists

An agent method is a unit the runtime can reason about: it has an owning agent, a model binding, a memory scope, a retry policy. A query has none of those — it belongs to a datasource, not to an agent. parallel over is the same story from the other side: it has no owning agent at all, so the runtime builds a synthetic one, which is also why each element gets its own fresh dependency instance.

Allowing them inside a method would mean answering questions nobody has answered yet — does a query inherit the agent's retry? its memory? does it count against the token budget? Refusing at parse time is the honest position until those have answers.

The shape this produces

A pipeline step therefore reads top to bottom, and the model work sits in the middle of it:

read the watermark      state.read("orders.since")
        │
pull rows               query Orders(since)          ← validated against Order
        │
reshape                 col.sortBy · col.groupBy · col.chunk
        │
the model               parallel over rows … { agent.classify(row) }
        │
write back              command MarkExported(since, batch)
        │
advance the watermark   state.write("orders.since", newest)

Conditional logic that would have wrapped a query goes one of two places instead: into the SQL as a parameter, or into an agent method that receives the rows. Both are usually the better design anyway — the first keeps the filtering where the index is, the second keeps the decision testable.

datasource · query · command

Luma owns the typed contract, not the driver. A database driver is an npm package, and shipping one would break the transpiler's single-file, zero-dependency promise — so the language describes the boundary and you supply roughly twenty lines of driver code.

luma
datasource Warehouse {
  credential: "PG_URL"
  pool_max: 4
  timeout: 30000
  retry: 2
}

schema Order {
  id: string
  amount: float
  created_at: string @fmt("date-time")
}

query Orders(since: string) -> Order[] {
  source: Warehouse
  sql: "select id, amount, created_at from orders where created_at > $1 order by created_at"
}

command MarkExported(since: string, batch: string) -> int {
  source: Warehouse
  sql: "update orders set export_batch = $2 where created_at > $1"
  side_effects: "write"
}
luma
rows     = query Orders("2026-01-01T00:00:00Z")
exported = command MarkExported("2026-01-01T00:00:00Z", "batch-1")

Declaration fields

FieldWhereDefaultMeaning
credentialdatasourcenoneResolved from the environment like getEnv. Never enters a prompt, a trace or a cache. Optional — a bridge that authenticates some other way simply omits it.
pool_maxdatasource1A real concurrency semaphore, not a decoration. At the default of 1, queries against one datasource serialize — including under parallel over. Raise it deliberately.
timeoutdatasourcemandatoryMilliseconds. There is no default because a silent unbounded wait is not a sensible fallback.
retrydatasource0Reads only. A command is never retried — a write that may not be idempotent is not something the language will repeat behind your back.
sourcequery · commandmandatoryWhich datasource this statement belongs to.
sqlquery · commandmandatoryA single-line string. Parameters are positional $1…$n, matched to the declared parameters in order.
side_effects: "write"commandmandatoryA command must declare it; a query may not. The declaration form and the effect cannot disagree.

The bridge is yours

A tool module exports the connections under a known name. This is the only part of the boundary Luma does not own:

ts
export const datasources = {
  Warehouse: async (sql, params, signal) => {
    const result = await pool.query(sql, params);   // pg, mysql2, better-sqlite3 — your choice
    return result.rows;
  },
};

Without one, the program stops before it runs anything: Error: datasource 'Warehouse' has no registered bridge; export it from a tool module as datasources.Warehouse.

Two properties earn their keep here

Rows are validated against the declared schema. A column that changed type upstream fails at the boundary with the row index and field named — NewOrders.row[0].amount must be a number. — instead of halfway through a transform, or worse, silently inside a model prompt.

sql cannot interpolate. Writing {since} inside it is refused while parsing: Error: query 'Orders' sql cannot interpolate; use $1 parameters. The injection surface closes in the language rather than in code review.

One row, or many

A return type of Schema[] takes whatever comes back. A bare Schema asserts exactly one row and fails otherwise, which is what you want for an aggregate:

luma
schema OrderTotal {
  counted: int
  summed: float
}

query Totals(since: string) -> OrderTotal {
  source: Warehouse
  sql: "select count(*) as counted, sum(amount) as summed from orders where created_at > $1"
}
Error: query 'Totals' expected exactly one row, got 3.
No transaction surface — deliberately

A command runs one statement at a time and the language offers no transaction declaration. Writing one is refused by name rather than ignored:

Error: Datasource 'Warehouse' does not support 'transaction'; it is deliberately out of this package — handle transactions inside the bridge (ROADMAP § U4).

Multi-statement atomicity belongs in the bridge, where the driver already has it: take the connection, open the transaction, run what you need, commit. Exposing it in Luma would mean modelling connection lifetime in the language, which the data surface deliberately does not do.

The practical consequence: if a step must be all-or-nothing, express it as one command whose SQL is a single atomic statement, or move the whole unit into the bridge.

HTTP

std/http reaches services that are not databases — an API, a webhook, an object store with an HTTP interface. The per-function reference lives on the Standard Library page; what matters here is how it behaves inside a pipeline.

luma
import http from "std/http"
import json from "std/json"

io fn fetchPage(url: string) -> HttpResponse {
  return http.get(url, { accept: "application/json" }, 5000)
}
BehaviourWhy it is that way
A status code is not an error404 and 500 return normally with the status on the response. Whether a 404 means "skip this record" or "abort the run" is a decision only the program can make. The one failure the runtime raises is not reaching the host at all.
timeout is a required argumentThere is no default, because a silent unbounded wait inside a batch job is the failure mode that takes a pipeline down at 3am.
Retry is not http's jobUse the agent's retry, or a try around the call. Layering a second retry inside the client would make the effective attempt count a product of two numbers nobody is tracking.
The body is capped--max-response-bytes, 10 MB by default, refused by name when exceeded.
Roughly 30–80 ms per call, and redaction is narrower than it looks

Cost. The expression evaluator is synchronous and there is no synchronous fetch, so every call runs in a child process. That is fine for coarse-grained work — a paginated pull, a few hundred requests — and the wrong tool for enriching every row of a large set, where the process cost dominates the run. Ten thousand per-row calls is five to thirteen minutes of pure overhead. Tracked as § U5.

Redaction. Request headers reach the trace through two filters, and the rule is about the header's name, not its sensitivity. A header whose name matches api_key · apikey · api-key · password · secret · credential · authorization (case-insensitive, substring) has its whole value replaced with [REDACTED]; separately, any secret value the runtime already knows — the api keys resolved from the environment and from model profiles — is replaced wherever it appears. Every other header is written in plaintext: cookie, x-auth-token, x-session, a signature header. Note that x-api-key is covered, because its name matches. If you carry a secret in a header the pattern does not name, it lands in the trace.

Incremental runs

A pipeline step that runs on a schedule needs to remember where it stopped. Luma does not invent a watermark abstraction, because a watermark is just a stored string:

luma
import state from "std/state"
import col   from "std/collections"

since  = state.read("orders.since")
rows   = query Orders(since)
newest = col.last(col.sortBy(rows, "created_at"))

// … process rows …

marker = state.write("orders.since", newest.created_at)
RuleDetail
The file is named by --state-fileThere is no default location. A run without the flag refuses by name rather than writing somewhere you did not choose — a silent default is silent data loss the first time two jobs share a machine.
Writes are atomicWritten to a temporary file and renamed. A run killed mid-write leaves the previous state intact rather than a truncated file.
Values are always stringStoring a number or a timestamp means converting it yourself through json or date. The store does not invent a type system.
Not part of --record/--replayReplay covers model calls. State is a real file on disk and a replayed run reads and writes it for real — isolate it per run when you compare two executions.
Advance the watermark last

Write the watermark after the work it describes has been committed, not before. If the run dies in the middle, the next run redoes a batch — which a correctly written pipeline tolerates — instead of skipping one, which nothing tolerates.

parallel over: fan-out across data

parallel fans out over declared branches — a fixed set of things you wrote down. parallel over fans out over data, one body evaluated per element:

luma
classified = parallel over rows as row limit 4 -> Classified[] {
  result = pipeline.classify(row)
  return result
}
RuleDetail
Results are in input orderNot completion order. This is the guarantee the design will not trade: the interpreter and the transpiled artifact are compared byte for byte, and a nondeterministic order would make that comparison worthless. A slow element does not move to the back.
limit is requiredThere is no default concurrency. An unbounded fan-out over a query result is an accidental denial of service against your own database or provider.
The return type must be Schema[]The body produces one Schema per element and each element result is validated on its own, so a single malformed element is named rather than corrupting the batch.
Option order is fixedon_error comes before limit: … as row on_error continue limit 4 -> Out[]. Written the other way round the header is not recognised at all, the body lines fall through to the top level, and what you get is 'return' can only be used in an agent method or fn body (line N). — a message that never mentions parallel over.
Default on error is fail_faston_error continue drops the failing element — the result list closes up rather than carrying a hole — and the skipped count is emitted as a telemetry event of type parallel_over with metadata { total, skipped }. That event fires only when something was skipped, so its absence means nothing was lost — it is the one machine-readable signal of a shortened list (see Telemetry & Tracing). on_error fallback is rejected by name: no per-element fallback value exists, and inventing one would substitute data silently.
No nestingparallel over inside parallel over is refused while parsing. Nested concurrency multiplies and stops being something limit can describe.
A fresh instance per elementGlobal agent instances referenced in the body are resolved under a per-element instance key. Under the default memory_scope: instance that gives each element its own history. It does not hold for the other scopes — see the warning below.
The body is a method body, but this is not a method

Inside the braces you may write what a method body allows — ask, tool calls, match, for, a dependency call. But parallel over itself is a top-level operation and cannot be written inside a method. It is documented here rather than on the Agents page for exactly that reason.

Non-default memory scopes break the isolation — measured

Element isolation comes from the instance key, but agent history is keyed by scope. Only the default instance scope is keyed per instance; session, shared and persistent are keyed by agent name, so every element of a parallel over reads and writes one history.

Measured against the mock gateway with a memory_scope: "session" agent over three rows, counting the history messages in each outgoing request:

limit 1   →   2, 4, 6     history is shared and grows
limit 3   →   2, 2, 2     all three elements race the same store

So the prompt actually sent to the model depends on limit — which is otherwise just a concurrency ceiling — and two runs at the same limit can send different prompts depending on scheduling. Inside parallel over, keep agents on the default memory scope. If elements genuinely need shared state, put it in std/state after the fan-out, where the write order is yours to control.

pool_max meets limit

These two interact and the interaction surprises people. limit 8 with a datasource whose pool_max is the default 1 gives you eight concurrent bodies competing for one connection: the model calls overlap, the queries do not. If the bodies query, raise pool_max to match the concurrency you actually want.

Flags and ceilings

Everything in a Luma program is a value in memory. There is no streaming and no lazy sequence, so the honest engineering answer is a documented ceiling that fails by name rather than truncating silently or growing until the process dies.

FlagDefaultWhat it boundsWhat it does not bound
--max-rows200000Rows returned by a query, and files read by io.readFilesstate.keys(), and anything you build in memory yourself
--max-response-bytes10 MBA single std/http response bodyThe total across a run
--state-filenoneWhere std/state reads and writes— required whenever state.* is called at all, including state.keys()
Error: Result set exceeded the row limit (200000). Process in batches with col.chunk, or raise --max-rows.

When a set is genuinely larger than the ceiling, the shape is a batched loop driven by the watermark: query a bounded window, process it, advance the watermark, exit. The next scheduled run picks up where this one stopped. That is the same incremental machinery from the section above, used deliberately rather than as a recovery mechanism.

The transpiled artifact carries these too

--state-file, --max-rows and --max-response-bytes all work on a transpiled program. What a data-only program loses is telemetry. The artifact carries the trace machinery only when the program has a model call or a tool call; without either it is tree-shaken out, and --trace, --trace-file, --trace-jsonl and --otel-file are all refused by name:

Transpile: telemetry is tree-shaken from this program (no model or tool calls; DECISIONS D6).

The interpreter running the same program does write a trace, and it contains the query and command events. The two paths diverge here on purpose — a named refusal is better than an artifact that silently writes a different trace than the interpreter would. See Shipping as one file.

A complete step

Everything above in the shape it actually takes. The orchestrator runs this on a schedule; the program itself has no opinion about when.

luma
import col   from "std/collections"
import state from "std/state"

schema Order {
  id: string
  amount: float
  created_at: string @fmt("date-time")
}

schema Classified {
  id: string
  band: enum["small", "large"]
}

schema Summary {
  processed: int
  advanced_to: string
}

datasource Warehouse {
  credential: "PG_URL"
  pool_max: 4
  timeout: 30000
  retry: 2
}

query Orders(since: string) -> Order[] {
  source: Warehouse
  sql: "select id, amount, created_at from orders where created_at > $1 order by created_at limit 1000"
}

command MarkExported(since: string, batch: string) -> int {
  source: Warehouse
  sql: "update orders set export_batch = $2 where created_at > $1"
  side_effects: "write"
}

agent Banding {
  fn classify(order: Order) -> Classified {
    ask "Band this order by size.\n\nORDER: {order}" -> Classified
  }
}

banding = Banding()

since      = state.read("orders.since")
rows       = query Orders(since)
ordered    = col.sortBy(rows, "created_at")
classified = parallel over ordered as order limit 4 -> Classified[] {
  return banding.classify(order)
}
exported   = command MarkExported(since, "batch-1")
newest     = col.last(ordered)
marker     = state.write("orders.since", newest.created_at)

Read it as five moves: where did I stop · what is new · reshape · the model, bounded · write back and advance. The limit 1000 in the SQL and the limit 4 on the fan-out are the two places this step declares how much of the world it will take on in one run.

What stays outside

Scheduling, DAG dependencies, backfill and retry-of-the-whole-step are not Luma's job and are not going to be. This program is one node; your orchestrator owns the graph. That boundary is why the language stayed small enough to keep its other promises — the parse-time guarantees, the single-file artifact, the byte-for-byte equality between the two execution paths.