Flo Runtime Book
This book is the public guide for writing Flo skill manifests and script tools.
It covers the supported authoring surface exposed through:
- skill and tool manifests
flo.d.tsimport * as flo from "flo:runtime"- the local Node preload shim in
flo_hooks.mts
Use this book when you are building or maintaining skills. Use the internal docs/ directory only when you are working on Flo itself.
What This Book Covers
- how to structure
*.tool.yamland*.skill.yaml - how to write TypeScript or JavaScript tools
- how to call tools, store state, read vault secrets, emit debug events, and use browser automation
- how to orchestrate child tasks with
flo.task.spawnChildren(...)
What This Book Does Not Cover
- backend-service deployment
- message bus internals
- database schema
- transport topology
- internal control-plane behavior
Those topics are intentionally kept out of this book so the content stays focused on skill authors.
Getting Started
Flo script tools run inside the Flo runtime and import their helpers from flo:runtime:
import * as flo from "flo:runtime";
The checked-in type declarations live in flo.d.ts. They are the source of truth for the public TypeScript surface documented in this book.
Local Authoring Loop
- Write or update a tool manifest such as
my_tool.tool.yaml. - Write the script inline with
execution.scriptor in a file withexecution.script_file. - Use
flo.d.tsfor editor autocomplete and type checking. - Use the repo-root preload shim for local Node-based smoke tests:
node --import=./flo_hooks.mts path/to/script.mts
The preload shim can invoke an exported __flo_main__() for local testing. It is a development aid, not a full runtime replica.
For a full local testing workflow, see Testing With Node.js.
Runtime Constraints You Should Know Early
- Runtime script source can be JavaScript or TypeScript.
- TypeScript is transpiled at runtime, but it is not type checked there.
- Static relative imports are supported.
- Bare imports, package-style imports, and dynamic
import()are rejected. - Some APIs are runtime-bound and intentionally fail in the local shim, including
flo.callTool(...),flo.state.*, and child-task orchestration. - Browser helpers require
FLO_LOCAL_BROWSER=1in the local shim.
Simple Script Example
import * as flo from "flo:runtime";
export async function run(input: { name?: string }) {
await flo.task.emitEvent({
event_type: "hello.started",
title: "Greeting tool",
message: "Preparing response",
level: "info",
});
return {
ok: true,
greeting: `hello ${input.name ?? "world"}`,
};
}
Next: Testing With Node.js
Testing With Node.js
Use the repo-root flo_hooks.mts preload shim to smoke-test script tools locally with Node.js.
This is the fastest way to validate script logic before running inside the full Flo runtime.
What The Preload Shim Does
flo_hooks.mts registers the flo:runtime module for local Node execution and provides a partial implementation of the public runtime surface.
It is useful for:
- module loading and import resolution
flo.d.ts-backed local TypeScript authoringflo.sleep(...)flo.time.formatUnixTimestamp(...)flo.vault.get(...)with mock dataflo.state.*with local binding fixturesflo.task.getToolState(...)flo.task.putToolState(...)flo.task.getContext(...)flo.task.emitEvent(...)- browser smoke tests when
FLO_LOCAL_BROWSER=1
It is not a full runtime replica.
Basic Invocation
Run a script file directly through Node:
node --import=./flo_hooks.mts path/to/skill_script.mts
If the module only exports run(...), nothing is auto-invoked. For local ad hoc testing, export __flo_main__():
import * as flo from "flo:runtime";
export async function __flo_main__() {
await flo.task.emitEvent({
event_type: "local.test",
title: "Node smoke test",
message: "Running locally through flo_hooks.mts",
level: "info",
});
return {
ok: true,
now: flo.time.formatUnixTimestamp(1_700_000_000, "YYYY-MM-DD HH:mm:ss", "UTC"),
};
}
When __flo_main__() returns a value, the preload shim prints it as JSON.
Typical Local Test Layout
- Keep the real runtime entrypoint as
export async function run(input). - Add a temporary or test-only
__flo_main__()that callsrun(...)with local fixture input. - Execute the script with
node --import=./flo_hooks.mts .... - Remove or keep
__flo_main__()only if it remains useful for manual testing.
Example:
import * as flo from "flo:runtime";
export async function run(input: { name?: string }) {
return {
greeting: `hello ${input.name ?? "world"}`,
};
}
export async function __flo_main__() {
return run({ name: "local-dev" });
}
Mocking Task Context
Use FLO_TASK_CONTEXT_JSON to provide a local durable task context:
FLO_TASK_CONTEXT_JSON='{"resume_payload":{"batch_id":"batch-1"},"custom":{"value":42}}' \
node --import=./flo_hooks.mts ./script.mts
Then read it in the script:
const context = await flo.task.getContext<{
resume_payload?: { batch_id?: string };
custom?: { value: number };
}>();
This is useful for testing resume-aware logic.
Mocking Vault Secrets
Use FLO_MOCKS_FILE to provide mock vault values:
{
"vault": {
"profile": {
"demo-token": "secret-value"
},
"shared": {
"shared-scope": {
"api-token": "shared-secret"
}
}
}
}
Run with:
FLO_MOCKS_FILE=./vault_mocks.json \
node --import=./flo_hooks.mts ./script.mts
Then fetch values normally:
const profileSecret = await flo.vault.get({
scope: "profile",
key: "demo-token",
});
const sharedSecret = await flo.vault.get({
scope: "shared",
scope_id: "shared-scope",
key: "api-token",
});
Browser Testing
Browser helpers stay disabled unless FLO_LOCAL_BROWSER=1 is set.
Basic browser-mode invocation:
FLO_LOCAL_BROWSER=1 \
node --import=./flo_hooks.mts ./script.mts
The shim can also point at a custom local worker module:
FLO_LOCAL_BROWSER=1 \
FLO_LOCAL_BROWSER_WORKER_MODULE=./tests/fixtures/flo-init/fake_playwright_worker.mjs \
node --import=./flo_hooks.mts ./script.mts
Use browser mode for smoke tests around:
- navigation
- screenshots
- request capture
- storage state import and export
Mocking State Bindings
Use FLO_MOCKS_FILE to provide local state binding metadata and stored values:
{
"state_bindings": [
{
"name": "profile_cache",
"key_prefix": "cache.",
"scope_kind": "profile"
},
{
"name": "shared_cache",
"key_prefix": "cache.shared.",
"scope_kind": "shared",
"scope_id": "service"
}
],
"state": {
"profile": {},
"session": {},
"task": {},
"shared": {}
}
}
Then call the runtime with scope_kind:
await flo.state.put({
scope_kind: "profile",
key: "cache.answer",
value: { answer: 42 },
});
await flo.state.get({
scope_kind: "shared",
key: "cache.shared.answer",
});
If a shared local binding omits scope_id, provide it in the request.
What Fails Fast In The Local Shim
Some runtime-bound APIs intentionally throw instead of pretending to work:
flo.callTool(...)flo.task.spawnChildren(...)flo.task.waitForBatch(...)flo.task.getBatchResults(...)
Recommended Workflow
- Use the Node preload shim for fast iteration on parsing, transforms, formatting, control flow, vault mocks, and state flows.
- Use
FLO_TASK_CONTEXT_JSONto exercise resume-aware logic. - Use
FLO_LOCAL_BROWSER=1only for browser-specific smoke tests. - Use runtime or integration tests for nested tool calls and child-task orchestration.
Next: Manifest Basics
Manifest Basics
Flo discovers tools and skills by file name:
**/*.tool.yamldefines one tool**/*.skill.yamldefines one skill
SKILL.md discovery is not supported.
Tool Manifest Shape
Every tool manifest must include:
namedescriptioninput_schemaexecution
Optional fields include:
timeout_msretry_policyvaultstate
Example:
name: capture_example
description: Open a page and return the current URL.
input_schema:
type: object
properties:
url:
type: string
required: [url]
additionalProperties: false
execution:
type: script
script_file: scripts/capture_example.mts
entrypoint: run
timeout_ms: 30000
Script Execution
For execution.type = script, define exactly one of:
script: inline JavaScript or TypeScriptscript_file: a relative path under the manifest directory
Use entrypoint to select the exported function the runtime should call.
Skill Manifest Shape
Every skill manifest must include:
skill_idnamedescription
Each skill must define exactly one instruction source:
instructioninstruction_file
Optional fields include:
versiontoolstool_definitionsrequires_skills
Example:
skill_id: browser_examples
name: Browser Examples
description: Browser-based tools for authenticated workflows.
tools:
- read_text_file
tool_definitions:
- name: capture_example
description: Open a page and return the current URL.
input_schema:
type: object
properties:
url:
type: string
required: [url]
execution:
type: script
script_file: scripts/capture_example.mts
entrypoint: run
instruction_file: instructions.md
State and Vault Declarations
Use state when your script needs durable non-secret data:
state:
- name: session_counter
key_prefix: counter.session.
scope_kind: session
- name: shared_counter
key_prefix: counter.shared.
scope_kind: shared
scope_id: service
Use vault when your script needs secrets:
vault:
- key: api_token
scope_kinds: [profile, shared]
The runtime still requires the script to fetch secrets explicitly through flo.vault.get(...).
Import Rules
The script runtime supports:
- local static ESM imports
- relative
.mjs,.mts, and related local module paths
The runtime rejects:
- bare specifiers
- package-style imports
- dynamic
import() ..traversal for author-facing asset imports
Next: TypeScript Runtime
TypeScript Runtime
The public runtime surface comes from:
import * as flo from "flo:runtime";
The module exports these top-level helpers:
sleeptimevaultstatetaskcallToolbrowser
JSON-Oriented API Design
Runtime values are JSON-shaped. FloJsonValue includes:
nullbooleannumberstring- arrays of JSON values
- objects whose values are JSON values
This affects:
- tool inputs and outputs
flo.state- child-task input and output
- browser
evaluateargs and values
Global Helpers
The runtime provides:
fetch(...)URLURLSearchParams
That means many integration-oriented tools can work without additional libraries.
Task Context
Use flo.task.getContext<T>() to read durable task context:
type Context = {
resume_payload?: {
batch_id?: string;
};
custom?: { value: number };
};
const context = await flo.task.getContext<Context>();
The context may include resume data after a suspension flow. When a task resumes, the runtime re-enters the script from its entrypoint instead of restoring the JavaScript stack.
Time and Sleep
Use flo.sleep(ms) to pause a script without blocking the runtime thread.
Use flo.time.formatUnixTimestamp(...) when you need stable date formatting:
const formatted = flo.time.formatUnixTimestamp(1_700_000_000, "YYYY-MM-DD HH:mm:ss", "UTC");
Local Shim Notes
The Node preload shim supports:
flo.sleep(...)flo.time.formatUnixTimestamp(...)flo.vault.get(...)withFLO_MOCKS_FILEflo.task.getContext(...)flo.task.emitEvent(...)- browser helpers when
FLO_LOCAL_BROWSER=1
Other runtime-bound APIs intentionally fail in the local shim so tests do not accidentally depend on unsupported local behavior.
Next: Nested Tool Calls
Nested Tool Calls
Use flo.callTool(...) to call another runtime tool from your script.
Generic Form
const result = await flo.callTool({
tool_id: "some_tool",
input: { value: 1 },
});
The returned shape is:
statusoutputerror
Possible statuses are:
successfailedtimeoutvalidation_errorsuspended
Typed Built-In Calls
For built-in tools declared in flo.d.ts, TypeScript can infer the input and output shape:
const file = await flo.callTool({
tool_id: "read_text_file",
input: {
path: "task://notes/summary.txt",
max_bytes: 4096,
},
});
Error Handling Pattern
Always branch on status instead of assuming output exists:
const result = await flo.callTool({
tool_id: "read_text_file",
input: { path: "task://notes/summary.txt" },
});
if (result.status !== "success") {
return {
ok: false,
status: result.status,
error: result.error,
};
}
return {
ok: true,
content: result.output?.content,
};
Execution Context
Nested tool calls run in the same runtime tool execution context as the calling script. In practice, this means task- and session-scoped helpers continue to operate on the current task and current virtual workspace.
Good uses:
- reading or writing VFS files through built-in tools
- composing smaller tools into a larger workflow
- delegating format-specific work to a built-in tool
Avoid using nested calls as a substitute for simple local code when a direct script implementation is clearer.
Next: Debug Events
Debug Events
Use flo.task.emitEvent(...) to append structured events to the current task timeline.
Example:
await flo.task.emitEvent({
event_type: "orders.sync.started",
title: "Sync started",
message: "Fetching latest order batch",
level: "info",
payload: {
shop_id: "acme",
page: 1,
},
});
Recommended Event Shape
event_type: stable machine-readable identifiertitle: short human-readable labelmessage: one-line progress updatelevel:info,warning, orerrorpayload: extra structured debug context
When To Emit Events
Emit events at boundaries that help explain tool behavior:
- external API request started
- expensive step completed
- retry branch taken
- validation failure
- child batch created or resumed
Avoid emitting noisy events for every trivial line of execution.
Local Testing
In the local Node preload shim, flo.task.emitEvent(...) writes the request to console.log. That makes it useful for smoke tests without requiring the full runtime.
Next: Vault
Vault
Use the vault for secrets. Do not put raw secrets in manifests.
Declaring Vault Access
Declare the keys your tool may use in the manifest:
vault:
- key: lx_token
scope_kinds: [shared]
- key: profile_api_key
scope_kinds: [profile]
This declaration does not inject secret values into your script. It declares which keys and scope kinds the tool expects to use.
Supported Scopes
The public runtime request shapes support:
profileshared
Profile scope uses only the secret key:
const token = await flo.vault.get({
scope: "profile",
key: "profile_api_key",
});
Shared scope also requires scope_id:
const sharedToken = await flo.vault.get({
scope: "shared",
scope_id: "shared",
key: "lx_token",
});
Authoring Guidance
- Use vault for credentials, API tokens, session secrets, and other secret material.
- Keep non-secret durable data in State, not in the vault.
- Treat returned secret strings as sensitive. Do not place them in normal tool outputs or debug payloads.
Local Testing With Mocked Secrets
The local preload shim supports flo.vault.get(...) via FLO_MOCKS_FILE.
Example shape:
{
"vault": {
"profile": {
"profile_api_key": "alice-profile-token"
},
"shared": {
"shared": {
"lx_token": "shared-api-token"
}
}
}
}
Then run:
FLO_MOCKS_FILE=./vault_mocks.json node --import=./flo_hooks.mts ./script.mts
Next: State
State
Use flo.state for durable non-secret data declared in the tool manifest.
Declaring State Bindings
Declare named bindings in the manifest:
state:
- name: session_counter
key_prefix: counter.session.
scope_kind: session
- name: task_audit
key_prefix: counter.audit.
scope_kind: task
- name: shared_carrier_map
key_prefix: carrier_mapping/
scope_kind: shared
scope_id: service
Supported Scope Kinds
Manifest state bindings support these scope kinds:
profilesessiontaskshared
For shared, bindings may declare a fixed scope_id. When they do, scripts do not pass scope_id at runtime. If a shared binding omits scope_id, runtime calls must provide it.
Read, List, Write, Delete
Read one key:
const entry = await flo.state.get<{ total: number }>({
scope_kind: "session",
key: "counter.session.total",
});
List a prefix:
const page = await flo.state.list({
scope_kind: "task",
key_prefix: "counter.audit.events.",
limit: 100,
});
Write with optional TTL and optimistic concurrency:
const write = await flo.state.put({
scope_kind: "session",
key: "counter.session.total",
value: { total: 4 },
ttl_seconds: 3600,
if_revision: entry?.revision ?? null,
});
Delete:
const deleted = await flo.state.delete({
scope_kind: "session",
key: "counter.session.total",
if_revision: entry?.revision ?? null,
});
Result Shapes
State entries include:
keyvaluerevision- optional
expires_at
Writes return:
ok- optional
entry - optional
conflict_revision
When To Use flo.state
Use manifest-declared state for:
- caches that need clear ownership
- profile/session/task/shared durable data
- multi-step flows that need explicit persistence
Use Task Tool State for lightweight task-scoped convenience state that does not need a manifest binding.
Shared Scope Pattern
Prefer multiple explicit shared bindings over a single dynamic shared namespace:
state:
- name: service_cache
key_prefix: cache.service.
scope_kind: shared
scope_id: service
- name: billing_cache
key_prefix: cache.billing.
scope_kind: shared
scope_id: billing
Then scripts stay explicit about the backing storage kind:
await flo.state.get({
scope_kind: "shared",
key: "cache.service.answer",
});
Next: Task Tool State
Task Tool State
Use flo.task.getToolState(...) and flo.task.putToolState(...) for lightweight, tool-partitioned state scoped to the current task.
Unlike flo.state, these helpers do not require a manifest state binding.
Read Current Tool State
const checkpoint = await flo.task.getToolState<{
page?: number;
cursor?: string;
}>({
key: "sync_checkpoint",
});
You can also read another tool’s convenience state in the same task by passing tool_id.
Write Current Tool State
await flo.task.putToolState({
key: "sync_checkpoint",
value: {
page: 2,
cursor: "abc123",
},
ttl_seconds: 3600,
});
putToolState(...) supports:
keyvalue- optional
ttl_seconds - optional
if_revision
It returns the same write-result structure used by flo.state.put(...).
Best Use Cases
Use tool state for:
- resume checkpoints
- small per-tool task notes
- retry-safe progress markers
- child-batch bookkeeping
This is especially useful before flo.task.waitForBatch(...), because resumed scripts restart from the entrypoint instead of restoring the JavaScript stack.
Choosing Between flo.state And Tool State
Prefer flo.task.getToolState(...) / putToolState(...) when:
- the data is specific to the current task
- the data belongs to one tool implementation
- you do not need a reusable manifest-declared binding
Prefer flo.state when:
- data should outlive the task
- data needs explicit profile, session, task, or shared binding semantics
- multiple scripts should share a named binding contract
Next: Browser Automation
Browser Automation
Use flo.browser to drive a host-managed browser session from a script tool.
Available Helpers
flo.browser.run(...)flo.browser.startRequestCapture(...)flo.browser.collectCapturedRequests(...)flo.browser.stopRequestCapture(...)flo.browser.exportState(...)flo.browser.importState(...)
Supported Commands
flo.browser.run(...) accepts these command types:
gotoreloadfillclickpressselectwait_forextractevaluatescreenshot
Example:
await flo.browser.run({
type: "goto",
url: input.url,
wait_until: "networkidle",
timeout_ms: 30_000,
});
const shot = await flo.browser.run({
type: "screenshot",
full_page: true,
});
Required Checks
Browser helpers accept optional session checks:
await flo.browser.run(
{ type: "click", selector: "button[type=submit]" },
{
required_checks: [
{
kind: "selector_present",
value: ".dashboard",
timeout_ms: 10_000,
},
],
},
);
Supported check kinds are:
url_not_matchesselector_presentselector_absent
Request Capture
Use request capture when you need to observe API traffic triggered by page actions.
const capture = await flo.browser.startRequestCapture([
{
url_regex: "https://example.com/orders/.*/api",
resource_types: ["fetch"],
},
]);
await flo.browser.run({
type: "goto",
url: input.page_url,
wait_until: "networkidle",
});
const collected = await flo.browser.collectCapturedRequests(capture.capture_id, {
timeout_ms: 10_000,
});
Storage State
Use storage state helpers when your flow must resume after a handoff or action-needed pause:
exportState(...)saves cookies and origin storageimportState(...)restores a previously exported state
Authoring Constraints
- Browser helpers only work when the runtime is executing a real tool invocation with task/session context.
- The local Node preload shim requires
FLO_LOCAL_BROWSER=1. - Keep browser steps short and deterministic.
- Use request capture for network observations instead of scraping low-level browser internals.
Next: Sub Agents
Sub Agents
Use child-task helpers when a script needs durable parallel work for specialized subtasks.
Spawn Children
Create a child batch with flo.task.spawnChildren(...):
const spawned = await flo.task.spawnChildren({
children: [
{
worker_kind: "extractor",
title: "Extract invoice fields",
objective: "Extract invoice number and total from the document",
input: { document_id: input.document_id },
},
{
worker_kind: "classifier",
title: "Classify invoice type",
objective: "Classify the invoice into the supported categories",
input: { document_id: input.document_id },
},
],
});
Each child defines:
worker_kindtitleobjectiveinput
Wait For Completion
Use flo.task.waitForBatch(...) when the parent should suspend until all child tasks are terminal:
await flo.task.putToolState({
key: "batch_checkpoint",
value: { batch_id: spawned.batch.batch_id },
});
const results = await flo.task.waitForBatch({
batch_id: spawned.batch.batch_id,
});
Important: after suspension, the runtime re-enters the script from the entrypoint. Persist the progress you need before waiting.
Read Results Without Suspending
Use flo.task.getBatchResults(...) only when the batch is already terminal:
const results = await flo.task.getBatchResults({
batch_id: batchId,
});
If the batch is still pending, this call fails non-retryably.
Result Shape
Each child result includes:
child_task_idworker_kindstatusoutput- optional
error - optional
completed_at
Authoring Guidance
- Use child tasks for durable parallelism, not for lightweight local branching.
- Persist checkpoints with Task Tool State before waiting.
- Keep child inputs compact and JSON-serializable.
- Handle partial failures explicitly; one child can fail while others succeed.
Next: Other APIs
Other APIs
This chapter collects the rest of the public author-facing runtime helpers.
flo.sleep(ms)
Pause the script asynchronously:
await flo.sleep(500);
Use it sparingly for pacing, polling, or short waits between retries.
flo.time.formatUnixTimestamp(...)
Format a Unix timestamp using the runtime helper:
const text = flo.time.formatUnixTimestamp(
1_700_000_000,
"YYYY-MM-DD HH:mm:ss",
"UTC",
);
flo.task.getContext<T>()
Read durable task context, including resume payloads:
const context = await flo.task.getContext<{
resume_payload?: { batch_id?: string };
custom?: { value: number };
}>();
flo.task.limits
Inspect runtime task-orchestration limits:
const maxChildren = flo.task.limits.maxSpawnChildren;
Use this value to chunk child-task work before calling spawnChildren(...).
Built-In Tools Through flo.callTool(...)
flo.d.ts includes typed support for built-ins such as:
read_text_filewrite_text_fileread_dirzipunzipcsv_*excel_*media_fetchmedia_push_vfsmedia_push_base64send_notificationsend_media_attachmentread_skill_resourceimport_skill_asset
When a built-in already matches the file or media operation you need, prefer it over reimplementing the same behavior in script code.
Skill Resources And Assets
Two built-ins are especially useful from authored skills:
read_skill_resourcereads a selected skill resource or imports it into VFSimport_skill_assetcopies a selected skill asset into VFS
Both are invoked through flo.callTool(...).
Final Notes
- Favor JSON-serializable inputs and outputs.
- Keep secrets in the vault, not in manifests or state.
- Use task tool state for resume checkpoints.
- Use the manifest as the contract for what your tool needs.
For exact TypeScript signatures, refer to flo.d.ts.