FlowScript
FlowScript is a TypeScript-flavoured rendering of a board. Every board can be shown as FlowScript, edited as text, and applied back onto the graph; it is also the language FlowPilot reads and writes when it changes a workflow. This language reference combines examples from rendered boards with authoring patterns that map back to the graph.
For a guided introduction, read FlowBook’s anatomy of a FlowScript document and its first Flow chapter. FlowBook teaches the language through connected examples; this page is the current syntax and authoring reference. Check each chapter’s Draft status.
File layout
Section titled “File layout”A FlowScript document has five parts, always in this order:
uselines that open namespaces (seeuse).interfacedeclarations — the schemas of struct-typed values.- Top-level variables (
const/let, optionally decorated with@category("…")or@secret). functiondeclarations — each becomes a Function layer whose parameters are input pins and whose returns are output pins.- Events — the entry nodes, written as
<eventType> <name>(params) { … }.
use db::*use string::*use ui::*
interface ReportEntry { title: string; uri: string; summary: string; date: string; interest?: string; report_id: string;}
@category("Report")const reportCreated = false@category("Report")const reportEntry: ReportEntry[] = []@category("Report")const reportID = ""
function saveConfig(config: Struct) { files::userDir({ nodeScope: false }).child("config.json").writeString({ content: json::stringify({ value: config, pretty: true }) })}
eventsSimple clickLatestOverview() { const database = open({ name: "report_overview", userScoped: true, batchSize: 1000 }) const session = df::createSession({ sessionName: "default" }) session.registerLance({ database: database, tableName: "reports" }) const { rows } = session.sqlQuery({ query: "SELECT report_id FROM reports ORDER BY created DESC LIMIT 1;" }) navigateTo({ route: `/briefing?report_id=${rows[0].report_id}` })}A top-level const whose initializer is a scalar literal infers its type (string, int,
float, bool); everything else keeps an explicit : Type. A secret variable renders its
@secret decorator but never its value.
Calling nodes
Section titled “Calling nodes”Every catalog node has a namespace and an alias. A call is the qualified path followed by an object whose keys are the node’s exact input pin names:
const hash = hash::md5({ input: item.content })log::info({ message: source, toast: false })const markdown = md::fromHtml({ html: text, skippedTags: ["script", "style"] }):: separates namespace segments; . is field or method access on a value. The same node can
be spelled three ways, and all three resolve to the same node:
| Spelling | Example | When |
|---|---|---|
| qualified | string::trim({ string: s }) | always works; the canonical static form |
| method | s.trim() | nodes whose declaration has a this: parameter |
| legacy flat | stringTrim({ string: s }) | the camelCase node type; accepted forever, never rendered |
A node is a method when it has a receiver pin — by default the first input when its type is
the namespace’s own value type (string, int, float, bool, array, map, set, struct,
bytes, path, datetime). The receiver binds that pin, so it is not repeated inside { … };
when exactly one input remains it may be passed positionally:
const arrayOut = elements.push(item)const label = "Topic {label}\nGoal: {Goal}".format({ goal: source.goal, label: source.label })setElementText({ elementRef: "page/generated-date", text: date.format("%A, %B %-d, %Y") })if (values.get(0).success) { … }Numeric literal receivers need parentheses ((5).abs()), exactly as in JavaScript.
Outputs
Section titled “Outputs”A call expression evaluates to the node’s default output. A node with a single data output is the value itself; pick outputs of a multi-output node by pin name, either through destructuring or field access:
const hash2 = md5({ input: item3.content })const { rows, rowCount } = sqlQuery({ session: session3, query: "SELECT …" })const { value, exists } = getQueryParams({ paramName: "report_id" })const aPICall = fetch({ request: makeRequest({ method: "GET", url: url }) })const text = responseToText({ response: aPICall.response })Destructuring renames with pin: local ({ value: reportId }); a binding used as a whole value
(aPICall) is the node’s default output and still exposes every pin as a field.
use ns::* opens a namespace so its members can be called bare. It is only valid at the top of
the file. The Rust forms are all accepted:
use string::* // glob: trim({ string: s }) / s.trim()use ai::response // brings `response` into scope: response::make()use github::copilot as copilot // alias: copilot::createSession({ … })use ui::{ setElementText, navigateTo }Rendered boards open every namespace with two or more static call sites (alphabetically) and keep
single calls qualified; use lines are derived from the board, never stored on it. Two opened
namespaces that export the same member (string::length and array::length) are disambiguated
by the argument shape; when that is not enough the compiler reports the qualified alternatives
instead of guessing.
Operators and sugar
Section titled “Operators and sugar”| Sugar | Lowers to |
|---|---|
a + b, a - b, a * b, a / b, a % b, a ** b | int::add / float::multiply / … by operand type |
"a" + b | string::concat |
-x | 0 - x |
x += v, -=, *=, /= | x = x + v |
==, !=, <, <=, >, >=, &&, ||, ! | the comparison / boolean nodes |
c ? a : b | types::select |
`Topic ${label}` | string::format with one pin per placeholder |
row.status = "done" | struct::set on a mutable binding |
let record = … / record.title = title | struct::set chain, rebinding record |
'single quotes', trailing ; | accepted; rendered as "…" without ; |
Template literal placeholders are named after the expression (${label} → label,
${source.goal} → goal, anything else → argN); static text that already contains {name}
is rejected because the format node would read it as a placeholder.
Control flow
Section titled “Control flow”if (reportCreated) { return `Already created a report`} else { reportCreated = true}
for (const item of userConfiguration.sources) { … } // control::forEach, item = valuefor (const [index, item2] of rows3) { … } // + index@parallel for (const item of items) { … } // control::parallelForEachwhile (i < 3) { … } // control::whileLoopfor (const parallelForEach of control::parallelForEach({ array: userConfiguration.sources, maxConcurrent: 15 })) { perpareNews({ source: parallelForEach.value }) // explicit handle form, still accepted}Nodes with several execution outputs open a block per arm; the arm labels are the node’s exact
execution pin names. A trailing comment on an if whose condition is itself a node call names the
execution pin the branch follows:
aPICall { execSuccess: { const text = responseToText({ response: aPICall.response }) return text } execError: { return `FAILED TO FETCH WEBSITE - SKIP` }}
if (pathExists({ path: child({ parentPath: pathFromUserDir({ nodeScope: false }), childName: "config.json" }) })) { // exec_out_exists const content = readToString({ path: child({ parentPath: pathFromUserDir({ nodeScope: false }), childName: "config.json" }) }) userConfiguration = fromString({ string: content })} else { // exec_out_missing userConfiguration = { general: { arXiv: false, hn: false, news: false }, sources: [] } saveConfig({ config: userConfiguration })}Functions and handlers
Section titled “Functions and handlers”function filterSources(hash: string) { let elements: Elements[] = [] for (const item3 of userConfiguration.sources) { const hash2 = md5({ input: item3.content }) if (hash != hash2) { const arrayOut3 = elements.push(item3) elements = arrayOut3 } } userConfiguration = { general: userConfiguration.general, sources: elements }}
eventsGeneric fetchPage(url: string, payload: Struct) { chatPushStep({ title: "Reading Website", description: url }) …}An event parameter written name?: Type is optional: callers may omit it and the runtime fills
the type’s empty value ("", 0, false, [], {}), while name?: Type = literal stores that
literal as the default instead. payload is never optional, and function parameters carry neither
? nor =.
eventsGeneric fetchPage(url: string, retries?: int = 3, payload: Struct) { …}A function becomes a Function layer. Declare named output pins for returned values:
function double(n: int): (out: int) { return n * 2 }. User functions also join the method tables.
The first parameter is the receiver, so fullName.parseName() calls
function parseName(name: string).
A helper function may omit return when it has no outputs. If it returns values, use one
return as the last statement in the function body, outside every branch, loop and execution
arm. Early, nested and multiple function returns are rejected. Initialize a mutable let with
a literal, assign it in the branches, then return it after the control block:
function chooseLabel(enabled: bool, left: string, right: string): (label: string) { let label = "" if (enabled) { label = left.trim() } else { label = right.trim() } return label}Event handlers keep separate return semantics. A Generic Event can return from a branch and
emits one value; the return examples under Control flow are handler examples.
A handler such as eventsGeneric fetchPage(…) declared inside a function is an entry node that
agents can invoke with tools: [fetchPage]. Its returns belong to that handler, not its enclosing
function.
Anchors and editing
Section titled “Anchors and editing”In the editable view every statement carries its node id as a trailing //@n:<id> comment
(variables //@v:, layers //@l:). Keep the anchor and you are editing that node; drop it and you
are deleting it — and deletions are blocked unless explicitly allowed. See the
FlowScript blog post for the reasoning behind the
round-trip invariants.
Declarations (.flow.d)
Section titled “Declarations (.flow.d)”The catalog is described by generated declaration files in packages/ast/flow.d/ (one per
top-level category, plus one per package under flow.d/packages/). They are the “types” behind
editor completion, hover, the VS Code extension, and FlowPilot’s get_declarations tool. Each
node is one function inside its declare namespace block:
declare namespace string { // === Utils/String ===
/** * Checks if a string contains a substring * @node string_contains @receiver string @alias stringContains * @param string — Input String (receiver: `this` in `x.contains(...)`) * @param substring — Substring to search for * @param ignoreCase (optional) — Compare without regard to upper/lower case * @returns contains — Does the string contain the substring? */ function contains(this: string, { string: string, substring: string, ignoreCase?: bool }): bool;}
declare namespace ai { namespace ml { /** … @node ai_ml_model_read @alias aiMlModelRead @impure has side effects / drives control flow */ function read({ path: string }): Struct; }}- The
{ … }object is the complete static call shape, receiver included. this: Tmarks the receiver pin (the node is a method onT);Tis the pin type, or the schema title for a titled struct.@nodeis the catalog node type,@receiverthe receiver pin,@aliasthe legacy camelCase spelling,@impuremarks nodes with execution pins.flow.d/names.jsonmaps every node type to{ qualified, namespace, alias, flat, receiver, class, category };node.flow.schemas.jsoncarries the JSON schemas of struct-typed pins, keyed by node type.
Regenerate them with cargo test -p flow-like-catalog --test generate_signatures.
get_declarations
Section titled “get_declarations”FlowPilot looks nodes up with get_declarations, which searches the embedded declaration index
by intent ("string contains substring", "gmail imap fetch mail") or by any exact spelling
(string::contains, stringContains, string_contains). The answer prints the use lines for
every namespace in the result, then one compact signature per node and, for nodes with a
receiver, both call forms:
// use string::*
1. string::contains — Checks if a string contains a substring [utils.flow.d :: Utils/String] function string::contains(this: string, { string: string, substring: string, ignoreCase?: bool }): bool; // string::contains({ string: string, substring: substring, ignoreCase: ignoreCase }) or string.contains({ substring: substring, ignoreCase: ignoreCase })