Docs / Reference
a9script function reference
Everything a script can call, generated from the platform’s function catalog.
Do not edit this file — it is rendered from the catalog; change the catalog
and run npm run docs:reference.
Two kinds of thing appear here. Some answer immediately, inside the run:
the same answer every time, nothing paused — and the values the platform
provides, like input. Platform calls do work outside the script; the run
pauses at the call and continues with the result it was given, so a resumed run
never calls twice. Scripts are written synchronously either way: there is no
await in this language.
Patterns
What to know before writing one of these calls. Each shows the shape to avoid and the shape to write instead — some are refused outright, some simply cost more than they look like they do.
Call platform functions from a plain loop, not from inside a callback
fetch, checkpoint and every other platform function work anywhere — inside a map, filter, forEach or sort callback included. Write a loop anyway: the calls happen one at a time either way, and only a loop can break when the list turns out to be long, the budget short, or an answer bad. A callback’s only exit is throw, which abandons the whole iteration.
Instead of this:
const contacts = input.event.body.items.map((item) =>
fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }),
);
return contacts.length;
write this:
const items = input.event.body.items;
const contacts = [];
for (const item of items) {
contacts.push(fetch({ connection: "crm", method: "GET", path: "/contacts/" + item.sku }));
}
return contacts.length;
// The loop can stop. `break` on a budget, a count, a bad answer —
// none of which a callback can say. From inside a callback the only
// way out is `throw`, which abandons the whole iteration.
Why — A map reads as a transformation and hides that each item is a call. A loop puts the calls in front of you, lets you stop early, and is the shape to reach for whenever the list is not small and known.
Better still — When the loop’s body only moves rows to or from a dataset, there is no loop to write: dsPutMany stores a whole list in one call, dsGetMany reads many keys back in one, and dsPage walks staged rows a page at a time. When the loop exists to read every page of an API, dsFetchInto is the whole pull in one call — it fetches, stages and keeps its place across a pause, which a hand-written cursor loop cannot. And when it exists to reshape staged rows or to fill them in from a second system, dsMapInto and dsEnrich do the whole set the same way: a batch at a time, on the platform’s side, with every call made exactly once even across a pause.
Turn a stored file into rows with the file calls, not by reading it into the script
A script may hold only a few hundred kilobytes of a file, so reading a stored CSV and parsing its text passes on a sample and refuses on a real export. dsFromCsv reads the file where it is stored into a dataset, a batch at a time; dsToCsv writes one back out.
Instead of this:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return parseCsv(atob(readBlob(file).contentBase64)).length;
write this:
const file = writeBlob(btoa("email\nada@example.com\n"), "text/csv");
return dsFromCsv(file, "run:contacts", { keyPath: "email" }).rows;
Why — A file’s contents are kept outside a run on purpose: a handle costs nothing to carry, a megabyte costs the run.
Better still — Keep readBlob and parseCsv for a small file you want the text of.
Count and summarise where the rows are, not in the script
A loop that reads staged rows only to add them up pulls the whole set through the run to produce a handful of numbers. dsCount answers a total, dsStats groups by any field, dsDuplicates finds the collisions a unique target field would refuse — each one query, no rows moved. The same holds for the sync’s own account of itself: sendReport renders the report dataset — totals, breakdowns, and the failures by name — and sends it, without a script reading a row.
Instead of this:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const counts = {};
let at = 0;
for (;;) {
const page = dsPage("run:people", { offset: at, limit: 200 });
if (page.length === 0) { break; }
for (const row of page) {
counts[row.value.dept] = (counts[row.value.dept] || 0) + 1;
}
at = at + page.length;
}
return counts.Research;
write this:
dsPutMany("run:people", [
{ key: "u1", value: { dept: "Research" } },
{ key: "u2", value: { dept: "Research" } },
{ key: "u3", value: { dept: "Operations" } },
]);
const groups = dsStats("run:people", "dept");
// One GROUP BY where the rows are. Each group is the value as JSON
// text — "Research" arrives with its quotes.
return groups[0].count;
Why — Staged data lives outside the run so that its size is not the run’s problem. A tally written in script quietly pulls it back in — the one thing the dataset calls exist to avoid.
Take the result of a call directly — there are no promises
A platform call returns its result to the next line. There is no await (the word does not parse), no .then, and nothing to hand a callback to — Promise is not defined. Callbacks themselves are ordinary; the platform simply never asks for one.
Instead of this:
const contacts = fetch({ connection: "crm", method: "GET", path: "/contacts" }).then(function (response) {
return response.body.items;
});
return contacts;
write this:
const response = fetch({ connection: "crm", method: "GET", path: "/contacts" });
return response.body.items;
Why — A call returns the answer itself — there is no promise, so there is no .then on it, and no callback for the platform to run later.
Core
log
Platform call — the run pauses here and continues with the result
log(message, data?)
Writes an info line to the run’s log — the record an operator reads afterwards.
Parameters
message(string) — The line’s text. A non-string value is recorded as JSON.data(object, optional) — Structured payload kept with the line, for detail a sentence cannot carry.
Returns — Nothing.
Example
log("order received", { id: input.event.body.id });
logInfo
Platform call — the run pauses here and continues with the result
logInfo(message, data?)
Writes an info line — the explicit spelling of log.
Parameters
message(string) — The line’s text.data(object, optional) — Structured payload kept with the line.
Returns — Nothing.
Example
logInfo("sync finished", { contacts: 42 });
logError
Platform call — the run pauses here and continues with the result
logError(message, data?)
Writes an error line. The run continues — an error line reports, it does not end anything; throw or return to end a run. A triggered run that logs one also turns its script’s health red until somebody acknowledges it; a Test never does.
Parameters
message(string) — What went wrong.data(object, optional) — Structured payload kept with the line (the offending record, a status).
Returns — Nothing.
Example
logError("contact rejected by the CRM", { id: input.event.body.id, status: 422 });
logWarning
Platform call — the run pauses here and continues with the result
logWarning(message, data?)
Writes a warn line — something survivable that a human should still see. A triggered run that logs one turns its script’s health amber until somebody acknowledges it; a Test never does.
Parameters
message(string) — What is off.data(object, optional) — Structured payload kept with the line.
Returns — Nothing.
Example
logWarning("contact has no address — skipped", { id: input.event.body.id });
logVerbose
Platform call — the run pauses here and continues with the result
logVerbose(message, data?)
Writes a verbose line. Kept in full while the run is recent; dropped from a SUCCEEDED run’s archived copy, so detail is cheap here.
Parameters
message(string) — The detail line.data(object, optional) — Structured payload kept with the line.
Returns — Nothing.
Example
logVerbose("page fetched", { page: 3, items: 25 });
logDebug
Platform call — the run pauses here and continues with the result
logDebug(message, data?)
Writes a debug line. Like logVerbose, it is dropped from a succeeded run’s archived copy and kept in full on a failed one.
Parameters
message(string) — The debug line.data(object, optional) — Structured payload kept with the line.
Returns — Nothing.
Example
logDebug("mapping the trigger payload", { raw: input.event.body });
respond
Platform call — the run pauses here and continues with the result
respond(value)
Answers the caller of a synchronous API call with value, as JSON. At most once per run — a second call throws. The run keeps going after it, and a run triggered by anything else (webhook, schedule, mail) simply has nobody listening.
Parameters
value(any) — The JSON-serialisable answer.respond(undefined)is a real, empty answer.
Returns — Nothing.
Example
respond({ ok: true, received: input.event.id });
stop
Platform call — the run pauses here and continues with the result
stop()
Does nothing — kept so the name resolves. A run ends with a top-level return (whose value becomes the run’s output) or by throwing.
Returns — Nothing.
Example
stop(); // does not end the run
return "nothing to do";
echo
Platform call — the run pauses here and continues with the result
echo(value)
Sends a value to the platform and back — a diagnostic. It answers identically in an on-demand Test run and in a dispatched one, which is what makes it useful when a script’s surroundings are in doubt. It carries no business meaning.
Parameters
value(any) — Any JSON-serialisable value; the same value comes back.
Returns — The value it was given.
Example
const pong = echo("ping");
log("round trip: " + pong);
fetch
Platform call — the run pauses here and continues with the result, and it does real network I/O
fetch(request)
Calls a configured HTTP connection. Authentication, retries, throttling, token refresh and log redaction are the platform’s job — the script names a connection and a path. A non-2xx answer RETURNS (branch on status); a call that could not be made throws, catchable, with its code in the message.
Parameters
request(object) —{ connection, method, path | url, query?, headers?, body?, timeoutMs? }.connectionis the name of a configured HTTP connection;pathis joined onto its base URL. Any other field is refused by name — the platform binds the tenant and environment itself.
Returns — { status, headers, body } — header names lowercased, body parsed when it is JSON.
Example
const response = fetch({
connection: "crm",
method: "GET",
path: "/contacts",
query: { updatedSince: input.settings.since }
});
if (response.status !== 200) {
throw new Error("crm answered " + response.status);
}
log("contacts: " + response.body.items.length);
input
Value — read it directly, nothing is called
input
The run’s input — a global variable filled from OUTSIDE before the script starts: input.event is what triggered the run, input.settings is what the linked settings script returned. Read-only: the whole object is frozen, and assigning to it throws.
Value — { event, settings }.
Example
log("triggered by " + input.event.kind);
input.event
Value — read it directly, nothing is called
input.event
What started this run: the webhook or API call, the scheduled fire, the arrived mail. Read-only — the whole input is frozen, and assigning to it throws.
Value — { kind, id, endpoint?, body, headers?, receivedAt, tenantId, environmentId } — body is the caller’s payload, kind says which door it came through.
Example
const order = input.event.body;
log("order " + order.id + " via " + input.event.kind);
input.settings
Value — read it directly, nothing is called
input.settings
The value the linked settings script returned, evaluated once before this run started — configuration as data, so environment-specific values never sit in the script. undefined when no settings script is linked.
Value — Whatever the settings script returned.
Example
const baseUrl = input.settings.crmBaseUrl;
log("using " + baseUrl);
Encoding
base64Encode
Platform call — the run pauses here and continues with the result
base64Encode(text)
Text to base64, read as UTF-8 — so accented letters and emoji encode as themselves.
Parameters
text(string) — The text to encode.
Returns — The base64 text.
Example
const encoded = base64Encode(input.event.body.customer);
log(encoded);
base64Decode
Platform call — the run pauses here and continues with the result
base64Decode(text)
Base64 back to text. Anything that is not base64, and anything whose bytes are not text, is refused — a half-decoded string full of replacement characters is corruption that looks like data.
Parameters
text(string) — The base64 to decode.
Returns — The decoded text.
Example
const decoded = base64Decode("aGVsbG8=");
log(decoded); // hello
hexEncode
Platform call — the run pauses here and continues with the result
hexEncode(text)
Text to lower-case hexadecimal, read as UTF-8 — two characters per byte.
Parameters
text(string) — The text to encode.
Returns — The hexadecimal text.
Example
log(hexEncode("hi")); // 6869
hexDecode
Platform call — the run pauses here and continues with the result
hexDecode(text)
Hexadecimal back to text. An odd number of characters, anything outside 0-9a-f, and bytes that are not text are all refused.
Parameters
text(string) — The hexadecimal to decode.
Returns — The decoded text.
Example
log(hexDecode("6869")); // hi
Crypto
uuid
Platform call — the run pauses here and continues with the result
uuid()
A fresh identifier, version 4, from real randomness. The value is kept the moment it is drawn, so a run that is saved and continued answers the same id — which is what makes it safe as an idempotency key for a call you must not make twice.
Returns — The identifier as text, e.g. f81d4fae-7dec-4d0e-a765-00a0c91e6bf6.
Example
const key = uuid();
log("idempotency key " + key);
randomHex
Platform call — the run pauses here and continues with the result
randomHex(bytes?)
Random bytes, written as hexadecimal — for a one-off token or a nonce. Real randomness, kept the moment it is drawn, exactly like uuid().
Parameters
bytes(number, optional) — How many bytes, from 1 to 1024. Defaults to 16.
Returns — Lower-case hexadecimal — two characters per byte, so 32 characters by default.
Example
const nonce = randomHex(8);
log("nonce " + nonce);
sha256
Platform call — the run pauses here and continues with the result
sha256(text)
The SHA-256 digest of a piece of text, read as UTF-8 — the same digest every other system computes for the same string.
Parameters
text(string) — The text to hash.
Returns — The digest as lower-case hexadecimal, 64 characters.
Example
const digest = sha256(input.event.body.id);
log("digest " + digest);
hmacSha256
Platform call — the run pauses here and continues with the result
hmacSha256(key, message)
Signs a message with a shared secret (HMAC-SHA-256) — the shape webhook signatures use, so this is how a script proves a payload came from it. To CHECK an incoming signature, configure the endpoint’s own signature check instead: it runs before the script and compares in constant time, which === in a script does not.
Parameters
key(string) — The shared secret, as text.message(string) — What to sign, read as UTF-8.
Returns — The signature as lower-case hexadecimal, 64 characters.
Example
const signature = hmacSha256(input.settings.signingSecret, input.event.body.id);
log("signature " + signature);
Text
slugify
Answers immediately — nothing pauses
slugify(text)
Turns a line of text into a slug that is safe in a URL or a file name: lower case, words joined by dashes, accented letters folded to their plain form and everything else dropped.
Parameters
text(string) — The text to convert. Only text — a number or a missing value is refused rather than turned into the slug of the word for it.
Returns — The slug. Text with nothing worth keeping — punctuation alone, an emoji — answers an empty string.
Example
const slug = slugify(input.event.body.customer);
log("slug: " + slug);
template
Answers immediately — nothing pauses
template(text, values)
Fills {{key}} placeholders in a piece of text from an object of values. Nothing is escaped — the answer is plain text, so a value that has to be safe inside HTML or a URL must be made safe before it goes in.
Parameters
text(string) — The text to fill.{{name}}marks a placeholder and spacing inside the braces is ignored; a single brace is ordinary text.values(object) — What to put in, looked up by key. Text, numbers and booleans are written out; a key the object does not carry, and a value that is null or missing, render as nothing; an object value is refused, since it has no sensible written form.
Returns — The filled text.
Example
const note = template("Hi {{name}}, order {{id}} is on its way.", {
name: "Ada",
id: input.event.body.id
});
log(note);
Numbers
formatNumber
Platform call — the run pauses here and continues with the result
formatNumber(value, options?)
Writes a number the way a reader in a given country expects — grouped thousands, the right decimal mark, as many decimals as you ask for.
Parameters
value(number) — The number to write. It must be finite.options(object, optional) —{ locale, minimumFractionDigits, maximumFractionDigits }.localeis a language tag such asen-USorde-DEand defaults toen-US— stated, never taken from the machine, so the same script writes the same number everywhere. Any other option is refused by name.
Returns — The formatted text.
Example
log(formatNumber(1234.5, { locale: "de-DE" })); // 1.234,5
formatCurrency
Platform call — the run pauses here and continues with the result
formatCurrency(value, currency, options?)
Writes an amount of money: the symbol where that country puts it, and the number of decimals the currency itself uses — two for dollars and euros, none for yen.
Parameters
value(number) — The amount. It must be finite.currency(string) — A three-letter currency code such asUSD,EURorJPY.options(object, optional) — The same optionsformatNumbertakes, with the sameen-USdefault when no locale is given.
Returns — The formatted text.
Example
const total = input.event.body.items[0].price;
log(formatCurrency(total, "EUR", { locale: "de-DE" }));
Structured data
get
Answers immediately — nothing pauses
get(data, path)
Reads a value out of a shape by a path, without failing when part of the path is not there. get(order, "customer.address.city") answers the city, or nothing at all when the order carries no address — where order.customer.address.city would end the run.
Parameters
data(object | array) — The shape to read from. Anything else — a number, or nothing at all — simply answers nothing.path(string) — Where to look: names joined by dots, positions in square brackets, as initems[0].sku. A name addresses a key of an object and[0]addresses an entry of an array. A path that is not written that way is refused, because no data can make it right.
Returns — The value at that place, or nothing when any step of the path is missing.
Example
const city = get(input.event.body, "shipping.address.city");
log(city === undefined ? "no city was given" : city);
set
Answers immediately — nothing pauses
set(data, path, value)
Answers a copy of a shape with one place changed. What you pass in is never touched — which is how a script changes something in the payload it was handed, since that payload is read-only.
Parameters
data(object | array) — The shape to derive from. Nothing at all is allowed too, and the answer is then just the shape the path describes.path(string) — Where to write — the same pathsgetreads. A step that does not exist yet is created: a name makes an object, a position makes an array. A step that holds a plain value, or one whose kind disagrees with the path, is refused by name rather than overwritten.value(any) — What to put there.
Returns — A new object or array. Everything the path did not touch is the very same value it was, not a copy of it, so changing one field of a large payload stays cheap. That sharing is also why a branch the answer still shares with the trigger’s payload cannot be assigned into — reach it with another set instead.
Example
const enriched = set(input.event.body, "status", "received");
log("status is now " + enriched.status);
jsonPath
Answers immediately — nothing pauses
jsonPath(data, expression)
Collects every value a shape holds at some place, as a list — for when the interesting values sit at a depth you would otherwise write a loop per level to reach.
Parameters
data(object | array) — The shape to search.expression(string) — Starts at$, the whole value..nameand['name']step into a key,[0]into a position,[*]takes every entry, and..namefinds that name at any depth. Anything else is refused rather than quietly matching nothing.
Returns — A list of the matching values, at most 10000 of them — an expression selecting more is refused rather than answered in part, so narrow the path or read the shape in smaller pieces. A descent answers shallow-first: a value’s own match comes back before matches found deeper inside it. A branch the shape holds in two places matches once per place. Nothing matched answers an empty list.
Example
const skus = jsonPath(input.event.body, "$.items[*].sku");
log("the order names " + skus.length + " products");
parseCsv
Platform call — the run pauses here and continues with the result
parseCsv(text, options?)
Reads CSV text into a list of rows. Quoted fields, embedded commas, quotes and line breaks are all handled — this is a real CSV reader, not a split on commas.
Parameters
text(string) — The CSV to read.options(object, optional) —{ delimiter, header }.delimiterdefaults to a comma;headerdefaults to true, meaning the first line NAMES the columns and each row comes back as an object. Set it to false to get each row as a list of cells instead. Any other option is refused by name.
Returns — A list of rows: objects keyed by column name, or lists of cells when there is no header. Every cell is TEXT — a CSV says nothing about types, and reading 01234 as a number would lose the leading zero of a postcode or an SKU. Use Number(cell) where you want a number.
Example
const rows = parseCsv("sku,qty\nA-1,2\nB-2,1\n");
log("the file holds " + rows.length + " rows");
log("first SKU: " + rows[0].sku);
toCsv
Platform call — the run pauses here and continues with the result
toCsv(rows, options?)
Writes a list of rows as CSV text, quoting whatever needs quoting. A cell that begins with =, +, - or @ is written as text, so a spreadsheet opening the file cannot be made to run it as a formula.
Parameters
rows(array) — The rows: objects keyed by column name, or lists of cells. Text, numbers and booleans are all written; a missing value or null writes an empty cell.options(object, optional) —{ delimiter, header, columns, guardFormulas }.columnsnames the columns to write and their order — otherwise the keys of the first row are used, so give it when later rows may carry keys the first does not.guardFormulasdefaults to true and is the protection described above; turn it off only when the file is not going to be opened in a spreadsheet.
Returns — The CSV text, one line per row, ending in a line break.
Example
const text = toCsv([
{ sku: "A-1", qty: 2 },
{ sku: "B-2", qty: 1 }
]);
log(text);
parseXml
Platform call — the run pauses here and continues with the result
parseXml(text, options?)
Reads XML into an object tree. An element becomes a key, a repeated element becomes a list, and an attribute becomes a key with @ in front of it — so an attribute and a child element of the same name never collide.
Parameters
text(string) — The XML to read. Text that is not well-formed is refused rather than half-read.options(object, optional) —{ attributePrefix, textKey, lists }.attributePrefixdefaults to@;textKeydefaults to#textand is where an element’s own text goes when it also carries attributes.listsnames the elements that are ALWAYS a list —["line"], or["order.line"]where the same name means something else at another depth — which is how a script that walks repeated data stops depending on how many the sender sent. Any other option is refused by name.
Returns — The tree. Every value is TEXT, for the reason parseCsv gives: XML carries no types, and reading 1.50 as a number would quietly drop the cents. One thing to watch: an element that appears ONCE is its value alone, not a one-entry list — so a document that happens to carry a single entry hands you its first character where you expected an entry. Name that element in lists and it is a list however many times it appears; without it, check Array.isArray first.
Example
const tree = parseXml("<order id=\"o-1\"><line>A-1</line></order>", { lists: ["line"] });
log("order " + tree.order["@id"]);
log("first line: " + tree.order.line[0]);
toXml
Platform call — the run pauses here and continues with the result
toXml(value, options?)
Writes an object tree as XML. A key with @ in front of it becomes an attribute, a list becomes a repeated element, and everything else becomes an element.
Parameters
value(object) — The tree to write.options(object, optional) —{ attributePrefix, textKey, indent }— the first two asparseXmltakes them, so a tree read with one prefix can be written back with the same one.indentdefaults to false; set it to true for XML a person is going to read.
Returns — The XML text.
Example
const xml = toXml({
order: { "@id": input.event.body.id, total: "42.00" }
});
log(xml);
Documents
createPdf
Platform call — the run pauses here and continues with the result
createPdf(document)
Draws a PDF and answers it as text you can file or send. You say what the document HOLDS — a list of blocks: headings, paragraphs, lists, tables, rules, gaps and page breaks — and how it is laid out is decided for you: text wraps, pages break where they must, and a table repeats its column names at the top of every page it runs onto. A document is set in the standard PDF fonts, which draw the Western alphabet; a character outside it — Greek, Cyrillic, Chinese, an emoji — is refused by name rather than drawn as something else.
Parameters
document(object) —{ title, blocks }.blocksis the list, drawn in the order you write it, and every block names itskind:heading(withlevel1, 2 or 3, andtext),text(withtext),list(withitems, andorderedto number them),table(withheadersandrows),divider,spacer(withheightin points) andpageBreak. Wherever text is expected you may also pass a number or true/false, and nothing at all writes an empty line — but a shape is refused, because there is no reading of it a person would want.titleis the name the document carries: a reader meets it in their PDF viewer’s title bar, never on the page, so a title to be SEEN is a heading block. Any other key, any unknown kind, and any row that does not fit its headers, is refused naming the block it is in.
Returns — The document as base64 text — the same shape writeBlob takes, so a report can be built and filed in two lines. Building the same document twice answers exactly the same text: nothing about the moment it was built goes into it, which is also why a date a reader needs belongs in a block they can see.
Example
const invoice = createPdf({
title: "Invoice 4711",
blocks: [
{ kind: "heading", level: 1, text: "Invoice 4711" },
{ kind: "text", text: "Thank you for your order." },
{ kind: "table", headers: ["item", "amount"], rows: [["Widget", 12.5], ["Delivery", 4]] },
{ kind: "divider" },
{ kind: "text", text: "Total: 16.50" }
]
});
log("the invoice is " + invoice.length + " characters of base64");
Markdown
markdownToHtml
Platform call — the run pauses here and continues with the result
markdownToHtml(markdown, options?)
Turns markdown into HTML — headings, lists, tables, links, code blocks, bold and italic, the dialect people write in. It does not make the result safe. Markdown is allowed to contain HTML, so anything dangerous in the markdown is still dangerous in the answer: if any part of it came from outside your own script — a form field, an email, a webhook — pass the answer through sanitizeHtml before it reaches a reader.
Parameters
markdown(string) — The markdown to read.options(object, optional) —{ lineBreaks }.lineBreaksdefaults to false, which is how markdown normally reads: a single newline is a space, and a blank line starts a paragraph. Set it to true when the text was typed in a box where people press Enter and expect a new line. Any other option is refused by name.
Returns — The HTML, as text. Characters that would otherwise be read as markup are escaped in ordinary text, but HTML written directly in the markdown is passed through as written — which is exactly why untrusted input needs sanitizeHtml.
Example
const html = markdownToHtml("# Report\n\nSales were **up**.");
log(sanitizeHtml(html));
sanitizeHtml
Platform call — the run pauses here and continues with the result
sanitizeHtml(html, options?)
Removes everything from HTML that could run, load or frame anything, and answers what is left. Works from a list of what is ALLOWED rather than a list of what is forbidden — so a tag nobody has heard of is removed too, which is what makes it safe against things nobody thought of yet.
Parameters
html(string) — The HTML to clean.options(object, optional) —{ allowedTags, allowedAttributes }. Both replace the standard list rather than adding to it, so a reader of the call can see everything it permits:allowedTagsis a list of tag names,allowedAttributesan object of tag name to the attributes that tag may keep. Passing an empty list for both answers plain text. Whatever you allow, a link or image address may still only use http, https, mailto or tel — widening cannot re-admit a dangerous one.
Returns — The cleaned HTML. A removed tag keeps the words it wrapped, so no content disappears — except for a script or a style block, whose contents go with it. Cleaning something already cleaned changes nothing.
Example
const safe = sanitizeHtml("<p>Hi</p><script>alert(1)</script>");
log("cleaned to: " + safe);
escapeHtml
Platform call — the run pauses here and continues with the result
escapeHtml(text)
Turns the five characters that change how markup is read — &, <, >, " and ' — into the codes that stand for them. Use it when you are building HTML yourself and want a value to appear as TEXT, exactly as written, rather than becoming part of the markup around it.
Parameters
text(string) — The text to escape.
Returns — The same text with those five characters replaced. Everything else — every alphabet, every accent, every emoji — is left exactly as it was.
Example
const name = escapeHtml("Ben & Jerry's <team>");
log("<p>Hello, " + name + "</p>");
stripMarkdown
Platform call — the run pauses here and continues with the result
stripMarkdown(markdown)
Answers the plain words of a markdown document, with all of its formatting removed — for a plain-text version of a message, a summary line, or anywhere a reader will not see markup.
Parameters
markdown(string) — The markdown to strip.
Returns — The text, with headings, emphasis, links and tags gone and the words kept. A link becomes the words it linked, not the address.
Example
const plain = stripMarkdown("# Title\n\nSome **bold** words.");
log(plain);
markdownToPdf
Platform call — the run pauses here and continues with the result
markdownToPdf(markdown, options?)
Draws a markdown document as a PDF, and answers it the way createPdf does. Headings become headings, lists become lists, tables become tables and a rule becomes a rule; bold and italic are flattened to their words, because a drawn document holds text rather than styled runs. Markdown has six heading levels and a document draws three, so a level 4, 5 or 6 heading is drawn at the smallest size rather than lost.
Parameters
markdown(string) — The markdown to draw.options(object, optional) —{ title }— the name the document carries, exactly ascreatePdftakes it: a reader meets it in their PDF viewer’s title bar, never on the page. Any other option is refused by name.
Returns — The document as base64 text — the same shape writeBlob takes. The same rules apply as when you build a document by hand, including which characters a document can draw: markdown that nests far deeper than anyone writes is refused rather than read.
Example
const pdf = markdownToPdf("# Invoice 4711\n\n| item | amount |\n| --- | --- |\n| Widget | 12.50 |", { title: "Invoice 4711" });
log("the document is " + pdf.length + " characters of base64");
Date & time
formatDate
Answers immediately — nothing pauses
formatDate(date, pattern)
Writes a date out in a pattern of your own, always in UTC. The tokens are YYYY, MM, DD, HH, mm and ss; every other character of the pattern is written as it stands.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value fromnew Date(...). A datetime without a timezone is refused: it would mean a different moment on every machine that read it.pattern(string) — The shape of the answer, e.g.DD.MM.YYYYorYYYY-MM-DD HH:mm:ss. Keep punctuation between the tokens: a word that happens to spell one carries it.
Returns — The formatted text.
Example
log(formatDate(input.event.receivedAt, "YYYY-MM-DD HH:mm"));
addDays
Answers immediately — nothing pauses
addDays(date, days)
Moves a date forwards or backwards by whole days and answers the new moment, in UTC. The time of day is kept.
Parameters
date(string | number | date) — An ISO date, a datetime that carries its timezone, milliseconds since 1970, or a date value — the same datesformatDatetakes.days(number) — How many days to move. A negative count moves back.
Returns — The moved date as an ISO string, e.g. 2026-07-21T00:00:00.000Z.
Example
const due = addDays(input.event.receivedAt, 14);
log("payable by " + formatDate(due, "YYYY-MM-DD"));
dateDiff
Answers immediately — nothing pauses
dateDiff(from, to)
How many whole days lie between two dates, counted as UTC calendar days: positive when to is the later one, negative when it is the earlier.
Parameters
from(string | number | date) — The date to count from — an ISO date, milliseconds since 1970, or a date value.to(string | number | date) — The date to count to, in any of the same forms.
Returns — The number of days. Two moments on the same UTC day answer 0; two hours either side of midnight answer 1, because the question is which day each one falls on.
Example
const age = dateDiff("2026-03-01", input.event.receivedAt);
log("the order is " + age + " days old");
now
Platform call — the run pauses here and continues with the result
now()
The current moment, in milliseconds. The same clock Date.now() reads, so the two can never disagree — and the reading is kept: a run that is saved and continued later still sees the moment it read.
Returns — Milliseconds since the start of 1970, as a number.
Example
const startedAt = now();
log("started at " + formatDate(startedAt, "YYYY-MM-DD HH:mm:ss"));
today
Platform call — the run pauses here and continues with the result
today()
Today’s date in UTC. The day now() falls on, written out.
Returns — The date as YYYY-MM-DD.
Example
log("the report covers " + today());
dateAdd
Platform call — the run pauses here and continues with the result
dateAdd(date, amount, unit, timeZone?)
Moves a moment forwards by whole units and answers the new moment. Months and years follow the calendar: one month after 31 January is 28 February, because there is no 31 February.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.amount(number) — How many units to move. A whole number; a negative one moves backwards.unit(string) — Which unit to move by:year,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose calendar to walk, such asEurope/Berlin. Without it, UTC — where a day is always twenty-four hours. With it, a day is a day on that clock: 09:00 in Berlin plus one day is 09:00 the next morning even when the clocks moved overnight, while plus 24 hours is 10:00.
Returns — The new moment as ISO text, written in the timezone that was named — 2026-02-28T10:00:00.000Z when none was.
Example
const due = dateAdd(input.event.receivedAt, 1, "month");
log("payable by " + dateFormat(due, "DD.MM.YYYY"));
const sameTimeTomorrow = dateAdd(input.event.receivedAt, 1, "day", "Europe/Berlin");
log("and again at " + dateFormat(sameTimeTomorrow, "HH:mm", "Europe/Berlin"));
dateSubtract
Platform call — the run pauses here and continues with the result
dateSubtract(date, amount, unit, timeZone?)
Moves a moment backwards by whole units — dateAdd the other way. Note that the two do not always undo each other: one month after 31 January is 28 February, and one month before that is 28 January.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.amount(number) — How many units to move back. A whole number.unit(string) — Which unit to move by:year,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose calendar to walk, such asEurope/Berlin. Without it, UTC — where a day is always twenty-four hours. With it, a day is a day on that clock: 09:00 in Berlin plus one day is 09:00 the next morning even when the clocks moved overnight, while plus 24 hours is 10:00.
Returns — The new moment as ISO text, written in the timezone that was named.
Example
const since = dateSubtract(today(), 7, "days");
log("looking back to " + since);
dateDifference
Platform call — the run pauses here and continues with the result
dateDifference(from, to, unit, timeZone?)
How many WHOLE units lie between two moments, counted the same way dateAdd moves them — so adding the answer to from never passes to.
Parameters
from(string | number | date) — The moment to count from. An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.to(string | number | date) — The moment to count to, in any of the same forms.unit(string) — Which unit to count in:year,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose calendar to walk, such asEurope/Berlin. Without it, UTC — where a day is always twenty-four hours. With it, a day is a day on that clock: 09:00 in Berlin plus one day is 09:00 the next morning even when the clocks moved overnight, while plus 24 hours is 10:00.
Returns — The number of whole units, positive when to is the later moment and negative when it is the earlier one. Anything short of a whole unit is dropped: two moments twenty-three hours apart are 0 days apart. One thing to know about leap days: 29 February to 28 February the next year is 0 years, because the anniversary is 1 March. For calendar days — which day each moment falls on, rather than how much time passed — use dateDiff.
Example
const age = dateDifference(input.event.receivedAt, now(), "day");
log("the order is " + age + " days old");
dateIsBefore
Platform call — the run pauses here and continues with the result
dateIsBefore(date, other, unit?, timeZone?)
Is the first moment earlier than the second?
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.other(string | number | date) — The moment to compare against.unit(string, optional) — Compare at this granularity instead of to the millisecond —"day"asks whether the first falls on an earlier day, so two moments on one day answer false. One ofyear,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose day (or week, or month) it is — 23:30 UTC is already the next day in Tokyo. Only meaningful together with a unit, and refused without one. Defaults to UTC.
Returns — true or false.
Example
if (dateIsBefore(input.event.receivedAt, today())) {
log("this one arrived before today");
}
dateIsAfter
Platform call — the run pauses here and continues with the result
dateIsAfter(date, other, unit?, timeZone?)
Is the first moment later than the second?
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.other(string | number | date) — The moment to compare against.unit(string, optional) — Compare at this granularity instead of to the millisecond. One ofyear,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose day (or week, or month) it is — 23:30 UTC is already the next day in Tokyo. Only meaningful together with a unit, and refused without one. Defaults to UTC.
Returns — true or false.
Example
log(dateIsAfter("2026-07-14", "2026-07-13"));
dateIsEqual
Platform call — the run pauses here and continues with the result
dateIsEqual(date, other, unit?, timeZone?)
Are the two the same moment? With a unit, whether they fall in the same one — which is how to ask “were these on the same day” without comparing text.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.other(string | number | date) — The moment to compare against.unit(string, optional) — Compare at this granularity:"day"answers true for any two moments on the same day — whose day it is depends on the timezone. One ofyear,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — Whose day (or week, or month) it is — 23:30 UTC is already the next day in Tokyo. Only meaningful together with a unit, and refused without one. Defaults to UTC.
Returns — true or false.
Example
if (dateIsEqual(input.event.receivedAt, now(), "day")) {
log("it arrived today");
}
dateIsBetween
Platform call — the run pauses here and continues with the result
dateIsBetween(date, start, end)
Is the moment inside the window? Both edges count as inside, so a window built from dateStartOf and dateEndOf holds every moment of the period and no more.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.start(string | number | date) — The first moment of the window.end(string | number | date) — The last moment of the window.
Returns — true or false. A window whose start is after its end is refused rather than answering false for everything, which would look exactly like an empty window.
Example
const from = dateStartOf(today(), "month");
const to = dateEndOf(today(), "month");
log(dateIsBetween(input.event.receivedAt, from, to));
dateStartOf
Platform call — the run pauses here and continues with the result
dateStartOf(date, unit, timeZone?)
The first moment of the day, month, year or other unit the date falls in. A week starts on Monday.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.unit(string) — Which period:year,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — The moment as ISO text, written in the timezone that was asked for. On a day whose clocks moved, this is that day’s real first moment — 2026-03-29T00:00:00.000+01:00 in Vienna, even though the day ends on +02:00.
Example
const monthStart = dateStartOf(now(), "month", "Europe/Vienna");
log("the month began at " + monthStart);
dateEndOf
Platform call — the run pauses here and continues with the result
dateEndOf(date, unit, timeZone?)
The LAST millisecond of the day, month, year or other unit the date falls in.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.unit(string) — Which period:year,month,week,day,hour,minute,secondormillisecond— plurals work too.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — The moment as ISO text in the timezone that was asked for, e.g. 2026-02-28T23:59:59.999Z — the last millisecond, so dateIsBetween with it covers the whole period.
Example
const monthEnd = dateEndOf(now(), "month");
log("the month runs until " + monthEnd);
dateParse
Platform call — the run pauses here and continues with the result
dateParse(text, pattern, timeZone?)
Reads a date somebody else wrote, by the pattern it was written in — the way to take 14.07.2026 from a file or a form and turn it into a moment.
Parameters
text(string) — The text to read. It must match the pattern exactly, with nothing left over.pattern(string) — The shape the text is in, from the number tokensYYYY,MM,DD,HH,mm,ssandSSS; everything else in the pattern must appear in the text as it stands. What the pattern does not name starts the period, soYYYY-MMreads as the first of that month at midnight, and a pattern that names no part of a date at all is refused rather than answering 1970. Month names and two-digit years can be written but not read — a name needs a language and26cannot say which century it means.timeZone(string, optional) — Which timezone the text is written in —2026-07-14 12:30means a different moment in Vienna than in UTC. Defaults to UTC. A wall clock that never happened, because the clocks jumped over it, answers the moment the day reached it; one that happened twice answers the first of them.
Returns — The moment as ISO text, written in the timezone it was read in.
Example
const paidAt = dateParse("14.07.2026", "DD.MM.YYYY", "Europe/Vienna");
log("paid at " + paidAt);
dateFormat
Platform call — the run pauses here and continues with the result
dateFormat(date, pattern, timeZone?, locale?)
Writes a moment out in a pattern of your own, in the timezone you name — the same tokens formatDate uses, plus milliseconds and the names of months and days.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.pattern(string) — The shape of the answer.YYYYandYYfor the year,MMfor the month andMMM/MMMMfor its name,DDfor the day andDDD/DDDDfor the weekday’s name,HH,mm,ssandSSSfor the time. Every other character is written as it stands.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.locale(string, optional) — Which language the month and weekday names are written in — a language tag such asde-DE. Defaults toen-US, stated rather than taken from the machine, so the same script writes the same date everywhere. A tag nobody has data for is refused rather than quietly written in English.
Returns — The written text.
Example
log(dateFormat(now(), "DDDD, DD. MMMM YYYY", "Europe/Vienna", "de-DE"));
dateGet
Platform call — the run pauses here and continues with the result
dateGet(date, unit, timeZone?)
Reads one part of a date as a number — the year, the month, the day, the time, or which day of the week it is.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.unit(string) — Which part:year,month(1–12),day,hour,minute,second,millisecond, orweekday(1 for Monday through 7 for Sunday).timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — The part as a number. Which one it is depends on the timezone: 23:30 UTC is already the next day, with a different weekday, in Vienna.
Example
if (dateGet(now(), "weekday", "Europe/Vienna") === 1) {
log("it is Monday in Vienna");
}
dateSet
Platform call — the run pauses here and continues with the result
dateSet(date, unit, value, timeZone?)
Answers the same moment with one part changed — the way to say “nine o’clock that morning” or “the first of that month”.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.unit(string) — Which part to change:year,month,day,hour,minute,secondormillisecond. The weekday cannot be set — move the date withdateAddinstead.value(number) — What to set it to. A value the part cannot hold is refused by name — the 31st of a month with 30 days says so, rather than quietly becoming the 1st of the next one. Changing the MONTH to a shorter one pulls the day back to its last day, exactly asdateAdddoes.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — The new moment as ISO text, written in the timezone that was asked for.
Example
const nine = dateSet(dateStartOf(now(), "day", "Europe/Vienna"), "hour", 9, "Europe/Vienna");
log("the reminder goes out at " + nine);
isWeekend
Platform call — the run pauses here and continues with the result
isWeekend(date, timeZone?)
Is the date a Saturday or a Sunday?
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — true or false. In the timezone asked for: Friday evening in UTC is already Saturday in Tokyo.
Example
if (isWeekend(now(), "Europe/Vienna")) {
log("holding this until Monday");
}
isLeapYear
Platform call — the run pauses here and continues with the result
isLeapYear(date, timeZone?)
Does the year this date falls in have a 29 February?
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — true or false, by the full rule — 2000 was a leap year, 1900 was not. Which year the date falls in depends on the timezone: 23:30 UTC on New Year’s Eve is already the new year further east.
Example
log(isLeapYear("2024-05-05"));
daysInMonth
Platform call — the run pauses here and continues with the result
daysInMonth(date, timeZone?)
How many days the month this date falls in has.
Parameters
date(string | number | date) — An ISO date (2026-07-14), a datetime that carries its timezone (2026-07-14T12:30:00Z), milliseconds since 1970, or a date value. A datetime without a timezone is refused: it would mean a different moment on every machine that read it.timeZone(string, optional) — A timezone name such asEurope/Vienna, orUTC. Defaults to UTC — never the machine’s own, so the same script answers the same everywhere. A name nobody has is refused rather than quietly read as UTC.
Returns — The number of days — 28, 29, 30 or 31.
Example
const days = daysInMonth(now());
log("this month has " + days + " days");
sendMail
Platform call — the run pauses here and continues with the result, and it does real network I/O
sendMail(message)
Sends an email through one of the environment’s mail accounts. The message says what to send; which server and which credentials is the account’s configuration, so a script never holds a password. When the environment has exactly one account it is used by default — name one in account when there are several.
Parameters
message(object) —{ to, subject, text?, html?, cc?, bcc?, from?, replyTo?, account?, attachments? }. Recipients (to,cc,bcc) are addresses as text — comma-separated — or a list of them.textandhtmlare the two ways to say the body; give both and the reader’s mail client picks.fromreplaces the account’s own address as the visible sender (“Alerts alerts@acme.example”).attachmentsis a list of{ filename, contentBase64, contentType?, cid? }— contents as base64 text, the same shapecreatePdfanswers; give an attachment acidand the html body can show it inline as<img src="cid:...">. Any other field is refused by name.
Returns — { ok: true, mode, messageId? } — the send succeeded. mode is what the account did with it: deliver sent it as written, redirect sent it to the addresses configured on the account instead of the recipients you named, and record sent nothing and wrote the message into this run’s log. messageId is the identifier the mail server will show the message under, when there was a send and it stated one. Under record the answer also carries recorded — { to, cc, bcc, subject, body }, the message that was not sent. A send that cannot happen — no account, an unknown account name, several accounts and none named, contents that are not base64, a server that refuses — raises a catchable error saying which, naming the account.
Example
const report = createPdf({
title: "Weekly report",
blocks: [{ kind: "heading", level: 1, text: "Weekly report" }]
});
const receipt = sendMail({
to: "ops@acme.example",
subject: "Weekly report",
text: "The report is attached.",
attachments: [{ filename: "report.pdf", contentBase64: report, contentType: "application/pdf" }]
});
log("sent as " + receipt.messageId);
Files
writeBlob
Platform call — the run pauses here and continues with the result, and it does real network I/O
writeBlob(contentBase64, mime?)
Stores bytes as a file and answers a handle for them. Keep the handle and pass it on — the bytes stay in storage instead of being carried through the script, which is what makes files of any size safe to work with.
Parameters
contentBase64(string) — The contents as base64 text —base64Encodemakes it from text. Text that is not base64 is refused rather than stored short, because a file that lost its tail looks fine until someone opens it.mime(string, optional) — What the file is, such as"application/pdf"or"text/csv". Stored with it and answered byreadBlob.
Returns — The handle: sha256: and the fingerprint of the contents. It IS the contents, so storing the same bytes again answers the same handle and keeps one copy — including when another environment stores the same file, which still cannot read yours.
Example
const handle = writeBlob(base64Encode("sku,qty\nA-1,2\n"), "text/csv");
log("stored as " + handle);
readBlob
Platform call — the run pauses here and continues with the result, and it does real network I/O
readBlob(blobId)
Reads a stored file back: its contents, the type it was stored with, and its size. Read a file only when the script itself has to look inside it — passing the handle on costs nothing whatever the file weighs.
Parameters
blobId(string) — The handlewriteBlobanswered. A file belongs to the environment that stored it: a handle from anywhere else is refused exactly as one that was never stored, and it never answers an empty file.
Returns — { contentBase64, mime, bytes } — the contents as base64 text, the type THIS environment stored it with (missing when it was stored without one), and the true size in bytes. A file too large to carry in a script is refused rather than truncated: keep passing the handle instead.
Example
const handle = writeBlob(base64Encode("hello"), "text/plain");
const file = readBlob(handle);
log("the file holds " + file.bytes + " bytes");
readFile
Platform call — the run pauses here and continues with the result, and it does real network I/O
readFile(path, options?)
Reads a file out of one of this environment’s file stores, by the path it was put there under. Use it for the things somebody uploaded — a logo, a template, a fixture — and for what an earlier run left behind.
Parameters
path(string) — Where the file sits in its store. It may contain slashes, so"assets/logo.png"is one path and not two.options(object, optional) —{ store }— which file store to read from. Left out, it means the store this environment marks as its default; an environment that marks none says so rather than picking one.
Returns — { contentBase64, mime, bytes } — the contents as base64 text, the type it was stored with (missing when it was stored without one), and its true size. A file too large to carry in a script is refused rather than truncated, and a path this environment does not hold is refused exactly as one that never existed.
Example
writeFile("greeting.txt", base64Encode("hello"));
const file = readFile("greeting.txt");
log("the file holds " + file.bytes + " bytes");
writeFile
Platform call — the run pauses here and continues with the result, and it does real network I/O
writeFile(path, contentBase64, options?)
Puts bytes into one of this environment’s file stores under a path, REPLACING whatever was there. What a run writes this way outlives it, which is what makes a file store the place for an artefact rather than a value.
Parameters
path(string) — Where to put it in the store. Writing the same path again replaces the file — an upload is not a create, so there is nothing to delete first.contentBase64(string) — The contents as base64 text, exactly aswriteBlobtakes them.base64Encodemakes it from text.options(object, optional) —{ store, mime }— which file store to write to, and what the file is. Without a store it means this environment’s default one. A store that only takes uploads refuses a script’s write, and a store that is full refuses too unless its author asked it to make room.
Returns — { path, bytes } — where it landed and how big it is. Deliberately NOT a handle: a stored file is addressed by its path, and the handle-taking calls (sending, attaching) take one from writeBlob.
Example
const stored = writeFile("reports/today.csv", base64Encode("sku,qty\nA-1,2\n"), { mime: "text/csv" });
log("wrote " + stored.bytes + " bytes to " + stored.path);
Mapping
setMapping
Platform call — the run pauses here and continues with the result, and it does real network I/O
setMapping(namespace, source, target)
Remembers which id in the target system corresponds to a source id — for systems that cannot store each other’s keys. Writing a source that is already mapped replaces its target: a re-synced record moved.
Parameters
namespace(string) — Which kind of thing is being mapped — one namespace per remote entity type, such as"users"or"invoices". Namespaces are separate maps: the same source id may map differently in each.source(string) — The id on the source side.target(string) — The id it corresponds to on the target side.
Returns — Nothing. The mapping is durable: every later run of this environment sees it.
Example
setMapping("users", "crm-4711", "hr-0815");
log("linked");
getMapping
Platform call — the run pauses here and continues with the result, and it does real network I/O
getMapping(namespace, source)
Answers the target id a source id was mapped to, or null when it never was.
Parameters
namespace(string) — The namespace the mapping lives in.source(string) — The source-side id to look up.
Returns — The target id as text, or null for a source nobody mapped — the everyday answer for a record seen for the first time, so check it rather than treating it as a failure.
Example
const known = getMapping("users", "crm-4711");
if (known === null) { log("first sight — create, then setMapping"); }
deleteMapping
Platform call — the run pauses here and continues with the result, and it does real network I/O
deleteMapping(namespace, source)
Forgets a mapping — for the record that was DELETED in the far end. There is no new target id to write in that case, and a mapping pointing at an id that no longer exists makes every later sync update into nowhere.
Parameters
namespace(string) — The namespace the mapping lives in.source(string) — The source-side id whose mapping should go.
Returns — true when a mapping was removed, false when there was none — never an error, because the everyday caller is cleaning up and does not know which. After this, the next upsertBySourceId for that source CREATES rather than updates: the arm is chosen on whether a mapping exists.
Example
if (deleteMapping("users", "crm-4711")) { log("forgot the old link — the next sync will create"); }
setMappings
Platform call — the run pauses here and continues with the result, and it does real network I/O
setMappings(namespace, pairs)
Stores many mappings in one call — the bulk form of setMapping, and the one to use inside a sync loop. The whole list lands together or not at all.
Parameters
namespace(string) — The namespace every pair goes into.pairs(array) — A list of{ source, target }pairs. A pair whose source is already mapped replaces that target, exactly as setMapping would.
Returns — Nothing. Either every pair is stored or, on a refusal, none is.
Example
setMappings("users", [
{ source: "crm-1", target: "hr-a" },
{ source: "crm-2", target: "hr-b" }
]);
getMappings
Platform call — the run pauses here and continues with the result, and it does real network I/O
getMappings(namespace, sources)
Looks up many source ids at once and answers their targets in the same order — one call for a whole page of records instead of one per record.
Parameters
namespace(string) — The namespace to look in.sources(array) — The source-side ids, as a list of text entries.
Returns — A list aligned with sources: at each position the mapped target id, or null where that source was never mapped — so sources[2] answers at position 2, always.
Example
const targets = getMappings("users", ["crm-1", "crm-2", "crm-3"]);
log(targets.filter((t) => t === null).length + " still unmapped");
listMappings
Platform call — the run pauses here and continues with the result, and it does real network I/O
listMappings(namespace, page?)
Reads a namespace’s mappings back, ordered by source id — for reports and reconciliation.
Parameters
namespace(string) — The namespace to list.page(object, optional) —{ offset, limit }— where to start and how many to answer. Left out, the platform still answers a bounded page rather than everything: walk with offsets when a namespace may be large. Never delete while you walk: a removed row moves every later one up, and the next page skips as many as you took out — collect what to forget, finish the walk, then delete.
Returns — A list of { source, target } pairs, ordered by source.
Example
const pairs = listMappings("users", { offset: 0, limit: 100 });
log("first page holds " + pairs.length + " mappings");
Datasets
dsPut
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsPut(dataset, key, value)
Stores one keyed row in a named dataset — the platform-side staging ground for bulk data. Writing an existing key replaces that row’s value.
Parameters
dataset(string) — Which dataset. A name startingrun:is PRIVATE TO THIS RUN — two runs staging under"run:pulled"never see each other — and such datasets are cleaned up after the run ends; every other name is durable and shared by the environment’s runs.key(string) — The row’s key — the record’s own id, usually.value(any) — Any JSON value up to 1 MiB serialized. A dataset holds at most 500000 rows and 512 MiB of values; a write that would cross either cap is refused as DATASET_FULL and nothing of it lands.
Returns — Nothing.
Example
dsPut("run:pulled", "user-1", { name: "Ada", active: true });
log("staged");
dsPutMany
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsPutMany(dataset, rows)
Stores a whole page of keyed rows in one call — the write to use when records arrive by the hundred. The batch lands entirely or not at all.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.rows(array) — A list of{ key, value }rows. A key that already exists replaces its row; a cap refusal (DATASET_FULL) rolls back the WHOLE batch, so a dataset never holds half a page.
Returns — Nothing.
Example
dsPutMany("run:pulled", [
{ key: "user-1", value: { name: "Ada" } },
{ key: "user-2", value: { name: "Bob" } }
]);
dsGet
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsGet(dataset, key)
Reads one row of a dataset back by its key.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.key(string) — The row’s key.
Returns — The row’s value, or undefined for a key the dataset does not hold. A stored null comes back as null — distinguishable from a missing row.
Example
const user = dsGet("run:pulled", "user-1");
log(user === undefined ? "not staged" : "staged");
dsGetMany
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsGetMany(dataset, keys)
Reads many rows by key in one call, answering only the rows that exist.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.keys(array) — The keys to read, as a list of text entries.
Returns — A list of { key, value } rows — the same shape dsPage answers — holding the rows that were FOUND, in the order their keys were asked for. A key with no row is simply absent, so compare lengths to find the misses.
Example
const rows = dsGetMany("run:pulled", ["user-1", "user-2", "user-9"]);
log(rows.length + " of 3 are staged");
dsPage
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsPage(dataset, options?)
Reads a slice of a dataset, ordered by key — the loop-friendly way to work through staged rows without holding them all at once.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.options(object, optional) —{ offset, limit, where }.wherenarrows to rows whose value at one dot-path equals one plain value —{ path: "profile.active", equals: true }— with text, numbers, true/false and null each matching only their own kind, andequals: nullalso matching rows where the path is absent. Left out, the platform still answers a bounded page rather than everything.
Returns — A list of { key, value } rows, ordered by key.
Example
const active = dsPage("run:pulled", { where: { path: "active", equals: true }, limit: 100 });
log(active.length + " active users in the first page");
dsCount
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsCount(dataset)
Answers how many rows a dataset holds — a dataset that does not exist counts 0.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.
Returns — The row count as a number.
Example
log("staged " + dsCount("run:pulled") + " rows so far");
dsClear
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsClear(dataset)
Removes a dataset entirely — its rows and its name. Clearing what does not exist is a no-op.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.
Returns — Nothing.
Example
dsClear("run:pulled");
log("staging ground empty");
dsRename
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsRename(from, to)
Renames a dataset — and REPLACES whatever already held the new name, in one step. This is how a run promotes its staging set: rename run:current to a durable name and it outlives the run, standing where the previous baseline stood.
Parameters
from(string) — The dataset to rename. Renaming one that does not exist is refused.to(string) — Its new name. A dataset already under this name is replaced — gone with the rename, which is the point of a promotion. Arun:name makes it this run’s own again.
Returns — Nothing.
Example
dsPut("run:pulled", "user-1", { name: "Ada" });
dsRename("run:pulled", "baseline");
log("promoted — next run diffs against this");
dsDiff
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsDiff(base, current)
Counts what separates two datasets — how many rows were added, changed or removed in current relative to base. Computed on the platform’s side from fingerprints stored with every write, so it costs the same however wide the rows are.
Parameters
base(string) — The dataset to compare against — last sync’s baseline, usually.current(string) — The dataset holding the fresh rows —run:names are this run’s own.
Returns — { added, changed, removed }, each a count: keys only in current, keys in both whose values differ, keys only in base. A value that merely reordered its object keys has NOT changed. A dataset that does not exist compares as empty.
Example
dsPut("run:pulled", "u1", { name: "Ada" });
const delta = dsDiff("baseline", "run:pulled");
log(delta.added + " new, " + delta.changed + " changed, " + delta.removed + " gone");
dsDiffKeys
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsDiffKeys(base, current, kind, page?)
Lists the keys behind one of dsDiff’s counts — the rows to actually act on, one class at a time, page by page.
Parameters
base(string) — The dataset to compare against.current(string) — The dataset holding the fresh rows.kind(string) —"added","changed"or"removed"— which class of keys to list.page(object, optional) —{ offset, limit }. Left out, the platform still answers a bounded page rather than everything — walk with offsets when a delta may be large.
Returns — The keys of that class, ordered. Feed them to dsGetMany for the rows to upsert, or use the removed ones to retire what the source no longer has.
Example
dsPut("run:pulled", "u1", { name: "Ada" });
const fresh = dsDiffKeys("baseline", "run:pulled", "added", { limit: 100 });
log(fresh.length + " new records to create");
dsStats
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsStats(dataset, path)
Groups a dataset’s rows by the value at one dot-path and counts each group — a one-call report over staged data, computed on the platform’s side.
Parameters
dataset(string) — Which dataset —run:names are this run’s own.path(string) — The dot-path to group by, such as"dept"or"profile.role".
Returns — A list of { group, count } entries, most frequent first. Each group is the value AS JSON TEXT — "active" with its quotes, 42, true — and rows where the path is absent group under "null" together with stored nulls.
Example
const groups = dsStats("run:pulled", "dept");
for (const g of groups) { log(g.group + ": " + g.count); }
dsMapInto
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsMapInto(source, mappingName, target, options?)
Maps every row of a staged dataset into another one, using a JSON mapping somebody configured — the whole set in one call, without the records ever passing through the script. The platform works through it a batch at a time, pausing the run in between, so a set of any size costs short segments rather than one long one.
Parameters
source(string) — The dataset to read —run:names are this run’s own.mappingName(string) — The configured JSON mapping to apply to each record. It is the same mapping a script could call by name, so what its preview showed is what these rows become; a function that is not a JSON mapping is refused.target(string) — The dataset the mapped records are written to.options(object, optional) —{ keyPath, onError }.keyPathtakes the new row’s key from the MAPPED record — the target system’s own identity — instead of keeping the source key.onErroris"reject"(the default: the row goes to<target>.rejectedwith a reason, and the rest of the set still runs) or"fail"(the first bad row ends the call).
Returns — { rows, rejected } — how many records were written and how many could not be mapped. The rejected ones are in a dataset called <target>.rejected, keyed by the row that failed and carrying the reason, so dsPage shows exactly what to fix. That dataset is emptied each time the call starts.
Example
dsPut("run:staged", "u1", { id: "u1", name: "Ada" });
const done = dsMapInto("run:staged", "MyMapping", "run:ready");
log(done.rows + " ready, " + done.rejected + " rejected");
dsEnrich
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsEnrich(dataset, functionName, options)
Fills staged rows in from a second system: one configured call per row, its answer merged into the row where you say. The calls happen one after another — never at the same time — so an integration is not throttled or blocked for asking too fast, and if the far end says it is being called too often the run waits and continues with the row it was on.
Parameters
dataset(string) — The dataset to enrich; its rows are updated in place.functionName(string) — The configured function to call for each row — usually the call that reads one record of the other system.options(object) —{ argPath, param, into, onError }.argPathsays where in the record the value to look up with is, andparamwhich parameter it binds to (left out: the function’s first).intonames the field the answer is written to.onErroris"reject"(the default: the row moves to<dataset>.rejected),"null"(the row stays, with nothing atinto) or"fail"(the first failed lookup ends the call).
Returns — { rows, rejected } — how many records were enriched and how many were not. For a call, the ANSWER is what the far end sent back; a status outside 200–299 counts as no answer, because a row has nothing to branch on.
Example
dsPut("run:staged", "u1", { key: "ABC-1" });
const done = dsEnrich("run:staged", "MyFunction", { argPath: "key", into: "issue" });
log(done.rows + " records enriched");
dsDuplicates
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsDuplicates(dataset, path, options?)
Finds the values that more than one record shares — the check to run BEFORE importing into a field the target system keeps unique. Ask it with the target field’s width and it compares what that field would actually store, so two names that differ today but collide at 40 characters are found here instead of halfway through an import.
Parameters
dataset(string) — The dataset to check —run:names are this run’s own.path(string) — Which field to check, as a dot-path —"email","profile.login". Records with nothing there are not a collision.options(object, optional) —{ length }— compare only the first N characters, the width of the field you are importing into. Left out, whole values are compared.
Returns — How many colliding groups there are, as a number. The groups themselves land in a dataset called <dataset>.duplicates — the shared value, how many records carry it, and their keys — so dsPage shows what to correct. Nothing is changed in the data: which of two colliding records is the right one is a business decision, not the platform’s.
Example
dsPut("run:staged", "u1", { email: "ada@example.com" });
const clashes = dsDuplicates("run:staged", "email", { length: 40 });
log(clashes === 0 ? "safe to import" : clashes + " collisions to fix first");
dsToCsv
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsToCsv(dataset, mappingName, options?)
Writes every row of a staged dataset into CSV files, using a columns table somebody configured — capped in size the way a bulk import wants them, each file complete with its own header line. The platform works through the set a file at a time, pausing the run in between, so a dataset of any size costs short pieces of work rather than one long one.
Parameters
dataset(string) — The dataset to write —run:names are this run’s own.mappingName(string) — The configured columns table to render each record with. It is the same one a script could call by name, so what its preview showed is what these files contain; a function that is not a columns table is refused.options(object, optional) —{ maxBytes, maxLines, maxLineBytes, where }. The first three cap one file — 10 MB, 10 000 records and 10 KiB per line by default; set them to what the system you are uploading to accepts.wherenarrows to rows whose value at one dot-path equals one plain value —{ path: "active", equals: true }. A record whose line is longer than the cap is not split: it goes to<dataset>.rejectedwith a reason, and the rest still go out.
Returns — A list of files, in order: { blobId, name, lines, bytes, firstKey, lastKey } each — the handle to upload, a suggested file name, how many records it carries, how big it is, and the first and last dataset key in it. The same list is left in a dataset called <dataset>.chunks, and anything that could not be written is in <dataset>.rejected, keyed by the row that failed; both are emptied each time the call starts.
Example
dsPut("run:ready", "u1", { id: "u1", name: "Ada" });
const files = dsToCsv("run:ready", "MyCsv", { maxLines: 5000 });
log(files.length + " files to upload, first is " + files[0].name);
dsFromCsv
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsFromCsv(file, into, mappingName?, options?)
Reads a stored CSV file into a dataset, one batch of records at a time, without the file ever passing through the script. This is how a file that arrived as a mail attachment or came back from a download becomes rows you can work with — a real export weighs far more than a script may hold, so reading its contents and parsing them yourself works on a sample and refuses on the file that matters.
Parameters
file(string) — The handle of the stored file to read.into(string) — The dataset the records are written to.mappingName(string, optional) — The configured columns table to shape each record with — it decides which column becomes which field and what type it is. Left out, the file’s first line names the fields and every value stays text. You can pass the options in its place when there is no table.options(object, optional) —{ keyPath, onError, maxLineBytes }.keyPathsays which field keys each row (left out, the record’s ownidis used).onErroris"reject"(the default: the record goes to<into>.rejectedwith the line it was on and a reason) or"fail"(the first bad record ends the call).
Returns — { rows, rejected } — how many records were staged and how many could not be. The rejected ones are in a dataset called <into>.rejected, keyed by the line they were on, so dsPage shows exactly what to fix; that dataset is emptied each time the call starts. A file whose row does not fit its own header ENDS the call naming the line, because everything after such a row would land in the wrong columns.
Example
const file = writeBlob(btoa("email,name\nada@example.com,Ada\n"), "text/csv");
const done = dsFromCsv(file, "run:incoming", { keyPath: "email" });
log(done.rows + " contacts staged, " + done.rejected + " rejected");
csvLineRecord
Platform call — the run pauses here and continues with the result, and it does real network I/O
csvLineRecord(file, line)
Turns a line number in a file this run wrote back into the record it came from. Bulk imports answer with line numbers — “line 3 was rejected” — and this is what lets a report name the customer or the order instead of a number nobody can act on. It counts lines the way the file really has them, so a value carrying a line break is accounted for.
Parameters
file(string) — The handle of a filedsToCsvwrote in this run.line(number) — The line number the other system reported, counting from 1.
Returns — { key, dataset, record } — the dataset key of the record that wrote that line, the dataset it is staged in, and the record itself. Answers null for the header line, for a line past the end of the file, and for a file this run did not write, so a report can walk whatever line numbers it was given.
Example
dsPut("run:ready", "u1", { id: "u1", name: "Ada" });
const files = dsToCsv("run:ready", "MyCsv");
const failed = csvLineRecord(files[0].blobId, 2);
log(failed === null ? "no record wrote that line" : "line 2 is record " + failed.key);
upsertBySourceId
Platform call — the run pauses here and continues with the result, and it does real network I/O
upsertBySourceId(namespace, record, options)
Creates a record in the other system the first time it is seen and updates it every time after — the platform remembers which is which. It looks up the id mapping, calls the one of your two functions that fits, and writes the mapping back when a create succeeds, so the next run knows.
Parameters
namespace(string) — Which kind of thing this is — one namespace per remote entity type, such as"people". The same namespace the mapping calls use.record(object) — The source record itself. Its own id identifies it (id,IdorID, or the fieldsourceIdPathnames), and that id is what the mapping is kept under.options(object) —{ createFn, updateFn, idPath, sourceIdPath, namePath, recordParam, targetParam }.createFnandupdateFnname the configured functions that do the work — the create gets the record, the update gets the record and the target id the platform remembered.idPathsays where the create’s ANSWER carries the new id (left out: its ownid).namePathsays which field names the record in the report.
Returns — { action, outcome, target, reason } — whether it was a create or an update, whether it worked, and the id in the other system. A record that could not be synced answers outcome: "failed" with the reason rather than ending the run, and a row naming it lands in the report dataset either way.
Example
const person = { id: "src-1", name: "Ada Lovelace" };
const done = upsertBySourceId("people", person, { createFn: "MyFunction", updateFn: "MyFunction" });
log(done.action + " " + person.name + ": " + done.outcome);
applyLinks
Platform call — the run pauses here and continues with the result, and it does real network I/O
applyLinks(dataset, spec)
Writes the relationships between records that are already in the other system — who reports to whom, which department something belongs to. It resolves both ends of every link from the id mappings, a whole page at a time, so the only calls made are the link writes themselves.
Parameters
dataset(string) — The staged rows to read the links from —run:names are this run’s own.spec(object) —{ linkFn, from, to, namePath }.linkFnnames the configured function that writes ONE link.fromandtoeach say{ namespace, path, param }— which mappings that side lives in, where the row carries its id, and which parameter of your function takes the resolved id (left out:fromandto).
Returns — { linked, failed }. A row whose other end is not in the target yet is not an error — it becomes a report row naming the record, and the link is written on the run that creates the missing side. A row with no link at all is skipped silently, because most records have none.
Example
dsPut("run:people", "p1", { id: "p1", name: "Ada", managerId: "p2" });
const spec = { linkFn: "MyFunction", from: { namespace: "people", path: "id" }, to: { namespace: "people", path: "managerId" } };
const done = applyLinks("run:people", spec);
log(done.linked + " links written, " + done.failed + " waiting for the other side");
stageDiff
Platform call — the run pauses here and continues with the result, and it does real network I/O
stageDiff(baseline, current, kinds, into)
Stages the RECORDS behind a comparison, ready for the phase that acts on them. dsDiff says how many rows differ and dsDiffKeys says which keys; this puts the rows themselves where you can walk them, taking a removed record from the baseline, which is the only side that still has it.
Parameters
baseline(string) — The dataset to compare against — last sync’s copy.current(string) — The dataset holding the fresh rows.kinds(array) — Which differences to stage: any of"added","changed"and"removed".into(string) — The family of datasets to stage into: each class lands in its own —"run:delta"writesrun:delta.added,run:delta.changedandrun:delta.removed. They are emptied each time the call starts.
Returns — How many rows each class staged, as { added, changed, removed } — only the classes that were asked for. A class that matched nothing answers zero, which is an answer to branch on rather than a missing key.
Example
const delta = stageDiff("baseline", "run:pulled", ["added", "changed"], "run:delta");
log(delta.added + " to create, " + delta.changed + " to update");
sendReport
Platform call — the run pauses here and continues with the result, and it does real network I/O
sendReport(channel, options)
Turns the report dataset into something a person reads — how many records went through, broken down by what was done and how it ended, then the ones that need a human, by name and with the reason. The counting happens where the rows are, so reporting on ten thousand records costs what ten costs.
Parameters
channel(string) —"email"sends it as mail, through one of your email functions — the mailbox and its password are that function’s business."request"sends it through an http function instead, which is how it reaches a ticketing system or a chat webhook.options(object) —{ fn, dataset, title, limit, bodyParam }.fnnames the configured function that sends it, and must match the channel.datasetis the report to render (left out:report).titleis the heading.limitcaps how many failures are named before the body starts counting instead (left out: 20).bodyParamis the parameter your function takes the markdown body as (left out:body).
Returns — { total, failed, listed } — how many records the report covered, how many failed, and how many of those the body named. A send that does not go through fails the run rather than being reported: the thing that would carry the bad news is the thing that broke.
Example
dsPut("report", "created:u1", { name: "Ada Lovelace", action: "created", outcome: "ok" });
const done = sendReport("request", { fn: "MyReport", title: "Nightly people sync" });
log(done.total + " records reported, " + done.failed + " need attention");
importAll
Platform call — the run pauses here and continues with the result, and it does real network I/O
importAll(files, importFn, options?)
Uploads the files dsToCsv produced to a bulk import API and waits for each one to finish — the waiting costs nothing, because the run is suspended rather than held. When the far end answers with line numbers, they are turned back into the records that wrote them, so the report names people instead of positions in a file.
Parameters
files(array) — The listdsToCsvanswered — its handles and names, passed straight through.importFn(string) — The configured function that uploads ONE file. It takes the file’s handle as itsfileparameter, and it is where the multipart form is authored.options(object, optional) —{ statusFn, pollMs, maxPolls, fileParam, jobParam, jobIdPath, donePath, doneValues, errorsPath, linePath, reasonPath }.statusFnnames the function that asks how an import is going — left out, an upload that answers is treated as finished. The paths say where that system’s answer carries the job id, its state and the lines it rejected; the defaults are the common spellings (id,status,errors,line,reason).
Returns — { chunks, failed, rejected } — files sent, files the far end would not take, and records it rejected by line. Each file gets a report row, and each rejected line becomes a row naming the record it came from. An import that never finishes is given up on after maxPolls asks and reported as such, because a run must not wait for ever.
Example
dsPut("run:ready", "u1", { id: "u1", name: "Ada" });
const files = dsToCsv("run:ready", "MyCsv");
const done = importAll(files, "MyFunction");
log(done.chunks + " files imported, " + done.rejected + " records rejected");
State
getState
Platform call — the run pauses here and continues with the result, and it does real network I/O
getState(key)
Reads a small durable value this environment stored earlier — a delta token, a watermark, a cursor. What setState put there is what comes back, across runs.
Parameters
key(string) — The key setState stored under.
Returns — The stored value, or undefined for a key that was never set. A stored null comes back as null — the two stay distinguishable, so a cleared marker and a missing one read differently.
Example
const watermark = getState("orders-watermark");
if (watermark === undefined) { log("first sync — start from the beginning"); }
setState
Platform call — the run pauses here and continues with the result, and it does real network I/O
setState(key, value)
Stores one small durable value under a key — the place for delta tokens, watermarks and cursors between runs. A later set replaces the value.
Parameters
key(string) — The key to store under.value(any) — Any JSON value. State is for SMALL things: a value over 64 KiB as JSON is refused, naming the limit — pages of records belong in a dataset.
Returns — Nothing.
Example
setState("orders-watermark", { since: "2026-07-01T00:00:00Z" });
log("watermark moved");
Pagination
fetchPage
Platform call — the run pauses here and continues with the result, and it does real network I/O
fetchPage(functionName, arguments, cursor?)
Reads ONE page from a configured function that was set up to page, and tells you how to ask for the next one. The hand-written way through a collection — reach for dsFetchInto when the pages are only going into a dataset anyway.
Parameters
functionName(string) — The configured function to call. It must have a pagination block: without one it answers a single result, and paging it is refused rather than guessed at.arguments(object) — The arguments that function declares, exactly as a bare call passes them. They are sent again for every page — a page is the same call, asked for a different slice.cursor(any, optional) — Where to continue: what the previous page answered asnext. Left out, the walk starts at the first page.
Returns — { items, next, delta }. items is this page’s records as a list. next is what to pass as the cursor for the page after this one — ABSENT when this was the last page, which is how the walk ends. delta is the provider’s sync token when the function’s block names where it lives, usually only on the last page; store it with setState if the next sync should ask for changes only.
Example
const page = fetchPage("MyPagedFunction", {});
log(page.items.length + " records on the first page");
if (page.next === undefined) { log("that was all of them"); }
dsFetchInto
Platform call — the run pauses here and continues with the result, and it does real network I/O
dsFetchInto(functionName, arguments, dataset, options?)
Pulls EVERY page of a configured function into a dataset — one call instead of a cursor loop. The platform fetches a page, stages it, and pauses the run between pages, so a pull of a hundred pages costs a hundred short segments instead of one run holding a worker; if the far end says it is being called too often, the run waits and continues from the page it was on rather than starting over.
Parameters
functionName(string) — The configured function to walk. It must have a pagination block.arguments(object) — The arguments that function declares — sent again for every page.dataset(string) — Where the records are staged. Arun:name is private to this run and cleaned up after it —dsRenameit to a durable name to keep it, which is how a pulled set becomes the next sync’s baseline.options(object, optional) —{ keyPath }— which field of a record is its key in the dataset. Left out, the record’s own id is used:id,IdorID, whichever it has. A record with none of them, and nokeyPathto say otherwise, is refused rather than given an invented key.
Returns — { pages, rows, delta } once the whole collection is in: how many pages were fetched, how many records were staged, and the provider’s sync token when its block names one. The pull stops early — with what it has — when the function’s configured page limit is reached.
Example
const pull = dsFetchInto("MyPagedFunction", {}, "run:contacts");
log("staged " + pull.rows + " records from " + pull.pages + " pages");
dsRename("run:contacts", "contacts");
Run control
checkpoint
Platform call — the run pauses here and continues with the result
checkpoint(waitMs?)
Cuts the run into segments: the platform saves the run, frees the worker, and continues where it left off. Parked time costs no execution budget and each segment starts with a fresh one, so a long job survives limits that would otherwise end it. Everything the script has in scope is preserved across the cut.
Parameters
waitMs(number, optional) — How long to stay parked before continuing. Omitted or0means continue as soon as a worker is free. At most 24 hours — a longer wait is refused, because a run that wants to wake up next week is a schedule, not a pause.
Returns — Nothing.
Example
for (let page = 1; page <= 50; page++) {
const response = fetch({ connection: "crm", method: "GET", path: "/contacts", query: { page: page } });
log("page " + page + ": " + response.body.items.length);
checkpoint(); // hand the worker back between pages
}
Config functions
MyFunction
Platform call — the run pauses here and continues with the result, and it does real network I/O
MyFunction(arguments?)
Every function configured in this environment is callable by its own name — MyFunction stands for whichever one you mean. What it describes is done for you: for a call, the connection and its credentials, retries, and the wait when the far end says it is being called too often; for a text template, the values filled into the words somebody else wrote; for a message, the mailbox it is sent from and the body written both as formatted mail and as plain text; for a mapping, which field of a record belongs in which column, and what each one holds; for a prompt to a language model, the connection that holds the API key, the model, and the same wait-and-continue when the model answers that it is being asked too often. Whatever it is, the script says only what it wants.
Parameters
arguments(object, optional) — One value per parameter the function declares. A parameter with a default may be left out; a name it does not declare is refused rather than ignored. A mapping is the exception: it declares no parameters and takes the ONE value it maps — a list of records for a CSV, an object (or a list of them) for a JSON mapping.
Returns — Whatever kind of function it is answers its own shape. A call answers { status, body }, with the body already read as data when the far end sent JSON — a status outside 200–299 comes back the same way, an answer to branch on rather than a failure. Unless whoever configured it said otherwise: a call may name the statuses it accepts, and then any other answer stops the run where the call was made, saying what the far end sent — so you write no status check. It may also say what its answer IS — the value at a path, or the one record found — and then that value is what you get, or null when the answer is that there is none. If you are unsure which a function does, test it: what the Test panel shows is exactly what a script receives. A text template answers the filled string, exactly as it was written: nothing is escaped, and markdown is not rendered. A message answers { ok: true, mode, messageId } once it has been handed to the mail server, exactly as sendMail does — and mode says what the mail account did with it, which is deliver unless somebody configured the account to redirect or only record its mail. A CSV mapping answers whichever direction it was given: hand it a list of records and it answers the CSV text, hand it that text and it answers the records back. A JSON mapping answers the structure it was built to produce, filled from the object — or the list of objects — you gave it. A prompt to a model answers { text, json, model, usage, stopReason } — text is the answer, json is that answer parsed for you in JSON mode (and a reply that is not JSON fails loudly rather than handing you a broken value). Treat the model’s answer as untrusted input: it is text a model wrote, not a value the platform vouches for, so validate it before you act on it.
Example
const answer = MyFunction({ key: "ABC-1" });
if (answer.status === 200) {
logInfo("got " + answer.body.title);
}