Library
IO & Multimodal
Luma's file access is deliberately narrow: you cannot step outside the project root, paths are written root-relative with an @ prefix, and all IO belongs to the io fn class — which means schema validation can never touch the disk. On top of that sits a marker system that carries image and PDF content to the model.
The project root and @ paths
project/ ← io root (the nearest package.json lives here)
├── package.json
├── luma/
│ └── invoice.luma the program being run
├── 0213/
│ ├── page-1.png
│ └── page-2.png
└── docs/
└── document.md
"@0213/page-1.png" → project/0213/page-1.png
"@/docs/document.md" → project/docs/document.md
"docs/document.md" → project/docs/document.md (root-relative even without @)
"../secret" → rejected| Rule | Detail |
|---|---|
| How the root is found | Starting from the directory of the .luma file being run, the search walks upward; the first directory containing a package.json becomes the root. |
The @ prefix | @path and @/path are the same thing: root-relative. Paths are resolved against the root even without the prefix. |
| The boundary | Every path that leaves the root is rejected: io path cannot escape the project directory: ../x |
| Empty path | io path expects a non-empty string. |
io.listFiles returns paths with the @ prefix (they can be handed straight to the other io functions). The path field in io.readFile/readFiles results and the return value of io.writeText, by contrast, are unprefixed root-relative paths.
Reading and writing files
import io from "std/io"
agent FileWorker {
fn run() -> Result {
exists = io.exists("@docs/document.md")
text = io.readText("@docs/document.md")
mime = io.mimeType("@0213/page-1.png") // "image/png"
file = io.readFile("@0213/page-1.png")
dataUrl = io.toDataUrl(file)
written = io.writeText("@output/summary.md", text)
return { path: written }
}
}The shape of an io.readFile result
{
"path": "0213/page-1.png",
"mime_type": "image/png",
"base64": "iVBORw0KGgoAAAANSUhEUg…",
"size": 184213
}MIME mapping
| Extension | MIME |
|---|---|
.png | image/png |
.jpg · .jpeg | image/jpeg |
.webp | image/webp |
.gif | image/gif |
.svg | image/svg+xml |
.pdf | application/pdf |
.md | text/markdown |
.txt | text/plain |
.json | application/json |
| other | application/octet-stream |
io.writeText creates the intermediate directories of the target automatically — you do not have to prepare the output directory beforehand.
Listing files
pages = io.listFiles("@0213", "*.png", false)
// ["@0213/page-1.png", "@0213/page-2.png", "@0213/page-10.png"]| Parameter | Rule |
|---|---|
directory | Must be a directory; if a file is given you get io listFiles expects a directory. |
pattern | A file name glob only: * (anything) and ? (a single character). It cannot contain / or \. Matching is case-insensitive. |
recursive | When true, the walk descends into subdirectories. |
Results come back in natural order: page-2.png comes before page-10.png. In document pipelines where page order matters, this gives you the right sequence without having to zero-pad file names.
pages = io.listFiles("@0213", "*.png", false)
for page in pages {
...
}// The pattern cannot contain a path:
pages = io.listFiles("@", "0213/*.png", false)
// io listFiles pattern expects
// only a file name glob.Multimodal markers
Image and file content is embedded into the prompt as a marker. When the model profile has multimodal: true, those markers are turned into content parts.
{{image_url:<url or data URL>}}
{{file_url:<url or data URL>}}// 1) A single file, by hand
file = io.readFile("@0157.pdf")
dataUrl = io.toDataUrl(file)
text = ask "Convert this invoice to Markdown. Document: {{file_url:{dataUrl}}}" -> Transcript
// 2) Many files, automatically
files = io.readFiles(pages)
markers = io.toMultimodalMarkers(files)
text = ask "Convert all the pages to Markdown, in order: {markers}" -> TranscriptThe two forms are not interchangeable, and the rule that separates them is about memory. io.readFiles reads every path you hand it, base64-encodes each one, and returns the whole list — so all of the files are resident at the same time, at roughly 4/3 of their bytes on disk. Pass a list to it when the files are small and you want them in one prompt anyway; pass [one] at a time when they are pages, scans or PDFs. That is why the end-to-end pipeline below calls io.readFiles([page]) inside a loop rather than io.readFiles(pages) once.
io.readFiles is bounded by --max-rows — the same ceiling that governs a query result, because it is global to row-producing paths rather than an io quirk. Over the default of 200000 paths the call refuses by name, before it reads anything:
Error: Result set exceeded the row limit (200000). Process in batches with col.chunk, or raise --max-rows.
Note what that bound counts: paths, not bytes. Two hundred 50 MB scans are far inside the ceiling and will still exhaust the heap, because nothing on this path corresponds to --max-response-bytes, which caps a single std/http body. The escape the message names is real — col.chunk splits the path list into batches you read one at a time — and it is the same escape as for an oversized query result. See Flags and ceilings.
io.toMultimodalMarkers looks at each file's MIME type to pick the right marker and joins them line by line:
| MIME | Marker produced |
|---|---|
image/* | {{image_url:data:image/png;base64,…}} |
application/pdf | {{file_url:data:application/pdf;base64,…}} |
| other | {{file_url:…}} |
If the model profile does not carry multimodal: true, markers are not processed at all and go into the prompt exactly as written — base64 payload included — as plain text. That both wastes tokens and leaves the model unable to see the image. Add this flag to every model profile that processes images.
Provider content mapping
| Adapter | {{image_url:…}} | {{file_url:…}} |
|---|---|---|
| OpenAI-compatible · Azure | { type: "image_url", image_url: { url } } | { type: "file_url", file_url: { url } } |
| Anthropic | { type: "image", source: { type: "url", url } } | data URL → { type: "document", source: { type: "base64", media_type, data } }plain URL → source: { type: "url", url } |
| Gemini | { fileData: { mimeType: "image/*", fileUri: url } } | data URL → { inlineData: { mimeType, data } }plain URL → { fileData: { … } } |
All three adapters map a data URL onto the embedded-content field: OpenAI-compatible uses image_url/file_url, Anthropic uses source.type: "base64", Gemini uses inlineData. When you hand over a hosted URL instead, Gemini uses fileData.fileUri and Anthropic uses source.type: "url". In other words, the output of io.toMultimodalMarkers works independently of the provider.
End to end: multi-page invoice extraction
The pipeline below reads the PNG pages in a directory and converts them to Markdown, then extracts typed invoice data out of that Markdown and writes two files. It is the essence of the luma/064_invoice_multi_desc_prompt.luma example in the repository.
@0213/*.png (in natural order)
↓ a separate multimodal ask per page
Markdown fragments → concatenate → @0213/pre.md
↓ schema.prompt(InvoiceExtract) + Markdown
InvoiceExtract (typed, validated)
↓
@0213/ext.json- Declare the input and the models. Using a multimodal model for the images and a separate wide-context model for the text extraction improves both quality and cost.
luma
input folder: string import io from "std/io" import json from "std/json" import schema from "std/schema" import num from "std/numeric" model Vision { provider: "openai-compatible" base_url: "https://gateway.example/v1" model: "vision-model" api_key: getEnv("api_key") max_tokens: 6000 multimodal: true } model TextExtractor { provider: "openai-compatible" base_url: "https://gateway.example/v1" model: "text-model" api_key: getEnv("api_key") max_tokens: 24000 } - Write the output schema together with its field descriptions.
@desctexts go into the JSON Schema and act as extraction instructions for the model.lumaschema InvoiceItem { line_no: int | null @desc("Line item sequence number on the invoice") description: string | null @desc("Description of the goods or the service") quantity: float | null @desc("Quantity sold") unit_price: float | null @desc("Unit price before discount") total: float | null @desc("Line item total after discount, excluding VAT") where quantity == null || unit_price == null || total == null || num.approximatelyEqual(quantity * unit_price, total, 0.01) @feedback("does not match the quantity * unit_price total") } schema MetaEntry { key: string value: string } schema InvoiceExtract @desc("Structured data extracted from the invoice Markdown") @prompt("Produce JSON conforming to the InvoiceExtract schema from the invoice Markdown text below. Use the field descriptions as extraction instructions. For every line item that appears in every table, produce a separate object inside items; do not skip rows and do not summarise. Do not invent values that are not in the text; use null for nullable fields and [] for array fields. Do not lose any information that does not fit the schema fields; keep it as key/value pairs in the meta field.") { invoice_number: string | null @desc("Invoice number") invoice_date: string | null @desc("Invoice date, YYYY-MM-dd HH:mm") items: InvoiceItem[] @desc("All line items on the invoice") notes: string[] @desc("Footnotes and bank details") meta: MetaEntry[] @desc("All information that does not fit the schema fields") } - Transcribe the pages one by one. Processing each page with its own
askis markedly more reliable than sending all the pages in a single request: the token limit is not exceeded and the model does not mix the pages up.lumaagent InvoiceReader { model: Vision retry: 2 fn transcribe() -> InvoiceTranscript { root = "@" + folder pages = io.listFiles(root, "*.png", false) markdown = "" for page in pages { files = io.readFiles([page]) markers = io.toMultimodalMarkers(files) part = ask "Convert this invoice image to Markdown in full. Keep the table as a Markdown table; carry over the company, the customer, the line items, the VAT, the totals and the footnotes. Fill in the markdown field only. Image: {markers}" -> InvoiceTranscript markdown = markdown + part.markdown + "\n\n" } written = io.writeText(root + "/pre.md", markdown) return { markdown: markdown } } } - Extract the typed data from the Markdown. The instruction is read from the schema itself.
luma
agent InvoiceExtractor { model: TextExtractor retry: 2 fn extract(markdown: string) -> InvoiceExtract { instruction = schema.prompt(InvoiceExtract) result = ask "{instruction}\n\nINVOICE MARKDOWN:\n\n{markdown}" -> InvoiceExtract root = "@" + folder written = io.writeText(root + "/ext.json", json.pretty(result)) return result } } - Wire the pipeline up and run it.
luma
reader = InvoiceReader() extractor = InvoiceExtractor() transcript = reader.transcribe() result = extractor.extract(transcript.markdown)
bun src/index.ts luma/invoice.luma \ --folder 0213 \ --all \ --trace-file /tmp/invoice-trace.json
Why this way?
| Decision | Rationale |
|---|---|
A separate ask per page | It does not exceed the token limit, failures stay isolated, and when a single page fails retry reruns only that page. |
io.readFiles([page]) inside the loop | One page is base64-encoded and held at a time. io.readFiles(pages) would encode the whole folder up front and keep all of it resident until the method returns — for a long scan that is the memory ceiling, and nothing on this path bounds bytes. |
The intermediate pre.md file | It separates transcription from extraction; when you change the extraction prompt you do not have to repeat the expensive vision step. |
meta: MetaEntry[] | It carries information that does not fit the schema without losing it, and avoids the strict-gateway risk of map<any>. |
| Nullable fields | They give the model an explicit way to say "I did not find it", which reduces invented values. |
A line-item level where | It catches arithmetic inconsistency and feeds the error message back to the model. |
| Two separate model profiles | The vision model is expensive and has a narrow context; the extraction model is cheap and wants a wide context. |
Instead of scanning the generated ext.json by eye, look at the validation events in the trace — you see which rules ran how many times and what failed:
jq -r '.events[] | select(.type=="validation" and .ok==false)
| "\(.name) [\(.metadata.rule // "-")] \(.error // "-")"' /tmp/invoice-trace.json