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

Effects

This specification owns Flow's one effect, !tool: what it marks, where it is written, where it is inferred, how it passes through functions given as arguments, and why timing is not an effect. It owns the meaning of "pure".

Source spelling belongs to grammar.md. Function types are types.md's, what a Tool call does is tools.md's, and the task operations are concurrency.md's. Static acceptance and its diagnostics belong to checking.md.

Every rule here binds every conforming implementation. Examples and the stated reasons for a refusal are non-normative.

One effect

A function is pure or it has !tool. It has !tool when its body makes a Tool call or calls something that does. A Tool call is a call to a @tool function (tools.md); there is no other way to reach the outside, so there is no other effect.

Pure means "makes no Tool calls". It does not mean the result can't depend on timing (Timing is not an effect).

flow describe(item: Item) -> View = render(item)               // pure, and checked
flow read_batch(refs: List<Text>) -> Batch !tool =
    Batch { views: list.map(refs, flow(r) = catalog.read(r)) }
Open in playground →

No mark means pure, and the compiler checks it. A function written without !tool whose body makes a Tool call, directly or through anything it calls, is an error at the call that does it, naming the function that needs the mark. !tool says a function may make Tool calls; it does not require it to.

A call has !tool when the function called has it, or when a function passed to the call passes its effect through (Effects of functions given as arguments). Ignoring a call's result never makes the call pure.

There is no finer effect. Which Tools a function can reach is something the compiler already knows and tooling shows. Permission, cost, budgets and approval belong to the host around the run, not to an effect.

The effect lives in the checker. It is not part of a value, is not recorded in history, and never reaches the Tool protocol.

Serves foundations: visible outside influence, typed, composable crossings.

What it prevents

  • A Tool call hidden in code that looks harmless. A helper named label can't call a model unless its signature says !tool.
  • Callbacks that multiply Tool calls. A library can require a pure callback, so a sort can't call a model an unknown number of times, in an order the sort algorithm chooses.
  • A dependency quietly starting to make calls. If an imported function becomes !tool, pure code that uses it stops compiling.
  • Outside contact where it must never happen. Equality, ordering, text, schemas (types.md) and every top-level constant are pure.
  • Fakes where none are needed. Pure code is tested without Tool bindings.

What is written and what is inferred

Every top-level flow is written in full: its parameter types, its result type and its effect. Its contract can be read without its body, and a mistake is reported where it is made, not at a distant caller.

pub flow map<A, B>(items: List<A>, operation: (A) -> B) -> List<B> = ...
flow summarize(reply: Result<Document, ToolProblem<ReadError>>) -> Summary = ...
flow read_batch(refs: List<Text>) -> Batch !tool = ...
Open in playground →

A @tool declaration writes no effect. Calling it is a Tool call, and its result type at the call is tools.md's.

Inferred: the types and effects of let bindings, local functions (a flow inside a block) and anonymous functions, and a type parameter's requirements (types.md). Tooling shows what was inferred.

flow review(input: Input) -> Report !tool = {
    flow check(f) = classify(f, input.rules)            // a local function: inferred
    let ask = flow(q) = human.confirm(q)                // anonymous: inferred !tool
    ...
}
Open in playground →

A top-level let constant is pure (semantics.md).

Effects of functions given as arguments

Every function type inside a parameter's type that has no effect written passes its effect through: the parameter itself, a list of functions, a record holding one. The body of the function that declares the parameter may call it without being !tool. The effect is decided at each call instead: the call has !tool when any function its arguments supply in such a position has !tool, and is pure otherwise. Effect variables are never written.

pub flow map<A, B>(items: List<A>, operation: (A) -> B) -> List<B> = ...

list.map(refs, flow(r) = catalog.read(r))       // this call is !tool
list.map(items, flow(i) = render(i))            // this call is pure
Open in playground →

The same holds for functions inside a parameter's type:

pub flow race<T>(work: List<() -> T>) -> T = ...

task.race([flow() = web.search(q), flow() = docs.search(q)])   // this call is !tool
Open in playground →

!pure on a function type inside a parameter's type requires a pure argument there. It is the only place !pure is written:

pub flow sort_by<T, K>(items: List<T>, key: (T) -> K !pure) -> List<T> = ...

list.sort_by(findings, flow(f) = model.rank(f))
// error: sort_by needs a pure key; model.rank is !tool
Open in playground →

A function type anywhere other than inside a parameter's type, such as a record field, a let annotation or a result, is pure when unmarked. Marked !tool, it allows Tool calls, and a pure function fits there too:

type Step { name: Text, run: (Input) -> Output !tool }
Open in playground →

A pure function fits there because a value moves into the place. Inside a declared type's type argument it fits as far as the declaration moves values: where the type parameter appears only where values come out (a field, a payload, a function type's result), a pure function type fits a !tool one; where it appears only in a function type's parameters, the reverse; where it appears in both, the effects must be equal. A type std or a host implements gives out what it holds. Two function types that meet without a value moving into a place, such as one name bound by two alternatives of a pattern, are equal only with the same effect (types.md).

Serves foundations: typed, composable crossings.

Timing is not an effect

task.first, task.race and task.stop answer by timing, and the runtime records each such choice in history (history.md), so replay is exact. They carry no effect, because pure means "makes no Tool calls". Starting work that makes Tool calls is already !tool through the work passed to it, so the only results that can differ between two live runs of pure code are races between purely computed work, and the record fixes those too.

A new effect is added only when a real program needs to see something !tool doesn't show. The effect position is a list from the start, so adding one breaks nothing written before.

Serves foundations: deterministic reconstruction, a general core, bounded in scope.

Considered and set aside

Writing only pub signatures in full. Two styles in one file, and a helper's mistake surfaces at its first caller.

Writing nothing, and inferring everything. A change in a body could silently change a public contract or add an effect.

Writing parameters, inferring result and effect. It hides the one thing a reviewer most needs: whether a function touches the outside.

Written effect variables (map<A, B, e>, List<() -> T !e>). Pass-through covers every program that needs one.

Other names (!io, !observe, !world). Every outside interaction in Flow is a Tool call, so !tool says exactly what it marks.

Finer effects, per Tool or per kind of Tool. The compiler already knows which Tools a function reaches, and what to allow belongs to the host.

A second effect for timing (!race). It would almost always appear beside !tool, and recorded choices already keep replay exact.

Timing operations only inside !tool functions. !tool would then mean something other than a Tool call.