Reference: graph.yaml¶
This page is the complete field reference for the low-level graph.yaml
document — every node and every arrow explicit, with each key, its type, and
its default. It is the compiled form of everything the
flow.yaml authoring layer (and the Flow builder) produces.
Workflows are loaded with load_workflow, validated with teff validate, and
serialized back out with workflow_to_yaml / Flow.to_yaml().
Two formats, auto-detected. A
flow.yamluses the idiom surface (llm:,team:,map:); agraph.yamlspells outid/type/configandedges:. The CLI and loaders detect the format withlooks_like_flow, soteff run/teff validate/teff graphwork on either without a flag. Preferflow.yamlwhen writing by hand; treat thisgraph.yamlreference as the canonical vocabulary both formats compile to.
Top-level keys¶
| Key | Type | Default | Purpose |
|---|---|---|---|
name |
string | "" |
Optional label. |
description |
string | — | Optional free-text description. |
include |
string/list | — | Merge steps/edges/tools/state from other files (path or {path, prefix}), recursively. |
steps |
list | — | Required. Nodes, each {id, type, config?, retry?} (see steps). |
edges |
list | [] |
Routing, each {from, to, condition?} (see edges). |
tools |
list | [] |
Tool instances made available to agents (see tools). |
state |
object | {} |
{schema, initial} — seed values and per-key reducers (see state). |
plugins |
string/list | — | Extra node/tool modules to import (paths). |
plugins_folder |
string | "plugins" |
Auto-loaded plugin folder. |
checkpoint |
object | — | Durable-runs block (see checkpoint). |
hooks |
object | — | Named hook callbacks (see hooks). |
observability |
object | — | Full-run tracing block (see tracing). |
providers |
list | [] |
Declared provider endpoints (see providers). |
default_provider |
string | — | Provider for every step that doesn't name one. |
default_model |
string | — | Model for every step that doesn't name one. |
Every value is interpolated against the process environment — ${ENV_VAR}
references are replaced; a variable that is not set stays as a literal
placeholder (see interpolation).
Structure¶
name: my-workflow # optional label
description: ... # optional
state:
initial: {title: hello} # seed values
plugins: [nodes] # optional: extra node/tool modules to import
plugins_folder: plugins # optional auto-loaded folder (default: plugins)
tools: # tool instances made available to agents
- type: web_search
steps: # nodes
- id: search
type: web_search
config: {query_key: q, output_key: results}
- id: answer
type: llm_chat
config: {model: llama3.1:8b, input_key: results, output_key: reply}
edges: # routing
- from: search
to: answer
- from: answer
condition: results=="" # conditional: route on a state expression
to: fallback
steps: — nodes¶
Every step is {id, type, config, retry}. id and type are required;
config is node-specific (see the node reference for
every key); retry wraps the node (see retrying).
Built-in type values:
type |
Purpose | Reference |
|---|---|---|
transform |
String/data transforms | transform |
llm_chat |
One model call | llm_chat |
react_agent |
Tool-calling agent loop | react_agent |
tool_exec |
Execute tool calls signalled by an agent | tool_exec |
tool_call |
Invoke a registered tool with fixed args | tool_call |
context_builder |
Compose a scratch prompt from state + conversation | context_builder |
append_assistant |
Append the result as an assistant message | append_assistant |
interrupt |
Pause for human input; resume via checkpoint | interrupt |
supervisor |
Ask a model "which agent next" + guards | supervisor |
gate |
Verdict → loop decider + retry budget | gate |
validate |
Decode an interrupt answer into a loop decider | validate |
command |
Declarative goto/STOP routing from state |
command |
loop |
Repeat a body chain until a state condition holds | loop |
parallel |
Concurrent branches, merged by reducers | parallel |
map |
Dynamic fan-out of a state list | map |
subflow |
Embed a complete inner graph as one node | subflows |
fallback |
Fill a field a model left empty | fallback |
Plugin nodes registered via plugins:/plugins_folder: are valid type
values too. An unknown type fails validation.
edges: — routing¶
Each edge is {from, to, condition}. from/to name node ids (required);
condition routes on the state:
condition |
Routes when |
|---|---|
| (omitted) | always (the edge fires unconditionally) |
"key=value" |
state["key"] == value |
"key!=value" |
state["key"] != value |
"key=a,b" |
state["key"] is a or b (comma = OR) |
"key>=N" / "key<=N" / "key>N" / "key<N" |
numeric comparison |
"__error__" |
the source node raised an exception |
Edges reference existing step ids (validation error otherwise). The entry point is the first step; an edge to a step already targeted is fine — first matching edge wins during execution.
Custom providers¶
Declare every provider exactly as it is configured in a top-level
providers: list. The block is the single source of truth — a provider
used by any step's provider:, by default_provider:, or by default_model:
must be declared here, and there is no implicit built-in fallback. Each entry
is a {name, ...} mapping that spells out the endpoint:
providers:
- name: vllm
base_url: http://vllm:8000/v1 # type defaults to openai_compatible
- name: claude-proxy
type: anthropic_compatible # Anthropic wire protocol
base_url: http://proxy
chat_path: /v1/messages
api_key_env: CLAUDE_PROXY_KEY
- name: ollama
type: ollama
base_url: http://localhost:11434
chat_path: /api/chat
steps:
- id: answer
type: llm_chat
config:
model: meta-llama/Llama-3.3-70B-Instruct
provider: vllm # must be declared in providers:
Bare preset-name strings are rejected — every provider is spelled out, so the
file says exactly what is configured. Each name must be unique, and only the
recognised provider fields may appear (a stray key is an error). Referencing a
provider name that is not declared here raises ConfigError.
The block round-trips through workflow_to_yaml / Flow.to_yaml(), and the
graph exposes it as graph.providers (a {name: Provider} map). In code you
can pass the same map straight to graph.run(state, providers=...), which
overrides graph.providers for that run.
Default provider for the whole workflow¶
A top-level default_provider: picks the default for every llm_chat /
react_agent step that doesn't name one — the YAML equivalent of
Flow("...", default_provider=...):
default_provider: ollama
default_model: llama3.1:8b
providers:
- name: ollama
type: ollama
base_url: http://localhost:11434
chat_path: /api/chat
name: chat
steps:
- id: answer
type: llm_chat
config: {}
default_model: supplies the model for steps that omit their own model:
(LLM(model=...) still wins). Neither default_provider nor model /
default_model resolved? The step raises ConfigError. Steps may still
override the default with their own provider: / model:.
Provider keys¶
| Key | Type | Default | Notes |
|---|---|---|---|
name |
string | — | Unique provider key referenced by provider:/default_provider:. |
type |
string | openai_compatible |
openai_compatible, anthropic_compatible, or ollama. |
base_url |
string | — | Endpoint base URL. |
chat_path |
string | — | Chat endpoint path override. |
api_key_env |
string | — | Env var holding the API key. |
auth_header / auth_prefix |
string | — | Custom auth header / prefix. |
timeout |
number | — | Request timeout. |
state: — initial values and reducers¶
The state: block seeds the graph state and declares how concurrent or
accumulating writers merge:
state:
initial: {topic: "…", messages: []}
schema:
messages:
reducer: append
type: list
status:
reducer: keep
| Key | Type | Default | Notes |
|---|---|---|---|
initial |
mapping | {} |
Seed values. Validated against schema (ConfigError on mismatch). |
schema |
mapping | {} |
Per-key reducer declarations. |
Reducers¶
Each schema.<key> is a mapping with a reducer: field (a bare string is
accepted too). Keys without a reducer default to override.
reducer |
Semantics |
|---|---|
override |
(default) The node's returned value replaces the key. |
append |
The node's returned value is appended into the existing list. |
keep |
The first write wins; later writes are ignored. |
Use append for shared lists (e.g. messages) so parallel branches and
supervisor turns accumulate instead of clobbering one another.
tools: — tool registry¶
Tool instances made available to agents (and tool_call steps):
tools:
- type: web_search
- type: python_eval
- type: rag
config: {store: sqlite, collection: docs}
- type: mcp # all tools of an MCP server
config:
id: drive
command: [uvx, mcp-server-google-drive]
Each entry is {type, config}; config is tool-specific. See the
tools reference for every built-in tool and its
config keys. RAG tools resolve relative store paths against the workflow file.
An unknown tool type fails validation.
type: mcp connects to a Model Context Protocol server: exactly one of
url: (streamable HTTP) or command: (stdio argv) is required, and the
server's tools become available as <id>__<tool> (e.g. drive__search_files).
The connection opens lazily on first use and is shared across runs — close it
with graph.aclose() (the CLI does this for you after run/daemon).
For known servers a preset: gives the command and env keys in one shot,
with env: merging your overrides over the defaults — no url/command
needed:
tools:
- type: mcp
config:
preset: google_drive # npx: google_drive | gmail | google_calendar
env: {GOOGLE_DRIVE_REFRESH_TOKEN: "${GDRIVE_TOKEN}"}
- type: mcp
config:
preset: git # uvx (Python): git | fetch | time | sqlite
Google presets launch via npx (Node.js required); the uvx presets are
pure Python. A missing launcher (npx/uvx) fails with a clear error.
Tracing a workflow (observability:)¶
A top-level observability: block turns on full-run tracing — topology,
per-node spans and the complete LLM prompt/response — without writing any
code. teff run and teff daemon pick it up automatically:
name: my-workflow
observability:
db: ./data/traces.db # local SQLite store (our trace dashboard)
export: # optional: also push to remote sinks
- type: webhook # any HTTP endpoint (e.g. our obs-server)
url: http://obs:8001/obs/ingest
- type: langfuse # langfuse public API (Basic auth)
host: https://cloud.langfuse.com
public_key_env: LANGFUSE_PUBLIC_KEY
secret_key_env: LANGFUSE_SECRET_KEY
- type: langsmith # langsmith runs API (x-api-key)
api_key_env: LANGCHAIN_API_KEY
project: my-project
steps:
- id: answer
type: llm_chat
config: {model: llama3.1:8b, output_key: reply}
db:resolves relative to the workflow file;data/is created if needed.- Sinks are fanned out to all exporters at once (
CompositeExporter); a failing sink is retried and then logged, never crashes the run. - Secrets come from environment variables (
*_env), never from the file. - Browse the local store in the browser:
teff obs-server --db ./data/traces.db --port 8001→http://localhost:8001/obs/ui. - A remote sink that targets
teff obs-serverneeds no API at all — pure YAML workflows push their traces over HTTP and the server renders the dashboard.
The same wiring is available in code via
teff.observability.build_observability / build_observer_factory.
The ${ENV} interpolation¶
Every value in the document is interpolated against the process environment. A variable that is not set stays as a literal placeholder — nothing crashes:
steps:
- type: llm_chat
config:
api_key_env: ${OPENAI_API_KEY}
Retrying failing steps¶
Any step can be wrapped with retry logic via a retry: block next to its
config:. The block supports max_retries (attempts, default 3),
delay (seconds between attempts, default 0), backoff (multiplier
per retry, default 1.0), timeout (per-attempt timeout), and
retry_on — a list of exception type names or HTTP status codes; by
default every exception is retried.
retry: key |
Type | Default | Notes |
|---|---|---|---|
enabled |
bool | true |
false keeps the schema valid but disables the wrapper. |
max_retries |
int | 3 |
Total attempts (including the first). |
delay |
number | 0 |
Seconds before each retry. |
backoff |
number | 1.0 |
Multiplier applied to delay per retry. |
timeout |
number | — | Per-attempt timeout. |
retry_on |
list | all | Exception type names or HTTP status codes; only these are retried. |
steps:
- id: search
type: web_search
config: {query_key: q, output_key: results}
retry:
max_retries: 4
delay: 0.5
backoff: 2.0 # delays: 0.5s, 1s, 2s, 4s
timeout: 30
retry_on: ["httpx.HTTPStatusError", 429]
Use retry: {enabled: false} to keep the schema valid but disable the
wrapper, and retry_on: [429] to only retry on that status code. The
retry wrapper preserves the inner node's normal success/failure behaviour,
so __error__ edges still fire after the final failed attempt.
Nested subflows (composite agents)¶
A subflow step embeds a complete inner graph — the composite-agent pattern.
The inner graph is declared with the same steps/edges vocabulary; the outer
graph maps state into it with input_map and pulls results back with
output_map:
steps:
- id: greet
type: transform
config: {action: trim, input_key: text, output_key: text}
- id: inner
type: subflow
config:
input_map: {text: x} # outer key → inner key
output_map: {y: result} # inner key → outer key
max_iterations: 50
graph: # the nested graph
steps:
- id: up
type: transform
config: {action: uppercase, input_key: x, output_key: y}
edges:
- from: greet
to: inner
Nested graphs validate against the same node registry (any built-in or plugin
type), support retry: per inner step, and round-trip through
workflow_to_yaml. Without input_map/output_map the whole parent state is
passed through.
Alternatively, config.build reuses the agent_step recipe (context builder →
ReAct harness → append assistant) as a composite agent:
steps:
- id: chat
type: subflow
config:
id_prefix: chat
build:
type: agent_step
system: You are a helpful assistant
output_key: answer
model: llama3.1:8b
messages_key: messages
use_tools: all
Parallel fan-out¶
A parallel step runs independent branches concurrently and merges their
results back via the state reducers. Each branch is a single step mapping
or a list of step mappings (run sequentially within the branch):
steps:
- id: fanout
type: parallel
config:
branches:
- {type: transform, config: {action: uppercase, input_key: q, output_key: web}}
- [{type: transform, config: {action: count_lines, input_key: q, output_key: upper}},
{type: transform, config: {action: uppercase, input_key: q, output_key: n}}]
edges:
- from: fanout
to: finish
Branches receive an isolated copy of the state and may run on the same
or different provider endpoints. Use state.schema ... reducer: append
when branches should accumulate (e.g. collecting messages) instead of
overwriting a key.
parallel config |
Type | Default | Notes |
|---|---|---|---|
branches |
list | — | Each branch: a step mapping or a list of step mappings (sequential inside the branch). |
converge |
mapping | — | A single {type: transform, config: ...} step that re-joins the branches (only transform is supported in YAML). |
Dynamic fan-out (map)¶
A map step fans a runtime state list out across parallel branches — branch
count comes from the data, not the file:
steps:
- id: summarize
type: map
config:
input_keys: [chunks]
output_key: summaries
max_concurrency: 4
processor:
type: llm_chat
config: {model: llama3.1:8b, input_key: chunk, output_key: summary}
map config |
Type | Default | Notes |
|---|---|---|---|
input_keys |
string/list | — | State list key(s); multiple are zipped per index. |
output_key |
string | — | State key receiving the list of per-item results. |
processor |
mapping | — | The node applied per item (inline {type, config}). |
chunk_size |
int | 1 |
Items per branch. |
max_concurrency |
int | — | Cap on concurrent branches. |
Composing workflows (include:)¶
An include: block merges steps, edges, tools and state from other workflow
files — recursively, since an included file may include others. Paths are
relative to the including file:
name: composed
include:
- path: ../shared/workflow.yaml
prefix: shared_
- path: ./retry.yaml
A prefix: (prepended to every included step id and edge endpoint) lets you
compose the same file into several places without id collisions; it is also
applied to command node goto targets. Included steps run first, then the
including workflow's own steps. Without a prefix, ids must not collide.
Declarative routing (command)¶
A command step routes the graph from state — dynamic goto / STOP
without code. The when expressions use the same language as edges:
conditions, and the first match wins:
steps:
- id: route
type: command
config:
routes:
- {when: "score >= 0.8", goto: approve}
- {when: "score < 0.3", goto: reject}
goto: review
update: {routed: true}
goto: STOP terminates the run.
command config |
Type | Default | Notes |
|---|---|---|---|
routes |
list | [] |
{when, goto} pairs; first match wins. |
goto |
string | — | Fallback target, or STOP to end the run. |
update |
mapping | — | State keys merged after routing. |
Loops (loop)¶
A loop step repeats a body chain until state[key] equals until —
everything in one node, no decider edges. body is a node or list of nodes
given as inline type: ... specs (like map's processor):
steps:
- id: refine
type: loop
config:
key: approved
until: "да"
max_rounds: 3
body:
- {type: transform, config: {action: value, value: "нет", output_key: approved}}
max_rounds (default 10) bounds the repetition; the condition uses the edges
expression language, so until: "да" matches "Да" or "да.".
loop config |
Type | Default | Notes |
|---|---|---|---|
key |
string | — | State key the condition reads. |
until |
string | — | Value of key that stops the loop. |
max_rounds |
int | 10 |
Maximum body rounds before giving up. |
body |
node/list | — | Chain run each round (inline {type, config} specs). |
Validated interrupts (strategy:)¶
An interrupt step can validate the operator's answer with a strategy:
mapping instead of comparing it verbatim. The loader expands it into the
classifier + validate chain ({id}-validate), the YAML counterpart of
flow.interrupt(key, prompt, accept=...):
steps:
- id: gate
type: interrupt
config:
key: approved
prompt: "Approve the report? (yes / no)"
strategy: {equals: да} # or: any_of: [да, ок] | regex: "^[A-Z0-9]{4}$"
- id: ship
type: transform
config: {action: value, value: shipped, output_key: status}
edges:
- {from: gate, to: ship, condition: "decision=да"}
An llm strategy needs model and provider:
strategy:
llm:
system: Classify the answer as approval or rejection.
user: "Answer: {approved}"
schema:
type: object
properties: {ok: {type: boolean}}
model: llama3.1:8b
provider: ollama
Edges that would have sourced from the interrupt now source from
{id}-validate, where the decision key (decision by default) is written.
interrupt config |
Type | Default | Notes |
|---|---|---|---|
key |
string | — | State key receiving the resume value. |
prompt |
string | — | Question shown to the operator. |
strategy |
mapping | — | {equals} | {any_of: [...]} | {regex} | {llm: {system, user, schema, model, provider}}, plus passthrough keys (decision_key, pass_value, fail_value, value_key, verdict_key, ok_field, rounds_key, max_rounds). |
Pausing raises GraphInterrupt; resume with the same checkpoint_id and
resume={key: answer}. Requires a checkpointer. See
durable execution.
Durable runs (checkpoint:)¶
A top-level checkpoint: block enables durable runs whose state is saved
before every node, so an interrupted or crashed workflow resumes instead of
restarting:
checkpoint:
type: sqlite # file | sqlite | sqlite_history | pg | pg_history
path: data/checkpoints.db
checkpoint: key |
Type | Notes |
|---|---|---|
type |
string | file | sqlite | sqlite_history | pg | pg_history. |
path |
string | Store path, resolved relative to the workflow file (SQLite/file). |
dsn |
string | PostgreSQL connection string (PG variants). |
table |
string | — |
path is resolved relative to the workflow file. PG variants require
dsn: (+ optional table:). Use teff run --checkpoint-id <id> (or the
--checkpoint '...' JSON flag to override the block per invocation). This
is the same durable machinery the teff CLI and the conversational
turn/Assistant layer use.
Hook events (hooks:)¶
Hooks observe node execution. Because they're Python callbacks, a workflow
names hooks registered in a plugin — declare the plugin under plugins:,
register them with the @hooks.hook decorator, then reference by name:
# plugins/telemetry.py
from teff import hooks
@hooks.hook("tick")
def tick(node_id, node, state, **kwargs):
metrics.counter("graph.node", node_id=node_id, type=node.type)
plugins: [plugins/telemetry.py]
hooks:
on_node_start: tick # (node_id, node, state)
on_node_end: [tick, finalize] # also passes the node result
on_node_error: on_error # also passes the exception
Each key takes a hook-name string or a list; an unknown name fails
validation with a clear message. Sync and async hooks are both supported
(graph.run awaits async ones). The same hooks= mapping can be passed
programmatically.
hooks: key |
Callback signature |
|---|---|
on_node_start |
(node_id, node, state) |
on_node_end |
(node_id, node, state) + the node result |
on_node_error |
(node_id, node, state) + the exception |
Inspecting a graph¶
Render the topology back to YAML or as a Mermaid diagram:
teff graph workflow.yaml # YAML topology
teff graph workflow.yaml --mermaid # Mermaid flowchart
The Mermaid output marks the entry point, annotates edges with their
conditions, and styles __error__ edges distinctly — useful for
docs and review.
Loading & validating¶
from teff.yaml import load_workflow
from teff.yaml_schema import validate_workflow_file, format_errors
errors = validate_workflow_file("workflow.yaml") # [] when ok
if errors:
print(format_errors(errors, source="workflow.yaml"))
graph, tools, state, reducers = load_workflow("workflow.yaml")
result = await graph.run(state, tools=tools, reducers=reducers)
Both loaders auto-detect the document layer: a flow.yaml-style document is
routed through the flow compiler/validator, a low-level graph document through
the graph path (validate_workflow_file → validate_flow vs
validate_workflow; load_workflow → load_flow vs load_workflow).
The CLI wraps both:
teff validate workflow.yaml
teff -f workflow.yaml
Exporting a code-built graph¶
Build with Flow, then serialize to a deployable workflow:
from teff.yaml import workflow_to_yaml, graph_to_yaml
yaml_text = workflow_to_yaml(graph, tools=tools, initial=state, reducers=reducers)
# graph_to_yaml(graph) is a shorthand when you only need the graph itself
ReAct edges (_tool_call_name !=) round-trip correctly, so an agent built in
code can be emitted as declarative YAML.
See also¶
flow.yamlreference — the authoring-layer document this format compiles from.- Choosing your abstraction — when to use which layer.
- Node reference — every
steps:typeand its config. - Tools reference — every
tools:type and its config. - Providers reference — how provider resolution works.