Skip to content

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.

A FlowScript document has five parts, always in this order:

  1. use lines that open namespaces (see use).
  2. interface declarations — the schemas of struct-typed values.
  3. Top-level variables (const / let, optionally decorated with @category("…") or @secret).
  4. function declarations — each becomes a Function layer whose parameters are input pins and whose returns are output pins.
  5. 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.

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:

SpellingExampleWhen
qualifiedstring::trim({ string: s })always works; the canonical static form
methods.trim()nodes whose declaration has a this: parameter
legacy flatstringTrim({ 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.

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.

SugarLowers to
a + b, a - b, a * b, a / b, a % b, a ** bint::add / float::multiply / … by operand type
"a" + bstring::concat
-x0 - x
x += v, -=, *=, /=x = x + v
==, !=, <, <=, >, >=, &&, ||, !the comparison / boolean nodes
c ? a : btypes::select
`Topic ${label}`string::format with one pin per placeholder
row.status = "done"struct::set on a mutable binding
let record = … / record.title = titlestruct::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.

if (reportCreated) {
return `Already created a report`
} else {
reportCreated = true
}
for (const item of userConfiguration.sources) { … } // control::forEach, item = value
for (const [index, item2] of rows3) { … } // + index
@parallel for (const item of items) { … } // control::parallelForEach
while (i < 3) { … } // control::whileLoop
for (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 })
}
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.

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.

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: T marks the receiver pin (the node is a method on T); T is the pin type, or the schema title for a titled struct.
  • @node is the catalog node type, @receiver the receiver pin, @alias the legacy camelCase spelling, @impure marks nodes with execution pins.
  • flow.d/names.json maps every node type to { qualified, namespace, alias, flat, receiver, class, category }; node.flow.schemas.json carries the JSON schemas of struct-typed pins, keyed by node type.

Regenerate them with cargo test -p flow-like-catalog --test generate_signatures.

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 })