Docs / The language & scripts
The language
Scripts are ordinary JS. Not a subset invented here, not a template language
with if bolted on: objects, arrays, map, try/catch, arrow functions,
template strings, JSON, Math, Map, Set, regular expressions — all of it
behaves the way you already expect.
This page is the short version: what a script is, what is not here, and the one shape that quietly costs a thousand times what it looks like. For the exact model with every claim executed, read Writing scripts; for every name the language itself gives you, the standard library.
A script is a program
Not a module, not a handler. Its statements run once, top to bottom, and when
the last one finishes the run is over. Nothing calls a main, nothing looks for
an export.
const order = input.event.body;
function total(items) {
let sum = 0;
for (const item of items) {
sum += item.price;
}
return sum;
}
log("order received", { id: order.id });
return { id: order.id, total: total(order.items) };
A top-level return is the run’s result. If you never write one, the final
value of a top-level variable named output is used instead; if there is
neither, the run simply has no result, which is perfectly normal.
Shared code goes in a library script. There is no import: the platform
puts every library a script links in front of it, so its functions are already
in scope.
Calls do not look asynchronous, because they are not
Every platform function — log, fetch, the functions configured for your
tenant — is called by its bare name and answers on the next line. That bare
name is the entity’s derived name, not the display name you typed in the
browser: Find contact is FindContact here, and the connection shown as
CRM is crm. See the name a script types.
const found = FindContact({ email: input.event.body.customer });
log("looked up the customer", { found: found === null ? "nothing" : found.id });
There is no await, no .then, no callback to hand over. That is not sugar
over something asynchronous: the call really does pause the run. The platform
puts the whole run away, does the work, and continues your script with the
answer already in place. A run that is resumed — on another machine, an hour
later — reads the saved answer instead of calling again, which is why an
interrupted run never sends the same request twice.
What is not here
A short list, all of it refused before the script runs, with the line and column:
class · this · super · new.target · async/await · generators ·
import/export · tagged templates · getters and setters · with · eval ·
Function · directives · destructuring defaults · sparse arrays · computed
object keys
And a few names that simply do not exist, because the platform answers those
needs itself: require, process, console, setTimeout, Promise. Use a
library script, input.settings, log, and checkpoint instead — each one is
in the function reference.
Nothing on that list is arbitrary. Every one of them either hides control flow that has to be saved between steps, or invites code that cannot mean the same thing on the machine that resumes the run.
Whatever you write has to mean the same thing everywhere
A run can be interrupted and continued somewhere else, so a script must not depend on which machine it is on.
The clock and randomness are yours to use — new Date(), Date.now(),
Math.random() all work, and what they answered is saved with the run, so a
value read before a pause is still that value after it. What you cannot rely on
is a machine’s local settings: a date-and-time string must carry its timezone
(2026-03-04T09:15:00Z), dates are UTC unless you name a zone, and
localeCompare wants the locale as an argument.
const arrivedAt = new Date(input.event.receivedAt);
return {
day: arrivedAt.getUTCDate(),
order: ["Öl", "Zebra"].sort(function (a, b) { return a.localeCompare(b, "sv"); }),
};
The one shape that does not scale
This is the mistake worth reading twice, because nothing stops you making it and nothing warns you afterwards.
// Do not reach for this.
const contacts = input.event.body.items.map(function (item) {
return FindContact({ email: item.sku + "@example.com" });
});
return contacts.length;
It works. Platform calls are legal inside a callback, and this is one call per item, in order, one after another. On the four items in your test payload it takes a moment. On four thousand it is four thousand calls, and if the far end starts asking you to slow down, the run parks and resumes once per call — for hours. The shape that costs an hour looks exactly like the shape that costs a second.
There are two ways out, and which one you want depends on why you were looping.
If the work really is per item: write a loop
const items = input.event.body.items;
const found = [];
for (const item of items) {
if (found.length >= 50) {
break; // a budget, a count, a bad answer — a loop can stop
}
found.push(FindContact({ email: item.sku + "@example.com" }));
}
return found.length;
Same calls, same order, same cost — with one difference that matters: a loop can
stop. The only way out of a callback is throw, which abandons the whole
iteration and tells you nothing about how far it got.
If the work is bulk: do not move it through the script at all
Reading every page of a system, staging thousands of records, reshaping them, counting them — none of that belongs in a run’s memory. Say what you want and the platform does the row work where the rows are:
const pull = dsFetchInto("ListContacts", {}, "run:contacts");
log("pulled every page", { pages: pull.pages, rows: pull.rows });
return dsCount("run:contacts");
One line fetches every page, stages every record, keeps its place if the far end
makes it wait, and answers a summary rather than the rows. dsPutMany stores a
whole list in one call, dsGetMany reads many keys back in one, dsMapInto and
dsEnrich reshape or fill in a whole staged set, and dsCount/dsStats count
without moving a row.
The full treatment of this — pulling, staging, comparing, pushing, reporting — is Syncing data between two systems.
Errors
throw ends the run and marks it failed; the name, message and line are
recorded with it. try/catch catches what you expect, and also catches a
platform call that could not be made at all:
try {
fetch({ connection: "unconfigured", method: "GET", path: "/contacts" });
} catch (error) {
logError("the call could not be made", { reason: error.message });
return error.name;
}
A call that reached the other side and got an unwelcome answer is a different thing: an HTTP call answers its status for you to branch on, and a configured function stops the run only for statuses its configuration does not accept.
What try/catch never catches is a budget: when a run is out of time or memory
the platform ends it, and a script must not be able to trap its own kill switch.
The numbers are on Limits and safety.
Long work
checkpoint() cuts a run into segments: it saves everything, hands the machine
back, and continues on the very next line with every variable still in scope.
Time parked costs no budget. Give it a number of milliseconds and it is also how
you wait — checkpoint(600000) continues ten minutes later, having held nothing
open in between.