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.
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.
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"
}rows = query Orders("2026-01-01T00:00:00Z")
exported = command MarkExported("2026-01-01T00:00:00Z", "batch-1")Declaration fields
| Field | Where | Default | Meaning |
|---|---|---|---|
credential | datasource | none | Resolved 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_max | datasource | 1 | A real concurrency semaphore, not a decoration. At the default of 1, queries against one datasource serialize — including under parallel over. Raise it deliberately. |
timeout | datasource | mandatory | Milliseconds. There is no default because a silent unbounded wait is not a sensible fallback. |
retry | datasource | 0 | Reads only. A command is never retried — a write that may not be idempotent is not something the language will repeat behind your back. |
source | query · command | mandatory | Which datasource this statement belongs to. |
sql | query · command | mandatory | A single-line string. Parameters are positional $1…$n, matched to the declared parameters in order. |
side_effects: "write" | command | mandatory | A 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:
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.
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:
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.
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.
import http from "std/http"
import json from "std/json"
io fn fetchPage(url: string) -> HttpResponse {
return http.get(url, { accept: "application/json" }, 5000)
}| Behaviour | Why it is that way |
|---|---|
| A status code is not an error | 404 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 argument | There 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 job | Use 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. |
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:
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)| Rule | Detail |
|---|---|
The file is named by --state-file | There 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 atomic | Written to a temporary file and renamed. A run killed mid-write leaves the previous state intact rather than a truncated file. |
Values are always string | Storing 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/--replay | Replay 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. |
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:
classified = parallel over rows as row limit 4 -> Classified[] {
result = pipeline.classify(row)
return result
}| Rule | Detail |
|---|---|
| Results are in input order | Not 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 required | There 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 fixed | on_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_fast | on_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 nesting | parallel over inside parallel over is refused while parsing. Nested concurrency multiplies and stops being something limit can describe. |
| A fresh instance per element | Global 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. |
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.
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.
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.
| Flag | Default | What it bounds | What it does not bound |
|---|---|---|---|
--max-rows | 200000 | Rows returned by a query, and files read by io.readFiles | state.keys(), and anything you build in memory yourself |
--max-response-bytes | 10 MB | A single std/http response body | The total across a run |
--state-file | none | Where 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.
--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.
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.
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.