# Background Tasks
Source: https://docs.timbal.ai/agents/background-tasks
Execute long-running operations asynchronously
Background tasks let runnables execute asynchronously while the agent continues. When a tool runs in the background, it immediately returns a `task_id` and `status: "running"` instead of blocking until completion.
## Configuring Background Mode
The `background_mode` parameter controls when a runnable executes asynchronously:
* `"never"` (default) — always runs synchronously
* `"always"` — always runs in the background
* `"auto"` — the LLM decides per call by setting `run_in_background=True` on the tool input
```python theme={"dark"}
from timbal import Tool
long_running_tool = Tool(
handler=slow_operation,
background_mode="always",
)
```
The built-in `Bash` tool uses `background_mode="auto"` by default. The model can run a shell command in the background by passing `run_in_background=True`.
```python theme={"dark"}
from timbal import Agent
from timbal.tools import Bash
agent = Agent(
name="task_manager",
model="openai/gpt-4o-mini",
tools=[Bash("*")],
)
# Agent starts the command in the background and returns immediately
result = await agent(prompt="Install project dependencies in the background").collect()
```
## Checking Task Status
Background tools return immediately with a task handle:
```python theme={"dark"}
{"task_id": "a3k9f2", "status": "running"}
```
Once at least one background task is running, the agent automatically exposes a `get_background_task` tool. Call it with the `task_id` to poll status and drain queued events:
```python theme={"dark"}
from timbal.state import get_run_context
# After starting a background task...
span = get_run_context().current_span()
status = span.runnable.get_background_task("a3k9f2")
# {"status": "running" | "completed" | "error" | "cancelled" | "not_found", "events": [...], ...}
```
Or let the agent poll for you on a follow-up turn — it will see `get_background_task` in its tool list and can call it with the `task_id` from the earlier tool result.
# Commands
Source: https://docs.timbal.ai/agents/commands
Invoke tools, agents, and workflows directly with slash commands — no LLM round-trip
Commands let users trigger a runnable directly from a message that starts with `/`. The agent parses the message, maps arguments to the handler's parameters, runs the runnable immediately, and **skips the LLM call** for that turn.
This is useful for deterministic shortcuts in chat UIs, Slack bots, or internal tools where you already know which action to run.
## Basic usage
Set `command` on any `Tool`, `Agent`, or `Workflow` in the agent's `tools` list:
```python theme={"dark"}
from timbal import Agent, Tool
def greet(name: str) -> str:
return f"Hello, {name}!"
agent = Agent(
name="support_agent",
model="openai/gpt-4o-mini",
tools=[Tool(handler=greet, command="greet")],
)
result = await agent(prompt="/greet Alice").collect()
# result.output → "Hello, Alice!"
```
The command name is matched with or without a leading slash — both `/greet` and registering `command="greet"` work. Unknown commands fall through to the LLM as a normal message.
## Argument parsing
Arguments after the command name are split with `shlex` (shell-style quoting) and mapped **positionally** to the handler's parameters in signature order:
```python theme={"dark"}
def add(a: int, b: int) -> int:
return a + b
Tool(handler=add, command="add")
# /add 5 3 → add(a=5, b=3)
# /echo "hello world" → echo(message="hello world")
```
* Extra arguments are ignored
* Missing required arguments surface as normal validation errors on the tool
* Only messages with a **single text block** starting with `/` are treated as commands
## Agents and workflows as commands
Any runnable in `tools` can expose a command — including nested agents and workflows:
```python theme={"dark"}
search_agent = Agent(
name="search_agent",
model="openai/gpt-4o-mini",
command="search",
description="Search and summarize",
)
main_agent = Agent(
name="main_agent",
model="openai/gpt-4o-mini",
tools=[search_agent],
)
# Quote multi-word prompts so they map to a single argument
await main_agent(prompt='/search "python tutorials"').collect()
```
Workflows work the same way — arguments map to the workflow's combined parameter model (all step inputs in signature order):
```python theme={"dark"}
from timbal import Workflow
workflow = Workflow(name="pipeline", command="pipeline")
workflow.step(fetch_data)
workflow.step(process_data, depends_on=["fetch_data"])
agent = Agent(name="agent", model="openai/gpt-4o-mini", tools=[workflow])
await agent(prompt='/pipeline "quarterly report"').collect()
```
## Behavior
| Behavior | Detail |
| ------------------ | ---------------------------------------------------------------------------------------- |
| **No LLM call** | Matching commands execute the runnable directly — no tokens spent on tool selection |
| **Memory** | Command invocations are recorded in conversation memory as a synthetic tool use + result |
| **Unknown `/foo`** | Falls through to the LLM if `foo` is not a registered command |
| **Collisions** | Two tools with the same `command` name log a warning; the first registered wins |
## When to use commands
* **Chat shortcuts** — `/help`, `/status`, `/reset` that should always hit the same handler
* **Power users** — skip LLM routing for actions they already know by name
* **Nested specialists** — `/search`, `/billing` to invoke sub-agents without the parent model choosing tools
For LLM-driven tool selection (the default), see [Adding Tools](/agents/tools).
# Dynamic Agents
Source: https://docs.timbal.ai/agents/dynamic
Learn how to create agents with dynamic system prompts that update automatically using real time data
## Dynamic System Prompts
Agents support dynamic system prompts through callable functions that are executed each time the agent runs, providing fresh context.
### Using Callable Functions
The preferred way to create dynamic system prompts is to pass a callable directly to `system_prompt`. This gives you full control over the system prompt construction and access to all runtime inputs:
```python theme={"dark"}
from datetime import datetime
from timbal import Agent
from timbal.state import get_run_context
def get_system_prompt() -> str:
run_context = get_run_context()
current_span = run_context.current_span()
now = datetime.now()
date_str = now.strftime("%A, %B %d, %Y")
time_str = now.strftime("%H:%M")
system_prompt = f"You're a helpful assistant. Current date: {date_str}. Current time: {time_str}."
# Access runtime inputs
instructions = current_span.input.get("instructions", None)
if instructions:
system_prompt += f"\n\n## Instructions\n{instructions}"
user = current_span.input.get("user", None)
if isinstance(user, dict):
system_prompt += "\n\n## About the User\n"
for k, v in user.items():
if isinstance(v, list):
system_prompt += f"\n- {k}:"
for item in v:
system_prompt += f"\n - {item}"
else:
system_prompt += f"\n- {k}: {v}"
return system_prompt
agent = Agent(
name="dynamic_agent",
model="openai/gpt-4o-mini",
system_prompt=get_system_prompt, # Pass the function directly
)
```
Then call the agent with runtime data:
```python theme={"dark"}
response = await agent(
prompt="Who am I?",
instructions="Be concise and friendly.",
user={
"name": "Alice",
"role": "Developer",
"memories": [
"Prefers Python over JavaScript",
"Working on a new project",
],
},
).collect()
```
### Using Template Syntax
Template syntax will be deprecated in a future release. We recommend using callable functions instead.
For simpler cases, you can use `{module::function}` syntax to embed dynamic values:
```python theme={"dark"}
agent = Agent(
name="dynamic_agent",
model="openai/gpt-4o-mini",
system_prompt="""You are a time-aware assistant.
Current time: {datetime::datetime.now}."""
)
```
The previous example used a built-in function (datetime). You can also create your own custom functions:
```python title="my_functions.py" theme={"dark"}
def get_server_status():
"""Get server status."""
status = check_server() # Calls external function
return f"Server: {status}"
agent = Agent(
name="custom_agent",
model="openai/gpt-4o-mini",
system_prompt="""You are a helpful assistant.
Status: {my_functions::get_server_status}."""
)
```
You can also pass dynamic parameters to these functions using `RunContext` data that you previously set in the context.
```python title="my_functions.py" theme={"dark"}
from timbal import Agent, Tool
from timbal.state import get_run_context
def get_user_language():
span = get_run_context().current_span()
return span.input["language"]
def set_user_language(l):
span = get_run_context().current_span()
span.input["language"] = "catalan"
agent = Agent(
name="multilang_agent",
model="openai/gpt-4o-mini",
pre_hook=set_user_language,
system_prompt="Answer in {my_functions::get_user_language}."
)
await agent(prompt="Which is the capital of Germany?").collect()
```
The response will be in Catalan.
**Benefits:**
* Real-time context: System prompts reflect current state
* Dynamic behavior: Agent adapts to changing conditions
* Automatic execution: Functions run on each conversation
* Performance: Template resolution is fast and cached
* Sync/Async: Handles both sync and async functions automatically
## Dynamic Tools
Timbal provides the `ToolSet` class for dynamic tool resolution. ToolSets resolve tools at runtime before each LLM call, enabling dynamic tool availability based on execution context. Use ToolSets instead of static tool lists when:
* **Context-dependent availability**: Tools should only appear under certain conditions (user permissions, environment state, iteration count)
* **Lazy loading**: Defer tool initialization until actually needed
* **Dynamic configuration**: Tools need runtime parameters or state that isn't known at agent creation
* **Conditional behavior**: Tool availability changes during execution
* **Token efficiency**: Reduce token consumption by exposing only relevant tools instead of all available tools
* **Improved clarity**: When many tools exist but only a few are available per context, the agent sees fewer options and is less likely to get confused
Implement the `resolve()` method to return a list of tools. Access runtime data through `get_run_context()` to inspect the current execution state.
### Example: Role-based tool access
This example shows accessing input parameters to conditionally provide tools. The role can be set via prehook or when calling the agent:
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.core.tool_set import ToolSet
from timbal.state import get_run_context
class RoleBasedToolSet(ToolSet):
async def resolve(self) -> list[Tool]:
span = get_run_context().current_span()
role = span.input.get("role", "user")
if role == "admin":
return [
Tool(handler=view_profile),
Tool(handler=delete_user),
Tool(handler=modify_permissions)
]
else:
return [Tool(handler=view_profile)]
admin_agent = Agent(
name="admin_agent",
model="openai/gpt-4o-mini",
tools=[RoleBasedToolSet()]
)
# Role can be set via prehook or as a parameter
await admin_agent(prompt="Delete user 123", role="admin").collect()
```
The `resolve()` method is called before each LLM call. It reads the `role` from the input parameters and returns different tools:
* `role == "admin"`: returns `view_profile`, `delete_user`, `modify_permissions`
* Otherwise: returns only `view_profile`
The agent only sees the tools returned by `resolve()`, preventing unauthorized actions when the role is not "admin".
# Guardrails
Source: https://docs.timbal.ai/agents/guardrails
Content policy for the four edges of an agent run: input, model output, tool args, and tool results
Guardrails intercept content at the four edges of an agent run and decide what happens to it — block it, redact it, ask the model to try again, escalate to a human, or just record it.
```python theme={"dark"}
from timbal import Agent
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tools=[...],
guardrails="default", # PII redaction + secret redaction + prompt-injection blocking
)
```
The edges:
1. **`input`** — the user's message, checked **before the first LLM call**. A blocked input spends zero tokens and executes zero tools.
2. **`model_output`** — the final assistant response, checked before it reaches the user (stream-safe — see [Streaming](#streaming)).
3. **`model_step`** — opt-in: **every** assistant message, including intermediate tool-calling steps, not just the final response. Use for policies that must hold mid-plan (leaked codenames in reasoning prose, PII in intermediate text). LLM-backed rails here multiply classifier calls per turn — prefer deterministic rails.
4. **`tool_args`** — a tool call's validated arguments, checked before the tool runs. An `escalate` verdict converts into a [human approval gate](/human-in-the-loop/approval-gates).
5. **`tool_result`** — a tool's output, checked before it enters memory (and before [tool result offloading](/agents/memory-compaction#tool-result-offloading), so rails always see the full text).
## From one string to full control
### One string
```python theme={"dark"}
agent = Agent(name="a", model="openai/gpt-4o-mini", guardrails="default")
```
`"default"` is deterministic and free — no classifier calls: `DetectPII(action="redact")` + `RedactSecrets()` + `PromptInjection(action="block")`.
### Shorthands
```python theme={"dark"}
agent = Agent(..., guardrails=["pii:redact", "injection:block", "secrets", "moderation:warn"])
```
Each shorthand is `name` or `name:action`. Valid names: `pii`, `secrets`, `injection`, `keywords`, `moderation`, `length`, `topic`, `judge`. A typo raises immediately with the valid options.
### Configured built-ins
```python theme={"dark"}
from timbal.guardrails import DetectPII, Moderate, PromptInjection, TopicGuard
agent = Agent(
...,
guardrails=[
DetectPII(on_input="redact", on_output="block", types=["email", "credit_card", "ssn"]),
PromptInjection(action="block"),
Moderate(provider="openai", action="warn"),
TopicGuard(allow=["billing", "shipping"],
blocked_message="I can only help with billing and shipping."),
],
)
```
Per-stage actions live on the rail: `on_input=`, `on_output=`, `on_tool_args=`, `on_tool_result=` override the rail's default `action` (and implicitly opt the rail into that stage).
### Plain callables
```python theme={"dark"}
from timbal.guardrails import Verdict, guardrail
def no_competitors(text: str):
if "acme corp" in text.lower():
return Verdict.block("competitor mention")
return True
agent = Agent(..., guardrails=[guardrail(no_competitors, stages=["model_output"])])
```
Return-value coercion: `True`/`None` allow, `False` blocks, a `str` replaces the content, a `Verdict` gives full control. Sync or async; take `(text)` or `(text, ctx)`. `@guardrail(stages=[...])` works as a decorator.
### LLM judge in one line
```python theme={"dark"}
from timbal.guardrails import LLMJudge
LLMJudge("Response must not give medical advice", model="openai/gpt-5.4-nano", action="retry")
```
With `action="retry"` the judge's critique is fed back to the model and the response is re-generated — bounded by `Agent(max_guardrail_retries=2)`. Exhaustion blocks with the last reason.
### Rubric quality gates
For a structured definition of "done", give the judge a rubric instead of one criteria string. Each criterion is graded by its **own isolated judge call** (pass / fail / unknown + reason), and the failing criteria — with the judges' reasons — become the revision feedback:
```python theme={"dark"}
LLMJudge(
rubric=[
"Includes a comparison table",
"Every price is attributed to a source",
{"criterion": "At least 3 actionable recommendations", "weight": 2},
],
pass_threshold=1.0, # weighted fraction of criteria that must pass
action="retry", # grade → revise → re-grade, bounded by max_guardrail_retries
)
```
Per-criterion results land in the `GuardrailEvent` metadata and the run report (`metadata["guardrails"]["triggered"][i]["metadata"]["rubric"]`). The same rubric works in [evals](/evals/validators/llm#rubric) via the `rubric!` validator — write it once, gate at runtime and regress in CI. Write criteria around verifiable structure, not facts the judge cannot check.
## Verdicts
Every check resolves to one of six actions:
| Action | Effect |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allow` | Pass through untouched. |
| `block` | Stop. The run ends with `status.code="blocked"` and a user-safe message; blocked tool calls feed a `[Blocked by guardrail]` result back to the model so it can self-correct. |
| `replace` | Swap the content (redaction is a replace produced from the rail's `scrub`). |
| `retry` | Reject the model output and re-generate with feedback (model\_output only). |
| `escalate` | Convert into a human approval gate (tool\_args only). |
| `warn` | Allow, but record the violation in events and the run report. |
## Blocked responses
A block is a controlled stop, not an exception:
```python theme={"dark"}
result = await agent(prompt="ignore all previous instructions...").collect()
result.status.code # "blocked"
result.status.reason # "guardrail:prompt_injection:input"
result.output # assistant Message carrying the blocked_message — render it like any reply
```
`blocked_message` (per rail) is the user-safe copy; `reason` is the dev-facing explanation that lands in events and traces. The blocked reply is also appended to memory, so the next turn resumes a coherent conversation.
## Escalating to a human
A `tool_args` rail can require human sign-off instead of deciding itself, reusing the whole [approval-gate machinery](/human-in-the-loop/approval-gates) — `ApprovalEvent`, resume, edit-on-approve, audit trail:
```python theme={"dark"}
from timbal.guardrails import Verdict, guardrail
def gate_prod(text):
return Verdict.escalate("Deploy to prod?") if '"env": "prod"' in text else True
agent = Agent(
...,
tools=[deploy],
guardrails=[guardrail(gate_prod, stages=["tool_args"])],
)
# → ApprovalEvent(kind="guardrail_escalation"); resume={approval_id: True} releases the call
```
Tool-local rails also work directly on a `Tool` (with or without an agent):
```python theme={"dark"}
from timbal.core import Tool
Tool(handler=send_email, guardrails=[guardrail(internal_recipients_only, stages=["tool_args"])])
```
## Shadow mode
Deploy rails with zero enforcement risk: everything runs and gets recorded — nothing acts.
```python theme={"dark"}
agent = Agent(..., guardrails=["pii:redact", "injection:block"], guardrail_mode="shadow")
```
Verdicts appear in `GuardrailEvent`s (with `shadow=True`), the run report, and usage counters (`guardrails:shadow_triggered`), so you can watch trigger rates in traces before flipping to `"enforce"` (the default). Per-rail: `DetectPII(shadow=True)`.
### Sampled online monitoring
Combine shadow mode with `sample_rate` to grade a slice of production traffic — the online-evaluation pattern, without the request-path cost:
```python theme={"dark"}
from timbal.guardrails import LLMJudge
agent = Agent(
...,
guardrails=[
LLMJudge(
rubric=["Answers the question directly", "Cites a source"],
shadow=True,
sample_rate=0.05, # grade ~5% of responses; verdicts land in traces
),
],
)
```
Sampled-out checks record nothing. `sample_rate` exists for shadow/`warn` monitoring: sampling an *enforcing* rail creates nondeterministic enforcement gaps (and buffer-until-verdict still engages on every run, because the streaming decision precedes the sampling roll) — configuring that logs a warning.
## Streaming
* Rails that only **redact** (deterministic detectors) transform text **and thinking** deltas **in flight**, with a per-content-block holdback window so patterns spanning chunk boundaries are still caught.
* Any rail that can **block / retry / escalate** forces **buffer-until-verdict**: deltas are withheld and replayed once the rails allow the message — a blocked response never leaks a single chunk. This trades streaming latency for enforcement; use `warn`/shadow rails if you need live streaming with observation only.
* Thinking blocks on stored messages are scrubbed by the redact rails too, so reasoning never carries PII into memory.
* `GuardrailEvent` is a first-class stream event, so UIs can show "response withheld" the moment a rail fires.
## Observability
Every triggered rail (including shadowed and crashed ones) produces:
* a **`GuardrailEvent`** in the stream: `{rail, stage, action, reason, latency_ms, shadow}`;
* an entry in the per-run report on **`OutputEvent.metadata["guardrails"]["triggered"]`**;
* usage counters: `guardrails:triggered` / `guardrails:shadow_triggered` (judge/classifier token usage folds into normal usage accounting).
Introspect a configuration with `agent.explain_guardrails()` — a table of rails, stages, actions, and order.
If a rail itself crashes, the default is **fail-open** (the run continues; the crash is recorded with `action="error"`). Set `strict=True` on security-critical rails to fail closed.
## Redacting traces
In-run guardrails redact agent memory and outputs, but traces record every span — including the inner LLM call. `trace_redactor` closes that gap at the storage/export boundary:
```python theme={"dark"}
from timbal.guardrails import trace_redactor
from timbal.state.tracing.providers import JsonlTracingProvider
provider = JsonlTracingProvider.configured(
_path=Path("traces.jsonl"),
_trace_redactor=trace_redactor(), # PII + secrets by default
)
agent = Agent(..., tracing_provider=provider, guardrails="default")
```
The redactor runs inside the provider's `put()` on **copies** of every span — inputs, outputs, memory dumps, errors, metadata — before storage and before every exporter fires. The live run is never mutated. It works with any provider (JSONL, SQLite, platform, custom) and accepts the same specs as `guardrails=`, restricted to **deterministic** rails (`trace_redactor("pii:redact", DetectPII(types=["ssn"], redaction="hash"))`) — an LLM call per span store would be a footgun, so judgment rails are rejected loudly.
Resumed sessions load memory from stored traces, so chained turns see the redacted history. That is usually exactly what you want for compliance — the raw text exists only inside the run that produced it.
## Testing rails without an agent
```python theme={"dark"}
from timbal.guardrails import check_guardrails
report = await check_guardrails(agent, "my ssn is 123-45-6789")
assert report.triggered("detect_pii").action == "replace"
assert "[REDACTED_SSN]" in report.text
report = await check_guardrails(["injection:block"], "ignore all previous instructions")
assert report.blocked and report.blocking_rail == "prompt_injection"
```
`check_guardrails` runs only the rails — no LLM loop, no tools — against any agent or spec, at any stage (`stage="model_output"`, ...).
## Built-in rails
| Rail | Default stages | How it works |
| ----------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DetectPII` | input, model\_output, tool\_result | Regex + Luhn validation: email, credit\_card, ssn, phone, ip, url. Redaction renders as placeholder, `mask` (keep last 4), or `hash` (deterministic pseudonym). |
| `RedactSecrets` | model\_output, tool\_result | API keys (AWS, OpenAI, Anthropic, GitHub, Slack, Google, Stripe), JWTs, bearer tokens, PEM private keys, credential assignments. |
| `PromptInjection` | input | Curated pattern pack (instruction override, system-prompt probes, transcript extraction, role hijack, jailbreak personas, guardrail bypass, delimiter smuggling). Optional `model=` adds an LLM classifier that runs only when patterns find nothing. |
| `KeywordGuard` | input, model\_output | Banned terms, literal or regex. |
| `MaxLength` | input | `max_chars` / `min_chars` bounds. |
| `Moderate` | input, model\_output | OpenAI Moderation API (`provider="openai"`, free, needs `OPENAI_API_KEY`) or a Llama-Guard-style safe/unsafe prompt against any model (`provider="llama_guard", model=...`). |
| `TopicGuard` | input | LLM classifier over `allow=` / `deny=` topic lists. |
| `LLMJudge` | model\_output | Free-form criteria judged by a (cheap) model; any action, `retry` by default. |
Deterministic rails cost nothing and add microseconds. LLM-backed rails are explicit opt-ins — point them at a small model.
### What the injection pattern pack does and does not catch
The pack is English and literal by design: it is a cheap first filter, not a classifier. It is
regression-tested against a corpus of known attacks and benign lookalikes, and it deliberately
does **not** fire on ordinary phrasing that shares its vocabulary ("print the instructions for
the desk", "remove the safety guard from my lawnmower").
It does not catch non-English attacks, base64 or character-separated obfuscation, or
paraphrases that avoid the keywords entirely. Those need the classifier:
```python theme={"dark"}
PromptInjection(model="openai/gpt-5.4-nano") # patterns first; classifier only when they find nothing
```
Treat either as defence in depth, not a boundary: the durable mitigations are least-privilege
tools, `tool_args` rails, and approval gates on anything destructive.
Order matters. Every rail is checked against the text as of **its position in the list**, so a rail placed after a redactor sees the redacted text — put normalizing/redacting rails before judging rails and the judges never receive the raw content. The first non-allow verdict in list order wins. Adjacent rails that cannot rewrite the text are checked concurrently for latency; a `redact`/`retry` rail is a barrier. A custom rail that returns replacement text must declare `action="redact"`, otherwise it raises (batching is decided from the action, so an undeclared rewrite would be an invisible ordering bug).
## Known limitations
* `model_output` rails judge the **final** assistant message. For intermediate tool-calling text, opt into the `model_step` stage (every step) or rely on the `tool_args` / `tool_result` edges and in-flight scrubbing.
* `retry` verdicts regenerate final responses only; a `retry` fired mid-plan (on a tool-calling step) coerces to block rather than corrupting the tool loop.
* `trace_redactor` accepts deterministic rails only, and redacted traces are what resumed sessions load as history.
# Overview
Source: https://docs.timbal.ai/agents/index
Master proven strategies for designing advanced, specialized AI agents using an architecture that work together seamlessly to tackle complex challenges
## What are Agents?
Agents are autonomous execution units that **orchestrate LLM interactions with tool calling**.
Without tools, an agent functions as a basic LLM. The simplest agent requires just a **name** and **model**:
```python theme={"dark"}
from timbal import Agent
agent = Agent(
name="my_agent",
model="openai/gpt-5"
) # That's it! You've created your first agent!
```
For a full step-by-step guide, check the [quickstart section](/quickstart) or see practical examples in the [examples section](/examples).
## Reading guide
The sidebar follows a **basics → platform → production** path. You can jump anywhere, but this order matches how most agents are built:
1. **[Tools](/agents/tools)** — give the agent capabilities (the core primitive)
2. **[Structured output](/agents/structured-output)** — constrain what comes back
3. **[Memory](/agents/memory)** → **[Memory compaction](/agents/memory-compaction)** — multi-turn context, then keeping oversized tool results and history within the window
4. **[Skills](/agents/skills)** — domain packages (knowledge + tools) once the agent loop makes sense
5. **[Dynamic agents](/agents/dynamic)** — runtime prompts and tool sets
6. **[Background tasks](/agents/background-tasks)** · **[Commands](/agents/commands)** — utilities (async tools, slash shortcuts)
After **[Workflows](/workflows)**, see **[Human in the loop](/human-in-the-loop)** for approvals, `suspend()`, and durable resume (agents, workflow steps, and tools).
This page covers running agents, models, and I/O. The sections below are the reference for that; the linked pages go deeper on each topic.
## Model Providers
You can specify any model using the "provider/model" format. See all supported models in the [Model Reference](/models/overview).
Some models require specific parameters (like `max_tokens` for Claude). Pass it as a top-level field:
```python highlight={4} theme={"dark"}
agent = Agent(
name="claude_agent",
model="anthropic/claude-sonnet-4-6",
max_tokens=1024
)
```
**Note:** Make sure to define all required environment variables—such as the API key model that you need—in your `.env` file.
```bash theme={"dark"}
OPENAI_API_KEY=your_openai_api_key
ANTHROPIC_API_KEY=your_claude_api_key
```
### Fallback Models
Use `FallbackModel` when you want an agent to try another model if the primary provider is rate limited, temporarily unavailable, times out, or returns a retryable server error.
```python theme={"dark"}
from timbal import Agent, FallbackModel
agent = Agent(
name="support_agent",
model=FallbackModel(
"anthropic/claude-sonnet-4-6",
"openai/gpt-5.5",
"google/gemini-2.5-pro",
),
max_tokens=1024, # Required for Anthropic models
)
```
Models are tried in order. By default, each model gets two retries before Timbal moves to the next model in the chain. If every model fails with a retryable provider error, Timbal raises `FallbackExhausted` with the per-model errors.
For per-model settings, use `ModelEntry`:
```python theme={"dark"}
from timbal import Agent, FallbackModel, ModelEntry
agent = Agent(
name="support_agent",
model=FallbackModel(
ModelEntry(
"anthropic/claude-sonnet-4-6",
max_retries=3,
retry_delay=0.5,
),
ModelEntry(
"openai/gpt-5.5",
max_retries=1,
),
),
max_tokens=1024,
)
```
Fallback only switches models before the first streamed chunk is emitted. If a stream fails after output has started, Timbal raises the error instead of silently switching models and risking duplicated or inconsistent output.
### Thinking and reasoning
Some models support extended thinking before responding. Check per-model capabilities on the [Model Reference](/models/overview) pages.
Configure at construction time via `model_params` (per-request overrides use `provider_params` — see [Overriding Model Configuration](#overriding-model-configuration) below):
```python theme={"dark"}
# Anthropic — max_tokens required; budget_tokens must be < max_tokens
agent = Agent(
name="reasoning_agent",
model="anthropic/claude-sonnet-4-6",
max_tokens=20000,
model_params={"thinking": {"type": "enabled", "budget_tokens": 10000}},
)
# OpenAI
agent = Agent(
name="reasoning_agent",
model="openai/gpt-5",
model_params={"reasoning": {"effort": "high", "summary": "auto"}},
)
```
Define tools as Python functions - the framework handles schema generation, parameter validation, and execution orchestration.
## Running Agents
Execute agents by calling them with a `prompt` parameter and using `.collect()` to get the result:
```python theme={"dark"}
response = await agent(
prompt="What is the capital of Germany?"
).collect()
```
### Streaming Events
For real-time processing, you can stream events as they happen:
```python theme={"dark"}
async for event in agent(prompt="Hello"):
print(event)
```
### Approval-Required Tools
Any runnable — Tool, Agent, or Workflow step — can pause for human approval before it runs. Mark it with `requires_approval`, listen for `ApprovalEvent`, and resume by calling the runnable again with `resume`:
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.types.events import ApprovalEvent
refund = Tool(
handler=lambda amount: f"refunded ${amount}",
requires_approval=lambda amount: amount > 100,
approval_prompt=lambda amount: f"Approve refunding ${amount}?",
)
agent = Agent(name="support_agent", model="openai/gpt-5", tools=[refund])
approval_id = None
async for event in agent(prompt="Refund $250"):
if isinstance(event, ApprovalEvent):
approval_id = event.approval_id
result = await agent(
prompt="Refund $250",
resume={approval_id: True},
).collect()
```
The full reference — durable cross-process resume, audit fields, redaction, parallel gates, `pending_approvals()`, status reasons, usage counters, plus `suspend()` for asking the user mid-run — lives on the dedicated [Human in the Loop](/human-in-the-loop) section.
## Input
Agents communicate through `Message` objects - Timbal's data structure that standardizes both input and output.
```python highlight={4} theme={"dark"}
from timbal.types.message import Message
response = await agent(
prompt=Message.validate("What is the capital of Germany?")
).collect()
```
Agents accept multiple input formats, automatically converting them to `Message` objects:
````python highlight={3, 9, 14} theme={"dark"}
from timbal.types.file import File
# String
response = await agent(
prompt="What's the weather?"
).collect()
# File - Timbal type
response = await agent(
prompt=File.validate("image.png")
).collect()
# List
response = await agent(
prompt=["Describe this image", File.validate("image.png")]
).collect()
### Passing Input Parameters
You can pass custom input parameters when calling an agent. Access these values anywhere in your agent (tools, system prompts, hooks) using `get_run_context().current_span().input`:
```python
from timbal import Agent, Tool
from timbal.state import get_run_context
def get_user_info():
span = get_run_context().current_span()
user_id = span.input.get("user_id")
role = span.input.get("role")
return f"User {user_id} with role {role}"
agent = Agent(
name="user_agent",
model="openai/gpt-4o-mini",
tools=[Tool(handler=get_user_info)]
)
# Pass custom inputs
response = await agent(
prompt="Who am I?",
user_id="123",
role="admin"
).collect()
````
The tool can access `user_id` and `role` from the input parameters. Input parameters work with both `.collect()` and streaming.
For more information about accessing input parameters and using the run context, see the [Context & State Management](/core-concepts/context) page.
### Overriding Model Configuration
What if you want to change the model, `max_tokens`, or thinking config for each run? Instead of creating multiple agents, you can pass these as input parameters. This is useful for A/B testing different models, adjusting token limits per request, or dynamically selecting models based on task complexity.
```python theme={"dark"}
# Agent with default values
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini", # Default model
max_tokens=1024 # Default max_tokens
)
# Override model: was "openai/gpt-4o-mini", now "anthropic/claude-sonnet-4-6"
response = await agent(
prompt="What is the capital of Germany?",
model="anthropic/claude-sonnet-4-6"
).collect()
# Override max_tokens: was 1024, now 2048
async for event in agent(
prompt="What is the capital of Germany?",
max_tokens=2048
):
print(event)
# Override thinking via provider_params at call time (provider-specific format).
# At construction time, use model_params instead — same keys, same shape.
# For OpenAI: pass "reasoning" key
response = await agent(
prompt="Solve this complex problem",
model="openai/gpt-5.5",
provider_params={"reasoning": {"effort": "high", "summary": "auto"}}
).collect()
# For Anthropic: pass "thinking" key
response = await agent(
prompt="Solve this complex problem",
model="anthropic/claude-sonnet-4-6",
provider_params={"thinking": {"type": "enabled", "budget_tokens": 10000}}
).collect()
```
The parameter names `model`, `max_tokens`, and `provider_params` are reserved and will affect model configuration when passed as input. These parameters will not be available as regular input to your agent. If you need to pass custom data without changing the actual model configuration, use different parameter names (e.g., `data_model` instead of `model` if you want to pass a data model name).
## Output
Calling `.collect()` returns an `OutputEvent` containing the agent's response. Access the `Message` via the `.output` property:
Learn more about events in [Events & Streaming](/core-concepts/events).
```python theme={"dark"}
result = await agent(prompt="What's 2+2?").collect()
# result is an OutputEvent
print(result.output)
# Message(role=assistant, content=[TextContent(type='text', text='2 + 2 = 4.')])
# You can access content directly
print(result.output.content[0].text)
# "2 + 2 = 4."
# However, the best way to get text is using collect_text()
print(result.output.collect_text())
# "2 + 2 = 4."
```
**Important:** When using models with thinking enabled, the content array structure changes:
* `content[0]` will contain the thinking/reasoning content
* `content[1]` will contain the actual text response
Directly accessing `result.output.content[0].text` may return thinking content instead of the response text. Always use `collect_text()` to reliably extract the text response, regardless of whether thinking is enabled.
## Messages
Messages are the structured data format that agents use to communicate. They contain a role and content, with automatic handling of different content types and provider compatibility.
```python theme={"dark"}
from timbal.types.message import Message
```
Messages contain a **role** and **content**:
**Role Types:**
* **user** - Messages from the user
* **assistant** - Messages from the AI agent
* **system** - System instructions and context
* **tool** - Tool execution results
**Content Types:**
* **TextContent** - Plain text messages
* **FileContent** - Files like PDFs, images, documents
* **ToolUseContent** - Function calls to tools
* **ToolResultContent** - Results from tool executions
Messages can contain different types of content - text, files, tool calls, and tool results. The framework automatically handles complex content structures:
```python theme={"dark"}
from timbal.types.content import FileContent, TextContent
from timbal.types.file import File
# Message with text and file
mixed_message = Message(
role="user",
content=[
TextContent(text="Analyze this document:"),
FileContent(file=File.validate("report.pdf"))
]
)
```
The same message above can be created easily using `Message.validate()`:
```python theme={"dark"}
from timbal.types.file import File
message = Message.validate([
"Summarize this document:",
File.validate("quarterly_report.pdf")
])
```
## Files
Agents can process files directly through the message content system. The framework automatically handles file reading, content extraction, and formatting for the AI model.
```python theme={"dark"}
from timbal.types.file import File
```
The framework supports common document and media formats:
* **Text files** (.txt, .md) - Direct content inclusion
* **PDFs** (.pdf) - Text extraction with structure preservation
* **Images** (.png, .jpg, .gif) - Visual analysis through vision-capable models
* **Spreadsheets** (.xlsx, .csv) - Structured data representation
* **Documents** (.docx) - Text and formatting extraction
Files are automatically converted to Timbal `File` objects using `File.validate()`:
```python theme={"dark"}
file = File.validate("quarterly_report.pdf")
```
# MCP Servers
Source: https://docs.timbal.ai/agents/mcp
Connect any Model Context Protocol server to your agents and use its tools like native Timbal tools
## What is MCP?
[Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) is an open standard that enables communication between AI models and external tools and services. Thousands of MCP servers exist for filesystems, databases, browsers, SaaS APIs, and more.
Timbal agents can connect to any MCP server with `MCPServer`. The server's tools are discovered at runtime and exposed to the LLM exactly like native tools — schemas, streaming, tracing, and error handling included.
This page covers **consuming MCP servers from your agents**. For the reverse direction — connecting your editor to the Timbal platform's MCP server — see [MCP Integration](/mcp-integration).
## Basic Usage
Add an `MCPServer` to the agent's tools list. Two transports are supported:
```python theme={"dark"}
from timbal import Agent
from timbal.core import MCPServer
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tools=[
# stdio: spawn a local server process
MCPServer(
name="filesystem",
transport="stdio",
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "."],
),
# http: connect to a remote server (streamable HTTP)
MCPServer(
name="timbal",
transport="http",
url="https://api.timbal.ai/mcp",
),
],
)
```
That's it — before each LLM call, the agent lists the server's tools and offers them to the model alongside any other tools. When the model calls one, the arguments are forwarded to the server and the result comes back as a tool result.
With `name=` set, a tool declared as `list_files` on the `filesystem` server appears to the LLM as `filesystem__list_files`. The bare name is still used on the wire.
## Configuration
| Field | Transport | Description |
| ----------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | both | Optional identifier. When set, tools are exposed as `{name}__{tool}` so multiple servers don't collide in the agent's flat tool registry. Recommended whenever you attach more than one server (and required by the [codegen CLI](#codegen-cli)). |
| `transport` | — | `"stdio"` or `"http"` (required) |
| `command` | stdio | Executable to spawn (e.g. `"npx"`, `"uvx"`, `"python"`) |
| `args` | stdio | Command arguments |
| `env` | stdio | Environment variables for the spawned process |
| `url` | http | Server URL |
| `headers` | http | HTTP headers sent with every request |
### Authentication
For servers that require auth, pass headers (http) or environment variables (stdio). Read secrets from the environment instead of hardcoding them:
```python theme={"dark"}
import os
timbal_mcp = MCPServer(
name="timbal",
transport="http",
url="https://api.timbal.ai/mcp",
headers={"Authorization": f"Bearer {os.environ['TIMBAL_API_KEY']}"},
)
github_mcp = MCPServer(
name="github",
transport="stdio",
command="npx",
args=["-y", "@modelcontextprotocol/server-github"],
env={"GITHUB_PERSONAL_ACCESS_TOKEN": os.environ["GITHUB_TOKEN"]},
)
```
## How Results Are Handled
MCP tool results are converted so the LLM always receives something useful:
* **Text content** becomes a plain string (or a list of strings for multiple blocks)
* **Images, audio, and binary resources** become Timbal [`File`](/core-concepts/runnables#files)s, forwarded to the model as file content — vision models can see returned images directly
* **Structured content** is returned as-is when the server sends no text representation
* **Errors** (`isError`) raise inside the tool call, so the model receives a proper error tool result and can self-correct
## Connection Lifecycle
Connections are **lazy**: nothing is spawned or contacted until the agent first needs the server's tools. The tool list is fetched once and cached for the life of the connection.
Close the connection when you're done:
```python theme={"dark"}
server = MCPServer(transport="stdio", command="npx", args=["-y", "@modelcontextprotocol/server-filesystem", "."])
agent = Agent(name="my_agent", model="openai/gpt-4o-mini", tools=[server])
result = await agent(prompt="List the files in the current directory").collect()
await server.close()
```
For long-running processes (servers, bots), create the `MCPServer` once and reuse it across runs — reconnecting per request adds latency, especially for stdio servers that spawn a subprocess.
## Dynamic Resolution
`MCPServer` is a [`ToolSet`](/agents/dynamic#dynamic-tools): tools are resolved at runtime, not import time. This means the available tools always reflect what the server currently offers, and you can mix MCP servers freely with functions, built-in tools, and other agents in the same `tools` list.
## Codegen CLI
The [codegen CLI](https://github.com/timbal-ai/timbal/tree/main/python/timbal/codegen) can add MCP servers to an agent's source file:
```bash theme={"dark"}
# stdio server
python -m timbal.codegen add-mcp --name fs \
--command npx --args '["-y", "@modelcontextprotocol/server-filesystem", "."]'
# http server — $VARS are emitted as os.environ lookups, never hardcoded
python -m timbal.codegen add-mcp --name timbal \
--url https://api.timbal.ai/mcp \
--headers '{"Authorization": "Bearer $TIMBAL_API_KEY"}'
# import a standard mcpServers JSON config (claude-desktop / cursor style)
python -m timbal.codegen add-mcp --from-json @mcp.json
```
Re-running `add-mcp` with the same `--name` replaces the server spec. Remove a server with `remove-tool --name ` — the assignment and import are cleaned up automatically.
# Memory
Source: https://docs.timbal.ai/agents/memory
Understand and manage how Agent memory works
Memory in Timbal agents enables multi-turn conversations and context persistence across agent interactions. **Agents automatically maintain conversation history without requiring additional configuration**.
## How Memory Works
Timbal implements memory through its [tracing system](/core-concepts/tracing), which captures conversation history during agent execution. When an agent runs, it automatically resolves memory from previous interactions to maintain conversational context.
### Automatic Memory Resolution
During each agent execution:
1. The agent checks for previous conversation context
2. If found, it retrieves conversation history from the tracing data
3. Previous messages are automatically included in the current conversation
4. The agent processes the new input with full conversation context
This happens transparently - agents receive conversation memory without any code changes.
### Storage Options
Memory storage depends on your deployment environment:
* **Timbal Platform**: Conversation history is automatically persisted with high availability and cross-instance sharing
* **Local Development**: Uses in-memory storage that's fast but cleared on restart
## Nested Agent Memory
When an agent is used as a tool of another agent, the **child gets an isolated context** — it does not inherit the parent's conversation history. The parent only sees what the child returns as its tool result. That keeps specialist agents focused and prevents parent history from bloating every nested call.
```python theme={"dark"}
from timbal import Agent
billing_agent = Agent(
name="billing_agent",
model="anthropic/claude-haiku-4-5",
# Fresh context per call — only the tool prompt from the parent, not the full chat
)
support_agent = Agent(
name="support_agent",
model="anthropic/claude-sonnet-4-6",
tools=[billing_agent], # add more specialist agents as needed
)
```
To hand the child specific context, put it in the tool call (the parent's prompt / args). To share state across turns of the *parent*, use the parent's own memory via `parent_id` session chaining — see [Rewind](#rewind) and [Tracing](/core-concepts/tracing).
## Configuration
Memory is automatically configured based on your deployment:
* **Platform Deployment**: No configuration needed
* **Self-Hosted**: Uses in-memory storage by default, with platform integration available
Memory works automatically. For deeper understanding of the underlying mechanisms, see [Tracing](/core-concepts/tracing) and [Context](/core-concepts/context).
## Rewind
Timbal supports conversation branching, allowing you to rewind to any previous point and explore alternative conversation paths.
### How Branching Works
Each agent interaction creates a unique run with its own context. You can branch from any previous run by referencing its `run_id`, creating independent conversation paths that diverge from that point.
```python theme={"dark"}
from timbal import Agent
from timbal.state import RunContext, set_run_context
agent = Agent(name="example", model="openai/gpt-4o-mini")
# Main conversation
step1 = await agent(prompt="Hello").collect()
step2 = await agent(prompt="My name is David").collect()
step3 = await agent(prompt="What's my name?").collect()
# Output: "Your name is David"
# Branch from step 1 (before name was shared)
context = RunContext(parent_id=step1.run_id)
set_run_context(context)
branch = await agent(prompt="What's my name?").collect()
# Output: "I don't know your name"
```
This creates branching conversations:
```
"Hello"
├── "My name is David" → "What's my name?" → "David"
└── "What's my name?" → "I don't know"
```
### Common Use Cases
* **A/B Testing**: Compare different conversation strategies
* **Error Recovery**: Return to a state before an error occurred
* **Debugging**: Isolate specific conversation states for testing
* **Exploration**: Test "what if" scenarios without affecting the main conversation
Each branch maintains independent memory from the branching point. Learn more about the underlying mechanisms in [Context](/core-concepts/context).
## Keeping memory within the context window
For long-running conversations, memory can grow large enough to exceed the model's context window. Timbal has two complementary layers:
1. **[Tool result offloading](/agents/memory-compaction#tool-result-offloading)** — oversized tool results are spilled to a store *when produced*, so they never dominate every later LLM call. Lossless; the model pages content back via `read_tool_result`.
2. **[Memory compaction](/agents/memory-compaction)** — strategies (keep last N turns, shrink old tool results, LLM summarize) that fire when context utilization crosses `memory_compaction_ratio` (default 75%).
Most long-running agents want both: offloading prevents the bloat at the source; compaction handles everything else. See [Memory Compaction](/agents/memory-compaction) for the full reference.
# Memory Compaction
Source: https://docs.timbal.ai/agents/memory-compaction
Keep agent memory within context window limits with tool result offloading and built-in compaction strategies
As conversations grow longer, the accumulated message history can exceed a model's context window. Timbal keeps memory bounded with two complementary layers, both configured on the agent and applied without changing how you call it:
1. **[Tool result offloading](#tool-result-offloading)** — oversized tool results are moved out of the window *the moment they are produced*, losslessly: the payload goes to a store, and the model keeps a preview plus a handle it can page back on demand.
2. **[Compaction strategies](#how-compaction-works)** — when context utilization crosses a threshold, the history already inside the window is trimmed or condensed (drop old tool results, keep the last N turns, summarize with an LLM).
Most long-running agents want both: offloading prevents the bloat at the source; compaction handles everything else.
## Tool Result Offloading
A single tool call can return hundreds of kilobytes — a big file read, a verbose log, a large API response. Because tool results persist in conversation memory, an oversized result is re-sent on **every subsequent LLM call**, paying its token cost for the rest of the run.
Tool result offloading intercepts a result when it is produced. If it exceeds a size threshold, the full payload is persisted to an offload store and the model sees a short placeholder instead:
```python theme={"dark"}
from timbal import Agent
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tools=[search_docs, run_query],
tool_result_limit=20_000, # offload results over 20,000 chars
)
```
Results under the threshold pass through untouched. Oversized ones are replaced with a placeholder like:
```text theme={"dark"}
[Tool result offloaded: 184,203 chars from 'search_docs'. The full content was saved and
can be read with read_tool_result(handle="06a7.../t1") — page with offset/limit or filter
with pattern.]
Shape: {"results": list[92], "total": int}
Preview (first 1,000 of 184,203 chars):
...
```
The reduction happens **once**, before the result ever enters memory, the serialized trace, or a provider request:
* **Lossless** — unlike truncation or summarization, the full payload stays retrievable. The model decides whether it needs line 500 of that output.
* **Prompt-cache friendly** — history stays append-only. The oversized payload never occupies a cached prefix that would later be rewritten (rewriting old messages invalidates provider prompt caches from that point on).
* **Persistent** — the placeholder is what gets traced and what seeds the next turn's memory. The reduction is never recomputed per request.
### Configuration
`tool_result_limit` accepts an int (threshold shorthand) or a `ToolResultLimit`:
```python theme={"dark"}
from timbal.core import LocalOffloadStore, Spill, ToolResultLimit, Truncate
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tool_result_limit=ToolResultLimit(
threshold=20_000, # chars of text content (default 20,000)
action=Spill(preview_chars=1_000), # what to do when the threshold is reached
store=LocalOffloadStore(), # where spilled payloads live
),
)
```
**`Spill`** (default) — persist the full payload, keep a preview + handle inline. Lossless. If the store write fails, `fallback` (default `Truncate()`) applies so an oversized result never slips through:
```python theme={"dark"}
Spill(preview_chars=1_000, fallback=Truncate(max_chars=2_000))
```
**`Truncate`** — clamp to a character budget. Lossy, zero-cost, no store needed:
```python theme={"dark"}
Truncate(strategy="head") # keep the start — headers, schemas
Truncate(strategy="tail") # keep the end — build/test output where errors land last
Truncate(strategy="head_tail") # keep both ends, elide the middle (default)
```
### Per-tool overrides
`Tool(result_limit=...)` overrides the agent default. `None` exempts a tool entirely:
```python theme={"dark"}
from timbal.core import Tool, ToolResultLimit, Truncate
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tool_result_limit=20_000, # agent-wide default: spill over 20k
tools=[
Tool(name="run_tests", handler=run_tests,
result_limit=ToolResultLimit(threshold=8_000, action=Truncate(strategy="tail"))),
Tool(name="load_policy", handler=load_policy, result_limit=None), # never reduced
],
)
```
Some results are never reduced, regardless of configuration:
* **Error results** — the model needs the full error to recover.
* **Pinned tools** (`pin_result=True`) — durable context like [skill](/agents/skills) documentation. See [Pinned results](#pinned-results).
* **`read_tool_result`'s own output** — it is bounded by construction.
### Reading content back
When offloading is active, the agent auto-registers a `read_tool_result` tool:
```python theme={"dark"}
read_tool_result(handle, offset=0, limit=200, pattern=None)
```
* Line-numbered paging via `offset`/`limit` (hard-capped at 500 lines / 50,000 chars per call, so a read can never blow the window back up).
* `pattern` filters to lines containing a **literal substring** (not a regex).
* Unknown or expired handles return a clean tool error the model can react to.
The model uses it on its own — the placeholder tells it how.
### The offload store
Spilled payloads go through a narrow protocol, so the backend is swappable:
```python theme={"dark"}
class OffloadStore(Protocol):
async def write(self, key: str, data: bytes) -> str: # returns a handle
async def read(self, handle: str) -> bytes:
```
The default `LocalOffloadStore` writes one file per result under `~/.timbal/offload/` (owner-only permissions; reads are hardened against path traversal and symlink escapes). Handles are store-relative keys, not absolute paths, so a different backend can resolve the same handle in another process.
Payloads are kept **forever by default** — deleting on run end would break a resumed or chained session that still holds handles. Bound disk usage with opt-in age-based pruning:
```python theme={"dark"}
from datetime import timedelta
from timbal.core import LocalOffloadStore, ToolResultLimit
store = LocalOffloadStore(cleanup_after=timedelta(days=7))
agent = Agent(..., tool_result_limit=ToolResultLimit(store=store))
```
Pruning runs off the hot path and never fails a run.
## How Compaction Works
Compaction is triggered automatically when context-window utilization exceeds `memory_compaction_ratio` (default `0.75`, i.e. 75%), at two points:
* **Turn start** — before the first LLM call of a turn, utilization is estimated from the **previous run's** token usage. The configured compactors are applied to the resolved memory before it reaches the LLM.
* **Mid-loop** — between iterations *within* a turn (after each tool round), utilization is read from the **previous LLM call's** reported usage. This bounds a single turn that makes many or large tool calls instead of letting it grow until it overflows. Mid-loop compaction always protects the most recent, still-unconsumed assistant tool batch (the results the next model call must read), so it never sends the agent back to re-plan the same step.
Both use the same strategies and the same ratio; you don't configure them separately.
```python theme={"dark"}
from timbal import Agent
from timbal.core.memory_compaction import keep_last_n_turns
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=keep_last_n_turns(10),
memory_compaction_ratio=0.75, # trigger when previous run used >75% of context
)
```
Set `memory_compaction_ratio=0.0` to always compact, or `1.0` to effectively disable auto-triggering.
Offloading and compaction compose cleanly: offloaded placeholders are small, so they push utilization down and compaction triggers later or never; `compact_tool_results` keeps offloaded placeholders intact by default so their handles stay dereferenceable; and `summarize` writes its canonical record to the same store, readable back through the same `read_tool_result` tool.
## Built-in Strategies
Import strategies from `timbal.core.memory_compaction`.
### `keep_last_n_turns(n)`
Keeps only the last `n` user/assistant turn pairs. Structure-aware: never leaves orphaned tool calls or results.
```python theme={"dark"}
from timbal.core.memory_compaction import keep_last_n_turns
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=keep_last_n_turns(5),
)
```
### `keep_last_n_messages(n)`
Keeps only the last `n` messages regardless of role. Also structure-aware.
```python theme={"dark"}
from timbal.core.memory_compaction import keep_last_n_messages
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=keep_last_n_messages(20),
)
```
### `compact_tool_results(...)`
Reduces the size of tool call history. Useful when tools return large payloads that are no longer needed verbatim.
```python theme={"dark"}
from timbal.core.memory_compaction import compact_tool_results
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=compact_tool_results(
keep_last_n=2, # keep last 2 tool call pairs intact
threshold=10, # only apply when memory exceeds 10 messages
replacement="[Tool '{tool_name}' result truncated ({result_length} chars)]",
),
)
```
**`replacement` controls what happens to compacted tool results:**
* `None` (default) — drop tool results and their corresponding `tool_use` entries entirely. Assistant messages that become empty are also dropped.
* `str` — replace each result with a template string. Supported placeholders: `{tool_name}`, `{call_id}`, `{result_length}`, and `{handle}` (the offload handle when the result was [offloaded](#tool-result-offloading), empty otherwise).
* `callable(tool_name, call_id, result_text) -> str` — call a function per result and use the return value as the replacement.
**`keep_offloaded`** (default `True`) — results already [offloaded at production time](#tool-result-offloading) are kept intact: they are small placeholders whose handle keeps the full payload reachable. Set `False` to compact them like any other result.
```python theme={"dark"}
# Drop all tool results
compact_tool_results()
# Replace with a short summary string
compact_tool_results(replacement="[{tool_name}: {result_length} chars]")
# Custom replacement logic
def shorten(tool_name, call_id, result_text):
return f"[{tool_name}]: {result_text[:100]}..."
compact_tool_results(replacement=shorten)
```
### `summarize(...)`
Summarizes old messages into a single context message using an LLM call. Uses **incremental summarization**: on subsequent runs, only the new overflow messages are sent to the summarizer, which updates the existing summary rather than regenerating it from scratch.
System messages are always preserved and never included in summarization.
```python theme={"dark"}
from timbal.core.memory_compaction import summarize
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=summarize(
threshold=20, # summarize when non-system messages exceed 20
model="openai/gpt-4o-mini", # model used for summarization (defaults to agent's model)
keep_last_n=4, # keep last 4 messages unsummarized
max_summary_tokens=500,
),
)
```
Use a smaller, cheaper model for summarization to reduce cost. For example, `model="openai/gpt-5.4-nano"` works well for summarization tasks.
The summary message is **sectioned**, and everything except the LLM summary itself is assembled mechanically — never paraphrased by the summarizer:
* **Verbatim User Messages** — the user's own words from the summarized region, carried forward verbatim across passes (`preserve_user_messages=True`, default). Summaries that drop or mischaracterize user instructions are the most damaging compaction failure, so the user's words are never trusted to the LLM. Budgeted by `max_verbatim_chars` (default 10,000; oldest entries dropped first).
* **Compacted Transcripts** — when an offload store is available, the full untruncated text of every summarized region is persisted and its handle listed, readable via `read_tool_result` (`canonical_record=True`, default). Summarization becomes recoverable instead of destructive. The store is shared automatically from the agent's [`tool_result_limit`](#tool-result-offloading), or passed explicitly via `store=`.
* **Note** — conservative continuation guidance: the model is told to verify intent against the user's verbatim words rather than acting on the summary alone.
* **Rehydrated Context** — output of an optional `rehydrate=` callable (sync or async, returning `str`, `list[str]`, or `None`), re-run on **every** compaction pass. Use it to re-inject working state that must survive summarization — the active plan, recently edited files:
```python theme={"dark"}
from pathlib import Path
from timbal.core.memory_compaction import summarize
memory_compaction=summarize(
threshold=20,
rehydrate=lambda: Path("PLAN.md").read_text(),
)
```
## Pinned results
Some tool results are durable context the model must keep referencing — for example loaded
[skill](/agents/skills) documentation. Those results are **pinned**: every compaction strategy
preserves them verbatim (and never orphans their paired tool call), regardless of `keep_last_n`,
turn windows, or drop/replacement mode. For `keep_last_n_messages` / `keep_last_n_turns` the
effective behavior is "last N **plus** pinned"; for `summarize`, pinned results are kept verbatim
and never fed to the summarizer (like system messages). Offloading skips pinned results too.
You opt a tool in declaratively with `pin_result=True`:
```python theme={"dark"}
from timbal.core.tool import Tool
tool = Tool(
name="load_policy",
handler=load_policy,
pin_result=True, # results survive compaction for the life of the conversation
)
```
The built-in `read_skill` tool sets this automatically, so skill guidance never gets compacted
away. Pinning is durable across pause/resume — the flag is persisted with the trace.
## Composing Strategies
Pass a list of compactors to apply them in order. Each compactor receives the output of the previous one.
```python theme={"dark"}
from timbal.core.memory_compaction import compact_tool_results, keep_last_n_turns
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
memory_compaction=[
compact_tool_results(keep_last_n=2), # first: shrink tool results
keep_last_n_turns(10), # then: trim to last 10 turns
],
)
```
## Observability
When compaction fires, the agent span records a `compaction` key in its metadata:
```json theme={"dark"}
{
"triggered": true,
"utilization": 0.91,
"steps": [
{ "compactor": "compact_tool_results", "before": 42, "after": 28 },
{ "compactor": "keep_last_n_turns", "before": 28, "after": 12 }
],
"passes": 1
}
```
* `utilization` — the context window fraction that triggered the most recent pass (`null` when `memory_compaction_ratio=0.0`).
* `steps` — one entry per compactor with message counts before and after (for the most recent pass).
* `passes` — how many times compaction fired on this span (turn start plus each mid-loop pass).
Every offload is recorded separately, in an `offload` key:
```json theme={"dark"}
{
"offload": [
{ "tool": "search_docs", "call_id": "t1", "original_chars": 184203,
"action": "spill", "handle": "06a7.../t1" }
]
}
```
`action` is `"spill"`, `"truncate"`, or `"truncate_fallback"` (a spill that degraded because the store was unavailable). The handle also persists on the tool result itself (`ToolResultContent.offload_handle`), surviving trace serialization and reload.
This data is visible in the Timbal platform trace viewer alongside the other span metadata.
# Skills
Source: https://docs.timbal.ai/agents/skills
Extend Agent capabilities through dynamic discovery of knowledge and tools
## What are Skills?
Skills provide **domain-specific knowledge and tools** that agents can selectively activate based on user requests.
Each skill is a module with its own documentation and tools that become accessible only when the skill is activated.
This is an extension of [Anthropic's Agent Skills](https://claude.com/blog/skills)
## How Skills Work
### Structure
Each skill is a directory containing:
* `SKILL.md` (required): Knowledge provided by the Skill
* `tools/` (optional): Folder for python files defining instances of skill-specific tools
* Supporting Files (optional): Additional documentation (must be referenced in `SKILL.md`)
Example structure:
```
skills/
└── payment_processing/
├── SKILL.md
├── fraud_detection.md
├── ...
└── tools/
├── process_refund.py
└── check_status.py
└── ...
```
### Loading Behavior
1. **Discovery**: The agent receives a list of available skills with their names and descriptions
2. **Activation**: When relevant to a user query, the agent loads the `SKILL.md` file and its associated tools
3. **Persistence**: Once loaded, skills remain available throughout the conversation
This lazy-loading approach keeps the agent's context efficient while ensuring specialized knowledge is available when needed.
**Skills, compaction, and offloading.** A skill's documentation reaches the model as the result of the `read_skill` tool call. Those results are **pinned**, so [memory compaction](/agents/memory-compaction) never drops or truncates them, and [tool result offloading](/agents/memory-compaction#tool-result-offloading) never spills them — the guidance stays available for as long as the skill is active, even on long runs where the rest of the history is compacted. This keeps an agent's tools and the instructions for using them from drifting apart.
## Using Skills
### Step 1: Enable Skills in Your Agent
Create a skills directory and configure your agent to use it:
```python highlight=6 theme={"dark"}
from timbal import Agent
agent = Agent(
name="my_agent",
model="anthropic/claude-sonnet-4-6",
skills_path="./skills"
)
```
The agent can now discover skills from this directory.
### Step 2: Create the SKILL.md File
Every skill requires a `SKILL.md` file with YAML frontmatter defining its name and description:
```markdown skills/payment_processing/SKILL.md theme={"dark"}
---
name: payment_processing
description: Complete payment processing including payments, refunds, and fraud detection
---
## Overview
There are different operations for e-commerce orders.
### Payment Policy
- Payments must be made within 30 days of order placement
- See `fraud_detection.md` for security guidelines
### Usage Guidelines
Always verify the order exists before processing payments or refunds.
### Escalation Process
If you cannot resolve an issue, escalate to the customer support team.
```
**Important**:\
The `name` and `description` fields are **required** in the YAML frontmatter. The agent uses these fields to decide when to activate the skill\
The `name` field must match the skill's directory name
### Step 3: Add Tools (Optional)
Skills can provide specialized tools that only become available when the skill is used.
```python skills/payment_processing/tools/process_refund.py theme={"dark"}
from timbal import Tool
async def process_refund(order_id: str, amount: float, reason: str) -> str:
"""Process a customer refund."""
# Your refund logic here
return f"Refund of ${amount} processed for order {order_id}"
# Create the tool instance
process_refund_tool = Tool(
name="process_refund",
description="Process a refund for a customer order",
handler=process_refund
)
```
### Step 4: Add Supporting Documentation (Optional)
For complex skills, include additional reference files and link to them in `SKILL.md`.
In the previous example, the `fraud_detection.md` file will only be read when the agent determines it is needed.
## Selecting Which Skills to Load
By default, every skill under `skills_path` is loaded into the agent. When a single directory is shared across multiple agents — each needing a different subset — use `skills_include` (whitelist) or `skills_exclude` (blacklist) to filter by directory name.
### Whitelist
Load only the listed skills:
```python theme={"dark"}
agent = Agent(
name="payments_agent",
model="anthropic/claude-sonnet-4-6",
skills_path="./skills",
skills_include=["payment_processing", "inventory_lookup"],
)
```
Unknown names raise `ValueError` — typos fail loudly instead of silently loading nothing.
### Blacklist
Load every skill except the listed ones:
```python theme={"dark"}
agent = Agent(
name="support_agent",
model="anthropic/claude-sonnet-4-6",
skills_path="./skills",
skills_exclude=["experimental_billing"],
)
```
Unknown names in `skills_exclude` are silently ignored.
`skills_include` and `skills_exclude` are mutually exclusive. Both require `skills_path` to be set.
### Explicit Selection Without `skills_path`
For full control, pass `Skill` instances directly via `tools=[...]`. This bypasses `skills_path` entirely and is useful when skills live in unrelated locations or when an agent only needs one or two:
```python theme={"dark"}
from timbal import Agent
from timbal.core.skill import Skill
agent = Agent(
name="refunds_agent",
model="anthropic/claude-sonnet-4-6",
tools=[Skill(path="./skills/payment_processing")],
)
```
## Best Practices
* Each skill should have a single, well-defined purpose.
* Write clear descriptions. Agents use it to decide when to activate a Skill.
* Use descriptive names for the skill.
## Key Takeaways
* **Skills are modular packages** that combine knowledge and tools for specific domains
* **Dynamic loading** keeps agents efficient by loading only relevant capabilities
* **SKILL.md YAML frontmatter** defines the skill's identity and purpose
* **Tools and supporting documentation** are loaded on demand when the skill is activated
# Structured Output
Source: https://docs.timbal.ai/agents/structured-output
Use Pydantic models to structure and validate your agent outputs
Instead of answering in natural language, the agent can return a specific output structure using Pydantic models.
## Why use structured output?
Using structured output ensures your agents return predictable, type-safe responses:
* Automatically validates and parses responses
* Guarantees required fields are present and correctly typed
* Reduces ambiguity compared to natural-language answers
By defining schemas upfront, `result.output` is always a validated Pydantic instance.
## Usage
1. Define the desired output schema using a [Pydantic model](https://docs.pydantic.dev/latest/api/base_model/).
2. Pass that model to the agent's `output_model` parameter.
3. Call the agent — `result.output` is the validated model, not a `Message`.
Using `tools` and `output_model` together may produce unexpected behavior — the agent can call tools instead of returning structured output. Prefer one or the other unless you have a deliberate multi-step flow.
```python theme={"dark"}
from pydantic import BaseModel, Field
from timbal import Agent
class Ingredient(BaseModel):
name: str = Field(..., description="Name of the ingredient")
amount: float = Field(..., description="Amount of the ingredient")
unit: str = Field(..., description="Unit of the ingredient")
class Recipe(BaseModel):
ingredients: list[Ingredient] = Field(..., description="List of ingredients")
total_time: float = Field(..., description="Total time in minutes")
steps: list[str] = Field(..., description="Steps to follow")
agent = Agent(
name="chef_agent",
model="openai/gpt-4o-mini",
output_model=Recipe,
)
result = await agent(prompt="I want to make lasagna").collect()
recipe: Recipe = result.output
print(recipe.total_time) # float
print(recipe.ingredients[0].name)
```
`agent.return_model` reflects the expected output type — `Message` by default, your Pydantic class when `output_model` is set.
## Validation and retries
If the model returns JSON that fails validation, the agent feeds the error back and retries (up to `max_iter` times) before raising. Field descriptions in your Pydantic model are sent to the LLM and strongly influence output quality — use them.
Pass extra validation context with `output_model_context` when your model's validators need runtime data:
```python theme={"dark"}
agent = Agent(
name="chef_agent",
model="openai/gpt-4o-mini",
output_model=Recipe,
output_model_context={"locale": "en-US"},
)
```
## Output example
`result.output` is a `Recipe` instance. Serialized:
```json theme={"dark"}
{
"ingredients": [
{ "name": "Lasagna sheets", "amount": 12, "unit": "pieces" },
{ "name": "Ground beef", "amount": 500, "unit": "grams" },
{ "name": "Ricotta cheese", "amount": 400, "unit": "grams" },
{ "name": "Mozzarella", "amount": 200, "unit": "grams" },
{ "name": "Marinara sauce", "amount": 600, "unit": "ml" },
{ "name": "Parmesan", "amount": 50, "unit": "grams" }
],
"total_time": 90,
"steps": [
"Brown the ground beef and season with salt and pepper.",
"Spread a thin layer of marinara in a baking dish.",
"Layer lasagna sheets, ricotta, beef, mozzarella, and sauce. Repeat.",
"Finish with sauce and parmesan on top.",
"Bake at 180°C for 45 minutes until bubbling.",
"Rest 10 minutes before serving."
]
}
```
# Adding Tools
Source: https://docs.timbal.ai/agents/tools
Give your agents the ability to interact with the outside world through automatic schema generation, parameter validation, and concurrent execution
## Basic Usage
Add functions directly as tools:
```python theme={"dark"}
def get_weather(location: str) -> str:
"""Get weather for a location."""
return f"Weather in {location}: 22°C"
agent = Agent(
name="weather_agent",
model="openai/gpt-4o-mini",
tools=[get_weather] # Function automatically becomes a tool
)
```
## Tool Configuration
Use the `Tool` type for custom descriptions and parameter control. The `handler` parameter is required and must be the function:
```python theme={"dark"}
from timbal import Tool
agent = Agent(
name="weather_agent",
model="openai/gpt-4o-mini",
tools=[Tool(handler=get_weather)]
)
```
### Custom Description
If the function name or docstring doesn't clearly describe what the tool does, add a custom `description`. This is the description the LLM sees about the tool:
```python theme={"dark"}
Tool(
handler=get_weather,
description="Get current weather information for any location"
)
```
### Parameter Visibility
When a tool has many parameters, reduce tokens and avoid overwhelming the LLM by showing only required parameters:
```python theme={"dark"}
Tool(
handler=search,
schema_params_mode="required" # Only show required params (default: "all")
)
```
You can also include or exclude specific parameters to fine-tune what the LLM sees:
```python theme={"dark"}
# Include extra parameters even in "required" mode
Tool(
handler=search_function,
schema_params_mode="required",
schema_include_params=["model"] # Include this optional param
)
# Exclude sensitive or internal parameters
Tool(
handler=api_function,
schema_exclude_params=["api_key", "debug_mode"] # Hide from LLM
)
```
### Default Parameters
Set default values that are automatically applied when the tool is called. These values are used when not specified by the LLM:
```python theme={"dark"}
Tool(
handler=database_query,
default_params={
"timeout": 30
}
)
```
You can also use functions or environment variables as default parameters:
```python theme={"dark"}
def get_current_timestamp():
return datetime.now().isoformat()
def process_data(data: str, timestamp: str | None = None, user_id: str = "default"):
return f"Processed {data} at {timestamp} by {user_id}"
# Tool with dynamic default parameters
tool = Tool(
name="data_processor",
handler=process_data,
default_params={
"timestamp": get_current_timestamp,
"user_id": "system_user"
}
)
```
## Built-in Tools
Timbal provides built-in tools for common use cases. These tools are ready to use and don't require implementing handlers.
### WebSearch
`WebSearch` enables agents to search the web.
`WebSearch` only works with **OpenAI** and **Anthropic** models. It's a specification-only tool that defines the tool schema for the LLM but doesn't contain executable logic. The actual web search execution is handled by the model provider. Other model providers are not supported.
```python theme={"dark"}
from timbal import Agent
from timbal.tools import WebSearch
agent = Agent(
name="research_agent",
model="openai/gpt-4o-mini",
tools=[
WebSearch(
allowed_domains=["wikipedia.org", "github.com"], # Restrict to specific domains
user_location={
"type": "approximate",
"country": "US",
"city": "New York"
} # Localize results
)
]
)
```
Available options:
* `allowed_domains`: List of domains to restrict searches to
* `blocked_domains`: List of domains to exclude (Anthropic only)
* `user_location`: Dictionary with location info to localize search results. Must include `type` field. Common values are `"approximate"` or `"exact"`, but valid values depend on the provider.
#### Multiple Instances of the Same Tool
If you need multiple instances of the same built-in tool with different configurations, they will have the same name by default, which causes conflicts. Set unique names and descriptions for each instance:
```python theme={"dark"}
general_search = WebSearch()
general_search.name = "general_search"
general_search.description = "Search the web for general information."
domain_search = WebSearch(allowed_domains=["example.com"])
domain_search.name = "domain_search"
domain_search.description = "Search only within example.com domain."
agent = Agent(
name="research_agent",
model="anthropic/claude-opus-4-6",
tools=[general_search, domain_search]
)
```
## Agent as a Tool
You can use an Agent as a tool within another Agent, enabling hierarchical agent compositions where specialized agents handle specific tasks.
Since `Agent` instances can be treated as `Tool` objects, they inherit the same parameter control configurations available to regular tools.
```python theme={"dark"}
from timbal import Agent
def calculate_cost(days: int, hotel_rate: float, flights: float = 0) -> float:
"""Calculate total trip cost."""
return (days * hotel_rate) + flights
# Pricing specialist agent
pricing_agent = Agent(
name="pricing_calculator",
description="Calculate travel costs", # Tool description for other agents
model="openai/gpt-4o-mini",
system_prompt="Answer only with the price. Any text",
tools=[calculate_cost]
)
# Main travel agent
travel_agent = Agent(
name="travel_assistant",
model="openai/gpt-4o",
system_prompt="Help plan trips and provide travel advice.",
tools=[pricing_agent]
)
```
**Automatic Nesting:** When an agent is used as a tool within another agent (as shown above), it's automatically nested for proper tracing and context management. However, if you use an agent inside a `pre_hook` or `post_hook`, you must manually call `.nest()`. See [Using Agents in Hooks](/core-concepts/context#using-agents-in-hooks) for details.
### Pinning results against compaction
Some tool results are durable context the model must keep referencing (loaded policy docs, skill guidance). Opt in with `pin_result=True` — [memory compaction](/agents/memory-compaction) never drops or truncates those results, and [tool result offloading](/agents/memory-compaction#tool-result-offloading) skips them too:
```python theme={"dark"}
Tool(
handler=load_policy,
pin_result=True, # survive compaction for the life of the conversation
)
```
The built-in `read_skill` tool sets this automatically. See [Pinned results](/agents/memory-compaction#pinned-results).
### Limiting oversized results
When a tool can return huge payloads, set a per-tool size limit. Results over the threshold are spilled to a store (or truncated) **once, when produced**, before they enter memory:
```python theme={"dark"}
from timbal.core import ToolResultLimit, Truncate
Tool(
handler=run_tests,
result_limit=ToolResultLimit(threshold=8_000, action=Truncate(strategy="tail")),
)
Tool(
handler=load_policy,
result_limit=None, # exempt — never reduced
)
```
An agent-wide default is `Agent(tool_result_limit=...)`. Precedence: `Tool.result_limit` > agent default. See [Tool Result Offloading](/agents/memory-compaction#tool-result-offloading).
## Commands
Register a `command` on any tool, agent, or workflow to let users invoke it directly with a `/` prefix — bypassing the LLM for that turn:
```python theme={"dark"}
Tool(handler=get_weather, command="weather")
# User sends: /weather Barcelona
# → runs get_weather(location="Barcelona") directly
```
See [Commands](/agents/commands) for argument parsing, nested agents, and workflows.
## MCP Servers
Connect any [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) server and use its tools like native ones:
```python theme={"dark"}
from timbal import Agent
from timbal.core import MCPServer
agent = Agent(
name="my_agent",
model="openai/gpt-4o-mini",
tools=[
MCPServer(
name="filesystem",
transport="stdio",
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "."],
),
],
)
```
The server's tools are discovered at runtime and offered to the LLM alongside your other tools. See [MCP Servers](/agents/mcp) for transports, authentication, result handling, and connection lifecycle.
## Dynamic Tools
When tool availability depends on runtime conditions (user role, permissions, input parameters), you need dynamic tool resolution.
Instead of exposing all tools to the agent, use Timbal's `ToolSet` class to resolve which tools are available at runtime.
For example, with a role-based ToolSet:
* **Role: admin** → Available tools: `delete_user`, `modify_permissions`, `view_profile`
* **Role: user** → Available tools: `view_profile`
The ToolSet checks the role at runtime and returns only the relevant tools. The agent only has access to the tools returned by the ToolSet for that role. Users without admin role won't see admin tools, preventing the agent from attempting unauthorized actions.
See [Dynamic Agents](/agents/dynamic#dynamic-tools) for implementation details and examples.
## Summary
* **Automatic Introspection**: Function signatures become tool schemas automatically
* **Enhanced Validation**: Pydantic-based parameter validation
* **Execution Flexibility**: Support for all Python callable types
* **Better Configuration**: Fine-grained parameter control
* **Performance**: Concurrent execution and optimized patterns
* **Robustness**: Improved error handling and tracing
* **Tool Sets**: Dynamic tool resolution
* **MCP Servers**: Any Model Context Protocol server as a tool source — see [MCP Servers](/agents/mcp)
* **Commands**: Slash-command shortcuts for direct tool invocation — see [Commands](/agents/commands)
* **Result limits & pinning**: Keep oversized or durable tool output under control — see [Memory Compaction](/agents/memory-compaction)
For more advanced patterns, see [Dynamic Agents](/agents/dynamic) and explore the built-in tools in the Timbal library.
# Chat Completions
Source: https://docs.timbal.ai/api-reference/ace/chat-completions
POST /ace/{ace_uid}/v1/chat/completions
OpenAI-compatible chat completions endpoint for an ACE.
# Create from Agent
Source: https://docs.timbal.ai/api-reference/ace/create-from-workforce
POST /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/ace
Create an empty ACE (ready, empty playbook) and link it to a workforce component. Does not trigger playbook generation.
# Cancel Job
Source: https://docs.timbal.ai/api-reference/ace/jobs/cancel
POST /ace/{ace_uid}/jobs/{job_uid}/cancel
Cancel an active ACE job.
# Enqueue Job
Source: https://docs.timbal.ai/api-reference/ace/jobs/create
POST /ace/{ace_uid}/jobs
Enqueue a job against an ACE. Today: `regenerate` (playbook [re-]generation from the linked workforce's agent code file; the current playbook stays usable until the job commits a new one). The kind vocabulary grows server-side — future kinds include usage-driven adaptation.
# Get Job
Source: https://docs.timbal.ai/api-reference/ace/jobs/get
GET /ace/{ace_uid}/jobs/{job_uid}
Get a single ACE job by id.
# List Jobs
Source: https://docs.timbal.ai/api-reference/ace/jobs/list
GET /ace/{ace_uid}/jobs
List jobs for an ACE, most recent first.
# Retry Job
Source: https://docs.timbal.ai/api-reference/ace/jobs/retry
POST /ace/{ace_uid}/jobs/{job_uid}/retry
Retry a failed, cancelled, or errored ACE job.
# Link to Agent
Source: https://docs.timbal.ai/api-reference/ace/link
PUT /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/ace
Link an existing ACE to a workforce component.
# List Context Variables
Source: https://docs.timbal.ai/api-reference/ace/list-context-vars
GET /ace/{ace_uid}/vars
List the variables declared on an ACE.
# List Policies
Source: https://docs.timbal.ai/api-reference/ace/list-policies
GET /ace/{ace_uid}/policies
List the policies declared on an ACE.
# Unlink from Agent
Source: https://docs.timbal.ai/api-reference/ace/unlink
DELETE /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/ace
Detach the ACE linked to a workforce component.
# Attach Channel to Alarm
Source: https://docs.timbal.ai/api-reference/alarms/attach-channel
POST /orgs/{org_id}/alarms/{alarm_id}/channels
Attach another notification channel to an alarm, so one condition reaches several destinations (page Slack and email the on-call, say).
Accepts the same channel shape as alarm creation: `{ "id": }` to reuse an existing channel, or inline `type` + `config` to create one. Inline channels identical to an existing row are deduped rather than duplicated.
Idempotent — re-attaching a channel that is already linked returns `already_linked: true` and changes nothing.
Gated by the alarm, not the channel: `alarms.manage` for an org-level alarm, `projects.alarms.manage` on the owner for a project alarm. Channels themselves are org-wide, so a project-scoped caller can point their alarm at any existing channel — attaching one is a routing decision about their alarm, whereas creating or deleting the channel row needs `notification_channels.manage`.
# Create Channel
Source: https://docs.timbal.ai/api-reference/alarms/channels/create
POST /orgs/{org_id}/channels
Create a delivery channel for this org, or reuse an existing one.
Channels dedupe on `(type, config)`: posting the same Slack channel twice returns the original row with `channel_action: "reused"` rather than creating a near-duplicate. Attach the returned `id` to an alarm via `POST /orgs/{org_id}/alarms/{alarm_id}/channels`.
# Delete Channel
Source: https://docs.timbal.ai/api-reference/alarms/channels/delete
DELETE /orgs/{org_id}/channels/{channel_id}
Delete a delivery channel for the organization.
Refused with `409` while alarms still reference the channel; `force: true` deletes anyway and unlinks them. The one case `force` will not do is strip the last channel from an *enabled* alarm — that alarm would go on evaluating and recording state transitions with nowhere to send them. Attach a replacement channel or disable the alarm first.
# List Channels
Source: https://docs.timbal.ai/api-reference/alarms/channels/list
GET /orgs/{org_id}/channels
List the organization's delivery channels — the addresses alarms can notify (email inboxes, Slack channels).
Channels carry no conditions of their own. What to watch and when to fire lives on the alarm; this is only where the message goes.
# Create Alarm
Source: https://docs.timbal.ai/api-reference/alarms/create
POST /orgs/{org_id}/alarms
Create a metric alarm with a threshold over evaluation periods.
Set `denominator_metric_name` for a **rate** rather than a count: the datapoint becomes `metric_name / denominator_metric_name` and `threshold` reads as a fraction (`0.05` = 5%). Prefer this for anything that scales with traffic — a fixed error count pages at 3am once the org grows. Pair it with `min_sample_count` so a single failure in a quiet minute isn't a 100% error rate.
Both series must be binned on the same clock, since they are divided period by period. For outcome rates that means `app.run.terminal_count` — runs that *finished* in the period — and not `app.run.count`, which counts runs that *started* and includes ones still running. Mismatched pairs are rejected.
Ratio alarms also require `stat` `sum` or `count`. The stat is applied to each series and the two results divided, so only stats that add up across the minutes in a period come out as a rate — `avg` would divide two means and `min`/`max` would take their extremes from different minutes. Other stats are rejected rather than silently producing a number that isn't a rate.
A sustained incident only ever notifies once, so repeat messages mean the alarm is flapping. Three levers fix that, and which one depends on the metric: `clear_threshold` (fire high, clear low) for continuous series like rates and latencies; `datapoints_to_alarm` for spiky ones that cross the line briefly; and `missing_data: "ignore"` for sparse ones, where a minute with no traffic is absence of evidence rather than a recovery.
Use `GET /orgs/{org_id}/metrics/catalog` to see which metric names and labels this org is actually emitting. Two metrics won't be listed until you alarm on them: `org.credits.usage_pct` (percent of the billing period's prepaid budget consumed) and `org.credits.remaining` (credits left in it) are only sampled for orgs that have an enabled alarm on one, since the underlying read is the billing gate's and too heavy to run speculatively. Both are levels sampled once a minute, so use `max`, `avg` or `min` — `sum` would add one reading per minute in the period and is rejected. `org.credits.remaining` goes negative under overage.
Percent and headroom are worth having separately: 95% of a large budget can still be thousands of credits, while 50% of a small one is one bad afternoon. For burn *volume* rather than budget position, alarm on `org.credits.spend` with `sum` — that one carries `project_id` and `workforce_id` dims, so it can scope to a single project or workforce the way a budget can't.
For latency, `stat` accepts any percentile from `p0.1` to `p99.9`, in tenths. Equivalent spellings are fine (`p95.0` is accepted and comes back as `p95`), but anything finer than a tenth is rejected rather than rounded, so an alarm never silently watches a different point than the one you asked for. These are exact, computed over the individual run durations rather than interpolated from bins, which is also why they are limited to `app.run.duration_ms`, cannot be a ratio, and cannot carry `label`, `project_env_id` or `dims.deployment_id` — a run row has no such column, so those filters would silently widen the alarm to everything. Prefer a high percentile over `avg` for latency: an average hides the tail that users actually notice, and hides it harder as traffic grows.
Two different project fields, gated separately. Top-level `project_id` is ownership — who may see and edit the alarm afterwards — and setting it requires `projects.alarms.manage` on that project, while leaving it out makes an org-level alarm and requires `alarms.manage`. `dims.project_id` only narrows which metric bins are read, but it still exposes that project's numbers through the alarm's notifications, so it requires `projects.alarms.read` on the project it names.
# Delete Alarm
Source: https://docs.timbal.ai/api-reference/alarms/delete
DELETE /orgs/{org_id}/alarms/{alarm_id}
Delete a metric alarm and its channel links.
Needs `alarms.manage` for an org-level alarm, or `projects.alarms.manage` on the owning project for a project alarm.
# Detach Channel from Alarm
Source: https://docs.timbal.ai/api-reference/alarms/detach-channel
DELETE /orgs/{org_id}/alarms/{alarm_id}/channels/{channel_id}
Detach a channel from an alarm. The `NotificationChannels` row itself is left alone — other alarms and notification rules may still be using it.
Removing the **last** channel from an enabled alarm is rejected: it would keep evaluating and recording transitions while notifying nobody, which looks healthy from the outside. To swap a sole channel, attach the replacement first; to silence an alarm, `PATCH` it with `enabled: false`.
# Get Alarm
Source: https://docs.timbal.ai/api-reference/alarms/get
GET /orgs/{org_id}/alarms/{alarm_id}
One alarm's configuration, current state and linked channels.
Same shape as an entry in `GET /orgs/{org_id}/alarms`, for refreshing a detail view without refetching the whole list.
Needs `alarms.read` for an org-level alarm, or `projects.alarms.read` on the owning project for a project alarm.
# Alarms Health
Source: https://docs.timbal.ai/api-reference/alarms/health
GET /orgs/{org_id}/alarms/health
For every enabled alarm, when the metric series behind it last produced a datapoint.
Answers the one question an alarm's own `state` cannot: is it still watching anything? Under the default `not_breaching` policy an alarm whose metric stopped being emitted — a deleted workforce, a disabled trace filter, a renamed metric — sits at `ok` indefinitely and looks identical to one that is genuinely healthy.
`stale` marks silence lasting ten evaluation windows (minimum one hour), which is long enough that a quiet-but-live series won't trip it. A brand-new alarm reads as stale until its first datapoint, which is accurate: it cannot fire yet.
For a ratio alarm, `last_datapoint_at` is the **older** of the two series, since a period only yields a datapoint where both sides have one. A live numerator over a dead denominator is therefore reported as stale — every period resolves to a missing ratio, so the alarm is blind despite one of its series still flowing. `denominator_last_datapoint_at` tells you which side went quiet.
Covers the same alarms `GET /orgs/{org_id}/alarms` returns for you, so `healthy` and `stale_count` describe your visible subset — a project-scoped caller gets their project's blind spots, not the org's.
# Alarm History
Source: https://docs.timbal.ai/api-reference/alarms/history
GET /orgs/{org_id}/alarms/{alarm_id}/history
State transition history for an alarm, newest first.
Needs `alarms.read` for an org-level alarm, or `projects.alarms.read` on the owning project for a project alarm.
# List Alarms
Source: https://docs.timbal.ai/api-reference/alarms/list
GET /orgs/{org_id}/alarms
The metric alarms you can see in this org, with current state and channels.
Filtered per alarm, not per request: org-level alarms (credits, org-wide spend) need `alarms.read`, and an alarm tagged to a project needs `projects.alarms.read` on that project. Holding one and not the other returns the matching subset rather than a `403`, so a role scoped to a single project sees exactly that project's alarms.
# Metrics Catalog
Source: https://docs.timbal.ai/api-reference/alarms/metrics/catalog
GET /orgs/{org_id}/metrics/catalog
Metric names and label values actually recorded for this org in the recent past. Use it to populate a metric picker: `GET /orgs/{org_id}/metrics` requires a name, and alarms reference names that only exist once something has emitted them.
`origin` separates the two kinds: `system` metrics (`app.*`, `deployment.*`, `org.*`) are emitted by the platform and have no configuration, while `custom` ones come from a metric definition — see `definition_ids` for the config behind them.
This reports what is *flowing*, not what is configured, so the two can disagree in both directions: a definition that has never matched a span will not appear, and a deleted definition's series stays until its bins age out.
Needs `analytics.read` for the org-wide catalog. Passing `project_id` narrows it to series carrying that project's dimension and accepts `projects.alarms.read` there instead, so a project-scoped role gets a working metric picker. That view omits series with no project dimension, `org.credits.*` among them — which are org-level metrics that caller cannot alarm on anyway.
# Create Metric Definition
Source: https://docs.timbal.ai/api-reference/alarms/metrics/definitions/create
POST /orgs/{org_id}/metrics/definitions
Define a metric of your own, emitted as runs land. Once it exists it behaves like any platform metric: readable through `GET /orgs/{org_id}/metrics`, listed in the catalog with `origin: custom`, and alarmable with no extra setup.
The definition is a span predicate over each completed run's trace. Span paths are `{workforce}.{step}` and matching is on the **leaf**: use `llm` or `get_address`, not the full path. Predicate fields AND together and all are optional — omitting every one matches every non-root span.
`metric_name` is forced under the reserved `trace.` prefix so a definition cannot shadow a platform series. Set `label_source` to `span_name` to break the metric out per step, then point an alarm at a single step with `dims.label`.
Definitions are **not retroactive**: matching happens as runs arrive, so a new definition only sees traces written after it. Dry-run it against stored traces first with `POST /orgs/{org_id}/metrics/definitions/preview`. Note also that only sum/count/avg/min/max are available on the result — percentiles are not derivable from metric bins.
Set `project_id` to scope the definition to one project, which also makes it editable by holders of `projects.alarms.manage` there. Leaving it out defines an org-wide metric that runs against every project's traces, and needs `alarms.manage`.
# Delete Metric Definition
Source: https://docs.timbal.ai/api-reference/alarms/metrics/definitions/delete
DELETE /orgs/{org_id}/metrics/definitions/{definition_id}
Stop emitting a custom metric. Takes effect within one flush interval (~10s).
Bins the definition already wrote are kept — they are real observations — and age out with normal metric retention, so the series stays readable and keeps appearing in the catalog for a while with no definition behind it. Alarms pointing at the metric are left alone and will fall to `insufficient_data` once the series stops.
Needs `alarms.manage` for an org-wide definition, or `projects.alarms.manage` on the owning project for a project one.
# List Metric Definitions
Source: https://docs.timbal.ai/api-reference/alarms/metrics/definitions/list
GET /orgs/{org_id}/metrics/definitions
The org's custom metric definitions and their current predicates.
`project_id` filters to what is *in effect* for that project, which includes org-wide definitions (those with no `project_id`) since they run against that project's runs too.
This lists configuration, not data. A definition that has never matched a span appears here but not in `GET /orgs/{org_id}/metrics/catalog`.
Filtered per definition the same way `GET /orgs/{org_id}/alarms` is: org-wide definitions need `alarms.read`, project ones need `projects.alarms.read` on the owner. So a project-scoped caller sees their own definitions but not the org-wide ones that also run against their runs, even with `project_id` set.
# Preview Metric Definition
Source: https://docs.timbal.ai/api-reference/alarms/metrics/definitions/preview
POST /orgs/{org_id}/metrics/definitions/preview
Run a candidate predicate against recent stored traces without saving it, and see what it would have recorded.
Definitions are not retroactive, so a saved predicate that matches nothing looks identical to one that matches nothing *yet*. This answers that before you commit: which spans matched, what values they'd contribute, and how many distinct labels the metric would carry.
Samples the newest runs (20 by default, 50 max) — enough to tell a working predicate from a broken one, not a measurement of the true rate. Pass `workforce_id` to make the sample representative of the agent you're actually matching. This is the only endpoint that reads stored traces, which is why it is capped; do not poll it.
Because it returns trace content, `project_id` doubles as the permission target: omit it and the sample spans the whole org, which needs `alarms.manage`; set it and the sample is capped to that project, which needs `projects.alarms.manage` there. A caller scoped to one project must therefore pass it.
# Update Metric Definition
Source: https://docs.timbal.ai/api-reference/alarms/metrics/definitions/update
PATCH /orgs/{org_id}/metrics/definitions/{definition_id}
Only the name and the enabled flag are mutable. The predicate and destination metric are fixed at creation: changing them would splice two different meanings into one series with nothing marking the seam, so a changed definition should be a new definition.
Disabling stops new observations within one flush interval (~10s). Bins already written stay and age out with normal metric retention.
The owning project is not mutable either, so the permission needed here is fixed: `alarms.manage` for an org-wide definition, `projects.alarms.manage` on the owner for a project one.
# Query Metric
Source: https://docs.timbal.ai/api-reference/alarms/metrics/query
GET /orgs/{org_id}/metrics
Time-bucketed count/sum/avg/min/max for one metric, with optional dimension filters.
Needs `analytics.read` for an unrestricted query. Passing `project_id` also accepts `projects.alarms.read` on that project, so a project-scoped role can read the series behind its own alarms.
# Update Alarm
Source: https://docs.timbal.ai/api-reference/alarms/update
PATCH /orgs/{org_id}/alarms/{alarm_id}
Update alarm thresholds, evaluation settings, or enabled state.
`clear_threshold` is the one field where `null` differs from omitting the key: send `null` to drop the hysteresis, omit it to leave it as is. It is validated against the resulting `threshold` and `comparison`, so patching either side of the pair alone can't invert them.
`metric_name` and `denominator_metric_name` are not patchable: changing what an alarm watches would leave its state and history describing a different question, so define a new alarm instead.
`enabled: true` requires at least one attached channel. Channels may be detached freely while an alarm is disabled, so this is what stops that from re-enabling an alarm that evaluates and records transitions with nowhere to notify.
The alarm's owning project is not patchable either, so the permission needed here is fixed for the life of the alarm: `alarms.manage` when it is org-level, `projects.alarms.manage` on the owner when it is not.
# Authentication
Source: https://docs.timbal.ai/api-reference/authentication
How to authenticate to the Timbal API
The Timbal API uses API keys to authenticate requests. You can create and manage your API keys by navigating to **Profile → API Keys** in the dashboard.
API keys are **personal to your user account** and inherit all your permissions across organizations. Each key carries the same privileges as your user account, so be sure to keep them secure! Do not share your API keys in publicly accessible areas such as GitHub, client-side code, and so forth.
**Important:** For security, we do not store your API keys after creation. Make sure to copy and store them in a secure location immediately—you will not be able to recover them later.
When using the Timbal framework, you can set these environment variables for automatic authentication:
```bash theme={"dark"}
export TIMBAL_API_KEY="your_api_key_here"
export TIMBAL_API_HOST="https://api.timbal.ai"
```
The framework will automatically use these values, eliminating the need to pass credentials in your code.
All API requests must be made over HTTPS. Calls made over plain HTTP will fail. API requests without authentication will also fail.
# Browse Columns
Source: https://docs.timbal.ai/api-reference/connectors/browse/columns
GET /orgs/{org_id}/connectors/{connector_id}/browse/columns
List columns for a remote table.
# Browse Connections
Source: https://docs.timbal.ai/api-reference/connectors/browse/connections
GET /orgs/{org_id}/connectors/{connector_id}/browse/connections
List remote connections available to a connector.
# List Filesystem Entries
Source: https://docs.timbal.ai/api-reference/connectors/browse/filesystems/entries
GET /orgs/{org_id}/connectors/{connector_id}/browse/filesystems/entries
List one directory under a connector filesystem root, with size/mtime per entry, optional glob filter, sort, and bounded recursion (depth). Requires fs:browse (0.1.27+).
# List Filesystems
Source: https://docs.timbal.ai/api-reference/connectors/browse/filesystems/list
GET /orgs/{org_id}/connectors/{connector_id}/browse/filesystems
List filesystem roots configured on a connector (names only). Requires a connector advertising fs:browse (0.1.27+).
# Search Filesystem
Source: https://docs.timbal.ai/api-reference/connectors/browse/filesystems/search
GET /orgs/{org_id}/connectors/{connector_id}/browse/filesystems/search
Search file contents under a connector filesystem root with a line regex — bounded on files, matches, bytes, and time; binaries skipped. Runs entirely in-process on the node. Requires ConnectorsManage (reads file contents, same bar as ad-hoc SQL) and fs:browse (0.1.27+).
# Summarize Filesystem
Source: https://docs.timbal.ai/api-reference/connectors/browse/filesystems/summary
GET /orgs/{org_id}/connectors/{connector_id}/browse/filesystems/summary
Summarize a directory tree on a connector filesystem root without listing it: file/dir counts, total bytes, largest files, extension histogram. The probe to run before a recursive copy. Requires fs:browse (0.1.27+).
# Browse Table
Source: https://docs.timbal.ai/api-reference/connectors/browse/table
GET /orgs/{org_id}/connectors/{connector_id}/browse/table
Full introspection of one remote table: rich columns (defaults, identity, comments, precision), indexes, foreign keys, check constraints, table comment and size. Requires a connector advertising browse:describe (0.1.25+).
# Browse Tables
Source: https://docs.timbal.ai/api-reference/connectors/browse/tables
GET /orgs/{org_id}/connectors/{connector_id}/browse/tables
List remote tables available to a connector.
# Create Enrollment Token
Source: https://docs.timbal.ai/api-reference/connectors/enrollment-tokens/create
POST /orgs/{org_id}/connectors/enrollment-tokens
Create a connector enrollment token.
# Copy Filesystem to Knowledge Base
Source: https://docs.timbal.ai/api-reference/connectors/filesystems/copy
POST /orgs/{org_id}/connectors/{connector_id}/filesystems/copy
Copy a file or directory tree from a connector filesystem root into a knowledge base's document store. Store-only by default (parse: true to opt in). Requires fs:copy (0.1.27+).
# Get Connector
Source: https://docs.timbal.ai/api-reference/connectors/get
GET /orgs/{org_id}/connectors/{connector_id}
Get a connector by id.
# Create Connector Job
Source: https://docs.timbal.ai/api-reference/connectors/jobs/create
POST /orgs/{org_id}/connectors/{connector_id}/jobs
Dispatch a job to a connector.
# List Connector Jobs
Source: https://docs.timbal.ai/api-reference/connectors/jobs/list
GET /orgs/{org_id}/connectors/jobs
List connector jobs for the organization.
# Stop Connector Job
Source: https://docs.timbal.ai/api-reference/connectors/jobs/stop
POST /orgs/{org_id}/connectors/{connector_id}/jobs/{job_id}/stop
Cancel a running extract job. The row is terminalized as `stopped` first
(so the run is dead even if the connector is offline or never acks), then a
`StopJob` command aborts the connector-side task best-effort. Idempotent:
stopping an already-terminal job returns its current state.
# List Connectors
Source: https://docs.timbal.ai/api-reference/connectors/list
GET /orgs/{org_id}/connectors
List connectors for the organization.
# Push Local Config
Source: https://docs.timbal.ai/api-reference/connectors/local-config
POST /orgs/{org_id}/connectors/{connector_id}/local-config
Push replacement connections/filesystems maps to a connector box.
# Query Connector
Source: https://docs.timbal.ai/api-reference/connectors/query
POST /orgs/{org_id}/connectors/{connector_id}/query
Run an ad-hoc SQL query against a connector's source and return the bounded result inline. Read-only by default (enforced node-side); write mode must also be allowed by the connection's local policy on the connector box. Requires a connector advertising query:sql (0.1.26+).
# Rebind Connector
Source: https://docs.timbal.ai/api-reference/connectors/rebind
POST /orgs/{org_id}/connectors/{connector_id}/rebind
Rebind a connector to a different knowledge base.
# Revoke Connector
Source: https://docs.timbal.ai/api-reference/connectors/revoke
POST /orgs/{org_id}/connectors/{connector_id}/revoke
Revoke a connector's credential and remove it from the fleet.
# Create Connector Sync
Source: https://docs.timbal.ai/api-reference/connectors/syncs/create
POST /orgs/{org_id}/connectors/{connector_id}/syncs
Create a connector sync configuration.
# Delete Connector Sync
Source: https://docs.timbal.ai/api-reference/connectors/syncs/delete
DELETE /orgs/{org_id}/connectors/{connector_id}/syncs/{sync_id}
Delete a connector sync.
# Get Connector Sync
Source: https://docs.timbal.ai/api-reference/connectors/syncs/get
GET /orgs/{org_id}/connectors/{connector_id}/syncs/{sync_id}
Get a connector sync by id.
# List Connector Syncs
Source: https://docs.timbal.ai/api-reference/connectors/syncs/list
GET /orgs/{org_id}/connectors/syncs
List connector syncs for the organization.
# Run Sync Now
Source: https://docs.timbal.ai/api-reference/connectors/syncs/run
POST /orgs/{org_id}/connectors/{connector_id}/syncs/{sync_id}/run
Run a connector sync immediately.
# Update Connector Sync
Source: https://docs.timbal.ai/api-reference/connectors/syncs/update
PATCH /orgs/{org_id}/connectors/{connector_id}/syncs/{sync_id}
Update a connector sync.
# Update Connector
Source: https://docs.timbal.ai/api-reference/connectors/update
POST /orgs/{org_id}/connectors/{connector_id}/update
Trigger a connector software update.
# Errors
Source: https://docs.timbal.ai/api-reference/errors
How to handle errors
The Timbal API uses conventional HTTP response codes to indicate the success or failure of an API request.
In general: Codes in the `2xx` range indicate success. Codes in the `4xx` range indicate an error that failed given the information provided (*e.g.*, a required parameter was omitted, a charge failed, etc.). Codes in the `5xx` range indicate an error with Timbal servers (these are rare).
## Rate limiting
Some endpoints enforce **per-user** rate limits. When limits apply, successful **`2xx`** responses and **`429 Too Many Requests`** may include:
| Header | Meaning |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| `RateLimit-Limit` | Maximum requests allowed in the current window. |
| `RateLimit-Remaining` | Requests left before the window resets. |
| `RateLimit-Reset` | Seconds until the window resets. |
| `Retry-After` | On **`429`**, seconds to wait before retrying (prefer this over a fixed backoff when present). |
Legacy aliases **`X-RateLimit-Limit`**, **`X-RateLimit-Remaining`**, and **`X-RateLimit-Reset`** mirror the `RateLimit-*` values.
| Code | Status | Description |
| ------------------------------ | ----------------- | --------------------------------------------------------------------------------------------------- |
| 200 | OK | Everything worked as expected. |
| 400
422 | Bad Requests | The request was unacceptable, often due to missing a required parameter. |
| 401 | Unauthorized | No valid API key provided. |
| 402 | Request Failed | The parameters were valid but the request failed. |
| 403 | Forbidden | The API key doesn’t have permissions to perform the request. |
| 404 | Not Found | The requested resource doesn’t exist. |
| 429 | Too Many Requests | Per-user rate limit exceeded. Honor **`Retry-After`** when sent; otherwise use exponential backoff. |
| 500
502
503
504 | Server Errors | Something went wrong on our end. |
# List Eval Definitions
Source: https://docs.timbal.ai/api-reference/evals/definitions
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/definitions
List eval definitions (test cases from evals/*.yaml) per workforce component. Reads the env branch's worktree by default; pass ?rev= to read from git.
# Create Eval Job
Source: https://docs.timbal.ai/api-reference/evals/jobs/create
POST /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/jobs
Enqueue a background eval job (today: generate_initial — Claude-generate an initial eval suite for a workforce component, written to the env branch's worktree).
# Get Eval Job
Source: https://docs.timbal.ai/api-reference/evals/jobs/get
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/jobs/{job_uid}
Get a single eval job by uid.
# List Eval Jobs
Source: https://docs.timbal.ai/api-reference/evals/jobs/list
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/jobs
List eval jobs (background eval generation) for an env, most recent first.
# Create Eval Run
Source: https://docs.timbal.ai/api-reference/evals/runs/create
POST /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/runs
Manually run evals for a workforce component (no deploy). Defaults to the env worktree so uncommitted evals are included; pass source=git for a clean extract.
# Delete Eval Run
Source: https://docs.timbal.ai/api-reference/evals/runs/delete
DELETE /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/runs/{eval_run_id}
Delete an eval run from the history. Running runs cannot be deleted.
# Stream Eval Run Events
Source: https://docs.timbal.ai/api-reference/evals/runs/events
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/runs/{eval_run_id}/events
Stream an in-flight eval run's JSONL events (cursor + long-poll). On `expired: true`, fall back to the single-run GET.
# Get Eval Run
Source: https://docs.timbal.ai/api-reference/evals/runs/get
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/runs/{eval_run_id}
Get a single eval run, including its logs.
# List Eval Runs
Source: https://docs.timbal.ai/api-reference/evals/runs/list
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/evals/runs
List eval runs (deploy-gate and manual executions) for an environment.
# Upload file (temporary)
Source: https://docs.timbal.ai/api-reference/files/upload
POST /files
Upload a short-lived file for temporary staging.
**Utility endpoint** — not org-scoped. Upload requires authentication; the response includes a download URL (\~24h). Anyone with the URL can download until expiry — treat it as a secret. Not for sensitive or regulated data.
Ephemeral staging only (\~24h lifecycle); there is no durable org file record behind this route.
For **durable storage, parsing, embedding, and reuse** across a knowledge base, use [Knowledge Bases → Files](/api-reference/knowledge-bases/files/list) (`POST /orgs/{org_id}/k2/{kb_id}/files`) instead.
# Introduction
Source: https://docs.timbal.ai/api-reference/introduction
Introduction to the Timbal Platform API
The Timbal API is organized around [REST](https://en.wikipedia.org/wiki/REST). Our API has predictable resource-oriented URLs, accepts JSON-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs.
Unlike traditional APIs that offer a single *test mode* or *sandbox*, the Timbal Platform empowers you with unlimited deployment flexibility through versioning.
Every resource you create automatically supports versioning, similar to Git's branching and commit system. You can create new versions of your agents, workflows, and knowledge bases, then seamlessly navigate between versions, roll back changes, or branch off in different directions.
Each version of your agents, workflows, and knowledge bases can be configured with its own environment variables, permissions, and settings—allowing you to seamlessly manage development, staging, production, or any custom configuration that suits your deployment pipeline.
## Organization scoping
Most resources in Timbal are scoped to an **organization**, meaning their API path is prefixed with `/orgs/{org_id}`. Resources are then nested under their parent in a standard RESTful hierarchy. For example:
```
/orgs/{org_id}/projects
/orgs/{org_id}/projects/{project_id}/envs
/orgs/{org_id}/projects/{project_id}/envs/{env_id}/deploy
/orgs/{org_id}/k2/{kb_id}/query
```
Not all endpoints are org-scoped. Some are user-scoped and live under `/me` (e.g. listing organizations or managing API keys). **`POST /files`** uploads a short-lived binary to object storage (Bearer auth, same as the rest of the API); it does **not** include `org_id` in the path—org context comes from the authenticated key.
HTTP verbs follow REST conventions:
| Verb | Usage |
| -------- | --------------------------- |
| `GET` | Retrieve a resource or list |
| `POST` | Create a resource |
| `PATCH` | Partially update a resource |
| `DELETE` | Delete a resource |
# Create Backup
Source: https://docs.timbal.ai/api-reference/knowledge-bases/backups/create
POST /orgs/{org_id}/k2/{kb_id}/backups
Create an on-demand backup of a knowledge base.
On-demand backup of a knowledge base.
# Get Backup
Source: https://docs.timbal.ai/api-reference/knowledge-bases/backups/get
GET /orgs/{org_id}/k2/{kb_id}/backups/{backup_id}
Get a single backup.
# List Backups
Source: https://docs.timbal.ai/api-reference/knowledge-bases/backups/list
GET /orgs/{org_id}/k2/{kb_id}/backups
List backups for a knowledge base.
# Create Knowledge Base
Source: https://docs.timbal.ai/api-reference/knowledge-bases/create
POST /orgs/{org_id}/k2
Create a new knowledge base
# Delete Knowledge Base
Source: https://docs.timbal.ai/api-reference/knowledge-bases/delete
DELETE /orgs/{org_id}/k2/{kb_id}
Delete a knowledge base
# Add File
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/add
POST /orgs/{org_id}/k2/{kb_id}/files
Add a file to a knowledge base by upload or URL.
File names must be unique within a directory. Uploading `report.pdf` to `docs/` when it already exists returns `409`.
Folders and files share the same namespace: you cannot upload a file whose name matches an existing folder in the same directory.
# Delete Chunk
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/chunks/delete
DELETE /orgs/{org_id}/k2/{kb_id}/files/{file_id}/chunks/{chunk_uid}
Delete a chunk from a knowledge base file.
# Get Chunk
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/chunks/get
GET /orgs/{org_id}/k2/{kb_id}/files/{file_id}/chunks/{chunk_uid}
Get a single chunk by uid.
# Insert Chunk
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/chunks/insert
POST /orgs/{org_id}/k2/{kb_id}/files/{file_id}/chunks
Insert a new chunk into a knowledge base file.
# List Chunks
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/chunks/list
GET /orgs/{org_id}/k2/{kb_id}/files/{file_id}/chunks
List chunks for a knowledge base file in document order.
# Update Chunk
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/chunks/update
PATCH /orgs/{org_id}/k2/{kb_id}/files/{file_id}/chunks/{chunk_uid}
Edit a chunk's content, embedding, or page range.
# Create Directory
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/create-dir
POST /orgs/{org_id}/k2/{kb_id}/directories
Create a virtual directory in the knowledge base
Folders appear in file listings with `content_type` set to `application/vnd.timbal.k2-directory`. Creating a folder that already exists is idempotent and returns `200`.
Folders and files share the same namespace: creating folder `reports` under `docs/` returns `409` if a file named reports already exists there.
# Delete File
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/delete
DELETE /orgs/{org_id}/k2/{kb_id}/files/{file_id}
Delete a file from a knowledge base
Use this endpoint to delete a **regular file** by `file_id`, and also to remove a **directory** from the knowledge base.
For a folder, use the same **`id`** returned for that row in **`GET /orgs/{org_id}/k2/{kb_id}/files`** (or the `placeholder_file_id` from create-directory). Deleting that listing row removes the folder from file listings.
# Get File
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/get
GET /orgs/{org_id}/k2/{kb_id}/files/{file_id}
Get a file with its parsings and embeddings metadata
Listings from `GET /orgs/{org_id}/k2/{kb_id}/files` include **folder rows** (`content_type` `application/vnd.timbal.k2-directory`). This **get file** endpoint is only for **regular uploaded files** (parsings, embeddings, etc.).
If you pass a **directory row’s `id`** from the list endpoint, the API responds with **`404` Not Found** — virtual directories do not have a full file payload here.
# List Files
Source: https://docs.timbal.ai/api-reference/knowledge-bases/files/list
GET /orgs/{org_id}/k2/{kb_id}/files
List files and folders in a knowledge base directory.
Both regular files and folders are returned. Folder entries have `content_type` set to `application/vnd.timbal.k2-directory` and `content_length` of `0`. Use the optional directory query parameter to scope results to a specific folder (e.g. `?directory=docs`).
# Get Knowledge Base
Source: https://docs.timbal.ai/api-reference/knowledge-bases/get
GET /orgs/{org_id}/k2/{kb_id}
Get knowledge base details
# List Knowledge Bases
Source: https://docs.timbal.ai/api-reference/knowledge-bases/list
GET /orgs/{org_id}/k2
List knowledge bases in an organization
# Create Policy Assignment
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/assignments/create
POST /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}/assignments
Bind an access-shaping rule to a role.
# Delete Policy Assignment
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/assignments/delete
DELETE /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}/assignments/{assignment_id}
Revoke a role binding on an access-shaping rule.
# Get Policy Assignment
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/assignments/get
GET /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}/assignments/{assignment_id}
Fetch a single role binding for an access-shaping rule.
# List Policy Assignments
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/assignments/list
GET /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}/assignments
List role bindings for an access-shaping rule.
# Create Policy
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/create
POST /orgs/{org_id}/k2/{kb_id}/policies
Author a new access-shaping rule on a knowledge base.
# Delete Policy
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/delete
DELETE /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}
Soft-delete an access-shaping rule on a knowledge base.
# Get Policy
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/get
GET /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}
Fetch a single access-shaping rule by id.
# Set Governance Mode
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/governance-mode
PUT /orgs/{org_id}/k2/{kb_id}/policies/governance-mode
Set query-time governance mode for a knowledge base.
# List Policies
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/list
GET /orgs/{org_id}/k2/{kb_id}/policies
List access-shaping rules authored on a knowledge base.
# Update Policy
Source: https://docs.timbal.ai/api-reference/knowledge-bases/policies/update
PATCH /orgs/{org_id}/k2/{kb_id}/policies/{policy_id}
Update an access-shaping rule on a knowledge base.
# Query
Source: https://docs.timbal.ai/api-reference/knowledge-bases/query
POST /orgs/{org_id}/k2/{kb_id}/query
Execute a SQL query against the knowledge base
# Restore
Source: https://docs.timbal.ai/api-reference/knowledge-bases/restores/create
POST /orgs/{org_id}/k2/{kb_id}/restores
Restore a knowledge base from a completed backup.
# Get Restore
Source: https://docs.timbal.ai/api-reference/knowledge-bases/restores/get
GET /orgs/{org_id}/k2/{kb_id}/restores/{restore_id}
Get a single restore.
# List Restores
Source: https://docs.timbal.ai/api-reference/knowledge-bases/restores/list
GET /orgs/{org_id}/k2/{kb_id}/restores
List restore attempts for a knowledge base.
# Get Schema
Source: https://docs.timbal.ai/api-reference/knowledge-bases/schema
GET /orgs/{org_id}/k2/{kb_id}/schema
Get the schema for a knowledge base
# Edit Knowledge Base
Source: https://docs.timbal.ai/api-reference/knowledge-bases/update
PATCH /orgs/{org_id}/k2/{kb_id}
Update a knowledge base.
# Upload Data
Source: https://docs.timbal.ai/api-reference/knowledge-bases/upload
POST /orgs/{org_id}/k2/{kb_id}/upload
Upload a data file into a knowledge base.
# List Organizations
Source: https://docs.timbal.ai/api-reference/me/list-orgs
GET /me/orgs
List organizations the authenticated user belongs to
# Delete Profile Photo
Source: https://docs.timbal.ai/api-reference/me/photo/delete
DELETE /me/photo
Clear the authenticated user's profile picture.
# Upload Profile Photo
Source: https://docs.timbal.ai/api-reference/me/photo/upload
PUT /me/photo
Upload and set the authenticated user's profile picture.
# Create API Token
Source: https://docs.timbal.ai/api-reference/me/tokens/create
POST /me/tokens
Create an API credential for the authenticated user.
# List API Tokens
Source: https://docs.timbal.ai/api-reference/me/tokens/list
GET /me/tokens
List the authenticated user's API credentials.
# Revoke API Token
Source: https://docs.timbal.ai/api-reference/me/tokens/revoke
DELETE /me/tokens/{token_id}
Revoke an API credential owned by the authenticated user.
# Update Profile
Source: https://docs.timbal.ai/api-reference/me/update
PATCH /me
Patch the authenticated user's profile.
# Org Analytics — Costs
Source: https://docs.timbal.ai/api-reference/orgs/analytics/costs
GET /orgs/{org_id}/analytics/costs
Time-binned credits and USD spend across an org, grouped by product surface.
# Org Analytics — Usage
Source: https://docs.timbal.ai/api-reference/orgs/analytics/usage
GET /orgs/{org_id}/analytics/usage
Time-binned run counts, user counts, and duration percentiles across an org.
# Org Analytics — Users
Source: https://docs.timbal.ai/api-reference/orgs/analytics/users
GET /orgs/{org_id}/analytics/users
Per-user run counts and full-ledger spend aggregates across an org.
# Get Credit Budgets
Source: https://docs.timbal.ai/api-reference/orgs/billing/budgets/get
GET /orgs/{org_id}/billing/budgets
Read credit budgets for the organization.
# Patch Credit Budgets
Source: https://docs.timbal.ai/api-reference/orgs/billing/budgets/patch
PATCH /orgs/{org_id}/billing/budgets
Merge partial credit budget changes for the organization.
# Replace Credit Budgets
Source: https://docs.timbal.ai/api-reference/orgs/billing/budgets/replace
PUT /orgs/{org_id}/billing/budgets
Replace the organization's full credit budget configuration.
# Get Compute Billing
Source: https://docs.timbal.ai/api-reference/orgs/billing/compute/get
GET /orgs/{org_id}/billing/compute
Get purchased compute machines and the org's compute pool usage.
# Set Compute Billing
Source: https://docs.timbal.ai/api-reference/orgs/billing/compute/put
PUT /orgs/{org_id}/billing/compute
Set the compute machine add-ons purchased by the organization.
# Update Overage Billing
Source: https://docs.timbal.ai/api-reference/orgs/billing/overage
POST /orgs/{org_id}/billing/overage
Update overage billing settings for the organization.
`enabled` turns usage-based overage on or off. `cap_credits` limits how many overage credits can accrue; omit it or set it to `null` for no cap.
To read current-period overage usage (invoiced vs pending credits), see [Get Overage Summary](/api-reference/orgs/billing/overage-summary).
# Get Overage Summary
Source: https://docs.timbal.ai/api-reference/orgs/billing/overage-summary
GET /orgs/{org_id}/billing/overage
Get overage credit usage for the current billing period.
# List Account Contacts
Source: https://docs.timbal.ai/api-reference/orgs/contacts/list
GET /orgs/{org_id}/contacts
Account-side contacts assigned to an organization.
Account-side contacts assigned to an organization.
# Sign Content URL
Source: https://docs.timbal.ai/api-reference/orgs/content/sign
POST /orgs/{org_id}/content/sign
Refresh a signed URL for stored content.
Refresh a signed URL for stored content. Pass a content URL previously returned by the API (signed or unsigned), or a bare object key. The server resolves it back to a known object, re-checks your access, and mints a fresh URL — useful when a cached signed URL has expired.
# Create Organization
Source: https://docs.timbal.ai/api-reference/orgs/create
POST /orgs
Create a new organization.
Self-serve callers may own at most one organization. Attempting to create a second org returns `429`; contact sales for additional orgs.
# List Credit Grants
Source: https://docs.timbal.ai/api-reference/orgs/credit-grants/list
GET /orgs/{org_id}/credit-grants
Credit grants awarded to an organization, most recent first.
Credit grants awarded to an organization, most recent first.
# Create Domain
Source: https://docs.timbal.ai/api-reference/orgs/domains/create
POST /orgs/{org_id}/domains
Add a custom domain to an organization
# Delete Domain
Source: https://docs.timbal.ai/api-reference/orgs/domains/delete
DELETE /orgs/{org_id}/domains/{domain_id}
Delete a domain
# Get Domain
Source: https://docs.timbal.ai/api-reference/orgs/domains/get
GET /orgs/{org_id}/domains/{domain_id}
Get a custom domain with current DNS and certificate status.
# List Domains
Source: https://docs.timbal.ai/api-reference/orgs/domains/list
GET /orgs/{org_id}/domains
List connected domains for an organization
# List Embedding Models
Source: https://docs.timbal.ai/api-reference/orgs/embedding-models/list
GET /orgs/{org_id}/embedding-models
List all available embedding models for the organization
# Get Organization
Source: https://docs.timbal.ai/api-reference/orgs/get
GET /orgs/{org_id}
Get an organization by ID
# List IAM Actions
Source: https://docs.timbal.ai/api-reference/orgs/iam/actions/list
GET /orgs/{org_id}/iam/actions
Catalog of grantable IAM actions for the organization.
# List Users by Action
Source: https://docs.timbal.ai/api-reference/orgs/iam/actions/users
GET /orgs/{org_id}/iam/actions/{action}/users
List members who effectively hold an org-wide IAM action.
# Check IAM Permissions
Source: https://docs.timbal.ai/api-reference/orgs/iam/check
POST /orgs/{org_id}/iam/check
Simulate authorization checks against a user's effective grants.
# List Users by Resource
Source: https://docs.timbal.ai/api-reference/orgs/iam/resources/users
GET /orgs/{org_id}/iam/resources/{resource}/users
List members with effective access to a resource, per action.
# Batch Update User Roles
Source: https://docs.timbal.ai/api-reference/orgs/iam/users/batch-roles
PUT /orgs/{org_id}/iam/users/roles
Replace role attachments for multiple members in one request.
# Get User Effective Grants
Source: https://docs.timbal.ai/api-reference/orgs/iam/users/effective-grants
GET /orgs/{org_id}/iam/users/{user_id}/effective-grants
List a member's effective IAM grants in the organization.
# Create Identity Provider
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/create
POST /orgs/{org_id}/identity-providers
Create an SSO or directory identity connection for the organization.
# Default Role
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/default-role
PUT /orgs/{org_id}/identity-providers/{provider_id}/default-role
Set or clear the SSO connection's default just-in-time role.
# Delete an SSO connection (cascades to its group mappings)
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/delete
DELETE /orgs/{org_id}/identity-providers/{provider_id}
Delete an SSO identity connection.
# Get a single SSO connection (secret masked)
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/get
GET /orgs/{org_id}/identity-providers/{provider_id}
Get an SSO identity connection by id.
# Map an external IdP group to a role for this connection
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/group-mappings/create
POST /orgs/{org_id}/identity-providers/{provider_id}/group-mappings
Map an external IdP group to a role for this connection
# Delete a group-to-role mapping
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/group-mappings/delete
DELETE /orgs/{org_id}/identity-providers/{provider_id}/group-mappings/{mapping_id}
Delete a group-to-role mapping
# List external-group to role mappings for a connection
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/group-mappings/list
GET /orgs/{org_id}/identity-providers/{provider_id}/group-mappings
List external-group to role mappings for a connection
# List Identity Providers
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/list
GET /orgs/{org_id}/identity-providers
List SSO identity connections for the organization.
# Update Identity Provider
Source: https://docs.timbal.ai/api-reference/orgs/identity-providers/update
PATCH /orgs/{org_id}/identity-providers/{provider_id}
Update an SSO identity connection.
# Create Model Policy
Source: https://docs.timbal.ai/api-reference/orgs/models/policies/create
POST /orgs/{org_id}/models/policies
Create a rule restricting which LLM providers or models the organization can use.
# Delete Model Policy
Source: https://docs.timbal.ai/api-reference/orgs/models/policies/delete
DELETE /orgs/{org_id}/models/policies/{policy_id}
Delete an LLM model or provider policy rule from the organization.
# List Model Policies
Source: https://docs.timbal.ai/api-reference/orgs/models/policies/list
GET /orgs/{org_id}/models/policies
List the organization's LLM model and provider usage policies.
# Delete Organization Logo
Source: https://docs.timbal.ai/api-reference/orgs/photo/delete
DELETE /orgs/{org_id}/photo
Clear the organization's logo.
# Upload Organization Logo
Source: https://docs.timbal.ai/api-reference/orgs/photo/upload
PUT /orgs/{org_id}/photo
Upload and set the organization's logo.
# Create Organization Role
Source: https://docs.timbal.ai/api-reference/orgs/roles/create
POST /orgs/{org_id}/iam/roles
Create a custom role for the organization.
# Delete Organization Role
Source: https://docs.timbal.ai/api-reference/orgs/roles/delete
DELETE /orgs/{org_id}/iam/roles/{role_id}
Delete a custom organization role.
# Get Organization Role
Source: https://docs.timbal.ai/api-reference/orgs/roles/get
GET /orgs/{org_id}/iam/roles/{role_id}
Retrieve a role and the full set of grants it carries.
# List Organization Roles
Source: https://docs.timbal.ai/api-reference/orgs/roles/list
GET /orgs/{org_id}/iam/roles
List system and custom roles for the organization.
# Update Organization Role
Source: https://docs.timbal.ai/api-reference/orgs/roles/update
PATCH /orgs/{org_id}/iam/roles/{role_id}
Partially update a custom organization role.
# Create SCIM Token
Source: https://docs.timbal.ai/api-reference/orgs/scim-tokens/create
POST /orgs/{org_id}/scim-tokens
Create a SCIM bearer token for the organization.
# List SCIM Tokens
Source: https://docs.timbal.ai/api-reference/orgs/scim-tokens/list
GET /orgs/{org_id}/scim-tokens
List SCIM tokens for the organization.
# Revoke SCIM Token
Source: https://docs.timbal.ai/api-reference/orgs/scim-tokens/revoke
DELETE /orgs/{org_id}/scim-tokens/{token_id}
Revoke a SCIM token
# List Templates
Source: https://docs.timbal.ai/api-reference/orgs/templates/list
GET /orgs/{org_id}/templates
Public project template catalog available to this organization.
# Update Organization
Source: https://docs.timbal.ai/api-reference/orgs/update
PATCH /orgs/{org_id}
Partially update an organization
Omitted fields are left unchanged. To explicitly clear a field, pass it as `null`.
# Add User
Source: https://docs.timbal.ai/api-reference/orgs/users/add
POST /orgs/{org_id}/iam/users
Invite one or more users to an organization
# Remove User
Source: https://docs.timbal.ai/api-reference/orgs/users/delete
DELETE /orgs/{org_id}/iam/users
Remove one or more members from an organization
# List Users
Source: https://docs.timbal.ai/api-reference/orgs/users/list
GET /orgs/{org_id}/iam/users
List members of an organization.
# Update User Roles
Source: https://docs.timbal.ai/api-reference/orgs/users/update
PUT /orgs/{org_id}/iam/users/{user_id}/roles
Replace a member's role attachments.
# Pagination
Source: https://docs.timbal.ai/api-reference/pagination
How to paginate through resources
The Timbal API uses **cursor-based pagination** with page tokens for efficient navigation through large collections.
## Making Requests
**Initial request:**
```http theme={"dark"}
GET /apps
```
**Subsequent requests:**
When a response includes a `next_page_token`, use it as the `page_token` query parameter:
```http theme={"dark"}
GET /apps?page_token=eyJpZCI6IjEyMyIsInRzIjoxNjk...
```
Page tokens are opaque strings—never parse or modify them.
# Cost Analytics
Source: https://docs.timbal.ai/api-reference/projects/analytics/credits
GET /orgs/{org_id}/projects/{project_id}/analytics/credits
Time-binned credits and USD cost breakdown for a project.
Only **project admins** can call this endpoint. Each response returns at most **1440** time bins; there is no pagination. You may omit `group_by`, `from`, and/or `to` — the server applies defaults — and the JSON body includes `group_by`, `from`, and `to` so you can see the bin size and range that were actually used.
# Usage Analytics
Source: https://docs.timbal.ai/api-reference/projects/analytics/usage
GET /orgs/{org_id}/projects/{project_id}/analytics/usage
Time-binned run counts, user counts, and duration percentiles for a project.
Only **project admins** can call this endpoint. Each response returns at most **1440** time bins; there is no pagination. You may omit `group_by`, `from`, and/or `to` — the server applies defaults — and the JSON body includes `group_by`, `from`, and `to` so you can see the bin size and range that were actually used.
# User Analytics
Source: https://docs.timbal.ai/api-reference/projects/analytics/users
GET /orgs/{org_id}/projects/{project_id}/analytics/users
Per-user run counts and project-attributed spend aggregates for a project.
Only **project admins** can call this endpoint. Results are **paginated**: pass `page_token` from the previous response's `next_page_token` to fetch the next page. The response includes `from` and `to` for the window you requested.
# Get App Auth Settings
Source: https://docs.timbal.ai/api-reference/projects/auth/get
GET /orgs/{org_id}/projects/{project_id}/auth
Get app sign-in settings for a project.
# Update Auth Provider
Source: https://docs.timbal.ai/api-reference/projects/auth/providers/update
PUT /orgs/{org_id}/projects/{project_id}/auth/providers/{provider}
Enable, disable, or configure an app sign-in provider.
# Update App Auth Settings
Source: https://docs.timbal.ai/api-reference/projects/auth/update
PATCH /orgs/{org_id}/projects/{project_id}/auth
Toggle app sign-in for a project.
# Delete Channel
Source: https://docs.timbal.ai/api-reference/projects/channels/delete
DELETE /orgs/{org_id}/projects/{project_id}/channels/{workforce}/{provider}
Disconnect a messaging channel binding, discarding its stored credentials.
# Upsert Channel
Source: https://docs.timbal.ai/api-reference/projects/channels/put
PUT /orgs/{org_id}/projects/{project_id}/channels/{workforce}/{provider}
Connect or update a messaging channel binding. Upserts on (workforce, provider); `credentials` is write-only — omit to keep the stored secrets, `null` to clear them, an object to replace them.
# Create Project
Source: https://docs.timbal.ai/api-reference/projects/create
POST /orgs/{org_id}/projects
Create a new project.
# Delete Project
Source: https://docs.timbal.ai/api-reference/projects/delete
DELETE /orgs/{org_id}/projects/{project_id}
Delete a project.
# Duplicate Project
Source: https://docs.timbal.ai/api-reference/projects/duplicate
POST /orgs/{org_id}/projects/{project_id}/duplicate
Duplicate a project, copying its code and knowledge base structure.
# Create Environment
Source: https://docs.timbal.ai/api-reference/projects/environments/create
POST /orgs/{org_id}/projects/{project_id}/envs
Create a new environment for a project.
# Delete Environment
Source: https://docs.timbal.ai/api-reference/projects/environments/delete
DELETE /orgs/{org_id}/projects/{project_id}/envs/{env_id}
Delete an environment.
# Deploy
Source: https://docs.timbal.ai/api-reference/projects/environments/deploy
POST /orgs/{org_id}/projects/{project_id}/envs/{env_id}/deploy
Deploy an environment to a revision.
# List Environment Deployments
Source: https://docs.timbal.ai/api-reference/projects/environments/deployments/list
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/deployments
List deployments for an environment.
# Get Metrics
Source: https://docs.timbal.ai/api-reference/projects/environments/get-metrics
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/metrics/{metric_type}
Query runtime metrics for an environment.
# List Environments
Source: https://docs.timbal.ai/api-reference/projects/environments/list
GET /orgs/{org_id}/projects/{project_id}/envs
List environments for a project with resolved per-component deploy config.
# Get Environment Logs
Source: https://docs.timbal.ai/api-reference/projects/environments/logs
GET /orgs/{org_id}/projects/{project_id}/envs/{env_id}/logs
Fetch runtime logs for a deployed project component.
# Stop
Source: https://docs.timbal.ai/api-reference/projects/environments/stop
POST /orgs/{org_id}/projects/{project_id}/envs/{env_id}/stop
Stop a running environment.
# Update Branch
Source: https://docs.timbal.ai/api-reference/projects/environments/update-branch
PATCH /orgs/{org_id}/projects/{project_id}/envs/{env_id}/branch
Set or clear the source branch for an environment.
# Update Config
Source: https://docs.timbal.ai/api-reference/projects/environments/update-config
PATCH /orgs/{org_id}/projects/{project_id}/envs/{env_id}/config
Update deploy configuration for an environment, including per-component overrides.
# Update Domain
Source: https://docs.timbal.ai/api-reference/projects/environments/update-domain
PATCH /orgs/{org_id}/projects/{project_id}/envs/{env_id}/domain
Attach, replace, or detach a custom domain for an environment.
# Get Project
Source: https://docs.timbal.ai/api-reference/projects/get
GET /orgs/{org_id}/projects/{project_id}
Get a project by ID.
# Link Knowledge Base
Source: https://docs.timbal.ai/api-reference/projects/link-knowledge-base
PUT /orgs/{org_id}/projects/{project_id}/k2
Link a knowledge base to a project.
# List Projects
Source: https://docs.timbal.ai/api-reference/projects/list
GET /orgs/{org_id}/projects
List projects in an organization.
Organization admins will retrieve a list of all projects in the organization. Other users will retrieve a list of projects they have access to.
Use the `view` query parameter to control the response detail level. The default `list` view returns a lightweight `ProjectPreview` per project. Pass `view=full` to get the same rich `ProjectDetail` shape returned by the [Get Project](/api-reference/projects/get) endpoint — including environments, knowledge bases, integrations, and workforce.
# Get Run
Source: https://docs.timbal.ai/api-reference/projects/runs/get
GET /orgs/{org_id}/projects/{project_id}/runs/{run_id}
Get a single project run by id or idempotency key.
# List Runs
Source: https://docs.timbal.ai/api-reference/projects/runs/list
GET /orgs/{org_id}/projects/{project_id}/runs
List runs for a project.
# Add Reaction
Source: https://docs.timbal.ai/api-reference/projects/runs/reactions-add
POST /orgs/{org_id}/projects/{project_id}/runs/{run_id}/reactions
Add a reaction to a project run.
# List Reactions
Source: https://docs.timbal.ai/api-reference/projects/runs/reactions-list
GET /orgs/{org_id}/projects/{project_id}/runs/{run_id}/reactions
List reactions on a project run.
# Unlink Knowledge Base
Source: https://docs.timbal.ai/api-reference/projects/unlink-knowledge-base
DELETE /orgs/{org_id}/projects/{project_id}/k2
Unlink the knowledge base from a project.
# Update Project
Source: https://docs.timbal.ai/api-reference/projects/update
PATCH /orgs/{org_id}/projects/{project_id}
Edit project metadata.
# Batch Create Variables
Source: https://docs.timbal.ai/api-reference/projects/vars/batch-create
POST /orgs/{org_id}/projects/{project_id}/vars/batch
Batch create project variables.
# Create Variable
Source: https://docs.timbal.ai/api-reference/projects/vars/create
POST /orgs/{org_id}/projects/{project_id}/vars
Create a project variable.
# Delete Variable
Source: https://docs.timbal.ai/api-reference/projects/vars/delete
DELETE /orgs/{org_id}/projects/{project_id}/vars/{var_id}
Delete a project variable.
# Get Variable
Source: https://docs.timbal.ai/api-reference/projects/vars/get
GET /orgs/{org_id}/projects/{project_id}/vars/{var_id}
Get a project variable by ID.
# List Variables
Source: https://docs.timbal.ai/api-reference/projects/vars/list
GET /orgs/{org_id}/projects/{project_id}/vars
List variables for a project.
# Update Variable
Source: https://docs.timbal.ai/api-reference/projects/vars/update
PATCH /orgs/{org_id}/projects/{project_id}/vars/{var_id}
Update a project variable.
# Call Workforce
Source: https://docs.timbal.ai/api-reference/projects/workforce/call
POST /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/{*path}
Proxy HTTP requests to a deployed workforce component.
This route is a **gateway proxy** to a **running** workforce deployment (an agent or workflow). The platform forwards your HTTP request to the deployment; responses pass back through unchanged (aside from normal proxy behavior).
The reference below is for **POST** so the API playground and generated fields render. The same path and parameters apply to **GET**, **PUT**, **PATCH**, and **DELETE** — use the verb you need when calling the API directly.
## Supported HTTP methods
| Method | Use case |
| ------------------- | --------------------------------------------------------------- |
| **GET** | Read-only calls (e.g. health, metadata). |
| **POST** | Typical RPC-style calls (e.g. `…/run`). |
| **PUT** / **PATCH** | Idempotent or partial updates when your component exposes them. |
| **DELETE** | Deletes or teardown actions your component exposes. |
Path, query, and auth are the same for every method. The deployment must handle the method you send.
## Path and query
* **`workforce`** — Which component to hit: numeric id, manifest UUID, or name (same identifiers you use elsewhere in the project API).
* **`path`** — Suffix on the deployment server after the component segment (for example `run` or `healthcheck`). Use the path your running app defines; can be empty for the deployment root.
* **`rev`** (query, required) — Git branch the deployment is tied to (for example `main`).
## Limits
* **WebSocket upgrades are not supported** on this proxy route.
* If nothing is running for that component on `rev`, you get **404**. If the deployment returns an error upstream, you may see **502**.
# Call Workforce (DELETE)
Source: https://docs.timbal.ai/api-reference/projects/workforce/call-delete
DELETE /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/{*path}
Proxy HTTP requests to a deployed workforce component.
This route is a **gateway proxy** to a **running** workforce deployment (an agent or workflow). See [Call Workforce (POST)](/api-reference/projects/workforce/call) for path, query, and limit details.
# Call Workforce (GET)
Source: https://docs.timbal.ai/api-reference/projects/workforce/call-get
GET /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/{*path}
Proxy HTTP requests to a deployed workforce component.
This route is a **gateway proxy** to a **running** workforce deployment (an agent or workflow). See [Call Workforce (POST)](/api-reference/projects/workforce/call) for path, query, and limit details.
# Call Workforce (PATCH)
Source: https://docs.timbal.ai/api-reference/projects/workforce/call-patch
PATCH /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/{*path}
Proxy HTTP requests to a deployed workforce component.
This route is a **gateway proxy** to a **running** workforce deployment (an agent or workflow). See [Call Workforce (POST)](/api-reference/projects/workforce/call) for path, query, and limit details.
# Call Workforce (PUT)
Source: https://docs.timbal.ai/api-reference/projects/workforce/call-put
PUT /orgs/{org_id}/projects/{project_id}/workforce/{workforce}/{*path}
Proxy HTTP requests to a deployed workforce component.
This route is a **gateway proxy** to a **running** workforce deployment (an agent or workflow). See [Call Workforce (POST)](/api-reference/projects/workforce/call) for path, query, and limit details.
# Create Workforce
Source: https://docs.timbal.ai/api-reference/projects/workforce/create
POST /orgs/{org_id}/projects/{project_id}/workforce
Create a workforce component on a branch revision.
# Delete Workforce
Source: https://docs.timbal.ai/api-reference/projects/workforce/delete
DELETE /orgs/{org_id}/projects/{project_id}/workforce
Delete a workforce component on a branch revision.
# List Workforce
Source: https://docs.timbal.ai/api-reference/projects/workforce/list
GET /orgs/{org_id}/projects/{project_id}/workforce
List workforce components on a branch revision.
# Context & State Management
Source: https://docs.timbal.ai/core-concepts/context
The shared memory that connects everything together across the entire lifetime of a run
The RunContext is the central storage and state management system for your runs. Beyond storing spans, it enables data sharing between components, input/output manipulation, and hierarchical data access across parent-child relationships.
## Accessing the RunContext
The RunContext is accessible from any callable within a Runnable's execution using `get_run_context()`. This includes:
* **Main handlers**: The main function that does the work (*i.e.* the function you pass to a Tool).
* **Default parameter callables**: Functions used to compute default values at runtime (as shown in [Runnables](./runnables#parameter-handling-and-basic-execution)).
* **Lifecycle hooks**: Functions that run before or after the main handler.
The `RunContext.current_span()` method returns the span object for the currently executing Runnable, containing all execution data - input parameters, output, timing, metadata, and any custom data you store on it. You get direct access to the live span object being built during execution.
Here's a simple example showing context access from a main handler:
```python highlight={7} theme={"dark"}
from datetime import datetime
import httpx
from timbal import Tool
from timbal.state import get_run_context
async def api_call(endpoint: str) -> dict:
span = get_run_context().current_span()
# Store request metadata for observability
span.endpoint = endpoint
span.request_start = datetime.now()
# Perform actual HTTP request
async with httpx.AsyncClient() as client:
response = await client.get(endpoint)
# Store response metadata for debugging/monitoring
span.response_status = response.status_code
span.request_duration = datetime.now() - span.request_start
return response.json()
api_tool = Tool(
name="api_call",
handler=api_call,
)
```
Beyond `current_span()`, the RunContext provides methods like `.parent_span()` and `.step_span()` to access parent or neighbor spans. We'll explore these methods in future sections when working with multi-step workflows and nested executions.
## Lifecycle Hooks
Beyond the main handler, Runnables support lifecycle hooks - functions that run at specific points during execution. These provide structured access points for context interaction and enable powerful data transformation patterns.
Every Runnable supports two optional hooks:
* **`pre_hook`**: A function that runs before the main handler
* **`post_hook`**: A function that runs after the main handler completes
Hooks can modify inputs, store custom data, and transform outputs - all while sharing the same RunContext.
### Pre-hooks: Modifying Input and Adding Context
A `pre_hook` runs before your handler and can both modify input parameters and store additional context data:
```python highlight={19} theme={"dark"}
from datetime import datetime
from timbal.state import get_run_context
def pre_hook():
span = get_run_context().current_span()
# Modify input parameters that will be passed to the handler
span.input["name"] = span.input["name"].capitalize()
# Add a new parameter
span.input["location"] = "Barcelona"
# Store custom data for later use
span.greet_time = datetime.now()
def greet(name: str, location: str) -> str:
return f"Hello {name} from {location}!"
greet_tool = Tool(
name="greet",
pre_hook=pre_hook,
handler=greet,
)
result = await greet_tool(name="alice").collect() # "Hello Alice from Barcelona!"
```
Pre-hooks are perfect for:
* **Data Preparation**: Process raw webhook payloads, parse JSON, or normalize input formats
* **Input Enhancement**: Enrich data with additional context from databases or APIs
* **Request Preprocessing**: Extract headers, validate signatures, or decode authentication tokens
* **State Initialization**: Set up execution context, timestamps, or tracking metadata
### Post-hooks: Processing Output After Completion
A `post_hook` runs after your handler and can access both input and output. You can also **modify or completely replace the output** by assigning a new value to `span.output`:
```python highlight={8} theme={"dark"}
def post_hook():
span = get_run_context().current_span()
# Retrieve custom data stored in pre_hook
greet_time = span.greet_time
print(f"Greeting at {greet_time}")
# Modify the output before it's returned
# You can assign any value to completely replace the handler's output
span.output = "Greeting overridden!"
greet_tool = Tool(
name="greet",
pre_hook=pre_hook,
handler=greet,
post_hook=post_hook,
)
result = await greet_tool(name="alice").collect() # "Greeting overridden!"
```
**Output Modification**: You can completely replace the output in a post-hook by assigning to `span.output`. The assigned value will be returned instead of the handler's original output. This is useful for transforming results, adding metadata, or implementing custom response formatting.
Post-hooks are perfect for:
* **Logging**: Record execution details and results
* **Metadata Storage**: Store processing metrics, timestamps, or analysis data
* **Output Modification**: Transform or enrich the final result
* **Cleanup Tasks**: Handle resource cleanup or state management
These simple examples show the basics. In practice, hooks excel at:
* **Input manipulation**: Processing webhooks where we don't control the shape of the incoming data.
* **Agent adaptation**: Converting between modalities (audio ↔ text) for different models.
More advanced patterns in the [Agents](../agents) section.
## Using Agents in Hooks
When you use an Agent inside a `pre_hook` or `post_hook`, you need to call `.nest()` to establish the proper hierarchical path for tracing and context management. This ensures the agent's execution is correctly nested under the parent agent's path.
Agents used as tools within another agent are automatically nested. However, agents used in hooks require manual nesting.
```python highlight={13} theme={"dark"}
from timbal import Agent
from timbal.state import get_run_context
# Agent that will be used in a hook
agent_is_company = Agent(
name="agent_is_company",
system_prompt="""Determine if the input is a company name.
Return only 'true' or 'false'.""",
model="openai/gpt-4.1-mini"
)
# Nest the agent under the parent agent's path
agent_is_company.nest("agent")
async def pre_hook():
span = get_run_context().current_span()
result = await agent_is_company(
prompt=f"Is '{span.input.get('name')}' a company?"
).collect()
span.is_company = result.output.collect_text().strip().lower() == "true"
agent = Agent(
name="agent",
model="openai/gpt-4.1-mini",
pre_hook=pre_hook
)
```
The `.nest()` method updates the agent's path hierarchy, ensuring proper tracing structure (e.g., `agent.agent_is_company` instead of just `agent_is_company`) and correct span nesting under the parent. Nested agents still run with **isolated memory** — they do not inherit the parent's conversation history (see [Nested Agent Memory](/agents/memory#nested-agent-memory)).
## Early Exit with bail()
Use `bail()` to exit early from any Runnable execution when validation fails or conditions aren't met. The `bail()` function raises an `EarlyExit` error that stops execution of the current runnable.
You can use `bail()` in:
* **Handlers**: Exit early from tool or agent handlers
* **Hooks**: Exit early from pre\_hook or post\_hook functions
* **Default parameter callables**: Exit early when computing default values
```python highlight={6} theme={"dark"}
from timbal.errors import bail
from timbal import Tool
def process_data(role: str) -> str:
if role != "admin":
bail("Admin access required")
return f"Processed: {role}"
tool = Tool(
name="process_data",
handler=process_data
)
```
This is useful for input validation, filtering unwanted requests, or implementing conditional logic in any Runnable.
# Events & Streaming
Source: https://docs.timbal.ai/core-concepts/events
Monitor execution in real-time and handle streaming results
## The Event Stream
In the previous [examples](./runnables#parameter-handling-and-basic-execution), we used `.collect()` to get the final result. When you call a Runnable, it doesn't just return the answer - it returns a stream of events that tell you what's happening step by step (an async generator). The `.collect()` method waits for all events and gives you just the final answer.
You can iterate through the async generator to process events in real-time:
```python theme={"dark"}
async for event in add_tool(a=5, b=3):
print(event)
# Output:
# StartEvent(run_id="068c4458382e79bb80006dc019ac3039", ...)
# OutputEvent(run_id="068c4458382e79bb80006dc019ac3039", output=8, ...)
```
This enables you to:
* Monitor execution progress in real-time
* Handle streaming responses from LLMs
* Debug execution flow
* Build reactive user interfaces
## Event Logging
In reality, you don't need to manually print events. By default, events are logged to standard output by the framework. You can control logging behavior with these environment variables:
* `TIMBAL_LOG_EVENTS`: Which events to log (default: `"START,OUTPUT"`)
* `TIMBAL_LOG_FORMAT`: Log format - `"dev"` for human-readable or `"json"` for structured (default)
* `TIMBAL_LOG_LEVEL`: Standard log level (default: `"INFO"`)
## Event Types
Events are the communication mechanism that Runnables use to stream information throughout their execution lifecycle. Every Runnable execution produces a sequence of events that can be consumed in real-time or collected for later processing.
Events are designed to be lightweight. For comprehensive execution data, see [Traces](./tracing.mdx).
Every execution produces at least a **Start** event (when it begins) and an **Output** event (when it finishes). LLMs and streaming operations also produce **Delta** events for intermediate results. Runs that pause for human input emit **Approval** or **Interaction** events.
The following examples show what these events look like for an LLM interaction:
### Start Event
Signals the beginning of an execution.
```python theme={"dark"}
from timbal.types.events import StartEvent
start_event = StartEvent(
run_id="068c4458382e79bb80006dc019ac3039",
parent_run_id=None,
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
parent_call_id="068c4458382e708f8000cc0f9b19d970",
)
```
These fields are present in all events to identify their source - **the framework handles this automatically**. We'll explore this in greater detail in advanced sections.
### Delta Events
Streaming content is emitted as typed **Delta Events** — structured, semantic updates about text, tool calls, thinking, and other content types.
#### Delta Event Structure
Each `DeltaEvent` contains an `item` property with a typed delta item. All delta items inherit from `DeltaItem` and have an `id` and `type` field:
```python theme={"dark"}
from timbal.types.events import DeltaEvent
from timbal.types.events.delta import Text, TextDelta, ToolUse
# Text block start (complete text content)
text_event = DeltaEvent(
run_id="068c4458382e79bb80006dc019ac3039",
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
item=Text(id="text_0", text="Hello")
)
# Text streaming delta
text_delta_event = DeltaEvent(
run_id="068c4458382e79bb80006dc019ac3039",
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
item=TextDelta(id="text_0", text_delta=" world")
)
# Tool call start
tool_event = DeltaEvent(
run_id="068c4458382e79bb80006dc019ac3039",
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
item=ToolUse(id="call_123", name="search", input="")
)
```
#### Delta Item Types
DeltaEvents use a pattern of paired types: a "start" type for the beginning of a content block, and a "delta" type for streaming incremental updates. All items have an `id` field to correlate deltas with their parent block.
**Text** - Start of a text content block:
```python theme={"dark"}
Text(
id="text_0",
text="Hello" # Initial text content
)
```
**TextDelta** - Streaming text increments:
```python theme={"dark"}
TextDelta(
id="text_0",
text_delta=" world" # Incremental text
)
```
**ToolUse** - Start of a tool call:
```python theme={"dark"}
ToolUse(
id="call_abc123",
name="search_web",
input="", # Initially empty
is_server_tool_use=False
)
```
**ToolUseDelta** - Streaming tool input parameters:
```python theme={"dark"}
ToolUseDelta(
id="call_abc123",
input_delta='{"query' # Partial JSON
)
```
LLMs stream tool input parameters incrementally. Multiple `ToolUseDelta` events will be emitted for a single tool call. You need to accumulate these deltas and parse the complete JSON afterwards.
**Thinking** - Start of LLM reasoning block:
```python theme={"dark"}
Thinking(
id="thinking_0",
thinking="Let me analyze this problem..."
)
```
**ThinkingDelta** - Streaming thinking increments:
```python theme={"dark"}
ThinkingDelta(
id="thinking_0",
thinking_delta="First, I'll consider..."
)
```
**Custom** - Arbitrary custom content:
```python theme={"dark"}
Custom(
id="custom_0",
data={"type": "image_progress", "percent": 45}
)
```
Use `Custom` for streaming content that doesn't fit standard types (e.g., multimodal content, provider-specific features, experimental data). This allows custom collectors and tools to emit arbitrary typed data while participating in the delta event system.
**ContentBlockStop** - Signals end of a content block:
```python theme={"dark"}
ContentBlockStop(id="text_0")
```
This event signals that a content block (text, tool use, thinking) has finished streaming. Use it to finalize processing of accumulated deltas.
#### Using Delta Events
```python theme={"dark"}
from timbal.types.events import DeltaEvent
from timbal.types.events.delta import (
Text, TextDelta, ToolUse, ToolUseDelta,
Thinking, ThinkingDelta, Custom, ContentBlockStop
)
async for event in agent(prompt="Hello"):
if isinstance(event, DeltaEvent):
item = event.item
# Type-safe handling of different content types
if isinstance(item, Text):
print(item.text, end="", flush=True)
elif isinstance(item, TextDelta):
print(item.text_delta, end="", flush=True)
elif isinstance(item, ToolUse):
print(f"\n[Calling {item.name}]")
elif isinstance(item, ToolUseDelta):
# Accumulate input_delta for later JSON parsing
pass
elif isinstance(item, Thinking):
print(f"\n[Thinking: {item.thinking}]")
elif isinstance(item, ThinkingDelta):
print(item.thinking_delta, end="", flush=True)
elif isinstance(item, Custom):
print(f"\n[Custom: {item.data}]")
elif isinstance(item, ContentBlockStop):
print(f"\n[Block {item.id} complete]")
```
#### Benefits of Delta Events
* **Type Safety**: Each delta item has specific fields and types
* **Semantic Information**: Know exactly what type of content is streaming
* **Better Observability**: Track tool calls, thinking, and text separately
* **Block Lifecycle**: Start and stop events for each content block
* **UI Flexibility**: Render different content types with appropriate components
* **Structured Logging**: Monitor different content types independently
### Approval and Interaction Events
When a run pauses for human input, the framework emits structured events before the final `OutputEvent`:
* **`ApprovalEvent`** — a `requires_approval` gate fired before a runnable executed. Resume with `resume={approval_id: True}` (or an `ApprovalResolution`).
* **`InteractionEvent`** — a handler called `suspend()` to ask the user for input. Resume with `resume={suspension_id: value}`.
See [Human in the Loop](/human-in-the-loop) for the full reference.
### Output Event
Contains the final result and signals completion. Contains either the final `output` result (if successful) or `error` information (if something went wrong).
```python theme={"dark"}
from timbal.types.events import OutputEvent
output_event = OutputEvent(
run_id="068c4458382e79bb80006dc019ac3039",
parent_run_id=None,
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
parent_call_id="068c4458382e708f8000cc0f9b19d970",
output={
"role": "assistant",
"content": [{"type": "text", "text": "Hello! How can I assist you today?"}]
},
error=None
)
```
## Handling Errors
When a Runnable encounters an error during execution, it **won't raise an exception**. Instead, it will always return an `OutputEvent` with error information. This ensures the event stream continues and you can handle errors gracefully:
```python theme={"dark"}
try:
result = await runnable(**kwargs).collect()
except Exception as e:
# This won't happen - errors are in the OutputEvent
pass
# Result will always be an instance of OutputEvent
if result.error:
print(f"Error: {result.error['message']}")
```
Errors will always have this structure:
```python theme={"dark"}
error = {
"type": "ValueError",
"message": "Invalid input provided",
"traceback": "Traceback (most recent call last):\\n..."
}
```
# Runnables
Source: https://docs.timbal.ai/core-concepts/runnables
Executable primitives that provide consistent interfaces for all Timbal components
## What is a Runnable?
A Runnable is an executable unit capable of processing inputs and producing outputs through an async generator interface. It works as a wrapper that turns any callable into a standardized, traceable, and composable execution unit.
All runnables provide a unified interface and execution pattern, enabling seamless composition regardless of their underlying implementation:
* **[Tools](#)** - Function wrappers with automatic schema generation and explicit parameter control
* **[Agents](#)** - Autonomous execution units that orchestrate LLM interactions with tool calling
* **[Workflows](#)** - Programmable execution pipelines that orchestrate step-by-step processing
All runnables must have a unique `name`. This name is used for tracing, debugging, and referencing the runnable in workflows, agents, and other components. Whether you're creating a Tool, an Agent, or a Workflow, the `name` parameter is required and must be unique within your application context.
Here's how to create a basic Tool:
```python theme={"dark"}
from timbal import Tool
def add(a: float, b: float) -> float:
"""Add two numbers together."""
return a + b
add_tool = Tool(
name="add_tool",
handler=add,
)
```
## Parameter Handling and Basic Execution
Runnables can be called as a regular python function. You can **pass parameters as keyword arguments**, and they'll be automatically mapped to the appropriate parameters in the underlying functions:
```python theme={"dark"}
result = await add_tool(a=5, b=3).collect() # Returns 8
```
You can also set default parameter values when creating the runnable:
```python theme={"dark"}
add_tool = Tool(
name="add_tool",
handler=add,
default_params={"b": 3}
)
result = await add_tool(a=5).collect() # Returns 8
```
Default parameters can also be callables that are evaluated at runtime:
```python theme={"dark"}
import random
add_tool = Tool(
name="add_tool",
handler=add,
default_params={"b": lambda: random.randint(0, 10)}
)
result = await add_tool(a=5).collect() # Returns a number between 5 - 15
```
Note that runtime parameters always override default values, including callable defaults:
```python theme={"dark"}
result = await add_tool(a=5, b=10).collect() # Returns 15
```
In the above example, `b=10` is passed at runtime and overrides the callable default that would generate a random number.
# Tracing & Observability
Source: https://docs.timbal.ai/core-concepts/tracing
Comprehensive execution tracing with input/output/error/timing capture for complete observability
Events are **not persisted to memory** - they are **immutable** temporary notifications that stream during execution. They provide immediate feedback about what's happening (*e.g.* start, progress chunks, completion) but are not stored permanently. Events are consumed as they're generated and are ideal for real-time monitoring, progress tracking, and streaming responses. For permanent storage and analysis, this information is captured in Traces.
## Traces
A Trace is the core data structure that captures execution information for every runnable execution, providing a complete audit trail of what happened. This tracing system enables complete observability into your application's behavior and performance.
For the previous LLM example, a single **span** inside the run's trace would look like this:
```python theme={"dark"}
from timbal.state.tracing.span import Span
llm_span = Span(
path="agent.llm",
call_id="068c4458383678be800031537a8df42e",
parent_call_id="068c4458382e708f8000cc0f9b19d970",
t0=1640995200000, # Start time (Unix ms)
t1=1640995201000, # End time (Unix ms)
input={
"model": "openai/gpt-4o-mini",
"messages": [{
"role": "user",
"content": [{"type": "text", "text": "Hello"}],
}],
},
output={
"role": "assistant",
"content": [{"type": "text", "text": "Hello! How can I assist you today?"}],
},
usage={
"openai/gpt-4o-mini:input_tokens": 32,
"openai/gpt-4o-mini:output_tokens": 10,
},
metadata={
"type": "LLM",
"model_provider": "openai",
"model_name": "gpt-4o-mini",
"ttft": 1.218775834015105, # Time To First Token
"tps": 103.44992571967803, # Tokens Per Second
},
)
```
Spans are stored in a `Trace` — a dict keyed by `call_id`. Access them during or after a run via `get_run_context()._trace` or helper methods like `step_span()`.
That's a single span, but spans don't usually come in isolation — they're organized into larger execution units: Runs.
## Runs
A Run represents the complete execution of your application - from the moment you invoke a runnable until it finishes. Think of it as the lifecycle of a single request or operation, whether that's a simple function call or a complex multi-step agent workflow.
Every Run is composed of a collection of spans:
* **Simple execution**: Running a single tool generates one span — the tool's input, output, timing, and metadata
* **Complex execution**: Running an agent generates multiple spans as it decides which tools to use, calls LLMs for reasoning, executes tools, and processes results
All spans within a run share the same `run_id` (via `RunContext.id`), creating a complete execution history that you can analyze, debug, or replay.
### The Run Context
Traces are stored in the RunContext - a central hub that manages all run data. You can access traces during execution, control storage behavior, and use this system to share data between different parts of your run.
More on this in the [Context](./context) section.
# Overview
Source: https://docs.timbal.ai/deployment/index
Run and deploy Timbal projects locally or in production
A Timbal project is three layers:
| Layer | Directory | Runtime |
| ------------- | ------------------- | ---------------------------------------------- |
| **UI** | `ui/` | React + Vite (Bun) |
| **API** | `api/` | Elysia (Bun) — routes requests to workforce |
| **Workforce** | `workforce//` | Python agents/workflows (`timbal.server.http`) |
Each workforce member has its own `timbal.yaml` manifest. The API and UI discover workforce services through env vars (`TIMBAL_START_WORKFORCE`, etc.) that `timbal start` wires up automatically.
## Local development
For day-to-day work, use [`timbal start`](/quickstart#run-locally). It:
* Detects `ui/`, `api/`, and every `workforce//` with a valid `timbal.yaml`
* Installs dependencies (`bun install`, `uv sync`)
* Picks free ports (defaults: UI `3737`, API `3000`, workforce from `4455`)
* Starts all services and multiplexes logs to one terminal
* Exposes interactive commands (`o` open UI, `s` status, `r` restart, `f`/`m` log focus/mute, `q` quit)
Override ports or env vars when needed:
```bash theme={"dark"}
timbal start --ui-port 4000 --api-port 3001 --port my-agent=4500
timbal start --env OPENAI_API_KEY=sk-... --env my-agent:DEBUG=1
timbal start --env-file .env.staging --env-file api:.env.api
```
## Environment variables
`timbal start` composes a separate environment for each service before spawning it. You do not need `--env-file` for the standard project layout — these files are **auto-loaded** when present:
| File | Scope |
| ----------------------- | ---------------------------------------------- |
| `/.env` | All services (UI, API, every workforce member) |
| `workforce//.env` | That workforce member only |
Missing files are skipped silently.
Override or add variables at the CLI:
```bash theme={"dark"}
timbal start --env DEBUG=1 # all services
timbal start --env api:LOG_LEVEL=debug # API only
timbal start --env-file .env.staging # extra file, all services
timbal start --env-file my-agent:.env.local # extra file, one member
```
`--env` and `--env-file` accept an optional scope prefix: `ui:`, `api:`, or a workforce member name. Unscoped entries apply globally.
### Precedence (low → high)
Later layers win over earlier ones:
1. Timbal built-ins (`TIMBAL_LOG_FORMAT`, `TIMBAL_API_KEY`, etc. from your CLI profile)
2. Inherited shell environment (`PORT` from your shell is ignored so it does not leak into services)
3. Auto-loaded `/.env`, then auto-loaded `workforce//.env` (members only)
4. `--env-file` (in flag order; scoped files apply only to that service)
5. `--env` (in flag order; scoped entries apply only to that service)
6. Runtime wiring that always wins (`PORT`, `TIMBAL_START_*`, `TIMBAL_WORKFORCE`)
Restart services (`r` in the `timbal start` terminal, or quit and re-run) after changing `.env` files — changes are read at startup, not hot-reloaded.
`.env` parsing is intentionally minimal: `KEY=VALUE` lines, optional `export` prefix, optional quotes, `#` comments. No variable expansion, line continuations, or shell escapes. For complex values, set the variable in your shell or use `--env`.
Vite and Bun may also read `ui/.env` and `api/.env` from those directories at dev time. Project-root `.env` is still the usual place for secrets shared across the stack (model API keys, integration tokens).
## Production
For production you have two paths:
Managed hosting — connect your git repo, deploy a branch, platform runs UI, API, and workforce for you
Run UI, API, and workforce on your own infra — you own process lifecycle, ports, env wiring, and logging
Validate locally with `timbal start` before deploying. The same project layout is what the platform builds from your repo.
# Timbal Platform
Source: https://docs.timbal.ai/deployment/platform
Deploy Timbal projects to managed hosting
The [Timbal Platform](https://app.timbal.ai) runs your project's UI, API, and workforce components for you. There is no local `timbal build` or Docker workflow — you push code to a connected git repo and deploy from the platform.
## Prerequisites
* A project scaffolded with [`timbal create`](/quickstart#create-a-timbal-project)
* Code pushed to a git remote the platform can access (typically GitHub)
* [Timbal CLI](/installation) installed (`timbal configure` for local credentials if needed)
* The project tested locally with [`timbal start`](/quickstart#run-locally)
## How deployment works
Each platform **project** maps to a git repository. **Environments** map to git branches (e.g. `main` → production, `staging` → staging). When you deploy an environment, the platform:
1. Checks out the requested git **rev** (branch)
2. Reads each `workforce//timbal.yaml` manifest
3. Builds and runs the UI, API, and every workforce member as separate deployable units
4. Routes traffic through a shared gateway (`proj-env-.deployments.timbal.ai`)
Scaffold locally, develop with `timbal start`, then push to your remote:
```bash theme={"dark"}
timbal create my-project
cd my-project
git remote add origin git@github.com:your-org/my-project.git
git push -u origin main
```
In the [Timbal Platform](https://app.timbal.ai), create a project linked to that repository. Create environments for the branches you want to deploy (production, staging, etc.).
Add environment variables and secrets in the platform dashboard — model API keys, integration tokens, anything sensitive. These are injected at runtime and are not baked into your repo.
Reference them in code with standard env var names (e.g. `OPENAI_API_KEY`).
Trigger a deploy for the target environment from the platform UI (or via the [Deploy API](/api-reference/projects/environments/deploy)). The platform deploys the git rev for that environment's branch.
Deployments are idempotent on the same rev. Re-deploy after pushing new commits to pick up changes.
## `timbal.yaml` manifest
Every workforce member needs a `timbal.yaml`. `timbal create` generates one automatically — do not remove the `_id` or `_type` fields.
```yaml workforce//timbal.yaml icon="y" theme={"dark"}
# Auto-generated. Do not modify _id / _type by hand unless you know what you're doing.
_id: "a1b2c3d4-..."
_type: "agent" # or "workflow"
build:
# Optional: apt packages for the platform build environment
# system_packages:
# - "libgl1-mesa-glx"
# Optional: commands run after the environment is set up
# run:
# - "echo env is ready!"
# Which runnable to serve (relative to this directory)
fqn: "agent.py::agent"
```
| Field | Purpose |
| ----------------------- | ---------------------------------------------------------------------- |
| `_id` | Stable manifest UUID — used for routing, logs, and deployment identity |
| `_type` | `agent` or `workflow` |
| `fqn` | Import spec for the Python runnable (`file.py::object`) |
| `build.system_packages` | Ubuntu packages installed during platform build |
| `build.run` | Shell commands run after dependency install |
Add additional workforce members locally with `timbal add` — each gets its own directory and manifest.
## Accessing a deployed project
Once live, the environment gateway exposes:
* **UI** at the environment root
* **API** at `/api/...`
* **Workforce** at `/api/workforce//...` (proxied to each member's HTTP server)
For programmatic access outside the gateway, the platform also exposes run endpoints:
```http theme={"dark"}
POST https://dev.timbal.ai/orgs/{org_id}/apps/{app_id}/runs/collect
POST https://dev.timbal.ai/orgs/{org_id}/apps/{app_id}/runs/stream
```
See the [API Reference](/api-reference/introduction) for auth, request shapes, and SDK usage.
## Monitoring and logs
The platform dashboard provides:
* **Deployment status** per component (UI, API, workforce members)
* **Runtime logs** filtered by component
* **Resource usage** and deploy history
You can also fetch logs via the [environment logs API](/api-reference/projects/environments/logs).
Even when self-hosting workforce runtimes, you can point tracing at the platform — see [Tracing](/core-concepts/tracing).
# Self-Hosted
Source: https://docs.timbal.ai/deployment/self-hosted
Run Timbal projects on your own infrastructure
Self-hosting means you run the same three layers a `timbal create` project contains — **UI**, **API**, and **workforce** — without `timbal start` managing them. You own process lifecycle, port allocation, env wiring, restarts, and log aggregation.
There is no `timbal build` or CLI-generated Docker image. You bring your own process manager (systemd, supervisord, Kubernetes, etc.) and production build pipeline.
## Prerequisites
* A project created with [`timbal create`](/quickstart#create-a-timbal-project)
* [Bun](https://bun.sh) for `api/` and `ui/`
* [uv](https://docs.astral.sh/uv/) for workforce Python deps
* `timbal[server]` installed in each workforce member's venv
## What `timbal start` does (that you must replicate)
`timbal start` is the reference implementation. When self-hosting, reproduce the same wiring:
### 1. Workforce members
For each `workforce//` with a `timbal.yaml`:
```bash theme={"dark"}
cd workforce/
uv sync
uv run -m timbal.server.http --port --import_spec
```
`` comes from `timbal.yaml` (e.g. `agent.py::agent`). The HTTP server exposes:
```http theme={"dark"}
POST http://localhost:/run # collect (returns final OutputEvent)
POST http://localhost:/stream # SSE event stream
GET http://localhost:/healthcheck
```
### 2. API
```bash theme={"dark"}
cd api
bun install
PORT= bun run dev # dev; use your production start command in prod
```
### 3. UI (if present)
```bash theme={"dark"}
cd ui
bun install
bun run dev --port # dev; use your production build/serve in prod
```
### 4. Cross-service env vars
The API and UI need to know where workforce members live. `timbal start` injects these — set them yourself when self-hosting:
| Variable | Example | Purpose |
| ------------------------ | ------------------------------------- | --------------------------------------------- |
| `TIMBAL_START_WORKFORCE` | `a1b2c3d4-...:4455,b5c6d7e8-...:4456` | Manifest `_id` → port map (comma-separated) |
| `TIMBAL_START_API_PORT` | `3000` | API port (for services that call back to API) |
| `TIMBAL_START_UI_PORT` | `3737` | UI port |
| `PORT` | per-service | Port each Bun service binds to |
`TIMBAL_START_WORKFORCE` uses the `_id` from each member's `timbal.yaml`, not the directory name.
Also pass through model keys and integration secrets (`OPENAI_API_KEY`, etc.). Locally, `timbal start` auto-loads `/.env` and `workforce//.env` into each process; when self-hosting you must export or inject those variables yourself (e.g. via your process manager or a secrets store). See [Environment variables](/deployment#environment-variables).
If any of `TIMBAL_START_*` are missing, platform SDK helpers that resolve service URLs will fail. Mirror what `timbal start` sets before debugging routing issues.
## Production considerations
### Process management
Run each component under a supervisor that restarts on crash:
* One process per workforce member
* One for API
* One for UI (if used)
Use health checks against `/healthcheck` on workforce HTTP servers.
### Logging
`timbal start` multiplexes stdout/stderr into one terminal with per-service prefixes and `f`/`m` focus controls. Self-hosted, you need your own approach:
* Separate log files or streams per component
* Structured logging if shipping to Datadog / CloudWatch / etc.
* Correlate by request ID if the API fans out to multiple workforce members
### Networking
* Put a reverse proxy (nginx, Caddy, etc.) in front of UI and API for TLS
* Workforce members can stay internal — only the API needs to reach them
* Lock down ports so workforce HTTP servers aren't exposed publicly
### Builds
The scaffold uses `bun run dev` for API/UI. For production, use whatever production build your scaffolded `api/` and `ui/` packages define (static UI build + API server, etc.). The workforce side stays `uv run -m timbal.server.http` with a pinned `uv sync` environment.
### Observability
Platform tracing still works from self-hosted runtimes — configure a [tracing provider](/core-concepts/tracing) to export spans to Timbal or your own OTLP endpoint.
## When to self-host
Self-hosting makes sense when you need full control over networking, data residency, or custom orchestration. For most teams, the [Timbal Platform](/deployment/platform) is less operational overhead — same project layout, no component wiring to maintain.
# Overview
Source: https://docs.timbal.ai/evals/index
Automated testing framework for Timbal agents and runnables
## What are Evals?
Evals are automated tests that validate your agent's behavior, outputs, and execution patterns. They help you ensure your agents perform correctly and consistently across different scenarios.
## Why Evals Matter
AI agents are non-deterministic - the same input can yield different results. Evals help you:
* **Validate outputs**: Ensure agents produce correct responses
* **Check tool usage**: Verify agents use the right tools with correct inputs
* **Monitor performance**: Track execution time and token usage
* **Catch regressions**: Prevent breaking changes during development
* **Test execution patterns**: Validate sequential and parallel tool execution
## How Evals Work
Timbal's eval system uses a YAML-based test definition format with a powerful validator system:
```yaml theme={"dark"}
- name: time_in_madrid
description: Test that agent returns the time in Madrid
runnable: agent.py::agent
tags: ["datetime", "smoke"]
timeout: 30000
params:
prompt: "what time is it in madrid"
output:
type!: "string"
contains!: ":"
pattern!: "\\d{1,2}:\\d{2}"
elapsed:
lt!: 6000
seq!:
- llm
- get_datetime
- llm
```
### Core Components
20+ validators for checking outputs, patterns, types, and more
Validate execution sequences and parallel tool calls
AI-powered semantic validation for natural language
Command-line interface for running and discovering evals
## Prerequisites
Before running evals, ensure the following environment variables are set:
* `TIMBAL_API_KEY` - Your Timbal API key
* `TIMBAL_API_HOST` - The Timbal API host URL
* `TIMBAL_ORG_ID` - Your organization ID
These can be set in your environment or in a `.env` file in your project directory.
## Quick Start
### 1. Create a test file
Create a file named `eval_greeting.yaml`:
```yaml theme={"dark"}
- name: greeting_test
description: Verify the agent greets users appropriately
runnable: agent.py::my_agent
params:
prompt: "Hi there!"
output:
not_null!: true
type!: "string"
prompt!: "The response greets the user politely"
elapsed:
lt!: 5000
```
### 2. Run your evals
```bash theme={"dark"}
python -m timbal.evals.cli path/to/eval_greeting.yaml
```
### 3. View results
The CLI displays pytest-style output with pass/fail status, duration, and detailed failure information:
```
========================= timbal evals =========================
collected 1 eval
eval_greeting.yaml
greeting_test ......................................... PASSED (0.45s)
tags: greeting, basic
├── output
│ ├── not_null! ✓
│ ├── type! ✓
│ └── prompt! ✓
└── elapsed
└── lt! ✓
========================= 1 passed in 0.45s =========================
```
## Eval Structure
Each eval consists of:
| Field | Description | Required |
| ------------- | -------------------------------------- | -------- |
| `name` | Unique identifier for the eval | Yes |
| `runnable` | Path to the runnable (`file.py::name`) | Yes |
| `params` | Input parameters for the runnable | No |
| `description` | Human-readable description | No |
| `tags` | List of tags for filtering | No |
| `timeout` | Maximum execution time in milliseconds | No |
| `output` | Validators for the final output | No |
| `elapsed` | Validators for total execution time | No |
| `seq!` | Sequence flow validator | No |
Eval names must be unique across all eval files. The CLI will error if duplicate names are found.
See [Writing Evals](/evals/writing-evals) for the complete syntax reference.
## Next Steps
Learn the full eval syntax and best practices
Complete reference for all validators
CLI options and CI/CD integration
# Running Evals
Source: https://docs.timbal.ai/evals/running-evals
CLI usage, file discovery, and CI/CD integration
Timbal provides a command-line interface for discovering and running evals. This guide covers all CLI options and integration patterns.
## Basic Usage
Run evals from the command line:
```bash theme={"dark"}
# Run all evals in a directory
python -m timbal.evals.cli path/to/evals/
# Run a specific eval file
python -m timbal.evals.cli path/to/eval_search.yaml
# Run a single eval by name (pytest-style)
python -m timbal.evals.cli path/to/eval_search.yaml::my_eval_name
# Run with a specific runnable
python -m timbal.evals.cli --runnable agent.py::my_agent path/to/evals/
```
## CLI Options
| Option | Description |
| -------------------- | ------------------------------------------------------------------ |
| `path` | Path to eval file or directory (positional) |
| `--runnable` | Fully qualified name of the runnable (`file.py::name`) |
| `--log-level` | Logging level (DEBUG, INFO, WARNING, ERROR) |
| `-s`, `--no-capture` | Disable stdout/stderr capture (show output live) |
| `-t`, `--tags` | Filter evals by tags (comma-separated) |
| `-f`, `--format` | Output format: `pretty` (default) or `json` (streams JSONL events) |
| `-j`, `--jobs` | Run up to N evals concurrently (default 1) |
| `-o`, `--output` | Write the full results document as JSON to a file (`-` for stdout) |
| `-V`, `--version` | Show version information |
### Runnable Specification
The runnable can be specified in two ways:
**1. Via CLI flag:**
```bash theme={"dark"}
python -m timbal.evals.cli --runnable agents/search.py::search_agent evals/
```
**2. Per-eval in YAML:**
```yaml theme={"dark"}
- name: test_search
runnable: agents/search.py::search_agent
params:
prompt: "Find products"
output:
not_null!: true
```
Per-eval runnable overrides the CLI flag.
### Tag Filtering
Filter evals by tags using `-t` or `--tags`:
```bash theme={"dark"}
# Run only evals with 'smoke' tag
python -m timbal.evals.cli -t smoke evals/
# Run evals matching ANY of the specified tags
python -m timbal.evals.cli -t smoke,fast evals/
# Combine with other options
python -m timbal.evals.cli --tags regression -s evals/
```
Evals matching **any** of the specified tags will run.
### Output Capture
By default, stdout/stderr from your agent is captured and only shown on failure. Use `-s` to see output live:
```bash theme={"dark"}
# Captured (default) - cleaner output
python -m timbal.evals.cli evals/
# Live output - useful for debugging
python -m timbal.evals.cli -s evals/
```
### Streaming JSON Output
Use `--format json` to stream structured events instead of the rich terminal report. One JSON object is written per line (JSONL) to stdout, and each `result` event is emitted **as the eval completes** — human-readable output goes to stderr, so stdout stays parseable:
```bash theme={"dark"}
python -m timbal.evals.cli evals/ --format json
```
```json theme={"dark"}
{"event": "start", "total": 2, "evals": [{"name": "greeting_test", "path": "evals/eval_basic.yaml"}, ...]}
{"event": "result", "name": "greeting_test", "passed": true, "duration": 1.2, "output": ..., "validators": [...]}
{"event": "result", "name": "search_test", "passed": false, "duration": 3.4, ...}
{"event": "summary", "total": 2, "passed": 1, "failed": 1, "total_duration": 4.6}
```
#### Event Reference
Every line is a JSON object with an `event` field: `start` (once, before any eval runs), `result` (once per eval), `summary` (once, at the end).
**`start`**
| Field | Type | Description |
| ------- | ---- | ------------------------------------------- |
| `total` | int | Number of evals that will run |
| `evals` | list | `{name, path}` for each eval, in file order |
**`result`** — one per eval. With `--jobs 1` results arrive in file order; with `--jobs > 1` they arrive in **completion order**, so use `name` to correlate.
| Field | Type | Description |
| ----------------- | -------------- | --------------------------------------------------------------- |
| `name` | string | Eval name |
| `path` | string | Eval file the eval was loaded from |
| `description` | string \| null | Eval description |
| `tags` | list | Eval tags |
| `passed` | bool | Overall verdict (no error and all validators passed) |
| `duration` | float | Wall-clock seconds for this eval |
| `error` | object \| null | `{type, message, traceback}` when the run itself failed |
| `params` | object | Input params the runnable was called with |
| `output` | any | Final runnable output (messages serialize to `{role, content}`) |
| `usage` | object | Token counts keyed by `provider/model:metric` |
| `validators` | list | Per-validator verdicts (see below) |
| `captured_stdout` | string | Stdout captured during the eval (empty with `-s`) |
| `captured_stderr` | string | Stderr captured during the eval (empty with `-s`) |
Each entry in `validators`:
| Field | Type | Description |
| -------------- | -------------- | ------------------------------------------------------------------ |
| `target` | string | Span/property path the validator ran against (e.g. `agent.output`) |
| `name` | string | Validator name (e.g. `contains!`, `prompt!`) |
| `value` | any | Expected value from the eval YAML |
| `passed` | bool | Whether the check passed |
| `evaluated` | bool | `false` when the validator was invalid and never executed |
| `error` | string \| null | Failure reason |
| `actual_value` | any | Resolved value (populated for LLM validators) |
**`summary`**
| Field | Type | Description |
| ----------------------------- | ----- | -------------------------------- |
| `total` / `passed` / `failed` | int | Eval counts |
| `total_duration` | float | Sum of eval durations in seconds |
The document written by `-o/--output` has the same shape: the `summary` fields at the top level plus a `results` list, where each entry matches the `result` event (minus the `event` key).
### Parallel Execution
Use `-j`/`--jobs` to run evals concurrently. Results are reported in **completion order**, which pairs naturally with `--format json` for streaming consumers:
```bash theme={"dark"}
# Run up to 4 evals at a time, streaming results as they finish
python -m timbal.evals.cli evals/ --format json -j 4
```
Per-eval stdout/stderr capture works in parallel mode — output is attributed to the eval that produced it.
### Saving Results
Use `-o`/`--output` to write the full results document as JSON after the run:
```bash theme={"dark"}
# Write to a file (works with both formats)
python -m timbal.evals.cli evals/ -o results.json
# Print the document to stdout (pretty format only; report goes to stderr)
python -m timbal.evals.cli evals/ -o -
```
## File Discovery
### Naming Patterns
Timbal discovers eval files matching these patterns:
* `eval*.yaml` - e.g., `eval_search.yaml`, `evals.yaml`
* `*eval.yaml` - e.g., `search_eval.yaml`, `my_eval.yaml`
### Directory Structure
```
project/
├── agents/
│ ├── search.py
│ └── support.py
└── evals/
├── eval_search.yaml
├── eval_support.yaml
└── regression/
├── eval_smoke.yaml
└── eval_full.yaml
```
Run all evals:
```bash theme={"dark"}
python -m timbal.evals.cli evals/
```
Run specific subset:
```bash theme={"dark"}
python -m timbal.evals.cli evals/regression/
```
### Configuration File
Create an `evalconf.yaml` in your project root for shared configuration:
```yaml theme={"dark"}
# evalconf.yaml
runnable: agents/main.py::agent
log_level: INFO
```
Timbal walks up the directory tree looking for `evalconf.yaml`.
## Output Format
### Successful Run
```
========================= timbal evals =========================
collected 5 evals
eval_search.yaml
basic_search .......................................... PASSED (0.45s)
tags: search, smoke
├── output
│ ├── not_null! ✓
│ └── contains! ✓
└── elapsed
└── lt! ✓
advanced_search ....................................... PASSED (0.82s)
tags: search, regression
├── output
│ ├── prompt! ✓
│ └── min_length! ✓
└── seq!
├── llm ✓
├── search_products ✓
└── llm ✓
========================= 5 passed in 2.34s =========================
```
### Failed Run
```
========================= timbal evals =========================
collected 3 evals
eval_validation.yaml
input_validation ...................................... FAILED (0.23s)
tags: validation
├── output
│ ├── not_null! ✓
│ └── contains! ✗
========================= FAILURES =========================
eval_validation.yaml::input_validation
-----------------------------------------
Captured stdout:
Processing input...
Captured stderr:
Warning: deprecated function used
Failed validators:
- output -> contains!
Expected: "validated"
Actual: "The input was processed successfully"
Error: Value does not contain expected substring
========================= 1 failed, 2 passed in 1.56s =========================
```
## Exit Codes
| Code | Meaning |
| ---- | ---------------------------- |
| 0 | All evals passed |
| 1 | One or more evals failed |
| 2 | Configuration or setup error |
## CI/CD Integration
### GitHub Actions
```yaml theme={"dark"}
# .github/workflows/evals.yaml
name: Run Evals
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -e .
pip install timbal
- name: Run evals
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python -m timbal.evals.cli --runnable agent.py::agent evals/ -j 4 -o results.json
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: eval-results
path: results.json
```
### GitLab CI
```yaml theme={"dark"}
# .gitlab-ci.yml
evals:
stage: test
image: python:3.11
script:
- pip install -e .
- pip install timbal
- python -m timbal.evals.cli --runnable agent.py::agent evals/
variables:
OPENAI_API_KEY: $OPENAI_API_KEY
```
### Pre-commit Hook
```bash theme={"dark"}
#!/bin/bash
# .git/hooks/pre-commit
echo "Running evals..."
python -m timbal.evals.cli evals/smoke/
if [ $? -ne 0 ]; then
echo "Evals failed. Commit aborted."
exit 1
fi
```
## Debugging
### Verbose Output
```bash theme={"dark"}
# Show debug logging
python -m timbal.evals.cli --log-level DEBUG evals/
# Show live output
python -m timbal.evals.cli -s evals/
```
### Run Single Eval
Test a specific eval during development:
```bash theme={"dark"}
# Run one file
python -m timbal.evals.cli evals/eval_search.yaml
# Run a specific eval by name (pytest-style syntax)
python -m timbal.evals.cli evals/eval_search.yaml::basic_search
```
### Environment Variables
Set environment variables for testing:
```bash theme={"dark"}
# Via shell
export API_KEY="test-key"
python -m timbal.evals.cli evals/
# Or in eval file
- name: test_with_key
runnable: agent.py::agent
env:
API_KEY: "test-key"
params:
prompt: "Fetch data"
output:
not_null!: true
```
## Best Practices
Separate fast smoke tests from slow regression tests:
```
evals/
├── smoke/ # Fast tests, run on every commit
│ └── eval_basic.yaml
└── regression/ # Comprehensive tests, run nightly
└── eval_full.yaml
```
```bash theme={"dark"}
# Quick check
python -m timbal.evals.cli evals/smoke/
# Full suite
python -m timbal.evals.cli evals/
```
Check exit codes in scripts:
```bash theme={"dark"}
python -m timbal.evals.cli evals/
status=$?
if [ $status -eq 0 ]; then
echo "All evals passed!"
elif [ $status -eq 1 ]; then
echo "Some evals failed"
exit 1
else
echo "Configuration error"
exit 2
fi
```
Prevent hanging tests with timeouts (in milliseconds):
```yaml theme={"dark"}
- name: slow_test
runnable: agent.py::agent
timeout: 60000 # 60 second timeout
params:
prompt: "Complex query"
output:
not_null!: true
```
Organize evals with tags:
```yaml theme={"dark"}
- name: smoke_test
runnable: agent.py::agent
tags:
- smoke
- quick
params:
prompt: "Hi"
output:
not_null!: true
```
# Comparison Validators
Source: https://docs.timbal.ai/evals/validators/comparison
Validators for equality, containment, patterns, and numeric comparisons
Comparison validators check values against expected content using various matching strategies.
All string comparison validators support [transforms](/evals/validators#transforms) and [negation](/evals/validators#negation).
## Equality
### eq!
Checks for exact equality between the actual and expected values.
```yaml theme={"dark"}
output:
eq!: "Hello, world!"
```
| Parameter | Type | Description |
| --------- | ---- | ------------------------ |
| value | any | The exact value to match |
```yaml theme={"dark"}
# String equality
output:
eq!: "Success"
# Case-insensitive equality with transform
output:
eq!:
value: "success"
transform: lowercase
# Numeric equality
get_calculate:
output:
eq!: 42
# Boolean equality
validate_input:
output:
eq!: true
# In span input
get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
```
### ne!
Checks that the actual value does NOT equal the expected value. This is the negated form of `eq!`.
```yaml theme={"dark"}
output:
ne!: "error"
```
| Parameter | Type | Description |
| --------- | ---- | ----------------------------- |
| value | any | The value that must NOT match |
```yaml theme={"dark"}
# Ensure response is not empty
output:
ne!: ""
# Case-insensitive not-equal
output:
ne!:
value: "error"
transform: lowercase
```
## String Matching
### contains!
Checks if the actual value contains the expected substring or item.
```yaml theme={"dark"}
output:
contains!: "success"
```
| Parameter | Type | Description |
| --------- | ---- | ------------------------- |
| value | any | Substring or item to find |
```yaml theme={"dark"}
# Substring check
output:
contains!: "order confirmed"
# Case-insensitive contains with transform
output:
contains!:
value: "success"
transform: lowercase
# Check output contains time format
output:
contains!: ":"
```
### not\_contains!
Checks that the actual value does NOT contain the expected substring or item.
```yaml theme={"dark"}
output:
not_contains!: "error"
```
| Parameter | Type | Description |
| --------- | ---- | ------------------------------------- |
| value | any | Substring or item that must be absent |
```yaml theme={"dark"}
# Ensure no error messages
output:
not_contains!: "error"
# Case-insensitive with transform
output:
not_contains!:
value: "error"
transform: lowercase
# Ensure no sensitive data in tool input
log_message:
input:
message:
not_contains!: "password"
```
### contains\_all!
Checks if the actual value contains ALL of the expected substrings or items.
```yaml theme={"dark"}
output:
contains_all!: ["time", "date", "timezone"]
```
| Parameter | Type | Description |
| --------- | ---- | ------------------------------------------------- |
| value | list | List of substrings/items that must ALL be present |
```yaml theme={"dark"}
# Check all required fields mentioned
output:
contains_all!: ["name", "email", "phone"]
# Case-insensitive with transform
output:
contains_all!:
value: ["madrid", "time"]
transform: lowercase
```
### not\_contains\_all!
Checks that the actual value does NOT contain all of the specified items (at least one must be missing). This is the negated form of `contains_all!`.
```yaml theme={"dark"}
output:
not_contains_all!: ["Paris", "London", "Tokyo"]
```
| Parameter | Type | Description |
| --------- | ---- | ----------------------------------------------- |
| value | list | List of items where at least one must be absent |
```yaml theme={"dark"}
# Ensure not all competitor names are mentioned
output:
not_contains_all!: ["CompetitorA", "CompetitorB", "CompetitorC"]
# With transform
output:
not_contains_all!:
value: ["error", "warning", "critical"]
transform: lowercase
```
### contains\_any!
Checks if the actual value contains AT LEAST ONE of the expected substrings or items.
```yaml theme={"dark"}
output:
contains_any!: ["success", "completed", "done"]
```
| Parameter | Type | Description |
| --------- | ---- | ----------------------------------------------------------- |
| value | list | List of substrings/items where at least one must be present |
```yaml theme={"dark"}
# Check for any success indicator
output:
contains_any!: ["success", "ok", "completed"]
# Case-insensitive with transform
output:
contains_any!:
value: ["yes", "confirmed", "approved"]
transform: lowercase
```
### not\_contains\_any!
Checks that the actual value contains NONE of the specified items. This is the negated form of `contains_any!`.
```yaml theme={"dark"}
output:
not_contains_any!: ["error", "failed", "exception"]
```
| Parameter | Type | Description |
| --------- | ---- | ------------------------------------- |
| value | list | List of items that must ALL be absent |
```yaml theme={"dark"}
# Ensure no error-related words
output:
not_contains_any!: ["error", "failed", "exception", "invalid"]
# With transform
output:
not_contains_any!:
value: ["ERROR", "FAILED"]
transform: uppercase
```
### pattern!
Checks if the actual value matches a regular expression pattern.
```yaml theme={"dark"}
output:
pattern!: "Order #\\d{6}"
```
| Parameter | Type | Description |
| --------- | ------ | -------------------------- |
| value | string | Regular expression pattern |
```yaml theme={"dark"}
# Match time format
output:
pattern!: "\\d{1,2}:\\d{2}"
# Match ISO date format
get_datetime:
output:
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
# Match price format
output:
pattern!: "\\$\\d+\\.\\d{2}"
```
Regular expressions use Python's `re` module syntax. Remember to escape special characters in YAML (use `\\d` instead of `\d`).
### not\_pattern!
Checks that the actual value does NOT match the regular expression pattern. This is the negated form of `pattern!`.
```yaml theme={"dark"}
output:
not_pattern!: "^Error:"
```
| Parameter | Type | Description |
| --------- | ------ | ---------------------------------------------- |
| value | string | Regular expression pattern that must NOT match |
```yaml theme={"dark"}
# Ensure no error prefix
output:
not_pattern!: "^Error:"
# Ensure no failed status
output:
not_pattern!: "\\bfailed\\b"
# Case-insensitive with transform
output:
not_pattern!:
value: "^error"
transform: lowercase
```
### starts\_with!
Checks if the actual value starts with the expected prefix.
```yaml theme={"dark"}
output:
starts_with!: "Hello"
```
| Parameter | Type | Description |
| --------- | ------ | --------------- |
| value | string | Expected prefix |
```yaml theme={"dark"}
# Check timezone prefix
get_datetime:
input:
timezone:
starts_with!: "Europe/"
# Check response starts correctly
output:
starts_with!: "The current time"
# Case-insensitive with transform
output:
starts_with!:
value: "the current"
transform: lowercase
```
### not\_starts\_with!
Checks that the actual value does NOT start with the expected prefix. This is the negated form of `starts_with!`.
```yaml theme={"dark"}
output:
not_starts_with!: "Error"
```
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| value | string | Prefix that must NOT be present |
```yaml theme={"dark"}
# Ensure no error prefix
output:
not_starts_with!: "Error:"
# Ensure no apology
output:
not_starts_with!: "Sorry"
# Case-insensitive with transform
output:
not_starts_with!:
value: "error"
transform: lowercase
```
### ends\_with!
Checks if the actual value ends with the expected suffix.
```yaml theme={"dark"}
output:
ends_with!: "."
```
| Parameter | Type | Description |
| --------- | ------ | --------------- |
| value | string | Expected suffix |
```yaml theme={"dark"}
# Check timezone suffix
get_datetime:
input:
timezone:
ends_with!: "rid" # Matches "Madrid"
# Check file extension
extract_file:
output:
filename:
ends_with!: ".pdf"
# Case-insensitive with transform
output:
ends_with!:
value: "completed."
transform: lowercase
```
### not\_ends\_with!
Checks that the actual value does NOT end with the expected suffix. This is the negated form of `ends_with!`.
```yaml theme={"dark"}
output:
not_ends_with!: "error"
```
| Parameter | Type | Description |
| --------- | ------ | ------------------------------- |
| value | string | Suffix that must NOT be present |
```yaml theme={"dark"}
# Ensure no error suffix
output:
not_ends_with!: "failed"
# Case-insensitive with transform
output:
not_ends_with!:
value: "error"
transform: lowercase
```
## Numeric Comparisons
Numeric validators support integers, floats, and date strings.
### lt!
Checks if the actual value is less than the expected value.
```yaml theme={"dark"}
elapsed:
lt!: 5000
```
| Parameter | Type | Description |
| --------- | --------------------- | ----------------------- |
| value | number or date string | Upper bound (exclusive) |
### lte!
Checks if the actual value is less than or equal to the expected value.
```yaml theme={"dark"}
llm:
usage:
input_tokens:
lte!: 500
```
| Parameter | Type | Description |
| --------- | --------------------- | ----------------------- |
| value | number or date string | Upper bound (inclusive) |
### gt!
Checks if the actual value is greater than the expected value.
```yaml theme={"dark"}
get_datetime:
output:
gt!: "2025-12-15T00:45:12"
```
| Parameter | Type | Description |
| --------- | --------------------- | ----------------------- |
| value | number or date string | Lower bound (exclusive) |
### gte!
Checks if the actual value is greater than or equal to the expected value.
```yaml theme={"dark"}
search_results:
output:
count:
gte!: 1
```
| Parameter | Type | Description |
| --------- | --------------------- | ----------------------- |
| value | number or date string | Lower bound (inclusive) |
```yaml theme={"dark"}
# Execution time limits
elapsed:
lt!: 6000
# Token usage limits
llm:
usage:
input_tokens:
lte!: 500
output_tokens:
lte!: 1000
# Per-span timing
get_datetime:
elapsed:
lt!: 1000
gte!: 10
# Date comparison
get_datetime:
output:
gt!: "2025-12-15T00:45:12"
lte!: "2026-01-01"
```
## Combining Comparison Validators
Combine validators for comprehensive checks:
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 50
contains!: "order"
not_contains!: "error"
pattern!: "#\\d{6}"
ends_with!: "."
```
### Range Checks
```yaml theme={"dark"}
elapsed:
gte!: 100
lt!: 5000
llm:
usage:
input_tokens:
gt!: 0
lte!: 500
```
### Span Input/Output Validation
```yaml theme={"dark"}
get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
starts_with!: "Europe/"
ends_with!: "rid"
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
gt!: "2025-01-01"
lte!: "2026-01-01"
```
# Flow Validators
Source: https://docs.timbal.ai/evals/validators/flow
Validators for checking execution sequences and parallel execution
Flow validators check the execution patterns of your agent - the order in which tools are called and whether operations run in parallel.
## seq!
Validates that execution spans match a sequence pattern. This is a top-level validator that checks the order of tool calls.
```yaml theme={"dark"}
seq!:
- llm
- search_products
- llm
```
The `seq!` validator checks that the specified spans appear in order in the execution trace.
### Basic Sequence
```yaml theme={"dark"}
seq!:
- llm # First: LLM call
- get_datetime # Then: datetime tool
- llm # Then: another LLM call
```
### Wildcard Patterns
Use wildcards to match flexible sequences:
| Pattern | Description |
| ------- | ------------------------------- |
| `..` | Exactly 1 span |
| `...` | Any number of spans (0 or more) |
| `n..m` | Between n and m spans |
| `n..` | At least n spans |
| `..m` | At most m spans |
```yaml theme={"dark"}
seq!:
- llm
- ... # Any spans in between
- send_email
```
```yaml theme={"dark"}
# Exactly one span between
seq!:
- validate_input
- .. # Exactly 1 span
- process_result
# Any number of spans between
seq!:
- llm
- ... # 0 or more spans
- llm
# Between 1 and 3 spans
seq!:
- llm
- 1..3 # 1 to 3 spans
- complete
# At least 1 span between
seq!:
- initialize
- 1.. # 1 or more spans
- finalize
# At most 2 spans between
seq!:
- fetch_data
- ..2 # 0 to 2 spans
- transform_data
```
### Span Validation Within Sequence
Validate span inputs, outputs, and timing within the sequence:
```yaml theme={"dark"}
seq!:
- llm:
elapsed:
lte!: 40000
usage:
input_tokens:
lte!: 1000
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
starts_with!: "Europe/"
ends_with!: "rid"
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
gt!: "2025-12-15T00:45:12"
- llm
```
### any! Pattern
Match any span meeting certain criteria:
```yaml theme={"dark"}
seq!:
- llm
- any!:
min: 1 # At least 1 match
max: 3 # At most 3 matches
contains: # Must match one of these
- validate_input
- check_format
not_contains: # Must not match these
- delete_user
- process_result
```
| Field | Type | Description |
| -------------- | ------- | -------------------------------- |
| `min` | integer | Minimum number of matching spans |
| `max` | integer | Maximum number of matching spans |
| `contains` | list | Span names that can match |
| `not_contains` | list | Span names that must not match |
```yaml theme={"dark"}
# At least one validation step
seq!:
- receive_input
- any!:
min: 1
contains:
- validate_email
- validate_phone
- validate_address
- save_record
# Any number of retry attempts (0-3)
seq!:
- initial_request
- any!:
min: 0
max: 3
contains:
- retry_request
- final_response
# Exclude certain operations
seq!:
- process_data
- any!:
min: 1
not_contains:
- delete_data
- drop_table
- complete
```
### Nested parallel! in Sequences
Check for parallel execution within a sequence:
```yaml theme={"dark"}
seq!:
- llm
- parallel!:
- get_datetime
- get_weather
- get_stock_price
- llm
```
With span validation inside parallel:
```yaml theme={"dark"}
seq!:
- 1..1
- parallel!:
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
- get_weather
- ...
```
## parallel!
Validates that specified spans executed in parallel (overlapping time ranges). This can be used as a top-level validator or nested within `seq!`.
```yaml theme={"dark"}
parallel!:
- get_datetime
- get_weather
- get_stock_price
```
| Field | Type | Description |
| ----------- | ------- | --------------------------------------------- |
| (list) | list | Span names that must execute in parallel |
| `tolerance` | integer | Timing tolerance in milliseconds (default: 0) |
| `spans` | list | Span names (when using tolerance) |
### Basic Parallel Check
```yaml theme={"dark"}
parallel!:
- fetch_user
- fetch_orders
- fetch_preferences
```
This validates that all three spans had overlapping execution times.
### With Tolerance
Network latency and scheduling can cause slight timing variations. Use tolerance to account for this:
```yaml theme={"dark"}
parallel!:
tolerance: 100 # 100ms tolerance
spans:
- fetch_user
- fetch_orders
- fetch_preferences
```
### With Span Validation
Validate inputs/outputs of parallel spans:
```yaml theme={"dark"}
parallel!:
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
output:
type!: "string"
- get_weather:
input:
city:
eq!: "Madrid"
- get_stock_price
```
## Complete Examples
### Basic Flow Validation
```yaml theme={"dark"}
- name: datetime_query
runnable: agent.py::agent
params:
prompt: "what time is it in madrid"
output:
contains!: ":"
pattern!: "\\d{1,2}:\\d{2}"
seq!:
- llm
- get_datetime
- llm
```
### Comprehensive Flow Validation
```yaml theme={"dark"}
- name: full_datetime_flow
runnable: agent.py::agent
params:
prompt: "what time is it in madrid"
output:
type!: "string"
min_length!: 10
contains!: ":"
pattern!: "\\d{1,2}:\\d{2}"
prompt!: "The response states the current time in Madrid"
elapsed:
lt!: 6000
seq!:
- llm:
elapsed:
lte!: 40000
usage:
input_tokens:
lte!: 1000
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
starts_with!: "Europe/"
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
- llm
```
### Parallel Tool Execution
```yaml theme={"dark"}
- name: parallel_data_fetch
runnable: agent.py::agent
params:
prompt: "Get my dashboard data"
parallel!:
- fetch_user
- fetch_orders
- fetch_recommendations
```
### Mixed Sequential and Parallel
```yaml theme={"dark"}
- name: complex_flow
runnable: agent.py::agent
params:
prompt: "Process my order"
seq!:
- llm
- validate_order
- parallel!:
- check_inventory
- calculate_shipping
- apply_discounts
- ...
- llm
```
## How Parallel Detection Works
Two spans are considered parallel if their execution times overlap:
```
Span A: |-------|
Span B: |-------|
→ Parallel (overlapping)
Span A: |-------|
Span B: |-------|
→ Sequential (no overlap)
```
With tolerance, spans starting within the tolerance window are still considered parallel:
```
Tolerance: 100ms
Span A: |-------|
Span B: |-------| (started 50ms after A ended)
→ Parallel (within tolerance)
```
## Tips
Use `seq!` with wildcards (`...`) for flexible matching when you care about the order of key operations but not every intermediate step.
Flow validators require execution tracing to be enabled. Tracing is automatically enabled when running evals.
Be careful with strict timing requirements in `parallel!`. Network conditions and system load can affect timing. Use appropriate tolerance values.
# Overview
Source: https://docs.timbal.ai/evals/validators/index
Complete reference for all Timbal eval validators
Validators are the core of Timbal's eval system. They check specific properties of your agent's execution and determine whether an eval passes or fails.
## Validator Syntax
All validators use the `name!` suffix convention:
```yaml theme={"dark"}
output:
type!: "string"
contains!: "hello"
min_length!: 10
```
The `!` suffix distinguishes validators from regular YAML keys.
## Transforms
Transforms allow you to normalize values before validation. This is useful for case-insensitive matching or handling whitespace.
```yaml theme={"dark"}
output:
contains!:
value: "success"
transform: lowercase
```
### Available Transforms
| Transform | Description | Example |
| --------------------- | ----------------------------------- | ------------------- |
| `lowercase` | Convert to lowercase | `"Hello" → "hello"` |
| `uppercase` | Convert to uppercase | `"Hello" → "HELLO"` |
| `trim` | Remove leading/trailing whitespace | `" hi " → "hi"` |
| `collapse_whitespace` | Replace multiple spaces with single | `"a b" → "a b"` |
### Chaining Transforms
Apply multiple transforms in order:
```yaml theme={"dark"}
output:
contains!:
value: "hello world"
transform: [trim, lowercase, collapse_whitespace]
```
Transforms are applied left-to-right: first `trim`, then `lowercase`, then `collapse_whitespace`.
## Negation
Most validators support negation using the `not_` prefix or the `negate` field.
### Using Aliases
```yaml theme={"dark"}
output:
not_contains!: "error"
not_starts_with!: "Error:"
ne!: "failed"
```
### Using the negate Field
```yaml theme={"dark"}
output:
contains!:
value: "error"
negate: true
```
### Combining Negation with Transforms
```yaml theme={"dark"}
output:
not_contains!:
value: "ERROR"
transform: uppercase
```
## Validator Categories
String matching, patterns, and equality checks
Type checking, JSON, and format validation
Length constraints and bounds
AI-powered checks for claims, meaning, and language
Execution sequence and parallelism
## Quick Reference
| Validator | Category | Description | Example |
| ------------------- | ---------- | --------------------------------- | ----------------------------------------------------- |
| `eq!` | Comparison | Exact equality | `eq!: "hello"` |
| `ne!` | Comparison | Not equal | `ne!: "error"` |
| `contains!` | Comparison | Substring/item check | `contains!: "world"` |
| `not_contains!` | Comparison | Absence check | `not_contains!: "error"` |
| `contains_all!` | Comparison | Contains all items | `contains_all!: ["a", "b"]` |
| `not_contains_all!` | Comparison | Missing at least one | `not_contains_all!: ["x", "y"]` |
| `contains_any!` | Comparison | Contains at least one | `contains_any!: ["a", "b"]` |
| `not_contains_any!` | Comparison | Contains none | `not_contains_any!: ["x", "y"]` |
| `pattern!` | Comparison | Regex match | `pattern!: "\\d+"` |
| `not_pattern!` | Comparison | Regex non-match | `not_pattern!: "^Error"` |
| `starts_with!` | Comparison | Prefix check | `starts_with!: "Hello"` |
| `not_starts_with!` | Comparison | No prefix match | `not_starts_with!: "Error"` |
| `ends_with!` | Comparison | Suffix check | `ends_with!: "."` |
| `not_ends_with!` | Comparison | No suffix match | `not_ends_with!: "error"` |
| `lt!` | Comparison | Less than | `lt!: 100` |
| `lte!` | Comparison | Less than or equal | `lte!: 100` |
| `gt!` | Comparison | Greater than | `gt!: 0` |
| `gte!` | Comparison | Greater than or equal | `gte!: 1` |
| `type!` | Type | Type checking | `type!: "string"` |
| `not_type!` | Type | Type exclusion | `not_type!: "null"` |
| `json!` | Type | Valid JSON | `json!: true` |
| `email!` | Type | Email format | `email!: true` |
| `not_null!` | Type | Non-null check | `not_null!: true` |
| `length!` | Length | Exact length | `length!: 10` |
| `min_length!` | Length | Minimum length | `min_length!: 5` |
| `max_length!` | Length | Maximum length | `max_length!: 100` |
| `prompt!` | LLM | Statement/claim is true in output | `prompt!: "The response explains the refund process"` |
| `semantic!` | LLM | Semantic match | `semantic!: "greeting"` |
| `not_semantic!` | LLM | Semantic non-match | `not_semantic!: "rude"` |
| `language!` | LLM | Language detection | `language!: "en"` |
| `not_language!` | LLM | Language exclusion | `not_language!: "fr"` |
| `seq!` | Flow | Execution sequence | See [Flow](/evals/validators/flow) |
| `parallel!` | Flow | Parallel execution | See [Flow](/evals/validators/flow) |
## Where Validators Apply
### Output Validation
```yaml theme={"dark"}
output:
type!: "string"
not_null!: true
contains!: "success"
```
### Timing Validation
```yaml theme={"dark"}
elapsed:
lt!: 5000
gte!: 100
```
### Span Validation
```yaml theme={"dark"}
get_weather:
input:
city:
eq!: "Madrid"
output:
type!: "object"
elapsed:
lt!: 2000
```
### Usage Validation
```yaml theme={"dark"}
llm:
usage:
input_tokens:
lte!: 500
output_tokens:
lte!: 1000
```
The token field names depend on the model provider:
* **OpenAI**: Use `input_text_tokens` and `output_text_tokens` (e.g., `input_text_tokens: lte!: 500`, `output_text_tokens: lte!: 1000`)
* **Anthropic**: Use `input_tokens` and `output_tokens` (e.g., `input_tokens: lte!: 500`, `output_tokens: lte!: 1000`)
## Combining Validators
### Multiple Validators on One Target
Apply multiple validators to the same target:
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 10
contains!: "success"
not_contains!: "error"
```
All validators must pass for the eval to succeed.
### Nested Validation
Validate nested properties in span inputs/outputs:
```yaml theme={"dark"}
search_products:
input:
query:
contains!: "laptop"
options:
limit:
lte!: 100
category:
eq!: "electronics"
output:
results:
min_length!: 1
```
## Error Messages
When a validator fails, it provides detailed error information:
```
FAILED: output
Validator: contains!
Expected: "success"
Actual: "The operation failed due to network error"
Error: Value does not contain expected substring
```
For LLM validators:
```
FAILED: output
Validator: prompt!
Expected: "The response greets the user politely"
Actual: "What do you want?"
Reason: The response is curt and lacks politeness
```
# Length Validators
Source: https://docs.timbal.ai/evals/validators/length
Validators for checking length constraints on strings, lists, and collections
Length validators check the size of strings, lists, and other collections.
Length validators support [transforms](/evals/validators#transforms) when validating strings. Transforms are applied before measuring length.
## length!
Checks that the value has exactly the specified length.
```yaml theme={"dark"}
generate_code:
output:
length!: 6
```
| Parameter | Type | Description |
| --------- | ------- | --------------------- |
| value | integer | Exact required length |
```yaml theme={"dark"}
# Exact code length
generate_pin:
output:
length!: 4
# Fixed-length ID
create_id:
output:
length!: 36 # UUID length
# Specific list size
get_top_results:
output:
length!: 5
```
## min\_length!
Checks that the value has at least the specified length.
```yaml theme={"dark"}
output:
min_length!: 10
```
| Parameter | Type | Description |
| --------- | ------- | ----------------------- |
| value | integer | Minimum required length |
```yaml theme={"dark"}
# Ensure substantive response
output:
min_length!: 50
# Require at least one result
search_products:
output:
min_length!: 1
# Validate input length
validate_password:
input:
password:
min_length!: 8
```
## max\_length!
Checks that the value has at most the specified length.
```yaml theme={"dark"}
output:
max_length!: 1000
```
| Parameter | Type | Description |
| --------- | ------- | ---------------------- |
| value | integer | Maximum allowed length |
```yaml theme={"dark"}
# Limit response length
output:
max_length!: 500
# Limit results returned
search_products:
output:
max_length!: 10
# Username length limit
validate_username:
input:
username:
max_length!: 32
```
## Combining Length Validators
### Length Range
```yaml theme={"dark"}
output:
min_length!: 50
max_length!: 500
```
### With Other Validators
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 10
max_length!: 1000
contains!: "result"
```
## Common Patterns
### Response Quality Checks
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 20 # Not too short
max_length!: 2000 # Not too long
```
### Array Size Validation
```yaml theme={"dark"}
search_products:
output:
type!: "array"
min_length!: 1
max_length!: 50
recommend:
output:
length!: 3 # Exactly 3 recommendations
```
### Code Generation
```yaml theme={"dark"}
generate_pin:
output:
type!: "string"
length!: 4
pattern!: "\\d{4}"
generate_code:
output:
type!: "string"
length!: 6
pattern!: "[A-Z0-9]{6}"
```
## Using Transforms
Transforms are applied before measuring length:
```yaml theme={"dark"}
# Trim whitespace before checking length
output:
min_length!:
value: 10
transform: trim
# Collapse whitespace then check length
output:
max_length!:
value: 100
transform: [trim, collapse_whitespace]
```
## Notes on Length Calculation
* **Strings**: Length is the number of characters
* **Arrays**: Length is the number of items
* **Objects**: Length is the number of keys
```yaml theme={"dark"}
# String length (characters)
output: # "Hello" has length 5
length!: 5
# Array length (items)
items: # [1, 2, 3] has length 3
length!: 3
# Object length (keys)
config: # {"a": 1, "b": 2} has length 2
length!: 2
```
# LLM Validators
Source: https://docs.timbal.ai/evals/validators/llm
AI-powered validators for checking claims, meaning, and language
LLM validators use AI models to evaluate content that can't be easily checked with exact matching. They're ideal for validating natural language outputs where wording may vary but meaning should be consistent.
LLM validators support [transforms](/evals/validators#transforms). Transforms are applied to the content before sending to the LLM for evaluation.
## prompt!
Validates whether a natural-language statement about the output is true based on the actual text.
```yaml theme={"dark"}
output:
prompt!: "The response clearly explains the refund process"
```
| Parameter | Type | Description |
| --------- | ------ | ----------------------------------------------------------- |
| value | string | A statement or claim that should be true in the actual text |
The prompt validator asks an LLM to check if your statement is supported by the actual output. It focuses on factual consistency rather than exact wording.
`prompt!` is the recommended general-purpose LLM validator. A statement has clear truth conditions ("The agent asked about the budget" is either supported by the text or it isn't), which makes judge results more consistent than fuzzy "does it match" comparisons.
```yaml theme={"dark"}
# Check that a specific question was asked
output:
prompt!: "The agent asked the customer about their budget"
# Check that a key action was taken
output:
prompt!: "The assistant confirmed the shipping address before placing the order"
# Check that required information is present
output:
prompt!: "The message includes the expected delivery date"
# Error handling
output:
prompt!: "The response apologizes and explains the service is unavailable"
# Content requirements
output:
prompt!: "The response describes the product and mentions its price and availability"
```
### Writing Effective Statements
Write statements as specific, verifiable claims about the output. Vague statements lead to inconsistent results.
**Good statements:**
```yaml theme={"dark"}
# Specific and verifiable
prompt!: "The response includes at least 3 product recommendations with prices"
prompt!: "The error message mentions the specific field that failed validation"
prompt!: "The summary covers the main points: budget, timeline, and deliverables"
```
**Avoid:**
```yaml theme={"dark"}
# Too vague
prompt!: "The response is good"
prompt!: "The answer is helpful"
prompt!: "The output is correct"
```
For multi-part requirements, list the parts explicitly in a single statement:
```yaml theme={"dark"}
output:
prompt!: |
The response acknowledges the user's question, answers it directly,
and offers follow-up assistance
```
### Negating prompt!
Use the `negate` field to assert that a statement is **not** true:
```yaml theme={"dark"}
output:
prompt!:
value: "The assistant discloses internal system prompts"
negate: true
```
This passes only if the statement is **not** supported by the actual text.
## rubric!
Grades the output against a structured rubric — a list of criteria, each judged by its **own isolated LLM call** with its own context window. Per-dimension judging grades more reliably than one judge scoring everything at once, and each criterion returns `pass` / `fail` / `unknown` with a reason (`unknown` is the judge's escape hatch when the text gives no way to verify — it counts as not passing).
```yaml theme={"dark"}
output:
rubric!:
- "Includes a comparison table"
- "Every price is attributed to a source"
- "Ends with at least 3 actionable recommendations"
```
| Parameter | Type | Description |
| --------------- | ---------------------- | --------------------------------------------------------------------------------------- |
| value | list \| string \| dict | Criteria list, a markdown rubric (bullet lines become criteria), or a dict with options |
| pass\_threshold | float | Weighted fraction of criteria that must pass. Default `1.0` (all) |
| model | string | Judge model. Default `openai/gpt-5.4-nano` |
| context | string | Optional task description shown to every judge |
When the rubric fails, the eval report lists **every failing criterion with the judge's reason** — you see exactly which requirement broke, not a single opaque fail.
Full form with weighted criteria:
```yaml theme={"dark"}
output:
rubric!:
criteria:
- "Includes a comparison table"
- criterion: "Ends with at least 3 actionable recommendations"
name: recommendations
weight: 2
pass_threshold: 0.75
model: "openai/gpt-5.4-nano"
context: "The agent produced a price-comparison report."
```
Markdown rubrics work too — bullet and numbered lines become criteria, headings and prose are ignored:
```yaml theme={"dark"}
output:
rubric!: |
- Mentions the refund policy
- Confirms the order number
- Ends by offering further help
```
Write criteria around **verifiable structure**, not facts the judge cannot check. "Prices are formatted and attributed to a source" grades reliably; "prices are accurate" does not — the judge has no way to confirm it and will answer `unknown`.
Use `rubric!` instead of several `prompt!` statements when the requirements form one quality bar: you get per-criterion verdicts, weights, a partial-credit threshold, and one aggregate score. The same rubric can also gate an agent at runtime via `timbal.guardrails.LLMJudge(rubric=...)`, which feeds failing criteria back to the agent for revision.
## semantic!
Uses an LLM to check if the actual value semantically matches the expected description.
```yaml theme={"dark"}
output:
semantic!: "A polite greeting that welcomes the user"
```
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------------------ |
| value | string | Natural language description of expected content |
The semantic validator sends the actual value and your description to an LLM, which determines if they match semantically.
If your check can be phrased as a verifiable statement, prefer [`prompt!`](#prompt). Use `semantic!` when you're matching the output against a *description* of its overall meaning, tone, or style rather than asserting a specific fact about it.
```yaml theme={"dark"}
# Tone
output:
semantic!: "A professional response suitable for a business context"
# Style matching
output:
semantic!: "A casual, friendly greeting that matches the user's informal tone"
# Overall content shape
output:
semantic!: "An apologetic message explaining the service is unavailable"
```
## not\_semantic!
Checks that the content does NOT semantically match the description. This is the negated form of `semantic!`.
```yaml theme={"dark"}
output:
not_semantic!: "An error message or apology"
```
| Parameter | Type | Description |
| --------- | ------ | ------------------------------------------------ |
| value | string | Natural language description that must NOT match |
```yaml theme={"dark"}
# Ensure response is not an error
output:
not_semantic!: "An error message or failure notification"
# Ensure not rude or dismissive
output:
not_semantic!: "A rude, dismissive, or unhelpful response"
# Ensure not off-topic
output:
not_semantic!: "A response about unrelated topics"
```
## language!
Checks that the content is written in a specific language.
```yaml theme={"dark"}
output:
language!: "en"
```
| Parameter | Type | Description |
| --------- | ------ | ---------------------- |
| value | string | Expected language code |
```yaml theme={"dark"}
# Verify language output
output:
language!: "en"
output:
language!: "es"
output:
language!: "fr"
output:
language!: "ja"
```
### Common Language Codes
| Language | Code |
| ---------- | ------ |
| English | `"en"` |
| Spanish | `"es"` |
| French | `"fr"` |
| German | `"de"` |
| Italian | `"it"` |
| Portuguese | `"pt"` |
| Chinese | `"zh"` |
| Japanese | `"ja"` |
| Korean | `"ko"` |
| Arabic | `"ar"` |
## not\_language!
Checks that the content is NOT written in a specific language. This is the negated form of `language!`.
```yaml theme={"dark"}
output:
not_language!: "fr"
```
| Parameter | Type | Description |
| --------- | ------ | --------------------------------- |
| value | string | Language code that must NOT match |
```yaml theme={"dark"}
# Ensure response is not in French
output:
not_language!: "fr"
# Ensure English-only output
output:
language!: "en"
not_language!: "es"
```
## LLM vs Exact Matching
Use LLM validation (`prompt!`, `semantic!`) when:
* Output wording can vary but meaning must be consistent
* Testing for tone, style, or completeness
* Validating summaries or explanations
Use exact matching (`eq!`, `contains!`) when:
* Specific words or phrases must appear
* Validating structured data
* Checking for exact values
```yaml theme={"dark"}
# Use prompt for flexible content
output:
prompt!: "The response confirms the order was placed successfully"
# Use contains for required terms
output:
contains!: "Order #"
# Combine both approaches
output:
contains!: "confirmed"
prompt!: "The response confirms the order and includes the order details"
```
## Common Patterns
### Multi-Language Support Testing
```yaml theme={"dark"}
- name: responds_in_spanish
runnable: agent.py::agent
params:
prompt: "Hola, necesito ayuda"
output:
language!: "es"
prompt!: "The response offers help to the user"
- name: responds_in_french
runnable: agent.py::agent
params:
prompt: "Bonjour, j'ai besoin d'aide"
output:
language!: "fr"
prompt!: "The response offers help to the user"
```
### Behavioral Checks
```yaml theme={"dark"}
- name: refund_process_explained
runnable: agent.py::agent
params:
prompt: "I want a refund"
output:
prompt!: "The response acknowledges the refund request and explains the refund process"
- name: address_confirmed_before_order
runnable: agent.py::agent
params:
prompt: "Order this to my usual address"
output:
prompt!: "The assistant confirmed the shipping address before placing the order"
```
### Tone and Style Validation
```yaml theme={"dark"}
- name: professional_tone
runnable: agent.py::agent
params:
prompt: "I want a refund"
output:
semantic!: "A professional, empathetic response"
- name: casual_tone
runnable: agent.py::agent
params:
prompt: "Hey what's up"
output:
semantic!: "A casual, friendly greeting that matches the user's informal tone"
```
### Completeness Checks
```yaml theme={"dark"}
output:
prompt!: |
The response acknowledges the user's question, answers it directly,
provides additional context, and offers follow-up assistance
```
### Error Message Quality
```yaml theme={"dark"}
- name: helpful_error_message
runnable: agent.py::agent
params:
prompt: "Buy product XYZ123"
output:
prompt!: |
The response clearly states the product was not found, suggests
possible alternatives or corrections, and offers help finding
the right product
```
## Using Transforms
Transforms normalize content before LLM evaluation:
```yaml theme={"dark"}
# Normalize whitespace before the LLM check
output:
prompt!:
value: "The response greets the user professionally"
transform: [trim, collapse_whitespace]
# Lowercase before language detection
output:
language!:
value: "en"
transform: lowercase
```
## Combining with Other Validators
```yaml theme={"dark"}
output:
# Structure checks
not_null!: true
type!: "string"
min_length!: 50
# Content checks
contains!: "order"
not_contains!: "error"
# LLM validation
prompt!: "The response confirms the order and includes an estimated delivery date"
# Language check
language!: "en"
```
## Choosing Models
LLM validators use Timbal agents under the hood. By default they use `openai/gpt-5.4-nano`, but you can override the model per-validator in YAML.
```yaml theme={"dark"}
output:
# Use a larger OpenAI model for factual checks
prompt!:
value: "The assistant clearly explains the refund process"
model: "openai/gpt-5.2"
# Use an Anthropic model for language + semantics
language!:
value: "es"
model: "anthropic/claude-sonnet-4-6"
# Use a Gemini model for description matching
semantic!:
value: "A helpful, on-topic answer"
model: "google/gemini-2.0-flash"
```
LLM validators rely on **structured output**. For Anthropic, structured-output support is currently in beta and only available on a limited set of models (for example: Claude Sonnet 4.5/4.6, Claude Opus 4.5/4.6, Claude Haiku 4.5). Make sure you pick one of these Anthropic variants when you set the `model` field.
You can otherwise pick any model supported by Timbal’s `Agent` – the `model` field here is passed directly through to the underlying agent used for the validator, subject to the provider’s structured-output limitations.
## Cost Considerations
LLM validators make API calls to language models, which incur costs. To optimize:
1. **Use structural validators first**: Check `not_null!`, `contains!`, etc. before LLM validation
2. **Be specific in statements**: Reduces need for retries
3. **Group LLM checks**: One detailed statement vs. multiple simple ones
4. **Use for critical paths**: Reserve LLM validation for important behavioral checks
```yaml theme={"dark"}
# Efficient: structural checks catch obvious failures quickly
output:
not_null!: true # Fast, free
type!: "string" # Fast, free
min_length!: 20 # Fast, free
contains!: "order" # Fast, free
prompt!: "The response confirms the order completely" # LLM call only if above pass
```
# Type Validators
Source: https://docs.timbal.ai/evals/validators/type
Validators for type checking, JSON, email, and null validation
Type validators check the type and format of values, ensuring data conforms to expected structures.
Type validators generally don't use transforms since they check data types rather than string content. See [Validators Overview](/evals/validators#transforms) for transform documentation.
## not\_null!
Checks that a value exists and is not null/None.
```yaml theme={"dark"}
output:
not_null!: true
```
| Parameter | Type | Description |
| --------- | ------- | --------------------------------------------------- |
| value | boolean | `true` to require non-null, `false` to require null |
```yaml theme={"dark"}
# Ensure output exists
output:
not_null!: true
# Ensure tool output exists
get_weather:
output:
not_null!: true
# Ensure specific field exists
search_results:
output:
items:
not_null!: true
```
Use `not_null!: true` as a first validator to ensure the target exists before applying other validators.
## type!
Checks that a value is of a specific type.
```yaml theme={"dark"}
output:
type!: "string"
```
| Parameter | Type | Description |
| --------- | ------ | ----------- |
| value | string | Type name |
### Supported Types
| Type | Matches |
| -------- | ---------------------- |
| `string` | Strings |
| `int` | Integers |
| `float` | Floating-point numbers |
| `number` | Any numeric type |
| `bool` | Boolean values |
| `array` | Lists/arrays |
| `object` | Dictionaries/objects |
| `null` | None/null values |
```yaml theme={"dark"}
# String output
output:
type!: "string"
# Numeric result
calculate:
output:
type!: "number"
# Array of results
search_products:
output:
type!: "array"
# Object response
get_user:
output:
type!: "object"
# Boolean flag
validate:
output:
type!: "bool"
```
## not\_type!
Checks that a value is NOT of a specific type. This is the negated form of `type!`.
```yaml theme={"dark"}
output:
not_type!: "null"
```
| Parameter | Type | Description |
| --------- | ------ | ----------------------------- |
| value | string | Type name that must NOT match |
```yaml theme={"dark"}
# Ensure output is not null
output:
not_type!: "null"
# Ensure result is not a string (expect structured data)
process_data:
output:
not_type!: "string"
# Ensure not returning an error object
validate:
output:
not_type!: "object"
```
## json!
Checks that a string value is valid JSON.
```yaml theme={"dark"}
output:
json!: true
```
| Parameter | Type | Description |
| --------- | ------- | ---------------------------- |
| value | boolean | `true` to require valid JSON |
```yaml theme={"dark"}
# Ensure output is valid JSON
output:
json!: true
# Combine with content checks
output:
json!: true
contains!: '"status"'
contains!: '"success"'
```
The `json!` validator only checks that the string is valid JSON. It does not validate the JSON structure. Use `contains!` or `pattern!` for content checks.
## email!
Checks that a string value is a valid email format.
```yaml theme={"dark"}
extract_email:
output:
email!: true
```
| Parameter | Type | Description |
| --------- | ------- | ------------------------------------ |
| value | boolean | `true` to require valid email format |
```yaml theme={"dark"}
# Validate extracted email
extract_contact:
output:
email:
email!: true
```
## Common Patterns
### Structured Output Validation
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
json!: true
contains!: '"id"'
contains!: '"status"'
```
### Type-Safe Numeric Results
```yaml theme={"dark"}
calculate:
output:
type!: "number"
gte!: 0
lte!: 100
```
### Span Output Type Checking
```yaml theme={"dark"}
get_datetime:
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
search_products:
output:
type!: "array"
min_length!: 1
```
### Validating Nested Fields
```yaml theme={"dark"}
get_user:
output:
type!: "object"
# Or validate specific nested fields
get_user:
output:
profile:
name:
type!: "string"
not_null!: true
email:
email!: true
```
# Writing Evals
Source: https://docs.timbal.ai/evals/writing-evals
Learn how to write effective evals for your Timbal agents
This guide covers the complete syntax for writing evals, from basic tests to complex multi-validator scenarios.
## File Structure
Evals are defined in YAML files. Each file can contain multiple eval definitions:
```yaml theme={"dark"}
# eval_search.yaml
- name: basic_search
description: Test basic product search
runnable: agents/search.py::search_agent
params:
prompt: "Find me a laptop"
output:
contains!: "laptop"
type!: "string"
- name: search_with_filters
description: Test search with price filters
runnable: agents/search.py::search_agent
params:
prompt: "Find laptops under $1000"
output:
pattern!: "\\$\\d+"
```
## Eval Definition
### Required Fields
```yaml theme={"dark"}
- name: my_eval_name
runnable: path/to/agent.py::agent_name
```
Eval names must be unique across all eval files in your project. The CLI will error if duplicate names are found.
### Optional Fields
```yaml theme={"dark"}
- name: complete_example
description: "A thorough test of greeting behavior"
runnable: agent.py::agent
tags:
- greeting
- smoke-test
timeout: 30000 # Milliseconds
env:
API_KEY: "test-key"
params:
prompt: "Hello"
output:
prompt!: "The response greets the user in a friendly way"
elapsed:
lt!: 5000
```
## Params Structure
The `params` field contains input parameters passed to your runnable:
### Simple Prompt
```yaml theme={"dark"}
params:
prompt: "What's the weather like?"
```
### With Messages
Use `messages` to establish conversation history for multi-turn testing:
```yaml theme={"dark"}
params:
messages:
- role: user
content: "What's the weather like in New York?"
- role: assistant
content: "It's currently 15°C and raining in New York."
- role: user
content: "Should I bring an umbrella?"
```
The agent receives the full conversation history and responds to the last message. This is useful for testing context retention, memory, and whether the agent avoids redundant tool calls when context is already available.
When using `messages` instead of `prompt`, the agent's input key will be `messages` rather than `prompt`.
### Additional Parameters
Pass any additional parameters your agent accepts:
```yaml theme={"dark"}
params:
prompt: "Search for products"
max_results: 10
include_reviews: true
category: "electronics"
```
## Validating Output
The `output` section validates the final response from your agent:
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 10
contains!: "success"
not_contains!: "error"
pattern!: "Order #\\d{6}"
prompt!: "The response confirms the order and includes the order details"
```
Multiple validators can be combined - all must pass.
## Validating Timing
The `elapsed` section validates total execution time in milliseconds:
```yaml theme={"dark"}
elapsed:
lt!: 5000 # Less than 5 seconds
gte!: 100 # At least 100ms (not instant)
```
## Validating Tool Spans
Validate specific tools by their name:
```yaml theme={"dark"}
get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
starts_with!: "Europe/"
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
elapsed:
lt!: 1000
search_products:
input:
query:
contains!: "laptop"
limit:
lte!: 100
output:
type!: "array"
min_length!: 1
```
## Validating Token Usage
Access usage metrics on LLM spans:
```yaml theme={"dark"}
llm:
usage:
input_tokens:
lte!: 500
output_tokens:
lte!: 1000
```
The token field names depend on the model provider:
* **OpenAI**: Use `input_text_tokens` and `output_text_tokens` (e.g., `input_text_tokens: lte!: 500`, `output_text_tokens: lte!: 1000`)
* **Anthropic**: Use `input_tokens` and `output_tokens` (e.g., `input_tokens: lte!: 500`, `output_tokens: lte!: 1000`)
For multi-model scenarios, tokens are automatically summed across models.
## Flow Validators
### Sequence Validation
Use `seq!` to validate the order of tool execution:
```yaml theme={"dark"}
seq!:
- llm
- search_products
- llm
```
With wildcards for flexible matching:
```yaml theme={"dark"}
seq!:
- llm
- ... # Any number of spans
- send_email
```
### Parallel Validation
Use `parallel!` to validate concurrent execution:
```yaml theme={"dark"}
parallel!:
- get_weather
- get_datetime
- get_stock_price
```
### Nested Flow Validation
Combine sequence and parallel:
```yaml theme={"dark"}
seq!:
- llm
- parallel!:
- fetch_user
- fetch_orders
- llm
```
### Span Validation Within Sequence
Validate span inputs/outputs within the sequence:
```yaml theme={"dark"}
seq!:
- llm:
elapsed:
lt!: 3000
usage:
input_tokens:
lte!: 500
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
output:
type!: "string"
- llm
```
## Complete Example
```yaml theme={"dark"}
- name: time_query
description: "Test time query with validation"
runnable: "agent.py::agent"
tags: ["datetime", "smoke"]
timeout: 30000
params:
prompt: "what time is it in madrid"
# Validate final output
output:
type!: "string"
min_length!: 10
contains!: ":"
pattern!: "\\d{1,2}:\\d{2}"
prompt!: "The response states the current time in Madrid"
# Validate timing
elapsed:
lt!: 6000
# Validate execution sequence
seq!:
- llm:
elapsed:
lte!: 40000
usage:
input_tokens:
lte!: 1000
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
starts_with!: "Europe/"
output:
type!: "string"
pattern!: "^\\d{4}-\\d{2}-\\d{2}"
- llm
```
## Wildcard Patterns in Sequences
| Pattern | Description |
| ------- | ------------------------------- |
| `..` | Exactly 1 span |
| `...` | Any number of spans (0 or more) |
| `n..m` | Between n and m spans |
| `n..` | At least n spans |
| `..m` | At most m spans |
```yaml theme={"dark"}
seq!:
- llm
- 1..3 # 1 to 3 spans
- validate_input
- ... # Any number
- send_response
```
## Tags and Filtering
Use tags to organize and filter evals:
```yaml theme={"dark"}
- name: smoke_test_greeting
tags:
- smoke
- greeting
- quick
runnable: agent.py::agent
params:
prompt: "Hi"
output:
not_null!: true
```
## Environment Variables
Set environment variables for specific evals:
```yaml theme={"dark"}
- name: test_with_api_key
runnable: agent.py::agent
env:
API_KEY: "test-key-123"
DEBUG: "true"
params:
prompt: "Fetch data"
output:
not_null!: true
```
## Best Practices
Names should clearly indicate what's being tested:
```yaml theme={"dark"}
# Good
- name: search_returns_relevant_products
- name: handles_empty_search_results
- name: validates_timezone_input
# Avoid
- name: test1
- name: search_test
```
Use multiple validators to thoroughly test behavior:
```yaml theme={"dark"}
output:
not_null!: true
type!: "string"
min_length!: 50
contains!: "product"
not_contains!: "error"
prompt!: "The response recommends products and includes their prices"
```
Don't just check the output - validate how the agent got there:
```yaml theme={"dark"}
output:
prompt!: "The response provides weather information for Madrid"
seq!:
- llm
- get_weather:
input:
city:
eq!: "Madrid"
- llm
```
For outputs that can vary in wording but should convey the same meaning:
```yaml theme={"dark"}
# Instead of exact matching
output:
eq!: "The current time in Madrid is 14:30."
# Use an LLM-checked statement
output:
prompt!: "The response indicates the current time in Madrid"
```
Prefer `prompt!` (a verifiable statement about the output) for behavioral checks. Use `semantic!` when matching against a description of tone or style, e.g. `semantic!: "A professional, empathetic response"`.
Create specific evals for error conditions:
```yaml theme={"dark"}
- name: handles_invalid_timezone
runnable: agent.py::agent
params:
prompt: "what time is it in xyzland"
output:
prompt!: "The response indicates the timezone or location is not recognized"
```
## File Naming Conventions
Timbal discovers eval files matching these patterns:
* `eval*.yaml` - e.g., `eval_search.yaml`, `evals.yaml`
* `*eval.yaml` - e.g., `search_eval.yaml`, `my_eval.yaml`
Organize evals by feature or agent:
```
evals/
├── eval_search.yaml
├── eval_support.yaml
├── eval_checkout.yaml
└── regression/
├── eval_smoke.yaml
└── eval_full.yaml
```
# Adaptive System Prompts
Source: https://docs.timbal.ai/examples/agents/adaptive-system-prompts
Adapt agent behavior based on detected context or origin
Combine `pre_hook` with a dynamic system prompt function to adapt agent behavior based on detected context.
This example shows how to detect the origin platform and adjust the system prompt accordingly:
```python theme={"dark"}
from timbal import Agent
from timbal.state import get_run_context
def get_system_prompt() -> str:
client_chain = get_run_context().current_span().client_chain
prompts = {
"whatsapp": "You are a WhatsApp business assistant. Keep responses concise and friendly. Use emojis appropriately.",
"slack": "You are a Slack bot for internal team communication. Be professional but approachable. Use Slack formatting.",
}
return prompts.get(client_chain, "You are a helpful assistant.")
def pre_hook():
span = get_run_context().current_span()
prompt_text = span.input.get("prompt")
if "whatsapp" in prompt_text:
span.client_chain = "whatsapp"
elif "slack" in prompt_text:
span.client_chain = "slack"
else:
span.client_chain = "unknown"
agent = Agent(
name="ContextAwareAgent",
model="openai/gpt-4o-mini",
system_prompt=get_system_prompt,
pre_hook=pre_hook
)
# Different origins get different system prompts
result1 = await agent(prompt="whatsapp: Hi! I need help").collect()
result2 = await agent(prompt="slack: @bot Can you help?").collect()
result3 = await agent(prompt="Hi! I need help").collect()
```
## Key Features
* **Pre-hook Detection**: Use `pre_hook` to detect context or origin from message content
* **Dynamic System Prompts**: System prompt adapts based on detected context
* **Platform-Specific Responses**: Different tones and formats per platform
* **Combined Pattern**: Shows how to combine hooks with dynamic system prompts
# Approval-Required Tools
Source: https://docs.timbal.ai/examples/agents/approval-required-tools
Pause an agent for human approval, capture the gate, and resume with a decision — including redaction and audit fields
This example wires up a refund tool that gates above \$100, captures the `ApprovalEvent` mid-stream, and resumes with a decision. See the full reference in the [Human in the Loop](/human-in-the-loop) section.
```python theme={"dark"}
import asyncio
from timbal import Agent, Tool
from timbal.types.approval import ApprovalResolution
from timbal.types.events import ApprovalEvent
def refund_customer(amount: int, customer_id: str) -> str:
return f"refunded ${amount} to {customer_id}"
refund = Tool(
handler=refund_customer,
requires_approval=lambda amount, **_: amount > 100,
approval_prompt=lambda amount, customer_id: (
f"Approve refunding ${amount} to {customer_id}?"
),
approval_redact_keys=["customer_id"],
)
agent = Agent(
name="support_agent",
model="openai/gpt-5",
tools=[refund],
)
async def main() -> None:
# First call — agent will try to refund and pause for approval.
pending: list[ApprovalEvent] = []
async for event in agent(prompt="Refund $250 to customer C-42"):
if isinstance(event, ApprovalEvent):
pending.append(event)
# Show the reviewer the redacted prompt + input.
for event in pending:
print(event.prompt, event.input)
# event.input["customer_id"] == "***" (redacted)
# Second call — resume with the reviewer's decision.
decisions = {
event.approval_id: ApprovalResolution(
approved=True,
approver_id="user_42",
comment="Verified invoice and customer history.",
)
for event in pending
}
result = await agent(
prompt="Refund $250 to customer C-42",
resume=decisions,
).collect()
print(result.output.collect_text())
# Refund executed, agent reports success.
asyncio.run(main())
```
## Resuming From a Different Process
To approve in a UI now and resume in a worker later, configure a durable tracing provider and pass `parent_id`:
```python theme={"dark"}
from pathlib import Path
from timbal.state.tracing.providers import JsonlTracingProvider
provider = JsonlTracingProvider.configured(_path=Path("traces.jsonl"))
agent = Agent(
name="support_agent",
model="openai/gpt-5",
tools=[refund],
tracing_provider=provider,
)
# In the worker, after the reviewer decided:
result = await agent(
prompt="Refund $250 to customer C-42",
parent_id=paused_run_id,
resume={approval_id: True},
).collect()
if result.status.reason == "approval_already_claimed":
# Another worker already resumed this approval. Safe to no-op.
return
```
`JsonlTracingProvider` (and `SqliteTracingProvider`) implement durable `(parent_id, approval_id)` claims, so two workers racing on the same gate will not both execute the handler.
## Denying With a Reason
When the agent calls a denied tool, Timbal converts the denial into a `ToolResultContent` so the model can react (apologize, escalate, try another path) instead of crashing:
```python theme={"dark"}
result = await agent(
prompt="Refund $250 to customer C-42",
resume={
approval_id: ApprovalResolution(
approved=False,
reason="Refund exceeds policy limit.",
approver_id="user_42",
)
},
).collect()
```
For direct tool calls (no agent), denial returns `status.reason == "approval_denied"` and the handler does not run.
## Key Features
* **Callable policy** — `requires_approval=lambda amount, **_: amount > 100` runs against the validated handler input
* **Redaction** — `approval_redact_keys` masks fields in the public approval surface; the handler still receives unredacted input
* **Audit fields** — `approver_id`, `comment`, `decided_at` persist under `span.metadata["approval"]["resolution"]`
* **Durable resume** — pair with `JsonlTracingProvider` / `SqliteTracingProvider` and `parent_id` to span processes
* **Duplicate protection** — `claim_approval` ensures a single worker resumes each gate
# Asking the User
Source: https://docs.timbal.ai/examples/agents/ask-user
Interaction tools built on suspend() — free text, single/multi choice, confirmation, structured forms, ratings, uploads, and generative-UI review — each with its own payload shape
`suspend()` lets a tool pause the run and hand control to the user, then resume with whatever they send back. Each tool picks a `kind` (the frontend's renderer discriminator) and a `payload` (what to render). The resume value can be **any JSON type** — a string, a bool, a list, a dict — so the same primitive covers everything from a yes/no to a multi-field form.
See the full reference in the [Human in the Loop](/human-in-the-loop) section. This page is a catalog of shapes you can copy.
## A catalog of interaction tools
```python theme={"dark"}
from timbal import suspend
def ask_text(question: str) -> str:
"""Ask the user an open-ended question. Resumes with their typed answer."""
return suspend({"question": question}, kind="ask_text")
def ask_choice(question: str, options: list[str]) -> str:
"""Ask the user to pick exactly one option. Renders as a single-select."""
return suspend({"question": question, "options": options}, kind="ask_choice")
def ask_user_multi(question: str, options: list[str]) -> list[str]:
"""Ask the user to pick any number of options. Resumes with a list."""
return suspend({"question": question, "options": options}, kind="ask_user_multi")
def confirm(action: str) -> bool:
"""Ask the user to confirm before proceeding. Resumes with a bool."""
return bool(suspend({"action": action}, kind="confirm"))
def ask_form(title: str, fields: list[dict]) -> dict:
"""Collect several values at once. Resumes with a {field_name: value} dict."""
return suspend({"title": title, "fields": fields}, kind="ask_form")
def ask_rating(question: str, scale: int = 5) -> int:
"""Ask for a rating on a 1..scale scale. Resumes with an int."""
return int(suspend({"question": question, "scale": scale}, kind="ask_rating"))
def request_upload(prompt: str, accept: list[str]) -> str:
"""Ask the user to upload a file. Resumes with a URL or file id."""
return suspend({"prompt": prompt, "accept": accept}, kind="request_upload")
def review_chart(title: str, series: list[dict]) -> dict:
"""Generative UI: render a chart for the user and wait for their tweaks.
Resumes with {"approved": bool, "edits": {...}}."""
return suspend({"title": title, "series": series}, kind="review_chart")
```
`suspend` is exported at the top level (`from timbal import suspend`). Ready-made `ask_user`, `ask_user_multi`, and `confirm` also ship in `timbal.tools` (`from timbal.tools import ask_user, ask_user_multi, confirm`).
The handler **re-executes from the top** on resume, so put `suspend()` before any non-idempotent side-effect (or make everything before it idempotent). For irreversible actions, gate them with [`requires_approval`](/human-in-the-loop/approval-gates) instead — that pauses *before* any handler code runs.
## Payload shapes at a glance
This is the contract your frontend renders against. Switch your UI on `kind`, render `payload`, and send the matching resume value back keyed by `interaction_id`.
| `kind` | `payload` shape | resume value |
| ---------------- | --------------------------------------------------- | -------------------------------------- |
| `ask_text` | `{ "question": str }` | `str` |
| `ask_choice` | `{ "question": str, "options": [str] }` | `str` (one option) |
| `ask_user_multi` | `{ "question": str, "options": [str] }` | `[str]` |
| `confirm` | `{ "action": str }` | `bool` |
| `ask_form` | `{ "title": str, "fields": [{name, label, type}] }` | `{ name: value }` |
| `ask_rating` | `{ "question": str, "scale": int }` | `int` |
| `request_upload` | `{ "prompt": str, "accept": [str] }` | `str` (url / file id) |
| `review_chart` | `{ "title": str, "series": [...] }` | `{ "approved": bool, "edits": {...} }` |
## Driving the loop
Give an agent whichever interaction tools fit your product, then run the pause/resume loop. The agent decides which tool to call; you render the payload and resume with the value.
```python theme={"dark"}
import asyncio
from timbal import Agent
from timbal.types.events import InteractionEvent, OutputEvent
agent = Agent(
name="onboarding_assistant",
model="openai/gpt-5",
tools=[ask_text, ask_choice, ask_user_multi, confirm, ask_form, ask_rating],
system_prompt=(
"You onboard new users. Gather what you need by calling the interaction "
"tools — never guess a value the user hasn't given you."
),
)
def render_and_collect(kind: str, payload: dict):
"""Your UI. Return the value matching the kind (see the table above)."""
if kind == "confirm":
return True
if kind == "ask_choice":
return payload["options"][0]
if kind == "ask_user_multi":
return payload["options"][:2]
if kind == "ask_form":
return {f["name"]: "..." for f in payload["fields"]}
if kind == "ask_rating":
return payload["scale"]
return "Acme Inc." # ask_text / request_upload / ...
async def main() -> None:
prompt = "Help me set up my workspace."
while True:
pending: list[InteractionEvent] = []
final: OutputEvent | None = None
async for event in agent(prompt=prompt, parent_id=getattr(main, "_run_id", None)):
if isinstance(event, InteractionEvent):
pending.append(event)
if isinstance(event, OutputEvent) and event.path == "onboarding_assistant":
final = event
# Finished — no more questions.
if final.status.reason != "input_required":
print(final.output.collect_text())
return
# Answer every pending question and resume from this run.
main._run_id = final.run_id
resume = {
it.interaction_id: render_and_collect(it.kind, it.payload)
for it in pending
}
result = await agent(prompt=prompt, parent_id=final.run_id, resume=resume).collect()
if result.status.reason != "input_required":
print(result.output.collect_text())
return
main._run_id = result.run_id
asyncio.run(main())
```
A single turn can open **multiple** interactions at once (the model calls several tools in parallel). You receive one `InteractionEvent` per question and send all answers back in one `resume` map: `{ "": ..., "": ... }`. Approval gates ride the same channel — mix freely.
## Structured form example
`ask_form` is the workhorse for collecting several values in one round-trip instead of a chain of questions:
```python theme={"dark"}
def collect_company_profile() -> dict:
return ask_form(
title="Company profile",
fields=[
{"name": "company_name", "label": "Company name", "type": "text"},
{"name": "team_size", "label": "Team size", "type": "number"},
{"name": "industry", "label": "Industry", "type": "select",
"options": ["SaaS", "Fintech", "Healthcare", "Other"]},
{"name": "wants_newsletter", "label": "Subscribe to updates?", "type": "boolean"},
],
)
```
The emitted `InteractionEvent.payload` is exactly the dict you passed; resume with the filled values:
```python theme={"dark"}
resume = {
interaction_id: {
"company_name": "Acme Inc.",
"team_size": 25,
"industry": "SaaS",
"wants_newsletter": True,
}
}
```
## Generative UI: render, then continue
`review_chart` shows how the same mechanism powers "render something, let the user tweak it, then keep going". The tool emits the data to draw; the resume value carries the user's edits back into the run:
```python theme={"dark"}
def propose_dashboard(metric: str) -> dict:
decision = review_chart(
title=f"{metric} over the last 30 days",
series=[{"label": metric, "points": fetch_points(metric)}],
)
# decision == {"approved": True, "edits": {"range": "90d"}}
return decision
```
If the user changes the range, you get `{"approved": True, "edits": {"range": "90d"}}` back and the handler continues with their choice — no separate "apply" endpoint needed.
## Resuming across processes
Everything above works in-process. To pause in a browser now and resume in a worker later, configure a durable provider and pass `parent_id` on resume:
```python theme={"dark"}
from pathlib import Path
from timbal.state.tracing.providers import JsonlTracingProvider
agent = Agent(
name="onboarding_assistant",
model="openai/gpt-5",
tools=[ask_text, ask_form, confirm],
tracing_provider=JsonlTracingProvider.configured(_path=Path("traces.jsonl")),
)
# Later, in any process:
result = await agent(
prompt="Help me set up my workspace.",
parent_id=paused_run_id,
resume={interaction_id: "Acme Inc."},
).collect()
```
See [Resuming a paused run](/human-in-the-loop/resuming) and [Client integration (HTTP)](/human-in-the-loop/client-integration) for the durable providers and the HTTP `/stream` wire contract your frontend talks to.
# Audio
Source: https://docs.timbal.ai/examples/agents/audio
AI agents can process audio files for transcription and analysis using speech-to-text capabilities
Transcribe audio files in a `pre_hook` before processing.
This example uses OpenAI's transcription API, but you can use any transcription service (ElevenLabs, Google Cloud, or custom implementations).
The `[Audio]` prefix is added to the transcribed text to clearly indicate that the content originated from an audio file. This helps the agent understand the context and source of the information, which can be useful for:
* **Context Awareness**: The agent knows the text came from audio transcription
* **Mixed Content**: When combining text and audio in the same prompt, the prefix distinguishes transcribed content
* **Traceability**: Makes it easier to track which parts of the conversation came from audio vs. text input
```python theme={"dark"}
from timbal import Agent
from timbal.state import get_run_context
from timbal.types.file import File
from timbal.types.content import content_factory
import os
from openai import AsyncOpenAI
async def stt(audio_file: File) -> str:
"""Transcribe an audio file."""
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
transcript = await client.audio.transcriptions.create(
model="whisper-1",
file=audio_file,
)
return transcript.text
async def pre_hook():
"""Transcribe audio files before processing."""
span = get_run_context().current_span()
prompt = span.input.get("prompt")
# Transcribe audio file and add prefix
if (isinstance(prompt, File) and
prompt.__content_type__ and
prompt.__content_type__.startswith("audio/")):
transcription = await stt(prompt)
span.input["prompt"] = content_factory(f"[Audio]: {transcription}")
agent = Agent(
name="AudioAgent",
model="openai/gpt-4.1-mini",
pre_hook=pre_hook
)
audio_file = File.validate("/path/to/recording.wav")
result = await agent(prompt=audio_file).collect()
```
This example uses OpenAI's transcription API directly. For more advanced features like language detection, timestamps, and better error handling, refer to the [OpenAI Audio API documentation](https://platform.openai.com/docs/guides/speech-to-text).
## Key Features
* **Pre-hook Transcription**: Audio is transcribed before the agent processes it
* **Any Model**: Works with any text model, not just audio-capable ones
* **Flexible Providers**: Use any transcription service (OpenAI, ElevenLabs, or custom)
* **Audio Prefix**: The "\[Audio]" prefix clearly indicates transcribed content
* **File Support**: Works with local files, URLs, and base64 data
# Content-Aware Tools
Source: https://docs.timbal.ai/examples/agents/content-aware-tools
Use ToolSet to resolve tools dynamically based on content type - automatically select image or PDF tools
Use `ToolSet` to resolve tools dynamically at runtime. This example shows how tools automatically adapt based on the content type:
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.core.tool_set import ToolSet
from timbal.state import get_run_context
from timbal.types.file import File
def analyze_image(image_path: str) -> str:
"""Analyze an image using computer vision."""
return f"Image analysis for: {image_path}"
def analyze_pdf(pdf_path: str) -> str:
"""Analyze a PDF document."""
return f"Analysis for: {pdf_path}"
def get_file_type(file_url: File) -> str:
"""Detect file type from content type or extension."""
content_type = file_url.__content_type__
if content_type and content_type.startswith("image/"):
return "image"
elif content_type and content_type.startswith("application/pdf"):
return "pdf"
return "unknown"
class FileTypeBasedToolSet(ToolSet):
async def resolve(self) -> list[Tool]:
span = get_run_context().current_span()
prompt = span.input.get("prompt", "")
tools = []
if isinstance(prompt, File):
file_type = get_file_type(prompt)
if file_type == "image":
tools = [Tool(handler=analyze_image)]
elif file_type == "pdf":
tools = [Tool(handler=analyze_pdf)]
return tools
agent = Agent(
name="FileTypeAwareAgent",
model="openai/gpt-5.2",
tools=[FileTypeBasedToolSet()]
)
# It does not have any tool available
result1 = await agent(
prompt="What is Python?",
).collect()
# Image analysis tool added
result2 = await agent(
prompt=File.validate('/path/to/image.jpg')
).collect()
# PDF analysis tool added
result3 = await agent(
prompt=File.validate('/path/to/document.pdf'),
).collect()
```
The `resolve()` method is called before each LLM call. It detects the file type and returns different tools:
* **Text/no file**: No tools available
* **Image files**: Adds `analyze_image`
* **PDF files**: Adds `analyze_pdf`
## Key Features
* **File Type Detection**: Automatically detects file type from content type
* **Context-Aware Tools**: Only relevant tools are exposed based on content type
* **Token Efficiency**: Reduces token usage by showing only necessary tools
* **Flexible**: Easily extend to support more file types
# CSV
Source: https://docs.timbal.ai/examples/agents/csv
AI agents can analyze and process CSV data files for insights and data manipulation
Agents automatically handle CSV files when included in prompts. First validate your CSV file with Timbal's `File` type, then pass it alongside text in a list:
```python theme={"dark"}
from timbal import Agent
from timbal.types.file import File
agent = Agent(
name="DataAgent",
model="openai/gpt-5",
system_prompt="Analyze CSV data and provide insights."
)
# Validate CSV file and analyze
csv_file = File.validate("path/to/data.csv")
result = await agent(
prompt=["What insights can you find in this data?", csv_file]
).collect()
print(result.output.collect_text())
```
## Key Features
* **Automatic Processing**: CSV files are automatically parsed and converted to the correct format
* **Data Analysis**: Extract insights, patterns, and summaries from structured data
* **File Support**: Works with local files, URLs, and base64 data
# Custom Functions in System Prompts
Source: https://docs.timbal.ai/examples/agents/custom-functions-system-prompts
Use your own functions to build dynamic system prompts that include real-time data and custom logic
Pass a function to `system_prompt` that builds the prompt using your custom functions:
```python theme={"dark"}
from datetime import datetime
from timbal import Agent
from timbal.state import get_run_context
def get_system_prompt() -> str:
span = get_run_context().current_span()
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
user_id = span.input.get("user_id", "unknown")
return f"""You are a helpful assistant.
Current time: {current_time}
User: {user_id}"""
agent = Agent(
name="TimeAwareAgent",
model="openai/gpt-4o-mini",
system_prompt=get_system_prompt
)
```
Your function is executed each time the agent runs, ensuring the system prompt always contains up-to-date information.
## Key Features
* **Custom Functions**: Define your own functions to fetch and format data
* **Automatic Execution**: Your function is called each time the agent runs
* **Access Runtime Context**: Use `get_run_context()` to access current execution data and user inputs
* **Full Control**: Build the system prompt dynamically with any logic you need
* **Real-time Data**: Perfect for integrating APIs, databases, or any dynamic data source
# Word
Source: https://docs.timbal.ai/examples/agents/docx
AI agents can read and analyze Microsoft Word documents for content extraction and processing
Agents automatically handle DOCX files when included in prompts. First validate your DOCX file with Timbal's `File` type, then pass it alongside text in a list:
```python theme={"dark"}
from timbal import Agent
from timbal.types.file import File
agent = Agent(
name="DocumentAgent",
model="gemini/gemini-2.5-pro",
system_prompt="Analyze Word documents and extract key information."
)
# Validate DOCX file and analyze
docx_file = File.validate("path/to/document.docx")
result = await agent(
prompt=["Summarize this document and highlight the main sections", docx_file]
).collect()
print(result.output.collect_text())
```
## Key Features
* **Automatic Processing**: DOCX files are automatically parsed and converted to readable format
* **Document Analysis**: Extract text, structure, and insights from Word documents
* **File Support**: Works with local files, URLs, and base64 data
# Email
Source: https://docs.timbal.ai/examples/agents/emls
AI agents can process and summarize email files for quick content extraction and analysis
Agents can analyze EML files to extract key information and provide summaries. First validate your EML file with Timbal's `File` type, then pass it alongside text in a list:
```python theme={"dark"}
from timbal import Agent
from timbal.types.file import File
agent = Agent(
name="EmailSummarizer",
model="openai/gpt-4.1-mini",
system_prompt="Summarize emails and extract key information like sender, subject, main points, and action items."
)
# Validate EML file and summarize
eml_file = File.validate("path/to/email.eml")
result = await agent(
prompt=["Summarize this email and extract the key points", eml_file]
).collect()
print(result.output.collect_text())
```
## Key Features
* **Email Summarization**: Extract key information from email content
* **Structured Analysis**: Identify sender, subject, main points, and action items
* **File Support**: Works with local files, URLs, and base64 data
# Execution Behavior
Source: https://docs.timbal.ai/examples/agents/execution-behavior
Validate agent execution flow, tool usage, and sequence of operations
Execution behavior validation ensures your agent follows the expected workflow. Verify tool selection, parameter values, execution order, and sequence compliance using flow validators.
## Example
This example demonstrates how to validate complex execution flows with parallel tool execution and sequential processing using nested `seq!` and `parallel!` validators.
### Eval Configuration
```yaml evals.yaml theme={"dark"}
- name: eval_travel_assistant_workflow
description: Fetch data in parallel then processes sequentially
runnable: agent.py::agent
params:
prompt: "I'm planning a trip to Madrid. What's the current time, the weather forecast, and flight prices?"
seq!:
- llm
- parallel!:
- get_datetime:
input:
timezone:
eq!: "Europe/Madrid"
- get_weather:
input:
city:
eq!: "Madrid"
- search_flights:
input:
destination:
contains!: "Madrid"
- llm
```
### Agent Implementation
```python agent.py theme={"dark"}
from timbal import Agent
async def get_datetime(timezone: str) -> str:
"""Get current datetime for a timezone."""
return f"2024-01-15 14:30:00 {timezone}"
async def get_weather(city: str) -> str:
"""Get weather forecast for a city."""
weather_data = {
"Madrid": "Sunny, 18°C",
"Barcelona": "Cloudy, 15°C"
}
return weather_data.get(city, "Weather data unavailable")
async def search_flights(destination: str) -> str:
"""Search for flights to a destination."""
return f"Found flights to {destination}"
agent = Agent(
name="travel_assistant",
model="openai/gpt-5.2",
tools=[get_datetime, get_weather, search_flights],
)
```
### Running Evaluations
```bash theme={"dark"}
python -m timbal.evals.cli evals.yaml
```
## How It Works
1. **Initial Processing**: The agent starts with an LLM call to understand the request.
2. **Parallel Execution**: Two tools (`get_datetime` and `get_weather`) execute in parallel using `parallel!`, improving efficiency by fetching independent data simultaneously.
3. **Sequential Processing**: After parallel execution completes, `search_flights` runs sequentially, as it may depend on the previous results.
4. **Final Processing**: A final LLM call synthesizes all the gathered information into a response.
5. **Validation**: The `seq!` validator ensures:
* Tools execute in the correct order
* Parallel tools run simultaneously (overlapping execution times)
* Tool inputs match expected values using nested validators
## Evaluation Results
### Successful Validation
When the agent follows the expected workflow with parallel and sequential execution:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
PASSED evals.yaml::eval_travel_assistant_workflow [4.65s]
└── travel_assistant
└── ✓ seq!
├── llm
├── ✓ parallel!
│ ├── get_datetime
│ │ └── ✓ input.timezone.eq! ("Europe/Madrid")
│ └── get_weather
│ └── ✓ input.city.eq! ("Madrid")
├── search_flights
│ └── ✓ input.destination.contains! ("Madrid")
└── llm
============================= 1 passed in 4.65s ==============================
```
### Failed Validation
When the agent doesn't follow the expected execution pattern:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
FAILED evals.yaml::eval_travel_assistant_workflow [4.36s]
└── travel_assistant
└── ✗ seq!
├── llm
├── ✗ parallel!
│ ├── get_datetime
│ │ └── ✓ input.timezone.eq! ("Europe/Madrid")
│ └── get_weather
│ └── ✓ input.city.eq! ("Madrid")
├── search_flights
│ └── ✓ input.destination.contains! ("Madrid")
└── llm
!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 1 failed in 4.36s !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
## Key Features
* **Nested Flow Validation**: Combine `seq!` and `parallel!` to validate complex execution patterns
* **Parallel Execution**: Use `parallel!` to ensure independent tools run simultaneously for better performance
* **Sequential Processing**: Use `seq!` to enforce order when tools depend on previous results
* **Tool Input Validation**: Validate tool input parameters using nested validators within flow validators
* **Span Validation**: Validate individual span properties (input, output, elapsed, usage) within sequences
* **Workflow Compliance**: Ensure agents follow expected execution patterns and optimize tool usage
# Image Generation
Source: https://docs.timbal.ai/examples/agents/image-generation
AI agents can generate images from text descriptions using OpenAI's image generation tools
Agents can create images from text prompts using image generation tools. This example uses a post hook to save generated images to disk and return file paths:
```python theme={"dark"}
import os
from openai import AsyncOpenAI
from pydantic import Field
from timbal import Agent, Tool
from timbal.state import get_run_context
from timbal.types.file import File
async def gen_images(
prompt: str = Field(
...,
description="A text description of the desired image(s). Max length: 32000 characters.",
max_length=32000,
),
) -> list[File]:
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
kwargs = {}
if prompt is not None:
kwargs["prompt"] = prompt
res = await client.images.generate(
model="gpt-image-1",
**kwargs,
)
files = [File.validate(f"data:image/png;base64,{image.b64_json}") for image in res.data]
return files
def post_hook():
"""Simple post hook to save files to disk."""
span = get_run_context().current_span()
if hasattr(span.output, '__iter__') and span.output:
saved_files = []
for i, file_obj in enumerate(span.output):
if hasattr(file_obj, 'to_disk'):
# Save to specific directory
output_dir = Path("generated_images")
output_dir.mkdir(exist_ok=True)
file_path = output_dir / f"image_{i+1}.png"
file_obj.to_disk(file_path)
saved_files.append(str(file_path))
if saved_files:
span.output = saved_files
# Create tool with post hook
gen_images_with_save = Tool(
name="gen_images",
handler=gen_images,
post_hook=post_hook
)
agent = Agent(
name="ImageAgent",
model="openai/gpt-4.1",
tools=[gen_images_with_save],
system_prompt="Generate images from text descriptions when requested."
)
result = await agent(
prompt="Create a picture of a cat."
).collect()
print("Generated image saved to:", result.output.collect_text())
```
## Key Features
* **High Quality**: Generate professional-quality images
* **Customizable**: Control size, style, and format
* **File Output**: Returns image files ready for use
* **Post Hooks**: Automatically save files to disk and return file paths
# Images
Source: https://docs.timbal.ai/examples/agents/images
AI agents can analyze and understand images by processing visual content alongside text instructions
Agents automatically handle image files when included in prompts. First validate your image file with Timbal's `File` type, then pass it alongside text in a list:
```python theme={"dark"}
from timbal import Agent
from timbal.types.file import File
agent = Agent(
name="VisionAgent",
model="anthropic/claude-sonnet-4-6", # Vision-capable model
max_tokens=256,
system_prompt="Analyze images and provide detailed descriptions."
)
# Validate multiple image files and analyze
image1 = File.validate("path/to/image1.jpg")
image2 = File.validate("path/to/image2.png")
image3 = File.validate("path/to/image3.jpeg")
result = await agent(
prompt=["Analyze these images and provide a description of each one", image1, image2, image3]
).collect()
print(result.output.collect_text())
```
Use vision-capable models for image processing. Check the [Model Reference](/models/overview) for models with vision support.
## Key Features
* **Automatic Processing**: Images are automatically converted to the correct format for vision models
* **Multi-modal**: Combine text and images in the same conversation
* **File Support**: Works with local files, URLs, and base64 data
# Input Parameters
Source: https://docs.timbal.ai/examples/agents/input-validation
Pass input parameters to agents and validate the output response
Parameters in `params` become part of the agent's input, and you can only validate the resulting output response.
## Example
This example demonstrates how to pass input parameters to the agent and validate the output. The `premium_user` parameter is passed via `params` and becomes part of the agent's input, which can be accessed in the system prompt function.
### Eval Configuration
```yaml evals.yaml theme={"dark"}
- name: eval_input_has_points
description: Pass premium_user parameter and validate output contains points information
runnable: agent.py::agent
params:
prompt: "Hello, I'm a premium user"
premium_user: True
output:
contains!: "10"
```
### Agent Implementation
```python agent.py theme={"dark"}
from timbal import Agent
from timbal.state import get_run_context
def get_system_prompt() -> str:
"""Build system prompt with premium_user status from input."""
span = get_run_context().current_span()
premium_user = span.input.get("premium_user", False)
return f"""You are a points system assistant.
Premium users have 10 points.
Premium user: {premium_user}"""
agent = Agent(
name="points_agent",
model="openai/gpt-4.1-mini",
system_prompt=get_system_prompt
)
```
### Running Evaluations
```bash theme={"dark"}
python -m timbal.evals.cli evals.yaml
```
## How It Works
1. **Parameters**: The `premium_user: True` parameter is passed via `params` and becomes part of the agent's input.
2. **System Prompt**: The `get_system_prompt()` function accesses the `premium_user` value from the input using `get_run_context()` and builds the system prompt dynamically.
3. **Output Validation**: The output is validated to ensure it contains "10" (the points for premium users).
## Evaluation Results
The CLI displays pytest-style output with pass/fail status:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
PASSED evals.yaml::eval_input_has_points [0.45s]
└── points_agent
└── ✓ output.contains! ("10")
============================= 1 passed in 0.45s ==============================
```
When validation fails, the CLI shows detailed error information:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
FAILED evals.yaml::eval_input_has_points [0.48s]
└── points_agent
└── ✗ output.contains! ("10")
Expected: "10"
Actual: "You currently have 0 points..."
!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 1 failed in 1.94s !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
## Key Features
* **Input Parameters**: Pass parameters directly to the agent via `params` - these become part of the agent's input
* **Dynamic System Prompts**: Use callable functions for `system_prompt` to access input parameters and build prompts dynamically
* **Output Validation**: Validate agent responses using output validators - input parameters cannot be validated directly
# Multi-turn Conversations
Source: https://docs.timbal.ai/examples/agents/multi-turn-conversation
Test agent memory, context retention, and complex conversational flows across multiple interactions
Multi-turn conversation testing ensures your agent maintains context across multiple interactions. Use `params.messages` to establish conversation history and validate the agent's response to the final message.
## Example
This example demonstrates how to test multi-turn conversations by passing a full conversation history via `params.messages` and validating that the agent uses context appropriately.
### Eval Configuration
```yaml evals.yaml theme={"dark"}
- name: eval_weather_advice
description: Test agent uses weather data to provide appropriate advice
runnable: agent.py::agent
params:
messages:
- role: user
content: "What's the weather like in New York?"
- role: assistant
content: "It's currently 15°C and raining in New York."
- role: user
content: "Should I bring an umbrella?"
output:
contains!:
value: "yes"
transform: lowercase
seq!:
- llm
# Only llm should be called
# get_weather should not be called since context is available
```
### Agent Implementation
```python agent.py theme={"dark"}
from timbal import Agent
def get_weather(location: str) -> str:
"""Get current weather information for a specific location."""
weather_data = {
"New York": "15°C and raining",
"London": "12°C and cloudy",
"Tokyo": "22°C and sunny"
}
return weather_data.get(location, f"Weather data not available for {location}")
agent = Agent(
name="weather_agent",
model="openai/gpt-5.2",
system_prompt="You are a helpful weather assistant.",
tools=[get_weather],
)
```
### Running Evaluations
```bash theme={"dark"}
python -m timbal.evals.cli evals.yaml
```
## How It Works
1. **Conversation History**: The `params.messages` array establishes the full conversation history, including previous user messages and assistant responses.
2. **Context Usage**: The agent receives the entire conversation history, so it can remember what was said in previous turns and answer accordingly.
3. **Output Validation**: The `output` validator checks that the agent's response contains the expected content (e.g., "yes" for the umbrella question).
4. **Sequence Validation**: The `seq!` validator ensures the agent only calls `llm` and doesn't unnecessarily call `get_weather` again, since the weather information is already available in the conversation history.
## Evaluation Results
### Successful Validation
When the agent remembers context and provides the correct response:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
PASSED evals.yaml::eval_weather_advice [1.06s]
└── weather_agent
├── ✓ seq!
│ └── llm
└── ✓ output.contains! ("yes") ⤳ lowercase
============================= 1 passed in 1.06s ==============================
```
### Failed Validation
When the agent doesn't use context or provides an incorrect response:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
FAILED evals.yaml::eval_weather_advice [2.91s]
└── weather_agent
├── ✗ seq!
│ └── llm
└── ✗ output.contains! ("yes") ⤳ lowercase
!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 1 failed in 2.91s !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
## Key Features
* **Conversation History**: Use `params.messages` to establish full conversation context
* **Context Memory**: Agents receive the entire conversation history and can remember previous interactions
* **Sequence Validation**: Use `seq!` to verify agents don't call tools unnecessarily when context is available
* **Response Continuity**: Ensure agents build logically on previous interactions
# Output Validation
Source: https://docs.timbal.ai/examples/agents/output-validation
Validate agent responses using content checks, format validation, and semantic evaluation
Output validation ensures your agent produces correct, well-formatted responses. You can validate content structure, exclude unwanted text, match patterns with regex, and use LLM-powered semantic evaluation.
## Example
This example demonstrates how to validate agent outputs using multiple validators including content checks, format validation, timing, and usage metrics.
### Eval Configuration
```yaml evals.yaml theme={"dark"}
- name: eval_creative_writer_response
description: Validate creative writing agent provides well-structured stories
runnable: agent.py::agent
params:
prompt: "Write a story about a robot learning to paint"
output:
contains_all!: ["Title", "Story", "Lesson"]
not_contains!: ["error", "failed"]
pattern!: "^Title: .+"
elapsed:
lt!: 15000
llm:
usage:
output_text_tokens:
lte!: 500
```
In this example, we use `output_text_tokens` instead of `output_tokens` because the agent uses OpenAI (`openai/gpt-5.2`). For Anthropic models, use `output_tokens` instead. See [Validating Token Usage](/evals/writing-evals#validating-token-usage) for more details.
### Agent Implementation
```python agent.py theme={"dark"}
from timbal import Agent
agent = Agent(
name="creative_writer",
model="openai/gpt-5.2",
system_prompt="""You are a creative writing assistant.
For any story request, always provide:
1. A compelling title
2. A complete short story (2 sentences)
3. A moral or lesson
Format your response as:
Title: [story title]
Story: [complete narrative]
Lesson: [moral or takeaway]"""
)
```
### Running Evaluations
```bash theme={"dark"}
python -m timbal.evals.cli evals.yaml
```
## How It Works
1. **Output Validation**: Multiple validators check the agent's response for required content (`contains_all!`), excluded content (`not_contains!`), and format (`pattern!`).
2. **Timing Validation**: The `elapsed` validator ensures the agent responds within the specified time limit.
3. **Usage Validation**: Span-level validators track resource consumption, such as token usage for LLM calls.
4. **Combined Validators**: All validators must pass for the eval to succeed.
## Evaluation Results
### Successful Validation
When all validators pass:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
PASSED evals.yaml::eval_creative_writer_response [0.52s]
└── creative_writer
├── ✓ output.contains_all! (["Title", "Story", "Lesson"])
├── ✓ output.not_contains! (["error", "failed"])
├── ✓ output.pattern! ("^Title: .+")
├── ✓ elapsed.lt! (15000)
└── ✓ llm.usage.output_text_tokens.lte! (500)
============================= 1 passed in 0.52s ==============================
```
### Failed Validation
When any validator fails:
```
──────────────────── Timbal Evals ────────────────────
collected 1 evals from 1 file
FAILED evals.yaml::eval_creative_writer_response [6.14s]
└── creative_writer
├── ✗ output.contains_all! (["Title", "Story", "Lesson"])
├── ✓ output.not_contains! (["error", "failed"])
├── ✗ output.pattern! ("^Title: .+")
├── ✓ elapsed.lt! (15000)
└── ✓ llm.usage.output_text_tokens.lte! (500)
!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 1 failed in 6.14s !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
```
## Key Features
* **Content Validation**: Verify required keywords (`contains_all!`) and exclude unwanted content (`not_contains!`)
* **Format Validation**: Ensure responses follow expected structure with `pattern!` regex validation
* **Time Validation**: Monitor execution time with `elapsed` validators (`lt!`, `lte!`, etc.)
* **Usage Validation**: Track resource consumption with span-level `usage` validators (e.g., `llm.usage.output_text_tokens` for OpenAI or `llm.usage.output_tokens` for Anthropic)
* **Combined Validators**: Use multiple validators together - all must pass for the eval to succeed
# PDF
Source: https://docs.timbal.ai/examples/agents/pdf
AI agents can read and analyze PDF documents for content extraction and processing
Agents automatically handle PDF files when included in prompts. First validate your PDF file with Timbal's `File` type, then pass it alongside text in a list:
```python theme={"dark"}
from timbal import Agent
from timbal.types.file import File
agent = Agent(
name="DocumentAgent",
model="openai/gpt-4.1", # Vision-capable model
system_prompt="Analyze PDF documents and extract key information."
)
# Validate PDF file and analyze
pdf_file = File.validate("path/to/document.pdf")
result = await agent(
prompt=["Summarize this document and highlight the main points", pdf_file]
).collect()
print(result.output.collect_text())
```
PDFs are converted to images, so vision-capable models are required. Check the [Model Reference](/models/overview) for models with vision support.
## Key Features
* **Automatic Processing**: PDF files are automatically converted to images and parsed for text content
* **Document Analysis**: Extract text, structure, and insights from PDF documents
* **File Support**: Works with local files, URLs, and base64 data
# Web Search
Source: https://docs.timbal.ai/examples/agents/search
AI agents can search the web for real-time information using OpenAI or Anthropic models
Agents can search the web for current information using the built-in `WebSearch` tool. This tool works with **OpenAI** and **Anthropic** models.
See the [Model Reference](/models/overview) to check which specific models support web search.
```python theme={"dark"}
from timbal import Agent
from timbal.tools import WebSearch
# Works with OpenAI or Anthropic models
agent = Agent(
name="SearchAgent",
model="openai/gpt-5.1",
tools=[WebSearch()],
system_prompt="Search the web for current information."
)
# Agent will automatically use search when needed
result = await agent(
prompt="When was the last match of Liverpool?"
).collect()
```
## Key Features
* **Real-time Information**: Access current web data and news
* **Automatic Tool Selection**: Agent decides when to search
* **Citation Support**: Includes source links in responses
# Semantic Search with Embeddings
Source: https://docs.timbal.ai/examples/agents/semantic-search
AI agents can create embeddings and perform intelligent semantic search through natural conversation
Agents can set up semantic search capabilities and find relevant content through simple conversations. The agent handles embedding creation and meaning-based search automatically:
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.platform.kbs.embeddings import create_embedding, list_embedding_models
from timbal.platform.kbs.tables import create_table, get_table, get_tables, import_csv, search_table
from timbal.state import RunContext, set_run_context
from timbal.types.file import File
agent = Agent(
name="ResearchAgent",
model="openai/gpt-4.1-mini",
system_prompt=("""
You are a research assistant. Help users set up semantic search and find relevant articles. Be conversational and helpful.
Configuration:
- org_id: '1'
- kb_id: '70'
If a table doesn't exist, create it immediately without asking for confirmation.
If an embedding already exists, skip creating it and proceed with the search.
Always use these configuration values when calling KB functions.
"""),
tools=[
Tool(handler=create_table, description="Use it to create the articles table. ALWAYS use 'text' data type for all columns. Match exactly the CSV file columns."),
Tool(handler=import_csv, description="Use it to import the CSV file itself to the table."),
Tool(handler=list_embedding_models, description="Use it to list the available embedding models."),
Tool(handler=create_embedding, description="Use it to create embeddings for semantic search. Only create if they don't already exist. Use an available model."),
Tool(handler=search_table, description="Use it to search articles using semantic search. First you have to know the column name of the embeddings."),
Tool(handler=get_table, description="Use it to get the table definitio."),
Tool(handler=get_tables, description="Use it to get the table definition"),
]
)
# First conversation: Setting up the knowledge base
csv_file = File.validate("/path/to/documents.csv")
result1 = await agent(
prompt=["I need to set up a technical support knowledge base. Create an 'articles' table with the columns from the CSV file /path/to/documents.csv and add the CSV file to the table.", csv_file]
).collect()
# Second conversation: Creating embeddings for semantic search
result2 = await agent(
prompt="Now create embeddings for the technical content so I can do semantic search on support documents."
).collect()
# Reset context to simulate a fresh conversation (no memory of previous steps)
run_context = RunContext() # Creates a new empty context
set_run_context(run_context) # Replaces current context, wiping all memory
# Third conversation: Performing semantic search
result3 = await agent(
prompt="Search in articles table for 'How can I install music software on my computer?'"
).collect()
print(result3.output.collect_text())
```
## Key Features
* **Natural Setup**: Create tables, add content, and set up embeddings through conversation
* **Semantic Understanding**: Find content by meaning, not exact keywords
* **Automatic Embeddings**: Agent creates vector embeddings for intelligent search
* **Contextual Matching**: Understand synonyms, related concepts, and intent
* **Flexible Queries**: Natural language queries work better than keyword searches
# SQL Queries & Data Management
Source: https://docs.timbal.ai/examples/agents/sql-queries
AI agents can add data to knowledge bases and then query it through natural conversation
Agents can manage your knowledge base data through simple conversations. Add records naturally, then ask questions about your data - the agent handles all the complex SQL operations behind the scenes:
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.platform.kbs.tables import create_table, import_records, query, get_tables, get_table
agent = Agent(
name="SupportAnalyst",
model="openai/gpt-4.1-mini",
system_prompt=("""
You are a support data manager. Help users add tickets to the database and analyze the data. Be conversational and helpful.
Configuration:
- org_id: 'your-org-id'
- kb_id: 'your-kb-id'
- table_name: 'tickets'
If the tickets table doesn't exist, create it immediately without asking for confirmation.
Always use these configuration values when calling KB functions.
"""),
tools=[
Tool(handler=get_tables, description="Use it to check if a table it is already created"),
Tool(handler=get_table, description="Use it to get the table definition"),
Tool(handler=create_table, description="Use it to create the table"),
Tool(handler=query, description="Use it to query the table"),
Tool(handler=import_records, description="Use it to import records to the table")
] # Add multiple KB functions as tools
)
# First conversation: Adding records to the knowledge base
await agent(
prompt="I need to add a new support ticket. Customer ID 103 reported a login issue with high priority."
).collect()
# Second conversation: Querying the data we just added
result2 = await agent(
prompt="How many high priority tickets do we have from customer 103?"
).collect()
print(result2.output.collect_text())
# There is currently 1 high priority ticket from customer 103. Would you like to see more details or do anything else?
```
*The tickets table as it appears in the Timbal Platform interface, showing the record added by the agent.*
## Key Features
* **Natural Conversations**: Add data and ask questions in plain English
* **Automatic SQL Generation**: Agent converts your requests into complex SQL queries
* **Data Management**: Add individual records or import bulk CSV files
* **Real-time Analysis**: Get instant insights from your data
* **PostgreSQL Power**: Full database capabilities behind simple conversations
# Text-to-Speech
Source: https://docs.timbal.ai/examples/agents/tts
AI agents can convert text to speech using OpenAI's TTS capabilities
Agents can generate audio from text using text-to-speech tools. Add the TTS tool to your agent's tools list:
```python theme={"dark"}
import os
from openai import AsyncOpenAI
from pydantic import Field
from timbal import Agent
from timbal.state import get_run_context
from timbal.types.file import File
async def tts(
text: str = Field(
...,
description="The text to convert to speech.",
),
) -> File:
client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
kwargs = {
"input": text,
'model': 'tts-1',
'voice': 'alloy'
}
response = await client.audio.speech.create(**kwargs)
return File.validate(
response.content,
{"extension": ".mp3"}
)
async def post_hook():
"""Post hook to extract and return the audio file."""
span = get_run_context().current_span()
span.output = await tts(text=span.output.collect_text())
span.output.to_disk("audio.mp3")
agent = Agent(
name="VoiceAgent",
model="openai/gpt-4.1-mini",
post_hook=post_hook
)
# Agent will generate audio files
await agent(
prompt="What is the capital of France?"
).collect()
```
## Key Features
* **Multiple Voices**: Choose from various voice options
* **Audio Formats**: Support for MP3, WAV, and other formats
* **File Output**: Returns audio files for download or playback
# Mail Assistant
Source: https://docs.timbal.ai/examples/guides/mail-assistant
AI-powered Gmail integration that triages emails and autonomously drafts intelligent responses
This guide shows how to:
* Connect to the Gmail API to retrieve incoming messages
* Use agents to generate draft replies
## Setup Requirements
Install the required Google API client libraries:
```python theme={"dark"}
google-api-core
google-api-python-client
google-auth
google-auth-oauthlib
```
Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project or select an existing one.
1. Navigate to **APIs & Services** > **Library**
2. Search for "Gmail API"
3. Click on **Gmail API** and then **Enable**
There are two ways to authenticate with Gmail API. Choose the one that fits your use case:
## Method 1: OAuth 2.0 (Personal/Desktop Apps)
**Use this if:**
* You're building a personal application
* You want users to authenticate with their own Google account
* You're using a personal Gmail account (not Google Workspace)
### Setup Steps:
**1. Create OAuth 2.0 Credentials**
1. Go to **APIs & Services** > **Credentials**
2. Click **Create Credentials** > **OAuth client ID**
3. Choose **Desktop app** as the application type
4. Download the credentials JSON file and save it as `credentials.json`
**2. Configure OAuth Scopes**
The following scopes are required:
```python theme={"dark"}
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.compose"
]
```
**3. Generate Access Token**
Run this script once to generate `token.json`:
```python theme={"dark"}
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.compose'
]
flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
creds = flow.run_local_server(port=0)
with open('token.json', 'w') as token:
token.write(creds.to_json())
print("token.json generated successfully!")
```
This will open a browser window for Google sign-in.
**4. Initialize in Code**
```python theme={"dark"}
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
import os
SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.compose'
]
def initialize_gmail():
"""Initialize Gmail API connection with OAuth"""
try:
creds = None
if os.path.exists('token.json'):
creds = Credentials.from_authorized_user_file('token.json', SCOPES)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
print("No valid credentials found.")
return None
gmail_service = build('gmail', 'v1', credentials=creds)
# Test connection and get profile
profile = gmail_service.users().getProfile(userId='me').execute()
print(f"Connected to Gmail: {profile['emailAddress']}")
return gmail_service
except Exception as e:
print(f"Error initializing Gmail API: {e}")
return None
```
***
## Method 2: Service Account (Google Workspace)
**Use this if:**
* You have a Google Workspace account
* You need to access Gmail on behalf of multiple users
* You're building a server-side application
**Requirements:**
* Google Workspace account (not personal Gmail)
* Google Workspace Admin access for domain-wide delegation
### Setup Steps:
**1. Create a Service Account**
1. Go to **IAM & Admin** > **Service Accounts**
2. Click **CREATE SERVICE ACCOUNT**
3. Enter name: `gmail-service-account`
4. Add description: `Service account for Gmail API access`
5. Click **CREATE AND CONTINUE**, then **DONE**
6. Note the service account email: `gmail-service-account@your-project-id.iam.gserviceaccount.com`
**2. Create and Download Service Account Key**
1. Click on your service account
2. Go to **KEYS** tab
3. Click **ADD KEY** > **Create new key**
4. Select **JSON** and click **CREATE**
5. Save the downloaded file as `credentials.json`
6. **Keep this file secure!**
**3. Configure Domain-Wide Delegation**
Requires Google Workspace Admin privileges
1. Open `credentials.json` and copy the `client_id` value (e.g., `103635629912027933995`)
2. Go to [Google Workspace Admin Console](https://admin.google.com/)
3. Navigate to **Security** > **Access and data control** > **API Controls**
4. Click **Manage Domain Wide Delegation** > **Add new**
5. Paste the **Client ID** and add scopes:
```
https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.compose
```
6. Click **Authorize**
**4. Initialize Service Account in Code**
```python theme={"dark"}
from google.oauth2 import service_account
from googleapiclient.discovery import build
SCOPES = [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.compose'
]
CREDENTIALS_FILE = 'credentials.json'
DELEGATED_USER = 'your-email@yourdomain.com'
def initialize_gmail():
"""Initialize Gmail API connection with service account"""
try:
credentials = service_account.Credentials.from_service_account_file(
CREDENTIALS_FILE, scopes=SCOPES)
delegated_credentials = credentials.with_subject(DELEGATED_USER)
gmail_service = build('gmail', 'v1', credentials=delegated_credentials)
# Test connection and get profile
profile = gmail_service.users().getProfile(userId='me').execute()
print(f"Connected to Gmail: {profile['emailAddress']}")
return gmail_service
except Exception as e:
print(f"Error initializing Gmail API: {e}")
return None
```
## Implementation
This section walks through the essential components needed to implement Gmail integration with Timbal.
For the complete example, visit our [GitHub repository](https://github.com/timbal-ai/timbal/tree/main/examples/gmail).
### Gmail Initialization
Establish a connection to the Gmail API using your credentials. Choose the method that matches your setup:
```python theme={"dark"}
def initialize_gmail(self):
"""Initialize Gmail API connection with OAuth"""
try:
creds = None
token_file = 'token.json'
if os.path.exists(token_file):
creds = Credentials.from_authorized_user_file(token_file, self.scopes)
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
print("Refreshing expired credentials...")
creds.refresh(Request())
else:
print("No valid credentials found.")
return False
self.gmail_service = build('gmail', 'v1', credentials=creds)
# Test connection and get initial history ID
profile = self.gmail_service.users().getProfile(userId='me').execute()
print(f"Connected to Gmail: {profile['emailAddress']}")
# Get initial history ID from profile
self.last_history_id = profile.get('historyId')
if self.last_history_id:
print(f"Starting from history ID: {self.last_history_id}")
else:
print("No history ID found, will monitor from now")
return True
except Exception as e:
print(f"Error initializing Gmail API: {e}")
return False
```
```python theme={"dark"}
def initialize_gmail(self):
"""Initialize Gmail API connection with Service Account"""
try:
CREDENTIALS_FILE = 'credentials.json'
DELEGATED_USER = 'your-email@yourdomain.com'
credentials = service_account.Credentials.from_service_account_file(
CREDENTIALS_FILE, scopes=self.scopes)
delegated_credentials = credentials.with_subject(DELEGATED_USER)
self.gmail_service = build('gmail', 'v1', credentials=delegated_credentials)
# Test connection and get initial history ID
profile = self.gmail_service.users().getProfile(userId='me').execute()
print(f"Connected to Gmail: {profile['emailAddress']}")
# Get initial history ID from profile
self.last_history_id = profile.get('historyId')
if self.last_history_id:
print(f"Starting from history ID: {self.last_history_id}")
else:
print("No history ID found, will monitor from now")
return True
except Exception as e:
print(f"Error initializing Gmail API: {e}")
return False
```
### Email monitoring
Monitor the Gmail inbox for new messages using polling:
```python theme={"dark"}
async def check_for_new_messages(self):
"""Check for new messages since last check"""
try:
# Check for new messages since last history ID
history = self.gmail_service.users().history().list(
userId='me',
startHistoryId=self.last_history_id,
historyTypes=['messageAdded']
).execute()
new_messages = []
for history_record in history.get('history', []):
for message_added in history_record.get('messagesAdded', []):
message_id = message_added['message']['id']
message_details = self.get_message_details(message_id)
if message_details:
# Filter out draft messages
if not self.is_draft_message(message_details):
new_messages.append(message_details)
else:
print(f"📝 Skipping draft message: {message_details.get('subject', 'No Subject')}")
if new_messages:
print(f"\n🔔 Found {len(new_messages)} new message(s) at {datetime.now().strftime('%H:%M:%S')}")
for message in new_messages:
await self.generate_draft(message)
# Update history ID
if history.get('history'):
self.last_history_id = history['history'][-1]['id']
except HttpError as error:
print(f"Error checking for new messages: {error}")
async def start_monitoring(self):
"""Start monitoring Gmail for new messages using polling"""
while True:
await self.check_for_new_messages()
await asyncio.sleep(10) # Check new emails every 10 seconds
```
### Create a Timbal Agent for intelligent email responses
Create an Agent to generate emails responses:
```python theme={"dark"}
agent = Agent(
name="email_response_generator",
model="openai/gpt-4o-mini",
system_prompt="You are an assistant that helps draft professional email responses.",
post_hook=self.save_draft,
)
```
### Call a Timbal Agent to generate the draft
Process incoming emails by creating the prompt and invoking the Agent to generate a response:
```python theme={"dark"}
def _create_email_prompt(self, email):
# Extract email information
subject = email.get('subject', 'No Subject')
sender = email.get('from', 'Unknown Sender')
body = email.get('body', '')
snippet = email.get('snippet', '')
content = body if body else snippet
prompt = f"""
You are an AI assistant that helps draft professional email responses.
Please write a helpful and appropriate response to this email:
**Received Email:**
From: {sender}
Subject: {subject}
Content: {content}
**Instructions:**
- Write a professional response
- Do not assume anything that is not explicitly stated in the email
- Address any questions or requests appropriately
- Keep the tone friendly but business-appropriate
- If you need more information, ask clarifying questions
- End with a professional closing
- Keep the response concise but complete
**Your Response:**
"""
return prompt.strip()
async def generate_draft(self, message):
prompt = self._create_email_prompt(message)
# Generate and create draft
try:
response_text = await self.agent(prompt=prompt, input_email=message).collect()
if response_text:
print("Generated Response:")
print("-" * 40)
print(response_text)
print("-" * 40)
print("Draft created successfully!")
print("="*60)
else:
print("Failed to generate response")
except Exception as e:
print(f"Error generating/creating draft: {e}")
print("="*60)
```
### Save the draft to the emails thread
Define the post-hook function that automatically saves the AI-generated response as a draft reply in the original email thread:
```python theme={"dark"}
def save_draft(self):
"""Create a draft response to the original message"""
span = get_run_context().current_span()
original_message = span.input['input_email']
response_text = span.output.collect_text()
try:
# Extract sender email from the original message
from_header = original_message.get('from', '')
if '<' in from_header and '>' in from_header:
# Extract email from "Name " format
sender_email = from_header.split('<')[1].split('>')[0].strip()
else:
# Assume the entire from header is the email
sender_email = from_header.strip()
# Create response subject
original_subject = original_message.get('subject', '')
if not original_subject.startswith('Re:'):
response_subject = f"Re: {original_subject}"
else:
response_subject = original_subject
# Create draft message
draft_message = self._create_message(sender_email, response_subject, response_text)
# Create the draft with thread ID to link it to the original conversation
draft_body = {
'message': {
'raw': draft_message
}
}
# Include thread ID if available to link the draft to the original conversation
if original_message.get('thread_id'):
draft_body['message']['threadId'] = original_message['thread_id']
draft = self.gmail_service.users().drafts().create(
userId='me',
body=draft_body
).execute()
print(f"Draft ID: {draft['id']}")
print(f"To: {sender_email}")
print(f"Subject: {response_subject}")
return draft
except Exception as e:
print(f"Error creating draft: {e}")
return None
```
## Key Features
* **Real-time Processing**: Constantly polls new emails
* **Intelligent Responses**: Uses AI to generate contextually appropriate draft replies
* **History Tracking**: Maintains state to avoid processing duplicate emails or generated drafts
## Troubleshooting
* **Missing scopes**: Verify all required Gmail API scopes are included
* **OAuth issues**: Check that `credentials.json` and `token.json` files exist and are valid
* **Service Account issues**: Verify domain-wide delegation is configured and `DELEGATED_USER` has correct email
* **Rate limits**: Gmail API has daily quotas. Check your usage.
* **Permission denied**: For Service Accounts, ensure domain-wide delegation is authorized with correct Client ID
* **Import errors**: Ensure all required packages are installed
* **File errors**: Check that your credentials file exists and is properly formatted
# Slack Bot
Source: https://docs.timbal.ai/examples/guides/slack-bot
Build a Slack bot that responds in real time via webhooks, with an AI agent and threaded conversations
This guide shows how to create a Slack bot using Timbal that can:
* Respond to messages in real-time via webhooks
* Process user requests intelligently with AI agents
* Handle threaded conversations
## Setup Requirements
Install the Slack SDK:
```bash theme={"dark"}
pip install slack-sdk
```
For all available Slack API methods and capabilities, see the [Slack API Methods Reference](https://docs.slack.dev/reference/methods).
1. Go to [Slack API](https://api.slack.com/apps) and click **Create New App**
2. Choose **From scratch**
3. Enter your app name and select your workspace
1. Go to **OAuth & Permissions**
2. Add the following **Bot Token Scopes** based on Timbal handlers you'll use:
```
# Message Operations
chat:write # Send messages
chat:write.public # Send messages to channels bot isn't in
im:write # Send direct messages
# Channel & Conversation Operations
channels:read # View basic channel information
channels:history # Get messages from channels
groups:read # View basic private channel information
im:read # View basic direct message information
im:history # Get direct message history
# App Mentions & Reactions
app_mentions:read # Receive app mention events
reactions:read # Read message reactions
# File Operations
files:read # Download files from Slack
files:write # Upload files to Slack
```
After adding or modifying scopes, you'll need to reinstall the app to your workspace for the changes to take effect.
1. In **OAuth & Permissions**, click **Install to Workspace**
2. Copy the **Bot User OAuth Token** (starts with `xoxb-`)
3. Set as environment variable: `SLACK_BOT_TOKEN`
1. Go to **App Home** in your Slack app settings
2. Under **Show Tabs**, enable **Home Tab**
3. Check **Allow users to send Slash commands and messages from the messages tab**
4. This enables users to send direct messages to your bot
1. In your Slack workspace, start a direct message with your bot
2. Click on the bot's name at the top of the chat
3. In the profile panel, find the **Member ID** (format: `U12345678`)
4. Set this as environment variable: `SLACK_BOT_USER_ID=U12345678`
The Bot User ID is required to prevent infinite loops by filtering out the bot's own messages.
## Implementation
### Setting up the Slack Bot Configuration
First, set up the Slack configuration with your bot's user ID and authentication token.
Create a `.env` file in your project root:
```bash .env theme={"dark"}
SLACK_BOT_USER_ID=U12345678 # Your actual bot user ID
SLACK_BOT_TOKEN=xoxb-your-bot-token-here # Your bot token
```
Then configure your Python code:
```python theme={"dark"}
import os
from dotenv import load_dotenv
from slack_sdk import WebClient
# Load environment variables from .env
load_dotenv()
SLACK_BOT_USER_ID = os.getenv("SLACK_BOT_USER_ID")
client = WebClient(token=os.getenv("SLACK_BOT_TOKEN"))
```
### Creating the Agent
Create the Timbal agent with pre and post hooks for Slack integration:
```python theme={"dark"}
from timbal import Agent
agent = Agent(
name="SlackAgent",
system_prompt="You are a tech support AI assistant. Help users with their technical questions clearly and concisely.",
model="openai/gpt-4.1-mini",
pre_hook=pre_hook,
post_hook=post_hook,
)
```
### Pre-hook: Processing Incoming Messages
The pre-hook handles incoming Slack webhook events and extracts relevant information.
**Parameter Name Flexibility**: The `_webhook` parameter name is completely custom. You can use any name you prefer:
* `await agent(slack_data=body).collect()`
* `await agent(webhook_payload=body).collect()`
* `await agent(event_data=body).collect()`
Just ensure the same name is used consistently in both the agent call and the pre\_hook check.
```python theme={"dark"}
from timbal.state import get_run_context
from timbal.errors import bail
async def pre_hook():
"""Process incoming Slack webhook events before agent execution."""
span = get_run_context().current_span()
# Check if this is a Slack webhook event
# Note: "_webhook" matches the parameter name used when calling agent(_webhook=body)
if "_webhook" not in span.input:
return # Not triggered by Slack
slack_event = span.input["_webhook"]["event"]
# Ignore bot's own messages to prevent infinite loops
if slack_event.get("user") == SLACK_BOT_USER_ID:
raise bail()
# Extract and validate message text
text = slack_event.get("text", "")
if not text:
raise bail()
# Store Slack context for response
span.slack_channel = slack_event["channel"]
span.slack_thread_ts = slack_event.get("thread_ts") # Reply in same thread
span.input["prompt"] = text
```
### Post-hook: Sending Responses
The post-hook sends the agent's response back to Slack:
```python theme={"dark"}
from timbal.state import get_run_context
def post_hook():
"""Send agent response back to Slack channel."""
span = get_run_context().current_span()
# Only process Slack webhook events
if "_webhook" not in span.input:
return
# Extract response context
slack_channel = span.slack_channel
slack_thread_ts = span.slack_thread_ts
reply = span.output.collect_text()
# Send response if not empty
if reply.strip():
client.chat_postMessage(
channel=slack_channel,
text=reply,
thread_ts=slack_thread_ts,
)
```
### Complete Example
Here's the full implementation ready to use:
```python agent.py theme={"dark"}
import os
from dotenv import load_dotenv
from slack_sdk import WebClient
from timbal import Agent
from timbal.errors import bail
from timbal.state import get_run_context
# Load environment variables from .env
load_dotenv()
SLACK_BOT_USER_ID = os.getenv("SLACK_BOT_USER_ID")
client = WebClient(token=os.getenv("SLACK_BOT_TOKEN"))
async def pre_hook():
"""Process incoming Slack webhook events before agent execution."""
span = get_run_context().current_span()
# Only process Slack webhook events
# Note: "_webhook" must match the parameter name used when calling agent(_webhook=body)
if "_webhook" not in span.input:
return
slack_event = span.input["_webhook"]["event"]
# Prevent infinite loops by ignoring bot's own messages
if slack_event.get("user") == SLACK_BOT_USER_ID:
raise bail()
# Extract message text
text = slack_event.get("text", "")
if not text:
raise bail()
# Store context for response
span.slack_channel = slack_event["channel"]
span.slack_thread_ts = slack_event.get("thread_ts")
span.input["prompt"] = text
def post_hook():
"""Send agent response back to Slack."""
span = get_run_context().current_span()
if "_webhook" not in span.input:
return
# Get response and send to Slack
reply = span.output.collect_text()
slack_channel = span.slack_channel
slack_thread_ts = span.slack_thread_ts
if reply.strip():
client.chat_postMessage(
channel=slack_channel,
text=reply,
thread_ts=slack_thread_ts,
)
# Create the Slack agent
agent = Agent(
name="SlackAgent",
system_prompt="You are a tech support AI assistant. Help users with their technical questions clearly and concisely.",
model="openai/gpt-4.1-mini",
pre_hook=pre_hook,
post_hook=post_hook,
)
```
## Running the Agent
Timbal provides built-in ngrok integration for easy local development. To use this feature:
1. Create an account at [ngrok.com](https://ngrok.com/)
2. Install ngrok on your system and the Python package:
```bash theme={"dark"}
pip install pyngrok
```
3. Configure your authtoken:
```bash theme={"dark"}
ngrok config add-authtoken YOUR_AUTHTOKEN
```
Create a simple FastAPI server to handle Slack's URL verification and webhook events:
```python server.py theme={"dark"}
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from pyngrok import ngrok
# Import our Slack agent to handle webhook events
from agent import agent
load_dotenv()
app = FastAPI()
@app.post("/")
async def slack_events(request: Request):
body = await request.json()
if body.get("type") == "url_verification":
return {"challenge": body.get("challenge")}
# Process actual Slack events here
if body.get("type") == "event_callback":
await agent(_webhook=body).collect() # _webhook is a custom parameter name you define
return {"status": "ok"}
if __name__ == "__main__":
port = 8000
public_url = ngrok.connect(port, "http")
print(f"Public URL: {public_url}")
uvicorn.run(app, host="0.0.0.0", port=port)
```
1. Run the script to start your Slack bot:
```bash theme={"dark"}
python server.py
```
Or using uv (recommended):
```bash theme={"dark"}
uv run server.py
```
2. Copy the ngrok public URL displayed in the terminal (e.g., `https://abc123.ngrok-free.app`)
1. Go to **Event Subscriptions** and toggle **Enable Events**
2. Set **Request URL** to our webhook endpoint (e.g., `https://abc123.ngrok-free.app/`)
3. Subscribe to **Bot Events** - these determine what events Slack will send to your bot:
```
message.channels # Bot receives messages in public channels it's added to
message.groups # Bot receives messages in private channels it's added to
message.im # Bot receives direct messages sent to it
app_mention # Bot receives messages that mention it (@botname)
reaction_added # Bot receives events when reactions are added to messages
```
Your Slack bot is now ready! 🚀 Start a direct message with your bot or mention it in a channel to begin chatting with your AI assistant.
## Key Features
* **Real-time Responses**: Instantly processes Slack messages via webhooks
* **Thread Support**: Maintains conversation context in Slack threads
* **Loop Prevention**: Automatically ignores bot's own messages
* **Flexible AI**: Uses any LLM model supported by Timbal
* **Easy Integration**: Minimal setup with existing Slack handlers
## Troubleshooting
* Check webhook URL is publicly accessible and returns 200 status
* Ensure bot has correct permissions in Slack workspace
* Confirm `SLACK_BOT_USER_ID` matches your bot's actual user ID
* Test webhook endpoint manually with curl or Postman
* Double-check `SLACK_BOT_USER_ID` is correct (found in Slack app settings)
* Ensure pre-hook properly filters bot messages with `bail()`
* Verify webhook events aren't being duplicated
* Check that bot doesn't respond to its own message events
* Check that OpenAI API key is valid and has sufficient credits
* Verify agent model name is correct (e.g., `openai/gpt-4o-mini`)
* Ensure pre-hook is setting `span.input["prompt"]` correctly
* Add logging to debug webhook payload structure
* Ensure all required packages are installed (`slack-sdk fastapi uvicorn pyngrok python-dotenv`)
* Verify ngrok is installed and configured with authtoken
* Make sure port 8000 is not already in use by another process
* Ensure Slack Request URL matches your POST endpoint route with "/" (e.g., `https://abc123.ngrok-free.app/`)
# WhatsApp Assistant
Source: https://docs.timbal.ai/examples/guides/whatsapp-assistant
Build a WhatsApp assistant with webhooks, conversation persistence (Knowledge Base or JSONL), and support for text, audio, images, and documents
This guide shows how to create a WhatsApp assistant with Timbal that can:
* Respond to messages in real time via webhooks
* Persist conversations using Timbal Knowledge Bases or JSONL
* Handle text, audio, images, and documents
## Setup Requirements
Go to the Meta console and open your app's WhatsApp section: [Meta WhatsApp App Dashboard](https://developers.facebook.com/apps/).
1. Set the **Callback URL** to your endpoint, e.g., `https://yourdomain.com/`
2. Set a **Verify Token** (keep it secret)
3. In your app code, use the same token (see `WHATSAPP_VERIFY_TOKEN` in the example below)
4. Click **Verify and Save**
In the WhatsApp product settings, subscribe to message-related events (e.g., messages, statuses).
Create a `.env` file and set:
```bash .env theme={"dark"}
# Timbal platform (if using KB persistence)
ORG_ID=your_org_id
KB_ID=your_kb_id
# WhatsApp API
WHATSAPP_ACCESS_TOKEN=your_long_lived_token
WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id
# Storage mode for this template: JSONL | timbal
WHATSAPP_STORAGE_MODE=timbal
# Webhook verification
WHATSAPP_VERIFY_TOKEN=your_verify_token
# Server
HOST=0.0.0.0
PORT=4343
# Optional: enable ngrok tunnel
ENABLE_NGROK=true
```
The included `whatsapp_tools.send_whatsapp_message` currently references `WHATSAPP_ACCESS_TOKEN`/`WHATSAPP_PHONE_NUMBER_ID`. Ensure these are set. If you see mismatched names in your local template (e.g., `WHATSAPP_TOKEN`, `PHONE_NUMBER_ID`), set both pairs to be safe.
## Minimal Webhook App (FastAPI)
Use a simple FastAPI app to receive the webhook and pass the full payload to the agent. The agent will process everything in `pre_hook` and send responses in `post_hook`.
```python app.py theme={"dark"}
from fastapi import FastAPI, Request, Response
from agent import agent
import os
import uvicorn
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
VERIFY_TOKEN = os.getenv("WHATSAPP_VERIFY_TOKEN")
@app.get("/")
async def verify(request: Request):
token = request.query_params.get("hub.verify_token")
challenge = request.query_params.get("hub.challenge")
if token == VERIFY_TOKEN and challenge:
return Response(content=challenge, media_type="text/plain")
return Response(content="Verification failed", status_code=403)
@app.post("/")
async def webhook(request: Request):
_webhook = await request.json()
await agent(_webhook=_webhook).collect()
return {"status": "ok"}
def _maybe_start_ngrok(port: int) -> None:
enable = os.getenv("ENABLE_NGORK", "false")
if enable != "true":
return
try:
from pyngrok import ngrok
public_url = ngrok.connect(port, "http")
print(f"ngrok public url: {public_url}")
except Exception as e:
print(f"Failed to start ngrok: {e}")
if __name__ == "__main__":
host = os.getenv("HOST", "0.0.0.0")
try:
port = int(os.getenv("PORT", "4343"))
except Exception:
port = 4343
_maybe_start_ngrok(port)
uvicorn.run("app:app", host=host, port=port, log_level="info")
```
Expose locally (optional):
```bash theme={"dark"}
ngrok http 4343
```
Then set the ngrok HTTPS URL as the Callback URL in the Meta console.
## Creating the Agent
```python agent.py theme={"dark"}
import os
import json
from timbal import Agent
from timbal.state import get_run_context
from whatsapp_tools import send_whatsapp_message
def _value(payload: dict) -> dict | None:
try:
return payload["entry"][0]["changes"][0]["value"]
except Exception:
return None
def _prompt_from_message(msg: dict) -> str:
t = msg.get("type")
if t == "text":
return (msg.get("text") or {}).get("body", "").strip()
if t == "image":
mid = (msg.get("image") or {}).get("id")
cap = (msg.get("image") or {}).get("caption", "")
return f"[image:{mid}] {cap}".strip()
if t == "audio":
mid = (msg.get("audio") or {}).get("id")
return f"[audio:{mid}]"
if t == "document":
fn = (msg.get("document") or {}).get("filename", "document")
return f"[document:{fn}]"
return f"[{t or 'unknown'}]"
def _append_jsonl(path: str, rec: dict) -> None:
try:
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
def _recent_history(path: str, user_phone: str, limit: int = 10) -> list[str]:
out = []
try:
if not os.path.exists(path):
return []
with open(path, "r", encoding="utf-8") as f:
rows = [json.loads(l) for l in f if l.strip()]
for r in reversed(rows):
if r.get("user_phone") == user_phone:
try:
payload = json.loads(r.get("message") or "{}")
out.append(f"{r.get('direction')}: {payload.get('content','')}")
except Exception:
continue
if len(out) >= limit:
break
return list(reversed(out))
except Exception:
return []
def pre_hook():
"""Build professional prompt from WhatsApp metadata, message, and optional history."""
span = get_run_context().current_span()
payload = span.input.get("_webhook")
if not payload:
bail("No payload found in webhook")
value = payload["entry"][0]["changes"][0]["value"]
if not value:
bail("No value found in webhook")
messages = value.get("messages", [])
if not messages:
bail("No messages found in webhook")
contacts = value.get("contacts", [])
profile = contacts[0].get("profile", {})
name = profile.get("name", "")
msg = messages[0]
from_number = msg.get("from")
message_id = msg.get("id")
phone_number_id = value.get("metadata").get("phone_number_id")
prompt = _prompt_from_message(msg)
msg_type = msg.get("type")
rec = {
"id": msg.get("id"),
"user_phone": from_number,
"direction": "inbound",
"message_type": msg_type,
"message": json.dumps({"type": msg_type, "content": prompt}, ensure_ascii=False),
"timestamp": msg.get("timestamp"),
"conversation_id": f"{phone_number_id}_{from_number}",
"user_name": name,
}
_append_jsonl("whatsapp_messages.jsonl", rec)
span.input["prompt"] = _recent_history("whatsapp_messages.jsonl", user_phone=from_number, limit=10)
span.input["whatsapp_from_number"] = from_number
span.input["name"] = name
```
The `pre_hook` runs before invoking the agent. It extracts metadata from the webhook, builds a prompt from the incoming message, persists the inbound event, and sets values in `span.input` for the agent to use during inference.
```python agent.py theme={"dark"}
def post_hook():
"""Send the agent response via WhatsApp and persist outbound to JSONL."""
span = get_run_context().current_span()
from_number = span.input.get("whatsapp_from_number")
if not from_number:
return
try:
response_text = (span.output.collect_text() or "").strip()
except Exception:
return
if not response_text:
return
try:
send_whatsapp_message(to=from_number, message=response_text)
except Exception:
pass
_append_jsonl("whatsapp_messages.jsonl", {
"id": f"outbound_{from_number}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
"user_phone": from_number,
"direction": "outbound",
"message_type": "agent",
"message": json.dumps({"type": "text", "content": response_text}, ensure_ascii=False),
"timestamp": datetime.now().isoformat(),
"conversation_id": "",
"user_name": span.input.get("name", ""),
})
```
The `post_hook` runs after obtaining the agent's response. It sends the text via WhatsApp and persists the outbound event to JSONL.
```python agent.py theme={"dark"}
agent = Agent(
name="WhatsAppAgent",
description="A minimal WhatsApp agent for Timbal",
model="openai/gpt-4.1-mini",
pre_hook=pre_hook,
post_hook=post_hook,
)
```
### Optional: system prompt example
```text theme={"dark"}
title: "WHATSAPP AGENT from {{YOUR_BRAND}}",
description: "You are a WhatsApp agent from {{YOUR_BRAND}}. Your goal is to help users find the perfect vehicle and manage all their automotive needs.",
## WhatsApp message format
You are responding to messages in WhatsApp. Use WhatsApp's text formatting syntax when appropriate:
Text format:
- *text* = bold (for important information)
- _text_ = italic (for clarifications or soft emphasis)
- ~text~ = strikethrough (for corrections or obsolete information)
- `text` = monospace (for code, commands or technical text)
- Use triple backticks for code blocks (for longer fragments)
- > text = quote (for references or highlighted information)
- Important: Never use [View here](url) or . Send the direct link instead; WhatsApp does not render these markdown forms.
Important rules:
- Symbols must be attached to the text (no spaces)
- Every opening symbol must have its closing symbol
- You can combine formats: *_text_* for bold+italic
- Use sparingly; only when it adds value
- For lists use bullets (•), numbers (1.), or hyphens (-)
```
### Optional: Timbal KB persistence and history
If you prefer using Timbal Knowledge Bases instead of JSONL, you can import and query records with the platform helpers. These are async functions; adapt their usage to your environment accordingly.
```python theme={"dark"}
from timbal.platform.kbs.tables import import_records, query
ORG_ID = int(os.getenv("ORG_ID"))
KB_ID = int(os.getenv("KB_ID"))
async def save_record_to_kb(record: dict) -> None:
await import_records(ORG_ID, KB_ID, "whatsapp_messages", [record])
async def fetch_recent_history_from_kb(user_phone: str, limit: int = 10) -> list[str]:
rows = await query(ORG_ID, KB_ID, sql=f"""
SELECT direction, message
FROM whatsapp_messages
WHERE user_phone = '{user_phone}'
ORDER BY timestamp DESC
LIMIT {limit}
""")
lines: list[str] = []
for r in reversed(rows or []):
try:
payload = json.loads(r.get("message") or "{}")
lines.append(f"{r.get('direction')}: {payload.get('content','')}")
except Exception:
continue
return lines
```
## Media Handling Notes
* **Images**: The webhook includes an image `id`. Use the Graph API to fetch the media URL or content using your `WHATSAPP_ACCESS_TOKEN`. Provide the URL or a `File` object to the agent if needed.
* **Audio**: The webhook includes an audio `id`. You can fetch and transcribe audio, then pass the transcript as the prompt.
* **Documents**: You receive basic metadata (e.g., filename). Decide whether to fetch and analyze content or treat as a reference.
Keep the `pre_hook` minimal: extract the message type, create a concise prompt, and persist the event.
## Complete implementation
For a full, end-to-end reference (webhook app, helpers, and utilities), see the official WhatsApp examples in the Timbal repository:
* [timbal/examples/whatsapp](https://github.com/timbal-ai/timbal/tree/main/examples/whatsapp)
## Possible Improvements
Each WhatsApp webhook creates a separate agent instance. Persist every inbound message immediately (JSONL or KB), then wait a short debounce window. After that window, if storage shows a newer message for the same user, bail this run so the most recent instance answers with full context. No in-memory buffer is required.
* **Why it helps**: avoids replying to partial context when the user is still typing or sending media.
* **Trade-offs**: adds small latency; choose a short window (e.g., 800–1500 ms).
* **Example approach (JSONL)**:
```python theme={"dark"}
import os, json, time
DEBOUNCE_MS = int(os.getenv("WHATSAPP_DEBOUNCE_MS", "1200"))
def _message_ts(msg: dict) -> int:
try:
return int(msg.get("timestamp") or 0)
except Exception:
return 0
def _newer_exists_in_jsonl(path: str, user_phone: str, after_ts: int) -> bool:
try:
if not os.path.exists(path):
return False
with open(path, "r", encoding="utf-8") as f:
rows = [json.loads(l) for l in f if l.strip()]
for rec in reversed(rows):
if rec.get("user_phone") != user_phone:
continue
if rec.get("direction") != "inbound":
continue
try:
ts = int(rec.get("timestamp") or 0)
except Exception:
continue
if ts > after_ts:
return True
return False
except Exception:
return False
def pre_hook():
span = get_run_context().current_span()
payload = span.input.get("_webhook") or {}
value = (payload.get("entry") or [{}])[0].get("changes", [{}])[0].get("value", {})
msg = (value.get("messages") or [None])[0] or {}
from_number = msg.get("from", "")
current_ts = _message_ts(msg)
# 1) Persist inbound immediately (example: JSONL)
_append_jsonl("whatsapp_messages.jsonl", {
"id": msg.get("id"),
"user_phone": from_number,
"direction": "inbound",
"message_type": msg.get("type"),
"message": json.dumps({"type": msg.get("type"), "content": _prompt_from_message(msg)}, ensure_ascii=False),
"timestamp": str(current_ts),
"conversation_id": (value.get("metadata") or {}).get("phone_number_id", ""),
"user_name": (value.get("contacts") or [{}])[0].get("profile", {}).get("name", ""),
})
# 2. Wait a small window, then check for a newer message
asyncio.sleep(DEBOUNCE_MS / 1000.0)
if _newer_exists_in_jsonl("whatsapp_messages.jsonl", from_number, current_ts):
bail("Newer message detected; aborting this run")
# 3. Proceed with prompt building from recent history
span.input["prompt"] = _recent_history("whatsapp_messages.jsonl", user_phone=from_number, limit=10)
```
Use the WhatsApp Cloud API to mark messages as read. This improves UX and clears unread indicators for the user.
```python theme={"dark"}
import os, requests
def mark_as_read(message_id: str) -> None:
token = os.getenv("WHATSAPP_ACCESS_TOKEN")
phone_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
try:
requests.post(
f"https://graph.facebook.com/v21.0/{phone_id}/messages",
headers={"Authorization": f"Bearer {token}"},
json={
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
},
timeout=10,
)
except Exception:
pass
```
Call `mark_as_read(message_id)` right after parsing the webhook.
You can mark the message as read and briefly show a typing indicator while the agent thinks or uses tools. Optionally send a short acknowledgement, then follow up with the final answer.
```python theme={"dark"}
import os, httpx
def send_typing_and_read(message_id: str) -> dict:
token = os.getenv("WHATSAPP_ACCESS_TOKEN")
phone_id = os.getenv("WHATSAPP_PHONE_NUMBER_ID")
if not token or not phone_id:
return {}
url = f"https://graph.facebook.com/v19.0/{phone_id}/messages"
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
with httpx.Client() as client:
# Mark as read
client.post(
url,
headers=headers,
json={
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
},
timeout=10,
)
# Show typing indicator briefly
resp = client.post(
url,
headers=headers,
json={
"messaging_product": "whatsapp",
"typing_indicator": {"type": "typing_on"},
},
timeout=10,
)
return resp.json()
```
* Call this right after parsing the webhook and before long operations.
* Keep interim notifications minimal to avoid noise.
Persist metadata for images, audio, videos, and documents so the agent can reuse them as context later (e.g., to re-attach an image or analyze it again).
Suggested fields to store: `id`, `user_phone`, `direction`, `message_type`, `media_id`, `media_url` (or object store key), `mime_type`, `caption`, `timestamp`.
```python theme={"dark"}
async def save_media_example(record: dict) -> None:
# Merge this idea with your existing save flow
await save_record_to_kb({
**record,
"message_type": "image", # or "video", "audio", "document"
# e.g. values fetched via Graph API media URL endpoint
"media_id": record.get("media_id"),
"media_url": record.get("media_url"),
"mime_type": record.get("mime_type"),
"caption": record.get("caption", ""),
})
```
Later, query recent media by `user_phone` or `conversation_id` and inject URLs or file handles into the agent context as needed.
## Running
```bash theme={"dark"}
python app.py
```
Expose with ngrok, then verify the webhook in the Meta console. New messages should invoke your agent and send responses via WhatsApp.
## Troubleshooting
* **Verify token**: Ensure the value in Meta matches `VERIFY_TOKEN` in your app
* **Public URL**: Your callback must be publicly reachable (use ngrok)
* **HTTPS**: Required in production
* **Tokens**: Confirm `WHATSAPP_ACCESS_TOKEN` and `WHATSAPP_PHONE_NUMBER_ID` are set
* **Phone ID**: Ensure your app uses the correct phone number ID
* **Permissions**: Check token permissions in Access Token Debugger
* Check `ORG_ID` and `KB_ID` if using Timbal KB
* If using JSONL, verify the JSONL file path and write permissions
# Overview
Source: https://docs.timbal.ai/examples/index
Complete guides and building patterns for Timbal AI agents
Learn to build powerful AI agents through comprehensive guides and focused examples that demonstrate real-world applications and core patterns.
## Guides
Production-ready guides that walk you through building real-world scenarios from start to finish. These comprehensive tutorials show you how to integrate Timbal with popular services and deploy to production from start to finish.
AI-powered Gmail integration that triages emails and autonomously drafts intelligent responses
Build a Slack bot that responds in real time via webhooks, with an AI agent and threaded conversations
Build a WhatsApp assistant with webhooks, conversation persistence (Knowledge Base or JSONL), and support for text, audio, images, and documents
## Agents
Quick-start examples showcasing Timbal's agent patterns. Learn specific techniques like tool integration, streaming responses, state management, and multi-agent orchestration through bite-sized implementations.
## Workflows
Step-by-step workflow examples that chain multiple operations together. Perfect for complex business processes that require explicit control flow and data transformation between steps.
# Conditional Routing
Source: https://docs.timbal.ai/examples/workflows/conditional-routing
Process data differently based on validation results
A data processing pipeline that validates input and routes to different handlers based on the result. Only the matching branch executes.
## Workflow
```python pipeline.py theme={"dark"}
from timbal import Workflow
from timbal.state import get_run_context
def validate_data(data: dict) -> str:
"""Validate data and return status."""
if not data.get("email"):
return "invalid"
if data.get("age", 0) < 18:
return "minor"
return "valid"
def process_adult(data: dict) -> dict:
"""Process adult user data."""
return {
"status": "processed",
"user": data["email"],
"tier": "adult",
}
def process_minor(data: dict) -> dict:
"""Process minor user data with restrictions."""
return {
"status": "processed",
"user": data["email"],
"tier": "minor",
"restricted": True,
}
def log_invalid(data: dict) -> dict:
"""Log invalid data."""
return {
"status": "rejected",
"reason": "missing email",
}
pipeline = (
Workflow(name="user_processor")
.step(validate_data, data={"email": "user@example.com", "age": 25})
.step(process_adult,
data=lambda: get_run_context().step_span("validate_data").input.get("data"),
when=lambda: get_run_context().step_span("validate_data").output == "valid")
.step(process_minor,
data=lambda: get_run_context().step_span("validate_data").input.get("data"),
when=lambda: get_run_context().step_span("validate_data").output == "minor")
.step(log_invalid,
data=lambda: get_run_context().step_span("validate_data").input.get("data"),
when=lambda: get_run_context().step_span("validate_data").output == "invalid")
)
```
## How It Works
```
validate_data ──┬─→ process_adult (if "valid")
├─→ process_minor (if "minor")
└─→ log_invalid (if "invalid")
```
1. **`validate_data`** — checks email and age, returns "valid", "minor", or "invalid"
2. **`process_adult`**, **`process_minor`**, or **`log_invalid`** — only the matching branch runs (`when` condition)
## Running
```python theme={"dark"}
result = await pipeline().collect()
print(result.output)
```
The output will be:
```json theme={"dark"}
{
"status": "processed",
"user": "user@example.com",
"tier": "adult"
}
```
# Parallel Fan-Out
Source: https://docs.timbal.ai/examples/workflows/parallel-fan-out
Fetch data from multiple sources in parallel and merge the results
Multiple independent data sources are fetched concurrently, then a final step merges everything into a single report.
## Workflow
```python pipeline.py theme={"dark"}
from timbal import Workflow
from timbal.state import get_run_context
def fetch_users() -> list[dict]:
"""Fetch user data."""
return [
{"id": 1, "name": "Alice", "role": "admin"},
{"id": 2, "name": "Bob", "role": "editor"},
]
def fetch_orders() -> list[dict]:
"""Fetch order data."""
return [
{"id": 101, "user_id": 1, "total": 59.99},
{"id": 102, "user_id": 2, "total": 29.99},
{"id": 103, "user_id": 1, "total": 14.50},
]
def fetch_inventory() -> list[dict]:
"""Fetch inventory data."""
return [
{"product": "Widget A", "stock": 150},
{"product": "Widget B", "stock": 0},
]
def build_report(users: list, orders: list, inventory: list) -> dict:
"""Merge all data into a summary report."""
total_revenue = sum(o["total"] for o in orders)
out_of_stock = [i["product"] for i in inventory if i["stock"] == 0]
return {
"total_users": len(users),
"total_orders": len(orders),
"revenue": total_revenue,
"out_of_stock": out_of_stock,
}
pipeline = (
Workflow(name="dashboard_report")
.step(fetch_users)
.step(fetch_orders)
.step(fetch_inventory)
.step(build_report,
users=lambda: get_run_context().step_span("fetch_users").output,
orders=lambda: get_run_context().step_span("fetch_orders").output,
inventory=lambda: get_run_context().step_span("fetch_inventory").output,
)
)
```
## How It Works
```
fetch_users ─┐
fetch_orders ─┼─→ build_report
fetch_inventory─┘
```
1. **`fetch_users`**, **`fetch_orders`**, and **`fetch_inventory`** run in **parallel** — no dependencies between them
2. **`build_report`** waits for all three to complete, then merges their outputs
## Running
```python theme={"dark"}
result = await pipeline().collect()
print(result.output)
```
The output will be:
```json theme={"dark"}
{
"total_users": 2,
"total_orders": 3,
"revenue": 104.48,
"out_of_stock": ["Widget B"]
}
```
# Sequential Steps
Source: https://docs.timbal.ai/examples/workflows/sequential-steps
Chain multiple processing steps with data passing between them
A document processing pipeline that fetches content, extracts key information, summarizes it with an LLM, and formats the final output. Each step depends on the previous one's output.
## Workflow
```python pipeline.py theme={"dark"}
from timbal import Agent, Workflow
from timbal.state import get_run_context
def fetch_content(url: str) -> str:
"""Fetch raw content from a URL."""
import urllib.request
with urllib.request.urlopen(url) as response:
return response.read().decode("utf-8")
def extract_metadata(html: str) -> dict:
"""Extract title and text from HTML content."""
import re
title_match = re.search(r"(.*?)", html)
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).strip()
return {
"title": title_match.group(1) if title_match else "Untitled",
"text": text[:5000],
}
summarizer = Agent(
name="summarizer",
model="openai/gpt-5-mini",
system_prompt="Summarize the given text in 3 bullet points. Be concise."
)
def format_report(title: str, summary: str) -> str:
"""Format the final report."""
return f"# {title}\n\n{summary}"
pipeline = (
Workflow(name="document_pipeline")
.step(fetch_content, url="https://example.com")
.step(extract_metadata,
html=lambda: get_run_context().step_span("fetch_content").output)
.step(summarizer,
prompt=lambda: get_run_context().step_span("extract_metadata").output["text"])
.step(format_report,
title=lambda: get_run_context().step_span("extract_metadata").output["title"],
summary=lambda: get_run_context().step_span("summarizer").output.collect_text())
)
```
## How It Works
```
fetch_content → extract_metadata → summarizer → format_report
```
1. **`fetch_content`** — fetches raw HTML from the URL
2. **`extract_metadata`** — parses title and text from the HTML (waits for `fetch_content`)
3. **`summarizer`** — LLM summarizes the extracted text (waits for `extract_metadata`)
4. **`format_report`** — combines title and summary into a report (waits for both `extract_metadata` and `summarizer`)
Each lambda creates an automatic dependency. No step runs until its dependencies are resolved.
## Running
```python theme={"dark"}
result = await pipeline().collect()
print(result.output)
```
The output will be similar to:
```
# Example Domain
- The page serves as an illustrative example for documentation purposes
- It can be used freely without permission or coordination
- More information is available through IANA at the referenced link
```
# Workflow Composition
Source: https://docs.timbal.ai/examples/workflows/workflow-composition
Nest workflows inside other workflows for modular pipelines
A reporting pipeline that uses an inner data workflow as a step. The inner workflow handles data fetching and cleaning, while the outer workflow generates and delivers the report.
## Workflow
```python pipeline.py theme={"dark"}
from timbal import Agent, Workflow
from timbal.state import get_run_context
import json
def fetch_sales() -> list[dict]:
"""Fetch raw sales data."""
return [
{"product": "Widget A", "amount": 120.00, "region": "EU"},
{"product": "Widget B", "amount": 85.50, "region": "US"},
{"product": "Widget A", "amount": 200.00, "region": "US"},
{"product": "Widget C", "amount": 45.00, "region": "EU"},
]
def aggregate(sales: list) -> dict:
"""Aggregate sales by region."""
by_region = {}
for sale in sales:
region = sale["region"]
by_region[region] = by_region.get(region, 0) + sale["amount"]
return {
"by_region": by_region,
"total": sum(sale["amount"] for sale in sales),
}
# Inner workflow: data preparation
data_pipeline = (
Workflow(name="data_pipeline")
.step(fetch_sales)
.step(aggregate,
sales=lambda: get_run_context().step_span("fetch_sales").output)
)
analyst = Agent(
name="analyst",
model="openai/gpt-5-mini",
system_prompt="You are a data analyst. Given sales data, write a short summary with key insights."
)
def format_email(analysis: str, data: dict) -> str:
"""Format the final email."""
return f"Subject: Weekly Sales Report\n\n{analysis}\n\nRaw data:\n{json.dumps(data, indent=2)}"
# Outer workflow: uses data_pipeline as a step
report_pipeline = (
Workflow(name="report_pipeline")
.step(data_pipeline)
.step(analyst,
prompt=lambda: json.dumps(get_run_context().step_span("data_pipeline").output))
.step(format_email,
analysis=lambda: get_run_context().step_span("analyst").output.collect_text(),
data=lambda: get_run_context().step_span("data_pipeline").output)
)
```
## How It Works
```
┌─ data_pipeline ─────────┐
│ fetch_sales → aggregate │
└────────────────────┘
│
├─→ analyst
│ │
└────┴─→ format_email
```
1. **`data_pipeline`** (inner workflow) — fetches and aggregates sales data as a single step
2. **`analyst`** — LLM analyzes the aggregated data (waits for `data_pipeline`)
3. **`format_email`** — combines the LLM analysis and raw data into a report (waits for both `data_pipeline` and `analyst`)
The inner workflow's final output (`aggregate`'s result) becomes the step output accessible by the outer workflow.
## Running
```python theme={"dark"}
result = await report_pipeline().collect()
print(result.output)
```
The output will be similar to:
```
Subject: Weekly Sales Report
The total sales for this period amount to $450.50. The US region leads with
$285.50 (63%), while the EU region accounts for $165.00 (37%). Widget A is the
top performer with $320.00 across both regions.
Raw data:
{
"by_region": {
"EU": 165.0,
"US": 285.5
},
"total": 450.5
}
```
# Approval gates
Source: https://docs.timbal.ai/human-in-the-loop/approval-gates
Declarative permission checks that fire before a runnable executes — gate irreversible actions for human approval
Approval gates (`requires_approval`) are declarative permission checks that fire **before** a runnable executes. Use them for irreversible actions: refunds, deploys, account deletions, outbound emails, anything that costs money or moves data. When a gate fires, zero handler code runs until a human decides.
## Quick start
Mark a tool as approval-required, stream events, and resume with a decision keyed by `approval_id`.
```python theme={"dark"}
from timbal import Agent, Tool
from timbal.types.events import ApprovalEvent
def refund_customer(amount: int) -> str:
return f"refunded ${amount}"
refund = Tool(
handler=refund_customer,
requires_approval=lambda amount: amount > 100,
approval_prompt=lambda amount: f"Approve refunding ${amount}?",
)
agent = Agent(
name="support_agent",
model="openai/gpt-5",
tools=[refund],
)
approval_id = None
async for event in agent(prompt="Refund $250"):
if isinstance(event, ApprovalEvent):
approval_id = event.approval_id
result = await agent(
prompt="Refund $250",
resume={approval_id: True},
).collect()
```
When a gate fires, the run ends with `status.code == "cancelled"` and `status.reason == "approval_required"`. The `ApprovalEvent` carries:
* `approval_id` — stable id used to resolve the gate
* `runnable_path`, `runnable_name`, `runnable_type` — what was about to execute
* `tool_call_id` — when the gate fired inside an agent tool call, the LLM `tool_use` id that triggered it, so the frontend can pin the approval card to the exact tool\_use block in the transcript. `None` for direct calls.
* `input` — validated handler input (after redaction, if configured)
* `input_schema` — JSON Schema of the handler params, so the UI can render a typed form
* `prompt`, `description` — human-facing strings
* `t0` — Unix-ms timestamp of when approval was requested (useful for SLA timers)
## Configuring approval gates
`requires_approval` accepts `True`, `False`, or a callable that receives the same kwargs as the handler and returns `bool`. `approval_prompt` and `approval_description` accept strings or callables and surface in the `ApprovalEvent` so the human reviewer has context.
```python theme={"dark"}
high_risk_deploy = Tool(
handler=deploy_handler,
requires_approval=lambda env, **_: env == "production",
approval_prompt=lambda env, **_: f"Deploy to {env}?",
approval_description="Deploys ship traffic to the listed environment.",
)
```
If `requires_approval` or `approval_prompt` raises, the runnable does **not** silently approve nor execute. It ends with `status.code == "error"` and `status.reason == "approval_policy_error"`, distinct from handler errors so dashboards can surface policy bugs separately.
## Resolutions: approve, deny, audit
Pass either a bare `bool` (`True`/`False`) or an `ApprovalResolution` for richer audit fields:
```python theme={"dark"}
from timbal.types.approval import ApprovalResolution
result = await agent(
prompt="Refund $250",
resume={
approval_id: ApprovalResolution(
approved=False,
reason="Refund exceeds policy limit.",
approver_id="user_42",
comment="Customer is outside the refund window.",
)
},
).collect()
```
Audit fields are first-class (not free-form metadata) and persist under `span.metadata["approval"]["resolution"]`:
* `approver_id` — who decided
* `comment` — free-form reasoning
* `decided_at` — Unix-ms timestamp; defaults to construction time. Pass an explicit value if you replay decisions and need idempotency
* `metadata` — org-specific extras
### Edit on approve (`override_input`)
A reviewer often wants to approve *with a tweak* (fix a typo'd recipient, lower an amount) rather than reject and round-trip back to the model. Set `override_input` on the resolution: the keys are merged over the originally-proposed input (override wins), **re-validated through the handler's params model**, and the handler runs with the corrected values.
```python theme={"dark"}
from timbal.types.approval import ApprovalResolution
result = await agent(
prompt="email the customer",
parent_id=paused_run_id,
resume={
approval_id: ApprovalResolution(
approved=True,
override_input={"to": "correct@example.com"}, # fix just this field
)
},
).collect()
```
Only the listed keys change; everything else from the proposal is kept. The edit is audited under `span.metadata["approval"]["resolution"]["override_input"]`, and the effective (redacted) input the handler ran with is recorded at `span.metadata["approval"]["effective_input"]`. `override_input` is ignored on denial. Re-validation means a bad override (wrong type, missing required field) fails the run with a normal validation error instead of silently running garbage.
### Tool denial vs Agent denial
Behavior differs based on who initiated the call:
* **Direct tool call** — denial returns `status.code == "cancelled"` and `status.reason == "approval_denied"`. The handler does not run.
* **Tool called by an Agent** — denial is converted into a `ToolResultContent` so the model can see "this tool was denied" and choose another path (apologize, escalate, try an alternative). The agent does not crash.
## Time-limited decisions
Bound decisions with `expires_at` (Unix-ms). Expired resolutions are ignored at gate time and the gate emits a fresh `ApprovalEvent` with `metadata["approval"]["expired"] == True`:
```python theme={"dark"}
import time
from timbal.types.approval import ApprovalResolution
decision = ApprovalResolution(
approved=True,
approver_id="user_42",
comment="Approved from the support console.",
expires_at=int(time.time() * 1000) + 60_000, # valid for 60s
)
```
This is useful when an operator stamps a decision in a UI but a worker doesn't pick it up immediately. Stale decisions force a fresh re-review instead of silently going through.
## Redacting approval input
Approval input is shown to humans and written to traces. If a gated runnable receives secrets or PII, redact the public approval snapshot.
The simple form lists keys to mask with `"***"`:
```python theme={"dark"}
rotate_key = Tool(
handler=rotate_key_impl,
requires_approval=True,
approval_prompt="Rotate this API key?",
approval_redact_keys=["api_key", "password"],
)
```
For custom logic, use `approval_redactor`. It receives a copy of the validated input dict and returns the public snapshot:
```python theme={"dark"}
rotate_key = Tool(
handler=rotate_key_impl,
requires_approval=True,
approval_redactor=lambda input: {
**input,
"api_key": "***",
"customer_email": input["customer_email"].split("@")[0] + "@***",
},
)
```
The redacted snapshot is used everywhere the input would otherwise be visible: `ApprovalEvent.input`, `span.input` while the gate is pending, `span.metadata["approval"]["input"]`, `OutputEvent.metadata["pending_approvals"]`, and any exporter (OTel, Langfuse, etc.). The handler still receives the **original unredacted input** when the approval is resumed.
A redactor that raises or returns a non-dict falls back to a placeholder so secrets never leak through a buggy redactor.
## `approval_id` semantics
The `approval_id` is derived from `(runnable_path, validated_input)`. The same path + input shares one decision, so a single resolution survives retries of the same call (stream resumes, transient failures, agent loops re-asking for the same tool). Treat the id as **opaque**: the derivation is an internal contract and may change across SDK versions, so don't persist ids across deploys.
For irreversible operations (money movement, destructive deletes) where every call must require a fresh decision, include a unique value in the input so each call derives a distinct `approval_id`, typically an `idempotency_key: str` parameter with `default_params={"idempotency_key": lambda: str(uuid4())}`. Timbal evaluates the callable per-invocation.
## Approvals in workflows
Workflow steps follow the same rules as tools. `requires_approval` is a `Runnable` config, so wrap the function in a `Tool` (or use any `Runnable`) before adding it as a step. When a gated step fires, the workflow run cancels with `approval_required` and emits one `ApprovalEvent` per pending gate. **Independent gates fire in parallel**, so you don't need to ping-pong one approval at a time.
```python theme={"dark"}
from timbal import Tool, Workflow
deploy_prod = Tool(
name="deploy_prod",
handler=deploy_prod_impl,
requires_approval=True,
approval_prompt="Promote to prod?",
)
workflow = (
Workflow(name="release_pipeline")
.step(deploy_staging)
.step(deploy_prod)
.step(announce, depends_on=["deploy_prod"])
)
```
When you resume with `resume={...}`, only the steps you decided on advance. Other pending gates remain pending and re-emit on the next call. This means you can approve a subset, observe what runs, and decide on the rest later.
## See also
* [Suspend & interaction tools](/human-in-the-loop/suspend) — ask the user for arbitrary input mid-run
* [Resuming a paused run](/human-in-the-loop/resuming) — durable cross-process resume and cancellation
* [Observability](/human-in-the-loop/observability) — status reasons and usage counters
# Client integration (HTTP)
Source: https://docs.timbal.ai/human-in-the-loop/client-integration
The full contract for a frontend talking to the /stream SSE endpoint: pause, render, resume
This is the full contract for a frontend talking to `timbal serve` (the `/stream` SSE endpoint). Two phases: **pause** (server → client) and **resume** (client → server). The example uses `ask_user`; an approval gate is identical except the pause event is an `APPROVAL` event and you resume with `true`/`false`.
## 1. Start the run
`POST /stream` with the runnable's params:
```json theme={"dark"}
{ "prompt": "set up my database" }
```
The response is an SSE stream (`text/event-stream`), one `data:` line per event. When the agent calls `ask_user`, the client receives an **`INTERACTION`** event:
```json theme={"dark"}
{
"type": "INTERACTION",
"run_id": "06a22e62b85d7b558000db9caf2790b2",
"path": "assistant.ask_user",
"t0": 1780672043531,
"interaction_id": "bfd231bf5760d201b31382e168c339d4",
"kind": "ask_user",
"runnable_name": "ask_user",
"runnable_type": "Tool",
"tool_call_id": "toolu_01abc...",
"payload": { "question": "Which database should I use?", "options": ["postgres", "mysql", "sqlite"] },
"response_schema": { "type": "string", "enum": ["postgres", "mysql", "sqlite"] }
}
```
`tool_call_id` (when present) is the LLM tool\_use id, so you can pin the prompt next to the matching message in the transcript. `response_schema` (when the tool declared one) is the JSON Schema the answer must satisfy; validate the user's input against it before resuming. An `APPROVAL` event additionally carries `input_schema` (the handler's params schema, for rendering a typed form).
Immediately followed by the final **`OUTPUT`** event for the run, which marks it as paused:
```json theme={"dark"}
{
"type": "OUTPUT",
"run_id": "06a22e62b85d7b558000db9caf2790b2",
"path": "assistant",
"status": { "code": "cancelled", "reason": "input_required", "message": "Input required to resume." },
"output": {
"suspension_id": "bfd231bf5760d201b31382e168c339d4",
"status": "input_required",
"kind": "ask_user",
"payload": { "question": "Which database should I use?", "options": ["postgres", "mysql", "sqlite"] }
}
}
```
**What the frontend does:**
* Render UI from the `INTERACTION` event's `kind` + `payload` (here: a question with three option buttons). For an `APPROVAL` event, render `prompt` + `input` and offer Approve/Deny.
* Stash `run_id` and `interaction_id` (or `approval_id`).
* A run is paused (not finished) whenever the terminal `OUTPUT` has `status.reason` of `input_required` or `approval_required`.
## 2. Resume with the answer
`POST /stream` again, echoing the original params plus two keys: `parent_id` (the paused `run_id`) and `resume` (a map of `id → value`):
```json theme={"dark"}
{
"prompt": "set up my database",
"parent_id": "06a22e62b85d7b558000db9caf2790b2",
"resume": { "bfd231bf5760d201b31382e168c339d4": "postgres" }
}
```
The run replays, `ask_user` returns `"postgres"`, and the stream ends with a normal success `OUTPUT`:
```json theme={"dark"}
{
"type": "OUTPUT",
"path": "assistant",
"status": { "code": "success", "reason": "end_turn" },
"output": { "role": "assistant", "content": [{ "type": "text", "text": "Great, using postgres." }] }
}
```
That's the whole loop. If a turn opens **multiple** pauses (parallel tools/steps, even a mix of approvals and interactions), the client receives one event per pause and sends every answer in a single `resume` map: `{ "": ..., "": true }`. The id you send back is always the one you received: approval ids resume with `true`/`false`, interaction ids with the value the handler asked for.
## Cancelling over HTTP
To abort instead of answering (user closed the dialog, navigated away), resume the id with the tagged cancel object, the JSON equivalent of `Cancel`:
```json theme={"dark"}
{
"prompt": "set up my database",
"parent_id": "06a22e62b85d7b558000db9caf2790b2",
"resume": { "bfd231bf5760d201b31382e168c339d4": { "type": "timbal.cancel", "reason": "user closed the dialog" } }
}
```
The run ends with a terminal `OUTPUT` of `status.reason == "cancelled"` (not `input_required`/`approval_denied`), and nothing is sent back to the model. An edit-on-approve over HTTP is the same idea on an approval id: `{ "": { "approved": true, "override_input": { "to": "fixed@example.com" } } }`.
## See also
* [Resuming a paused run](/human-in-the-loop/resuming) — the in-process and durable-provider story
* [Suspend & interaction tools](/human-in-the-loop/suspend) — `InteractionEvent` and response schemas
* [Approval gates](/human-in-the-loop/approval-gates) — the `APPROVAL` event and its fields
# Overview
Source: https://docs.timbal.ai/human-in-the-loop/index
Pause any run for a human: gate irreversible actions for approval, or stop mid-handler to ask the user a question, then resume on the same durable rails
These patterns apply to any **Runnable**: [Agents](/agents), [Workflows](/workflows) steps, and standalone [Tools](/agents/tools). Timbal pauses a run for a human in two ways, and resumes both through one channel:
* **[Approval gates](/human-in-the-loop/approval-gates)** (`requires_approval`) are declarative permission checks that fire **before** a runnable executes. Use them for irreversible actions: refunds, deploys, account deletions, outbound emails, anything that costs money or moves data.
* **[`suspend()`](/human-in-the-loop/suspend)** is a control-flow primitive a handler calls from the **inside** to ask the user for arbitrary input (a clarifying question, a picked option, a confirmation). Use it to build interaction tools like `ask_user` or `confirm`.
Both pause the run, persist it, emit a structured event, and resume when you call the runnable again with `resume={...}`. They share the same durable rails (`parent_id` + tracing provider). The only real difference is *when* they pause and *what* they resume with.
| | `requires_approval` (gate) | `suspend()` (interaction) |
| ------------- | ------------------------------------------------------- | --------------------------------------------------- |
| Intent | "may I do this?" | "tell me / give me" |
| Pauses | before the handler runs | inside the handler |
| Handler ran? | no (zero code) | yes, up to the `suspend()` call (re-runs on resume) |
| Resumes with | `bool` / `ApprovalResolution` | arbitrary value |
| Applied | declaratively on any runnable (even ones you don't own) | by calling `suspend()` in the handler |
| Event | `ApprovalEvent` | `InteractionEvent` |
| Cancel reason | `approval_required` | `input_required` |
`resume={id: value}` is the single channel for continuing **any** paused run. For an approval gate the value is a decision (`True`/`False` or an `ApprovalResolution`); for a `suspend()` call it's the arbitrary value the handler asked for. A [`Cancel`](/human-in-the-loop/resuming#cancelling-instead-of-answering) value works for either and aborts the whole run. The id-spaces are disjoint, so one `resume` dict can settle a mix of pending approvals and suspensions in a single call. Unrecognized ids are ignored with a warning.
## On this page group
* **[Approval gates](/human-in-the-loop/approval-gates)** — `requires_approval`, resolutions, edit-on-approve, redaction, time limits, workflows
* **[Suspend & interaction tools](/human-in-the-loop/suspend)** — `suspend()`, writing `ask_user`/`ask_user_multi`/`confirm`, `InteractionEvent`, response schemas
* **[Resuming a paused run](/human-in-the-loop/resuming)** — cancel, durable cross-process resume, duplicate-worker protection, enumerating pending
* **[Client integration (HTTP)](/human-in-the-loop/client-integration)** — the `/stream` wire contract a frontend talks to
* **[Observability](/human-in-the-loop/observability)** — status reasons, usage counters, common patterns
# Observability
Source: https://docs.timbal.ai/human-in-the-loop/observability
Status reasons, usage counters, and common patterns for paused runs
## Status reasons
When the run cancels, `OutputEvent.status.reason` carries one of:
* `approval_required` — a gate emitted an `ApprovalEvent` and is waiting on a decision
* `approval_denied` — a denying resolution was consumed (direct call only; agents convert this to a tool result)
* `approval_already_claimed` — durable claim said another worker already resumed this gate
* `approval_policy_error` — a `requires_approval` / `approval_prompt` callable raised
* `input_required` — a `suspend()` call emitted an `InteractionEvent` and is waiting on a value
* `cancelled` — a `Cancel` was supplied on resume (approval or suspension); the whole run aborted and nothing was fed back to the model
## Usage counters
`OutputEvent.usage` records pause-lifecycle counters so you can plot them in dashboards:
* `approvals:required` — a gate emitted an `ApprovalEvent`
* `approvals:approved` — a valid approved resolution was consumed
* `approvals:denied` — a valid denied resolution was consumed
* `approvals:expired` — an expired resolution was ignored and the gate re-emitted
* `approvals:cancelled` — a `Cancel` aborted the run at a gate
* `suspends:required` — a `suspend()` call paused the run
These propagate through the usage merge tree just like token counts, so a parent agent run aggregates pause counts from every nested tool/workflow gate or suspension.
## Common patterns
1. Configure a durable provider (`JsonlTracingProvider` / `SqliteTracingProvider` / `PlatformTracingProvider`).
2. UI calls `agent(prompt=...)`, captures `ApprovalEvent` (or polls the trace for `pending_approvals()`), shows reviewer the prompt + redacted input.
3. Reviewer clicks Approve/Deny. UI persists `(approval_id, ApprovalResolution)` to a queue.
4. Worker pulls the message and calls `agent(prompt=..., parent_id=run_id, resume={approval_id: resolution})`.
5. If `result.status.reason == "approval_already_claimed"`, no-op. Otherwise, the handler executed exactly once.
Give the agent the `ask_user` tool. When it's blocked it calls `ask_user`, the run ends `input_required` with an `InteractionEvent`, your UI renders the question, and you resume with `resume={interaction_id: answer}`. Keep everything before the `suspend()` call idempotent: the handler re-runs from the top on resume.
The agent may call multiple gated/suspending tools in parallel. Each pause emits its own event and the run cancels once they all settle. Collect every id, present them as a checklist, and resume with the full dict in one call: approvals and interactions can be mixed freely.
The default `approval_id` is stable across retries of the same `(path, input)`. To require a fresh decision per invocation, include a per-call unique value in the input, typically an `idempotency_key=str(uuid4())`, so each call derives a distinct id.
Use `approval_redact_keys=["api_key", "password"]` for the simple case. Use `approval_redactor=lambda input: {...}` for partial masking (e.g. mask the local-part of an email but keep the domain). The handler still receives the unredacted input.
The model proposed a slightly-wrong argument (a typo'd email, an amount that should be lower). Instead of denying and round-tripping, approve with `ApprovalResolution(approved=True, override_input={"to": "fixed@example.com"})`. The override merges over the proposal, re-validates through the params model, and the handler runs with the corrected input. Over HTTP send `{"approved": true, "override_input": {...}}`.
When the user closes the dialog or navigates away rather than deciding, resume with `Cancel(reason=...)` (HTTP: `{"type": "timbal.cancel", "reason": "..."}`) keyed to any pending id. The run ends `status.reason == "cancelled"` and nothing is fed back to the model, distinct from a denial, which the agent would see and react to.
## See also
* [Approval-Required Tools example](/examples/agents/approval-required-tools) — runnable end-to-end snippet
* [Events & Streaming](/core-concepts/events) — the event stream
* [Tracing](/core-concepts/tracing) — how runs are persisted and replayed
* [Context & State Management](/core-concepts/context) — `RunContext` internals
* [Tools](/agents/tools) — tool configuration reference
# Resuming a paused run
Source: https://docs.timbal.ai/human-in-the-loop/resuming
Resume any paused run (gate or suspension) with parent_id and resume={...}, in-process or across workers
You resume any paused run, gate or suspension, by calling the runnable again with `parent_id` (the paused run id) and `resume={...}`. In-process this works out of the box; across processes you need a durable provider; and you can enumerate what's pending either way.
## Cancelling instead of answering
A human won't always say yes or no. Sometimes they close the dialog, hit Escape, or abandon the task. That's a **cancel**, and it's different from a deny:
* **Deny / decline** continues the run. An approval `approved=False` is fed back to the model as a tool result so the agent can apologize or try another path; a `suspend()` decline is just a value your handler interprets.
* **Cancel** aborts the *entire* run. Nothing is fed back to the model.
Resume with `Cancel` (works on either an approval or a suspension id) to abort:
```python theme={"dark"}
from timbal.types.approval import Cancel
result = await agent(
prompt="...",
parent_id=paused_run_id,
resume={pending_id: Cancel(reason="user closed the dialog")},
).collect()
# result.status.code == "cancelled"
# result.status.reason == "cancelled" # distinct from approval_denied / input_required
# result.status.message == "user closed the dialog"
```
The handler never runs. The cancel reason lands on the cancelled span/status so it's queryable in traces, and a `Cancel` keyed to any pending id in a batch tears down the whole run.
## Across processes (durable providers)
Everything above works in-process by default. For "pause in a UI now, resume in a worker later" (the real human-in-the-loop shape) switch off the default `InMemoryTracingProvider` (which only resumes within the same Python process) to a durable provider. This applies identically to approval gates and `suspend()`.
```python theme={"dark"}
from pathlib import Path
from timbal import Agent
from timbal.state.tracing.providers import JsonlTracingProvider
provider = JsonlTracingProvider.configured(_path=Path("traces.jsonl"))
agent = Agent(
name="support_agent",
model="openai/gpt-5",
tools=[refund],
tracing_provider=provider,
)
```
`JsonlTracingProvider` writes one record per run and uses a sidecar lock file (`traces.jsonl.approval_claims.json` + `.lock` via `fcntl`) for cross-process approval claims. Good for local dev and single-host deployments. Not recommended for high-throughput production: `_store()` rewrites the file on each run.
```python theme={"dark"}
from pathlib import Path
from timbal.state.tracing.providers import SqliteTracingProvider
provider = SqliteTracingProvider.configured(_path=Path("traces.db"))
```
Same API as JSONL but uses a SQLite database with row-level locking. Better for higher write rates on a single host.
When `TIMBAL_API_KEY` and a project subject are set, runs auto-select `PlatformTracingProvider`. Paused runs replicate across workers without extra config.
To resume from a different process, pass the original run id as `parent_id`:
```python theme={"dark"}
result = await agent(
prompt="Refund $250",
parent_id=paused_run_id,
resume={approval_id: True},
).collect()
```
Timbal loads the parent trace (input messages, pending gates/suspensions, prior tool calls) before executing the resolution, so the runnable sees exactly the state it was paused at.
## Memory compaction on resume
If the agent has `memory_compaction` configured, a resume turn is treated like a continuation of the paused turn, not a fresh one. The loaded memory ends with the gated/suspended `tool_use` that has **no `tool_result` yet** — that trailing block is exactly what Timbal re-executes to settle the pause without re-calling the model.
[Tool result offloading](/agents/memory-compaction#tool-result-offloading) applies on resume the same way it does on a fresh turn: when the gated tool finally runs after approval, an oversized result is reduced once before it enters memory.
Compaction is structure-aware about this:
* The pending `tool_use` is **never** compacted away. (A naive pass would treat it as an orphaned tool call, strip it, and force the model to re-plan — which is nondeterministic and would silently drop the human's decision.)
* The **history before** the pending call is still compacted, so resuming a long paused thread doesn't carry the full uncompacted transcript into the continuation LLM call and overflow the context window.
This holds for every built-in strategy (`compact_tool_results`, `keep_last_n_messages`, `keep_last_n_turns`, `summarize`) and any custom compactor. You don't configure anything extra — it just works on the same `resume=` call.
Compaction runs at turn boundaries **and** between iterations within a turn (mid-loop), so a single turn that makes many or large tool calls is compacted as it grows once utilization crosses `memory_compaction_ratio`. Mid-loop compaction always protects the most recent (unconsumed) assistant tool batch — the results the next model call must read — so it bounds context without sending the agent back to re-plan the same step.
In a `Workflow`, compaction is a per-step concern: a `Workflow` has no LLM context of its own, and an `Agent` used as a step runs with isolated context (no cross-turn memory) but still mid-loop-compacts a long tool loop inside that step. Step outputs passed between steps are not "context" and are never compacted.
## Duplicate worker protection
When multiple workers consume the same queue, two of them might race to resume the same `(parent_id, approval_id)`. Timbal **claims** the pair before executing the resolution. The first claimer wins; later duplicates stop before handler execution with `status.reason == "approval_already_claimed"`.
```python theme={"dark"}
result = await agent(
prompt="Refund $250",
parent_id=paused_run_id,
resume={approval_id: True},
).collect()
if result.status.reason == "approval_already_claimed":
# Another worker already executed this approval. Safe to no-op.
return
```
This protection is implemented by `JsonlTracingProvider` and `SqliteTracingProvider` out of the box. **Custom providers must override `claim_approval(parent_id, approval_id, run_id)`** to get the same durable-lock behavior; the base class default is a no-op.
## Enumerating what's pending
When a run cancels and the runnable had multiple concurrent calls, each pause emits its own event. There are two ergonomic ways to enumerate them, and the same shape applies to both approvals and interactions.
**During the stream**, capture every event:
```python theme={"dark"}
pending_approvals, pending_interactions = [], []
async for event in agent(prompt="..."):
if isinstance(event, ApprovalEvent):
pending_approvals.append(event)
if isinstance(event, InteractionEvent):
pending_interactions.append(event)
```
**After `.collect()`**, read the lists the collector attaches to `OutputEvent.metadata` (the `status` only references the first pause):
```python theme={"dark"}
result = await agent(prompt="...").collect()
if result.status.reason == "approval_required":
for entry in result.metadata["pending_approvals"]:
print(entry["approval_id"], entry["runnable_path"], entry["prompt"], entry["input"])
if result.status.reason == "input_required":
for entry in result.metadata["pending_interactions"]:
print(entry["interaction_id"], entry["kind"], entry["payload"])
```
Resume by passing **all** the decisions/answers you want to settle in one call:
```python theme={"dark"}
resume = {entry["approval_id"]: True for entry in result.metadata["pending_approvals"]}
result = await agent(prompt="...", parent_id=paused_run_id, resume=resume).collect()
```
For server-side traversal of a loaded trace (e.g. building a review queue from durable storage), `RunContext.pending_approvals()` and `RunContext.pending_interactions()` walk `RunContext._trace` directly. They tolerate both live `RunStatus` and dict-after-reload shapes, so they work against in-memory, JSONL, SQLite, and platform traces. Approval entries use the **redacted** input snapshot, never the raw secrets.
`metadata["pending_*"]` is added by `.collect()`. Over the HTTP server (which streams **raw** events) the frontend reads the `APPROVAL` / `INTERACTION` events directly instead. See [Client integration (HTTP)](/human-in-the-loop/client-integration).
## See also
* [Approval gates](/human-in-the-loop/approval-gates) — declarative gates for irreversible actions
* [Suspend & interaction tools](/human-in-the-loop/suspend) — ask the user for arbitrary input
* [Client integration (HTTP)](/human-in-the-loop/client-integration) — the `/stream` wire contract
# Suspend & interaction tools
Source: https://docs.timbal.ai/human-in-the-loop/suspend
Pause a run from inside a handler to ask the user for arbitrary input, then resume with their answer
`suspend()` pauses a run from inside a handler and waits for an externally-supplied value. It's the general form of the [approval gate](/human-in-the-loop/approval-gates) (an approval resumes with a `bool`, a suspension resumes with anything) and it's how you build tools like `ask_user`, `confirm`, or `pick_option` that let an agent explicitly stop and hand control back to the user mid-loop.
## How it works
1. A handler calls `suspend(payload, kind=...)`.
2. The run ends with status `cancelled` / reason `input_required` and emits an `InteractionEvent` carrying `payload`.
3. The frontend renders the payload (keyed by `kind`) and collects a response.
4. You resume by calling the runnable again with the original run as `parent_id` and `resume={interaction_id: value}`. The handler re-executes and `suspend()` returns `value`.
```python theme={"dark"}
from timbal import Agent
from timbal.tools import ask_user
from timbal.types.events import InteractionEvent, OutputEvent
agent = Agent(name="assistant", model="openai/gpt-5", tools=[ask_user])
# First call — the model asks a question and the run suspends.
pending = []
final = None
async for event in agent(prompt="set up my database"):
if isinstance(event, InteractionEvent):
pending.append(event)
if isinstance(event, OutputEvent) and event.path == "assistant":
final = event
# pending[0].kind == "ask_user"
# pending[0].payload == {"question": "Which database?", "options": [...]}
# Resume with the user's answer.
result = await agent(
prompt="set up my database",
parent_id=final.run_id,
resume={pending[0].interaction_id: "postgres"},
).collect()
```
## Writing an interaction tool
A tool is any handler that calls `suspend()`:
```python theme={"dark"}
from timbal import suspend
def ask_user(question: str, options: list[str] | None = None) -> str:
"""Ask the user a clarifying question. Use ONLY when blocked."""
return suspend({"question": question, "options": options}, kind="ask_user")
def confirm(action: str) -> bool:
return bool(suspend({"action": action}, kind="confirm"))
```
`suspend` is exported at the top level (`from timbal import suspend`), and the ready-made `ask_user` / `ask_user_multi` / `confirm` tools ship in `timbal.tools` (`from timbal.tools import ask_user, ask_user_multi, confirm`).
Because the handler **re-executes from the top** on resume, `suspend()` must come before any non-idempotent side-effect in the handler (same constraint as LangGraph's `interrupt()`). Put the `suspend()` call first, or make everything before it idempotent. This is exactly why irreversible actions belong behind an [approval gate](/human-in-the-loop/approval-gates) instead: the gate guarantees zero handler code runs until approved.
## `InteractionEvent`
```python theme={"dark"}
class InteractionEvent(BaseEvent):
type: Literal["INTERACTION"]
t0: int # Unix-ms timestamp at which the run suspended
interaction_id: str # pass back as the resume key
kind: str # frontend renderer discriminator (e.g. "ask_user")
runnable_path: str
runnable_name: str
runnable_type: str
tool_call_id: str | None # originating LLM tool_use id (None for direct calls)
payload: dict # what the frontend renders
response_schema: dict | None # optional JSON Schema for the resume value
```
The `interaction_id` is a deterministic hash of the handler path + payload, stable across the pause/resume round-trip, so the client always sends back the id it received. `tool_call_id` lets the client correlate the interaction with the exact tool\_use block in the chat transcript when the agent triggered it.
### Declaring a response schema
Pass `response_schema` to `suspend()` to tell the client what shape the resume value must be. It rides on the `InteractionEvent` so the frontend can validate the user's input *before* resuming, with no extra round-trip to discover a bad value.
```python theme={"dark"}
from timbal import suspend
def ask_age() -> int:
"""Ask the user for their age."""
return suspend(
{"question": "How old are you?"},
kind="ask_user",
response_schema={"type": "integer", "minimum": 0, "maximum": 130},
)
```
The schema is advisory metadata for the client. `None` (the default) means any value is accepted.
`response_schema` is **not enforced server-side**. Timbal hands the resume value to the handler verbatim and does not validate it against the schema. Treat the schema as a contract for *your* client to validate against before resuming; if you need server-side guarantees, validate inside the handler after `suspend()` returns.
## See also
* [Approval gates](/human-in-the-loop/approval-gates) — gate irreversible actions before any handler code runs
* [Resuming a paused run](/human-in-the-loop/resuming) — durable cross-process resume and cancellation
* [Client integration (HTTP)](/human-in-the-loop/client-integration) — the `/stream` wire contract
# Introduction
Source: https://docs.timbal.ai/index
Simple, performant, battle-tested framework for building reliable AI applications
## What is Timbal?
Timbal is an open-source Python framework for building reliable AI applications. It gives you two primitives, **Agents** for autonomous reasoning and **Workflows** for explicit pipelines, plus everything you need to take them to production: a stable multi-provider model layer, memory, tracing, and evals.
There's no hidden magic. Under the hood it's async functions, Pydantic validation, and event-driven streaming. If you know `async`/`await`, you already know how Timbal works.
## Agents vs Workflows
**[Agents](/agents)** are autonomous execution units that orchestrate LLM calls and tool use. The model decides what to do.
Best for:
* Open-ended problem solving and multi-step reasoning
* Dynamic tool selection based on context
* Multi-turn conversations with memory
**[Workflows](/workflows)** are explicit, step-by-step pipelines. You define the steps, and the framework infers dependencies and runs independent steps concurrently. See the [workflows guide](/workflows) for control flow, branching, and composition.
Best for:
* Multi-stage data processing
* Predictable, deterministic execution paths
* Performance-critical work that benefits from parallelism
Both share the same interface and emit the same event stream, so you can compose them freely. An Agent can be a Workflow step, and a Workflow can be an Agent's tool.
## Why Timbal
* **The most performant agent framework.** In overhead benchmarks against LangGraph, CrewAI, the OpenAI Agents SDK, PydanticAI, and Agno (observability on both sides, faked LLMs to isolate framework cost), Timbal runs agent loops several times faster while using a fraction of the memory.
* **Simple and hackable.** The core framework is under 10k lines with no hidden magic, just async functions and Pydantic. You can read it, modify it, and fork it to fit your needs. The others are bloated with legacy, indirection, and abstraction that make doing the same nearly impossible.
* **One interface.** Agents, Workflows, and Tools share the same calling convention and event stream. Learn it once.
* **Provider-agnostic.** Swap models by changing a string. Built-in `FallbackModel` chains providers for automatic failover.
* **Production-shaped.** Refined in production before open-sourcing. Fast failure, clear errors, stable interfaces.
* **Stable in a chaotic ecosystem.** Providers ship breaking changes monthly. Timbal absorbs the churn so your code doesn't have to.
## Key Features
Persistent context with tool-result offloading and compaction for long conversations.
One interface across every provider, with fallback chains for failover.
A built-in tool library, your own functions, and any MCP server.
Get typed Pydantic models back instead of raw text.
Pause for approval or input, then resume across process restarts.
Reusable tool packages the agent loads on demand.
Every run produces a full span trace, exportable over OTLP.
Declarative YAML evaluation suite with built-in validators.
Run the full stack locally with `timbal start`, then ship it.
New here? Head to the [Quickstart](/quickstart) to build and run your first agent.
# Installation
Source: https://docs.timbal.ai/installation
Step-by-step instructions for setting up Timbal CLI and Python package
## Installation methods
The **Timbal CLI** provides a command-line interface for managing Timbal projects, including initialization, building, deploying, and other project management tasks.
Use `curl` to download and execute the installation script:
```bash theme={"dark"}
curl -LsSf https://timbal.ai/install.sh | sh
```
If your system doesn't have `curl`, you can use `wget`:
```bash theme={"dark"}
wget -qO- https://timbal.ai/install.sh | sh
```
Use PowerShell to download and execute the installation script:
```powershell theme={"dark"}
powershell -ExecutionPolicy ByPass -c "irm https://timbal.ai/install.ps1 | iex"
```
The installation script may be inspected before use:
```bash theme={"dark"}
curl -LsSf https://timbal.ai/install.sh | less
```
```powershell theme={"dark"}
irm https://timbal.ai/install.ps1 | less
```
Alternatively, the installer or binaries can be downloaded directly from [GitHub](https://github.com/timbal-ai/timbal/releases).
## Upgrading
To upgrade the CLI to the latest version, you can either run:
```bash theme={"dark"}
timbal upgrade
```
Or re-run the installation script:
```bash theme={"dark"}
curl -LsSf https://timbal.ai/install.sh | sh
```
```powershell theme={"dark"}
powershell -ExecutionPolicy ByPass -c "irm https://timbal.ai/install.ps1 | iex"
```
## Uninstallation
To remove the CLI, delete the binary and the `~/.timbal` data directory:
```bash theme={"dark"}
rm ~/.local/bin/timbal
rm -rf ~/.timbal
```
```powershell theme={"dark"}
Remove-Item "$env:USERPROFILE\.local\bin\timbal.exe"
Remove-Item -Recurse -Force "$env:USERPROFILE\.timbal"
```
***
## Python Package
If you want to use Timbal directly in your Python projects or integrate it into an existing codebase, you can install the Python package directly.
Before adding the package, ensure you have `python>=3.11` installed.
Using `pip`:
```bash theme={"dark"}
pip install timbal
```
Or with `uv`:
```bash theme={"dark"}
uv add timbal
```
Alternatively, if you want the latest changes and want to experiment with beta features, you can clone the repository and install from source:
```bash theme={"dark"}
git clone https://github.com/timbal-ai/timbal.git
cd timbal
pip install -e .
```
**Next Steps:** You're ready to create your first Timbal project! Continue to the [quickstart guide](./quickstart) to build your first AI agent.
# Claude
Source: https://docs.timbal.ai/mcp-integration/claude
Learn how to connect Timbal to Claude in the browser with OAuth or in Claude Code with a Timbal API Key via MCP.
Use **[OAuth on Claude.ai](#mcp-oauth)** in your browser, or a **[Timbal API Key with Claude Code](#mcp-api-key)** (the `claude` CLI).
## Claude.ai: OAuth
> ### Step 1: Open Claude.ai
1. In your browser, open [claude.ai](https://claude.ai) and sign in.
> ### Step 2: Access Connectors
1. In the left sidebar, open **Customize** → **Connectors**, or go directly to [claude.ai/customize/connectors](https://claude.ai/customize/connectors).
> ### Step 3: Add Timbal as a new MCP server
1. Click the **+** button (next to the search control), then choose **Add custom connector**.
2. Add the Timbal MCP URL (`https://api.timbal.ai/mcp`). For example, you can name the connector **`timbal-mcp`**.
3. When **timbal-mcp** appears in the list as a custom connector, click **Connect**.
4. A browser window will open to complete **Timbal authentication**.
5. Log in using your preferred method: GitHub, Google, Microsoft, or Timbal OAuth.
6. Accept the OAuth permissions when prompted and continue.
7. Return to Claude — you can use **Timbal tools** in chat.
Make sure to log in with your active Timbal account — the account registered on the [Timbal Platform](https://app.timbal.ai). Using a different account may prevent access to Timbal tools.
## Claude Code: API Key
> ### Step 1: Open Claude Code
1. Install [Claude Code](https://docs.claude.com/en/docs/claude-code/setup) (the `claude` CLI) on your machine if you have not already.
2. Open a terminal where you want to use Timbal tools with Claude.
> ### Step 2: Add Timbal as a new MCP server
1. Run the following, replacing `` with your Timbal API Key:
```bash theme={"dark"}
claude mcp add --transport http timbal-mcp https://api.timbal.ai/mcp \
--header "Authorization: Bearer "
```
2. If you already added `timbal-mcp` without a header, run `claude mcp remove timbal-mcp`, then run the command again.
You can choose another server name instead of `timbal-mcp`; it is only a label in your local Claude Code configuration.
See the [Authentication documentation](https://docs.timbal.ai/api-reference/authentication#authentication) for instructions on generating an API Key.
> ### Step 3: Start using Timbal in Claude Code
1. Run `claude` and work in your repository as usual.
2. Start a chat.
3. You can now use **Timbal tools** in your conversation. Claude Code will automatically leverage the MCP integration.
4. Approve tool runs when Claude Code prompts you, if your security settings require confirmation.
# Cursor
Source: https://docs.timbal.ai/mcp-integration/cursor
Learn how to connect Timbal to Cursor via MCP to access its features.
> ## Step 1: Open the Cursor interface
1. Launch the **Cursor** app on your system.
2. Log in with your account if required.
> ## Step 2: Access MCP settings
1. In the top menu, click **Settings** ( on the top-right) -> **Cursor Settings**.
2. Navigate to the **Tools & MCP** section.
3. This section allows you to **add, edit, or remove MCP connections**.
> ## Step 3: Add Timbal as a new MCP server
1. Click **Add Custom MCP**.
2. Add the Timbal MCP URL (`https://api.timbal.ai/mcp`) in `mcp.json`. For example, you can name it `"timbal-mcp"`:
```json title=mcp.json theme={"dark"}
{
"mcpServers": {
"timbal-mcp": {
"url": "https://api.timbal.ai/mcp",
// Use this to authenticate with a Timbal API Key instead of OAuth
// "headers": {
// "Authorization": "Bearer "
// }
}
}
}
```
3. Save the configuration.
### Option 1: Connect via OAuth
4. In the **Tools & MCP** section, check the **Installed MCP Servers** list — Timbal should appear there.
5. Click **Connect**.
6. A browser window will open to complete **Timbal authentication**.
7. Log in using your preferred method: GitHub, Google, Microsoft, or Timbal OAuth.
8. Accept the OAuth permissions when prompted and continue.
9. Return to Cursor — Timbal should now show a green indicator, and its tools will be listed.
Make sure to log in with your active Timbal account — the account registered on the [Timbal Platform](https://app.timbal.ai). Using a different account may prevent access to Timbal tools.
### Option 2: Connect using a Timbal Api Key
5. If you added a `"Authorization"` with `"TIMBAL_API_KEY"` in `mcp.json`, Cursor will automatically use it — no browser login is needed.
6. Check the **Installed MCPs** list — Timbal should appear in green, with its tools ready to use.
> ## Step 4: Start using Timbal in Cursor
1. Open the **Toggle Agents Sidebar** ( on top-right).
2. Click **New Agent** and start a chat.
3. You can now use **Timbal tools** in your conversation. Cursor will automatically leverage the MCP integration.
The first time you try to use a **Timbal tool** in Cursor, you’ll need to **accept the button in the chat** asking if you want to run Timbal MCP.
# Overview
Source: https://docs.timbal.ai/mcp-integration/index
Learn how to integrate Timbal with MCP on supported servers.
## What is MCP?
[Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) is an open standard that enables communication between AI models and external tools and services.
By exposing **Timbal as an MCP server**, AI-powered editors and applications can interact with Timbal’s functionality directly.
Looking for the other direction — connecting MCP servers **to your Timbal agents** as tool sources? See [MCP Servers](/agents/mcp).
## Connecting Timbal via MCP
To use Timbal with MCP, you first need to configure your editor or client to connect to Timbal’s MCP endpoint:
```
https://api.timbal.ai/mcp
```
Each editor or client handles MCP connections differently. The following sections inside **Connecting Timbal** provide detailed, step-by-step instructions for connecting Timbal to specific editors and tools.
Once the connection is configured, authentication is required to access Timbal’s features. An **active Timbal account** on the [Timbal Platform](https://app.timbal.ai) is needed.
**Authentication** can be performed using one of the following methods:
* **OAuth** (Google, Microsoft, GitHub, or Timbal OAuth)
* **API Key**
See the [Authentication documentation](https://docs.timbal.ai/api-reference/authentication#authentication) for instructions on generating an API Key.
# VS Code
Source: https://docs.timbal.ai/mcp-integration/vscode
Learn how to connect Timbal to VS Code via MCP and use its functionality inside the editor.
> ## Step 1: Open the VS Code App
1. Launch the **VS Code** app on your system.
2. Log in with your account if required.
> ## Step 2: Access MCP settings
1. Go to **Manage** button () at the bottom-left corner of the VS Code window.
2. Select **Profiles**.
3. In the Profiles window, go to Contents → **MCP Servers**.
4. Open the `mcp.json` configuration file.
> ## Step 3: Add Timbal as a new MCP server
1. Add the Timbal MCP URL (`https://api.timbal.ai/mcp`). For example, you can set the server name `"timbal-mcp"`:
```json title="mcp.json" theme={"dark"}
{
"servers": {
// After saving, the Start button and configuration will appear here
"timbal-mcp": {
"url": "https://api.timbal.ai/mcp",
"type": "http",
// Use this to authenticate with a Timbal API Key instead of OAuth
// "headers": {
// "Authorization": "Bearer "
// }
}
}
}
```
2. Save the file.
3. The **Start** button appears above the server entry (in this example, "timbal-mcp"). **Click** it to start the server.
### Option 1: Connect via OAuth
4. A browser window will open for authentication.
If the browser does not open automatically, you can start the connection manually from the MCP Servers panel.
5. Log in using your preferred OAuth method (GitHub, Google, Microsoft, or Timbal OAuth).
6. Accept the requested permissions.
7. Return to VS Code. In the `mcp.json` file, the status should change from **Start** to **Running**.
Make sure you log in with your active Timbal account (the one registered on the Timbal platform). Using a different account may prevent access to Timbal tools.
### Option 2: Connect using a Timbal API Key
4. If you add the `"Authorization"` header with your `"TIMBAL_API_KEY"` in `mcp.json`, VS Code will authenticate automatically.
5. No browser login is required.
6. In the `mcp.json` file, the status should display **Running** instead of **Start**.
> ## Step 4: Verify the MCP server is running
1. Open the left sidebar.
2. Go to **Extensions**.
3. At the bottom, under **MCP Servers – Installed**, you should see **Timbal MCP** listed.
4. Click the **Manage** button () to view and verify the configuration details.
> ## Step 5: Test the integration
1. Open a new chat.
2. You can now use **Timbal tools** in your conversation. VS Code will automatically leverage the MCP integration.
# Windsurf
Source: https://docs.timbal.ai/mcp-integration/windsurf
Step-by-step instructions to integrate Timbal with Windsurf through MCP.
> ## Step 1: Open the Windsurf interface
1. Launch the Windsurf app on your system.
2. Log in with your account if required.
> ## Step 2: Access MCP settings
1. In the top menu, click **Settings** ( on the top-right) -> **Windsurf Setting**s.
2. Navigate to the **Cascade** section → **MCP Servers**.
3. Click the **Open MCP Marketplace** link.
> ## Step 3: Add Timbal as a new MCP server
1. In the **Installed MCPs** list, click the **Settings** button ( positioned to the right of the title).
2. The file `mcp_config.json` will open.
3. Add the Timbal MCP URL (`https://api.timbal.ai/mcp`). For example, you can set the server name `"timbal-mcp"`:
```json title=mcp_config.json theme={"dark"}
{
"mcpServers": {
"timbal-mcp": {
"serverUrl": "https://api.timbal.ai/mcp",
// Use this to authenticate with a Timbal API Key instead of OAuth
// "headers": {
// "Authorization": ""
// }
}
}
}
```
4. Save the configuration.
### Option 1: Connect via OAuth
5. A browser window will open for authentication.
If the pop-up does not appear, you can manually click the **Connect** button next to Timbal MCP in the **MCP Marketplace**.
6. Log in using your preferred OAuth method: GitHub, Google, Microsoft, or Timbal OAuth.
Make sure to log in with your active Timbal account — the account registered on the [Timbal Platform](https://app.timbal.ai). Using a different account may prevent access to Timbal tools.
7. Accept the OAuth permissions.
8. Return to Windsurf — Timbal should now appear in green, with available tools listed inside.
### Option 2: Connect using a Timbal Api Key
5. If you added a `"Authorization"` with `"TIMBAL_API_KEY"` in `mcp_config.json`, Windsurf will automatically use it — no browser login is needed.
6. Check the **Installed MCPs** list — Timbal should appear in green, with its tools ready to use.
> ## Step 4: Start using Timbal in Windsurf
1. Open the **Toggle Cascade Side Bar** ( on top-right).
2. Start a chat.
3. You can now use **Timbal tools** in your conversation. Windsurf will leverage the MCP integration automatically.
# Zed
Source: https://docs.timbal.ai/mcp-integration/zed
Connect Timbal to Zed via MCP
> ## Step 1: Open the Zed interface
1. Launch the Zed app on your system.
2. Log in with your account if required.
> ## Step 2: Access MCP settings
1. Open the **Agent panel** ().
2. In the **Toggle Agent Menu** (the three points ...), click **Settings**.
3. In section Model Context Protocol (MCP) Servers click `+ Add Server`.
4. Select **Add Custom Server**.
> ## Step 3: Add Timbal as a new MCP server
1. A popup titled **Add MCP Server** will appear.
2. In the bottom-left corner, click **Configure Remote**.
3. Add the Timbal MCP URL (`https://api.timbal.ai/mcp`). For example, you can name the server `"timbal-mcp"`:
```json title="Add MCP Server" theme={"dark"}
{
"timbal-mcp": { // The name of Timbal MCP server
"url": "https://api.timbal.ai/mcp",
"headers": {
"Authorization": "Bearer "
}
}
}
```
Zed currently supports authentication using API Keys only. OAuth authentication is not supported for MCP connections in Zed.
4. Click Add Server to save the configuration.
> ## Step 4: Verify the connection
1. You can see in the right the Settings the MCP Servers the tools available in green.
2. Click the gear icon next to the server to view the list of available tools.
> ## Step 5: Start using Timbal in Zed
1. Open the **Agent panel**.
2. Start a new chat.
3. You can now use **Timbal tools** in your conversation. Zed will automatically leverage the MCP integration.
You will be prompted in the chat to confirm before running a Timbal MCP tool. Make sure to accept the request to allow the tool to execute.
# Anthropic
Source: https://docs.timbal.ai/models/anthropic
Claude Fable 5, Opus 5, Sonnet 5, and Haiku models with specs, pricing, and capabilities
Source: [Anthropic model docs](https://docs.anthropic.com/en/docs/about-claude/models) · [What's new in Opus 5](https://platform.claude.com/docs/en/about-claude/models/whats-new-opus-5) · [Pricing](https://docs.anthropic.com/en/about-claude/pricing)
## Latest
Reasoning · Speed
`anthropic/claude-fable-5`
Anthropic's most capable widely released model for long-horizon agentic work, complex reasoning, and ambitious multi-day coding tasks.
* \$10 / \$50 per 1M tokens (input / output)
* 1M context
* 128K max output
* Text, Image input
* Adaptive thinking (always on)
* Web search
* Knowledge cutoff Jan 2026
Reasoning · Speed
`anthropic/claude-opus-5`
Latest Opus and the recommended default for complex agentic coding and enterprise work — near-Fable-5 intelligence at half the price, with large gains in deep reasoning, long-horizon tool loops, and test-time compute scaling.
* \$5 / \$25 per 1M tokens (input / output; Fast mode \$10 / \$50 at \~2.5x speed)
* 1M context (default and maximum)
* 128K max output
* Text, Image input
* Adaptive thinking (on by default)
* Web search
* Knowledge cutoff May 2026
Reasoning · Speed
`anthropic/claude-sonnet-5`
Current-generation Sonnet — near-Opus intelligence at Sonnet pricing for coding, agents, and everyday professional work. Drop-in replacement for Sonnet 4.6.
* \$3 / \$15 per 1M tokens (input / output; intro \$2 / \$10 through Aug 31, 2026)
* 1M context
* 128K max output
* Text, Image input
* Adaptive thinking
* Web search
* Knowledge cutoff Jan 2026
Reasoning · Speed
`anthropic/claude-opus-4-8`
Previous Opus generation for agentic coding, long-running tasks, and complex reasoning. Same price as Opus 5 — migrate unless you depend on thinking being off by default.
* \$5 / \$25 per 1M tokens (input / output)
* 1M context
* 128K max output
* Text, Image input
* Adaptive thinking
* Web search
* Knowledge cutoff Jan 2026
Reasoning · Speed
`anthropic/claude-opus-4-7`
Frontier Opus for agentic coding and complex reasoning: stronger software engineering, verification, and high-resolution vision than 4.6, with adaptive thinking and a 1M-token context window at standard per-token rates.
* \$5 / \$25 per 1M tokens (input / output)
* 1M context
* 128K max output
* Text, Image input
* Adaptive thinking
* Web search
* Knowledge cutoff Jan 2026
Reasoning · Speed
`anthropic/claude-opus-4-6`
Anthropic's most intelligent model for building agents and coding. Exceptional at planning, code review, debugging, and operating reliably within large codebases.
* \$5 / \$25
* 1M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
* Knowledge cutoff May 2025
Reasoning · Speed
`anthropic/claude-haiku-4-5`
The fastest Claude model with near-frontier intelligence for office files, strategy planning, and business analysis.
* \$1 / \$5
* 200K context
* 64K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Feb 2025
***
## Legacy
Reasoning · Speed
`anthropic/claude-opus-4-5`
Previous flagship Opus model with top-tier reasoning and coding capabilities.
* \$5 / \$25
* 200K context
* 64K max output
* Text, Image input
* Extended thinking
* Knowledge cutoff May 2025
Reasoning · Speed
`anthropic/claude-opus-4-1`
Earlier Opus variant with strong reasoning, higher pricing tier.
* \$15 / \$75
* 200K context
* 32K max output
* Text, Image input
* Extended thinking
* Knowledge cutoff Jan 2025
* Deprecated — retires August 5, 2026
Reasoning · Speed
`anthropic/claude-sonnet-4-6`
Previous Sonnet generation with strong speed/intelligence balance and computer use skills. Superseded by Sonnet 5.
* \$3 / \$15
* 1M context
* 64K max output
* Text, Image input
* Thinking
* Knowledge cutoff Aug 2025
Reasoning · Speed
`anthropic/claude-sonnet-4-5`
Previous Sonnet generation with strong all-around performance and 1M beta context support.
* \$3 / \$15
* 200K context (1M beta)
* 64K max output
* Text, Image input
* Thinking
* Knowledge cutoff Jan 2025
# BytePlus
Source: https://docs.timbal.ai/models/byteplus
Seed 2.0 and Seed 1.8 models with specs, pricing, and capabilities
Source: [BytePlus ModelArk docs](https://docs.byteplus.com/en/docs/ModelArk). All models support tool/function calling. Models with an activation warning must be enabled in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before use.
## Seed 2.0
Reasoning · Speed
`byteplus/seed-2-0-pro-260328`
BytePlus's flagship Seed 2.0 model. Excels at complex reasoning, coding, and agentic tasks with deep thinking capability and multimodal understanding across text, images, and video.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.47 / \~\$2.37
* 256K context
* Text, Image, Video input
* Deep thinking
Reasoning · Speed
`byteplus/seed-2-0-lite-260228`
Cost-efficient Seed 2.0 variant optimised for high-concurrency and latency-sensitive workloads. Supports multimodal input and tool calling at a fraction of the Pro cost.
* \$0.25 / \$1
* 256K context
* Text, Image, Video input
Reasoning · Speed
`byteplus/seed-2-0-mini-260215`
Lightweight Seed 2.0 model designed for edge deployments and cost-sensitive scenarios requiring fast responses with minimal resource usage.
* \~\$0.10 / \~\$0.40
* 256K context
* Text input
***
## Seed 1.x
Reasoning · Speed
`byteplus/seed-1-8-251228`
Advanced multimodal model with deep thinking and strong agent capabilities. Supports text, image, video, and document understanding with structured output (beta) and context caching.
* \$0.25 / \$2
* 256K context
* 64K max output
* Text, Image, Video input
* Deep thinking
Reasoning · Speed
`byteplus/seed-1-6-250915`
Stable text generation model from the Seed 1.x series, suited for general-purpose NLP tasks, summarisation, and instruction following.
* \~\$0.25 / \~\$2
* 262K context
* Text input
***
## Third-party (Ark)
Reasoning · Speed
`byteplus/kimi-k2-250905`
Moonshot Kimi K2 agentic model on BytePlus ModelArk for tool calling and autonomous tasks.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.60 / \~\$2.50
* 256K context
* Text input
Reasoning · Speed
`byteplus/kimi-k2-thinking-251104`
Moonshot Kimi K2 thinking variant on BytePlus ModelArk with chain-of-thought reasoning and tool use.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.60 / \~\$2.50
* 256K context
* Text input
* Thinking
Reasoning · Speed
`byteplus/deepseek-v4-pro-260425`
DeepSeek V4 Pro on BytePlus ModelArk: frontier open MoE for coding, reasoning, and long-context agent workloads.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.47 / \~\$2.37
* 256K context
* Text input
Reasoning · Speed
`byteplus/deepseek-v4-flash-260425`
Cost-efficient DeepSeek V4 Flash on BytePlus ModelArk for fast reasoning and agent workloads.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.25 / \~\$1
* 256K context
* Text input
Reasoning · Speed
`byteplus/deepseek-v3-2-251201`
DeepSeek V3.2 on BytePlus ModelArk with improved reasoning and tool use over V3.1.
* \~\$0.25 / \~\$1
* 256K context
* Text input
Reasoning · Speed
`byteplus/deepseek-r1-250528`
DeepSeek R1 reasoning model on BytePlus ModelArk (May 2025 checkpoint).
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.50 / \~\$2
* 256K context
* Text input
* Thinking
Reasoning · Speed
`byteplus/gpt-oss-120b-250805`
OpenAI GPT OSS 120B on BytePlus ModelArk. Apache 2.0.
* \~\$0.15 / \~\$0.60
* 128K context
* Text input
Reasoning · Speed
`byteplus/glm-4-7-251222`
Zhipu GLM-4.7 on BytePlus ModelArk for coding, reasoning, and agentic tasks.
* \~\$0.25 / \~\$1
* 128K context
* Text input
Reasoning · Speed
`byteplus/seed-2-0-code-preview-260328`
Seed 2.0 code-specialized preview on BytePlus ModelArk for agentic coding workloads.
Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use.
* \~\$0.25 / \~\$1
* 256K context
* Text input
# Cerebras
Source: https://docs.timbal.ai/models/cerebras
Wafer-scale inference at world-record token speeds with specs, pricing, and capabilities
Source: [Cerebras model docs](https://inference-docs.cerebras.ai/introduction). All model IDs use the prefix `cerebras/`. Powered by Cerebras wafer-scale chips — the world's largest AI accelerator — delivering up to 3000+ tokens/second.
## All Models
Reasoning · Speed
`cerebras/gpt-oss-120b`
OpenAI's open-weight MoE model with 120B total parameters (5.1B active per token), running at up to 3000 tokens/s on Cerebras wafer-scale hardware. Near-parity with o4-mini on reasoning benchmarks. Supports extended thinking. Apache 2.0.
* \$0.35 / \$0.75
* 128K context
* Text input
* Thinking
* Knowledge cutoff Jun 2024
Reasoning · Speed
`cerebras/zai-glm-4.7`
ZAI GLM 4.7 with 355B parameters, running at \~1000 tokens/s on Cerebras hardware. Strong multilingual reasoning and instruction-following capabilities.
* \$2.25 / \$2.75
* 128K context
* Text input
* Knowledge cutoff \~early 2025
# Fireworks
Source: https://docs.timbal.ai/models/fireworks
Open-source models via Fireworks serverless inference with specs, pricing, and capabilities
Source: [Fireworks model docs](https://fireworks.ai/models). All model IDs use the prefix `fireworks/accounts/fireworks/models/`. Only serverless models are listed here — other Fireworks models require a dedicated deployment.
## All Models
Reasoning · Speed
`fireworks/accounts/fireworks/models/deepseek-v4-pro`
DeepSeek V4 Pro on Fireworks serverless: frontier open MoE for coding, reasoning, and up to \~1M token context with function calling.
* \$1.74 / \$3.48
* 1M context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/deepseek-v4-flash`
DeepSeek V4 Flash on Fireworks serverless: fast, cost-efficient MoE with near-Pro reasoning at 1M context.
* \$0.14 / \$0.28
* 1M context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/qwen3p7-plus`
Alibaba Qwen 3.7 Plus on Fireworks serverless: multimodal flagship with strong agentic and coding benchmarks.
* \$0.40 / \$1.60
* 262K context
* Text, Image input
Reasoning · Speed
`fireworks/accounts/fireworks/models/qwen3p6-plus`
Qwen 3.6 multimodal plus tier on Fireworks for vision-language and general agent tasks.
* \$0.50 / \$3.00
* 262K context
* Text, Image input
* Serverless deprecated — prefer qwen3p7-plus
Reasoning · Speed
`fireworks/accounts/fireworks/models/kimi-k2p6`
Moonshot Kimi K2.6 on Fireworks: multimodal MoE for high-quality tool use and long-context workloads.
* \$0.95 / \$4.00
* 262K context
* Text, Image input
Reasoning · Speed
`fireworks/accounts/fireworks/models/kimi-k2p7-code`
Moonshot Kimi K2.7 Code on Fireworks serverless for long-context coding agents with thinking mode.
* \$0.95 / \$4.00
* 262K context
* Text, Image input
* Thinking
Reasoning · Speed
`fireworks/accounts/fireworks/models/kimi-k2p5`
Moonshot Kimi K2.5 on Fireworks serverless: multimodal 1T-parameter MoE with strong agentic tool use.
* \$0.60 / \$3.00
* 256K context
* Text, Image input
* Serverless deprecated — prefer kimi-k2p6
Reasoning · Speed
`fireworks/accounts/fireworks/models/glm-5p1`
Z.ai GLM-5.1 on Fireworks serverless: post-training upgrade with stronger coding, reasoning, and agentic tool use.
* \$1.40 / \$4.40
* 203K context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/glm-5p2`
Z.ai GLM-5.2 on Fireworks serverless: 1M-token context, strongest open coding model with prompt caching (cached input \$0.14 / 1M).
* \$1.40 / \$4.40
* 1M context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/minimax-m2p5`
MiniMax M2.5 MoE (230B total, 10B active) with SOTA coding and agentic tool use on Fireworks serverless.
* \$0.30 / \$1.20
* 200K context
* Text input
* Serverless deprecated — prefer minimax-m2p7 or minimax-m3
Reasoning · Speed
`fireworks/accounts/fireworks/models/minimax-m2p7`
MiniMax M2.7 MoE on Fireworks serverless: improved agent harnesses, complex skills, and dynamic tool search.
* \$0.30 / \$1.20
* 196K context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/minimax-m3`
MiniMax M3 multimodal MoE on Fireworks serverless for chat, agents, and long-context workloads.
* \$0.30 / \$1.20
* 512K context
* Text, Image input
Reasoning · Speed
`fireworks/accounts/fireworks/models/gpt-oss-120b`
OpenAI's open-weight 120B MoE achieving near-parity with o4-mini on reasoning benchmarks. Apache 2.0.
* \$0.15 / \$0.60
* 128K context
* Text input
Reasoning · Speed
`fireworks/accounts/fireworks/models/gpt-oss-20b`
OpenAI's compact 20B MoE similar to o3-mini, running on edge devices with 16GB memory. Apache 2.0.
* \$0.07 / \$0.30
* 128K context
* Text input
# Google
Source: https://docs.timbal.ai/models/google
Gemini 3.6, 3.5, and 2.5 series models with specs, pricing, and capabilities
Source: [Google AI model docs](https://ai.google.dev/gemini-api/docs/models)
## Gemini 3.6
Reasoning · Speed
`google/gemini-3.6-flash`
Workhorse Flash model with better token efficiency, code generation, and multimodal reasoning for multi-step orchestration and agentic workflows.
* \$1.50 / \$7.50
* 1M context
* 64K max output
* Text, Image, Video, Audio, PDF input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
***
## Gemini 3.5
Reasoning · Speed
`google/gemini-3.5-flash`
Sustained frontier-level intelligence for agentic workflows, sub-agent deployment, and long-horizon tasks at scale — optimized for speed and cost.
* \$1.50 / \$9
* 1M context
* 64K max output
* Text, Image, Video, Audio, PDF input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-3.5-flash-lite`
Fastest, most cost-effective 3.5-class model for high-throughput agentic search, document processing, and lightweight subagent workflows.
* \$0.30 / \$2.50
* 1M context
* 64K max output
* Text, Image, Video, Audio, PDF input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
***
## Gemini 3
Reasoning · Speed
`google/gemini-3.1-pro-preview`
The upgraded core intelligence model representing a step forward in reasoning, a smarter and more capable baseline for complex problem-solving across consumer and developer products.
* \$2 / \$12
* 1M context
* 64K max output
* Text, Image, Video, Audio, PDF input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-3.1-flash-lite`
The fastest and most cost-efficient Gemini 3 model, built for high-volume developer workloads at scale with 2.5x faster time to first token versus 2.5 Flash.
* \$0.25 / \$1.50
* 1M context
* 64K max output
* Text, Image, Video, Audio input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-3-flash-preview`
Combines Gemini 3 Pro's reasoning capabilities with the Flash line's latency, efficiency, and cost, designed for the most complex agentic workflows.
* \$0.50 / \$3
* 1M context
* 64K max output
* Text, Image, Video, Audio input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
***
## Gemini 2.5
\$2.50 / \$15 above 200K input tokens. All Gemini models support Google Search grounding.
Reasoning · Speed
`google/gemini-2.5-pro`
The most advanced reasoning Gemini model, capable of solving complex problems across text, audio, images, video, and code repositories.
* \$1.25 / \$10
* 1M context
* 65K max output
* Text, Image, Video, Audio, PDF input
* Extended thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-2.5-pro-preview-tts`
Text-to-speech variant of Gemini 2.5 Pro for generating audio output from text input.
* \$1 / \$20
* 1M context
* 66K max output
* Text input
* Audio output
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-2.5-flash`
The best model in terms of price and performance with well-rounded capabilities, the first Flash model featuring thinking capabilities.
* \$0.30 / \$2.50
* 1M context
* 65K max output
* Text, Image, Video, Audio input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-2.5-flash-lite`
The fastest and most budget-friendly multimodal reasoning model in the 2.5 family, ideal for classification, translation, and high-scale operations.
* \$0.10 / \$0.40
* 1M context
* 65K max output
* Text, Image, Video, Audio input
* Thinking
* Web search
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-2.5-flash-image`
Specialized model for image generation and editing from text and image prompts. \~\$0.039 per image output.
* \$0.30 / \$30
* 32K context
* 32K max output
* Text, Image input
* Text, Image output
* Knowledge cutoff Jan 2025
Reasoning · Speed
`google/gemini-2.5-flash-preview-tts`
Text-to-speech variant of Gemini 2.5 Flash for generating audio output at lower cost.
* \$0.50 / \$10
* 1M context
* 66K max output
* Text input
* Audio output
* Knowledge cutoff Jan 2025
# Groq
Source: https://docs.timbal.ai/models/groq
Ultra-low latency inference via Groq LPU with specs, pricing, and capabilities
Source: [Groq model docs](https://console.groq.com/docs/models). All model IDs use the prefix `groq/`. Ultra-low latency inference via custom LPU hardware.
## All Models
Reasoning · Speed
`groq/qwen/qwen3.6-27b`
Qwen3.6 27B with hybrid thinking mode, delivered at Groq's ultra-low latency. Preview tier replacing retired Qwen3 32B / Llama 4 Scout.
* \$0.60 / \$3.00
* 131K context
* Text input
* Hybrid thinking
Reasoning · Speed
`groq/openai/gpt-oss-120b`
OpenAI's open-weight MoE model with 120B total parameters (5.1B active per token), running at Groq speeds. Near-parity with o4-mini on reasoning benchmarks. Apache 2.0.
* \$0.15 / \$0.60
* 128K context
* Text input
* Thinking
Reasoning · Speed
`groq/openai/gpt-oss-20b`
OpenAI's compact open-weight MoE model with 20B total parameters (3.6B active), delivering results similar to o3-mini at Groq's ultra-low latency. Apache 2.0.
* \$0.075 / \$0.30
* 128K context
* Text input
* Thinking
Reasoning · Speed
`groq/llama-3.3-70b-versatile`
Multilingual instruction-tuned model with 70B parameters, optimized for versatile tasks with Groq's ultra-low latency inference.
* \$0.59 / \$0.79
* 131K context
* Text input
* Deprecated — shutdown August 16, 2026
Reasoning · Speed
`groq/llama-3.1-8b-instant`
The most compact Llama 3.1 model with 8B parameters, optimized for instant responses on Groq's LPU hardware.
* \$0.05 / \$0.08
* 131K context
* Text input
* Deprecated — shutdown August 16, 2026
# Moonshot (Kimi)
Source: https://docs.timbal.ai/models/moonshot
Kimi K3 and K2.x models via Moonshot's OpenAI-compatible API
Source: [Kimi API Platform](https://platform.kimi.ai). All model IDs use the prefix `moonshot/`. Base URL: `https://api.moonshot.ai/v1`.
## Flagship
Reasoning · Speed
`moonshot/kimi-k3`
Moonshot's 2.8T-parameter flagship MoE with a 1M-token context window, native vision/video, and always-on reasoning for long-horizon coding and knowledge work. Pass `reasoning_effort` via `model_params` (currently `"max"` only). Do not set `temperature` — the API fixes it at `1.0`.
* \$3 / \$15 (cache-hit input \$0.30)
* 1M context
* Text, Image, Video input
* Thinking (`reasoning_content` / `reasoning_effort`)
## Coding
Reasoning · Speed
`moonshot/kimi-k2.7-code`
Coding-focused multimodal model with thinking mode for long-context programming agents.
* \$0.95 / \$4 (cache-hit input \$0.19)
* 256K context
* Text, Image, Video input
* Thinking
Reasoning · Speed
`moonshot/kimi-k2.7-code-highspeed`
Same K2.7 Code weights with higher output throughput (\~180 tok/s) for interactive coding.
* \$1.90 / \$8 (cache-hit input \$0.38)
* 256K context
* Text, Image, Video input
* Thinking
## General
Reasoning · Speed
`moonshot/kimi-k2.6`
General-purpose multimodal MoE with thinking and non-thinking modes for chat, agents, and vision.
* \$0.95 / \$4 (cache-hit input \$0.16)
* 256K context
* Text, Image, Video input
* Thinking
Reasoning · Speed
`moonshot/kimi-k2.5`
Previous multimodal MoE generation. Prefer K2.6 or K3 for new workloads. Soft sunset for new users; full sunset August 31, 2026.
* \$0.60 / \$3
* 256K context
* Text, Image input
* Thinking
## Usage
```python theme={"dark"}
from timbal import Agent
agent = Agent(
name="kimi",
model="moonshot/kimi-k3",
model_params={"reasoning_effort": "max"},
tools=[],
)
result = await agent.collect(prompt="Explain Kimi K3 in one sentence.")
print(result.output.collect_text())
```
# OpenAI
Source: https://docs.timbal.ai/models/openai
GPT-5, GPT-4, and o-series models with specs, pricing, and capabilities
Source: [OpenAI model docs](https://platform.openai.com/docs/models) · [API pricing](https://openai.com/api/pricing/) (cached input \$0.50 / 1M for GPT-5.5)
## GPT-5 Series
Reasoning · Speed
`openai/gpt-5.5`
OpenAI's latest flagship for coding, knowledge work, and research—positioned above GPT-5.4 with a 1.05M-token API context (per OpenAI model table) and higher per-token pricing.
* \$5 / \$30 per 1M tokens (input / output); cached input \$0.50 / 1M
* 1.05M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
Reasoning · Speed
`openai/gpt-5.5-pro`
GPT-5.5 Pro tier for the hardest coding, agentic, and research workloads via the Responses API.
* \$30 / \$180 per 1M tokens (input / output)
* 1.05M context
* Text, Image input
* Extended thinking
Reasoning · Speed
`openai/gpt-5.6-sol`
GPT-5.6 flagship tier for the hardest coding, agentic, and long-horizon tasks. Supports programmatic tool calling and multi-agent flows in the Responses API.
* \$5 / \$30 per 1M tokens (input / output); cached input \$0.50 / 1M
* 1.05M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
Reasoning · Speed
`openai/gpt-5.6-terra`
GPT-5.6 balanced tier for everyday production work—competitive with GPT-5.5 at roughly half the cost of Sol.
* \$2.50 / \$15 per 1M tokens (input / output); cached input \$0.25 / 1M
* 1.05M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
Reasoning · Speed
`openai/gpt-5.6-luna`
GPT-5.6 fast, cost-efficient tier for high-volume, latency-sensitive, and budget-conscious workloads.
* \$1 / \$6 per 1M tokens (input / output); cached input \$0.10 / 1M
* 1.05M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
Reasoning · Speed
`openai/gpt-5.4`
OpenAI's most capable and efficient frontier model for professional work, combining industry-leading coding, reasoning, and agentic workflows with native computer-use capabilities.
* \$2.50 / \$15
* 1.05M context
* 128K max output
* Text, Image input
* Extended thinking
* Web search
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.4-pro`
GPT-5.4 Pro tier for maximum capability coding and agent workloads via the Responses API.
* \$30 / \$180 per 1M tokens (input / output)
* 1.05M context
* Text, Image input
* Extended thinking
Reasoning · Speed
`openai/gpt-5.4-mini`
OpenAI's most capable small model, significantly improving over GPT-5 mini across coding, reasoning, multimodal understanding, and tool use while running more than 2x faster.
* \$0.75 / \$4.50
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.4-nano`
The smallest, cheapest version of GPT-5.4 for tasks where speed and cost matter most. Ideal for classification, data extraction, ranking, and coding subagents.
* \$0.20 / \$1.25
* 400K context
* 128K max output
* Text, Image input
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.3-chat-latest`
Tuned for safe, useful answers more directly, designed for everyday usability across drafting, brainstorming, summarizing, and general writing tasks.
* \~\$1.75 / \~\$14
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.2`
The most capable model series for professional knowledge work, with significant improvements in general intelligence, long-context understanding, agentic tool-calling, and vision.
* \$1.75 / \$14
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.2-pro`
A version of GPT-5.2 that uses more compute to think harder and provide consistently better answers for complex tasks.
* \$21 / \$168
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Aug 2025
Reasoning · Speed
`openai/gpt-5.1`
Improves meaningfully on both intelligence and communication style; the first model to use adaptive reasoning to decide when to think before responding.
* \$1.25 / \$10
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Sep 2024
Reasoning · Speed
`openai/gpt-5.1-codex`
A faster, more intelligent agentic coding model designed for long-running, project-scale work with enhanced reasoning and token efficiency.
* \$1.25 / \$10
* 400K context
* 128K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Sep 2024
Reasoning · Speed
`openai/gpt-5`
OpenAI's unified AI system representing a significant leap in intelligence, with a smart efficient model for most questions and a deeper reasoning model for harder problems.
* \$1.25 / \$10
* 400K context
* 128K max output
* Text, Image, Audio, Video input
* Thinking
* Web search
* Knowledge cutoff Sep 2024
Reasoning · Speed
`openai/gpt-5-mini`
A smaller, faster variant of GPT-5's thinking model optimized for developer use, balancing strong reasoning with lower cost and latency.
* \$0.25 / \$2
* 400K context
* 128K max output
* Text, Image input
* Web search
* Knowledge cutoff May 2024
Reasoning · Speed
`openai/gpt-5-nano`
The smallest and fastest variant in the GPT-5 family, made for developers needing maximum speed at minimal cost.
* \$0.05 / \$0.40
* 400K context
* 128K max output
* Text, Image input
* Knowledge cutoff May 2024
***
## GPT-4 Series
Reasoning · Speed
`openai/gpt-4.1`
Excels at instruction following and tool calling with broad knowledge, featuring a 1M token context window and low latency without a reasoning step.
* \$2 / \$8
* 1.05M context
* 32K max output
* Text, Image input
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/gpt-4.1-mini`
A significant leap in small model performance that matches or exceeds GPT-4o in intelligence evals while reducing latency by nearly half and cost by 83%.
* \$0.40 / \$1.60
* 1M context
* 32K max output
* Text, Image input
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/gpt-4.1-nano`
OpenAI's fastest and cheapest model with a 1M token context window, ideal for classification and autocompletion tasks.
* \$0.10 / \$0.40
* 1M context
* 32K max output
* Text, Image input
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/gpt-4o`
An autoregressive omni model that accepts any combination of text, audio, image, and video inputs, trained end-to-end across modalities.
* \$2.50 / \$10
* 128K context
* 16K max output
* Text, Image, Audio input
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/gpt-4o-mini`
A fast, affordable small model for focused tasks that accepts text and image inputs at a fraction of the cost of frontier models.
* \$0.15 / \$0.60
* 128K context
* 16K max output
* Text, Image input
* Knowledge cutoff Oct 2023
***
## o-Series (Reasoning)
Reasoning · Speed
`openai/o4-mini`
A smaller reasoning model optimized for fast, cost-efficient reasoning with exceptional performance in math, coding, and visual tasks.
* \$1.10 / \$4.40
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/o4-mini-deep-research`
Deep research variant of o4-mini designed for extended multi-step research tasks with mandatory search.
* \$2 / \$8
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Web search (required)
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/o3`
OpenAI's most powerful reasoning model that pushes the frontier across coding, math, science, and visual perception, ideal for complex queries requiring multi-faceted analysis.
* \$2 / \$8
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/o3-mini`
The most cost-efficient model in OpenAI's reasoning series, delivering o1-level STEM performance with lower cost and faster speed.
* \$1.10 / \$4.40
* 200K context
* 100K max output
* Text input
* Thinking
* Knowledge cutoff Oct 2023
Reasoning · Speed
`openai/o3-pro`
A version of o3 designed to think longer and provide the most reliable, thoroughly reasoned responses for maximum performance on complex tasks.
* \$20 / \$80
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Web search
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/o3-deep-research`
Deep research variant of o3 for extended multi-step research tasks with mandatory search and maximum reasoning depth.
* \$10 / \$40
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Web search (required)
* Knowledge cutoff Jun 2024
Reasoning · Speed
`openai/o1`
OpenAI's first reasoning model, trained to think before answering by producing a long internal chain of thought, with strong performance in science, coding, and math.
* \$15 / \$60
* 200K context
* 100K max output
* Text, Image input
* Thinking
* Knowledge cutoff Oct 2023
Reasoning tokens are billed as output tokens on all o-series models.
# Overview
Source: https://docs.timbal.ai/models/overview
Specs, pricing, and capabilities for every supported model
Every model supported by Timbal with full specs, pricing, capability scores, and short descriptions. **Price** = per 1M tokens (input / output). All models support tool/function calling unless noted.
Claude Fable 5, Opus 5, Sonnet 5, and Haiku
Seed 2.0 and Seed 1.8 models
Wafer-scale inference at world-record token speeds
Open-source models via Fireworks
Gemini 3.6, 3.5, 3.1 and 2.5 series
Ultra-low latency via Groq LPU
Kimi K3 / K2.x via Moonshot's OpenAI-compatible API
GPT-5, GPT-4, and o-series reasoning models
High-throughput inference on custom RDU hardware
Open-source models via TogetherAI
Grok 4 and Grok 4 Fast
MiMo V2 Pro, Omni, and Flash
## Scoring
Each model is rated on two axes using a **1-5 scale**:
* **Reasoning** — depth of analytical and chain-of-thought capability
* **Speed** — relative latency and throughput for its class
Scores are **relative within the full set of models on this page**, not within a single provider. A reasoning score of 5 means frontier-class reasoning (e.g. o3-pro, Claude Opus 4.7, GPT-5.5, Gemini 2.5 Pro). A speed score of 5 means the fastest tier (nano/mini/flash-lite models, or Groq-hosted inference).
# SambaNova
Source: https://docs.timbal.ai/models/sambanova
High-throughput inference on custom RDU hardware with specs, pricing, and capabilities
Source: [SambaNova model docs](https://docs.sambanova.ai/docs/en/get-started/overview). All model IDs use the prefix `sambanova/`. Powered by SambaNova RDU (Reconfigurable Dataflow Unit) chips designed for large-scale AI inference.
## DeepSeek
Reasoning · Speed
`sambanova/DeepSeek-V3.2`
DeepSeek V3.2 running on SambaNova RDU hardware.
* \$3.00 / \$4.50
* 8K context
* Text input
Reasoning · Speed
`sambanova/DeepSeek-V3.1`
DeepSeek V3.1 running on SambaNova RDU hardware.
* \$3.00 / \$4.50
* 128K context
* Text input
## Meta Llama
Reasoning · Speed
`sambanova/Llama-4-Maverick-17B-128E-Instruct`
Meta Llama 4 Maverick with 17B active parameters across 128 experts. Supports image input.
* \$0.63 / \$1.80
* 128K context
* Text, Image input
Reasoning · Speed
`sambanova/Meta-Llama-3.3-70B-Instruct`
Meta Llama 3.3 70B instruction-tuned model running on SambaNova RDU hardware.
* \$0.60 / \$1.20
* 128K context
* Text input
## Google Gemma
Reasoning · Speed
`sambanova/gemma-4-31B-it`
Google Gemma 4 31B instruction-tuned model on SambaNova RDU hardware.
* \$0.22 / \$0.59
* 128K context
* Text, Image input
Reasoning · Speed
`sambanova/gemma-3-12b-it`
Google Gemma 3 12B instruction-tuned model on SambaNova RDU hardware.
* \$0.10 / \$0.20
* 128K context
* Text input
## OpenAI OSS / MiniMax
Reasoning · Speed
`sambanova/gpt-oss-120b`
OpenAI's open-weight 120B MoE on SambaNova RDU hardware. Near-parity with o4-mini on reasoning benchmarks. Apache 2.0.
* \$0.22 / \$0.59
* 128K context
* Text input
Reasoning · Speed
`sambanova/MiniMax-M2.7`
MiniMax M2.7 large-scale MoE running on SambaNova RDU hardware.
* \$0.60 / \$2.40
* 128K context
* Text input
# TogetherAI
Source: https://docs.timbal.ai/models/togetherai
Open-source models via TogetherAI inference with specs, pricing, and capabilities
Source: [TogetherAI model docs](https://docs.together.ai/docs/chat-models). All model IDs use the prefix `togetherai/`. Models with a dedicated-only warning are not serverless — you must create and start a dedicated endpoint first.
## Meta LLaMA
Reasoning · Speed
`togetherai/meta-llama/Llama-3.3-70B-Instruct-Turbo`
Multilingual instruction-tuned model with 70B parameters, delivering enhanced performance relative to Llama 3.1 70B and matching Llama 3.2 90B on text-only tasks.
* \$0.88 / \$0.88
* 128K context
* Text input
* Knowledge cutoff Dec 2023
***
## Qwen
Reasoning · Speed
`togetherai/Qwen/Qwen3.5-397B-A17B`
Multimodal foundation model with 397B total parameters (17B active) featuring a Hybrid MoE architecture with early fusion vision-language training. State-of-the-art across chat, RAG, vision-language, and agentic workflows.
* \$0.30 / \$1.20
* 262K context
* Text, Image input
* Hybrid thinking
* Knowledge cutoff \~2025
Reasoning · Speed
`togetherai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput`
MoE model with 235B total parameters (22B active) in non-thinking mode, optimized for throughput. Supports multilingual dialogue across 100+ languages.
* \$0.20 / \$0.60
* 262K context
* Text input
* Knowledge cutoff \~early 2025
Reasoning · Speed
`togetherai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8`
Qwen's most agentic code model, a 480B-parameter MoE (35B active) achieving results comparable to Claude Sonnet on agentic coding, browser-use, and repository-scale tasks.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8) before use.
* \$0.22 / \$1
* 262K context
* Text input
Reasoning · Speed
`togetherai/Qwen/Qwen3-Coder-Next-FP8`
Next-generation coding model with hybrid thinking mode for adaptive reasoning depth.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/Qwen/Qwen3-Coder-Next-FP8) before use.
* \$0.50 / \$1.20
* 256K context
* Text input
* Hybrid thinking
Reasoning · Speed
`togetherai/Qwen/Qwen3-Next-80B-A3B-Instruct`
First model in the Qwen3-Next series with 80B total parameters (3.9B active), featuring hybrid attention. Matches Qwen3-235B performance while using less than 10% training cost.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/Qwen/Qwen3-Next-80B-A3B-Instruct) before use.
* \$0.15 / \$1.50
* 262K context
* Text input
* Hybrid thinking
Reasoning · Speed
`togetherai/Qwen/Qwen2.5-7B-Instruct-Turbo`
Part of the Qwen2.5 family with 7B parameters, featuring improvements in coding, mathematics, instruction following, and structured data understanding.
* \$0.30 / \$1.20
* 128K context
* Text input
* Knowledge cutoff \~Oct 2023
***
## DeepSeek
Reasoning · Speed
`togetherai/deepseek-ai/DeepSeek-V3.1`
Hybrid model supporting both thinking and non-thinking modes. Features significantly improved tool usage and agent task performance, with quality comparable to DeepSeek-R1-0528 in thinking mode.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/deepseek-ai/DeepSeek-V3.1) before use.
* \$0.60 / \$1.70
* 128K context
* Text input
* Hybrid thinking
* Knowledge cutoff \~mid 2025
Reasoning · Speed
`togetherai/deepseek-ai/DeepSeek-V4-Pro`
DeepSeek V4 Pro on Together serverless: hybrid attention, up to 512K context, strong coding and agent benchmarks.
* \$1.74 / \$3.48
* 512K context
* Text input
* Hybrid thinking
* Knowledge cutoff \~2025
***
## Kimi / MiniMax / GLM / Other
Reasoning · Speed
`togetherai/moonshotai/Kimi-K2.6`
Moonshot Kimi K2.6 on Together serverless: 1T-scale MoE with tool calling and JSON mode for agentic and multimodal workloads.
* \$1.20 / \$4.50
* 262K context
* Text, Image input
* Knowledge cutoff \~2025
Reasoning · Speed
`togetherai/moonshotai/Kimi-K2.7-Code`
Moonshot Kimi K2.7 Code on Together for long-context programming agents with thinking mode.
* \$0.95 / \$4.00
* 262K context
* Text, Image input
* Thinking
Reasoning · Speed
`togetherai/MiniMaxAI/MiniMax-M2.7`
MiniMax successor MoE (\~229B) with improved coding and agentic tool use, JSON mode, and prompt caching on Together serverless.
* \$0.30 / \$1.20
* 203K context
* Text input
Reasoning · Speed
`togetherai/MiniMaxAI/MiniMax-M3`
MiniMax M3 multimodal model on Together for chat, agents, and long-context workloads.
* \$0.30 / \$1.20
* 512K context
* Text, Image input
Reasoning · Speed
`togetherai/zai-org/GLM-5`
Zhipu AI's fifth-generation model with \~745B parameters in a MoE architecture (44B active), designed for complex system engineering and long-range agent tasks. Trained entirely on Huawei Ascend chips.
* \$1 / \$3.20
* 200K context
* Text input
* Thinking
* Knowledge cutoff late 2025
Reasoning · Speed
`togetherai/zai-org/GLM-5.1`
Z.ai post-training upgrade to GLM-5: 754B MoE (40B active), 200K context, thinking mode, tool calling, and stronger coding via RL.
* \$1.40 / \$4.40
* 200K context
* Text input
* Thinking
* Knowledge cutoff late 2025
Reasoning · Speed
`togetherai/zai-org/GLM-5.2`
Z.ai GLM-5.2 flagship on Together for coding, reasoning, and agentic tool use.
* \$1.40 / \$4.40
* 200K context
* Text input
* Thinking
Reasoning · Speed
`togetherai/zai-org/GLM-4.7`
Zhipu AI's foundation model with \~400B parameters and 200K context, designed for real-world development environments with strong coding, reasoning, and agentic capabilities.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/zai-org/GLM-4.7) before use.
* \$0.45 / \$2
* 200K context
* Text input
* Thinking
* Knowledge cutoff \~mid 2024
Reasoning · Speed
`togetherai/openai/gpt-oss-120b`
OpenAI's open-weight MoE model with 120B total parameters (5.1B active per token). Achieves near-parity with o4-mini on core reasoning benchmarks while running on a single 80GB GPU. Apache 2.0.
* \$0.15 / \$0.60
* 128K context
* Text input
* Thinking
* Knowledge cutoff Jun 2024
Reasoning · Speed
`togetherai/openai/gpt-oss-20b`
OpenAI's compact 20B MoE delivering o3-mini-level results on Together serverless. Apache 2.0.
* \$0.05 / \$0.20
* 128K context
* Text input
* Thinking
* Knowledge cutoff Jun 2024
Reasoning · Speed
`togetherai/google/gemma-3n-E4B-it`
Google's on-device multimodal model with 8B raw parameters but an effective 4B memory footprint. First sub-10B model to exceed 1300 on LMArena, running with as little as 3GB of memory.
* \$0.02 / \$0.04
* 32K context
* Text, Image, Audio, Video input
Reasoning · Speed
`togetherai/google/gemma-3-27b-it`
Google's multimodal open model with 27B parameters, built from the same technology as Gemini 2.0. Supports 128K context, 140+ languages, and runs on a single GPU/TPU.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/google/gemma-3-27b-it) before use.
* \~\$0.10 / \~\$0.10
* 128K context
* Text, Image input
* Knowledge cutoff Aug 2024
Reasoning · Speed
`togetherai/deepcogito/cogito-v2-1-671b`
DeepCogito's MoE model with 671B total parameters (37B active), trained via a novel process supervision approach that guides reasoning chains. Competitive with frontier closed models while using fewer tokens.
* \$1.25 / \$1.25
* 128K context
* Text input
* Thinking
Reasoning · Speed
`togetherai/mistralai/Mistral-Small-24B-Instruct-2501`
A 24B-parameter dense model setting new benchmarks in the sub-70B category, with native function calling, JSON output, and support for dozens of languages. Fits on a single RTX 4090.
Dedicated only — create and start a [dedicated endpoint](https://api.together.ai/models/mistralai/Mistral-Small-24B-Instruct-2501) before use.
* \$0.10 / \$0.30
* 32K context
* Text input
* Knowledge cutoff Oct 2023
# xAI
Source: https://docs.timbal.ai/models/xai
Grok 4.5 and Grok 4.3 models with specs, pricing, and capabilities
Source: [xAI model docs](https://docs.x.ai/docs/models). Search includes Web Search and X Search, priced at \$2.50-\$5/1K calls. Cached input: \$0.05/1M.
## Grok 4.5
Reasoning · Speed
`xai/grok-4.5`
xAI's July 2026 flagship for coding, agentic tool use, and knowledge work. Configurable reasoning effort (low / medium / high).
* \$2 / \$6 (\$4 / \$12 above 200K prompt tokens)
* 500K context
* Text, Image input
* Configurable reasoning
* Web search
***
## Grok 4.3
Reasoning · Speed
`xai/grok-4.3`
Production mid-tier for coding, agents, and general workloads. Replacement for the retired Grok 4 Fast / 4.1 Fast slugs (removed May 15, 2026).
* \$1.25 / \$2.50
* 1M context
* Text, Image input
* Reasoning
* Web search
# Xiaomi MiMo
Source: https://docs.timbal.ai/models/xiaomi
MiMo V2.5 models with specs, pricing, and capabilities
Source: [Xiaomi MiMo platform](https://platform.xiaomimimo.com). All models support tool/function calling. MiMo v2 (pro/omni/flash) was fully deprecated June 30, 2026.
## MiMo V2.5
Reasoning · Speed
`xiaomi/mimo-v2.5-pro`
MiMo v2.5 Pro flagship on Xiaomi serverless for coding, agents, and extended reasoning workloads.
* \$0.435 / \$0.87
* 1M context
* Text input
Reasoning · Speed
`xiaomi/mimo-v2.5`
MiMo v2.5 base model on Xiaomi serverless: improved reasoning and agentic tool use over the retired v2 line.
* \$0.14 / \$0.28
* 262K context
* Text input
# Quickstart
Source: https://docs.timbal.ai/quickstart
Build your first AI Agent with Timbal in a few lines of code
In this quickstart guide we're going to see how to build an AI agent with Timbal. We'll start from scratch and gradually enhance it with advanced features. Let's dive in!
Before moving forward, ensure you've completed the [installation steps](/installation).
## Create a Timbal Project
Run the following Timbal CLI command to create a new project:
```bash theme={"dark"}
timbal create my-project
```
This will prompt you interactively to choose what you want to build (Agent or Workflow) and what UI you want for your project. After making your selections, it creates a project structure with all the necessary files and dependencies to get started.
### Project Structure
After running the command, your project will look like this:
```
my-project/
├── api/ - Elysia API server (Bun)
├── workforce/ - Your timbal components (Python)
│ └── /
│ ├── agent.py - Main app logic (or workflow.py for workflows)
│ ├── timbal.yaml - Timbal configuration
│ └── pyproject.toml - Python dependencies
├── ui/ - React + Vite + TypeScript + shadcn (Bun) [optional]
├── .gitignore - Git ignore file
└── README.md - Project documentation
```
| Directory/File | Purpose |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **api/** | Elysia API server running on Bun |
| **workforce/** | Your timbal components (Python). Contains subdirectories for each agent or workflow |
| **workforce/\/agent.py** | Main application file where you define your AI agents.
If you create a workflow, you'll have a `workflow.py` file instead |
| **workforce/\/pyproject.toml** | Python project configuration and dependency management |
| **workforce/\/timbal.yaml** | Timbal deployment configuration and settings |
| **ui/** | React + Vite + TypeScript + shadcn running on Bun (only if UI is selected) |
## Run locally
Run `timbal configure` before your first `timbal start` — it stores your Timbal credentials locally. For model calls, platform auth from configure is enough; otherwise set `OPENAI_API_KEY` in `/.env` (or the key for whichever provider your model uses).
From the project directory, start the full stack — API, workforce (your Python agents/workflows), and UI if you included one:
```bash theme={"dark"}
cd my-project
timbal start
```
`timbal start` prints the local URLs and keeps services running until you quit (`q`). While it's running you can press `o` to open the UI in your browser, or `h` for other commands.
If `/.env` exists, `timbal start` loads it automatically and injects those variables into every service (UI, API, and all workforce members). Per-member overrides can live in `workforce//.env`. See [Environment variables](/deployment#environment-variables) for scoping, precedence, and overrides.
To test a workforce member in isolation, you can also run its Python file directly: `uv run python workforce//agent.py` (the scaffold includes a small terminal REPL).
## Customize your agent
`timbal create` scaffolds a starter `agent.py` (or `workflow.py`) under `workforce//`. Open that file and edit the `Agent` definition.
A minimal agent needs only a **name** and **model**:
```python agent.py icon="python" theme={"dark"}
from timbal import Agent
agent = Agent(
name="my_agent",
model="openai/gpt-5-mini"
)
```
If you use your own provider key instead of platform auth, set it in a `.env` file at the project root (e.g. `OPENAI_API_KEY` for OpenAI models) — `timbal start` picks it up automatically. Never commit `.env` to version control.
The scaffold already includes a terminal REPL (`main()` at the bottom of `agent.py`). After editing, restart with `timbal start` to pick up changes in the full app, or run the file directly for a quick loop.
You can learn more about the Agent class and all the available options in the [Agents](/agents) section.
## Adding Tools
Agents become powerful with tools.
In Timbal, you can use both **baked-in tools** (pre-built and ready to use) and **custom tools** (functions you write yourself).
### Baked-in Tools
Timbal includes several ready-to-use tools. For example, the `WebSearch` tool allows your agent to search the internet for real-time information:
```python agent.py icon="python" theme={"dark"}
from timbal import Agent
from timbal.tools import WebSearch
agent = Agent(
name="my_agent",
model="openai/gpt-5-mini",
tools=[WebSearch()]
)
```
### Custom Tools
Creating custom tools is straightforward—just write a function. Here's a simple example that returns the current date and time:
```python agent.py icon="python" theme={"dark"}
from datetime import datetime
from timbal import Agent
from timbal.tools import WebSearch
def get_datetime() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
agent = Agent(
name="my_agent",
model="openai/gpt-5-mini",
tools=[WebSearch(), get_datetime]
)
```
You can combine any number of tools to create powerful, specialized agents.
Mix and match baked-in and custom tools to fit your specific use case.
You can even use agents and workflows as tools!
**Congratulations!** You've built a fully functional AI agent with tools in just a few lines of code.
Ready to explore more? Check out our [examples](/examples), or browse the latest code samples on GitHub.
## Moving to Production
Ready to take your agent to production? Timbal provides enterprise-grade deployment options designed for real-world applications.
Whether you need fully managed hosting or complete infrastructure control, we've got you covered. Check out our comprehensive [deployment guide](/deployment) to explore your options and choose the best approach for your needs.
# Agents & LLMs
Source: https://docs.timbal.ai/workflows/agents-and-llms
Use Agents as workflow steps and build LLM routing pipelines
Any Runnable works as a workflow step, including **Agents**. Use agents inside workflows when you want LLM reasoning at specific points in an otherwise deterministic pipeline, or when a classifier agent routes to specialist agents.
## Agent as a single step
```python theme={"dark"}
import asyncio
from timbal import Agent, Workflow
summarizer = Agent(
name="summarizer",
model="anthropic/claude-sonnet-4-6",
max_tokens=1024,
)
workflow = Workflow(name="summarize").step(
summarizer,
prompt="Summarize the quarterly report in three bullet points.",
)
async def main():
result = await workflow().collect()
print(result.output.collect_text())
asyncio.run(main())
```
The agent's output (typically a `Message`) is the workflow output.
## Passing inputs through the workflow
Expose agent params as workflow-level inputs by leaving them unbound on the step:
```python theme={"dark"}
workflow = (
Workflow(name="support")
.step(support_agent, prompt="Handle this ticket") # fixed prompt
)
# Or pass prompt at call time:
result = await workflow(prompt="Customer cannot log in").collect()
```
Steps with fixed kwargs in `.step(...)` hide those params from the workflow schema. Steps without defaults for a param surface it on the workflow.
## LLM routing (classify → specialist)
Use a lightweight classifier agent, then branch with `when`:
```python theme={"dark"}
import asyncio
from timbal import Agent, Workflow
from timbal.state import get_run_context
classifier = Agent(
name="classifier",
model="openai/gpt-5-mini",
system_prompt="Classify the message as 'technical' or 'billing'. One word only.",
max_tokens=64,
)
technical_agent = Agent(
name="technical_agent",
model="anthropic/claude-sonnet-4-6",
system_prompt="You are a technical support specialist.",
max_tokens=1024,
)
billing_agent = Agent(
name="billing_agent",
model="openai/gpt-5-mini",
system_prompt="You are a billing support specialist.",
max_tokens=1024,
)
workflow = (
Workflow(name="support_router")
.step(classifier)
.step(
technical_agent,
when=lambda: "technical"
in get_run_context().step_span("classifier").output.collect_text().lower(),
)
.step(
billing_agent,
when=lambda: "billing"
in get_run_context().step_span("classifier").output.collect_text().lower(),
)
)
async def main():
result = await workflow(prompt="I can't access my account after the upgrade").collect()
print(result.output.collect_text())
asyncio.run(main())
```
Only the matching specialist runs. See [Conditional Routing](/examples/workflows/conditional-routing) for a function-based variant.
## Agents + plain functions
Mix deterministic steps with agents in one pipeline:
```python theme={"dark"}
workflow = (
Workflow(name="doc_pipeline")
.step(fetch_content, url="https://example.com/report") # plain function
.step(extract_metadata, html=lambda: get_run_context().step_span("fetch_content").output)
.step(summarizer, prompt=lambda: f"Summarize: {get_run_context().step_span('extract_metadata').output['text']}")
.step(format_markdown, summary=lambda: get_run_context().step_span("summarizer").output.collect_text())
)
```
See [Sequential Steps](/examples/workflows/sequential-steps) for a full document pipeline.
## Nested workflows with agents
An inner workflow can contain agents; the outer workflow treats it as one step:
```python theme={"dark"}
analysis = (
Workflow(name="analysis")
.step(fetch_sales)
.step(summarizer, prompt=lambda: f"Summarize: {get_run_context().step_span('fetch_sales').output}")
)
delivery = (
Workflow(name="delivery")
.step(analysis)
.step(send_email, body=lambda: get_run_context().step_span("analysis").output.collect_text())
)
```
## When to use an agent vs a workflow
* **One agent with tools** — the model picks tools dynamically; good for open-ended tasks.
* **Workflow with agent steps** — you control which LLM runs when; good for staged pipelines, cost control, and auditability.
* **Workflow without agents** — pure data/code; fastest and cheapest.
## See also
* [Branching](/workflows/branching) — `when` conditions and skipped dependents
* [Agents](/agents) — agent configuration, tools, memory
* [Sequential Steps example](/examples/workflows/sequential-steps)
# Branching
Source: https://docs.timbal.ai/workflows/branching
Conditional step execution with the `when` parameter
## Conditional Execution
The `when` parameter controls whether a step runs based on runtime conditions:
```python theme={"dark"}
workflow = (
Workflow(name="router")
.step(classify, text="Urgent: server down")
.step(handle_urgent,
text="Server down",
when=lambda: get_run_context().step_span("classify").output == "urgent")
.step(handle_normal,
text="Server down",
when=lambda: get_run_context().step_span("classify").output == "normal")
)
```
Both `handle_urgent` and `handle_normal` wait for `classify` to complete. Only the one whose condition is met will execute.
## Skipped Steps
If a step's condition is not met, it is skipped along with all its dependents:
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(validate_input, data="...")
.step(process,
when=lambda: get_run_context().step_span("validate_input").output == "valid")
.step(save_results,
data=lambda: get_run_context().step_span("process").output)
)
```
If `validate_input` returns `"invalid"`, both `process` and `save_results` are skipped. `save_results` depends on `process`, which never runs.
Not all dependents of a skipped step are skipped. When a step uses `depends_on`, it only waits for the referenced steps to **resolve** (either complete or be skipped) — it doesn't need their data. This is useful when you don't know which branch will run:
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(classify, text="new customer signup")
.step(handle_new,
data="signup data",
when=lambda: get_run_context().step_span("classify").output == "new")
.step(handle_existing,
data="update data",
when=lambda: get_run_context().step_span("classify").output == "existing")
.step(finalize, depends_on=["handle_new", "handle_existing"])
)
```
`handle_new` and `handle_existing` have inverse conditions, so only one runs. `finalize` depends on both via `depends_on`, so it waits for both to resolve (one completes, the other is skipped) and then executes regardless.
## Conditional with Agents
Use an LLM to make routing decisions:
```python theme={"dark"}
classifier = Agent(
name="classifier",
model="openai/gpt-5-mini",
system_prompt="Classify the message as 'technical' or 'billing'. Respond with one word only.",
max_tokens=64,
)
technical_agent = Agent(
name="technical_agent",
model="anthropic/claude-sonnet-4-6",
system_prompt="You are a technical support specialist.",
max_tokens=1024,
)
billing_agent = Agent(
name="billing_agent",
model="openai/gpt-5-mini",
system_prompt="You are a billing support specialist.",
max_tokens=1024,
)
workflow = (
Workflow(name="support_router")
.step(classifier)
.step(technical_agent,
when=lambda: "technical" in get_run_context().step_span("classifier").output.collect_text().lower())
.step(billing_agent,
when=lambda: "billing" in get_run_context().step_span("classifier").output.collect_text().lower())
)
result = await workflow(prompt="I can't access my account").collect()
```
The classifier agent decides which handler runs. Only the matching branch executes.
# Control Flow
Source: https://docs.timbal.ai/workflows/control-flow
Manage step execution order with dependencies and data passing
Workflows execute steps based on their dependencies. By understanding how Timbal resolves these dependencies, you can build efficient pipelines that run steps in parallel when possible and sequentially when needed.
## Parallel by Default
When steps have no relationship between them, they run concurrently. Timbal doesn't wait for one to finish before starting the next:
```python theme={"dark"}
async def fetch_users():
return ["Alice", "Bob"]
async def fetch_orders():
return [{"id": 1}, {"id": 2}]
workflow = (
Workflow(name="data_loader")
.step(fetch_users)
.step(fetch_orders)
)
# fetch_users and fetch_orders run in parallel
```
## Context Access
The input and output from each step is stored in its [Run Context](/core-concepts/context), accessible via `get_run_context()`. Each step exposes two built-in variables:
* **`.input`**: Contains a dictionary of all the parameters passed to the step. They are accessed through their name.
* **`.output`**: Contains the value(s) returned by the step. Can be a single value, dictionary, array, or custom class.
Steps can access data from their own context, the parent workflow, or any sibling step:
| Method | Description |
| ------------------- | ----------------------- |
| `current_span()` | Current step's data |
| `parent_span()` | Parent workflow's data |
| `step_span("name")` | Any sibling step's data |
### Custom Variables
You can also create your own custom variables to share data between steps:
```python theme={"dark"}
async def process_user(user_id: str):
# Store custom data on this step's context
get_run_context().current_span().user_status = "active"
return f"Processed user: {user_id}"
async def check_status():
# Access the custom variable from another step
status = get_run_context().step_span("process_user").user_status
return f"User status: {status}"
workflow = (
Workflow(name="user_pipeline")
.step(process_user, user_id="user_123")
.step(check_status)
)
```
After the workflow runs, each step has the following variables in their context:
**`process_user`:**
```python theme={"dark"}
.input["user_id"] = "user_123"
.output = "Processed user: user_123"
.user_status = "active"
```
**`check_status`:**
```python theme={"dark"}
.input = {}
.output = "User status: active"
```
## Data Dependencies
When a step parameter references another step's output via a lambda, Timbal automatically creates a dependency and enforces sequential execution:
```python theme={"dark"}
async def merge_results(users: list, orders: list) -> dict:
return {"users": users, "orders": orders}
workflow = (
Workflow(name="data_loader")
.step(fetch_users)
.step(fetch_orders)
.step(merge_results,
users=lambda: get_run_context().step_span("fetch_users").output,
orders=lambda: get_run_context().step_span("fetch_orders").output,
)
)
```
Here:
* `fetch_users` and `fetch_orders` run in parallel (no dependencies between them)
* `merge_results` waits for **both** to complete (its parameters reference their outputs)
The dependency is resolved automatically. You don't need to declare it explicitly. Even though `fetch_users` and `fetch_orders` haven't executed when `merge_results` is defined, Timbal detects the `step_span()` references and knows to wait.
### Handling Optional Steps
When a step might be skipped (e.g., due to a `when` condition), you need to safely handle cases where that step didn't execute. Use `default=None` with `step_span()` to check if a step ran before accessing its output.
#### Execution Flow
Consider a workflow that validates data and then processes it differently based on the validation result:
```
1. validate_data (always runs)
↓
├─→ If valid: process_valid runs
└─→ If invalid: process_invalid runs
2. merge_results (always runs)
└─→ Needs to access either process_valid OR process_invalid
(but not both, since only one will have executed)
```
**The Problem**: When `merge_results` tries to access `process_valid` or `process_invalid`, one of them won't exist because it was skipped. Without `default=None`, accessing a skipped step would raise an error.
**The Solution**: Use `step_span("name", default=None)` which returns `None` if the step was skipped, allowing you to check which step actually ran.
#### Example
```python {20-29} theme={"dark"}
workflow = (
Workflow(name="pipeline")
# Step 1: Always runs - validates the input data
.step(validate_data, data={"email": "user@example.com"})
# Step 2: Only runs if validation passes
.step(process_valid,
data=lambda: get_run_context().step_span("validate_data").input.get("data"),
when=lambda: get_run_context().step_span("validate_data").output.get("valid", False))
# Step 3: Only runs if validation fails
.step(process_invalid,
data=lambda: get_run_context().step_span("validate_data").input.get("data"),
when=lambda: not get_run_context().step_span("validate_data").output.get("valid", False))
# Step 4: Always runs - merges results from validation and processing
.step(merge_results,
order=lambda: get_run_context().step_span("validate_data").input.get("data"),
validated=lambda: get_run_context().step_span("validate_data").output,
processed=lambda: (
# Try to get process_valid output (returns None if step was skipped)
get_run_context().step_span("process_valid", default=None).output
if get_run_context().step_span("process_valid", default=None) is not None
# Otherwise, try process_invalid output
else (
get_run_context().step_span("process_invalid", default=None).output
if get_run_context().step_span("process_invalid", default=None) is not None
else None
)
)
)
)
```
**How it works**:
1. `validate_data` always executes and returns `{"valid": True}` or `{"valid": False}`
2. Based on the validation result, either `process_valid` or `process_invalid` runs (but never both)
3. `merge_results` uses `step_span("name", default=None)` to safely check which processing step ran:
* If `step_span("process_valid", default=None)` returns a span (not `None`), that step ran
* If it returns `None`, the step was skipped, so we check `process_invalid` instead
**Key Point**: `step_span("name", default=None)` returns `None` if the step was skipped, allowing you to handle optional dependencies gracefully. Without `default=None`, accessing a skipped step would raise an error.
## Loops
Use `while_` to repeat a step. It accepts an int (run exactly N times) or a parameterless callable evaluated after each iteration (do-while — the step always runs at least once):
```python theme={"dark"}
def fetch_page() -> dict:
# The step owns its cursor state (module global, closure, or reading
# its own previous span) — params are NOT re-resolved per iteration.
span = get_run_context().step_span("fetch_page", default=None)
cursor = span.output["next_cursor"] if span else None
return api.fetch(cursor=cursor)
workflow = (
Workflow(name="paginate")
.step(fetch_page,
while_=lambda: get_run_context().step_span("fetch_page").output["next_cursor"] is not None)
.step(summarize,
pages=lambda: get_run_context().step_span("fetch_page").output)
)
```
Each iteration produces its own span. `step_span("name")` returns the **latest** one — both inside the `while_` condition and in downstream steps. `Trace.get_path()` returns all iterations.
A `while_` condition that reads its own step's span does not create a dependency edge (the loop is not a cycle in the DAG). Conditions reading *other* steps' spans create dependencies as usual. `while_` combines with `when`: if `when` returns `False`, the step (and the whole loop) is skipped.
Things to keep in mind with `while_`:
* **Params resolve once**, before the first iteration. Lambdas are not re-evaluated per iteration — a looping step must own its cursor/accumulator state (e.g. by reading its own previous span, as above).
* **No built-in iteration cap** for callable conditions. A condition that never returns falsy loops forever; prefer an int count or make the condition provably terminating.
* **Pausing mid-loop restarts the loop.** If the step hits an approval gate or calls `suspend()`, loop progress is not persisted — on resume the loop restarts from iteration 1, re-running side effects of completed iterations. Also, because approval ids derive from `(path, input)` and the input is fixed across iterations, a single approval covers every iteration in the same resume run. Avoid combining `while_` with approval-gated or suspending steps unless that behavior is acceptable.
* An error in any iteration stops the loop and fails the step (and the workflow).
## Explicit Dependencies
Use `depends_on` when you need ordering without a data dependency:
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(init_database)
.step(process_data, depends_on=["init_database"])
)
```
`process_data` waits for `init_database` even though it doesn't use its output.
## Summary
* Steps run in **parallel** by default
* Access sibling step data via `step_span()`, custom variables via `current_span()`
* Lambda parameters **automatically create dependencies** — even if the referenced step hasn't executed yet
* Use `depends_on` for explicit ordering without data dependency
* Use `while_` (int count or callable, do-while) to repeat a step; `step_span()` returns the latest iteration
# Errors & Failure
Source: https://docs.timbal.ai/workflows/errors-and-failure
How workflow steps fail, skip, and propagate errors
Workflows run steps concurrently but respect dependencies. When something goes wrong, behavior depends on whether the step **failed** (raised an error) or was **skipped** (`when` returned false, or a dependency was missing).
## Skipped vs failed
| State | Cause | Dependents |
| ----------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Skipped** | `when` returned `False`, or `step_span()` hit a skipped upstream step (`SpanNotFound`) | Steps that need that step's data are skipped; steps with only `depends_on` wait and may still run |
| **Failed** | Handler raised, step returned `OutputEvent` with error, or param/`when` evaluation failed | Steps that try to read the failed step's output fail during evaluation; workflow ends with `status.code == "error"` |
Skipped is intentional (branch not taken). Failed is an error you should handle or fix.
## Step failure
When a step handler raises, Timbal records the error on that step's span and marks the workflow failed:
```python theme={"dark"}
result = await workflow().collect()
# result.status.code == "error"
# result.status.reason == "step_failed"
# result.error == {"type": "ValueError", "message": "...", "traceback": "..."}
```
If multiple steps run in parallel and one fails, the workflow still reports error even if other steps succeeded.
## Dependent steps
A downstream step that reads a failed step's output via `step_span()` fails during parameter resolution (before its handler runs):
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(raises_error) # fails
.step(
process,
data=lambda: get_run_context().step_span("raises_error").output,
)
)
```
Use `step_span("name", default=None)` when a step might have been skipped (see [Control flow](/workflows/control-flow#handling-optional-steps)), not when it failed. A failed upstream step does not produce output to read.
## Skipped branches
When `when` is false, the step is skipped and dependents that **require its output** are skipped too:
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(validate_input, data="...")
.step(process, when=lambda: get_run_context().step_span("validate_input").output == "valid")
.step(save_results, data=lambda: get_run_context().step_span("process").output)
)
```
If validation fails, both `process` and `save_results` are skipped.
Steps that only need ordering (not data) can use `depends_on` to run after a branch resolves:
```python theme={"dark"}
.step(finalize, depends_on=["handle_new", "handle_existing"])
```
See [Branching](/workflows/branching#skipped-steps) for the full pattern.
## Pauses (approval / suspend)
When a step hits an [approval gate](/human-in-the-loop/approval-gates) or [`suspend()`](/human-in-the-loop/suspend), the workflow pauses with `status.code == "cancelled"` and a reason like `approval_required` or `input_required`. Parallel steps that also pause emit their events before the workflow stops, so you can resume multiple pending ids in one call.
Pauses are not failures. Resume with `parent_id` and `resume={...}` as documented in [Resuming a paused run](/human-in-the-loop/resuming).
## Cycle detection
Linking steps that would create a cycle raises immediately when you call `.step()`:
```python theme={"dark"}
# ValueError: Linking step_a -> step_b would create a cycle in the workflow.
```
Dependencies come from `depends_on`, `when` callables, and lambda parameters that call `step_span()`.
## Tracing
Every step gets its own span under the workflow span. Inspect per-step input, output, and errors via `get_run_context()._trace` or your [tracing provider](/core-concepts/tracing). Failed steps record `error` on their span even when the workflow's final status is `error`.
## See also
* [Control flow](/workflows/control-flow) — optional steps with `default=None`
* [Branching](/workflows/branching) — conditional execution
* [Human in the loop](/workflows/human-in-the-loop) — pauses on workflow steps
# Human in the Loop
Source: https://docs.timbal.ai/workflows/human-in-the-loop
Pause workflow steps for approval or user input, then resume
Human-in-the-loop works on **workflow steps** the same way it works on agents and tools. Any step wrapped in a `Tool` (or any Runnable) can use `requires_approval`, and any handler can call `suspend()`.
## Approval gates on steps
Wrap a step function in a `Tool` with `requires_approval`:
```python theme={"dark"}
from timbal import Tool, Workflow
deploy_prod = Tool(
name="deploy_prod",
handler=deploy_prod_impl,
requires_approval=True,
approval_prompt="Promote to production?",
)
workflow = (
Workflow(name="release_pipeline")
.step(deploy_staging)
.step(deploy_prod)
.step(announce, depends_on=["deploy_prod"])
)
```
When the gate fires, the workflow pauses with `status.reason == "approval_required"`. Independent gates in parallel all emit before the workflow stops, so you can approve multiple steps in one `resume={...}` call.
Full reference: [Approval gates](/human-in-the-loop/approval-gates), including edit-on-approve, redaction, time limits, and [approvals in workflows](/human-in-the-loop/approval-gates#approvals-in-workflows).
## Suspend inside a step
Handlers that call `suspend()` pause the workflow mid-step. The handler re-runs from the top on resume, so keep side effects after the `suspend()` call or make them idempotent.
Use [Suspend & interaction tools](/human-in-the-loop/suspend) for `ask_user`, `ask_user_multi`, `confirm`, and custom interaction tools.
## Resuming
Call the workflow again with `parent_id` (the paused run id) and `resume={approval_id: True}` or `resume={interaction_id: value}`. Cross-process resume needs a durable [tracing provider](/human-in-the-loop/resuming).
## See also
* [Errors & failure](/workflows/errors-and-failure) — pauses vs failures
* [Human in the Loop overview](/human-in-the-loop)
# Overview
Source: https://docs.timbal.ai/workflows/index
Build multi-step AI pipelines with explicit control flow
Workflows orchestrate **steps** in a DAG: you define what runs, Timbal infers dependencies and runs independent steps in parallel. Use them when the path is predictable (ETL, gated deploys, fan-out/fan-in, LLM routing) rather than leaving every decision to the model.
## Workflow vs Agent
| | **Workflow** | **Agent** |
| ----------- | -------------------------------------------- | ---------------------------------- |
| Control | You define steps and order | The model decides what to do |
| Best for | Pipelines, batch jobs, deterministic routing | Open-ended reasoning, tool picking |
| Parallelism | Built-in across independent steps | Sequential tool loop |
| Composition | Nest workflows, mix agents + functions | Tools and sub-agents |
They share the same interface: call a Runnable to get an event stream, or chain `.collect()` for the final `OutputEvent`. A common pattern is a **workflow that routes to agents** (classify → specialist agent). See [Agents & LLMs](/workflows/agents-and-llms).
## Reading guide
1. **[Control flow](/workflows/control-flow)** — parallel execution, `step_span()`, `depends_on`, wiring data between steps
2. **[Branching](/workflows/branching)** — conditional steps with `when`
3. **[Errors & failure](/workflows/errors-and-failure)** — what happens when a step fails or is skipped
4. **[Agents & LLMs](/workflows/agents-and-llms)** — agents as steps, LLM routing patterns
5. **[Human in the loop](/workflows/human-in-the-loop)** — approval gates on workflow steps
6. **Examples** below — copy-paste pipelines for common shapes
## Quickstart
```python theme={"dark"}
import asyncio
from timbal import Workflow
from timbal.state import get_run_context
def celsius_to_fahrenheit(celsius: float) -> float:
return (celsius * 9 / 5) + 32
def format_result(temperature: float) -> str:
return f"Temperature: {temperature}°F"
workflow = (
Workflow(name="temperature_converter")
.step(celsius_to_fahrenheit, celsius=35)
.step(
format_result,
temperature=lambda: get_run_context().step_span("celsius_to_fahrenheit").output,
)
)
async def main():
result = await workflow().collect()
print(result.output)
asyncio.run(main())
```
Functions used as steps must accept and return Pydantic-serializable types (`str`, `int`, `float`, `bool`, `dict`, `list`, `BaseModel`). Custom classes that aren't Pydantic models cannot be passed between steps.
## Adding steps
Use `.step()` to add a Runnable (function, Tool, Agent, or nested Workflow):
```python theme={"dark"}
from timbal import Tool, Workflow
# Same handler twice? Give each Tool a unique name.
threshold_high = Tool(name="threshold_high", handler=check_threshold)
threshold_low = Tool(name="threshold_low", handler=check_threshold)
workflow = (
Workflow(name="monitoring")
.step(threshold_high, value=80, limit=100)
.step(threshold_low, value=80, limit=50)
)
```
Each step name must be unique within the workflow. Timbal detects cycles when you link steps and raises if the graph would loop.
## Workflow inputs
Keyword arguments passed to the workflow are forwarded to steps. Parameters without a default on a step become **workflow-level inputs** (merged into the workflow's params schema):
```python theme={"dark"}
workflow = (
Workflow(name="scraper")
.step(fetch) # fetch(url: str) → url is a workflow input
.step(process, raw=lambda: get_run_context().step_span("fetch").output)
)
result = await workflow(url="https://example.com").collect()
```
Fixed values passed in `.step(name, key=value)` are defaults for that step only and are not exposed as workflow inputs.
## Output
The workflow returns the **last executed step's output**. If you need multiple step results, add a final merge step:
```python theme={"dark"}
workflow = (
Workflow(name="pipeline")
.step(fetch_users)
.step(fetch_orders)
.step(
build_report,
users=lambda: get_run_context().step_span("fetch_users").output,
orders=lambda: get_run_context().step_span("fetch_orders").output,
)
)
# build_report's return value is the workflow output
```
## Running
```python theme={"dark"}
# Collect final OutputEvent
result = await workflow(url="https://example.com").collect()
print(result.output)
print(result.status.code) # "success" | "error" | "cancelled"
print(result.usage)
# Or stream events from every step
async for event in workflow(url="https://example.com"):
print(event)
```
## Composition
Nest workflows as steps. The inner workflow's final output becomes that step's output:
```python theme={"dark"}
data_pipeline = (
Workflow(name="data_pipeline")
.step(fetch_data, source="api")
.step(clean_data, raw=lambda: get_run_context().step_span("fetch_data").output)
)
report_pipeline = (
Workflow(name="report_pipeline")
.step(data_pipeline)
.step(generate_report, data=lambda: get_run_context().step_span("data_pipeline").output)
)
```
See [Workflow Composition](/examples/workflows/workflow-composition) for a full example.
## Examples
Chain steps with data passing between them
Fetch from multiple sources concurrently, then merge
Route to different handlers based on validation
Nest an inner workflow as a step