Browse docs

Start here

examplesGetting started with Flowdocumentation

Design

FoundationsLanguage architecturePhilosophy

Language specification

Program checkingConcurrencyDataEffectsResults, Tool problems, and faultsGrammarHistoryModules and importsLanguage specificationEvaluationStandard libraryToolsTypes

Runtime

Runtime architectureThe host boundaryDiagnosticsRunning a programThe history format

Guides

Writing programs that reach checkpointsImplementing Tools with a toolkitLoops that never returnRecursive delegationSharing types between Tool modulesHarnesses over tool registries

Harnesses over tool registries

This guide is non-normative. It shows practice; it creates no semantics and states no guarantee. Tools owns @tool declarations, generic Tools, decoding and json.Value; stdlib owns the json module; the runtime ABI owns schemas and the value encoding.

A harness is a loop that asks a model what to do next, does it, and feeds the result back. What the model may ask for is either a set the program knows when it is written, or a registry the host offers at run time. Flow handles the two cases differently, and a harness often mixes them.

When the set is known: an Action enum

Declare every action the program allows as one enum, and declare the model's turn as a Tool that returns it (tools):

pub type Action {
    Read { path: Text },
    Search { query: Text, limit: Int },
    Edit { path: Text, patch: Text },
    Finish(Text),
}

@tool pub flow next(context: List<Turn>) -> Result<Action, ModelError>
Open in playground →

The implementation shows the model the schema of Action and decodes the answer into it. A malformed answer, or an action outside the enum, comes back as BadReply rather than reaching the program. The loop then matches on the action:

flow step(context: List<Turn>, round: Int) -> Result<Text, Problem> !tool = {
    guard round <= 40 else { return Err(OutOfRounds(context)) }
    let action = planner.next(context) ? Planning
    match action {
        Read { path } => step(context ++ [Observed(show(files.read(path)))], round + 1),
        Search { query, limit } => step(context ++ [Observed(show(web.search(query, limit)))], round + 1),
        Edit { path, patch } => step(context ++ [Observed(show(files.edit(path, patch)))], round + 1),
        Finish(text) => Ok(text),
    }
}
Open in playground →

Everything the model can cause is visible in one declaration, the compiler checks that every action is handled, and each tail call to step can be a checkpoint (checkpoints.md). Prefer this form whenever the set is known.

When the set is not known: one generic call

Some hosts offer tools the program cannot know in advance, such as an MCP server whose tools change, or a plugin registry. The program cannot declare a @tool for each, and it should not pretend to. Instead the host provides an ordinary Tool that takes a name and raw JSON arguments (tools):

// github.com/acme/registry/registry.flow
pub type Entry { pub name: Text, pub description: Text, pub parameters: json.Value }

@tool pub flow entries() -> Result<List<Entry>, RegistryError>
@tool pub flow invoke<T>(name: Text, arguments: json.Value) -> Result<T, InvokeError>
Open in playground →

json.Value describes any JSON value and encodes as that value, so the arguments pass through untouched. The caller chooses T: a known reply type when it has one, or json.Value to keep the reply raw.

The program learns what the registry holds the way it learns anything: through a Tool call. It passes the entries to the model, and the model's choice comes back as data:

pub type Action {
    Read { path: Text },
    External { name: Text, arguments: json.Value },
    Finish(Text),
}

flow act(context: List<Turn>, action: Action, round: Int) -> Result<Text, Problem> !tool =
    match action {
        Read { path } => step(context ++ [Observed(show(files.read(path)))], round + 1),
        External { name, arguments } => {
            let reply: Result<json.Value, ToolProblem<InvokeError>> = registry.invoke(name, arguments)
            step(context ++ [Observed(show_raw(reply))], round + 1)
        },
        Finish(text) => Ok(text),
    }
Open in playground →

Known actions keep their own constructors and types. Only the open part goes through invoke, and it is visible as one constructor.

What the name means is the host's business. Flow does not look at it, check it against entries(), or treat it as naming a declaration. History records the call as a call to registry.invoke, with the name and arguments as ordinary data and the concrete T beside them (history). A host that wants to refuse some names, ask a person first, or limit what an invocation may touch does so inside its implementation of invoke.

Why there is no reflection

A program cannot list the Tools it can reach, look one up by name, or ask which implementation serves it (modules). That is what keeps a few properties true:

  • The outside surface is known before the run. The compiler lists every @tool a program can reach, so a reviewer, a host and flow check all see the same list. A lookup by name would make that list depend on run-time text.
  • Every call is typed. A Tool found by name would have no declared signature, so its arguments could not be checked and its reply could not be decoded into anything but raw JSON. invoke makes that explicit instead of hiding it.
  • History names declarations. A recorded call names the @tool declaration it went through. With a generic invoke, that declaration is invoke itself, and the open part is honest data inside it.

The cost is that an open registry is less typed than an enum. That is the true state of affairs: the program does not know those tools, and its types say so. When a registry entry becomes important enough to depend on, give it its own @tool declaration or its own Action constructor.

Practical notes

  • Validate what you can before calling. Decoding an argument object built by the model into a declared record with json.decode turns a malformed object into an Err the loop can report back to the model.
  • Treat invoke's replies as unchecked content. A reply that decodes into json.Value proves only that it is JSON.
  • Keep large registry listings out of the loop's state. Every argument of a checkpointed round is stored with the checkpoint; calling entries() again when the model needs them keeps the state small.