Teff — Durable AI workflows as data
Durable AI workflows as data.
Every agent project has the same arc: one for loop of LLM calls feels fine,
a second agent needs branching, a third needs retries and state — and soon
the logic of your product is buried in order-6 loops of async code that
nobody can read, nobody can resume, and nobody can debug.
Teff is the other pattern: the workflow is data. Branching, retries, checkpointing and pausing live in the structure of a graph, not in the body of your functions. Run it — and when the process dies, it picks up exactly where it stopped.
Build stateful AI agents as YAML or Python graphs and run them safely in production:
YAML Flow (Python)
│ │
└───────┬─────────┘
▼
┌─────────┐
│ Graph │
└────┬────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
LLM Tools RAG
│ │ │
└────────────┼────────────┘
▼
Checkpoint
│
crash / pause
│
▼
Resume
Teff is async-first: nodes, tools and LLM calls are async, and a run can be
checkpointed, inspected, paused, resumed and evaluated. The flow is
data, every run is durable, and nothing is a black box. Read the whole
story in Why Teff.
Why Teff?
Workflows are data
Branching, retries and error handling live on edges, not inside loops. Define a workflow in YAML and it becomes a versionable, diffable, reviewable artifact — generated or modified independently from your application code:
name: research
default_model: llama3.1:8b
default_provider: ollama
providers:
- name: ollama
type: ollama
base_url: http://localhost:11434
chat_path: /api/chat
state:
initial:
input: "What's new in durable AI workflows?"
steps:
- agent:
id: research
system: "Research {input}"
output_key: research
- llm:
id: summarize
system: "Summarize {research}"
output_key: summary
Run it from the terminal:
teff -f workflow.yaml
Durable by default
Long-running AI workflows fail. Teff writes a checkpoint before every node, so a run continues from the last completed node instead of starting over:
research ✓
search ✓
LLM ✓
report ✗ ← crash / pause
↓ restart
research ✓
search ✓
LLM ✓
report → resume (only this node re-runs)
await graph.run(
state,
checkpointer=checkpoint_store,
checkpoint_id="research-42",
)
File, SQLite and PostgreSQL checkpointers included — see Durable (checkpoints).
Python when you need it
YAML is optional. The same graph builds directly in Python — the Flow API
chains nodes with short sugar methods (llm(), transform(), branch(),
parallel(), map(), interrupt(), …), so an agent reads top-to-bottom:
from teff.flow import Flow
from teff.provider import ProviderRegistry
flow = (
Flow(
"research",
providers=ProviderRegistry.from_presets("ollama"),
default_provider="ollama",
default_model="llama3.1:8b",
)
.llm(prompt="Summarize {text}", output_key="summary")
.transform(action="uppercase", input_key="summary", output_key="result")
)
graph = flow.compile()
result = await graph.run({"text": "Hello world"})
Every node stays inspectable and the whole thing is one YAML export away — see Flow builder.
Beneath the Flow builder sits the low-level Graph API — Graph +
Node + Edge — for hand-wiring every arrow, a custom Node subclass, or
Command routing when you need full control. YAML, Flow and Graph all
compile to the same runnable graph:
from teff.graph import Edge, Graph
from teff.node import Transform
graph = Graph(
nodes={
"trim": Transform(action="trim", input_key="title", output_key="loud"),
"upper": Transform(action="uppercase", input_key="loud", output_key="result"),
},
edges=[Edge("trim", "upper")],
entry_point="trim",
)
result = await graph.run(state={"title": " hello "})
Teff vs the alternatives
Not a comparison of names, but of patterns — the two ways people build agents today, and the one Teff offers:
| Imperative loops | Big platform SDKs | Teff | |
|---|---|---|---|
| Flow is visible | No (in code) | Usually yes | First-class — the graph IS the app |
| Durable / resumable | No | Yes, often on their runtime | Yes, no server: file/SQLite/PG |
| Crash recovery | Start over | Resume at job level | Resume from the failed node |
| Human-in-the-loop | Hand-rolled | Available | Interrupt → resume, a step like any other |
| Dependencies | Your code only | Heavy SDK + runtime | 4 core runtime deps, no SDKs, raw HTTP |
| Embeddable in your app | Yes | Only on their platform | Yes — you import us, a library |
| Observability | print/log | Their dashboard, you write adapters | Built in — traces, token usage, cost |
| Vendor lock-in | None | Strong | None |
The honest trade-off: you don't get a "library of literally everything" — you get structure, durability and instrumentability, without operating any server. Full story in Why Teff.
Built for real workflows
- Async-first — nodes, tools and LLM calls are async
- Agents — ReAct/tool-calling loops, multi-agent supervisors
- Durable execution — checkpoints and resume from the failed node
- Human-in-the-loop —
Interruptto pause, resume, approve - Parallel & fan-out — concurrent branches and dynamic
Map - RAG — pluggable vector stores and embeddings
- Structured output — JSON Schema and Python types
- Observability — traces, token usage, cost, local dashboard
- MCP & skills — external tools,
SKILL.mdscoping - Evaluations — score workflows against datasets
- CLI — validate, run, inspect and evaluate workflows
- Multiple providers — Ollama, OpenAI, Anthropic, OpenAI-compatible
- Embeddable — a library, not a hosted platform
Each is a full chapter in the docs.
Try it in 30 seconds
pip install teff # or: uvx teff
teff -f workflow.yaml # run a workflow
Or clone the repo and run a complete example:
git clone https://github.com/bzdvdn/teff
cd teff && uv sync
uv run teff run --file examples/hello_workflow/workflow.yaml
In action — a durable LLM run, resume and graph render (needs local Ollama):
Run + durable resume + graph (hello_llm example) |
|---|
Human-in-the-loop is a first-class citizen — the whole workflow, as data:
name: poem_chat
state:
initial: { messages: [], poem: "", critic: {}, critic_note: "", decision: "" }
checkpoint: { type: file, path: data/checkpoints }
providers:
- name: ollama
type: ollama
base_url: http://localhost:11434
chat_path: /api/chat
default_provider: ollama
default_model: llama3.1:8b
steps:
- context_builder: # topic + latest user feedback -> input
id: compose
messages_key: messages
sections:
poem: "Current poem"
critic_note: "Critic feedback"
answer: "New user feedback"
output_key: topic
reset_keys: [poem, critic_note, answer]
- llm: # a step is just `type: config` — no edges to spell out
id: poet
system: >-
You are an outstanding poet. Below is the topic and, if present,
feedback — take it into account and rewrite the poem accordingly.
Reply with ONLY the poem text, no explanations, no headings.
prompt: "{topic}"
output_key: poem
- llm:
id: critic
system: >-
You are a demanding poetry critic. Judge the poem: rhyme, rhythm,
imagery. Reply with a single JSON object with fields "verdict" and
"note": "verdict" is "ok" or "fix"; "note" is one-two short sentences
telling the poet what to improve (or an empty string when it is ok).
prompt: |-
Poem:
{poem}
Reply with JSON.
output_key: critic
parse: true
- transform:
{
id: take_note,
action: json_get,
input_key: critic,
field: note,
output_key: critic_note,
}
- append_assistant: {output_key: poem, messages_key: messages}
- interrupt:
id: approval
key: answer
prompt: >-
Here is the poem:
{poem}
---
Do you like it? Say what you think (yes / of course / make it shorter
/ no)…
strategy:
llm: # judges free-form answers - no hard-coded keywords
system: >-
Classify how the user feels about the poem they were just shown.
Return one JSON object with a boolean "ok" field.
ok=true means the user accepts the poem. Examples of acceptance:
"yes", "sure", "perfect", "nice", "not bad", "keep it".
ok=false means they want changes or a rewrite. Examples of
rejection: "no", "rewrite", "shorter", "change it", "the other".
When in doubt, prefer ok=true for mildly positive or ambiguous
replies and ok=false only when a change is clearly requested.
user: 'The user said: "{answer}". The poem: {poem}'
model: qwen2.5:7b
provider: ollama
schema:
type: object
properties: { ok: { type: boolean } }
required: [ok]
decision_key: decision
pass_value: keep
fail_value: rewrite
- route:
id: route
routes:
- { when: "decision=keep", goto: done }
- { when: "decision=rewrite", goto: compose }
goto: approval
- transform:
{
id: done,
action: value,
value: "Poem done — hope you like it!",
output_key: done,
}
The steps above chain top-to-bottom automatically — a context_builder
injects the topic + latest user feedback into compose, and the loop simply
returns there; the router, loop and human gate are plain steps, not
control-flow in code.
teff chat examples/poem_chat/workflow.yaml
# "write a poem about autumn" → bot writes + pauses
# "make it shorter" → loop rewrites it
# "yes, perfect" → done
The same workflow, as a chat (poem_chat example) |
|---|
Teff vs application code
Without Teff, every app re-implements the same infrastructure:
application
├── LLM calls
├── tool execution
├── retries
├── state
├── persistence
├── routing
├── resume logic
└── observability
With Teff, the infrastructure is the runtime. You own business logic; Teff owns execution:
workflow.yaml ──► Teff ──► state, graph execution, checkpoints,
tools, retries, tracing
Production examples
The same few primitives scale to real systems:
examples/applications/repair-ai-chat/— a five-agent supervisor (RAG, tools, streaming, FastAPI, trace dashboard).examples/supervisor_complex/— a pure-YAML supervisor with a quality gate: Map fixes in parallel, loop-until-pass, and an operator interrupt gate. Runs offline, no API key.examples/recipes/support_triage/— knowledge-grounded supervisor that escalates to a human instead of rolling a wrong answer.
More in Examples — and if you want it written end to end, Recipe: from zero to a FastAPI agent in 10 minutes.
CLI
teff -f workflow.yaml # run (the default command)
teff -f workflow.yaml --trace # run + JSON trace to stderr
teff validate workflow.yaml # validate without running
teff eval workflow.yaml --data dataset.jsonl --exact
teff inspect --checkpoint '{"type":"sqlite","path":"cp.db"}' --checkpoint-id run-1
teff new support-ai # scaffold a FastAPI app
teff daemon -f workflow.yaml --interval 60 # restart a run every 60s
teff obs-server --db traces.db --port 8001 # observe trace dashboard
teff version
Install & extras
Python >= 3.11. Core runtime depends only on httpx, jsonschema, pyyaml,
and typer.
pip install teff
# extras: teff[stores-qdrant] etc. for one RAG store, teff[embedding] for all,
# teff[pg-checkpoint] for PostgreSQL checkpoints, teff[mcp] for MCP tools,
# teff[tools] for built-in tools, teff[all] for everything except docs
uv tool install teff # global `teff` CLI
uvx teff -f workflow.yaml # run on the fly without installing
Docker
Official images on Docker Hub for every v* tag — one build, six variants:
| Image | Contents | Commands |
|---|---|---|
bzdvdn/teff |
core + teff[tools] |
the teff CLI |
bzdvdn/teff-fastapi |
core + teff[fastapi] |
a FastAPI server |
bzdvdn/teff-worker |
core + teff[queue] |
celery workers |
bzdvdn/teff-obs |
core + teff[observability] |
teff obs-server dashboard |
bzdvdn/teff-rag |
core + teff[stores-qdrant,tools,rag-pdf] |
slim RAG build |
bzdvdn/teff-all |
every docs-less extra |
full optional surface |
docker run --rm -v "$PWD:/workflow" bzdvdn/teff:latest run -f /workflow/workflow.yaml
Development
uv sync --all-extras # install deps (incl. optional extras used by tests)
uv run pytest tests/ -q # tests — the suite is fully offline (no API keys)
uv run ruff check . # lint
uv run ruff format --check . # formatting
uv run mypy . # types
uv run mkdocs build # build these docs
Docs & community
- Why Teff — the full story
- Documentation — guides, recipes, reference
- Examples
- Contributing · Code of Conduct · Security
- Constitution — the principles behind the framework
Status
0.2.0 — added the flow.yaml authoring layer: a sugar surface mirroring
the Python Flow builder (single-key idiom steps, team: / supervisor:,
parallel: / map: / loop:, interrupt: + strategy:), compiled via
teff build -f flow.yaml into the low-level graph.yaml artifact; plus the
flow.team() Python API and AgentRole. Every example now ships a
validated sugar + low-level twin. The low-level workflow.yaml surface
remains unchanged and stable.
Coming next: durable conversations with built-in memory, tighter tool ergonomics, and more vector stores + plugins. Want to shape the roadmap or have a workflow Teff can't express yet? Open an issue — every report steers the project.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file teff-0.2.0.tar.gz.
File metadata
- Download URL: teff-0.2.0.tar.gz
- Upload date:
- Size: 2.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3103bf21705930cdfd464c913ade487ebaab192590d65a4a1bc5d7cf6920d995
|
|
| MD5 |
61852ad46d770b86c1e9d6ee4d8006e3
|
|
| BLAKE2b-256 |
23656ca43e52b7e7bb7934e476737c3007cde5240ce9ee9c70871931a393b1f7
|
Provenance
The following attestation bundles were made for teff-0.2.0.tar.gz:
Publisher:
release.yml on bzdvdn/teff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
teff-0.2.0.tar.gz -
Subject digest:
3103bf21705930cdfd464c913ade487ebaab192590d65a4a1bc5d7cf6920d995 - Sigstore transparency entry: 2395918902
- Sigstore integration time:
-
Permalink:
bzdvdn/teff@901d4ad74b30fafd178b7bdc037699b2645a322b -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/bzdvdn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@901d4ad74b30fafd178b7bdc037699b2645a322b -
Trigger Event:
push
-
Statement type:
File details
Details for the file teff-0.2.0-py3-none-any.whl.
File metadata
- Download URL: teff-0.2.0-py3-none-any.whl
- Upload date:
- Size: 387.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a20047d9ce2136075fb193d713e34e767e33d46b4ddffb8125543a24144b59fb
|
|
| MD5 |
dc2079ecbdf4e21c91103ee15b7c2196
|
|
| BLAKE2b-256 |
d5d261c7522a2c2bef4a5d733982067c2a739a29a5e7fb30f0c5d90e0850a6fd
|
Provenance
The following attestation bundles were made for teff-0.2.0-py3-none-any.whl:
Publisher:
release.yml on bzdvdn/teff
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
teff-0.2.0-py3-none-any.whl -
Subject digest:
a20047d9ce2136075fb193d713e34e767e33d46b4ddffb8125543a24144b59fb - Sigstore transparency entry: 2395919392
- Sigstore integration time:
-
Permalink:
bzdvdn/teff@901d4ad74b30fafd178b7bdc037699b2645a322b -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/bzdvdn
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@901d4ad74b30fafd178b7bdc037699b2645a322b -
Trigger Event:
push
-
Statement type: