Builtins
Builtins — every function and helper family, one entry each #
Every built-in function, one section each — fetch one by name (builtins#List.sum) instead of the whole chapter. Each entry gives the signature, what it means, whether the result is always there or T | absent, an ordering requirement where one exists, and an example. The reference chapter's operators-and-functions is the one-page version of this list. Entries run in groups: the core words, then each namespace with its members. Core words are always there and never qualified; a namespace member is always written with its namespace (Text.upper(x)). Every argument is checked when you save, and a call is typed by what it gives back (Text.upper(x) is text), so a built-in's call written into a number field is refused. A call can sit inside any expression and runs left to right.
Inside walk brackets -[…]-> these functions are spelled in capitals: LOWER UPPER TRIM LENGTH ABS ROUND FLOOR CEIL DATE DATETIME NUMBER COALESCE EXISTS, as in ch-[m:Messages WHERE LOWER(m.Text) CONTAINS "urgent"]->. Each section is headed by the name you write outside brackets (Text.upper, List.sum), which is what you fetch it by; the capitals names above are only the spellings inside walk brackets.
Core words #
x == null #
x == null → boolean, always present — the presence test inverted (x != null is true when there is a value). Takes a bound name or a field read off one (x.Field== null), and proving a field absent proves nothing about the record it came off — proving it present (!= null) is what narrows the record.
if (`Suggested Action` == null) { fail("nothing to act on") }
exists #
exists(walk) → boolean, always present — whether a relationship has any records. Takes a walk only, with an optional WHERE through an alias (exists(m-[a:Attachments WHERE a.Name CONTAINS "pdf"]->)); on a value it is refused with the != null rewrite. !exists(…) is has-none. if (x != null) { … } narrows x present inside that arm only; x != null ? a : b narrows the same way inside a.
if (exists(msg-[:Attachments]->)) { … }
coalesce #
coalesce(a, b, …) → the first argument with a value, present whenever at least one argument is unconditionally present (a literal, or anything already known to be there). It stops at the first argument that has a value and never evaluates the rest, so a later argument that calls a model, reads a file or fails the run does nothing when an earlier one is present. A coalesce whose arguments might all be empty is itself T | absent and discharges nothing.
Name: coalesce(msg-[:Sender]->.Name, msg-[:Sender]->.Email, "unknown")
count #
count(list | walk) → number, always present, reads any order. The member count; 0 for null.
n = count(co-[t:Team]->)
only #
only(list | walk) → T | absent, reads any order — the one that matched: answers that record or value, and fails the run if there turns out to be more than one. Reach for it whenever a WHERE narrows to a single thing; over a bare walk it binds the whole record.
channel = only(chat-[ch:Channels WHERE ch.Name == "ops"]->)
first #
first(list) → T | absent — needs an ordered sequence, refused otherwise (the message points at only when the source is really a single-match lookup).
newest = first(crm-[c:Companies ORDER BY c.`Created At` DESC]->.Name)
sort #
sort(list) / sort(list, { order: "desc" }) / sort(list, (r) => r.score) / sort(list, (r) => r.score, { order: "desc" }) → the same members in order, always present (commutative — reads any order in). Plain values sort by themselves; a list of records or dicts takes a key function reading one field. The settings record is last, and "asc" is the default order.
ranked = sort(rows, (r) => r.score, { order: "desc" })
map #
map(list, f) or map(list, f, settings) → a list of what f returned, member by member, in the order given — always present (each slot may itself be absent when f's value is). f is a function (member) => { … } or (member) => expression and takes its parameter's type from the collection; it may await (each member waits on its own). map alone among the five may skip return — each slot is then absent and the closure's writes still run — and a bare map(...) with no binding is a legal statement on its own.
bulleted = map(lines, (t) => { return "• ${t}" })
upper = map(lines, (t) => Text.upper(t))
map(ch-[m:Messages]->, (m) => { write graph-[:note]-> { text: m.Text } })
settings is a record written in place, last: onError ("error", the default, "warn" or "ignore": what a member whose function fails does — the other two leave it out of the answer, "warn" with a warning on the trace), concurrency (members run at once) and initialConcurrency (a first batch of that size runs to the end before the rest start, so a prompt cache is warm before the fan-out). The answer stays in input order, and members writing the same unique by key take turns, so the record is made once. See map-and-filter-settings in the expressions chapter.
filter #
filter(list, f) or filter(list, f, settings) → the members f answered true for, in order, always present. f must give a boolean — return one, or write the condition as the body. settings is the same record map takes; a member whose predicate fails is not kept. A test that keeps only members that are there ((x) => x != null) answers a list of present members.
kept = filter(lines, (t) => { return Text.length(t) > 0 })
reduce #
reduce(list, f, start) → whatever f last returned, always present when start is. Needs an ordered list — f is (carried, member) => { … } and must return the next carried value.
total = reduce(lines, (carried, t) => { return carried + Text.length(t) }, 0)
members #
members(<T>) → a list of a closed type's values, always present, in the order they were declared — a refinement you wrote, or a field's option set borrowed from a system. Refused on a field whose values are merely known (other values are legal there too), since that has no complete membership.
theses = members(<Thesis>)
ai #
ai("…") → a value from a language model, resolving to a real null when nothing applies — gate with != null. Optional settings record last: { tier: "quick" } (default), "careful", "thorough". See ai in the expressions chapter for the full treatment — prompting, tiers, and the null-gating idiom.
Category: ai("one of: bug, feature, question — for: ${msg.Subject}", { tier: "careful" })
Operators and indexing #
conditional #
c ? a : b — the value-level conditional; if (c) { … } branches statements instead. The : b is required, so a conditional always has a type. Only the arm that is taken is evaluated, so a call in the other arm never runs. The condition guards its own a the way a statement if guards its arm, so a presence test on the condition narrows inside a only.
line = domain != null ? "${domain}" : ""
unary minus #
-x → number — arithmetic negation. The operand must already be a number that is there: coerce first if it isn't (-coalesce(Number.parse(x), 0)), and narrow or coalesce one that may be absent. Parenthesise a sub-expression the same as any arithmetic: -(a + b).
delta = -(after - before)
remainder #
a % b → number — same precedence as * and /, left-associative. TypeScript semantics: the sign follows the dividend (-7 % 20 is -7, not 13). Both operands must already be numbers that are there (narrow or coalesce one that may be absent), the same rule every arithmetic operator has; division by zero answers null.
leftover = amount % batch_size
xs[i] #
Indexing, by what you index. xs[n] → T | absent — a number index into a list, needing an ordered sequence exactly as first does (refused otherwise). A list literal is a tuple, so [a, b][0] reads the first slot exactly and is always present. d.k → present when k is one of the dict's own written keys (a typo is refused with a did-you-mean). d[key] → T | absent when the key is computed at run time, or when the dict's per-key shape isn't known (a system value, a List.groupBy/List.keyBy result — its keys are data, not declarations).
top = sort(names)[0]
copy = { intro: "Welcome", body: "…" }
bucket = copy.intro
Text #
Text.trim #
Text.trim(text) → the text with leading and trailing whitespace removed. Null in, null out.
name = Text.trim(msg.Subject)
Text.lower #
Text.lower(text) → the text, lowercased. Null in, null out.
key = Text.lower(msg.From)
Text.upper #
Text.upper(text) → the text, uppercased. Null in, null out.
code = Text.upper(Text.trim(msg.Subject))
Text.includes #
Text.includes(text, part) → boolean — whether the text contains part, ignoring case. The same test as CONTAINS in a WHERE.
urgent = Text.includes(msg.Subject, "urgent")
Text.length #
Text.length(text) → number, always present — the character count of a text (an emoji is one character). 0 for null. A list's length is count(xs).
big = Text.length(msg.Body) > 500
Text.from #
Text.from(value) → the value's text form. Null in, null out — a number, date, or boolean prints as its usual text; a record or list is refused (write a field off it instead).
label = Text.from(amount)
Text.split #
Text.split(text, separator?) → a list of text, always present (empty when the text is empty). Each piece is trimmed and empty pieces are dropped; separator defaults to ",".
tags = Text.split(msg.Subject, ";")
Text.concat #
Text.concat(a, b, …) → text, always present — joins its arguments as text. A part that is T | absent is accepted and prints as nothing, the same rule interpolation follows ("${x}" desugars to this same call). A structured value (a dict, a record) is refused — write a field off it first.
line = Text.concat(msg.From, " — ", msg.Subject)
Text.join #
Text.join(list, separator) → text, always present — the separator is text written in place (", "), never a name or a template. Needs an ordered sequence (an ORDER BY on the hop, a relationship the source keeps in order, or a sort), refused otherwise naming the fix. Over a bound block it joins what the iterations returned.
roster = Text.join(sort(co-[t:Team]->.Name), ", ")
Text.chunks #
Text.chunks(text, { size | entities, overlap }) → a list of text, always present — cuts a long text into pieces. size bounds each piece by characters; entities instead sizes each piece by the records it is expected to yield, cutting on whole lines; exactly one of the two is written, and overlap (characters repeated from the piece before) works with either. unit: takes only "chars".
pieces = Text.chunks(text, { size: 40000, overlap: 2000 })
Text.regexExtract #
Text.regexExtract(text, pattern, group?) → text, empty when nothing matches — the first regex match, or the first capture group when the pattern has one. The pattern reads whole characters (. matches an emoji) and is absent when malformed: escape only punctuation (\\., \\-) or a known escape (\\d, \\s, \\x41, \\u{1F600}); an octal escape or an unknown letter escape is malformed.
ref = Text.regexExtract(m.Subject, "(DEAL-\\d+)")
Text.slug #
Text.slug(text) → text, always present — a lowercase-hyphen slug.
slug = Text.slug("Acme Corp Ltd.") # "acme-corp-ltd"
Text.pairs #
Text.pairs(record, separator?) → text, always present — a record or a dict rendered as key=value pairs, keys in the order they were written (a dict literal's own order; a declared or extracted record's field order), joined by separator (default " | "). It takes a record from a walk or a name — Text.pairs(e) inside deduped-[e:entries]-> { … }, an extracted node, a first(…) answer — as well as a literal. Each value renders exactly as ${…} interpolation would: absent is empty, a boolean is true/false, a number is Text.from's form. A nested node or edge field is SKIPPED, not stringified — only scalar fields make it into the line. Refused on <json>: it looks keyed but the checker cannot see its keys, so name the fields into a dict first.
Project A's composer posts one Slack line per candidate row:
summary = Text.pairs({ name: c.Name, url: c.Website, stage: c.Stage })
# "name=Acme | url=acme.com | stage=Series A"
line = "candidate — ${Text.pairs({ name: c.Name, url: c.Website }, ", ")}"
# "candidate — name=Acme, url=acme.com"
Json #
Json.stringify #
Json.stringify(value) → text, always present — any value written out as JSON text for a prompt, the same bytes every time it is the same value (so a prompt that opens with it stays cacheable). The JSON has two-space indent and keys sorted at every level, so the order you wrote fields in never shows. A record is its fields, plus one key per edge it carries holding the nodes on that edge as a list (always a list, even for one node). A dict, list or record inside the value is written out the same way. Absent is null (a record's absent field keeps its key; a whole absent value is the text null). Dates and datetimes are ISO text; a file is { contentType, kind: "file", name, size }, never its contents. A record read live from a system is refused (build a dict of the fields you want).
card = Json.stringify({ name: "Acme", stage: c.Stage })
# two lines of "name" and "stage", sorted, indented two spaces, inside braces
Number #
Number.abs #
Number.abs(number) → number, the absolute value.
diff = Number.abs(before - after)
Number.round #
Number.round(number) → number, rounded to the nearest whole number (a half rounds away from zero: 2.5 is 3, -2.5 is -3).
pct = Number.round(score * 100)
Number.floor #
Number.floor(number) → number, rounded down.
pages = Number.floor(Text.length(text) / 3000)
Number.ceil #
Number.ceil(number) → number, rounded up.
batches = Number.ceil(count(rows) / 50)
Number.parse #
Number.parse(value) → number | absent — coerces text to a number, and is absent when the text is no number. Guard it (n != null) or give it a fallback (coalesce, ?:) where a value is required. NUMBER(value) is its spelling inside walk brackets.
n = Number.parse(msg.Subject) ?: 0
crm-[t:Teams WHERE t.`Team Size` <= NUMBER(m.Body)]->
Number.format #
Number.format(number, style) → text | absent — a number written out. style is a literal, checked when you save: "compact" abbreviates with K/M/B/T, one decimal at most and no trailing .0; "grouped" adds thousands separators, keeping a decimal part up to two places.
short = Number.format(1200000, "compact") # "1.2M"
long = Number.format(1200000, "grouped") # "1,200,000"
Date #
Date.from #
Date.from(value) → date — coerces any readable date or timestamp to a calendar day (midnight UTC). Reach for it when a comparison spans two kinds of value; DATE(value) is its spelling inside walk brackets.
crm-[s:Tasks WHERE s.`Snoozed Until` <= DATE(m.Body)]->
Date.parse #
Date.parse(text) → date | absent — a written date read as an ISO date; unreadable text (including an ambiguous numeric form like "12/03/2026") is absent rather than a guess.
d = Date.parse("12 March 2026") # "2026-03-12"
Date.addDays #
Date.addDays(date, days) → date, always present — a date shifted by whole days.
deadline = Date.addDays(@current_date, 14)
Date.format #
Date.format(value, pattern) → text, always present — a date written out for a person to read, from the tokens YYYY YY MMMM MMM MM M DD D dddd ddd HH H mm m ss s; anything that is not a letter prints as written. The pattern is a literal, checked when you save.
line = Date.format(@current_date, "MMMM D, YYYY") # "August 31, 2026"
Date.today #
Date.today(zone) → date, always present — the calendar date it is right now in a place, reading the run's pinned instant (not a live clock: every read in one run gives the same day). zone is a literal IANA name, checked when you save.
today = Date.today("Europe/Berlin")
DateTime #
DateTime.from #
DateTime.from(value) → datetime — coerces to a full UTC instant; DATETIME(value) is its spelling inside walk brackets.
sent = DateTime.from(m.Date)
DateTime.at #
DateTime.at(date, time, zone) → datetime, always present — the instant a wall-clock time on a date names in a place. time and zone are literals, checked when you save. Move days with Date.addDays and anchor each end of a window separately — a daylight-saving change then takes care of itself.
win_end = DateTime.at(today, "07:00", "Europe/Berlin")
DateTime.toIso #
DateTime.toIso(value) → any readable date or timestamp normalised to a full ISO 8601 UTC timestamp. A text with a time of day is read as ISO 8601 / RFC 3339 only (2026-03-05T10:00, 2026-03-05 10:00:30+01:00); with no offset it is UTC.
iso = DateTime.toIso(m.Date)
Currency #
Currency.numberFromFigure #
Currency.numberFromFigure(figure) → number, always present (empty when unreadable) — the numeric amount in a money figure.
amount = Currency.numberFromFigure("£1.2m") # 1200000
Currency.codeFromFigure #
Currency.codeFromFigure(figure) → text — the ISO currency code in a money figure.
code = Currency.codeFromFigure("£1.2m") # "GBP"
Currency.formatFigure #
Currency.formatFigure(number, code) → text | absent — a number as a money figure, the inverse of Currency.numberFromFigure: a symbol where the code has one, the ISO code after a space where it doesn't, the bare number for an empty or unrecognised code. code may be any expression, not only a literal.
line = Currency.formatFigure(1200000, "EUR") # "€1.2M"
chf = Currency.formatFigure(1200000, "CHF") # "1.2M CHF"
Url #
Url.host #
Url.host(text) → text | absent — the host, lowercased and otherwise verbatim (www. kept, no port, no path). Takes a full URL (https://acme.com) or a scheme-less address (acme.com, acme.com:8080, www.Acme.com/path) — the bare form is read as if http:// were in front of it. Absent for an email (joe@acme.com names an account, not a host) and for text that names no address at all.
host = Url.host("https://WWW.Acme.com:8080/deals/1") # "www.acme.com"
host2 = Url.host("acme.com") # "acme.com" — no scheme needed
List #
List.sum #
List.sum(list of number) → number, always present, reads any order — the total. 0 over an empty list. A list that may hold an absent member is refused: List.compact it first, or filter(xs, (x) => x != null).
total = List.sum(co-[o:Orders]->.Amount)
List.includes #
List.includes(list, value) → boolean — whether the list holds value. The same test as IN in a WHERE.
known = List.includes(["won", "lost"], deal.Stage)
List.avg #
List.avg(list of number) → number, always present, reads any order — the mean.
average = List.avg(co-[o:Orders]->.Amount)
List.min #
List.min(list) → T | absent, reads any order — the smallest member; absent over an empty list. The element must be orderable (a number, date, datetime, or text).
earliest = List.min(co-[o:Orders]->.`Placed At`)
List.max #
List.max(list) → T | absent, reads any order — the largest member; absent over an empty list.
biggest = List.max(co-[o:Orders]->.Amount)
List.last #
List.last(list) → T | absent — the same ordering requirement as first, reading the other end.
oldest = List.last(sort(co-[o:Orders]->.`Placed At`))
List.from #
List.from(list | walk) → a list, always present (empty rather than absent), reads any order but keeps the source's order when it has one. Materialises a walk into an ordinary list value — the form to reach for when a later fold (Text.join, first, xs[n]) needs one and the walk itself has none to give.
names = List.from(co-[p:People]->.Name)
List.compact #
List.compact(a, b, c, …) → a flat list of the arguments with nulls dropped, always present. A list argument flattens one level in, so List.compact is the way to combine several possibly-empty values (or lists of them) into one list.
Tags: List.compact("inbound", region, [`Sub Tag`])
List.groupBy #
List.groupBy(list, key) → a dict of lists, always present — files each member under the key its function answers. The values carry the members' own shape (a dict literal's fields read straight off a group); the keys are data, so a lookup with by[key] is always T | absent even against a literal string.
by = List.groupBy(rows, (r) => { return r.thesis })
List.keyBy #
List.keyBy(list, key) → a dict of members, one per key, always present — a repeated key fails the run, naming it. Same shape-carrying and key-lookup rules as List.groupBy.
byId = List.keyBy(co-[p:People]->, (p) => { return p.Email })
File #
File.from #
File.from(content, "text") → a file value, always present — turns a composed string into a text file for a file-typed field. The content is kept as written, line breaks preserved.
File: File.from(digest, "text")
pdf.render #
A PDF is drawn by the @lf/pdf plugin: pdf.render(content) → a file value, the PDF, always present. The content is drawn as plain text, line breaks preserved. { pageSize: "a4" | "letter", margin } (points, from 0 to 200) is the settings record, last. A character the default writer has no glyph for (anything outside Latin-1 and the common punctuation) is drawn as ? and the run is warned, naming @lf/pdf.
import { `@lf/pdf` as pdf } from plugins
File: pdf.render(digest)
File: pdf.render(digest, { pageSize: "letter" })
File.read #
File.read(file) → text | absent — the text in a file. An extraction reads a file in its content itself; reach for File.read when you want the text in hand to cut, measure, or pass to a plugin.
text = File.read(f.File)