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
@toola program can reach, so a reviewer, a host andflow checkall 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.
invokemakes that explicit instead of hiding it. - History names declarations. A recorded call names the
@tooldeclaration it went through. With a genericinvoke, that declaration isinvokeitself, 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.decodeturns a malformed object into anErrthe loop can report back to the model. - Treat
invoke's replies as unchecked content. A reply that decodes intojson.Valueproves 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.