Expressions — reading, combining, and generating values

Expressions — reading, combining, and generating values #

Everywhere an automation needs a value — a write field, an if condition, an item in an extraction's data list — it uses the same expression grammar. To choose between two values inside one, use <cond> ? <a> : <b>; the statement if branches whole statements instead, and has its own chapter.

interpolation #

Message: "New enquiry from ${msg-[:Sender]->.Name}:
  ${company.url}"

Double-quoted strings span newlines and interpolate with ${…}. A value that may not be there prints as nothing, so "${x}" needs no fallback — in an ai("…") prompt too. Whenever you are composing prose a person will read, write it as a template rather than as a Text.concat — the template shows the paragraphs it produces.

ai #

Name: ai("the company name this email is about.
  Prefer the legal entity name over the brand name;
  ignore the sender's own firm.

  The email:
  Subject: ${msg.Subject}
  ${msg.Body}")

ai("…") computes one value from an instruction in plain prose. The model sees only your prompt — not the triggering event, not the message body, nothing else from the run — so interpolate in the content the judgement needs. A prompt with no interpolated content is answered blind, and that is the usual cause of an ai() value that comes back empty or generic. For several records with several fields each, declare an extract tree instead: one pass rather than many independent calls.

A settings record says how much thinking the answer is worth, as { tier: … }. Three tiers, and nothing else to choose:

Leave it off and you get "quick". Nothing here names a model: which one answers, and how long it may take over the answer, is ours to keep current, and it changes without your automation changing.

Category: ai("one of: bug, feature, question — for: ${msg.Subject}")
Summary:  ai(`Digest Prompt`, { tier: "careful" })
Ranking:  ai("rank these by how well each fits the thesis: ${`Candidates`}", { tier: "thorough" })

Long prompts read best bound once at file scope under a name of their own, then passed by that name where the call needs them.

ai() may decide nothing applies, and then resolves to a real null rather than placeholder text like "none" or "N/A". Write the prompt so that no answer is a legitimate outcome ("only if…", "if any"), and gate on the result:

`Suggested Action` = ai("a brief, actionable next step — only if one is
  genuinely needed — suggested by this email: ${msg.bodyText}")
if (`Suggested Action` != null) {
  channel = only(team-[ch:Channels WHERE ch.Name == "follow-ups"]->)
  if (channel == null) { fail("no #follow-ups channel") }
  write channel-[:Messages]-> { Message: `Suggested Action` }
}

meta-fields #

write crm-[:Companies]-> {
  Name:          msg.Subject
  Description ?: "Owner: ${@user_email} — forwarded in by ${@actor_name} on ${@current_date}"
}

Values that come from the run itself rather than from any record wear an @ prefix and read like any other value, anywhere an expression can appear. There are eight, in three groups:

The two agree until a forward, relay, or service account sits between the originator and the responsible person. A meta-field the run can't resolve — an event the host maps to no identity, a source that reports no sender — is simply empty, never an error.

aggregates #

n       = count(co-[t:Team]->)
owner   = only(co-[t:Team WHERE t.Role == "Owner"]->)
newest  = first(crm-[c:Companies ORDER BY c.`Created At` DESC]->.Name)
thread  = Text.join(ch-[m:Messages LIMIT 20]->.Text, "
")
roster  = Text.join(sort(co-[t:Team]->.Name), ", ")

An aggregate reads a whole traversal (or a bound block's list) and answers one value. Which ones you may reach for depends on whether what you are reading has an order.

keyed-values #

sections = { intro: "Welcome", body: "The details", sign_off: "the team" }
line     = sections.body

A dict is a set of values looked up by name — write it in braces, read it with d["key"]. Keys are text and nothing else: a key of some other kind is refused when you save, with the coercion for you to write (Date.format(d, "YYYY-MM-DD") for a day, or a template for anything else) — there is no one right spelling, and a silent choice is how two halves of the same automation come to disagree about the same day.

A dict written in braces knows its keys, each with its own type. Looked up by a key written in quotes — or read with a dot, sections.body, which is the same lookup — it answers that key's value, always there; a key it was not written with is refused when you save, naming the closest one. Looked up by a key worked out while the automation runs, it answers T | absent: a key that is not there is the everyday case, not a failure, so discharge it the way you discharge any other possibly-missing value. A dict whose keys came from data — List.groupBy's answer — is always looked up that way.

iterating-values #

lines    = ch-[m:Messages]-> { return m.Text }
bulleted = map(lines, (t) => "• ${t}")
kept     = filter(lines, (t) => Text.length(t) > 0)
total    = reduce(lines, (carried, t) => carried + Text.length(t), 0)

Iterate a collection with these five, each given a function that runs once per member. A record is a value too, so a hop written on its own — ch-[m:Messages]-> — is a collection of records, and these read it like any other.

A function written in place takes its parameter's type from the collection, so there is nothing to annotate, and its body may be a single expression (see closures). It may await: a member that waits parks on its own while the others go on — a waiting member holds its place, so no more than concurrency members are in flight — and the op hands back its value, in member order, once every member has finished. filter and reduce must return something — a filter needs a boolean, a reduce needs the value it is carrying. map alone allows a function with no return: each slot is then absent, and the writes inside it still run — map(ch-[m:Messages]->, (m) => { write graph-[:note]-> { text: m.Text } }) is legal on its own, with no binding, run purely for what it writes.

theses = members(<Thesis>)

members(<T>) lists a closed type's values in the order they were declared — a refinement you wrote, or a field's option set borrowed from a system. That order is a fact about the type, so a report's sections come from the declaration instead of a list kept beside it. A field whose values are merely known (other values are legal there too) has no complete membership, and is refused.

map-and-filter-settings #

map and filter take a settings record as their last argument:

profiles = map(companies, (c) => {
  return extractOne([...content, Json.stringify(c)], Profile, { tier: "careful" })
}, { onError: "warn", concurrency: 4, initialConcurrency: 1 })

closures #

A function written in place is a value. Its body is a block, or a single expression as in TypeScript:

inc     = (v: <number>) => v + 1
shout   = (t: <text>) => Text.upper(t)
doubled = map(counts, (x) => x * 2)

calls-inside-expressions #

Because a function's arguments are ordinary expressions, a call can sit inside any expression: double(n) + 1, extractOne(content, Company), count(map(xs, f)), map(map(xs, f), g), log(map(xs, f)).

lists-spread-and-tuples #

pdfs    = m-[a:Attachments WHERE a.`Content Type` == "application/pdf"]->.File
content = [m.Body, ...pdfs, "end"]
inline  = [m.Body, ...m-[a:Attachments]->.File]

a-table-you-declare-once #

roster = [
  { Name: "Ada",   Theme: "Consumer" },
  { Name: "Grace", Theme: "Infra" },
  { Name: "Alan",  Theme: "Health" },
]

node Mention: "each company named, weighed against the team's own themes" {
  name: <text> "the company's name"
}

function `Match Mentions`(m: <inbox-[:Email]->>) {
  themes = Text.join(map(roster, (r) => "${r.Name} (${r.Theme})"), ", ")
  byName = List.keyBy(roster, (r) => r.Name)
  found  = extract([m.Body], Mention)

  owner = byName.Ada
  if (owner == null) { fail("no such teammate on the roster") }
  map(found, (c) => {
    write crm-[:Companies]-> { unique by (FUZZY Name), Name: c.name, Description ?: "flagged by ${owner.Name}; team themes: ${themes}" }
  })
}

A file-scope list of dicts is a small table every function in the file reads. map and Text.join turn it into one string for a prompt — here, an extraction's description. List.keyBy turns it into a dict keyed by one field, so a lookup is a plain read; a computed key still needs the guard, since List.keyBy's keys are data.

spreading-maps-and-records #

base   = { stage: "Seed", source: "email" }
row    = { ...base, stage: "Series A" }
merged = { ...c, ...details }

values-that-may-not-be-there #

Name: coalesce(msg-[:Sender]->.Name, msg-[:Sender]->.Email)

A traversal that yields nothing makes the value empty rather than failing, and coalesce takes the first non-empty of its arguments. To test rather than fall back: exists(msg-[:Sender]->) tells whether a relationship has any records, and x != null or x == null tells whether a binding or a field read off one has a value — `Suggested Action != null,channel.Topic == null`.

A separate and stronger thing is a value typed as possibly not there at all — T | absent. That is a real part of the type, not a caution. Five things produce one:

Reading such a value is always fine, and so is interpolating it — it prints as nothing. Testing it needs no guard either. == and != take it on either side: a missing value equals only null, so o.stage == "Seed" is false and o.stage != "Seed" true when the model found nothing. A condition takes a <boolean> that may be missing and reads a missing one as false — if (o.viable) { … }, o.viable ? a : b, a && o.viable, !o.viable.

What is refused is using it somewhere a value is genuinely required — a write field, a write or traversal target, a field read off it, an ordered comparison (<, <=, >, >=), arithmetic (n + 1, -n, Number.round(n), and List.sum, List.avg, List.min or List.max over a list that may hold an absent member) — and the refusal comes while you write rather than at run time. There is no "it will probably be there".

Four things discharge it, and one of them always fits:

For a list that may hold absent members, drop them (List.sum(List.compact(xs))) or fill them (List.sum(map(xs, (x) => coalesce(x, 0)))) before folding.

Ordered comparisons and arithmetic get no licence from the ?: fill — there is no write field to fill — so either give the value a fallback with coalesce, or prove presence first (a guard clause, a != null test, a traversal gate) and order inside the branch — or, for a guard clause, the code after it — that proved it.

helper-families #

The standard namespaces — Currency, Text, Url, Date, DateTime, Number — do the messy real-world conversions deterministically: no model involved, the same input always gives the same answer, and an input the function can't read gives an empty value rather than an error. Currency reads money figures out of text ("£1.2m" → 1200000, or its code → "GBP"); Text does surgical string work (a regex extraction, a slug); Url.host(text) reads the host out of a link (www. kept, no port, no path) and gives empty for text that isn't a URL — the same shape as Date.parse, one function reading a shape out of loose text; Date handles dates without guesswork — Date.parse("12 March 2026") → "2026-03-12", while an ambiguous numeric form ("12/03/2026" — day-first or month-first?) deliberately gives empty rather than a guess. Date.format(value, "MMMM D, YYYY") writes a date out for a person to read — "August 31, 2026" — from the tokens YYYY YY MMMM MMM MM M DD D dddd ddd HH H mm m ss s, with anything that is not a letter printed as written; the pattern is written down and checked when you save, so an unknown token is named there rather than printed wrong. Date.today(zone) and DateTime.at(date, time, zone) do time zones — the next section. The reference chapter lists every member; a name that isn't one is refused when you save, with the namespace's real functions listed.

time-zones-and-windows #

Take the day in a place, move whole days on it, and anchor each end of the window on its own:

berlin_today = Date.today("Europe/Berlin")
win_end      = DateTime.at(berlin_today, "07:00", "Europe/Berlin")
win_start    = DateTime.at(Date.addDays(berlin_today, -1), "07:00", "Europe/Berlin")

Arithmetic happens in calendar space; anchoring happens last. Date.addDays moves a day on the calendar, and DateTime.at(date, time, zone) turns a day plus a wall-clock time into the one instant it names there. Anchor each end separately and the window between yesterday 07:00 and today 07:00 comes out 23 or 25 hours long on the two days a year the clocks move, without you writing anything down. Anchor one end and subtract 24 hours instead, and those two days silently run from 06:00 or 08:00.

Date.today(zone) reads the moment the run fired, in that zone — a run firing at 00:30 in Berlin is on the Berlin date, not yesterday's UTC one. Every reading of the clock in one run gives the same answer, including after the run waits for a person, so a window computed at the start and one computed at the end agree.

Both the zone ("Europe/Berlin", "America/New_York", "UTC") and the time ("07:00", 24-hour HH:mm) are written down and checked when you save. DateTime.at always names an instant: on the spring-forward morning a time the clock skips resolves forward to the moment the clock resumed, and on the fall-back morning a time that happens twice takes the first of the two.

coercers #

Date.from(value), DateTime.from(value) and Number.parse(value) are coercers — they say "treat this as a calendar day / an instant / a number". Reach for a coercer when a comparison spans two kinds of value: a comparison only makes sense within one kind, so Snoozed Until` <= 5` is refused when you save andSnoozed Until<= Date.from(…) is the repair. (Dates and timestamps compare freely — a date is midnight. When a value's type isn't known, the comparison is left alone; only a definite clash is flagged.)

Prefer a helper over ai() whenever one fits: it is instant, free, and gives the same answer every run.

Beyond the built-ins, a target system may supply functions of its own on particular fields — a chat system's message formatter on a message's text, say. Those are members of the adapter's imported package (Message: slack.<formatter>(…)) and run as the value of the write field that advertises them: the target supplies the function, so it exists exactly where you write to that target. Calling one anywhere else — a condition, a plain binding — or calling a name no field advertises, fails the run naming the function.

file-artifacts #

File: pdf.render(digest)
File: File.from(digest, "text")

pdf.render(content) (the @lf/pdf plugin, imported with import { `@lf/pdf` as pdf } from plugins) and File.from(content, "text") turn a composed string into a file you can write into a file-typed field — an attachment, a document slot. The content is drawn or kept as plain text preserving your line breaks, so compose it first (interpolation, or Text.join over a block's results — the patterns chapter has the digest shape) and then wrap it. The default PDF writer draws Latin-1 text; any other character is drawn as ? and the run gets a warning naming @lf/pdf. The artifact remembers where its content came from: its provenance trail carries the reads that fed the string.

Read a file the other way — File.read(file) hands back the text in it, and Text.chunks cuts a long text into pieces:

text   = File.read(f.File)
pieces = Text.chunks(coalesce(text, ""), { size: 40000, overlap: 2000 })