Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.ts
  • import * 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.yaml and *.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

  1. Write or update a tool manifest such as my_tool.tool.yaml.
  2. Write the script inline with execution.script or in a file with execution.script_file.
  3. If you need bundle-wide execution guidance, add a single *.prompts.yaml file such as runtime.prompts.yaml.
  4. Use flo.d.ts for editor autocomplete and type checking.
  5. 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 support flo.state.* when FLO_MOCKS_FILE includes matching state_bindings.
  • Browser helpers require FLO_LOCAL_BROWSER=1 in 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 authoring
  • flo.sleep(...)
  • flo.getRuntimeConfig(...) with mock data
  • flo.time.formatUnixTimestamp(...)
  • flo.vault.get(...) with mock data
  • flo.state.* with local binding fixtures
  • flo.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

  1. Keep the real runtime entrypoint as export async function run(input).
  2. Add a temporary or test-only __flo_main__() that calls run(...) with local fixture input.
  3. Execute the script with node --import=./flo_hooks.mts ....
  4. 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(...)
  • Use the Node preload shim for fast iteration on parsing, transforms, formatting, control flow, vault mocks, and state flows.
  • Use FLO_TASK_CONTEXT_JSON to exercise resume-aware logic.
  • Use FLO_LOCAL_BROWSER=1 only 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.yaml defines one tool
  • **/*.skill.yaml defines one skill
  • **/*.schedule.yaml defines one bundle-managed schedule
  • **/*.prompts.yaml defines one bundle-managed runtime prompt manifest

SKILL.md discovery is not supported.

Tool Manifest Shape

Every tool manifest must include:

  • name
  • description
  • input_schema
  • execution

Optional fields include:

  • timeout_ms
  • retry_policy
  • vault
  • state
  • requires_permissions
  • direct_call
  • script_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 agentd slash commands are /call and /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
  • 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 /call unless the helper tool also sets direct_call: true
  • requires_permissions

    • defaults to []
    • declares permission ids required before the tool is usable
    • the caller must have all listed permissions

Script Execution

For execution.type = script, define exactly one of:

  • script: inline JavaScript or TypeScript
  • script_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_id
  • name
  • description

Each skill must define exactly one instruction source:

  • instruction
  • instruction_file

Optional fields include:

  • version
  • execution_model_tier
  • tools
  • script_tools
  • tool_definitions
  • requires_skills
  • requires_labels
  • requires_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 tools or script_tools
  • requires_permissions
    • defaults to []
    • declares permission ids required before the skill is visible or selectable
    • the caller must have all listed permissions

Authoring rules:

  • use tools when the model should be able to call the tool directly
  • use script_tools when only your script should call the tool
  • do not repeat an inline tool from tool_definitions in either tools or script_tools
  • script_tools only 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’ tools and script_tools, and any tool whose manifest sets direct_call: true
  • inline tools from tool_definitions are available to the owning skill without being repeated elsewhere

During /call execution:

  • /call lists tools whose manifests set direct_call: true
  • the external-app direct-call API uses the same direct_call: true gate
  • 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
  • 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, or requires_permissions entries are rejected
  • if a bundle includes more than one *.permissions.yaml file, loading fails

Runtime behavior:

  • a skill with requires_permissions is hidden unless the caller has all listed permissions
  • a tool with requires_permissions is 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.preamble is supported

Schedule Manifest Shape

Schedule manifests are bundle-scoped resources. They are not owned by a skill.

Required fields:

  • schedule_id
  • profile_channel
  • profile_external_user_id
  • user_message
  • exactly one of trigger or cron_expression
  • timezone

Optional fields:

  • context
  • target_agent_override
  • required_labels
  • selected_skill_ids
  • skip_skill_selection
  • enabled
  • cron_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-service starts with persisted bundle slots.
  • Flo resolves profile_channel plus profile_external_user_id through 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
  • hours_interval
    • optional integer in 1..=23
    • runs every N hours
  • times_of_day
    • optional list of wall-clock times
    • each item has hour in 0..=23 and minute in 0..=59
  • days_of_week
    • optional list of weekdays using 0..=6
    • 0 is Sunday, 6 is Saturday

Validation rules:

  • define exactly one of minutes_interval, hours_interval, or times_of_day
  • days_of_week is a filter and may be combined with any one of the cadence options above
  • times_of_day entries must not contain duplicates
  • in v1, all times_of_day entries must share the same minute
  • days_of_week must 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:

  • sleep
  • getRuntimeConfig
  • time
  • vault
  • state
  • task
  • dispatcher
  • callTool
  • browser

JSON-Oriented API Design

Runtime values are JSON-shaped. FloJsonValue includes:

  • null
  • boolean
  • number
  • string
  • arrays of JSON values
  • objects whose values are JSON values

This affects:

  • tool inputs and outputs
  • flo.state
  • child-task input and output
  • browser evaluate args and values

Global Helpers

The runtime provides:

  • fetch(...)
  • Blob
  • File
  • FormData
  • URL
  • URLSearchParams

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(...) with FLO_MOCKS_FILE
  • flo.time.formatUnixTimestamp(...)
  • flo.vault.get(...) with FLO_MOCKS_FILE
  • flo.state.* with local state binding fixtures
  • flo.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:

  • status
  • output
  • error

Possible statuses are:

  • success
  • failed
  • timeout
  • validation_error
  • suspended

When error is present, it includes:

  • retryable: whether the failed task/run is eligible for retry
  • continuable: 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:

  • tools controls what the execution-stage LLM can call for a selected skill
  • script_tools controls helper access from flo.callTool(...) without exposing those helpers to the LLM
  • direct_call: true controls whether /call <tool_id> is allowed and also makes that tool reachable from flo.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 marked direct_call: true
  • profile permission checks still apply to nested calls into direct_call: true tools

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_tools without 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,
  },
});
  • event_type: stable machine-readable identifier
  • title: short human-readable label
  • message: one-line progress update
  • level: info, warning, or error
  • payload: 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:

  • profile
  • shared

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:

  • profile
  • session
  • task
  • shared

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
  • null removes 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:

  • key
  • value
  • revision
  • 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:

  • key
  • value
  • 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_key values
  • 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_id
  • subject_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_key
  • status
  • cursor
  • meta
  • next_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_at
  • retry_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-shenzhen
  • account:12345
  • store: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:

  1. discover all current subjects
  2. syncSubjects(...)
  3. claimDueSubjects(...) with a small limit
  4. process each claimed subject independently
  5. checkpoint long work
  6. 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:

  • goto
  • reload
  • fill
  • click
  • press
  • select
  • wait_for
  • extract
  • evaluate
  • screenshot

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_matches
  • selector_present
  • selector_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 storage
  • importState(...) 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_kind
  • title
  • objective
  • input
  • 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_id
  • worker_kind
  • status
  • output
  • 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_id
  • profile_kind
  • permissions
  • 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_file
  • write_text_file
  • read_dir
  • zip
  • unzip
  • csv_*
  • excel_*
  • media_fetch
  • media_push_vfs
  • media_push_base64
  • send_notification
  • send_media_attachment
  • activate_skill
  • read_skill_resource
  • import_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_skill proactively adds a visible skill to the current task by skill_id
    • activation is handled by suspending the current execution so agentd can restart the task turn with the expanded skill set
    • if the newly activated skills require labels the current agent does not satisfy, agentd may hand the task over before execution continues
  • read_skill_resource reads a selected skill resource or imports it into VFS
  • import_skill_asset copies 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.