A script node is a node you write in TypeScript inside Studio. It runs in the browser's agent with the same runtime SDK as the built-in nodes, so it can drive the page, open tabs, log, wait for a human and call mooflair's run API.
A script whose code mentions page or tabs is a page node: it runs in a Chrome browser. Without them it runs where other nodes with no page do: the run's Chrome browser if it has one, else the owner's worker, where page and tabs.open throw (The worker).
Create one
- In the editor, open the node picker (
Taborn) and choose New script node. - Fill in Name, Icon (a lucide name such as
sparkles), Description, and turn on Acts on the outside world if it posts, replies, sends or buys (then it's an effect node and is simulated in test runs). - Write the code and Save. The node appears in the picker under My nodes with type
script/<8 characters>.
To change it later: right-click a placed script node › Edit script.
The code
Export one async function as the default export. It receives the context and returns { item: [{ dot, json }] }: its items, each with the output dot it leaves through (an empty list ends the branch). It runs once per input item.
Output dots come from the code. On save, mooflair collects every literal dot: "…" it finds (quotes or backticks) and makes one dot per name; no literal means a single next dot. Write each dot as its own literal, like { dot: "empty", … }, not dot: isEmpty ? "empty" : "next". The code must also compile, or the save is refused with the error.
import type { TFlowNodeContext, TFlowNodeResult } from "@/types/lib/flow";
export default async (props: Readonly<TFlowNodeContext>): Promise<TFlowNodeResult> => {
const { page, field, log } = props;
await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
const title = await page.title();
log(`Title: ${title}`);
return title ? { item: [{ dot: "next", json: { title } }] } : { item: [] };
};
The code is transpiled to CommonJS (esbuild, target node24) when a run starts and shipped to the engine with its app's definition (kept there until its code changes). patchright is the only external module available.
The runtime SDK
| Member | Type | What it's for |
|---|---|---|
page | Patchright Page | The active tab of the run's browser. |
tabs.list | Page[] | Every open tab. |
tabs.open(url?) | Promise<Page> | Open a tab (it becomes the active page for later nodes). |
tabs.close() | Promise<void> | Close the active tab and go back to the previous one. |
tabs.renew() | Promise<Page> | The active tab again in a fresh window at its URL, the old one closed. Use it instead of page.reload() on a page that runs for hours: a reload keeps the tab's Chrome process and the memory it gathered, a new window starts clean. Use the page it returns from then on. |
field | Record<string, unknown> | The node's field values with expressions already resolved. |
nodes | Record<string, unknown> | Each earlier node its fields reference, by id: that node's item linked to this one (else its first). |
item | Record<string, unknown> | The item it runs for. |
items | Record<string, unknown>[] | Every input item. |
input | Record<string, Record<string, unknown>[]> | The input items by input dot (in). |
index | number | The item's position in items. |
file.item / file.items | Record<string, TFlowFile> / …[] | The of the item it runs for / of every input item. |
file.write({ name, mime, data }) | Promise<TFlowFile> | Save bytes (Buffer or text) as a file; return it as file: { <key>: ref } on an item. 100 MB a file, 1 GB a run. |
file.read(ref) / file.path(ref) | Promise<Buffer> / string | A file's bytes / its path in the browser (for page.setInputFiles). |
state | Record<string, unknown> | This node's own memory for the run (kept between calls, e.g. in a loop). For memory across runs and campaigns use a . |
Examples
Read a value from an earlier node
export default async ({ nodes, log }) => {
const items = (nodes.collect as { items: { url: string }[] }).items;
log(`Got ${items.length} posts`);
if (!items.length) return { item: [] };
return { item: [{ dot: "next", json: { first: items[0].url } }] };
};
Act differently in test runs
A script marked Acts on the outside world doesn't run at all in a test run (it returns { "dry": true } through its first dot). For a script that isn't marked but should still behave differently in tests, check run.dry:
export default async ({ page, field, run, log }) => {
if (run.dry) {
log(`Would like ${field.url}`);
return { item: [{ dot: "next", json: { liked: false, simulated: true } }] };
}
await page.goto(String(field.url));
await page.getByTestId("like").click();
return { item: [{ dot: "next", json: { liked: true } }] };
};
Wait for the human to finish something in the live view
export default async ({ page, intervention }) => {
await page.goto("https://accounts.example.com/login");
const answer = await intervention({
title: "Sign in to Example",
message: "Sign in in the live view.",
step: ["Enter your email and password", "Press Done"],
button: [
{ key: "skip", label: "Skip" },
{ key: "done", label: "Done", tone: "primary", check: true },
],
check: async () => (page.url().includes("/home") ? true : { done: false, reason: "Still on the sign-in page" }),
});
return { item: [{ dot: "next", json: { url: page.url(), skipped: answer?.key === "skip" } }] };
};
Write to a data table
export default async ({ api, field, signal }) => {
const response = await fetch(`${api.link}/api/run/table`, {
method: "POST",
signal,
headers: { authorization: `Bearer ${api.token}`, "Content-Type": "application/json" },
body: JSON.stringify({ table: field.tableId, operation: "insert", data: { url: field.url, at: new Date().toISOString() } }),
});
return { item: [{ dot: "next", json: await response.json() }] };
};
Script nodes in packages
carries the script nodes a flow uses, and import recreates them for the importing user.
FAQ
Can a script node import npm packages?
No. Only patchright is external. Write plain TypeScript, or call an HTTP API with fetch.
Why doesn't my script node show the output dot I return?
The dots come from the literal dot: "…" values in the code when you save. A dot computed at run time (a variable, a ternary) isn't seen; return each one as its own literal and save again.
Is my code run in mooflair or in the engine?
In the engine: inside the browser container's agent, next to the page it controls.