Luma public docs
Türkçe

Library

Standard Library

The standard library is a set of thin pure fn wrappers written in Luma under luma/std/. All of them are deterministic; they can be called freely inside where rules, @valid expressions and agent methods. The one exception is std/io — it consists of io fns and cannot be used inside where/@valid.

Usage and the alias rule

luma
import math from "std/math"         // math.abs(...)
import "std/math"                    // abs(...)  — no namespace
The ten names below are reserved — a dependency cannot take one

In an agent method body, a line of the form x = name.function(...) is ambiguous: it could be a standard-library call or a call on a uses dependency. The parser resolves it against a set of namespace names, which is the ten conventional aliases below plus whatever alias you actually imported under. Anything else is treated as a dependency call.

Three consequences, each measured:

  • You may import under any alias. import numbers from "std/numeric" followed by numbers.round(3.7) works — your alias is registered when the import is resolved. The table below is convention, not a constraint.
  • A uses dependency may not be named after one of the ten. uses col: Helper then r = col.give() parses as a namespace call, never reaches the dependency, and fails at runtime with 'col' variable is not defined yet. — a message that says nothing about dependencies.
  • A name that is neither — a typo, or an import you forgot — fails by name at parse time: 'helper' is not defined as a dependency.
ModuleConventional aliasKind
std/mathmathpure
std/stringstrpure
std/datedatepure
std/collectionscolpure
std/regexregexpure
std/numericnumpure
std/jsonjsonpure
std/schemaschemapure
std/agentagentpure
std/findingsfindingsio
std/ioioio
✓ Correct
agent A {
  uses helper: Helper

  fn f() -> S {
    r = helper.give()
    return { v: r.v }
  }
}
✗ Wrong
agent A {
  uses col: Helper

  fn f() -> S {
    r = col.give()
    // Error: 'col' variable is
    //        not defined yet.
  }
}

std/math

This module is written entirely in Luma — you can read its source in luma/std/math.luma.

math.abs(x: float) → float

Absolute value.

math.abs(0 - 7)   // 7
math.sum(values: float[]) → float

The sum of the numbers in the list. 0 for an empty list.

math.sum([1, 2, 3])   // 6
math.min(a: float, b: float) → float

The smaller of two numbers.

math.max(a: float, b: float) → float

The larger of two numbers.

math.clamp(x: float, minimum: float, maximum: float) → float

Clamps the value into the given range.

math.clamp(150, 0, 100)   // 100
luma
import math from "std/math"

schema Calculation {
  values: float[1..10]
  total: float

  where math.abs(math.sum(values) - total) <= 0.01
}

std/string

str.lower(value: string) → string

Converts to lowercase.

str.upper(value: string) → string

Converts to uppercase.

str.trim(value: string) → string

Strips only leading and trailing whitespace. Interior whitespace is preserved.

str.trim("  Ada  Byron  ")   // "Ada  Byron"
str.startsWith(value: string, prefix: string) → bool

Prefix check.

str.endsWith(value: string, suffix: string) → bool

Suffix check.

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

Replaces every match. It performs a plain-text search — this is not a regex.

str.replace("a-b-c", "-", "/")   // "a/b/c"
str.textLength(value: string) → int

Length in UTF-16 units — an astral character counts as 2. (Does the same job as the built-in length.)

luma
import str from "std/string"

schema Text {
  name: string @valid(x => str.startsWith(str.trim(x), "Ada"))
  slug: string @valid(x => str.lower(x) == "luma-dsl")
  description: string @valid(x => str.textLength(x) > 5)
}
Cleaning up interior whitespace

str.trim does not touch repeated whitespace inside the value. Use a regex to normalize it:

import regex from "std/regex"

normalized = regex.replace(raw, "\\s+", " ")

std/date

Accepted formats: YYYY-MM-DD and a full ISO 8601 date-time (a Z or ±HH:MM offset is required). Calendar-invalid dates (2024-02-31) are rejected. Every field read is performed in UTC.

date.isValid(value: string) → bool

Is the value parseable and a real date?

date.isBefore(left: string, right: string) → bool

left < right. If either side is invalid it returns false instead of throwing.

date.isAfter(left: string, right: string) → bool

left > right. false on invalid input.

date.diffDays(from: string, to: string) → int

The whole-day difference between the two (the fractional part is discarded). On an invalid date it throws.

date.diffDays("2024-01-01", "2024-01-06")   // 5
date.year(value: string) → int

The UTC year. Throws on an invalid date.

date.month(value: string) → int

The UTC month, 1–12 (not zero-based). Throws on an invalid date.

date.day(value: string) → int

The UTC day of the month. Throws on an invalid date.

date.toDate(value: string) → string

Returns the first 10 characters of the value — reduces a date-time to YYYY-MM-DD.

date.toDate("2024-01-02T10:20:30Z")   // "2024-01-02"
luma
import date from "std/date"

schema Period {
  start: string @fmt("date") @valid(x => date.isValid(x))
  end: string @fmt("date")
  days: int @valid(x => x > 0)

  where date.isBefore(start, end)
  where date.diffDays(start, end) == days
  where date.year(start) >= 2024
}
isValid and diffDays behave differently on failure

The comparison functions (isBefore/isAfter) silently return false on invalid input; the calculation functions (diffDays, year, month, day, toDate) throw. On nullable date fields, guard with date.isValid first.

std/collections

col.all(values: bool[]) → bool

Are all elements true? true for an empty list.

col.any(values: bool[]) → bool

Is at least one true? false for an empty list.

col.contains(values: string[], value: string) → bool

Does the list contain the element?

col.join(values: string[], separator: string) → string

Joins a list of strings.

col.unique(values: string[]) → string[]

Drops duplicates; order is preserved.

col.flatten(values: string[][]) → string[]

Flattens nested lists completely.

col.flatten([["a"], ["b", "c"]])   // ["a", "b", "c"]
luma
import col from "std/collections"

schema Collection {
  tags: string[] @valid(x => col.contains(x, "vip"))
  groups: string[][]
  flags: bool[] @valid(x => col.any(x))

  where col.contains(tags, "vip")
  where contains(col.flatten(groups), "approved")
  where col.join(tags, ",") == "vip,active"
}
col.contains versus the built-in contains

The built-in contains searches for a substring in a string as well as an element in an array, and it needs no import. col.contains, on the other hand, is typed for string lists only. When you are searching inside a scalar string, use the built-in one:

name: string @valid(x => contains(x, "Ada"))     // built-in
tags: string[] @valid(x => col.contains(x, "vip"))   // std/collections

Building and ordering (v0.4.0)

col.append(values: any[], value: any) → any[]

Returns a new list with the value at the end. No std function mutates its input.

col.concat(first: any[], second: any[]) → any[]

Concatenates two lists into a new one.

col.sort(values: any[]) → any[]

Ascending, stable sort. Comparable element kinds are string, int and float, and the two ways a list can be unsortable refuse with different messages: an element of another kind gives col.sort supports string, int and float values., while comparable elements of mixed kinds give col.sort values have mixed types. A non-list argument gives col.sort expects a list.

col.sortBy(items: any[], field: string) → any[]

Sorts a list of records by a field, ascending and stable. The key is a string field name, not a lambda — Luma has no lambda values. The field must be present on every item and single-typed.

by_amount = col.sortBy(items, "amount")
col.sortByDesc(items: any[], field: string) → any[]

Same contract as col.sortBy, descending.

col.keys(entries: map<any>) → string[]

The keys of a map, in its deterministic iteration order.

Slicing and shaping (v0.4.0)

col.slice(values: any[], start: int, end: int) → any[]

Elements from start up to (excluding) end. Negative bounds refuse by name; start > end gives an empty list; end beyond the length is clamped.

col.take(values: any[], count: int) → any[]

First count elements. count beyond the length returns the whole list — not an error. Negative counts refuse by name.

col.drop(values: any[], count: int) → any[]

Everything after the first count elements; beyond the length gives an empty list.

col.chunk(values: any[], size: int) → any[]

Splits into buckets of size; the last bucket may be shorter. size < 1 refuses by name.

col.chunk([1, 2, 3, 4, 5], 2)   // [[1, 2], [3, 4], [5]]
col.zip(first: any[], second: any[]) → any[]

Pairs elements by index into two-element lists; the longer list is cut to the shorter one.

col.reverse(values: any[]) → any[]

A new list in reverse order.

col.indexOf(values: any[], value: any) → int

Index of the first match, or -1.

col.first(values: any[]) → any

First element. An empty list refuses by name: col.first on an empty list.

col.last(values: any[]) → any

Last element; same empty-list contract as col.first.

Grouping and aggregation (v0.4.0)

col.groupBy(items: any[], field: string) → map<any>

Buckets records by a string-valued field. Key order is deterministic (sorted by the field's value); a non-string key field refuses by name.

buckets = col.groupBy(orders, "region")   // map<Order[]>
col.distinctBy(items: any[], field: string) → any[]

First record per distinct field value. The result is ordered by field value, not by input order — it delegates to col.sortBy; only which record wins inside a bucket follows input order.

col.sum(values: float[]) → float

Sum of a numeric list; 0 for an empty list. Accepts int lists.

col.count(items: any[], field: string, value: any) → int

How many records carry value in field; 0 when none match.

Boundary errors come from the validation layer

The Luma-written collection functions express their edge contracts through parameter annotations (@valid + @feedback), so their errors carry the validation prefix: Error: pure col.first.values: col.first on an empty list. The message text after the prefix is pinned by tests, byte for byte.

std/regex

Patterns use JavaScript regex syntax. Pattern length is capped at 1000 characters; an invalid pattern throws.

regex.matches(value: string, pattern: string) → bool

Does the pattern occur in the text? (test semantics — write ^…$ for a full match.)

regex.replace(value: string, pattern: string, replacement: string) → string

Replaces every match (global flag).

regex.replace("Ada 42 apples", "[0-9]+", "#")   // "Ada # apples"
regex.find(value: string, pattern: string) → string | null

Returns the first match; null if there is none.

regex.count(value: string, pattern: string) → int

The number of matches; 0 if there are none.

regex.isValidPattern(pattern: string) → bool

Does the pattern compile? Does not throw.

luma
import regex from "std/regex"

schema RegexExample {
  code: string @valid(x => regex.matches(x, "^[A-Z]{3}-[0-9]+$"))
  text: string
  found: string | null

  where regex.count(text, "a") == 2
  where regex.find(text, "[0-9]+") == found
}
A | character inside a pattern is safe

The && and || combinations in a @valid body are split with string and parenthesis awareness; a | character inside a regex pattern does not break the expression:

code: string @valid(x => regex.matches(x, "^(AA||BB)-[0-9]+$"))

std/numeric

num.round(value: float) → int

Rounds to the nearest integer. Throws on a non-finite value.

num.floor(value: float) → int

Rounds down.

num.ceil(value: float) → int

Rounds up.

num.isFinite(value: float) → bool

Is it a finite number? false for NaN/Infinity.

num.isInteger(value: float) → bool

Is it an integer?

num.approximatelyEqual(left: float, right: float, tolerance: float) → bool

|left − right| ≤ tolerance. For floating-point comparisons this is the right tool.

num.approximatelyEqual(0.1 + 0.2, 0.3, 0.0001)   // true
num.clamp(value: float, minimum: float, maximum: float) → float

Clamps the value into the range.

luma
import num from "std/numeric"

schema InvoiceItem {
  quantity: float | null
  unit_price: float | null
  total: float | null

  where quantity == null || unit_price == null || total == null || num.approximatelyEqual(quantity * unit_price, total, 0.01)
    @feedback("does not match quantity * unit_price")
}
Always use a tolerance in money calculations

Direct equalities such as where total == subtotal + tax fail because of floating-point error even with solid models, and cause needless retries. num.approximatelyEqual(…, 0.01) is a suitable choice for cent-level precision.

std/json

json.parse(raw: string) → any

Converts JSON text into a value. If it is invalid, it throws Invalid JSON text.

json.stringify(value: any) → string

Compact JSON text.

json.pretty(value: any) → string

JSON text indented with 2 spaces.

json.compact(value: any) → string

Identical to stringify — a separate name for readability.

json.isValid(raw: string) → bool

Is it parseable? Does not throw.

json.has(value: any, path: string) → bool

Is there a value at the dot-separated path? true when it is not undefined.

json.has(data, "seller.name")
json.getString(value: any, path: string) → string | null

Returns the value at the path if it is a string; null when it is missing or of a different type.

json.getNumber(value: any, path: string) → float | null

Returns the value at the path if it is a number, otherwise null.

json.getBoolean(value: any, path: string) → bool | null

Returns the value at the path if it is a boolean, otherwise null.

This family is the only way to walk data whose shape you do not control

A value from json.parse has type any, and on any a direct field access throws when the field is absent:

Error: Field 'd.isyeri' was not found.

So you cannot guard it the obvious way — if d.isyeri != null throws on the access itself, before the comparison happens. That is the same rule as xs[5] is out of bounds (length 2). and m["missing"] is not present., and it is deliberate: a silent null here would flow onward and end up inside validated output, which is the one thing this language exists to prevent. It also means a typo in a field name is caught rather than read as "absent".

When the shape is under your control, declare a schema and let validation do the work. When it is not — heterogeneous payloads, an ext.json column, a third-party webhook — json.has plus the json.get* family is the navigation surface, and the get* functions return null for missing and for wrong-typed, so one check covers both.

luma
import json from "std/json"

schema JsonExample {
  raw: string
  data: any
  payload: json<Payload>

  where json.isValid(raw)
  where json.has(data, "id")
  where json.getString(data, "id") == "p1"
  where json.getNumber(data, "count") == 2
}

agent JsonProducer {
  fn create() -> JsonExample {
    raw: string  = "{\"id\":\"p1\",\"count\":2}"
    data: any     = json.parse(raw)
    payload: Payload = json.parse(raw)
    return { raw: raw, data: data, payload: payload }
  }
}
When getX returns null you cannot tell why

json.getString(v, "a.b") returns null both when "the path does not exist" and when "the value is not a string". If the distinction matters, check with json.has(v, "a.b") first.

std/schema

Schemas are compile-time definitions and cannot be modified at runtime; this module only reads them. When a schema name appears inside an expression it automatically becomes a schema reference.

schema.name(ref) → string

The schema name.

schema.description(ref) → string | null

The schema-level @desc text.

schema.prompt(ref) → string | null

The schema-level @prompt text. It is not sent to the model automatically; you add it to the prompt yourself.

schema.describe(ref) → any

The full descriptor: { name, description, prompt, fields, rules, components }.

schema.fields(ref) → any[]

The list of field descriptors (shape below).

schema.field(ref, field_name: string) → any

The descriptor of a single field; null if it does not exist.

schema.hasField(ref, field_path: string) → bool

Does the dot-separated path exist? It descends into nested schemas and array element types.

schema.hasField(InvoiceExtract, "seller.name")   // true
schema.rules(ref) → any[]

The list of where and conditional rules: { kind: "where" | "conditional", expression, feedback }.

schema.components(ref) → string[]

The composition parts. On a tagged union it returns the member names instead — official since v0.9.0 and locked by a test. [] when the schema is neither. There is no name → reference bridge: the strings name members, they do not resolve to them.

schema.catalog(refs: any[]) → any[]

The descriptor list for several schemas. A repeated schema is an error.

schema.toJsonSchema(ref) → any

The JSON Schema counterpart of a single schema. It does not include $defs.

schema.toJsonSchemas(refs: any[]) → any

A name → JSON Schema mapping.

Shape of a field descriptor

json
{
  "name": "name",
  "type": "string",
  "optional": false,
  "description": null,
  "title": "Full name",
  "format": null,
  "aliases": [],
  "deprecated": null,
  "examples": ["Ada Byron"],
  "constraints": { "minLength": 2, "maxLength": 40 }
}

The type field is written in Luma syntax: "string", "int", "LineItem[]", "map<float>", "string | null", "enum[\"a\", \"b\"]". optional becomes true when the field is marked with ? or carries a @default. constraints contains only the constraints that are actually defined.

luma
import schema from "std/schema"
import json from "std/json"

agent SchemaInspector {
  fn inspect() -> ReflectionResult {
    catalog     = schema.catalog([Invoice, Receipt])
    field       = schema.field(Invoice, "invoice_number")
    json_schema = schema.toJsonSchema(Invoice)

    return { name: schema.name(Invoice)
      , description: schema.description(Invoice)
      , field_name: field.name
      , has_field: schema.hasField(Invoice, "invoice_number")
      , catalog_count: length(catalog)
      , rule_count: length(schema.rules(Invoice))
      , json_description: json.getString(json_schema, "description") }
  }
}
Make the schema self-documenting with @prompt

Writing the extraction instruction inside @prompt and reading it back with schema.prompt(...) makes the instruction versioned together with the schema — when you add a field you update the instruction in the same place.

instruction = schema.prompt(InvoiceExtract)
result = ask "{instruction}\n\nMARKDOWN:\n{markdown}" -> InvoiceExtract

std/agent

Deterministic helpers for reading error objects and assembling prompt fragments. This module does not wrap ask, tool, retry or memory — those are features of the language itself.

agent.errorCode(error: any) → string | null

Reads the error code (TOOL_TIMEOUT, MODEL_RATE_LIMIT…). null when there is no code.

agent.errorMessage(error: any) → string | null

Reads the error message.

agent.isRetryable(error: any) → bool

The retryable flag; false when absent.

agent.errorDetails(error: any) → any

The structured details object; null when absent.

agent.toolError(code: string, message: string, retryable: bool, details?: any) → any

Produces a typed tool-error object.

agent.toolError("VALIDATION_FAILED", "Invoice could not be validated", true, { field: "total" })
agent.section(title: string, body: string) → string

Produces a section in the form "Title:\ntext".

agent.compose(parts: string[]) → string

Joins the sections with a double newline.

luma
import agent from "std/agent"

agent Helper {
  tools: [db_write]

  fn run(data: string) -> Result {
    try {
      written = tool db_write({ request_id: "r-1", value: data })
      return { status: "ok", message: "" }
    } catch error {
      if agent.isRetryable(error) {
        return { status: "temporary_error", message: agent.errorMessage(error) }
      }
      return { status: agent.errorCode(error), message: agent.errorMessage(error) }
    }
  }
}

Building a prompt

luma
prompt = agent.compose([
  agent.section("Instruction", "Return valid JSON only."),
  agent.section("Rules", "Set any field you cannot find to null."),
  agent.section("Data", json.pretty(record))
])

result = ask "{prompt}" -> Output
Instruction:
Return valid JSON only.

Rules:
Set any field you cannot find to null.

Data:
{ … }

std/findings (v0.8.0)

Reads the flags a @severity("warn") rule left on a value. A soft rule does not stop the run; what it found is recorded against the operation that produced the value, and this module is how the program gets at it.

findings.of(value: any) → Finding[]

The findings recorded for this value; an empty list when there are none. Reading marks them read, which is what silences the end-of-run warning.

findings.count(value: any) → int

How many there are, without materialising the list. Also counts as reading.

Field of FindingTypeWhat it holds
rulestringThe rule that failed, as valid:<expression> or where:<expression>. The ceiling's marker uses findings.capped.
pathstringWhere it failed, e.g. Maker.make.return.score.
messagestringThe text the rule would have failed with.
severitystring"warn" today.
attemptintThe attempt the value was accepted on; 1 when there was no retry.
luma
import findings from "std/findings"

schema Scored {
  score: int @valid(x => x >= 0) @severity("warn")
}

agent Auditor {
  uses maker: Maker

  fn audit() -> Audit {
    shaded = maker.make(-7)
    flaws = findings.of(shaded)
    return { score: shaded.score, flaws: length(flaws), first_rule: flaws[0].rule }
  }
}
This is the one io module you cannot call from a rule

Both functions read run state, so they are io — @valid, where and pure fn bodies refuse them by name:

Error: findings is io and cannot be used inside @valid or where rules: findings.count

That refusal is the point: without it a rule could read its own violations, and its result would depend on the order the rules ran in.

Findings live per operation, and per element under parallel over. A value that was never flagged simply returns an empty list — there is nothing to guard against before calling. The full semantics, including the ceiling and the unread warning, are on the Schemas & Validation page.

std/http (v0.4.0)

std/http talks to HTTP services with zero dependencies — it rides on the runtime's built-in fetch. It pays for that with a subprocess per call, roughly 30–80 ms: the expression evaluator is synchronous and there is no synchronous fetch, so the request runs in a child process. That is fine for coarse-grained calls — paginated pulls, a few hundred requests — and the wrong tool for enriching every row of a large set, where the process cost dominates. Tracked in ROADMAP.md § U5. Every function returns the standard library's first exported schema:

luma
schema HttpResponse {
  status: int
  body: string
  headers: map<string>
}
http.get(url: string, headers: map<string>, timeout: int) → HttpResponse

GET. timeout is milliseconds and mandatory — there is no default; a silent infinite wait is a silent failure.

http.postJson(url: string, body: any, headers: map<string>, timeout: int) → HttpResponse

POST with a JSON body; content-type defaults to application/json.

http.put(url: string, body: any, headers: map<string>, timeout: int) → HttpResponse

PUT with a JSON body.

http.patch(url: string, body: any, headers: map<string>, timeout: int) → HttpResponse

PATCH with a JSON body.

http.delete(url: string, headers: map<string>, timeout: int) → HttpResponse

DELETE.

Status codes are not errors

A 404 or a 500 returns normally — deciding what a status means is the program's job. The only failure that throws is not reaching the host at all: http.get could not reach '<url>': <reason>.

luma
import http from "std/http"

response = http.get("https://api.example/v1/orders", { "authorization": token }, 5000)
if response.status == 200 {
  data = json.parse(response.body)
}
Credentials and volume

Redaction goes by the header's name, not by how sensitive its value is, so do not rely on it as a general secret filter. A header whose name matches api_key · apikey · api-key · password · secret · credential · authorization (case-insensitive, substring) has its whole value replaced with [REDACTED] — x-api-key is covered by that rule, authorization loses the entire value rather than just the bearer token. 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 goes to the trace in plaintext — cookie, x-auth-token, x-session, a signature header. The URL and the body are never logged at all. Response bodies are capped by --max-response-bytes (default 10 MB) — exceeding the cap refuses by name instead of silently ballooning. Retries are not http's job: use the agent retry / program try machinery.

std/state (v0.4.0)

std/state is the incremental-run primitive: a flat key→string store backed by one JSON file. A watermark is just a stored string — no separate abstraction.

state.read(key: string) → string | null

The stored value, or null when the key has never been written — a first run can tell itself apart.

state.write(key: string, value: string) → string

Persists and returns the value. The write is atomic (temp file + rename): an interrupted run cannot corrupt the store.

state.keys() → string[]

All stored keys.

luma
import state from "std/state"

cursor = state.read("orders_watermark")     // first run: null
state.write("orders_watermark", latest)
No default location

The store's path comes from --state-file <path> and there is no default: without the flag, state.read/state.write refuse by name (state.read needs a state file; pass --state-file <path>.). A silent default location would be silent data loss. State is a real file and is not part of --record/--replay.

Values are always strings; a program that stores numbers or dates converts explicitly with json/date. --state-file is required whenever any state.* function is called, state.keys() included — there is no default location, because a silent one is silent data loss the first time two jobs share a machine.

std/io

This module consists of io fns

std/io functions cannot be used inside where, @valid or pure fn. They can only be called in agent methods and inside io fns. Every path is confined to the project root. For in-depth usage → IO & Multimodal.

io.readFiles is bounded by --max-rows (default 200000), which is a global ceiling on row-producing paths rather than an io quirk — the same limit and the same message govern a query result. Exceeding it refuses by name and points at col.chunk: Result set exceeded the row limit (200000). Process in batches with col.chunk, or raise --max-rows. See Flags and ceilings.

io.readText(path: string) → string

Reads a text file as UTF-8.

io.writeText(path: string, content: string) → string

Writes a text file, creating the intermediate directories. Returns the root-relative path of the file it wrote.

io.readFile(path: string) → any

Reads a binary file: { path, mime_type, base64, size }.

io.readFiles(paths: string[]) → any[]

Reads several files in the same shape. Capped by --max-rows — see the note below.

io.listFiles(directory: string, pattern: string, recursive: bool) → string[]

A naturally sorted list of @-prefixed paths. The pattern is a glob over the file name only (*, ?); it cannot contain /.

pages = io.listFiles("@0213", "*.png", false)
io.exists(path: string) → bool

Does the file/directory exist?

io.mimeType(path: string) → string

The MIME type derived from the extension; application/octet-stream when unknown.

io.toDataUrl(file: any) → string

Converts an io.readFile result into a data:<mime>;base64,… URL.

io.toMultimodalMarkers(files: any[]) → string

Converts a file list into line-by-line {{image_url:…}} / {{file_url:…}} markers.

luma
import io from "std/io"

agent InvoiceReader {
  model: Vision

  fn transcribe(folder: string) -> Transcript {
    pages   = io.listFiles("@" + folder, "*.png", false)
    files   = io.readFiles(pages)
    markers = io.toMultimodalMarkers(files)
    text    = ask "Convert every page to Markdown: {markers}" -> Transcript
    written = io.writeText("@" + folder + "/pre.md", text.markdown)
    return text
  }
}