Host API for scripts
Who this is for: people and AI assistants who write run: steps, platform/transform scripts or actions for the company library.
Before you start: know how steps work, from the workflow file format.
Scripts are JavaScript (or TypeScript) modules that run in Liitora’s sandbox. The sandbox has no network, files, clock or globals of its own. Everything a script can do goes through ctx, the host API described here. Liitora applies the sign-in, the safety rules and the limits, and hides secret values in logs and results.
The shape of a script
Section titled “The shape of a script”export default async function run(ctx, input) { // input: the step's `with:` values (for an action: its checked input) // ctx: the host API below return { some: "output" }; // becomes steps.<id>.outputs}- The module has exactly one
export default: a function (or arrow function) with at most two parameters,ctxandinput. It may beasync, but not a generator. - Return a JSON value, usually an object. Its keys become the step’s outputs.
- Return
ctx.skip("reason")to end the step as skipped. - Throw an error to fail the step. The message and the script line are shown in the run log.
- In
platform/transform,scriptis only the function body: Liitora wraps it inexport default async function run(ctx, input) { ... }. - TypeScript works. Types are removed before the script runs, and
enumandnamespaceare supported.
A complete example
Section titled “A complete example”export default async function run(ctx, input) { const erp = ctx.connection("erp"); // sign-in added by Liitora const since = (await ctx.state.get("last_synced_at")) ?? input.since;
const res = await erp.get("/v1/invoices", { query: { since } }); if (!res.ok) { // Non-2xx answers don't throw: check res.ok yourself. throw new Error(`the ERP answered ${res.status}`); } const items = res.json().items; ctx.log.info(`found ${items.length} invoices since ${since}`); ctx.metric("invoices", items.length); if (items.length === 0) return ctx.skip("no new invoices");
const post = await ctx.connection("finance").post("/api/invoices/batch", { json: { invoices: items }, headers: { "Idempotency-Key": ctx.idempotencyKey }, }); if (!post.ok) throw new Error(`the finance system answered ${post.status}`);
await ctx.state.set("last_synced_at", ctx.now().toISOString()); return { count: items.length };}The step binds both connections:
connections: erp: visma finance: finance-apictx members
Section titled “ctx members”| Member | What it does |
|---|---|
ctx.event |
The trigger payload: the same values as trigger in expressions, such as ctx.event.body for a webhook. |
ctx.idempotencyKey |
A key that stays the same for every attempt of this step (and item). Send it as Idempotency-Key on requests that change something. |
ctx.connection(slot) |
A client for the connection bound to slot in the step’s connections:. Liitora adds the base address and the sign-in. |
ctx.http.fetch(url, options) |
A raw HTTPS request to a full address, without a connection. |
ctx.secrets.get(name) |
A secret’s value, or null when it doesn’t exist. Prefer connections: then the script never holds the value. |
ctx.state.get(key) / ctx.state.set(key, value) |
Small JSON values saved for later runs of the same workflow, such as a sync cursor. |
ctx.artifacts.put(name, content, { contentType }) |
Saves a file made by this step. Returns { id, name, contentType, size }. |
ctx.artifacts.read(file, { offset, length }) |
Reads a file of this run, given by name, ID or ${{ artifacts.<name> }}. Returns { id, name, contentType, size, bytes }. One read returns at most 8 MiB, so read a large file in parts with offset and length. length: 0 reads only the name, type and size. Files of other runs are never found. |
ctx.lookup(table, key, column, fallback) |
Reads a row of a company lookup table, or one cell when you give a column. See Lookup tables. |
ctx.log.debug/info/warn/error(...) |
Writes a line to the run log. |
ctx.metric(name, value) |
Records a number, for example rows processed. Names use letters, digits, _, - and .. Alerts can watch metrics. |
ctx.now() |
The current time as a Date. Date.now() and new Date() give the same clock. In tests the clock is frozen. |
ctx.skip(reason) |
Returns a value that ends the step as skipped: return ctx.skip("nothing to do"). |
Connection clients
Section titled “Connection clients”ctx.connection(slot) returns an object with request(method, path, options) and the shortcuts get, post, put, patch and delete, each taking (path, options).
pathis joined to the connection’s base path: base/v1and pathitemsgive/v1/items. Paths with.., backslashes, fragments or a leading//are refused, also when the..is percent-encoded once or several times (%2e%2e,%252e%252e).- Encode a value before you put it into a path, for example
"/customers/" + encodeURIComponent(id), so?,&and/in the value stay part of it. In workflow YAML, useurl_encode. - A full address is allowed only for a host the connection allows. A full address to any other host is refused before anything is sent, so the connection’s sign-in details never go there.
- The slot name must be written as a literal text, such as
ctx.connection("erp"), so Liitora knows before the run which connections the script uses. - An OAuth2 connection gets its token by itself. A
401answer makes Liitora fetch a new token and try once more. - Safe requests (
GET,HEAD,OPTIONS,PUT,DELETE) are retried on 429, 502, 503 and 504, up to 3 attempts.POSTandPATCHare retried only when they carry anIdempotency-Key.
Request options
Section titled “Request options”| Option | What it is |
|---|---|
method |
Only for ctx.http.fetch: GET (default), POST, PUT, PATCH, DELETE, HEAD or OPTIONS. |
headers |
Request headers. null and undefined values are dropped. |
query |
Query parameters added to the address. |
body |
A raw body: text, or bytes as a Uint8Array. Bytes are sent exactly as they are. |
json |
A JSON body. Sets content-type: application/json. |
xml |
An object written with ctx.formats.xml.build. Sets content-type: application/xml. |
csv |
Rows written with ctx.formats.csv.build. Sets content-type: text/csv; charset=utf-8. |
form |
An object of fields written as a=1&b=x+y. Sets content-type: application/x-www-form-urlencoded. |
multipart |
A list of parts written with ctx.formats.multipart.build, for file uploads. Sets content-type: multipart/form-data with the boundary. |
timeoutMs |
A timeout in milliseconds. It can only be lower than the platform limit. |
Give only one of body, json, xml, csv, form and multipart: two of them throw a TypeError. The helpers set content-type only when your headers don’t, so you can send application/soap+xml with xml, for example. With multipart, leave content-type out, because Liitora puts the boundary in it. A value the helper cannot write throws a FormatError before anything is sent.
const erp = ctx.connection("erp");await erp.post("/orders", { xml: { order: { "@id": "7", total: "9.50" } } });await erp.post("/token", { form: { grant_type: "client_credentials", scope: "read" } });const pdf = (await ctx.artifacts.read("invoice.pdf")).bytes;await erp.post("/upload", { multipart: [ { name: "kind", data: "invoice" }, { name: "file", filename: "invoice.pdf", contentType: "application/pdf", data: pdf }, ],});Responses
Section titled “Responses”| Member | What it is |
|---|---|
status |
The HTTP status. |
ok |
true for 2xx. Other statuses do not throw. |
headers |
Response headers with lower-case names. |
text() |
The body as UTF-8 text. Bytes that are not valid UTF-8 become U+FFFD (�). |
text({ encoding }) |
The body decoded strictly with ctx.formats.text.decode, for example { encoding: "windows-1252" }. A byte that is not valid in that encoding throws a FormatError. |
json() |
The body parsed as JSON. Throws SyntaxError for invalid JSON. |
xml(options) |
The body parsed with ctx.formats.xml.parse, with the same options. The body is decoded with the charset in the response’s content-type, or UTF-8 when there is none. |
csv(options) |
The body parsed with ctx.formats.csv.parse, decoded the same way as xml(). |
bytes() |
A copy of the raw body as a Uint8Array, exactly as received. Use it for files such as PDFs and images. |
Rules for outgoing requests
Section titled “Rules for outgoing requests”- HTTPS only, and only public addresses. Private, internal and cloud metadata addresses (such as
10.x,192.168.x,127.0.0.1,169.254.169.254and their IPv6 forms) are blocked, also when a name resolves to them. - No user name or password in the address.
- Redirects are not followed: a 3xx answer comes back to your script.
- Timeouts: 10 seconds to connect, 30 seconds per read, 60 seconds in total. Request and response bodies are at most 10 MB.
- Liitora hides secrets in the logged request: sign-in headers, cookies and any header, query or JSON key with names such as
token,secret,keyorpassword, and every secret value it knows, also in encoded forms. Your script still gets the real response.
Secrets
Section titled “Secrets”ctx.secrets.get("name") returns the value of a secret whose status is Set. A requested or expired secret fails the call with a message that says why. A secret that doesn’t exist gives null. For a secret with several fields, read one field as "name.field", for example ctx.secrets.get("erp_login.username").
Write the name as a literal text. It must not be built at run time, and ctx.secrets must not be copied to another variable. This way Liitora knows which secrets a workflow needs before it runs, and can block activation until they are set.
Why names must be literal
Section titled “Why names must be literal”When you save a workflow, Liitora records exactly which secrets and connections that version can use. A run gets only those, nothing else from your company. The person who approves the version sees the same list, so they know what they are approving. This keeps the damage small if a script has a bug or gets unexpected input.
That only works if Liitora can read every name before the workflow runs. So:
-
Write secret names and connection slots as plain text in quotes:
ctx.secrets.get("erp_key")andctx.connection("erp"). -
Don’t build a name from parts, a variable or
${...}. To pick between two secrets, callctx.secrets.getonce for each name, for example in anifandelse. -
Bind every slot a script uses under the step’s
connections:. -
Use
ctxonly asctx.something. Don’t copyctxto another variable, pass it to your own function, or take it apart. To give a helper function what it needs, pass that part:ctx.log,ctx.http,ctx.state, or the client thatctx.connection("erp")returns. Onlyctx.secretsandctx.connectioncan’t be passed on.
A name that is built at run time, or a ctx that is passed around, is refused when you save, with the line and column (secret_name_not_literal, connection_slot_not_literal or ctx_indirect_use).
The list is kept per step: a step can use only the secrets and connections its own script or action needs. If a run still asks for something its step didn’t declare, the whole run stops at once with this message. The script can’t catch it, and the attempt is recorded in the audit log:
This workflow didn’t declare the secret erp_key. Add it to the workflow and save it as a new version.
A secret that a connection signs in with doesn’t need to be listed: only that connection can use it. A workflow called with uses: workflow/... runs with its own list, and the approval page shows that list too, marked as coming from the called workflow. See secret_not_declared.
Before a person approves the version
Section titled “Before a person approves the version”A test run or dry run of a version that no person has approved yet gets less. This keeps a draft from reading a secret, or sending it somewhere, before anyone agreed to it:
ctx.secrets.getreturns a real value only for a secret that the workflow’s last approved version also reads. Any other secret stops the run withsecret_not_approved, and the script can’t catch it. A workflow with no approved version yet reads no secret this way.ctx.http.fetch(andhttp.requestorwebhook.sendwith a full URL) reaches only the hosts of the connections the workflow declares: their base URL and allowed hosts. Any other host fails withhost_not_declaredbefore anything is sent. This also covers the reads that a dry run sends for real.- Email isn’t sent, except as a preview in a dry run (
email_not_approved).
Connections keep working as usual. The workflow’s active or production version is not limited, and neither are production runs. To try a new secret before approval, give it a dummy value in the workflow tests.
Secret values are never stored
Section titled “Secret values are never stored”Everything a run keeps is cleaned of the secret values it read: outputs, inputs, log lines, HTTP calls, error messages, the run result, state values and files. A value is replaced with [REDACTED], also in base64, hex, URL or JSON encoding, and in all lower or upper case for values of 8 characters or more.
- A file you save with
ctx.artifacts.putkeeps its content, with any secret value in it replaced, also in binary files. The run log then says that values were removed from the file. - A state key, file name or content type that contains a secret value is refused with
invalid_argument. Use a fixed key or name instead.
Liitora can only remove a value it recognizes. A script that changes a secret on purpose, for example reverses it, cuts it into pieces, spreads it over several log lines or encodes it in a way not listed above, can still get it into a result. That is why a draft that no person approved reads only the secrets that an approved version already reads (see above). Read the scripts that use secrets before you approve them, and prefer connections, so the script never holds the value.
Lookup tables
Section titled “Lookup tables”ctx.lookup reads your company’s lookup tables, with the same answers as lookup() in expressions.
export default async function run(ctx, input) { // One cell: the text in the column "name" of the row whose key is "100". const name = await ctx.lookup("cost_centers", input.costCenter, "name"); // The whole row, such as { code: "100", name: "Sales", team_email: "sales@example.com" }. const row = await ctx.lookup("cost_centers", input.costCenter); // A fallback for a key that isn't in the table. const team = await ctx.lookup("cost_centers", input.costCenter, "team_email", "finance@example.com"); return { name, team, code: row.code };}- The key is compared as exact text. A number is turned into text first, so
100finds the key100. - A key that isn’t in the table throws a
HostErrorwith codenot_found, unless you give a fourth argument. Then you get that value instead. To get a whole row with a fallback, passnullas the column:ctx.lookup("cost_centers", code, null, {}). - A table or a column that doesn’t exist also throws
not_found, with the names you can use. - The run reads one version of each table, for its whole life. If the workflow’s expressions already read the table, the script gets the same version. Otherwise the script’s first read fixes the version that is current then, and every later read, retry and resume of the run reads the same rows. The run log says which version, for example
lookup tables: cost_centers v3, and the run page lists it under Lookup tables read. - A script reads only your own company’s tables.
- The table name can come from a variable. A run can read up to 100 different tables (
limit_exceeded). - A table name is 1 to 63 lowercase letters, digits or
_, starting with a letter, and a key is at most 1,000 characters. Anything else throwsinvalid_argument. - Each call is one host call, so it counts toward the limits. Reading the same table again is fast: the run keeps it after the first read.
In workflow tests, ctx.lookup reads the tables the test case gives under given.lookups, never your real ones.
TypeScript types
Section titled “TypeScript types”This is the full type declaration of the host API, as your editor or AI assistant can use it. AI assistants can also read it as the MCP resource liitora://reference/host-api.d.ts.
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** Request body: text, bytes, or use `json` instead. */export type Body = string | Uint8Array | ArrayBuffer | ArrayBufferView;
export interface RequestOptions { /** Request headers. `null`/`undefined` values are dropped. */ headers?: Record<string, string | number | boolean | null | undefined>; /** Query parameters appended to the URL. */ query?: Record<string, string | number | boolean | null | undefined>; /** Raw body. Ignored when `json` is given. */ body?: Body; /** JSON body; sets `content-type: application/json` unless given. */ json?: Json; /** Per-request timeout in milliseconds, capped by the platform limit. */ timeoutMs?: number;}
export interface FetchOptions extends RequestOptions { /** HTTP method, default `GET`. */ method?: HttpMethod;}
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
export interface HttpResponse { readonly status: number; /** `true` for 2xx statuses. Non-2xx responses do not throw. */ readonly ok: boolean; /** Lower-cased header names. */ readonly headers: Readonly<Record<string, string>>; /** Body decoded as UTF-8. */ text(): string; /** Body parsed as JSON. Throws `SyntaxError` on invalid JSON. */ json<T = any>(): T; /** A copy of the raw body. */ bytes(): Uint8Array;}
export interface ConnectionClient { readonly slot: string; request(method: HttpMethod, path: string, options?: RequestOptions): Promise<HttpResponse>; get(path: string, options?: RequestOptions): Promise<HttpResponse>; post(path: string, options?: RequestOptions): Promise<HttpResponse>; put(path: string, options?: RequestOptions): Promise<HttpResponse>; patch(path: string, options?: RequestOptions): Promise<HttpResponse>; delete(path: string, options?: RequestOptions): Promise<HttpResponse>;}
export interface ArtifactRef { readonly id: string; readonly name: string; readonly contentType: string; readonly size: number;}
/** A value returned by `ctx.skip()`. */export interface Skip { readonly reason: string;}
export interface HostError extends Error { readonly name: "HostError"; readonly code: string;}
export interface Ctx { /** Trigger payload: webhook body and headers, schedule info, manual input. */ readonly event: any; /** Stable per step attempt; send it as `Idempotency-Key` for side effects. */ readonly idempotencyKey: string; /** * Client for a connection slot declared by the action. Write the slot as * a string literal (`ctx.connection("erp")`): computed slots are refused * at save, and the step must bind the slot under `connections:`. */ connection(slot: string): ConnectionClient; readonly http: { /** Raw HTTPS request through the platform's SecureHttp policy. */ fetch(url: string, options?: FetchOptions): Promise<HttpResponse>; }; readonly secrets: { /** * A secret value, or `null` when it does not exist. Prefer connections. * Write the name as a string literal (`ctx.secrets.get("erp_key")`): * the platform records each version's secret names when it is saved, * and each step can read only its own. A computed name is refused at * save (`secret_name_not_literal`, with its line and column). Reading * a name the step did not declare ends the whole run at once with * `secret_not_declared`: the script cannot catch it. */ get(name: string): Promise<string | null>; }; /** Log lines are capped per run (count, bytes and line length). */ readonly log: { debug(...args: unknown[]): void; info(...args: unknown[]): void; warn(...args: unknown[]): void; error(...args: unknown[]): void; }; /** The host clock. */ now(): Date; /** Small per-workflow key-value state, e.g. sync cursors. */ readonly state: { get<T = Json>(key: string): Promise<T | null>; set(key: string, value: Json): Promise<void>; }; readonly artifacts: { /** Stores a file produced by this step. */ put(name: string, content: Body, options?: { contentType?: string }): Promise<ArtifactRef>; /** Reads up to `length` bytes (at most 8 MiB) of a file of this run, from `offset`. */ read( ref: ArtifactRef | string, options?: { offset?: number; length?: number }, ): Promise<ArtifactRef & { readonly bytes: Uint8Array }>; }; /** * A row of a company lookup table as column to text, or one cell with * `column`. The run reads one version of each table. An unknown key * throws `not_found` unless `fallback` is given. */ lookup(table: string, key: string | number): Promise<Record<string, string>>; lookup(table: string, key: string | number, column: string): Promise<string>; lookup<F>(table: string, key: string | number, column: null | undefined, fallback: F): Promise<Record<string, string> | F>; lookup<F>(table: string, key: string | number, column: string, fallback: F): Promise<string | F>; /** Records a numeric metric (name: letters, digits, `_`, `-`, `.`). */ metric(name: string, value: number): void; /** Ends the run as skipped: `return ctx.skip("nothing to do")`. */ skip(reason?: string): Skip;}What scripts can’t do
Section titled “What scripts can’t do”importandimport()don’t work. Onlyimport typeis allowed in TypeScript. Reuse code by making it an action that other steps use.eval,new Functionand timers with text code are blocked.- There is no
fetch,process,require,setTimeout,console,WebAssembly, file system or network outsidectx. - Each run starts fresh. Nothing is kept between runs except what you save with
ctx.stateorctx.artifacts. Math.random()works but is not safe for secrets or tokens.- Changing
Object.prototypeor other built-in objects affects only your own script.
XML in scripts
Section titled “XML in scripts”ctx.formats.xml reads and writes XML. It works inside the sandbox, so it needs no connection and no import. Data formats lists every ctx.formats call with its options, limits and error kinds.
export default async function run(ctx, input) { const doc = ctx.formats.xml.parse(input.body, { arrays: ["order.items.item"] }); const items = doc.order.items.item; // always an array, even with one item const reply = ctx.formats.xml.build({ ack: { "@order": doc.order["@id"], count: items.length }, }); // reply: <?xml version="1.0" encoding="UTF-8"?><ack order="7"><count>2</count></ack> return { items, reply };}How XML becomes an object. parse returns an object with one key, the root element.
| XML | Object |
|---|---|
<id>7</id> |
{ id: "7" }: an element with only text is a string |
<note/> |
{ note: "" } |
<a lang="fi">hei</a> |
{ a: { "@lang": "fi", "#text": "hei" } }: attributes start with @, and the text is #text |
<o><x>1</x><x>2</x></o> |
{ o: { x: ["1", "2"] } }: an element that repeats is an array |
<p>Hi <b>you</b>!</p> |
{ p: { "#content": ["Hi ", { b: "you" }, "!"] } }: text mixed with elements keeps its order in #content |
All values are strings. Whitespace between elements is dropped, comments are dropped, and CDATA sections become normal text.
Options for parse:
| Option | What it does |
|---|---|
arrays |
Elements that are always arrays, even when they appear once. Give dotted paths from the root, such as ["order.items.item"], or "always" to make every child element an array. |
namespaces |
"strip" (the default) keeps only local names, so <s:Body> is Body. "prefix" keeps names as written, such as s:Body. "uri" writes {urn:example}Body, for standards that need the full namespace. |
charset |
The character set the text was decoded from, "utf-8" by default. If the XML declaration names a different encoding, the parse fails, so two systems never read the same bytes differently. |
In every mode, each prefix must be declared, a namespace is compared by its value after character references are read (so urn:x and urn:x are the same namespace), and one element can’t have two attributes with the same namespace and name.
build(value, options) turns an object with one key back into XML. It uses the same rules the other way round: @name for attributes, #text for text, #content for mixed content and arrays for repeated elements. #cdata writes a CDATA section. Numbers and true/false are written as text, and null is an empty element. Every value is escaped, so data can never break out of its element. Set declaration: false to leave out the <?xml ...?> line. For namespaces, write names with a prefix ("s:Body") and declare it with an "@xmlns:s" attribute on that element or one of its parents. build refuses a prefix that isn’t declared (only xml: needs no declaration) and two attributes that would have the same namespace and name, so the XML it writes is always readable by other systems.
Safety rules. Liitora refuses XML that could be read in more than one way or could attack the reader:
- a
DOCTYPE(so there are no DTDs, external entities or entity expansion), and any entity other than&,<,>,"and'(character references such aséare fine); - the same attribute twice on one element, also when two prefixes point to the same namespace;
- a namespace prefix that isn’t declared, and a namespace declaration with an unknown entity or an empty value;
- an element or attribute name longer than 1024 bytes;
- a byte order mark (BOM) before the root element, and an encoding declaration that doesn’t match
charset; - more than 8 MB of text, more than 64 levels of nesting, more than 200 000 elements, attributes and text pieces, or more than 256 attributes on one element.
buildcan write at most 16 MB.
Options must be plain objects with known keys. Getters and Proxy objects are refused.
Errors. When the XML or the options are refused, the call throws an Error with name FormatError and code FORMAT_ERROR. The message says what is wrong and usually the line. Catch it to handle bad data yourself. If nothing catches it, the step fails with format_error. Liitora doesn’t retry it, because the same data would fail the same way.
For large files, split the data first with platform/xml.split and parse one record at a time in the script.
CSV in scripts
Section titled “CSV in scripts”ctx.formats.csv reads and writes CSV. Like XML, it runs inside the sandbox: no connection, no import. Its options and limits are in Data formats.
export default async function run(ctx, input) { const rows = ctx.formats.csv.parse(input.body); // [{ name: "Ann", total: "12.50" }, ...] const paid = rows.filter((r) => Number(r.total) > 0); const out = ctx.formats.csv.build(paid); // "name,total\r\nAnn,12.50\r\n" return { count: paid.length, out };}parse(text, options) reads CSV text (or a Uint8Array of UTF-8 bytes) into rows.
| Option | What it does |
|---|---|
header |
true by default: the first row names the columns and parse returns objects. A blank name becomes column_1, column_2…; a repeated name gets _2, _3… With header: false, every row is an array of strings. |
delimiter |
One character, "," by default. |
quote |
One character, "\"" by default. Must differ from delimiter. |
trim |
Trims leading and trailing spaces from every field, including the header. |
A row with a different number of fields than the header (or the first row, when there is no header) fails with a FormatError naming the line: Liitora never guesses which column shifted.
build(rows, options) takes the same shape back to CSV text (CRLF line ends, RFC 4180 quoting). With header: true (the default), columns come from the first row’s keys, in order, and are written as the header row; with header: false, rows must be arrays.
Formula injection. A text cell that starts with =, +, -, @, a tab or a carriage return (also after leading spaces, and the full-width signs such as =) gets a leading ', so Excel, LibreOffice and Google Sheets show it as text instead of running it as a formula. This only applies to text: a JSON number such as -5 is written as a number.
Limits: 8 MB of input, 200 000 rows, 2 000 columns, 1 MB per field, 16 MB of output.
Excel and fixed-width files in scripts
Section titled “Excel and fixed-width files in scripts”ctx.formats.xlsx reads and writes Excel workbooks (.xlsx), and ctx.formats.fixed reads and writes fixed-width records. Like CSV, they run inside the sandbox. Their options and limits are in Data formats.
export default async function run(ctx, input) { const file = await ctx.artifacts.read(input.workbook); const book = ctx.formats.xlsx.read(file.bytes, { sheet: "Orders" }); // { sheet: "Orders", sheets: ["Orders"], epoch: 1900, formulas: 0, rows: [{ id: 1, due: "2026-10-04" }] } const out = ctx.formats.xlsx.write(book.rows, { sheet: "Copy" }); // a Uint8Array await ctx.artifacts.put("copy.xlsx", out, { contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", });
const lines = ctx.formats.fixed.build([{ id: 7, name: "Ann" }], { unit: "chars", fields: [{ name: "id", width: 3, align: "right", pad: "0" }, { name: "name", width: 6 }], }); // "007Ann \n" return { rows: book.rows.length, lines };}xlsx.read(bytes, options) reads one sheet of a workbook given as a Uint8Array and returns { sheet, sheets, epoch, formulas, rows }. Options: sheet (a name or a number counting from 0), header (true by default, so rows are objects) and dates ("iso" by default, or "serial"). Formulas are never run: you get the value Excel saved, and formulas counts the formula cells. Macro workbooks, .xlsb and .xls files throw a FormatError.
xlsx.write(rows, options) returns the workbook as a Uint8Array. Give one list of rows, or an object of sheet name to rows for several sheets. With header: true (the default) rows are objects and the first row’s keys become the header. Text that starts with =, +, -, @, a tab or a carriage return (also after leading spaces, and the full-width signs such as =) gets a leading ', as in CSV.
fixed.parse(data, options) and fixed.build(rows, options) take the layout as fields and need unit: "chars" or "bytes". parse returns one object of strings per line; build returns text with unit: "chars" and a Uint8Array with unit: "bytes". A value too long for its field, and a line of the wrong length, throw instead of being cut.
Forms and file uploads in scripts
Section titled “Forms and file uploads in scripts”ctx.formats.form reads and writes application/x-www-form-urlencoded text, the format of HTML forms and many OAuth token endpoints. ctx.formats.multipart reads and writes multipart/form-data, the format of file uploads.
export default async function run(ctx, input) { const fields = ctx.formats.form.parse("name=Ada+L&tag=a&tag=b"); // { name: "Ada L", tag: ["a", "b"] } const text = ctx.formats.form.build({ name: "Ada L", tag: ["a", "b"] }); // "name=Ada+L&tag=a&tag=b" return { fields, text };}form.parse(text, options) returns an object with one key per field name, in the order they first appear. + is a space and %XX is a byte, and the result must be UTF-8.
| Option | What it does |
|---|---|
duplicates |
"array" (the default): a name that appears more than once becomes an array of its values. "error": a repeated name throws, for endpoints where a second value would be an attack. |
arrays |
Names that are always arrays, even with one value, such as ["tag"]. "always" makes every field an array. |
A broken % escape, a NUL character and bytes that are not UTF-8 throw a FormatError. Liitora doesn’t guess what the sender meant.
form.build(fields) takes an object whose values are text, numbers, true/false, null (left out) or arrays of those (one field per value). Everything except letters, digits and *-._ is percent-encoded, so a value can never add a field or a line of its own.
multipart.parse(body, options) reads a multipart body (a Uint8Array or text) into a list of parts. Give the boundary as { boundary }, or the whole header as { contentType: res.headers["content-type"] }. Each part is { name, filename, contentType, headers, data }: data is a Uint8Array, and headers has lower-case names. filename and contentType are there only when the part has them.
filename is what the sender wrote. Never use it as a path: it can contain ../ or a name that looks like another file. Pick your own name, or clean it first.
multipart.build(parts, options) writes parts back and returns { body, contentType }: body is a Uint8Array, contentType is the header to send with it. Each part needs name and data (text or a Uint8Array), and can have filename and contentType. A part with a filename and no contentType is sent as application/octet-stream. Liitora picks a boundary that does not appear in the data. You can set your own with { boundary }, and if it appears in the data, build throws.
Strict rules, so two systems never read the same body in different ways:
- Names, file names and content types with a quote, a backslash or a line break are refused, so a value can’t add a header or a part.
- The same header twice in one part, and the same
nameorfilenameparameter twice, are refused. Two parts with the samenameare fine: they are two entries in the list. - Headers folded over several lines,
filename*=parameters and transfer encodings other than7bit,8bitandbinary(such as base64) are refused. - The text before the first boundary and after the last one is ignored.
Limits: form text 8 MB, 10,000 fields and names up to 1,024 bytes; multipart bodies 16 MB, 1,000 parts, 16 headers and 8 KB of headers per part. See Data formats for all of them.
Text encodings and binary data
Section titled “Text encodings and binary data”Most integrations are UTF-8 text, but some send older encodings or raw bytes. ctx.formats.text, ctx.formats.base64 and ctx.formats.hex handle those; ctx.formats.ndjson reads and writes newline-delimited JSON the same way.
export default async function run(ctx, input) { // input.body is Windows-1252 bytes from a legacy system. const text = ctx.formats.text.decode(input.body, { encoding: "windows-1252" }); const utf8 = ctx.formats.text.encode(text, { encoding: "utf-8" }); const b64 = ctx.formats.base64.encode(utf8); return { text, b64 };}text.decode(bytes, options) turns bytes into a string. encoding is one of "utf-8", "utf-16le", "utf-16be", "windows-1252", "iso-8859-1" or "iso-8859-15". A byte sequence that is not valid in that encoding fails by default; with onInvalid: "replace", decode always returns { text, replaced }, where replaced counts how many broken sequences were replaced with U+FFFD (the � replacement character). Nothing is ever silently mangled.
text.encode(text, options) turns a string into bytes in the given encoding, returned as a Uint8Array. A character the encoding cannot represent (for example a Chinese character encoded as iso-8859-1) fails rather than being dropped or turned into a question mark.
iso-8859-1 is true Latin-1: every byte is its own character 0-255. It is not the same as windows-1252, which reuses bytes 0x80-0x9F for the euro sign and other printable characters instead of control characters. Use windows-1252 for files from Windows programs, iso-8859-1 when the sender says strictly Latin-1.
ctx.formats.base64.encode(bytes, options) and .decode(text, options) convert between bytes and base64 text. { url: true } uses the URL-safe alphabet (- and _ instead of + and /). decode requires exact padding and refuses anything outside the alphabet.
ctx.formats.hex.encode(bytes) and .decode(text) convert between bytes and lower-case hex text. decode accepts upper or lower case but needs an even number of characters.
ctx.formats.ndjson.parse(text) reads one JSON value per line into an array; .build(values) writes one compact JSON value per line. A blank line at the very end is fine (the file ends with a newline); a blank line anywhere else fails with the line number, since a hole in the middle of the file is more likely a bug than an empty record.
Limits: 16 MB of input and output for text, base64 and hex; NDJSON follows the same caps as CSV rows (200 000 lines, 1 MB per line). Data formats lists every limit.
Limits
Section titled “Limits”These are the default limits. Your plan can set its own, see Limits per plan.
| Limit | Default | When it is reached |
|---|---|---|
| CPU time | about 1 second of busy work | the step fails with cpu_limit |
| Run time | 30 seconds, or the step’s timeout_minutes if lower |
timeout |
| Memory | 64 MB | memory_limit |
| Stack depth | about 1 MB | stack_overflow (can’t be caught with try) |
| Returned output | 1 MB of JSON | output_too_large |
| Script size | 1 MB (inline run: in YAML: 64 KB) |
invalid_request |
| Input and trigger payload | 4 MB | invalid_request |
| Log lines | 1000 lines, 256 KB, 8 KB per line | extra lines are dropped and counted; long lines end with [truncated] |
| Metrics | 100 per run | the call throws HostError with limit_exceeded |
| One host call | 15 seconds | the call throws HostError with timeout |
| Host calls | 32 at the same time, 1000 per run | the call throws HostError with rejected |
| Request or response through a host call | 8 MB | HostError with rejected or limit_exceeded |
| Requests and responses of all unfinished host calls together | 32 MB | a new call throws HostError with rejected, and a response that does not fit throws limit_exceeded. Await some calls before starting more large ones. |
| Saved state value | 64 KB of JSON | the call fails |
| Saved state keys | 1,000 per workflow (replacing a key’s value always works) | the call fails; keep many items in one key, for example as a map |
| Saved state and storage | each value counts toward the plan’s storage | the call fails with quota_exceeded |
Waiting for a host call uses no CPU time, and host calls run at the same time, so Promise.all works.
Host call errors
Section titled “Host call errors”A host call that fails throws an Error with name HostError and one of these codes. The error points at the line of your script that made the call.
| Code | What happened |
|---|---|
invalid_argument |
An argument is wrong, for example a header with a line break or an invalid artifact name. |
denied |
Not allowed, for example a slot that is not bound, a blocked address or a secret that is requested or expired. |
not_found |
The thing asked for doesn’t exist, for example a lookup table, column or key. |
timeout |
The call took too long. |
limit_exceeded |
A limit was reached, for example too many metrics or a response that is too large. |
failed |
The call failed, for example the network was down. |
rejected |
Too many calls at the same time, arguments too large, or the call budget used up. |
How a script can end
Section titled “How a script can end”The run log shows one of these codes when a script step fails.
| Code | Meaning |
|---|---|
syntax_error |
The script can’t be parsed, or it uses import. |
script_error |
The script threw an error, or a promise was rejected, and nothing caught it. |
host_error |
A host call failed and the script did not catch the HostError. |
no_default_export |
There is no export default function. |
unresolved_promise |
The script waits for something that can never finish. |
invalid_output |
The returned value is not JSON, or is nested more than 128 levels deep. |
output_too_large |
The returned value is larger than the limit. |
cpu_limit, timeout, memory_limit, stack_overflow |
A limit from the table above was reached. |
format_error |
ctx.formats could not read or write some data, and the script did not catch the FormatError. See XML in scripts and Data formats. |
invalid_request |
The script or its input is too large. |
internal |
A problem inside Liitora. Try again, and report it if it keeps happening. |
Checks when you save
Section titled “Checks when you save”Liitora checks every script before it is saved, test-run or activated. Each problem has a code, the line and column, and how to fix it.
| Rule | Codes |
|---|---|
| The script is not too large (512 KB and 10 000 lines for actions) | script_too_large, too_many_lines |
| The code is not nested too deep or too long in one expression | nesting_too_deep, expression_too_long |
| The code parses as a JavaScript or TypeScript module | syntax_error |
Exactly one export default: a function with at most (ctx, input), not a generator |
missing_default_export, default_export_not_function, default_export_generator, default_export_params |
| No other exports | named_export, reexport_forbidden |
No imports, require, import.meta or module |
import_forbidden, dynamic_import, import_meta, require_forbidden |
No eval, Function, timers with text code, debugger or decorators |
forbidden_eval, forbidden_function_constructor, string_timer, debugger_statement, decorator |
No globalThis, window, self, global, process, Deno, Bun or WebAssembly (your own variables with those names are fine) |
forbidden_global |
No __proto__ or constructor chains, also not built from text such as this["ev" + "al"] |
proto_access, constructor_chain, forbidden_computed_access |
ctx.secrets.get and ctx.connection take one literal name |
secret_name_not_literal, connection_slot_not_literal |
ctx.secrets and ctx.connection are only called directly, never copied, taken apart or passed on |
secrets_indirect_use, connection_indirect_use |
ctx itself is only used as ctx.something: not copied, passed to a function, taken apart, read with [...], or looked at with Reflect or Object |
ctx_indirect_use |
No other variable or parameter has the same name as ctx |
ctx_shadowed |
No arguments |
arguments_forbidden |
| The check itself could not run (try again) | validator_unavailable |
Passwords and keys in code
Section titled “Passwords and keys in code”Liitora scans text in scripts, with: values and test data for credentials. For AI assistants every finding is an error, and the save is refused. For people it is a warning that says “Move to secret”. The fix is always the same: use a connection, or ctx.secrets.get("<name>") with a secret a person fills in.
| Code | What was found |
|---|---|
credential_private_key |
A PEM private key. |
credential_jwt |
A JSON Web Token. |
credential_api_key |
An sk- style API key. |
credential_stripe_key |
A Stripe secret, restricted or live publishable key. |
credential_github_token |
A GitHub token. |
credential_liitora_token |
A Liitora API token. |
credential_aws_key |
An AWS access key. |
credential_slack_token, credential_slack_webhook |
A Slack token or incoming webhook address. |
credential_google_api_key |
A Google API key. |
credential_azure_key |
An Azure signature or account key. |
credential_url_userinfo |
An address with a user name and password in it. |
credential_bearer_token, credential_basic_auth |
A literal Bearer or Basic sign-in value. |
credential_query_parameter |
A query parameter such as api_key= with a literal value. |
credential_named_value |
A value assigned to a name such as password, token, apiKey or client_secret. |
credential_high_entropy |
A long random-looking text that is probably a key. |
Obvious placeholders are never reported: empty values, <...>, ${...}, {{...}}, masks such as ****, and values with words such as dummy, example or placeholder. Known key formats are always reported.