Flow builder (full API)¶
Flow is the ergonomic, chainable way to assemble a Graph in Python. Every
method returns self, so a whole workflow compiles in one expression. The
result of flow.compile() is a Graph you can run or stream, and
flow.to_yaml() exports it as a deployable workflow.
from teff.flow import Flow, Case
from teff.node import LLM, Transform
from teff.provider import ProviderRegistry
flow = Flow(
"my-flow", # optional name
providers=ProviderRegistry.from_presets("ollama"),
default_provider="ollama",
default_model="llama3.1:8b",
)
flow.step(LLM(output_key="answer"))
result = await flow.compile().run(state={"..."})
LLM nodes fall back to the graph-level default_provider= / default_model=
when they don't set one — every provider a graph uses must be declared in
providers= (see Providers).
Linear chain¶
step(node)¶
Append a node to the linear chain. Accepts any Node — built-in or custom:
flow.step(Transform(action="uppercase"))
flow.step(LLM(model="gpt-4"))
flow.step(custom_node)
Conditional steps¶
step(..., when=...) guards a node behind a predicate on the state: it only runs
when the callable (or string condition) matches. Follow it with default(node):
flow.step(decider)
flow.step(handle_yes, when=lambda s: s["ok"])
flow.default(handle_no) # runs when the guard fails
The guarded step's edge wins on a match, so default() fires only when the
guard misses. default() requires a preceding guarded step to be useful;
for branch fallbacks use branch(..., default=node) instead.
llm(...) and transform(...)¶
Shorthands for the two most common node classes. Accept either a pre-built instance (to reuse a shared node) or keyword config:
flow.llm(model="gpt-4", system="You are helpful", output_key="answer")
flow.llm(LLM(model="gpt-4", parse=True, output_key="data"))
flow.transform(action="uppercase", input_key="text", output_key="shout")
flow.transform(Transform(action="value", value="done", output_key="status"))
Passing both an instance and kwargs raises TypeError.
context_builder(...) and append_assistant(...)¶
Shorthands for the turn-routing pair used by agent_step: compose a
plain-text input from state (each section as <label>:\n<value> plus the
latest user message), then append the agent's reply back to the shared
conversation:
flow.context_builder(
sections={"plan": "Plan", "summary": "Summary"},
messages_key="messages",
output_key="input",
)
flow.append_assistant(output_key="draft", messages_key="messages")
context_builder accepts sections, messages_key, output_key and
reset_keys (scratch state cleared before the agent runs); both also accept
a pre-built instance instead of kwargs.
add_flow(flow, ...) / SubFlow¶
Embed a whole sub-flow as a single node. The inner Flow is compiled and
wrapped in a SubFlow:
inner = Flow("inner")
inner.step(LLM(model="gpt-4", output_key="tmp"))
outer = Flow("outer")
outer.step(prepare_node)
outer.add_flow(inner, input_map={"user": "question"}, output_map={"tmp": "result"})
outer.step(use_result_node)
SubFlow keys:
| Key | Description |
|---|---|
input_map |
Parent state key → sub-graph key (default: full passthrough). |
output_map |
Sub-graph key → parent state key (default: full passthrough). |
max_iterations |
Max node executions inside the sub-graph (None = unlimited). |
The sub-graph runs on isolated copies and streams its node/token/llm events
through (its own run_start/run_end are stripped). Tools from the outer
context are forwarded to the inner run.
Branching¶
branch(key, *cases, default=...) + Case¶
Conditional routing from the last added node, based on a state key:
flow.branch(
"sentiment", # state key to inspect
Case("positive").add(on_pos_llm), # when sentiment == "positive"
Case("negative").add(on_neg_llm), # when sentiment == "negative"
default=fallback_llm, # anything else
).converge(Transform(action="uppercase", input_key="reply", output_key="result"))
- Each
Case(value)produces an edgekey=value; aCasecan hold several chained nodes via repeated.add(node). default=adds an edgekey!=<all case values>; it is the only way to add a branch fallback. The separatedefault(node)method is reserved for guarded steps (seestep(..., when=...)below).
converge(node)¶
Merge every branch end into a single node. Call it after branch() or
parallel() to rejoin paths before continuing the chain.
loop(key, until, done=..., body=...)¶
Repeat a body chain until state[key] == until, then run done and
continue after the loop:
flow.step(draft_llm)
flow.interrupt("approved", "Одобрить? (да / правки)") # decider
flow.loop(
key="approved",
until="да",
done=final_llm,
body=edit_llm,
)
Wires decider --key=until--> done (stop) and
decider --key!=until--> body -> ... -> decider (repeat). The decider is any
node that writes key — an Interrupt, an LLM, a Transform, etc.
route(key, *, finish=..., **agents)¶
The supervisor loop — route between agent chains, looping back to a decider
until it says finish. See Multi-agent supervisors.
Dynamic routing with Command¶
Edges are static — but any node can pick its own successor at runtime by
returning a Command with an explicit goto:
from teff.node import Command
async def classify(ctx, state):
if "bad" in state["text"]:
return Command(update={"blocked": True}, goto=Command.STOP)
if "trusted" in state["text"]:
return Command(update={"cleared": True}, goto="deliver")
return {"cleared": True} # fall through to normal edges
goto may target any node id — it does not need an edge — and
Command.STOP ends the run. Command(update=...) without goto keeps the
normal edge routing. See Command routing and the
command_routing example.
Concurrency¶
parallel(*branches)¶
Run branch chains concurrently from the last node via asyncio.gather, on
isolated copies of state. Per-key reducers merge their updates back:
flow.parallel(
[Transform(action="uppercase", input_key="a", output_key="a")],
[Transform(action="uppercase", input_key="b", output_key="b")],
).converge(Transform(action="value", value="done", output_key="status"))
A branch is a single Node, a list of nodes (sequential inside the branch),
or a Flow (embedded as SubFlow). The underlying node works directly too:
Parallel([[node1], [node2]]).
map(processor, *, input_keys, output_key, chunk_size=..., max_concurrency=...)¶
Dynamically fan a state list out across parallel branches — branch count comes from the data at runtime:
flow.map(
LLM(model="llama3.1:8b", input_key="chunk", output_key="summary"),
input_keys=["chunks"], # list key(s); multiple are zipped per index
output_key="summaries", # list of per-item results
max_concurrency=2,
)
chunk_size batches items per branch, result_key overrides which per-item
key is collected.
Human input¶
interrupt(key, prompt="", accept=None)¶
Pause for a human. graph.run() raises GraphInterrupt; resume with the same
checkpoint_id and resume={key: answer}. Requires a checkpointer. See
Durable execution.
With accept (an Ask strategy) the raw answer
is validated instead of compared verbatim — and an arbitrary value can be
captured. flow.interrupt(key, prompt, accept=Ask.regex(...)) validates a
single answer; flow.interrupt_loop(key, accept=Ask(...), body=..., done=...)
re-asks until it passes:
from teff.node import Ask
flow.interrupt_loop(
key="code",
prompt="Введите промокод (формат XX-1234):",
accept=Ask.regex(
r"^[A-Z]{2}-[0-9]{4}$", decision_key="code_ok", value_key="discount_code"
),
body=Transform(action="value", value="неверный код", output_key="total"),
done=Transform(action="value", value="скидка применена", output_key="total"),
)
Ask.llm(...) inserts an LLM classifier so free-form answers ("конечно",
"ок", "хорошо") all count as approval. See the
ask_strategies example and the
validate reference.
Agents¶
harness(...) / react(...)¶
Build the ReAct loop (agent node ↔ tool executor) inside the flow. See Agents and Supervisors.
Custom agent node¶
Pass a pre-built ReActAgent instance or subclass to override behaviour:
flow.react(agent=MyAgent(model="gpt-4", system="..."))
flow.react(agent=MyAgentClass, model="gpt-4", system="...")
Export & run¶
compile()¶
Compile into a Graph ready for run()/stream(). Raises ValueError if
no nodes were added.
to_yaml(tools=..., initial=..., reducers=...)¶
Export the compiled flow as a workflow.yaml document — including the ReAct
loop wiring. Flow does not track tools/state, so pass them explicitly:
from teff.flow import Flow
from teff.provider import ProviderRegistry
from teff.tool.builtin.git import GitTool
yaml_text = (
Flow(
"repo",
providers=ProviderRegistry.from_presets("ollama"),
default_provider="ollama",
default_model="llama3.1:8b",
)
.react(use_tools="all")
.to_yaml(tools=[GitTool()])
)
The result validates with teff validate and round-trips through
teff.yaml.load_workflow.
Quick reference¶
| Method | Builds | See |
|---|---|---|
step(node) |
one node in the chain | Nodes |
llm(...) / transform(...) |
shorthand for LLM/Transform |
Nodes |
context_builder(...) / append_assistant(...) |
turn routing pair | Nodes |
supervisor(...) |
supervisor decider | Supervisors |
add_flow(flow) |
nested SubFlow node |
— |
branch(key, *cases, default=...) |
conditional edges | State |
default(node) / converge(node) |
guard fallback / rejoin | — |
loop(key, until, ...) |
repeat-until cycle | Durable |
route(key, **agents) |
supervisor loop | Supervisors |
return Command(goto=...) |
dynamic per-node routing | Command routing |
parallel(*branches) |
concurrent branches | State |
map(processor, ...) |
dynamic fan-out | State |
interrupt(key, prompt, accept=...) |
human-in-the-loop | Durable |
interrupt_loop(key, accept, body, done) |
re-ask until the answer passes | Durable |
react(...) / harness(...) |
ReAct agent loop | Agents |
compile() |
runnable Graph |
— |
to_yaml(...) |
deployable workflow | YAML workflows |