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
  • 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 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. Use flo.d.ts for editor autocomplete and type checking.
  4. Use the repo-root preload shim for local Node-based smoke tests:
node --import=./flo_hooks.mts path/to/script.mts

The preload shim can invoke an exported __flo_main__() for local testing. It is a development aid, not a full runtime replica.

For a full local testing workflow, see Testing With Node.js.

Runtime Constraints You Should Know Early

  • Runtime script source can be JavaScript or TypeScript.
  • TypeScript is transpiled at runtime, but it is not type checked there.
  • Static relative imports are supported.
  • Bare imports, package-style imports, and dynamic import() are rejected.
  • Some APIs are runtime-bound and intentionally fail in the local shim, including flo.callTool(...), flo.state.*, and child-task orchestration.
  • Browser helpers require FLO_LOCAL_BROWSER=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.time.formatUnixTimestamp(...)
  • flo.vault.get(...) with mock data
  • flo.state.* with local binding fixtures
  • 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"
      }
    }
  }
}

Run with:

FLO_MOCKS_FILE=./vault_mocks.json \
node --import=./flo_hooks.mts ./script.mts

Then fetch values normally:

const profileSecret = await flo.vault.get({
  scope: "profile",
  key: "demo-token",
});

const sharedSecret = await flo.vault.get({
  scope: "shared",
  scope_id: "shared-scope",
  key: "api-token",
});

Browser Testing

Browser helpers stay disabled unless FLO_LOCAL_BROWSER=1 is set.

Basic browser-mode invocation:

FLO_LOCAL_BROWSER=1 \
node --import=./flo_hooks.mts ./script.mts

The shim can also point at a custom local worker module:

FLO_LOCAL_BROWSER=1 \
FLO_LOCAL_BROWSER_WORKER_MODULE=./tests/fixtures/flo-init/fake_playwright_worker.mjs \
node --import=./flo_hooks.mts ./script.mts

Use browser mode for smoke tests around:

  • navigation
  • screenshots
  • request capture
  • storage state import and export

Mocking State Bindings

Use FLO_MOCKS_FILE to provide local state binding metadata and stored values:

{
  "state_bindings": [
    {
      "name": "profile_cache",
      "key_prefix": "cache.",
      "scope_kind": "profile"
    },
    {
      "name": "shared_cache",
      "key_prefix": "cache.shared.",
      "scope_kind": "shared",
      "scope_id": "service"
    }
  ],
  "state": {
    "profile": {},
    "session": {},
    "task": {},
    "shared": {}
  }
}

Then call the runtime with scope_kind:

await flo.state.put({
  scope_kind: "profile",
  key: "cache.answer",
  value: { answer: 42 },
});

await flo.state.get({
  scope_kind: "shared",
  key: "cache.shared.answer",
});

If a shared local binding omits scope_id, provide it in the request.

What Fails Fast In The Local Shim

Some runtime-bound APIs intentionally throw instead of pretending to work:

  • flo.callTool(...)
  • flo.task.spawnChildren(...)
  • flo.task.waitForBatch(...)
  • flo.task.getBatchResults(...)
  • 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

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

Example:

name: capture_example
description: Open a page and return the current URL.
input_schema:
  type: object
  properties:
    url:
      type: string
  required: [url]
  additionalProperties: false
execution:
  type: script
  script_file: scripts/capture_example.mts
  entrypoint: run
timeout_ms: 30000

Script Execution

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

  • script: inline JavaScript or 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
  • tools
  • tool_definitions
  • requires_skills

Example:

skill_id: browser_examples
name: Browser Examples
description: Browser-based tools for authenticated workflows.
tools:
  - read_text_file
tool_definitions:
  - name: capture_example
    description: Open a page and return the current URL.
    input_schema:
      type: object
      properties:
        url:
          type: string
      required: [url]
    execution:
      type: script
      script_file: scripts/capture_example.mts
      entrypoint: run
instruction_file: instructions.md

State and Vault Declarations

Use state when your script needs durable non-secret data:

state:
  - name: session_counter
    key_prefix: counter.session.
    scope_kind: session
  - name: shared_counter
    key_prefix: counter.shared.
    scope_kind: shared
    scope_id: service

Use vault when your script needs secrets:

vault:
  - key: api_token
    scope_kinds: [profile, shared]

The runtime still requires the script to fetch secrets explicitly through flo.vault.get(...).

Import Rules

The script runtime supports:

  • local static ESM imports
  • relative .mjs, .mts, and related local module paths

The runtime rejects:

  • bare specifiers
  • package-style imports
  • dynamic import()
  • .. traversal for author-facing asset imports

Next: TypeScript Runtime

TypeScript Runtime

The public runtime surface comes from:

import * as flo from "flo:runtime";

The module exports these top-level helpers:

  • sleep
  • time
  • vault
  • state
  • task
  • 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(...)
  • URL
  • URLSearchParams

That means many integration-oriented tools can work without additional libraries.

Task Context

Use flo.task.getContext<T>() to read durable task context:

type Context = {
  resume_payload?: {
    batch_id?: string;
  };
  custom?: { value: number };
};

const context = await flo.task.getContext<Context>();

The context may include resume data after a suspension flow. When a task resumes, the runtime re-enters the script from its entrypoint instead of restoring the JavaScript stack.

Time and Sleep

Use flo.sleep(ms) to pause a script without blocking the runtime thread.

Use flo.time.formatUnixTimestamp(...) when you need stable date formatting:

const formatted = flo.time.formatUnixTimestamp(1_700_000_000, "YYYY-MM-DD HH:mm:ss", "UTC");

Local Shim Notes

The Node preload shim supports:

  • flo.sleep(...)
  • flo.time.formatUnixTimestamp(...)
  • flo.vault.get(...) with FLO_MOCKS_FILE
  • flo.task.getContext(...)
  • flo.task.emitEvent(...)
  • browser helpers when FLO_LOCAL_BROWSER=1

Other runtime-bound APIs intentionally fail in the local shim so tests do not accidentally depend on unsupported local behavior.

Next: Nested Tool Calls

Nested Tool Calls

Use flo.callTool(...) to call another runtime tool from your script.

Generic Form

const result = await flo.callTool({
  tool_id: "some_tool",
  input: { value: 1 },
});

The returned shape is:

  • status
  • output
  • error

Possible statuses are:

  • success
  • failed
  • timeout
  • validation_error
  • suspended

Typed Built-In Calls

For built-in tools declared in flo.d.ts, TypeScript can infer the input and output shape:

const file = await flo.callTool({
  tool_id: "read_text_file",
  input: {
    path: "task://notes/summary.txt",
    max_bytes: 4096,
  },
});

Error Handling Pattern

Always branch on status instead of assuming output exists:

const result = await flo.callTool({
  tool_id: "read_text_file",
  input: { path: "task://notes/summary.txt" },
});

if (result.status !== "success") {
  return {
    ok: false,
    status: result.status,
    error: result.error,
  };
}

return {
  ok: true,
  content: result.output?.content,
};

Execution Context

Nested tool calls run in the same runtime tool execution context as the calling script. In practice, this means task- and session-scoped helpers continue to operate on the current task and current virtual workspace.

Good uses:

  • reading or writing VFS files through built-in tools
  • composing smaller tools into a larger workflow
  • delegating format-specific work to a built-in tool

Avoid using nested calls as a substitute for simple local code when a direct script implementation is clearer.

Next: Debug Events

Debug Events

Use flo.task.emitEvent(...) to append structured events to the current task timeline.

Example:

await flo.task.emitEvent({
  event_type: "orders.sync.started",
  title: "Sync started",
  message: "Fetching latest order batch",
  level: "info",
  payload: {
    shop_id: "acme",
    page: 1,
  },
});
  • 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 may declare a fixed scope_id. When they do, scripts do not pass scope_id at runtime. If a shared binding omits scope_id, runtime calls must provide it.

Read, List, Write, Delete

Read one key:

const entry = await flo.state.get<{ total: number }>({
  scope_kind: "session",
  key: "counter.session.total",
});

List a prefix:

const page = await flo.state.list({
  scope_kind: "task",
  key_prefix: "counter.audit.events.",
  limit: 100,
});

Write with optional TTL and optimistic concurrency:

const write = await flo.state.put({
  scope_kind: "session",
  key: "counter.session.total",
  value: { total: 4 },
  ttl_seconds: 3600,
  if_revision: entry?.revision ?? null,
});

Delete:

const deleted = await flo.state.delete({
  scope_kind: "session",
  key: "counter.session.total",
  if_revision: entry?.revision ?? null,
});

Result Shapes

State entries include:

  • 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

Use Task Tool State for lightweight task-scoped convenience state that does not need a manifest binding.

Shared Scope Pattern

Prefer multiple explicit shared bindings over a single dynamic shared namespace:

state:
  - name: service_cache
    key_prefix: cache.service.
    scope_kind: shared
    scope_id: service
  - name: billing_cache
    key_prefix: cache.billing.
    scope_kind: shared
    scope_id: billing

Then scripts stay explicit about the backing storage kind:

await flo.state.get({
  scope_kind: "shared",
  key: "cache.service.answer",
});

Next: Task Tool State

Task Tool State

Use flo.task.getToolState(...) and flo.task.putToolState(...) for lightweight, tool-partitioned state scoped to the current task.

Unlike flo.state, these helpers do not require a manifest state binding.

Read Current Tool State

const checkpoint = await flo.task.getToolState<{
  page?: number;
  cursor?: string;
}>({
  key: "sync_checkpoint",
});

You can also read another tool’s convenience state in the same task by passing tool_id.

Write Current Tool State

await flo.task.putToolState({
  key: "sync_checkpoint",
  value: {
    page: 2,
    cursor: "abc123",
  },
  ttl_seconds: 3600,
});

putToolState(...) supports:

  • key
  • value
  • optional ttl_seconds
  • optional if_revision

It returns the same write-result structure used by flo.state.put(...).

Best Use Cases

Use tool state for:

  • resume checkpoints
  • small per-tool task notes
  • retry-safe progress markers
  • child-batch bookkeeping

This is especially useful before flo.task.waitForBatch(...), because resumed scripts restart from the entrypoint instead of restoring the JavaScript stack.

Choosing Between flo.state And Tool State

Prefer flo.task.getToolState(...) / putToolState(...) when:

  • the data is specific to the current task
  • the data belongs to one tool implementation
  • you do not need a reusable manifest-declared binding

Prefer flo.state when:

  • data should outlive the task
  • data needs explicit profile, session, task, or shared binding semantics
  • multiple scripts should share a named binding contract

Next: Browser Automation

Browser Automation

Use flo.browser to drive a host-managed browser session from a script tool.

Available Helpers

  • flo.browser.run(...)
  • flo.browser.startRequestCapture(...)
  • flo.browser.collectCapturedRequests(...)
  • flo.browser.stopRequestCapture(...)
  • flo.browser.exportState(...)
  • flo.browser.importState(...)

Supported Commands

flo.browser.run(...) accepts these command types:

  • 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 },
    },
    {
      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

Wait For Completion

Use flo.task.waitForBatch(...) when the parent should suspend until all child tasks are terminal:

await flo.task.putToolState({
  key: "batch_checkpoint",
  value: { batch_id: spawned.batch.batch_id },
});

const results = await flo.task.waitForBatch({
  batch_id: spawned.batch.batch_id,
});

Important: after suspension, the runtime re-enters the script from the entrypoint. Persist the progress you need before waiting.

Read Results Without Suspending

Use flo.task.getBatchResults(...) only when the batch is already terminal:

const results = await flo.task.getBatchResults({
  batch_id: batchId,
});

If the batch is still pending, this call fails non-retryably.

Result Shape

Each child result includes:

  • child_task_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 Tool State before waiting.
  • Keep child inputs compact and JSON-serializable.
  • Handle partial failures explicitly; one child can fail while others succeed.

Next: Other APIs

Other APIs

This chapter collects the rest of the public author-facing runtime helpers.

flo.sleep(ms)

Pause the script asynchronously:

await flo.sleep(500);

Use it sparingly for pacing, polling, or short waits between retries.

flo.time.formatUnixTimestamp(...)

Format a Unix timestamp using the runtime helper:

const text = flo.time.formatUnixTimestamp(
  1_700_000_000,
  "YYYY-MM-DD HH:mm:ss",
  "UTC",
);

flo.task.getContext<T>()

Read durable task context, including resume payloads:

const context = await flo.task.getContext<{
  resume_payload?: { batch_id?: string };
  custom?: { value: number };
}>();

flo.task.limits

Inspect runtime task-orchestration limits:

const maxChildren = flo.task.limits.maxSpawnChildren;

Use this value to chunk child-task work before calling spawnChildren(...).

Built-In Tools Through flo.callTool(...)

flo.d.ts includes typed support for built-ins such as:

  • read_text_file
  • write_text_file
  • read_dir
  • zip
  • unzip
  • csv_*
  • excel_*
  • media_fetch
  • media_push_vfs
  • media_push_base64
  • send_notification
  • send_media_attachment
  • 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:

  • 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 task tool state for resume checkpoints.
  • Use the manifest as the contract for what your tool needs.

For exact TypeScript signatures, refer to flo.d.ts.