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
- bundle-level runtime prompt 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 structure bundle-level
*.prompts.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. - If you need bundle-wide execution guidance, add a single
*.prompts.yamlfile such asruntime.prompts.yaml. - 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.
Bundle-level prompt manifest example:
execution:
preamble: |
You are a slot-scoped runtime.
Keep responses concrete and concise.
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 still runtime-bound and intentionally fail in the local shim, including
flo.callTool(...)and child-task orchestration. The shim does supportflo.state.*whenFLO_MOCKS_FILEincludes matchingstate_bindings. - 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.getRuntimeConfig(...)with mock dataflo.time.formatUnixTimestamp(...)flo.vault.get(...)with mock dataflo.state.*with local binding fixturesflo.task.getState(...)flo.task.putState(...)flo.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"
}
}
}
}
FLO_MOCKS_FILE can also define plain runtime config values for flo.getRuntimeConfig(...).
{
"runtime_config": {
"FLO_API_BASE": "https://api.example.test"
}
}
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.patch({
scope_kind: "profile",
key: "cache.answer",
patch: { refreshed_at: "2026-05-07T00:00:00Z" },
});
await flo.state.append({
scope_kind: "profile",
key: "queue.jobs",
item: { id: 1 },
});
await flo.state.get({
scope_kind: "shared",
key: "cache.shared.answer",
});
Shared local bindings must declare scope_id in state_bindings; the shim does not accept request-time scope_id for flo.state.*.
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**/*.schedule.yamldefines one bundle-managed schedule**/*.prompts.yamldefines one bundle-managed runtime prompt manifest
SKILL.md discovery is not supported.
Tool Manifest Shape
Every tool manifest must include:
namedescriptioninput_schemaexecution
Optional fields include:
timeout_msretry_policyvaultstaterequires_permissionsdirect_callscript_tools
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
Tool field behavior:
-
supported
agentdslash commands are/calland/web -
direct_call- defaults to
false - when
true, the tool may be invoked explicitly through/call <tool_id> - it also makes the tool reachable from
flo.callTool(...)even when the tool is outside the current selected-skill tool set - it does not automatically expose helper tools to the LLM
- defaults to
-
script_tools- defaults to
[] - declares helper tool ids callable from this tool through
flo.callTool(...) - these helpers are not automatically exposed to the LLM
- these helpers are not listed by
/callunless the helper tool also setsdirect_call: true
- defaults to
-
requires_permissions- defaults to
[] - declares permission ids required before the tool is usable
- the caller must have all listed permissions
- defaults to
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:
versionexecution_model_tiertoolsscript_toolstool_definitionsrequires_skillsrequires_labelsrequires_permissions
Example:
skill_id: browser_examples
name: Browser Examples
description: Browser-based tools for authenticated workflows.
execution_model_tier: large
tools:
- read_text_file
script_tools:
- send_media_attachment
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
Field behavior:
tools- declares referenced external or built-in tool ids for the selected skill
- these tools are exposed to the LLM tool list
- these tools are also callable from
flo.callTool(...)
execution_model_tier- optionally requests the execution-stage model tier for that skill
- allowed values:
nano,small,medium,large,frontier - applies only to the main execution stage, not task selection, gate, or task summary
- when multiple selected skills request different tiers, Flo uses the highest requested tier
- the requested tier is resolved through the runtime’s configured tier-to-model mapping
- when omitted, Flo uses the configured execution-stage model
script_tools- declares referenced external or built-in tool ids for the selected skill
- these tools are callable from
flo.callTool(...) - these tools are not exposed to the LLM tool list
tool_definitions- declares inline tool manifests owned by the skill
- inline tools are available to the selected skill without being repeated in
toolsorscript_tools
requires_permissions- defaults to
[] - declares permission ids required before the skill is visible or selectable
- the caller must have all listed permissions
- defaults to
Authoring rules:
- use
toolswhen the model should be able to call the tool directly - use
script_toolswhen only your script should call the tool - do not repeat an inline tool from
tool_definitionsin eithertoolsorscript_tools script_toolsonly changes LLM visibility; it does not create a separate security boundary from the selected skill’s scripts
Visibility Summary
Flo has separate visibility rules for normal execution and explicit /call usage.
During normal execution:
- the LLM sees globally available tools plus tools listed in the selected skills’
tools - the selected skills’ scripts can call globally available tools, tools listed in the selected skills’
toolsandscript_tools, and any tool whose manifest setsdirect_call: true - inline tools from
tool_definitionsare available to the owning skill without being repeated elsewhere
During /call execution:
/calllists tools whose manifests setdirect_call: true- the external-app direct-call API uses the same
direct_call: truegate - running
/call <tool_id>preserves the current selected-skill context for nested tool access - the called tool may always call itself
- the called tool may also call globally available tools and tools listed in that tool manifest’s own
script_tools - helper tools still remain hidden unless they are global, reachable through the selected skill set, or declared in the direct-call tool’s own
script_tools
Permission Catalogs
Permissions are bundle-scoped. A skill bundle may include at most one *.permissions.yaml file.
Use the catalog file to define the permission ids that skills and tools reference from requires_permissions.
Example:
catalog_id: admin
version: v1
groups:
- group_id: admin
name: Admin
description: Administrative actions
permissions:
- permission_id: admin.roles.write
name: Manage roles
description: Create, update, and delete roles
- permission_id: admin.permissions.read
name: View permission catalog
Catalog fields:
catalog_id- optional stable identifier for the catalog
version- optional catalog version string
groups- required list of permission groups
groups[].group_id- required unique group id
groups[].name- required display name
groups[].description- optional description
groups[].permissions- list of permissions in the group
groups[].permissions[].permission_id- required unique permission id used by
requires_permissions
- required unique permission id used by
groups[].permissions[].name- required display name
groups[].permissions[].description- optional description
Authoring rules:
- define each permission id once in the bundle catalog
- use exact permission ids in
requires_permissions - permission ids, group ids, and names must be non-empty
- leading or trailing whitespace is rejected
- duplicate
group_id,permission_id, orrequires_permissionsentries are rejected - if a bundle includes more than one
*.permissions.yamlfile, loading fails
Runtime behavior:
- a skill with
requires_permissionsis hidden unless the caller has all listed permissions - a tool with
requires_permissionsis unavailable unless the caller has all listed permissions - profile permission assignment is validated against the active bundle slot’s catalog
- if a profile is granted a permission id that is not defined in the active catalog, the update is rejected
Runtime Prompt Manifest Shape
Runtime prompt manifests are bundle-scoped resources. They are not owned by a skill.
Flo loads at most one *.prompts.yaml file from a bundle. If more than one is present, bundle loading fails.
Required fields:
execution.preamble
Example:
execution:
preamble: |
You are a slot-scoped runtime.
Keep responses concrete and concise.
Field behavior:
execution.preamble- required non-empty string
- prepended to the runtime’s configured execution-stage prompt
- if the runtime execution prompt is empty, the preamble is used by itself
Authoring rules:
- use this manifest for bundle-wide execution guidance that should apply regardless of which skills are selected
- keep it focused on durable execution behavior, not one-off task content
- unknown top-level fields are rejected
- the current manifest shape is intentionally narrow; today only
execution.preambleis supported
Schedule Manifest Shape
Schedule manifests are bundle-scoped resources. They are not owned by a skill.
Required fields:
schedule_idprofile_channelprofile_external_user_iduser_message- exactly one of
triggerorcron_expression timezone
Optional fields:
contexttarget_agent_overriderequired_labelsselected_skill_idsskip_skill_selectionenabledcron_expression
Example:
schedule_id: morning_digest
profile_channel: wecom
profile_external_user_id: alice
user_message: Send the morning digest.
selected_skill_ids:
- skill.digest
trigger:
kind: recurring
times_of_day:
- hour: 9
minute: 0
timezone: America/Toronto
enabled: true
Behavior:
- Flo reconciles schedule manifests when a skill bundle is pushed and when
backend-servicestarts with persisted bundle slots. - Flo resolves
profile_channelplusprofile_external_user_idthrough the runtime alias table. - If the alias does not exist, Flo auto-creates a normal non-admin user profile and records the alias.
- If a schedule manifest is removed, Flo deletes the managed schedule but does not delete the profile or alias it created.
- Managed schedules are read-only in the admin UI except for
Run now.
Schedule Trigger Shape
When using trigger, the supported shape is:
trigger:
kind: recurring
Recurring triggers support these fields:
minutes_interval- optional integer in
1..=59 - runs every N minutes
- optional integer in
hours_interval- optional integer in
1..=23 - runs every N hours
- optional integer in
times_of_day- optional list of wall-clock times
- each item has
hourin0..=23andminutein0..=59
days_of_week- optional list of weekdays using
0..=6 0is Sunday,6is Saturday
- optional list of weekdays using
Validation rules:
- define exactly one of
minutes_interval,hours_interval, ortimes_of_day days_of_weekis a filter and may be combined with any one of the cadence options abovetimes_of_dayentries must not contain duplicates- in v1, all
times_of_dayentries must share the sameminute days_of_weekmust not contain duplicates
Examples:
Every 15 minutes:
trigger:
kind: recurring
minutes_interval: 15
Every 6 hours:
trigger:
kind: recurring
hours_interval: 6
At 09:00 and 17:00 every weekday:
trigger:
kind: recurring
times_of_day:
- hour: 9
minute: 0
- hour: 17
minute: 0
days_of_week: [1, 2, 3, 4, 5]
Use cron_expression instead of trigger when you need a schedule shape that is not covered by the recurring trigger fields.
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:
sleepgetRuntimeConfigtimevaultstatetaskdispatchercallToolbrowser
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(...)BlobFileFormDataURLURLSearchParams
That means many integration-oriented tools can work without additional libraries.
For multipart uploads from the virtual workspace, create a VFS-backed File with flo.file(...):
import * as flo from "flo:runtime";
const form = new FormData();
form.append("meta", JSON.stringify({ kind: "report" }));
form.append(
"file",
flo.file("task://artifacts/report.csv", {
type: "text/csv",
name: "report.csv",
}),
);
await fetch("https://example.com/upload", {
method: "POST",
body: form,
});
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.
Current Profile
Use flo.task.getProfile() to read the current runtime profile metadata:
const { profile_id, display_name } = await flo.task.getProfile();
The response also includes profile_kind and permissions. display_name is optional.
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");
Admin-Managed Runtime Config
Use await flo.getRuntimeConfig(key) to read one admin-managed plain-text runtime config value:
const apiBase = await flo.getRuntimeConfig("FLO_API_BASE");
This helper does not require a manifest entry, resolves to undefined when the key is not configured, and is intended for non-secret settings that admins manage centrally. Use flo.vault.get(...) for secrets.
Local Shim Notes
The Node preload shim supports:
flo.sleep(...)flo.getRuntimeConfig(...)withFLO_MOCKS_FILEflo.time.formatUnixTimestamp(...)flo.vault.get(...)withFLO_MOCKS_FILEflo.state.*with local state binding fixturesflo.task.getProfile()flo.task.getContext(...)flo.task.emitEvent(...)- browser helpers when
FLO_LOCAL_BROWSER=1
For flo.task.getProfile(), the shim uses local-node-profile by default and supports
FLO_LOCAL_PROFILE_ID plus FLO_LOCAL_PROFILE_DISPLAY_NAME overrides.
Other runtime-bound APIs, including flo.dispatcher.* and flo.task.waitForUserMessage(...), 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.
The supported agentd slash commands are /call and /web.
Nested calls are still scoped by the selected skill set. A script can call:
- globally available runtime tools
- tools listed in the skill manifest’s
tools - tools listed in the skill manifest’s
script_tools - inline tools declared via
tool_definitions
When a tool is invoked through /call, nested calls are scoped differently. A direct-call tool can call:
- globally available runtime tools
- tools reachable through the current selected skill set
- the direct-call tool itself
- tools listed in that tool manifest’s
script_tools
script_tools are the usual choice when you want a helper tool callable from script without adding it to the LLM-visible tool list. Inline tools from tool_definitions are also callable because they are compiled into the selected skill’s runtime tool set automatically.
Generic Form
const result = await flo.callTool({
tool_id: "some_tool",
input: { value: 1 },
});
The returned shape is:
statusoutputerror
Possible statuses are:
successfailedtimeoutvalidation_errorsuspended
When error is present, it includes:
retryable: whether the failed task/run is eligible for retrycontinuable: whether the model may continue the current turn after that failed call
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,
},
});
Manifest Setup
Use tools when the LLM should be able to call the tool directly.
Use script_tools when only your script should call it:
skill_id: file_sender
name: File Sender
description: Prepare a file and return it as an attachment.
script_tools:
- send_media_attachment
instruction_file: instructions.md
In that example, send_media_attachment is available to flo.callTool(...) inside the selected skill’s scripts, but it is not exposed in the execution-stage LLM tool list.
For /call, declare helper access on the tool itself:
name: publish_report
description: Publish a prepared report.
direct_call: true
script_tools:
- send_media_attachment
input_schema:
type: object
additionalProperties: false
execution:
type: script
script_file: scripts/publish_report.mts
entrypoint: run
That makes publish_report callable from /call publish_report, while send_media_attachment stays nested-only unless it separately declares direct_call: true. The same direct_call: true eligibility also governs typed direct-call invocations from external apps.
Direct-Call Visibility Rules
direct_call: true controls two runtime behaviors: whether a tool can be invoked explicitly through /call or the external-app direct-call API, and whether flo.callTool(...) may reach that tool outside the current selected-skill tool set.
It does not:
- add the tool to the normal execution-stage LLM tool list
- expose that tool’s helpers to the LLM
- make every other selected-skill-only helper automatically callable
In practice:
toolscontrols what the execution-stage LLM can call for a selected skillscript_toolscontrols helper access fromflo.callTool(...)without exposing those helpers to the LLMdirect_call: truecontrols whether/call <tool_id>is allowed and also makes that tool reachable fromflo.callTool(...)even when it is outside the current selected-skill tool set- a helper used from a direct-call tool still needs to be global, available through the current selected skills, declared in the direct-call tool’s own
script_tools, or independently markeddirect_call: true - profile permission checks still apply to nested calls into
direct_call: truetools
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.
Manifest Boundaries
flo.callTool(...) crosses a runtime tool boundary. The called tool runs with its own manifest-declared vault and state bindings, so the runtime applies that wiring automatically for the nested call.
By contrast, a plain TypeScript import does not create a new tool boundary. Imported code runs as part of the current script tool, so the current tool manifest must declare any flo.vault.get(...) and flo.state.* access used by that code.
Use flo.callTool(...) when you want another tool’s manifest contract to apply. Use local imports when you just want to share code within one tool’s existing manifest contract.
Good uses:
- reading or writing VFS files through built-in tools
- composing smaller tools into a larger workflow
- calling skill-scoped helper tools through
script_toolswithout polluting the prompt tool list - 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
Wait For User Messages
Use flo.task.waitForUserMessage(...) when a script tool needs to stop and wait for a later user reply before continuing.
This is useful for flows such as:
- asking the user to confirm an external action
- waiting for the user to finish a browser login
- collecting missing information before continuing a long-running task
API Name
The public runtime API is flo.task.waitForUserMessage(...).
Some internal and legacy suspension payloads may still mention wait_for_human or script_wait_for_human, but authored tools should use waitForUserMessage.
Basic Usage
import * as flo from "flo:runtime";
const response = await flo.task.waitForUserMessage({
user_message: "Please finish the login, then reply done.",
});
return {
user_message: response.user_message,
};
When called for the first time, the helper suspends the current task. The task resumes when a later user message is dispatched back to the suspended task.
Resume Behavior
The runtime re-enters the script from its entrypoint after resume. It does not preserve the JavaScript stack.
Use resume_payload to carry a small checkpoint through the suspension:
import * as flo from "flo:runtime";
const response = await flo.task.waitForUserMessage({
user_message: "Approve the pending order, then reply approved.",
resume_payload: {
order_id: input.order_id,
step: "approval",
},
});
return {
approved_by_reply: response.user_message,
checkpoint: response.resume_payload,
};
The returned response.user_message is the message that resumed the task. The returned response.resume_payload is the checkpoint payload that was stored when the task suspended.
For larger or shared checkpoints, persist state before suspending:
- use
flo.task.putToolState(...)for tool-owned checkpoints - use
flo.task.putState(...)when later tools in the same task need the checkpoint
Resume Schema
You can provide resume_schema to validate the checkpoint payload before suspension and again after resume:
const response = await flo.task.waitForUserMessage({
user_message: "Reply with done after the export is ready.",
resume_schema: {
type: "object",
properties: {
export_id: { type: "string" },
},
required: ["export_id"],
},
resume_payload: {
export_id: input.export_id,
},
});
resume_schema validates the checkpoint payload, not the user’s message text.
Browser Handoff
When browser automation reaches a human-required state, call waitForUserMessage(...) after saving any progress you need. The runtime may include browser resume data in the internal suspension payload so the task can continue with restored browser state after the user replies.
Local Testing
The local Node preload shim declares flo.task.waitForUserMessage(...), but it intentionally fails at runtime because user-message suspension requires a real task dispatcher.
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 must declare scope_id. Scripts do not pass scope_id at runtime.
Read, List, Write, Patch, Append, 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,
});
Atomically merge-patch an object value:
const patched = await flo.state.patch({
scope_kind: "session",
key: "counter.session.total",
patch: {
totals: { success: 4 },
stale_field: null,
},
if_revision: write.entry?.revision,
});
patch uses JSON merge-patch semantics:
- nested objects merge recursively
nullremoves a field- the full update commits under one new revision
Atomically append one item to an array value:
const appended = await flo.state.append({
scope_kind: "task",
key: "counter.audit.events",
item: { type: "tick", at: 1700000000 },
if_revision: patched.entry?.revision,
});
If the key is missing, append creates a one-item array. If the current value is not an array, the call fails.
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
The runtime authorizes each call by matching the request scope_kind and key or key prefix against the manifest-declared bindings for the tool.
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 State
Use the flo.task.*State(...) helpers for lightweight state scoped to the current task.
Unlike flo.state, these helpers do not require a manifest state binding.
Shared Task State
Use flo.task.getState(...) and flo.task.putState(...) when every tool in the current task should see the same value.
Read Shared Task State
const checkpoint = await flo.task.getState<{
page?: number;
cursor?: string;
}>({
key: "sync_checkpoint",
});
Write Shared Task State
await flo.task.putState({
key: "sync_checkpoint",
value: {
page: 2,
cursor: "abc123",
},
ttl_seconds: 3600,
});
putState(...) supports:
keyvalue- optional
ttl_seconds - optional
if_revision
It returns the same write-result structure used by flo.state.put(...).
Use shared task state for:
- cross-tool coordination inside one task
- shared progress markers
- durable checkpoints that later tools should reuse
Tool-Partitioned Task State
Use flo.task.getToolState(...) and flo.task.putToolState(...) when the data belongs to one tool implementation.
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,
});
Use tool-partitioned task state for:
- per-tool resume checkpoints
- small per-tool task notes
- retry-safe progress markers owned by one tool
Choosing Between flo.state, Shared Task State, And Tool State
Prefer flo.task.getState(...) / putState(...) when:
- the data is specific to the current task
- multiple tools in the same task should share it
- you do not need a reusable manifest-declared binding
Prefer flo.task.getToolState(...) / putToolState(...) when:
- the data belongs to one tool implementation
- you want task scoping but not cross-tool visibility
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 across runs
This is especially useful before flo.task.waitForBatch(...), because resumed scripts restart from the entrypoint instead of restoring the JavaScript stack.
Next: Browser Automation
Dispatcher
Use flo.dispatcher when one scheduled task needs to coordinate durable work across many stable subjects such as warehouses, tenants, accounts, or pages.
The runtime owns leases, revisions, retries, and subject state persistence. Your script still owns:
- discovering subjects
- choosing stable
subject_keyvalues - running the per-subject business logic
- deciding when to checkpoint
- optionally hinting the next due time on success or failure
When To Use It
Use flo.dispatcher when:
- one task needs to process many independent partitions
- each partition needs its own cursor or progress state
- one subject failing should not block the others
- work may continue across retries or later schedule ticks
Use Task State or State instead when you only need one shared checkpoint for the whole task.
Dispatcher Identity
Dispatcher state is partitioned by:
- the current profile
dispatcher_idsubject_key
Scripts do not pass a task id or lease owner id. The runtime still requires normal execution context and derives lease ownership internally.
Subject State Shape
Claim and mutation calls return server-issued subject state including:
subject_keystatuscursormetanext_due_at- lease fields
- attempt and failure counters
revision
Treat revision as mandatory concurrency state. Every mutating call must send the latest revision returned by the runtime.
Typical Flow
1. Sync discovered subjects
syncSubjects(...) creates missing subjects and refreshes bootstrap cursor, metadata, and due time for already-known non-active subjects.
import * as flo from "flo:runtime";
await flo.dispatcher.syncSubjects({
dispatcher_id: "dispatcher.inventory",
subjects: [
{
subject_key: "warehouse:cn-shenzhen",
cursor: { page: 1 },
meta: { warehouse_id: "cn-shenzhen" },
next_due_at: new Date().toISOString(),
},
],
});
V1 sync is additive only. Leaving a subject out of the payload does not delete it.
If a subject is already leased or running, sync does not overwrite its in-flight cursor or metadata.
2. Claim a bounded batch
const claim = await flo.dispatcher.claimDueSubjects<
{ page?: number; next_cursor?: string },
{ warehouse_id: string }
>({
dispatcher_id: "dispatcher.inventory",
limit: 10,
lease_ms: 5 * 60 * 1000,
});
The runtime only claims:
- due unleased subjects
- subjects whose previous lease has expired
Each returned subject already includes the lease and current revision you need for later calls.
3. Checkpoint progress
Use checkpointSubject(...) for long-running subjects or paginated scans:
const running = await flo.dispatcher.checkpointSubject({
dispatcher_id: claimed.dispatcher_id,
subject_key: claimed.subject_key,
revision: claimed.revision,
cursor: { page: 2, next_cursor: "cursor-2" },
meta: claimed.meta,
lease_ms: 5 * 60 * 1000,
});
This persists progress, marks the subject running, and can extend the lease.
4. Complete, fail, or release
Complete with an explicit next due time:
await flo.dispatcher.completeSubject({
dispatcher_id: running.dispatcher_id,
subject_key: running.subject_key,
revision: running.revision,
cursor: running.cursor,
meta: running.meta,
next_due_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
});
Fail with a runtime retry hint:
await flo.dispatcher.failSubject({
dispatcher_id: running.dispatcher_id,
subject_key: running.subject_key,
revision: running.revision,
error: {
code: "upstream_timeout",
message: "warehouse API timed out",
},
retry_after_ms: 60_000,
});
Release without recording success or failure:
await flo.dispatcher.releaseSubject({
dispatcher_id: running.dispatcher_id,
subject_key: running.subject_key,
revision: running.revision,
});
Retry Behavior
failSubject(...) accepts either:
next_due_atretry_after_ms
Do not send both. When you send neither, the backend applies its default retry schedule.
Choosing Subject Keys
subject_key should be stable, deterministic, and specific to one logical partition:
warehouse:cn-shenzhenaccount:12345store:us-west:42
Avoid keys that depend on timestamps, random ids, or transient pagination positions. Put changing progress into cursor instead.
Practical Pattern
For schedule-driven fan-out work:
- discover all current subjects
syncSubjects(...)claimDueSubjects(...)with a small limit- process each claimed subject independently
- checkpoint long work
- complete or fail each subject individually
That pattern preserves per-subject isolation while keeping claim and retry correctness in the backend.
Subject state persists across later scheduled task runs for the same profile and dispatcher id, so cursors and retry history survive a fresh task UUID without leaking across different profiles.
For exact signatures, refer to flo.d.ts.
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 },
selected_skill_ids: ["skill.ocr", "skill.invoice-parser"],
},
{
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- optional
selected_skill_ids
Omit selected_skill_ids to keep the child task’s selected skill list empty.
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: Suspension Restarts The Script
Important: after suspension, the runtime re-enters the script from the entrypoint. Persist the progress you need before waiting.
What this means in practice:
- local variables do not survive suspension
- the JavaScript call stack is not restored
- code after
waitForBatch(...)only runs when the script reaches that point again on the resumed invocation
Treat waitForBatch(...) like a durable checkpoint boundary:
- write the child batch id before waiting
- write any progress markers you need to decide what to do on resume
- on the next entrypoint run, read that state back and branch accordingly
Typical pattern:
const checkpoint = await flo.task.getToolState<{ batch_id?: string }>({
key: "batch_checkpoint",
});
if (!checkpoint?.batch_id) {
const spawned = await flo.task.spawnChildren({ children });
await flo.task.putToolState({
key: "batch_checkpoint",
value: { batch_id: spawned.batch.batch_id },
});
await flo.task.waitForBatch({
batch_id: spawned.batch.batch_id,
});
}
const resumed = await flo.task.getToolState<{ batch_id: string }>({
key: "batch_checkpoint",
});
const results = await flo.task.getBatchResults({
batch_id: resumed.batch_id,
});
Use Task State for these checkpoints. Prefer tool-partitioned task state by default, and switch to shared task state only when later tools in the same task need the same checkpoint.
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 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.getProfile()
Read the current runtime profile metadata for the tool caller:
const profile = await flo.task.getProfile();
const profileId = profile.profile_id;
const displayName = profile.display_name;
The returned object includes:
profile_idprofile_kindpermissions- optional
display_name
In the local Node preload shim, profile_id defaults to local-node-profile and can be
overridden with FLO_LOCAL_PROFILE_ID. display_name can be set with
FLO_LOCAL_PROFILE_DISPLAY_NAME.
flo.task.waitForUserMessage(...)
Suspend the current task until a later user message resumes it:
const response = await flo.task.waitForUserMessage({
user_message: "Please finish the login, then reply done.",
resume_payload: { step: "login" },
});
The public API name is waitForUserMessage. Legacy internal payloads may mention wait_for_human, but authored tools should use flo.task.waitForUserMessage(...).
See Wait For User Messages for resume behavior and checkpointing details.
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(...).
flo.task.getState(...) And flo.task.putState(...)
Read and write lightweight task-scoped state shared by all tools in the current task:
await flo.task.putState({
key: "checkpoint",
value: { phase: "fetching" },
});
const checkpoint = await flo.task.getState<{ phase?: string }>({
key: "checkpoint",
});
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_attachmentactivate_skillread_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:
activate_skillproactively adds a visible skill to the current task byskill_id- activation is handled by suspending the current execution so
agentdcan restart the task turn with the expanded skill set - if the newly activated skills require labels the current agent does not satisfy,
agentdmay hand the task over before execution continues
- activation is handled by suspending the current execution so
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 shared task state or task tool state for resume checkpoints, depending on whether later tools need the same checkpoint.
- Use the manifest as the contract for what your tool needs.
For exact TypeScript signatures, refer to flo.d.ts.