Luma public docs
Türkçe

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
RuleDetail
How the root is foundStarting 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 boundaryEvery path that leaves the root is rejected: io path cannot escape the project directory: ../x
Empty pathio path expects a non-empty string.
Returned paths are root-relative too

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

luma
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

json
{
  "path":      "0213/page-1.png",
  "mime_type": "image/png",
  "base64":    "iVBORw0KGgoAAAANSUhEUg…",
  "size":      184213
}

MIME mapping

ExtensionMIME
.pngimage/png
.jpg · .jpegimage/jpeg
.webpimage/webp
.gifimage/gif
.svgimage/svg+xml
.pdfapplication/pdf
.mdtext/markdown
.txttext/plain
.jsonapplication/json
otherapplication/octet-stream

io.writeText creates the intermediate directories of the target automatically — you do not have to prepare the output directory beforehand.

Listing files

luma
pages = io.listFiles("@0213", "*.png", false)
// ["@0213/page-1.png", "@0213/page-2.png", "@0213/page-10.png"]
ParameterRule
directoryMust be a directory; if a file is given you get io listFiles expects a directory.
patternA file name glob only: * (anything) and ? (a single character). It cannot contain / or \. Matching is case-insensitive.
recursiveWhen true, the walk descends into subdirectories.
The ordering is natural

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.

✓ Correct
pages = io.listFiles("@0213", "*.png", false)
for page in pages {
  ...
}
✗ Wrong
// 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>}}
luma
// 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}" -> Transcript

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

There is a count ceiling, and no byte ceiling

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:

MIMEMarker produced
image/*{{image_url:data:image/png;base64,…}}
application/pdf{{file_url:data:application/pdf;base64,…}}
other{{file_url:…}}
With multimodal: false a marker is plain text

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: { … } }
Base64 content lands in the right field on every adapter

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
  1. 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
    }
  2. Write the output schema together with its field descriptions. @desc texts go into the JSON Schema and act as extraction instructions for the model.
    luma
    schema 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")
    }
  3. Transcribe the pages one by one. Processing each page with its own ask is 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.
    luma
    agent 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 }
      }
    }
  4. 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
      }
    }
  5. 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?

DecisionRationale
A separate ask per pageIt 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 loopOne 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 fileIt 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 fieldsThey give the model an explicit way to say "I did not find it", which reduces invented values.
A line-item level whereIt catches arithmetic inconsistency and feeds the error message back to the model.
Two separate model profilesThe vision model is expensive and has a narrow context; the extraction model is cheap and wants a wide context.
The practical way to verify an extraction

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