# 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. Tasks belong to the **session** that started them: the agent stays talkable, can run several children at once, and can answer questions about any of them on a later turn. ## 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": "k4d9x2mq7c1a", "status": "running"} ``` Once this session has at least one background task, the agent automatically gains three tools — `get_background_task`, `list_background_tasks`, and `cancel_background_task` — so it can brief the user or stop a child on a follow-up turn without you wiring anything. Set `background_transcript_tool=True` on the Agent to also expose `read_background_transcript` for paged raw event replay (off by default). `get_background_task` **peeks**; it does not drain. Calling it twice returns the same thing, so the agent polling a child never steals events from a frontend streaming the same child. ## Completion notifications When a background child reaches a terminal status (`completed`, `error`, `cancelled`, `timed_out`, or `stalled`), the session store queues a one-shot notice. At the **start of the next parent turn** — not mid-turn while the parent is still talking — the agent drains that inbox and injects a synthetic user message into memory so the LLM learns without polling: ```text theme={"dark"} Background task(s) finished since your last turn. ... task_id: k4d9x2mq7c1a name: build_site title: Build the marketing site status: completed result: ... summary: ... ``` The notice stays `role="user"` (provider wire formats require it mid-history) but is tagged: ```json theme={"dark"} {"source": "runtime", "kind": "background_task_completed"} ``` Transcript UIs should filter `metadata.source == "runtime"` (or `Message.is_runtime()`) — do not sniff the XML tag. The model still sees the message; humans should not. Notices are lean (short summary / result preview, error string if any) — not full transcripts. After drain they are not re-injected; `get_background_task` still peeks the full snapshot. Concurrent parent runs without a shared `parent_id` keep isolated inboxes. If the model already polls via the auto-registered `get_background_task` / `list_background_tasks` tools and sees a terminal status, that peek **acks** the notice — the next turn will not fire a duplicate `` for a completion the agent already handled. App/Python peeks (`timbal.state.get_background_task`) do not ack by default, so UIs can inspect without stealing the LLM inbox. ```python theme={"dark"} from timbal.state import get_background_task get_background_task("k4d9x2mq7c1a") ``` ```python theme={"dark"} { "status": "running", # running | completed | error | cancelled | timed_out | stalled | not_found "task_id": "k4d9x2mq7c1a", "name": "build_site", "title": "Build the marketing site", "input": {"prompt": "Build the marketing site"}, "started_at": 1755385200000, # Unix ms "summary": { "text": "...", # trailing assistant text, capped at 1000 chars "phase": "processing", # last stage label, heuristic phase, or terminal status "pct": 50, # 0–100 when the child reports progress (else null) "last_tool": "write_file", # most recent tool_use name, if any "tools_in_flight": ["write_file", "bash"], # open tool calls not yet finished "event_count": 42, }, "transcript_cursor": 42, # pass as `after` to read raw events } ``` The summary is deliberately bounded — it's for briefing the user, not for dumping a long build into the context window. When the task finishes, the snapshot also carries `result` (or `error` on failure / timeout). Durable child ids seen on the child's events, such as its `run_id`, are lifted onto the snapshot as they arrive. ### Structured progress Handlers that yield progress dicts (e.g. `{"stage": "processing", "progress": 50}`) surface as `summary.phase` and `summary.pct`. Agent children also expose `summary.last_tool` and `summary.tools_in_flight` (open tool calls inferred from the event log — a tool drops off when its `OutputEvent` arrives). While running with no explicit stage, `phase` falls back to `streaming`, `tool:{name}`, or `running`; on terminal success `phase` becomes `completed` and `pct` is `100`. ## Opt-in automatic follow-up turns `Agent.stream_until_idle()` streams the initial turn and starts another turn when a background task finishes or a [session event](#posting-events-to-a-session) arrives. Ordinary `agent(...)` calls keep their existing single-turn behavior. ```python theme={"dark"} from contextlib import aclosing stream = agent.stream_until_idle( prompt="Prepare the report in the background, then tell me the result.", max_idle_secs=300, max_continuations=10, ) async with aclosing(stream): async for event in stream: print(event) print(stream.stop_reason, stream.last_run_id) ``` Each turn has a fresh `run_id`, the previous turn as its `parent_run_id`, and its own root `OUTPUT` event. Consume the whole stream, not just the first output. A follow-up's `prompt` is the completion notice itself: a `role="user"` message with `metadata.source == "runtime"`, so transcripts that filter with `Message.is_runtime()` hide it and input guardrails skip it, as for any notice. It is not a human utterance and earlier traces are never rewritten. Follow-up outputs carry `metadata.trigger`: ```python theme={"dark"} {"kind": "background_completion", "continuation_index": 1, "task_ids": ["..."]} # with posted events: "kind": "session_event" and "event_ids": [...] ``` The stream ends when no children or pending notices remain, or on an idle timeout, continuation limit, unsuccessful run, or approval/input suspension. Read `stop_reason` (and `last_status` for the last run's status code) after iteration. `max_idle_secs` bounds waiting between turns, not the duration of a model call. `max_continuations=0` allows only the initial turn. Tracing must be enabled so subsequent turns can read their history. ### Sending another user turn Just send it: `agent(prompt=..., parent_id=stream.last_run_id)`, or start another `stream_until_idle` with that `parent_id`. While the stream is waiting between runs, the new turn **preempts** it: the stream ends with `stop_reason == "preempted"`, and any notices that were pending ride along in the new turn's memory just before its prompt. While one of the stream's runs is still in flight, the new turn raises `SessionBusyError` before hooks or the model run; wait for that run's `OUTPUT` first. To queue it instead, create the stream with `on_busy="enqueue"`: the new turn waits for the in-flight run to finish, then preempts the stream as above. A turn let into the session always continues from its latest run, even if it was sent with an older `parent_id` (one it queued behind, or one a client had not caught up with). Its `OUTPUT` carries the run it continued from as `parent_run_id`, and its root span keeps the one it asked for as `metadata.requested_parent_id`. ### Which results start a turn By default every finished task and every posted event starts a follow-up. A tool or an event can ask for less, or more, attention: ```python theme={"dark"} Tool(handler=refresh_cache, background_mode="always", background_announce="silent") ``` * `"when_idle"` (default): start a follow-up once the in-flight run, if any, has finished. * `"silent"`: start nothing. The result rides the next turn that starts for another reason, like any pending notice. A stream with only silent work left ends with `stop_reason == "idle"`. * `"interrupt"`: in a voice call, cut in on the agent's reply (see [Using tools](/voice/tools#report-back-when-slow-work-finishes)). A text run is never cut, so here it behaves like `"when_idle"`. A follow-up started by an interrupting item records `"announce": "interrupt"` in its `metadata.trigger`. ### Stopping, hosting, and gating follow-ups ```python theme={"dark"} from timbal.core.continuation import AgentContinuation, continuation_for_run stream = AgentContinuation( agent, admit=wait_until_the_line_is_quiet, # awaited before each follow-up starts prompt="Prepare the report in the background, then tell me the result.", ) # Elsewhere, with only a run id (a request handler, say): driver = continuation_for_run(run_id) if driver is not None: driver.stop() ``` * `stop()` ends the stream immediately while it waits, or after the current run's `OUTPUT` while one is in flight. `aclose()` also closes the in-flight run. * `admit` is an optional parameterless callable (sync or async). The stream calls it once a completion is pending and starts the follow-up as soon as it returns; a preemption, `stop()`, or the idle timeout cancels it. Channel-specific conditions such as voice playback or caller silence belong here. * `continuation_for_run(run_id)` finds the stream that owns a session through its background-task store, so it returns `None` before the session's first spawn. This is a **process-local, caller-driven** stream, not a durable session service. Stopping, closing, or cancelling it stops further automatic responses but leaves detached children running. Use explicit background-task cancellation to stop those too. Close it explicitly when breaking iteration. Existing HTTP routes do not opt into this behavior; voice sessions run their own version of it (see [Using tools](/voice/tools#report-back-when-slow-work-finishes)). Restart recovery, cross-worker ownership, and guarantees that a user heard a response are outside this API's scope. ## Posting events to a session Work that is not a background task of the agent (another service, a job queue, a webhook) reports into the conversation with a session event: ```python theme={"dark"} from timbal.state import post_session_event event = post_session_event(run_id, "The nightly export finished: 1,204 rows.", key="export") ``` The session's next turn receives the event as a runtime message ahead of its prompt (never as a user utterance); a `stream_until_idle` stream waiting on the session wakes and starts a follow-up unless the event says `announce="silent"` (see [Which results start a turn](#which-results-start-a-turn)). A pending event with the same `key` is superseded. The returned `SessionEvent` is a receipt: `state` is `"pending"`, then `"delivered"` (with `run_id`) once a turn has it, or `"superseded"`; `await event.settled()` waits for that. Turns that receive events count them in their usage as `session_events:delivered`. Any run id of a session that has started a background task (or received an event) reaches it. For a session with neither, `post_session_event` raises `LookupError`; pass `create=True` with the session's **latest** run id, since only the turn chained directly on that run will find the event. Posting is safe from another thread (a webhook handler, a sync tool). Text is limited to 4,000 characters. `source="client"` marks text from a user's device, which the model is told to treat as information, not instructions. Events are process-local, like background tasks. ## Reporting progress into the conversation [Structured progress](#structured-progress) is for peeking: it updates the task's summary and the model sees it only if it polls. To tell the conversation while the task runs, call `report_progress` from inside the background task: ```python theme={"dark"} from timbal.state import report_progress async def build_site(repo: str) -> str: """Build and deploy the site. Takes a few minutes.""" for phase in ("install", "build", "deploy"): await run_phase(repo, phase) report_progress(f"{phase} finished") return "Deployed." ``` Each report is a session event keyed on the task, so a newer report replaces one no turn has read yet, and the task's completion notice replaces any report still pending. The model is told the task is still running. A streaming session starts a follow-up for a report like for any event; pass `announce="silent"` to only let it ride the next turn. Outside a background task, `report_progress` raises `RuntimeError`. ## Timeouts Set `background_timeout` (seconds) on a tool to hard-cap how long a detached child may run. When the deadline hits, the child is cancelled like a user cancel (Task cancel + handler `aclose` + `on_background_cancel`) but reports status `timed_out` so the next-turn completion notice is unambiguous. ```python theme={"dark"} long_running_tool = Tool( handler=slow_operation, background_mode="always", background_timeout=600.0, # 10 minutes ) ``` `None` (default) or a non-positive value means no deadline. ## Stall detection Set `background_stall_timeout` (seconds) to cancel a child that stops emitting log events — distinct from wall-clock `background_timeout`. The idle timer **resets on every event** (streaming chunks, tool calls, progress dicts), so a long but chatty build stays alive while a hung subprocess does not. ```python theme={"dark"} long_running_tool = Tool( handler=slow_operation, background_mode="always", background_stall_timeout=120.0, # no events for 2 minutes → stalled ) ``` Terminal status is `stalled`. While still running, snapshots expose `summary.seconds_since_event` and `stall_timeout` for UI warnings before the watchdog fires. ## Concurrency and depth caps Each session bag bounds fan-out so one turn cannot detach unbounded work: * **Concurrent** — max children whose asyncio.Task is still running (default **20**). Override per Agent with `max_background_concurrent=...` or env `TIMBAL_MAX_CONCURRENT_BACKGROUND` (`0` / `none` / `unlimited` = no cap). * **Depth** — max nesting of background spawns (default **unlimited**). `max_background_depth=1` means only top-level code may detach; a background child cannot spawn further background work. Env: `TIMBAL_MAX_BACKGROUND_DEPTH`. Hitting a cap raises `BackgroundLimitError` (surfaced as a tool error to the LLM) instead of spawning. ```python theme={"dark"} agent = Agent( name="orchestrator", model="...", tools=[Bash("*")], max_background_concurrent=4, max_background_depth=1, ) ``` ## Reading the Raw Transcript For the full event stream, read from a logical cursor. This does not drain either — the same `after` returns the same events while they remain in the ring, so it's safe to call from more than one place. **Python / app code** — always available via `timbal.state.read_background_transcript`. **LLM tool** — opt in with `background_transcript_tool=True` on the Agent (registered alongside the other session background tools once a child exists). ```python theme={"dark"} agent = Agent( name="debugger", model="...", tools=[...], background_transcript_tool=True, ) ``` When the ring has dropped older events, `gapped` is `true` and `after` is behind `forgotten_through` — treat that like an expired reconnect, not a silent skip to the new head. ```python theme={"dark"} from timbal.state import read_background_transcript page = read_background_transcript("k4d9x2mq7c1a") # {"status": "running", "task_id": ..., "events": [...], "cursor": 42, # "gapped": false, "forgotten_through": 0} # Later, pick up where you left off page = read_background_transcript("k4d9x2mq7c1a", after=page["cursor"]) ``` ## Waiting for completion For app code or frontends that want to block instead of polling the agent loop, use `wait_for_background`. It returns the same snapshot as `get_background_task` and does **not** ack completion notices. ```python theme={"dark"} from timbal.state import wait_for_background # Block until terminal (or timeout) snap = await wait_for_background("k4d9x2mq7c1a", timeout=30.0) if snap["status"] == "running": ... # timeout — poll again # Long-poll until new events land past a cursor (or terminal / timeout) snap = await wait_for_background("k4d9x2mq7c1a", after=cursor, timeout=5.0) ``` Without `after`, the wait is tied to the child's asyncio task finishing. With `after`, it wakes when the append-only log advances, the log closes on completion, or the timeout hits — useful for streaming UIs that already have a `transcript_cursor`. ## Log retention Each child's event log is a **ring buffer** (defaults: 50k events / 32 MiB — same idea as HTTP `JobStore`). Oldest events drop when over cap; `forgotten_through` is the logical floor. Finished **task records** are removed from the session bag after **300 seconds** by default so long-lived workers do not leak metadata. Override per Agent: ```python theme={"dark"} agent = Agent( ..., max_background_log_events=10_000, max_background_log_bytes=None, # unlimited bytes background_task_retention_secs=600, ) ``` Env: `TIMBAL_BG_LOG_MAX_EVENTS`, `TIMBAL_BG_LOG_MAX_BYTES`, `TIMBAL_BG_TASK_RETENTION_SECS` (`0` / `none` = unlimited / keep forever). To follow a child live instead of polling, subscribe to its log. `subscribe` replays from `after` and then yields events as they arrive, so you cannot miss one that lands mid-replay. If `after` is behind `forgotten_through` (ring eviction), the iterator yields `BACKGROUND_LOG_GAPPED` once — same signal as `gapped=True` on `read`/`peek` — then replays from the ring head and continues with live events. ```python theme={"dark"} from timbal.state.background import current_background_store record = current_background_store().get("k4d9x2mq7c1a") async for event in record.log.subscribe(): print(event) ``` A streaming handler can yield either form and both reach the log. Raw delta items are wrapped into `DeltaEvent`s under the parent's run. Already-formed Timbal events (`StartEvent`, `DeltaEvent`, `OutputEvent`) are logged as they are — which is how a child that runs its own agent, such as a coding harness or a remote worker, keeps its own `run_id` and metadata on the task record. Those are the ids `on_background_cancel` and resume need. ## Listing and Cancelling ```python theme={"dark"} from timbal.state import cancel_background_task, list_background_tasks list_background_tasks() # [{"task_id": "k4d9x2mq7c1a", "name": "build_site", "status": "running", # "started_at": 1755385200000, "title": "Build the marketing site"}] cancel_background_task("k4d9x2mq7c1a") # returns the final snapshot ``` Cancelling stops the child's in-flight work: the task is cancelled and its handler is closed, so a handler holding a subprocess or a socket runs its `finally` instead of being abandoned mid-flight. ## Session Scope The task store is bound to the `RunContext` and inherited across sequential turns via `parent_id`. In practice: * A finished turn can still list, peek, and cancel the children it started, because the next turn passes the previous run as `parent_id`. * Two concurrent runs of the same `Agent` object — no shared `parent_id` — get isolated stores. Neither sees the other's tasks, and `get_background_task` with a foreign `task_id` returns `not_found`. * The store is process-local, so it does not survive a restart. Use a durable job store if a child must outlive the process. ## Stopping External Work Cancelling an `asyncio.Task` can only reach work the event loop owns. If a child drives something outside the process, use `on_background_cancel` to tear it down. The hook receives the task record, whose `metadata` carries the child's ids once its events have arrived. ```python theme={"dark"} def stop_remote_build(record): remote_api.stop(record.metadata["run_id"]) Tool( name="build_site", handler=start_remote_build, background_mode="always", on_background_cancel=stop_remote_build, ) ``` # 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 > [!WARNING] > > 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. > [!NOTE] > > 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. > [!TIP] > > 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! ``` > [!TIP] > > 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). For live spoken conversations, continue to **[Voice agents](/voice)** for speech providers, browser and phone transports, interruptions, and usage tracking. 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 fails, including authentication errors, rate limits, timeouts, and server errors. ```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 up to two retries for transient errors. Authentication errors trigger fallback immediately, as do rate limits when another model is available. If every model fails, 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, provider_params={"thinking": {"type": "adaptive"}}, ), ModelEntry( "openai/gpt-5.5", max_retries=1, provider_params={"reasoning": {"effort": "low"}}, ), ), 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. The agent's `api_key` and `base_url` apply only to models from the primary provider. When the chain switches providers, Timbal resolves that provider's own credentials and endpoint from its environment variables or the platform proxy. Use `ModelEntry(api_key=..., base_url=...)` to override these for an individual model, including when multiple providers share a gateway. The agent's `model_params` (or per-request `provider_params`) also apply only to models from the primary provider. Other providers use their defaults unless their `ModelEntry` sets `provider_params`. An entry's dict **replaces**, rather than merges with, the shared params; use `{}` to clear them explicitly. An omitted value or `None` inherits shared params only for the primary provider. Timbal does not translate provider-specific options such as OpenAI's `reasoning` into Anthropic's `thinking`. This scoping also applies to providers that share an API format, such as OpenAI and xAI; set entry-level params explicitly to reuse compatible options. Shared `max_tokens`, `temperature`, tools, and structured output configuration still apply across the chain. ### 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) ``` ### Iteration Budget `max_iter` (default `10`) bounds the LLM → tool rounds in one turn. `on_max_iter` decides what happens when the budget is spent and the model still wants tools: | `on_max_iter` | Extra LLM call | Output | | - | - | - | | `"final_answer"` (default) | One, with **no tools**, preceded by a notice telling the model its budget is gone | The model's text | | `"stop"` | None | A fixed assistant reply (`stop_reason="max_iter"`) | | `"error"` | None | Raises `timbal.errors.MaxIterExceeded` — for one-shot runs; memory is left ending in an unanswered tool batch | The notice matters: silently handed an empty tool list, models narrate the step they were about to take ("I'll update the file now") as if it had happened. For anything custom, pass a callable (sync or async). It receives a `MaxIterContext(max_iter, memory, agent_path)` and returns a message: `role="user"` is used as the notice for a final tool-less call, `role="assistant"` is the reply and ends the turn, `None` makes the tool-less call with no notice, and raising fails the run. ```python theme={"dark"} def out_of_budget(ctx): if ctx.agent_path != "assistant": # sub-agents: stop cheaply, let the parent decide return {"role": "assistant", "content": f"[Stopped after {ctx.max_iter} steps.]"} return {"role": "user", "content": "Budget spent. Report what is done and what is not; claim nothing else."} agent = Agent(name="assistant", model="openai/gpt-5", tools=[search, write_report], max_iter=16, on_max_iter=out_of_budget) ``` Every mode records `metadata["max_iter_reached"] = {"max_iter", "behavior"}` on the agent span. Injected messages are persisted in memory tagged `metadata={"source": "runtime", "kind": "max_iter_notice" | "max_iter_stop"}`, so `Message.is_runtime()` lets you hide them when rendering a human transcript. ### Timeout `max_iter` bounds steps; `timeout` bounds time. It is the wall-clock budget, in seconds, for one turn — every LLM call and tool call included. Default `None` (no limit). ```python theme={"dark"} agent = Agent(name="assistant", model="openai/gpt-5", tools=[search, write_report], timeout=120) result = await agent(prompt="Research and write the report").collect() if result.status.code == "timeout": print(result.error["message"]) # "assistant timed out after 120s" partial = result.output # whatever the turn produced before the deadline ``` When the deadline passes, the in-flight LLM or tool call is cancelled and the turn ends with `status.code == "timeout"` and an `error` of type `RunTimeout`. Memory stays consistent: an interrupted tool call gets a synthetic result, so the next turn runs normally. `timeout` is available on every runnable, not just agents: * **Sub-agents and tools.** A child's own `timeout` fails only that call. The parent agent receives it as an error tool result and can recover. A `timeout` on the parent bounds the whole turn, children included. * **Workflow steps.** A step that times out blocks its dependents, including transitive dependents. Unrelated branches may finish before the workflow reports the step failure. An agent inside a workflow can still recover from its own tools' timeouts; the workflow uses that agent's final result. See [Errors & failure](/workflows/errors-and-failure#timeouts). * **Hooks.** `pre_hook`, the handler, and `post_hook` share one deadline. A slow hook times out the call; the budget is not restarted between stages. If the handler finished before a post-hook timed out, its output is retained. * **What doesn't count.** The clock starts after input validation and the approval gate, so time a run spends paused waiting for approval does not use up the budget. Children detached with `background_mode` use `background_timeout` instead (see [Background Tasks](/agents/background-tasks#timeouts)). * **Sync handlers.** A sync tool running inline on the event loop can't be interrupted. Make it async, or set `offload_blocking=True`. A `TimeoutError` raised inside a handler, such as a slow HTTP call, is still reported as a regular `error`. Only the runnable's own deadline produces `timeout`. **Performance.** An unset timeout has negligible overhead: the per-call check remains, but the stream relay and timers are bypassed. Enabling a timeout adds timer and relay work per streamed event. The `benchmarks/timeout/bench_streaming.py` benchmark measures this cost separately from network and LLM latency. ### 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 theme={"dark"} 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. > [!NOTE] > > 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() ``` > [!WARNING] > > 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: > [!NOTE] > > 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." ``` > [!WARNING] > > Response content can contain thinking, text, tool, and other blocks in a provider-dependent order. `content[0]` is not guaranteed to be text, and reasoning need not occupy a fixed position. Use `collect_text()` to extract the text response. ## 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. > [!NOTE] > > 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. ## The project API For routes exposed by the TypeScript SDK's `timbalMcp()` plugin, use: ```python theme={"dark"} project_tools = MCPServer(name="app", transport="http", service="api") ``` The endpoint and authorization resolve at execution time from the current run. Local development uses the API port's `/mcp`; deployed projects use the project origin's `/api/mcp`. Do not point MCP at the UI origin's `/mcp`, capture a caller bearer during module import, or put a project service secret in source. Service mode cannot be combined with a manual URL or headers. Discovery and each tool call open and close their own connection, so concurrent users never share a connection authenticated as someone else. This adds a handshake per operation; explicit-URL MCP servers retain their existing persistent connection behavior. Discovery does not invoke application tools, and tool approvals still apply. ## 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 | | `service` | http | `"api"` resolves the project API MCP endpoint and current caller authorization at runtime. Mutually exclusive with `url` and `headers`. | | `url` | http | Explicit external server URL | | `headers` | http | HTTP headers sent with every request | | `timeout` | both | Seconds to wait for a request on an established session (`tools/list`, `tools/call`). Default **600** (10 minutes) — past any tool that legitimately runs long, but a server that accepted a request and will never answer becomes an error instead of a run that sits open forever. `None` = wait forever. A dead transport fails the call immediately regardless. | | `tool_timeouts` | both | Per-tool override of `timeout` for `tools/call`, keyed by the server's bare tool name: `{"codegen": 900, "whoami": 10}`. `None` waits forever for that tool. | | `connect_timeout` | both | Seconds to wait for the transport to come up and `initialize` to complete. Separate from `timeout` because a stdio server's cold start is routinely longer than a sensible per-request bound. Default `None` (HTTP TCP connect is still bounded at 30s). | | `approval` | both | Gate tools through the [approval flow](/human-in-the-loop/approval-gates): `"destructive"`, `"all"`, a callable over the tool, or a callable over the tool **and the call's arguments**. See [Approval from annotations](#approval-from-annotations). | | `elicitation` | both | Advertise the elicitation capability and bridge server questions to `suspend()`. Default `True`. See [Elicitation](#elicitation). | | `sampling_model` | both | Timbal model string used to answer the server's `sampling/createMessage` requests. Default `None` (unsupported). | ### 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`](/agents#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 ## Approval from Annotations MCP tools carry [`ToolAnnotations`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-annotations) such as `readOnlyHint` and `destructiveHint`. `approval=` turns them into a client-side gate that runs through Timbal's normal approval flow — a server cannot skip it: ```python theme={"dark"} MCPServer( name="timbal", transport="http", url="https://api.timbal.ai/mcp", approval="destructive", # or "all", or lambda tool: tool.name in {"delete_project"} ) ``` * `"destructive"` gates tools whose annotations mark them destructive (`destructiveHint` not `false` and `readOnlyHint` not `true`). Tools with **no annotations at all are not gated** — gating them would mean gating every tool of every server that never annotated. Note the spec default: a tool that sets *any* annotation without `destructiveHint: false` counts as destructive, so a server that annotates `readOnlyHint: false` on every writer gates every writer. * `"all"` gates every tool. * A one-argument callable receives the server's `mcp.types.Tool` once, at resolve time, and returns `True` to gate every call of that tool. * A two-argument callable receives the tool **and the call's arguments** and is evaluated per call, so one tool that multiplexes subcommands can gate some and wave others through. It may be async and may return a `bool`, a dict, or an `ApprovalPolicyDecision` (the same contract as [`requires_approval`](/human-in-the-loop/approval-gates)): ```python theme={"dark"} def needs_approval(tool, arguments): if tool.name == "codegen": return arguments.get("command") in {"delete-workforce"} return tool.name in {"delete_kb_file"} or (tool.annotations and tool.annotations.destructiveHint is True) MCPServer(name="timbal", transport="http", url="https://api.timbal.ai/mcp", approval=needs_approval) ``` Gated tools emit an `ApprovalEvent` with `kind="mcp_tool"`, the server's input schema as `input_schema`, and `ui={"server", "tool", "title", "annotations"}`. Resume exactly like any other approval: `resume={approval_id: True}`. The gate runs client-side before anything is sent, so it is unaffected by parallel tool calls and survives reconnecting or rebuilding the `MCPServer` between the pause and the resume — the approval id is derived from the tool path and input, not the session. ## Elicitation Servers can ask the user a question mid tool call ([elicitation](https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation)) — for example, "Swap KB 1069 → 1586 (562 agents → 3)?" before a destructive operation. Timbal advertises the capability by default and bridges each request to [`suspend()`](/human-in-the-loop/suspend): the run ends with status `input_required` and an `InteractionEvent` of `kind="mcp_elicitation"`. ```python theme={"dark"} async for event in agent(prompt="Swap the KB to 1586"): if isinstance(event, InteractionEvent) and event.kind == "mcp_elicitation": print(event.payload["message"]) # the server's question print(event.payload["requested_schema"]) # flat JSON schema of primitives interaction_id = event.interaction_id # Render the form, then resume with the answer: result = await agent( prompt="Swap the KB to 1586", parent_id=paused_run_id, resume={interaction_id: {"confirm": True}}, ).collect() ``` Resume values: | Value | Sent to the server | | - | - | | `{"confirm": True, ...}` (a content dict) | `accept` with that content — checked against `requested_schema` first | | `True` | `accept` with empty content (pure confirmations) | | `False` / `None` | `decline` | | `{"action": "accept" \| "decline" \| "cancel", "content": {...}}` | explicit | | `Cancel(reason=...)` | `cancel`, and the whole run stops | Because the tool is **re-invoked on resume**, the server's tool must be idempotent up to the `elicit()` call and its message deterministic for identical input — the suspension id is a hash of the tool path, the server's message and schema, and the LLM's tool-call id. Two or more tool calls in flight on the same session cannot be told apart when one of them elicits (the protocol carries no correlation), so the question is answered `cancel` for it: a call the server then fails gets an error asking the model to call the tool on its own; a call the server answers normally (a server that treats a cancelled confirmation as "not swapped") succeeds with a note appended to its text result saying the question was auto-cancelled — every call that was in flight carries the note, since which one asked is unknowable. Prefer `approval=` for confirmations on tools the model is likely to call in parallel. URL-mode elicitations surface `payload["url"]`; resume with `True` once the out-of-band flow is complete. Set `elicitation=False` to hide the capability. Servers should then fail closed on tools that need confirmation rather than proceeding silently. > [!WARNING] > > **Stateless HTTP servers cannot do push elicitation or sampling.** A 2025-era server that issues no `Mcp-Session-Id` treats every POST as independent, so the client's answer lands on a transport that has no idea who asked and the server's `elicit()` waits forever. Timbal detects this at connect time (a warning is logged) and, when such a server asks, fails the tool call immediately with a clear error instead of hanging. Use `approval=` for confirmations against stateless servers. One case is invisible to any client: a server that sends the request without `related_request_id` drops it before it leaves the server; only `timeout=` bounds that. ## Server Notifications * **`tools/list_changed`** marks the cached tool list stale. It is refetched at the **next run**, never mid-run — swapping tools under the model's feet would also invalidate the prompt cache. * **Log messages** (`notifications/message`) are forwarded to structlog as `"MCP server log"` with `server`, `level`, `logger`, and `data`. * **Sampling** (`sampling/createMessage`) is served with `sampling_model=` when set. The call runs as an `mcp_sampling` LLM span under the in-flight tool call, so its usage shows up in the trace. ## 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, sorted by name (so the LLM's tools prefix stays stable for prompt caching), 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() ``` > [!WARNING] > > 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. Each connection is owned by a dedicated task, so `close()` can be called from any task and in any order across servers. Failure handling on top of the SDK: * **Transport dies mid-call** (server process exits, HTTP connection refused): the in-flight call fails immediately with `connection lost during tools/call ...` instead of hanging, the connection is forgotten, and the next call reconnects. The cached tools stay valid. * **Stale session** (an HTTP server restarted or expired the session while you were idle, so it 404s your `Mcp-Session-Id` with `Session terminated`): the session is reset and the request retried once, transparently. Safe for any request — a 404 means the server never saw it. * **Server unreachable**: `ConnectionError("MCP server 'name' failed to connect: ConnectError: ...")` with the real cause, not anyio's `unhandled errors in a TaskGroup`. * **Server accepts but never answers**: `timeout=` (default 600s, per tool via `tool_timeouts=`) turns it into a `TimeoutError`; the session stays up and the next call works. The handshake is bounded only by `connect_timeout=` (default `None`, but the TCP connect itself is capped at 30s). * **Slow HTTP tools**: the transport's read timeout is unbounded, so a `tools/call` that takes minutes completes. (A bare `httpx.AsyncClient` applied its 5-second default to the SSE read and the SDK swallowed the resulting `ReadTimeout`, so any HTTP tool slower than 5s hung the call forever.) Connect stays bounded at 30s (or `connect_timeout`) even when `connect_timeout=None`. A `tools/call` that loses its connection mid-flight is never retried automatically — the tool may already have run. `tools/list` is. ## 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 enables multi-turn conversations by loading history from a previous run's trace. Sequential calls in the same task can chain automatically. Across HTTP requests, processes, or explicitly separate tasks, pass `parent_id` to continue the intended conversation and use a tracing provider that can retrieve that prior run. ## 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 When the run is linked to a prior run, history resolution happens automatically. An unrelated request does not acquire a conversation merely because it invokes the same Agent object. ### Storage Options Memory storage depends on your deployment environment: * **Platform tracing**: Stores runs remotely so an authorized continuation can retrieve them across processes. * **In-memory tracing**: The local default; history disappears on process restart. * **JSONL or SQLite tracing**: Local persistence options for applications that configure them. ## 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 Configure the tracing provider for your deployment and keep run IDs with the conversation. The server also supports explicit continuation: ```python theme={"dark"} first = await agent(prompt="My name is David").collect() second = await agent(parent_id=first.run_id, prompt="What's my name?").collect() ``` > [!TIP] > > Run IDs identify continuation points; the host must authorize access to the conversation before accepting a client-supplied `parent_id`. See [Tracing](/core-concepts/tracing) and [Context](/core-concepts/context) for storage and context behavior. ## Interrupted turns A turn can end early: a voice barge-in cancels it, a timeout expires, or it fails. The next turn continues from what that turn reached: * **Cut after the agent assembled its memory** (while the model was running, or mid-reply): the earlier turns, the interrupted prompt, and the part of the reply that was produced. Voice sessions trim that reply to what the caller actually heard. * **Cut before** (while resolving the system prompt or loading the previous turn): the turn stores no memory. The next turn rebuilds it from the turn before plus the interrupted prompt, so it sees the same conversation as if the turn had been cut a moment later. Up to 8 consecutive turns like that are followed back. This holds on every built-in provider, with or without the platform trace cache. Rebuilding relies on the provider returning the run a trace continued from (`Trace.parent_id`); a custom provider that does not set it continues from the interrupted prompt alone. When the run a turn continues from cannot be loaded at all (never stored, deleted, or unreachable), the turn continues without that conversation. It logs an error and counts `memory:parent_missing` in the run's usage, so lost conversations can be monitored. ## 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 > [!NOTE] > > 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_offloaded`. 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_offloaded(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_offloaded`'s own output** — it is bounded by construction. ### Reading content back When offloading is active, the agent auto-registers a `read_offloaded` tool. It reads everything behind a handle: offloaded tool results, [offloaded attachments](#attachments), and compacted transcripts. ```python theme={"dark"} read_offloaded(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). Lines over 10,000 chars, such as minified JSON, are split into consecutive numbered segments so they can be paged too. * `pattern` filters to lines containing a **literal substring** (not a regex). A match spanning a segment boundary returns both segments; real newline boundaries are preserved. * Unknown or expired handles return a clean tool error the model can react to. * Earlier versions named it `read_tool_result`. Placeholders already stored in memory still say so, and calls to that name reach the same tool; it is not advertised to the model. 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"} from typing import Protocol 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. A handle is only readable while its payload exists, so the store must outlive the run that wrote it: later turns of the conversation still hold the handle. Set `TIMBAL_OFFLOAD_DIR` to move the default root wherever that holds. This matters when the home directory is thrown away with the process, as in containers and sandboxes. Timbal platform runs set it to the workforce's persistent directory. An explicit `LocalOffloadStore(root=...)` takes precedence. When a payload is gone anyway, `read_offloaded` returns an error telling the model not to retry the handle. 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. ### Attachments Files in a user message reach the model in one of two ways. Images, PDFs and audio are sent natively. Everything else (plain text, JSON, CSV, logs, code, HAR captures, and `.xlsx`/`.docx` after text extraction) is pasted into the prompt whole. One large upload can overflow the context window, and because the message stays in memory, every later turn of that conversation fails the same way. `attachment_limit` bounds those pasted attachments with the same `ToolResultLimit` config: ```python theme={"dark"} agent = Agent( name="my_agent", model="openai/gpt-4o-mini", tool_result_limit=20_000, attachment_limit=20_000, # attachments whose text reaches 20,000 chars are offloaded ) ``` The model sees a stand-in instead of the file, and reads the full text back with `read_offloaded`: ```text theme={"dark"} [Attached file offloaded: "export.json" (application/json), 3,754,724 chars. The full text was saved and can be read with read_offloaded(handle="attachments/ae87...") — page with offset/limit or filter with pattern.] Shape: {"log": object} Preview (first 1,000 of 3,754,724 chars): ... ``` Unlike tool results, attachments are reduced in **each request**, not when they enter memory: * **Memory and traces keep the file.** The `FileContent` the caller sent is what gets stored and replayed; only the request to the model differs. * **Existing conversations are fixed on their next call.** A chat that already holds an oversized upload is bounded as soon as the limit is configured, without rewriting stored history. * **Prompt-cache friendly.** The stand-in depends only on the file's text and the limit. The text is saved under a content hash and reused while its payload exists. If cleanup removes it, the next request restores it from the original attachment. With `LocalOffloadStore`, the restored handle and stand-in stay identical. `Spill` (default) shares the offload store with `tool_result_limit` and registers `read_offloaded` if it isn't already there. `Truncate` needs no store. If no store is available, the attachment is truncated rather than pasted whole. Images, PDFs, audio, `.eml` files and attachments under the threshold are always sent as before. Without `attachment_limit` (the default), a text attachment estimated to exceed the model's context window on its own (more than 4 characters per token of the window) is replaced by its first 20,000 chars and a note on every model call, and a warning is logged. This is a character-based heuristic: token density varies, so it can also shorten files that would fit, and it does not account for the combined size of other attachments, history, or output tokens. Use `attachment_limit` to explicitly bound attachments. Models with an unknown context window are left alone. ## 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 read from the previous run's **context measurement** — the usage of its last LLM call that reported context tokens — plus an estimate of the messages added after that call's request and output (the new prompt, and any tool results a failed call never sent). 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 same **context measurement**, plus an estimate of the tool results appended since. 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. Utilization is measured against the context window of the model the run calls: a per-run `model` input when given, otherwise the agent's `model`. For traces saved before context measurements were recorded, the first continuation uses the previous run's recorded token usage plus a content estimate of the full memory. This conservative fallback may compact early, but preserves file-token usage and counts content that was never measured. The next LLM call that reports context tokens replaces it with a measurement and its request boundary. Traces without any usable token usage use the content estimate alone. ```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 the context passes 75% of the window ) ``` 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_offloaded` 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, ), ) ``` > [!TIP] > > 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_offloaded` (`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. > [!NOTE] > > **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. ``` > [!NOTE] > > **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. > [!NOTE] > > `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`. > [!WARNING] > > 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. With no `provider`, `WebSearch()` defines native search for Anthropic and OpenAI/xAI Responses, and the model provider executes it. Chat Completions has no native search, so on those models (`timbal/auto`, Gemini, Groq, …) the agent searches through the Timbal platform instead; that needs a platform subject (a deployed app, or `TIMBAL_API_KEY` with an app). Domain filters are applied to the results there too. With `provider="tavily"`, `"scraperapi"`, `"cala"`, `"firecrawl"`, or `"google"`, it becomes an executable function tool usable with any tool-capable model. Configure the selected search service's credentials separately. Native search also falls back to the platform when the model can't apply the domain filters: OpenAI's `gpt-4.1-mini` and `gpt-4o-mini` (including dated snapshots) take none, OpenAI takes up to 100 domains per list, and xAI takes up to 5 and only one of `allowed_domains` / `blocked_domains`. That fallback also needs a platform subject; unfiltered searches stay native. To search without platform credentials, select an executable provider with your own key. ```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; native Anthropic uses `blocked_domains`, OpenAI/xAI Responses uses `filters.excluded_domains` * `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. For example, set `TAVILY_API_KEY` and use `WebSearch(provider="tavily")` with a Gemini or Groq agent. Executable mode resolves an explicit `api_key` first, then a platform integration, then the search provider's environment credentials. Google Custom Search additionally needs `GOOGLE_CSE_CX` or an explicit `cx`. #### 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] ) ``` > [!NOTE] > > **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. > [!WARNING] > > **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="api.timbal.ai" ``` The framework uses these values for platform requests. `TIMBAL_API_HOST` is a hostname, without `https://` or a path; the request helper adds HTTPS. Set both values before creating the run context. Some operations also need `TIMBAL_ORG_ID`, `TIMBAL_KB_ID`, or explicit resource IDs. > [!WARNING] > > 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. > [!NOTE] > > **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. Projects have environments for deployment configuration, including environment variables and deployed revisions. Use separate environments for development, staging, and production. Knowledge bases are separate organization resources; project deployment versions do not automatically version their data. ## 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 ``` > [!NOTE] > > 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. > [!NOTE] > > `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. > [!NOTE] > > 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 > [!NOTE] > > 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... ``` > [!TIP] > > 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. > [!NOTE] > > Organization admins will retrieve a list of all projects in the organization. Other users will retrieve a list of projects they have access to. > [!NOTE] > > 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). > [!NOTE] > > 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, ) ``` > [!NOTE] > > 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!" ``` > [!NOTE] > > **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 > [!TIP] > > 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. > [!NOTE] > > 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", ) ``` > [!NOTE] > > 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", status={"code": "success"}, t0=1640995200000, t1=1640995201000, output={ "role": "assistant", "content": [{"type": "text", "text": "Hello! How can I assist you today?"}] }, error=None ) ``` ## Handling Errors Handler errors are recorded in the runnable's final `OutputEvent`. Check the root result's `status` and `error`; a nested tool can fail while its agent recovers successfully. Construction errors, errors in your own event consumer, and task cancellation can still propagate outside this result contract. ```python theme={"dark"} result = await runnable(**kwargs).collect() if result.error: print(f"Error: {result.error['message']}") elif result.status.code == "cancelled": print(f"Run paused or cancelled: {result.status.reason}") ``` Execution errors use 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](/agents/tools)** - Function wrappers with automatic schema generation and explicit parameter control * **[Agents](/agents)** - Autonomous execution units that orchestrate LLM interactions with tool calling * **[Workflows](/workflows)** - Programmable execution pipelines that orchestrate step-by-step processing Names identify runnables in tracing, workflows, and agent tools. Give Agents and Workflows an explicit `name`. A Tool can infer its name from the handler function; lambdas and callable objects need an explicit name. Names must be unique among sibling tools or workflow steps, and nested paths distinguish names in separate parents. 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() print(result.output) # 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() print(result.output) # 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() print(result.output) # A number between 5 and 15 ``` Note that runtime parameters always override default values, including callable defaults: ```python theme={"dark"} result = await add_tool(a=5, b=10).collect() print(result.output) # 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 stream execution notifications such as starts, content deltas, pauses, and completion. They do not themselves persist conversation memory. Applications can consume or record them, and the HTTP job store and background-task APIs retain bounded event logs. Traces capture span input, output, errors, timing, and usage through the configured tracing provider. ## 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_text_tokens": 32, "openai/gpt-4o-mini:output_text_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. ### When a save fails The trace is saved as each span completes. If the provider fails to store a snapshot, for example the platform rejecting a run whose parent it has no record of, or an outage that outlasts the retries, the run still completes and returns its output. The failure is logged as an error and counted as `tracing:save_failed` in the run's usage. Cancellation is not caught. ## Platform trace cache `PlatformTracingProvider` caches traces this process successfully saves, so a following agent or workflow run can load its parent's trace without another platform request. This also applies to voice turns. The cache holds up to 256 distinct runs and 64 MiB of serialized traces across the process by default; saving the same run again replaces its snapshot and refreshes its expiry. A failed save drops that run's cached snapshot, so the next read goes to the platform. When saves of the same run overlap, the most recently taken snapshot decides what is cached, whichever request finishes last, even if its cached copy has since been evicted. Configure the cache with environment variables: ```bash theme={"dark"} export TIMBAL_TRACE_CACHE_MAX_ENTRIES=64 export TIMBAL_TRACE_CACHE_TTL_SECS=60 ``` * `TIMBAL_TRACE_CACHE_MAX_ENTRIES`: maximum number of cached runs, a non-negative integer. Default: `256`. * `TIMBAL_TRACE_CACHE_TTL_SECS`: seconds since the last successful save, a non-negative finite number. Default: `300`. Reading a trace does not extend its lifetime. Setting either value to `0` disables caching and clears existing entries on the next cache access. Invalid values produce one warning per distinct value and fall back to the corresponding default. Settings are read on each cache access. Cache reads and writes remove expired entries, including entries for other runs. Cleanup is lazy: an idle process retains entries until the next cache access. When either limit is exceeded, the least recently saved runs are evicted; a single trace larger than the byte limit is not cached. Eviction only removes the local copy; traces remain stored on the platform, and cache misses fetch them there. Traces fetched from the platform are not added to this cache. A read of a run this process is still saving waits up to 2 seconds for the saves already in flight, with or without the cache: until a PATCH lands, the platform may answer 404 or an older snapshot. This covers a voice turn whose save is still on the wire when the next turn starts. Entries are scoped by host, organization, app, token, and run ID, and each read returns an independent copy. Updates made by another process can remain invisible until the cached entry expires or is evicted; lower the TTL or disable caching when those updates must be visible immediately. # 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. > [!NOTE] > > `.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`. > [!TIP] > > 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). ### Syncing with the platform: `timbal env pull` / `push` For projects connected to the [Timbal Platform](/deployment/platform), `timbal env` keeps the local files above in sync with the project's variables for one environment (the env tracking your current branch, `--rev `, or `--default`). The CLI detects your current Git branch automatically. Variable types and secret recovery use the environment tracking that branch, including variables shared across all environments. Use `--rev` or `--default` only when you want to override the current branch. ```bash theme={"dark"} timbal env pull # platform → /.env (+ TIMBAL_APP_ID per member) timbal env push # /.env → platform (upsert only) timbal env push --dry-run # show the plan, send nothing timbal env push --secret DB_URL --plain LOG_LEVEL ``` What lands where: | Var | Written to | Why | | - | - | - | | Your project vars (`OPENAI_API_KEY`, ...) | `/.env` | Loaded into every service by `timbal start` | | `TIMBAL_APP_ID` | `workforce//.env` | Per workforce member; matched via the `_id` in that member's `timbal.yaml`. Merged into the file — other lines are untouched | | Platform runtime vars (`TIMBAL_*`, `VITE_TIMBAL_*`) | `/.env`, **commented out** (`--include-platform-vars` writes them active) | The platform resolves these before every deploy/preview. Active locally, `TIMBAL_PROJECT_ENV_ID` reroutes service calls to the deployed gateway instead of localhost. **Push never sends `TIMBAL_*` vars**, whatever the file or flags say | Secrets vs plain: a `# type: secret` (or `# secret`) comment directly above a var marks it as a secret, `# type: plain` (or `# plain`) as plain. A var without a comment keeps its current type on the platform; a brand-new var is inferred from its name and value (`*_KEY`, `*_TOKEN`, `*_PASSWORD`, `sk-...`, `user:pass@` URLs → secret). `--secret NAME` / `--plain NAME` override everything. The platform fixes a var's type when it is created — pushing an existing var only updates its value — so change types in the platform UI. Push refuses a file that marks a platform secret as plain unless you pass `--plain NAME` explicitly. The push plan always lists every var with its resolved type and where that decision came from. > [!WARNING] > > Pulled files contain secrets in plaintext — keep `.env` files gitignored (`timbal create` does this). `PORT`, `TIMBAL_PROJECT_SECRET`, and `TIMBAL_APP_ID` are never pushed as project vars. `TIMBAL_APP_ID` remains active in each member's local `.env`. On POSIX systems, new env files are created with owner-only permissions, and atomic updates preserve existing file permissions. Pull attempts to recover secrets whose values were omitted or returned as placeholders; values that remain unavailable stay commented out so a later push cannot erase them. ## Production For production you have two paths: > **[Timbal Platform](/deployment/platform)** > > Managed hosting — connect your git repo, deploy a branch, platform runs UI, API, and workforce for you > **[Self-hosted](/deployment/self-hosted)** > > Run UI, API, and workforce on your own infra — you own process lifecycle, ports, env wiring, and logging > [!TIP] > > 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 with `timbal push` and deploy from the platform. ## Prerequisites * A project scaffolded with [`timbal create`](/quickstart#create-a-timbal-project) * [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`) **1. Create and push your project** Scaffold locally, develop with `timbal start`, then commit and push: ```bash theme={"dark"} timbal create my-project cd my-project timbal configure git add . git commit -m "Initial project" timbal push ``` **2. Open your linked project** On the first push, the CLI creates a project in your configured organization and adds a `timbal` Git remote. The platform default branch remains `main`; the CLI pushes whichever branch you have checked out. Existing Timbal remotes are reused, and GitHub remotes stay intact. Open the project in the [Timbal Platform](https://app.timbal.ai). Pushing discovers your components and provisions new branch environments. Pushes to an existing environment follow the platform’s normal deployment behavior. **3. Configure secrets** 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`). **4. Deploy** 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. ## Pushing code `timbal push` uploads the current branch's commits using Git. It leaves uncommitted files alone and preserves your existing upstream. To also set the Timbal remote as the branch's upstream, use `timbal push -u`. ```bash theme={"dark"} # First push: optional project name and organization overrides timbal push --name my-project --org 123 # Select credentials and API host from a configured profile timbal push --profile dev # Preview without creating a project or linking a remote timbal push --dry-run ``` The default project name is the repository directory name. `--name` and `--org` apply only to the first push. Profile selection follows `--profile`, then `TIMBAL_PROFILE`, then `default`, for both project creation and Git authentication. A failed upload keeps the project link, so `timbal push` retries the same project. For a project created by this command, the initial push to `main` replaces the generated scaffold using an exact-SHA `--force-with-lease`. The CLI verifies the scaffold is a single initial commit and saves its SHA before pushing; retries never refresh the lease. A concurrent update causes rejection. Existing linked projects and subsequent pushes use normal Git behavior. Common Git options such as `--force-with-lease`, `--porcelain`, and `--follow-tags` are supported; run `timbal push --help` for the full list. The command always targets the current branch; use Git directly for other refspecs. Automatic creation uses the existing minimal `Scratch` scaffold without server-generated workforce members. Your committed UI and workforce code are uploaded by Git; the project’s `has_ui` metadata retains the creation default because the existing metadata API does not expose that field. If several Timbal remotes exist, name the intended one `timbal`. A remote named `timbal` that points elsewhere must be renamed before automatic linking can proceed. Environment variables are separate from code: use `timbal env push` to upload them and `timbal env pull` to configure your local environment after the first code push. ## `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://api.timbal.ai/orgs/{org_id}/apps/{app_id}/runs/collect POST https://api.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). > [!TIP] > > 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 ``` An Agent also exposes `/voice`, `/voice/ws`, and `/voice/rtc`. Install `timbal[server,voice]` for browser voice and WebRTC, or add `voice-livekit` for LiveKit rooms. Voice connections are long-lived: configure WebSocket upgrades, connection timeouts, and the appropriate media networking in your reverse proxy. See [Voice transports](/voice/transports) for carrier routes and ICE/TURN configuration. ### 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). > [!WARNING] > > 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"] 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 > **[Validators](/evals/validators)** > > 20+ validators for checking outputs, patterns, types, and more > **[Flow Validators](/evals/validators/flow)** > > Validate execution sequences and parallel tool calls > **[LLM Validators](/evals/validators/llm)** > > AI-powered semantic validation for natural language > **[CLI](/evals/running-evals)** > > Command-line interface for running and discovering evals ## Prerequisites Install `timbal[evals]` for the CLI. Configure the model provider keys used by your runnable and any LLM validators, such as `OPENAI_API_KEY`. Local evals do not require a Timbal Platform account. If the runnable uses platform services, also configure `TIMBAL_API_KEY`, `TIMBAL_API_HOST` (hostname without a scheme), and the resource IDs that service needs. 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` | Accepted but currently not enforced; use the runnable's timeout and a CI/process deadline | No | | `output` | Validators for the final output | No | | `elapsed` | Validators for total execution time | No | | `seq!` | Sequence flow validator | No | > [!NOTE] > > 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 > **[Writing Evals](/evals/writing-evals)** > > Learn the full eval syntax and best practices > **[Validators Reference](/evals/validators)** > > Complete reference for all validators > **[Running Evals](/evals/running-evals)** > > 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 | Configuration and setup errors also exit with code `1`; the CLI does not use a distinct setup-error code. ## 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 ".[evals]" - 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 ".[evals]" - 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 > **Organize evals by speed** > > 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/ > ``` > **Use meaningful exit codes** > > 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 > ``` > **Set timeouts** > > Set a runnable timeout in seconds to bound the agent turn: > > ```python agent.py theme={"dark"} > from timbal import Agent > > agent = Agent(name="assistant", model="openai/gpt-4.1-mini", timeout=60) > ``` > > The eval YAML's `timeout` field is currently accepted but not enforced by the runner. Do not rely on it to stop a hung evaluation. A runnable timeout bounds the run; external judge calls and the overall CLI job need their own process or CI deadline. > **Use tags for filtering** > > 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. > [!NOTE] > > 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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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}" > ``` > [!NOTE] > > 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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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) | > **Numeric comparison examples** > > ```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 ``` > **Wildcard examples** > > ```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 | > **any! examples** > > ```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 > [!TIP] > > Use `seq!` with wildcards (`...`) for flexible matching when you care about the order of key operations but not every intermediate step. > [!NOTE] > > Flow validators require execution tracing to be enabled. Tracing is automatically enabled when running evals. > [!WARNING] > > 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 > **[Comparison](/evals/validators/comparison)** > > String matching, patterns, and equality checks > **[Type](/evals/validators/type)** > > Type checking, JSON, and format validation > **[Length](/evals/validators/length)** > > Length constraints and bounds > **[LLM](/evals/validators/llm)** > > AI-powered checks for claims, meaning, and language > **[Flow](/evals/validators/flow)** > > 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 ``` > [!NOTE] > > 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. > [!NOTE] > > 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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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. > [!NOTE] > > 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. > [!TIP] > > `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. > **Examples** > > ```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 > [!TIP] > > 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 ``` > [!TIP] > > 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`. > [!NOTE] > > 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. > [!NOTE] > > 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. > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```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.5-flash" ``` > [!WARNING] > > 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. > [!NOTE] > > 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 | > **Examples** > > ```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 > ``` > [!TIP] > > 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 | > **Examples** > > ```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 | > **Examples** > > ```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 | > **Examples** > > ```yaml theme={"dark"} > # Ensure output is valid JSON > output: > json!: true > > # Combine with content checks > output: > json!: true > contains!: '"status"' > contains!: '"success"' > ``` > [!NOTE] > > 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 | > **Examples** > > ```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 ``` > [!WARNING] > > 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 params: prompt: "Hello" output: prompt!: "The response greets the user in a friendly way" elapsed: lt!: 5000 ``` The model accepts `timeout` and `env` fields, but the current runner does not enforce the timeout or apply per-eval environment variables. Configure credentials in the process environment or a loaded `.env`, and set timeouts on the runnable. See [Running evals](/evals/running-evals). ## 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. > [!WARNING] > > 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 ``` > [!NOTE] > > 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"] 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 > **Use descriptive names** > > 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 > ``` > **Combine validators effectively** > > 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" > ``` > **Validate both output and execution** > > 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 > ``` > **Use LLM validators for natural language** > > 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"`. > **Test edge cases** > > 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`). > [!WARNING] > > 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()) ``` > [!NOTE] > > 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. For a live microphone conversation with streaming responses and interruptions, see [Voice Agents](/voice). > [!NOTE] > > 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() ``` > [!WARNING] > > 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="google/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()) ``` > [!WARNING] > > 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 ``` > [!NOTE] > > 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()) ``` > [!WARNING] > > 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 Use provider-native web search or an executable search service with any tool-capable model Agents can search the web using the built-in `WebSearch` tool. Native `WebSearch()` runs on the model provider with Anthropic and OpenAI/xAI Responses, and through the Timbal platform on every other tool-capable model (`timbal/auto` included). Select an executable search provider to use your own search credentials instead. Domain-filtered searches also use the platform fallback when the model can't apply the filters natively (`openai/gpt-4.1-mini`, `openai/gpt-4o-mini`, or xAI with both lists or more than 5 domains). This requires a platform subject; unfiltered searches stay native. > [!NOTE] > > 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() ``` ## Search with another model provider Set `TAVILY_API_KEY` and use an executable search tool: ```python theme={"dark"} agent = Agent( name="search_assistant", model="google/gemini-2.5-flash", tools=[WebSearch(provider="tavily")], system_prompt="Search for current information and cite the sources you use.", ) ``` Other executable selectors are `scraperapi`, `cala`, `firecrawl`, and `google`. See [Tools](/agents/tools#websearch) for credential resolution and native search constraints. ## 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 and Hybrid Search Source: https://docs.timbal.ai/examples/agents/semantic-search Give an agent a parameterized search tool over an existing Timbal knowledge base Create or connect a knowledge base and ingest your documents in the Timbal Platform first. Inspect its schema to find the table and columns to search. This example assumes an indexed `articles` table; replace that fixed table name with your own. Configure platform access before the first call: ```bash .env theme={"dark"} TIMBAL_API_HOST=api.timbal.ai TIMBAL_API_KEY=your_platform_token TIMBAL_ORG_ID=your_org_id TIMBAL_KB_ID=your_knowledge_base_id OPENAI_API_KEY=your_openai_key ``` The Python helper is `timbal.platform.knowledge_bases.query`. It accepts SQL and positional parameters and resolves organization/knowledge-base IDs from explicit keywords or the environment. Platform authentication also needs the API host and token, or existing Timbal credentials. ```python theme={"dark"} import asyncio from dotenv import load_dotenv from timbal import Agent from timbal.platform.knowledge_bases import query load_dotenv() async def search_articles(question: str) -> dict: """Search the indexed articles table by meaning and keywords.""" return await query( "SELECT * FROM hybrid_search('articles', $1, $2, 8)", [question, question], ) agent = Agent( name="research_assistant", model="openai/gpt-4.1-mini", system_prompt=( "Use search_articles to answer questions about our documentation. " "Base your answer on retrieved records and say when the records are insufficient." ), tools=[search_articles], ) async def main(): result = await agent(prompt="How do I configure authentication?").collect() if result.error: raise RuntimeError(result.error["message"]) print(result.output.collect_text()) if __name__ == "__main__": asyncio.run(main()) ``` Search-position string parameters are embedded by the platform for vector retrieval. Hybrid search takes a vector query and a full-text query, so pass the same question twice to combine semantic and keyword matching. Use `vector_search('articles', $1, 8)` for semantic-only retrieval or `fts_search('articles', $1, 8)` for keyword-only retrieval. Keep table identifiers fixed in application code and pass user text through `$1`, `$2`, and `params`. An unindexed or nonexistent table needs ingestion/index setup; the tool does not create it. See the [Knowledge base query API](/api-reference/knowledge-bases/query) for the request contract. # SQL Queries and Data Management Source: https://docs.timbal.ai/examples/agents/sql-queries Expose parameterized knowledge-base queries and explicit data operations as agent tools Use `timbal.platform.knowledge_bases.query` to execute SQL against a Timbal knowledge base. Configure `TIMBAL_API_HOST`, `TIMBAL_API_KEY`, `TIMBAL_ORG_ID`, and `TIMBAL_KB_ID` before creating the run context, or supply an authenticated platform configuration. See [Semantic search](/examples/agents/semantic-search) for a complete environment example. Create the `tickets` table through your database setup or migration process first. This example assumes `customer_id` and `priority` columns. The agent receives specific operations rather than a tool accepting arbitrary SQL: ```python theme={"dark"} import asyncio from dotenv import load_dotenv from timbal import Agent, Tool from timbal.platform.knowledge_bases import query load_dotenv() async def count_high_priority_tickets(customer_id: str) -> dict: """Count high-priority tickets for a customer.""" return await query( "SELECT count(*) AS count FROM tickets WHERE customer_id = $1 AND priority = $2", [customer_id, "high"], ) async def add_ticket(customer_id: str, priority: str) -> dict: """Insert a support ticket into the configured tickets table.""" return await query( "INSERT INTO tickets (customer_id, priority) VALUES ($1, $2) RETURNING *", [customer_id, priority], ) agent = Agent( name="support_analyst", model="openai/gpt-4.1-mini", system_prompt="Use the ticket tools to record support requests and answer ticket-count questions.", tools=[ count_high_priority_tickets, Tool(handler=add_ticket, requires_approval=True), ], ) async def main(): result = await agent(prompt="How many high-priority tickets does customer 103 have?").collect() if result.error: raise RuntimeError(result.error["message"]) print(result.output.collect_text()) if __name__ == "__main__": asyncio.run(main()) ``` `query(sql, params, org_id=..., kb_id=...)` targets the current K2 endpoint and returns parsed response JSON, typically with a `rows` list. Set `legacy=True` only for an existing legacy knowledge base. The removed `timbal.platform.kbs.tables` and embedding-management helpers are not part of the current Python package. User values belong in positional parameters, while table and column identifiers stay fixed in code. The sample insert tool requires approval; handle and resume it as described in [Approval gates](/human-in-the-loop/approval-gates). Query counts depend on the records already in your database. # Text-to-Speech Source: https://docs.timbal.ai/examples/agents/tts Generate audio with text-to-speech tools or use OpenAI and Deepgram in live voice sessions For an end-to-end spoken conversation, start with the [Voice quickstart](/voice/quickstart). This page covers individual speech providers and audio-generation tools. ## OpenAI speech providers Set `OPENAI_API_KEY` on the server and install `timbal[server,voice]`: ```python theme={"dark"} from timbal import Agent agent = Agent( name="VoiceAgent", model="openai/gpt-4.1-mini", voice_config={ "stt_provider": "openai", "stt_model": "gpt-transcribe", "tts_provider": "openai", "tts_model": "gpt-4o-mini-tts", "voice": "coral", "language": "en", }, ) ``` These are independent STT/TTS providers in the regular Timbal voice pipeline: the agent still chooses its own LLM and tools. You can mix OpenAI transcription with ElevenLabs or Deepgram speech output, or select OpenAI only for TTS. WebSocket, WebRTC, LiveKit and telephony sessions use the same provider wiring. Both selectors are available in the voice playground; the advanced STT model field accepts an explicit transcription model. `OpenAIRealtimeSTT` uses a transcription-only Realtime WebSocket session. The default is `gpt-transcribe`. Legacy `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` (including its 2025-03-20 and 2025-12-15 snapshots), and `whisper-1` are also accepted. This adapter uses **server VAD**, keeping Timbal's turn detector active. It supports local force-commit, accumulates partial transcripts, and delivers completed transcripts in audio-turn order. `gpt-live-transcribe` and `gpt-realtime-whisper` need client-side VAD and are explicitly rejected, as are diarization models. Legacy transcription models are [deprecated](https://developers.openai.com/api/docs/deprecations); new integrations should use the default. STT tuning lives in `stt_extra`: `prompt`, `threshold` (default `0.5`), `prefix_padding_ms` (`300`), `silence_duration_ms` (`500`), and `noise_reduction` (`"near_field"`, `"far_field"`, or `None`). The session's `language` is an optional ISO-639-1 transcription hint. `OpenAIStreamTTS` uses the Speech API with streaming PCM audio. It defaults to `gpt-4o-mini-tts` and `coral`; `tts-1` and `tts-1-hd` are also supported. Set `voice` to a built-in voice such as `alloy`, `coral`, `marin`, or `cedar` (`tts-1` / `tts-1-hd` support only `alloy`, `ash`, `coral`, `echo`, `fable`, `onyx`, `nova`, `sage`, and `shimmer`). Invalid OpenAI model ids and unsupported model/voice combinations fail before opening a request. `OPENAI_TTS_VOICE` sets the fallback voice when a foreign provider's voice id is inherited. Foreign model ids are replaced with the OpenAI defaults. Use `tts_extra={"speed": 1.1, "instructions": "Speak warmly and clearly."}` for speech style; `instructions` applies only to `gpt-4o-mini-tts`. The speech endpoint accepts complete text segments, so Timbal synthesizes each segment as it becomes ready rather than opening an incremental text context. Interrupting a segment closes its response; subsequent segments have fresh audio/resampling state. Each segment makes a separate HTTP request; this can introduce pauses and has different latency from ElevenLabs incremental text streaming. Benchmark with your own call audio before choosing a model. OpenAI's wire audio is 24 kHz PCM16. The providers resample in both directions to the configured session sample rate, including the default 16 kHz. Rates other than 24 kHz require the `timbal[voice]` extra. PCM framing and resampler tails are preserved so playback and recordings use the advertised rate. Server-only `stt_host` / `tts_host` extras can select an API-compatible endpoint; clients cannot override the host. Credentials stay on the server. For file transcription or standalone audio generation, use the built-in tools: ```python theme={"dark"} from timbal.tools import OpenAISpeechToText, OpenAITextToSpeech transcript = await OpenAISpeechToText()( audio_file_path="recording.wav", language="en", ).collect() audio = await OpenAITextToSpeech()( text="Hello from Timbal!", voice="coral", response_format="mp3", ).collect() ``` Both tools also work in `Agent(tools=[...])` and accept explicit `api_key`, platform `integration`, or `OPENAI_API_KEY`. `OpenAISpeechToText` accepts exactly one local path or base64 source (with `filename` for base64), returning JSON with `text`. `OpenAITextToSpeech` returns `audio_base64`, `content_type`, `model`, `voice`, and `response_format`. Formats: MP3, Opus, AAC, FLAC, WAV or raw PCM. Callers should identify generated speech as AI-generated in their user experience. Protocol references: [Realtime transcription](https://developers.openai.com/api/docs/guides/realtime-transcription), [Realtime session events](https://developers.openai.com/api/reference/resources/realtime/client-events), and [text to speech](https://developers.openai.com/api/docs/guides/text-to-speech). ## Deepgram Aura Set `DEEPGRAM_API_KEY` on the server. For live conversations, install `timbal[server,voice]` and select Deepgram in your agent's voice configuration: ```python theme={"dark"} from timbal import Agent agent = Agent( name="VoiceAgent", model="openai/gpt-4.1-mini", voice_config={ "stt_provider": "deepgram-flux", "tts_provider": "deepgram", "tts_model": "aura-2-thalia-en", }, ) ``` The same configuration works through the voice server's WebSocket, WebRTC, LiveKit, and telephony transports. STT and TTS are independent: you can keep ElevenLabs transcription and use Deepgram for speech output. The voice playground also includes a **Deepgram Aura** provider option. Aura combines the voice and language into its model id. `tts_model` takes precedence; a full Aura id in `voice` is also accepted when `tts_model` contains another provider's default. Otherwise, `DEEPGRAM_TTS_MODEL` is used, falling back to `aura-2-thalia-en`. Select the appropriate language's voice explicitly; the `language` setting controls transcription and does not change an Aura voice. See the [voice catalog](https://developers.deepgram.com/docs/tts-models). Live output is raw PCM16 at the session's sample rate (8000, 16000, 24000, 32000, or 48000 Hz). Each reply streams text over its own connection and flushes once at the end. Interrupting a reply closes its socket and drops its queued audio. Greeting, filler, and reply streams are isolated. Each new reply pays a WebSocket handshake; no Deepgram SDK is required. This adapter uses Aura's `/v1/speak` protocol, not the separate Flux TTS `/v2/speak` API. Optional server configuration: `tts_extra={"speed": 1.1}` adjusts speaking rate where supported. `tts_extra` also accepts `mip_opt_out` and a server-only `tts_host` for regional/self-hosted endpoints (for example, `api.eu.deepgram.com`). Client overrides cannot change the host or opt-out policy. For standalone audio generation and voice discovery, add the built-in tools: ```python theme={"dark"} from timbal import Agent from timbal.tools import DeepgramListVoices, DeepgramTextToSpeech agent = Agent( name="Narrator", model="openai/gpt-4.1-mini", tools=[DeepgramListVoices(), DeepgramTextToSpeech()], ) # Tools also work directly, without an agent: voices = await DeepgramListVoices()(language="es").collect() audio = await DeepgramTextToSpeech()( text="Hello from Timbal!", model="aura-2-thalia-en", encoding="mp3", ).collect() ``` `DeepgramListVoices` returns Aura model ids, languages and provider metadata (including voice sample URLs). `DeepgramTextToSpeech` returns `audio_base64`, `content_type`, `model`, and `encoding`. REST generation supports MP3, linear16, mu-law, A-law, Opus, FLAC and AAC; only pass sample rate, bitrate and container options supported by the selected format. Both tools support explicit `api_key`, platform `integration`, or `DEEPGRAM_API_KEY` credential resolution, following the same precedence as the ElevenLabs tools. For a custom voice pipeline, import `DeepgramStreamTTS` from `timbal.voice` and pass it as the `tts` provider to `VoiceSession`. ## Custom OpenAI tool Agents can also generate audio using a custom text-to-speech tool: ```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 > **Installing Packages** > > Install the required Google API client libraries: > > ```python theme={"dark"} > google-api-core > google-api-python-client > google-auth > google-auth-oauthlib > ``` > **Enabling Gmail API to your account** > > **1. Create a Google Cloud Project** > > Go to the [Google Cloud Console](https://console.cloud.google.com/) and create a new project or select an existing one. > > **2. Enable Gmail API** > > 1. Navigate to **APIs & Services** > **Library** > 2. Search for "Gmail API" > 3. Click on **Gmail API** and then **Enable** > > **3. Choose Authentication Method** > > 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 > > > [!WARNING] > > > > **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** > > > [!WARNING] > > > > 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: **OAuth 2.0** ```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 ``` **Service Account** ```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 > **Authentication Issues** > > * **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 > **API Connection Problems** > > * **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 > **Code Issues** > > * **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 > **Installing Packages** > > Install the Slack SDK: > > ```bash theme={"dark"} > pip install "timbal[server]" slack-sdk > ``` > > > [!NOTE] > > > > For all available Slack API methods and capabilities, see the [Slack API Methods Reference](https://docs.slack.dev/reference/methods). > **Creating a Slack App** > > **1. Create a Slack App** > > 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 > > **2. Configure Bot Token Scopes** > > 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 > ``` > > > [!NOTE] > > > > After adding or modifying scopes, you'll need to reinstall the app to your workspace for the changes to take effect. > > **3. Install App to Workspace** > > 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` > > **4. Enable Home Tab (for Direct Messages)** > > 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 > > **5. Get Bot User ID** > > 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` > > > [!NOTE] > > > > 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. > [!NOTE] > > **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"].get("event") or {} # Ignore bot's own messages to prevent infinite loops if slack_event.get("bot_id") or slack_event.get("user") == SLACK_BOT_USER_ID: bail() # Extract and validate message text if slack_event.get("type") not in ("message", "app_mention"): bail() text = slack_event.get("text", "") if not text: 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 Combine the snippets into these files for local development. Thread timestamps control where replies appear; maintaining history per thread additionally requires storing each thread's latest run ID and passing it as `parent_id` with a suitable tracing provider. See [Memory](/agents/memory). ```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"].get("event") or {} # Prevent infinite loops by ignoring bot's own messages if slack_event.get("bot_id") or slack_event.get("user") == SLACK_BOT_USER_ID: bail() # Extract message text if slack_event.get("type") not in ("message", "app_mention"): bail() text = slack_event.get("text", "") if not text: 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 This example uses `pyngrok` to expose the FastAPI app during local development. For production, verify Slack request signatures, deduplicate event IDs, acknowledge before Slack's request deadline, and process events in a durable worker. **1. Set up ngrok account and authtoken** 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 ``` **2. Create FastAPI Webhook Handler** 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) ``` **3. Run the Server and Get ngrok URL** 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`) **4. Enable Event Subscriptions** 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) ``` 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 Replies**: Sends replies to the incoming message's thread; history requires explicit run chaining * **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 > **Bot Not Responding** > > * 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 > **Infinite Loop Issues** > > * 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 > **Agent Not Processing Messages** > > * 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 > **Server Won't Start** > > * 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 text WhatsApp assistant with webhooks and conversation persistence, then extend it to media 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 messages and preserve metadata for audio, images, and documents The runnable example answers text messages. Media IDs and filenames are placeholders until you add downloading and transcription or file analysis. Install `timbal[server]` and `httpx`; add `pyngrok` only if you use the optional tunnel helper. This demonstration processes the agent inside the webhook request. For production, authenticate webhooks, deduplicate message IDs, acknowledge promptly, and process through a durable queue. ## Setup Requirements > **Meta WhatsApp App and Webhook** > > **1. Create or open your WhatsApp App** > > Go to the Meta console and open your app's WhatsApp section: [Meta WhatsApp App Dashboard](https://developers.facebook.com/apps/). > > **2. Set Callback URL and Verify Token** > > 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** > > **3. Subscribe to fields** > > In the WhatsApp product settings, subscribe to message-related events (e.g., messages, statuses). > **Environment Variables** > > 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 > TIMBAL_API_HOST=api.timbal.ai > TIMBAL_API_KEY=your_timbal_key > > # WhatsApp API > WHATSAPP_ACCESS_TOKEN=your_long_lived_token > WHATSAPP_PHONE_NUMBER_ID=your_phone_number_id > # Set a Graph API version supported by your Meta app, including the v prefix > WHATSAPP_GRAPH_API_VERSION=your_supported_version > > # Webhook verification > WHATSAPP_VERIFY_TOKEN=your_verify_token > > # Server > HOST=0.0.0.0 > PORT=4343 > > # Optional: enable ngrok tunnel > ENABLE_NGROK=true > ``` > > > [!NOTE] > > > > The main example stores JSONL. The optional KB section replaces that storage; setting environment variables alone does not switch it. Replace `your_supported_version` with your app's Graph API version. ## 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 import os import uvicorn from dotenv import load_dotenv load_dotenv() from agent import agent 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 VERIFY_TOKEN and 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_NGROK", "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 datetime import datetime import httpx from timbal import Agent from timbal.errors import bail from timbal.state import get_run_context async def send_whatsapp_message(to: str, message: str) -> dict: version = os.environ["WHATSAPP_GRAPH_API_VERSION"] phone_id = os.environ["WHATSAPP_PHONE_NUMBER_ID"] async with httpx.AsyncClient() as client: response = await client.post( f"https://graph.facebook.com/{version}/{phone_id}/messages", headers={"Authorization": f"Bearer {os.environ['WHATSAPP_ACCESS_TOKEN']}"}, json={"messaging_product": "whatsapp", "to": to, "type": "text", "text": {"body": message}}, timeout=10, ) response.raise_for_status() return response.json() 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 = _value(payload) 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") or {}) if contacts else {} name = profile.get("name", "") msg = messages[0] from_number = msg.get("from") message_id = msg.get("id") phone_number_id = (value.get("metadata") or {}).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"} async 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 await send_whatsapp_message(to=from_number, message=response_text) _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 > **Show example system\_prompt** > > ```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 ![Image](url). 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 Timbal Knowledge Bases instead of JSONL, use the async SQL helper below and replace the storage calls in async pre/post hooks. Create the `whatsapp_messages` table with the listed columns first. The helper targets K2; pass `legacy=True` for a legacy KB. User values are SQL parameters. ```python theme={"dark"} import json import os from timbal.platform.knowledge_bases import query ORG_ID = os.environ["ORG_ID"] KB_ID = os.environ["KB_ID"] async def save_record_to_kb(record: dict) -> None: await query( """INSERT INTO whatsapp_messages (id, user_phone, direction, message_type, message, timestamp, conversation_id, user_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)""", params=[record.get(key) for key in ( "id", "user_phone", "direction", "message_type", "message", "timestamp", "conversation_id", "user_name", )], org_id=ORG_ID, kb_id=KB_ID, ) async def fetch_recent_history_from_kb(user_phone: str, limit: int = 10) -> list[str]: result = await query(""" SELECT direction, message FROM whatsapp_messages WHERE user_phone = $1 ORDER BY timestamp DESC LIMIT $2 """, params=[user_phone, limit], org_id=ORG_ID, kb_id=KB_ID) lines: list[str] = [] for r in reversed(result.get("rows", [])): 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 additional webhook and utility examples, see the Timbal repository. These are separate examples and may use different configuration from this guide: * [timbal/examples/whatsapp](https://github.com/timbal-ai/timbal/tree/main/examples/whatsapp) ## Possible Improvements > **Delay response to batch context (debounce)** > > Each WhatsApp webhook starts a separate agent run. 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 run 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 asyncio, os, json > > 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 > > async 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 > await 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) > ``` > **Mark inbound message as read** > > 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/{os.environ['WHATSAPP_GRAPH_API_VERSION']}/{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. > **Progress updates (typing indicator and ack)** > > 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. > > This request follows [Meta's typing indicator example](https://www.postman.com/meta/whatsapp-business-platform/request/lhf0duq/send-typing-indicator-and-read-receipt). The synchronous helper below should run in a worker, rather than block an async webhook handler. > > ```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/{os.environ['WHATSAPP_GRAPH_API_VERSION']}/{phone_id}/messages" > headers = { > "Authorization": f"Bearer {token}", > "Content-Type": "application/json", > } > > with httpx.Client() as client: > # Mark as read and display the typing indicator in one request > resp = client.post( > url, > headers=headers, > json={ > "messaging_product": "whatsapp", > "status": "read", > "message_id": message_id, > "typing_indicator": {"type": "text"}, > }, > timeout=10, > ) > resp.raise_for_status() > return resp.json() > ``` > > * Call this right after parsing the webhook and before long operations. > * Keep interim notifications minimal to avoid noise. > **Persist and reuse media in the KB** > > 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 > **Webhook verification fails** > > * **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 > **Messages not sent** > > * **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 > **No context/history** > > * 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 Guides for connecting agents to services and building spoken conversations. Start with the local examples, then add the authentication, persistence, and deployment behavior your application needs. > **[Voice Agent](/voice/quickstart)** > > Start with a browser conversation, then add greetings, tools, thinking sounds, fillers, and phone calls one lesson at a time > **[Mail Assistant](/examples/guides/mail-assistant)** > > AI-powered Gmail integration that triages emails and autonomously drafts intelligent responses > **[Slack Bot](/examples/guides/slack-bot)** > > Build a Slack bot that responds in real time via webhooks, with an AI agent and threaded conversations > **[WhatsApp Assistant](/examples/guides/whatsapp-assistant)** > > Build a text WhatsApp assistant with webhooks and persistence, then extend it to media ## 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. **Calling Agents** [Images](/examples/agents/images) [Word](/examples/agents/docx) [PDF](/examples/agents/pdf) [CSV](/examples/agents/csv) [Email](/examples/agents/emls) [Audio](/examples/agents/audio) **Tools** [Web Search](/examples/agents/search) [Approval-Required Tools](/examples/agents/approval-required-tools) [Text-to-Speech](/examples/agents/tts) [Image Generation](/examples/agents/image-generation) **Knowledge Bases** [SQL Queries & Data Management](/examples/agents/sql-queries) [Semantic Search with Embeddings](/examples/agents/semantic-search) **Dynamic Agents** [Custom Functions in System Prompts](/examples/agents/custom-functions-system-prompts) [Adaptive System Prompts](/examples/agents/adaptive-system-prompts) [Content-Aware Tools](/examples/agents/content-aware-tools) **Evals** [Input Validation](/examples/agents/input-validation) [Output Validation](/examples/agents/output-validation) [Tool Usage Validation](/examples/agents/execution-behavior) [Multi-turn Conversation Testing](/examples/agents/multi-turn-conversation) ## 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. **Control Flow** [Sequential Steps](/examples/workflows/sequential-steps) [Parallel Fan-Out](/examples/workflows/parallel-fan-out) **Branching** [Conditional Routing](/examples/workflows/conditional-routing) **Composition** [Workflow Composition](/examples/workflows/workflow-composition) # 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. ## "Yes, and don't ask me again" (`scope="session"`) An `approval_id` covers one exact call. A human who has approved `rm /tmp/a` will be asked again for `rm /tmp/b`. To let them stop being asked, resume with `scope="session"`: ```python theme={"dark"} result = await agent( prompt="clean up", parent_id=paused_run_id, resume={approval_id: {"approved": True, "scope": "session"}}, ).collect() ``` That approves the call **and** leaves an `ApprovalGrant` in the run's session data (`RunContext.get_session()`, the dict that follows a conversation across runs on the root span), keyed by the gate's `grant_key`. Every later gate in the same session — the chain of runs linked by `parent_id` — that resolves to the same key is approved without an `ApprovalEvent`. The synthesized resolution is written to `span.metadata["approval"]["resolution"]` like any other, with `scope: "session"` and `metadata.granted_by` = the `approval_id` the human actually answered; the grant itself is at `span.metadata["approval"]["granted"]`. Because grants ride the session, they persist wherever the trace does (JSONL, SQLite, the platform) with no extra storage. Every `ApprovalEvent` carries the two fields a client needs to offer the option: * `grant_key` — what a session approval would remember. Defaults to the runnable's path ("this tool"). Show it on the button: *Always allow `rm` in this chat*. * `grantable` — whether the option may be offered at all. Narrow or forbid remembering from the tool side: ```python theme={"dark"} # One tool, several subcommands: remembering `delete` must not cover `drop`. admin = Tool( handler=admin_impl, requires_approval=lambda command, **_: command in {"delete", "drop"}, approval_grant_key=lambda command, **_: f"admin:{command}", ) # Money moves: every occurrence needs a human. wire = Tool(handler=wire_impl, requires_approval=True, approval_grantable=False) ``` A policy callable can return the same two fields on its `ApprovalPolicyDecision` (`grant_key`, `grantable`); they win over the tool-level values. Rules: * An explicit resume value for a call always beats a grant — declining one occurrence after remembering the tool means that occurrence. * A decline is never remembered (`scope` is ignored when `approved=False`). * A `scope="session"` resolution on a gate that is not `grantable` approves that call only and sets `span.metadata["approval"]["grant_refused"] = True`. * `expires_at` on the resolution bounds the grant too; an expired grant is ignored and the gate asks again. * `scope="call"` is the default and is what a bare `True` means. Other scopes (a user across sessions, an org) may be added later; they will be additive. ## 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. > [!NOTE] > > 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" } } }`. ## Over a voice session A voice agent that pauses is, from the caller's side, a voice agent that went quiet. `VoiceSession` lifts both pause events into session events, so they reach the client over whichever transport the call uses (WebSocket, WebRTC, LiveKit) alongside transcripts, agent text and audio: ```json theme={"dark"} { "type": "agent_approval", "run_id": "06a22e62b85d7b558000db9caf2790b2", "approval_id": "8f21c0d4e93b47a1b0c5d2e6f7a81b34", "kind": "payment", "prompt": "Wire $500?", "ui": { "title": "Wire transfer", "severity": "high" }, "input": { "amount": 500 }, "input_schema": { "type": "object", "properties": { "amount": { "type": "integer" } } }, "tool_call_id": "toolu_01abc..." } ``` `agent_interaction` is the same idea for `suspend()`/`ask_user`, carrying `interaction_id`, `kind`, `payload` and `response_schema`. Both are emitted the moment the run parks, not at the end of the turn. **Resuming is still HTTP.** The voice channel carries the pause outward only; there is no inbound resume message. `run_id` is what makes the suspension actionable — resume exactly as above, with `parent_id` set to the `run_id` you received and the id in the `resume` map. It is also the only way a voice client learns the run id behind the call. Two transport notes: * Both events are additive. A client that dispatches on `type` and ignores unknown values behaves exactly as it did before. * On LiveKit, a payload over the \~12 KiB reliable-data cap is split into `{"type":"chunk","chunk_id","msg_type","seq","total","data"}` envelopes, where `data` is a base64 slice. Concatenate by `seq`, base64-decode, and parse one message. This matters for approvals: a card carrying a long brief in `ui`/`input` routinely exceeds the cap. (`session_transcript` keeps its own entry-wise split, unchanged.) ### Continuing a conversation across modalities A voice call and a text session are the same run chain — every voice turn persists with a `parent_run_id` — so moving between them is just a matter of carrying the pointer. **Voice → text.** `agent_text_done` carries the `run_id` of the run that produced the turn. Together with the suspension events above, a client holds a current pointer after every turn, suspended or not: to continue in chat, `POST /stream` with `parent_id` set to the last `run_id` seen. It is `null` for text with no run behind it — the opener greeting (spoken before any turn exists) and realtime speech-to-speech sessions (provider-held state, no runs). **Text → voice.** The voice session accepts the run to continue from — `VoiceSession(parent_run_id=...)` — and makes the call's **first** turn a child of that run, memory included; from turn two on the session's own chaining takes over. Where it comes from is deliberate: a `parent_id` says *which conversation this caller is joining*, so it rides the server-minted dial (`parent_id` on the LiveKit dial body, or `TIMBAL_VOICE_PARENT_RUN_ID` in the serverless boot env), never the browser hello. For the playground, a hello-supplied `parent_id` sits behind the same `TIMBAL_VOICE_ALLOW_CLIENT_CALL_CONTEXT` development gate as `call_context` — both are the caller asserting its own identity. When a seed was applied, `session_started` reports it back as `parent_run_id`. ## 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` | > [!NOTE] > > `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:remembered` — an approved resolution carried `scope="session"` and a grant was stored * `approvals:granted` — a gate was approved by an existing session grant, without emitting an `ApprovalEvent` * `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 > **Approve from a UI, resume from a worker** > > 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. > **Ask the user a clarifying question mid-task** > > 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. > **Settle a batch of pauses in one round-trip** > > 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. > **Force a fresh decision for each call** > > 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. > **Hide secrets from reviewers** > > 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. > **Approve with a correction (edit on approve)** > > 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": {...}}`. > **Cancel a task the user abandoned** > > 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()`. **JSONL (local / single host)** ```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. **SQLite (single host, higher throughput)** ```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. **Timbal Platform (multi-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. > [!NOTE] > > 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["grant_key"], entry["grantable"]) 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. > [!NOTE] > > `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`). > [!WARNING] > > 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. > [!WARNING] > > `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 * **Measured framework overhead.** The [benchmark suite](https://github.com/timbal-ai/timbal/tree/main/benchmarks) compares agent-loop overhead with fake LLMs to isolate framework cost. Real application latency also depends on models, tools, speech providers, and network calls. * **Simple and hackable.** Python source, async functions, and explicit interfaces make the framework inspectable and extensible. * **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 > **[Memory](/agents/memory)** > > Persistent context with tool-result offloading and compaction for long conversations. > **[Voice Agents](/voice)** > > Streaming speech, interruption handling, browser calls, and telephony with your agent's tools. > **[Model Routing](/models/overview)** > > One interface across every provider, with fallback chains for failover. > **[Tools & MCP](/agents/tools)** > > A built-in tool library, your own functions, and any MCP server. > **[Structured Output](/agents/structured-output)** > > Get typed Pydantic models back instead of raw text. > **[Human in the Loop](/human-in-the-loop)** > > Pause for approval or input, then resume across process restarts. > **[Skills](/agents/skills)** > > Reusable tool packages the agent loads on demand. > **[Tracing](/core-concepts/tracing)** > > Every run produces a full span trace, exportable over OTLP. > **[Evals](/evals)** > > Declarative YAML evaluation suite with built-in validators. > **[Deployment](/deployment)** > > Run the full stack locally with `timbal start`, then ship it. > [!TIP] > > 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. **macOS / Linux** 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 ``` **Windows** Use PowerShell to download and execute the installation script: ```powershell theme={"dark"} powershell -ExecutionPolicy ByPass -c "irm https://timbal.ai/install.ps1 | iex" ``` > [!TIP] > > The installation script may be inspected before use: > > **macOS / Linux** > > ```bash theme={"dark"} > curl -LsSf https://timbal.ai/install.sh | less > ``` > > **Windows** > > ```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: **macOS / Linux** ```bash theme={"dark"} curl -LsSf https://timbal.ai/install.sh | sh ``` **Windows** ```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: **macOS / Linux** ```bash theme={"dark"} rm ~/.local/bin/timbal rm -rf ~/.timbal ``` **Windows** ```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. > [!NOTE] > > 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 . ``` ### Optional dependencies Install the extras used by your application: * `timbal[server]`: HTTP server and WebSocket endpoints. * `timbal[voice]`: WebRTC, audio resampling, and local voice turn detection. Combine with `server` for the [voice playground](/voice/quickstart). * `timbal[voice-livekit]`: LiveKit media transport; combine with `server,voice` for live calls with local detection. * `timbal[documents]`: PDF, Word, and Excel parsing. * `timbal[evals]`: Rich CLI output for evaluations. * `timbal[codegen]`: Code generation and formatting tools. * `timbal[all]`: All of the above. ```bash theme={"dark"} pip install "timbal[server,voice]" uv add "timbal[documents,evals]" ``` CSV and plain text do not require document parser extras. External integration examples may need their own SDKs in addition to Timbal. > [!TIP] > > **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. > [!WARNING] > > 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. > [!NOTE] > > You can choose another server name instead of `timbal-mcp`; it is only a label in your local Claude Code configuration. > [!NOTE] > > 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. > [!WARNING] > > 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. > [!NOTE] > > 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. > [!NOTE] > > 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 ``` > [!NOTE] > > 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** > [!NOTE] > > 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. > [!NOTE] > > 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**. > [!WARNING] > > 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. > [!NOTE] > > 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. > [!WARNING] > > 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 " } } } ``` > [!NOTE] > > 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. > [!NOTE] > > 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.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 models with specs, pricing, and capabilities > [!NOTE] > > Source: [Anthropic model docs](https://platform.claude.com/docs/en/about-claude/models/overview) · [Release notes](https://platform.claude.com/docs/en/release-notes/overview) · [Pricing](https://platform.claude.com/docs/en/about-claude/pricing) ### How cache and fast-mode pricing is tracked Claude 4.6 and later models bill the full 1M window at standard rates, except Haiku 5.5: prompts over 100,000 tokens use a higher rate for the full request. For global streaming requests, Timbal's collector emits one unit per disjoint token bucket. Multipliers below are relative to the model's input rate unless specified otherwise: | Anthropic `usage` field | Usage key on `OutputEvent.usage` | Rate | | - | - | - | | `input_tokens` | `input_tokens` | 1x | | `cache_read_input_tokens` | `cache_read_input_tokens` | 0.1x (0.05x on Opus 5.5 and Sonnet 5.5; 0.025x on Fable 5.1) | | `cache_creation.ephemeral_5m_input_tokens` | `ephemeral_5m_input_tokens` | 1.25x | | `cache_creation.ephemeral_1h_input_tokens` | `ephemeral_1h_input_tokens` | 2x | | `cache_creation_input_tokens` (only when no valid per-TTL breakdown is reported) | `cache_creation_input_tokens` | 1.25x fallback; assumes 5m TTL | | `output_tokens` | `output_tokens` | output rate | | `server_tool_use.*_requests` | e.g. `web_search_requests` | per call | `output_tokens_details.thinking_tokens` is a subset of `output_tokens` and is **not** emitted as a separate unit (it would be billed twice). On Opus 5.5 / Opus 5 / Opus 4.8, `usage.speed == "fast"` ([fast mode](https://platform.claude.com/docs/en/build-with-claude/fast-mode), 2x) suffixes every token unit with `_fast`, e.g. `anthropic/claude-opus-5-5:output_tokens_fast`; pass `model_params={"extra_body": {"speed": "fast"}, "extra_headers": {"anthropic-beta": "fast-mode-2026-02-01"}}` to opt in. If a stream is interrupted before the final `message_delta`, the prompt-side units are still recorded from `message_start` (Anthropic charges them regardless); output is left unbilled. On Haiku 5.5, the tier threshold uses `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. Exactly 100,000 prompt tokens still use the lower rates. Above that boundary, every token unit receives `_long_context`, including output and both cache-write durations, e.g. `anthropic/claude-haiku-5-5:ephemeral_1h_input_tokens_long_context`. The per-TTL write breakdown is already included in `cache_creation_input_tokens` and is not counted twice. Per-call tool fees keep their original units. The same tier selection applies to prompt usage from interrupted streams. Web search costs \$0.01 per search plus token charges; web fetch has no additional per-call fee. Code execution is free alongside the current web search/fetch tools; otherwise Anthropic bills execution time, with 1,550 free hours per organization per month, then \$0.05 per container-hour and a five-minute minimum. A `code_execution_requests` count alone is insufficient to calculate that charge. The catalog stores global standard input, output, cache-read, and both cache-write rates explicitly: `cached_input_price` for reads, `cache_write_price` for 5m writes, and `cache_write_1h_price` for 1h writes. The SQL generator omits each missing or null rate independently, including its fast-mode variant; it never derives cache prices from the input price. Omitted rates are unknown, not free. The collector does not distinguish Batch API or US-only inference pricing in its usage keys. Apply those provider modifiers separately when reconciling billing. An aggregate cache-write count without a valid TTL breakdown cannot exactly price 1h or mixed-TTL writes. ## Latest > **claude-fable-5-1** > > Reasoning · Speed > > `anthropic/claude-fable-5-1` > > Anthropic's most capable generally available model. Successor to Fable 5 for long-running agentic coding, knowledge work, and research, with fewer false-positive safety refusals in multi-turn agent loops. Same price as Fable 5; cache reads are 75% cheaper. > > * \$10 / \$50 per 1M tokens (input / output); cache reads \$0.25 (0.025x input); batch \$5 / \$25 > * 1M context > * 128K max output > * Text, Image input > * Adaptive thinking (always on) > * Web search > * Knowledge cutoff Jun 2026 > * `tool_choice` `any` / `tool` not supported (400); requires 30-day data retention (no ZDR) > **claude-opus-5-5** > > Reasoning · Speed > > `anthropic/claude-opus-5-5` > > Latest Opus for long-running agentic coding and knowledge work. Lower input, output, and cache-read prices than Opus 5. > > * \$4 / \$20 per 1M tokens (input / output); cache reads \$0.20; 5m / 1h cache writes \$5 / \$8 > * Fast mode \$8 / \$40; cache reads \$0.40; 5m / 1h writes \$10 / \$16 (Claude API research preview, requires access) > * Provider Batch API input / output \$2 / \$10; global prices, US-only inference adds 10% > * 1M context at standard rates > * 128K max output (300K with the provider's Batch API beta) > * Text, Image input; Text output > * Adaptive thinking always on; default effort `medium` > * Knowledge cutoff Jun 2026 > * Disabled/budgeted thinking and forced `tool_choice` (`any` / `tool`) return 400 > **claude-opus-5** > > Reasoning · Speed > > `anthropic/claude-opus-5` > > Previous Opus generation for complex agentic coding and enterprise work. Superseded by Opus 5.5, which has lower token and cache-read prices. > > * \$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 > **claude-sonnet-5-5** > > Reasoning · Speed > > `anthropic/claude-sonnet-5-5` > > Latest Sonnet for coding, agents, and professional work. Supersedes Sonnet 5 at the same input/output prices and half the cache-read price. > > * \$2 / \$10 per 1M tokens (input / output); cache reads \$0.10; 5m / 1h cache writes \$2.50 / \$4 > * Provider Batch API input / output \$1 / \$5; cache reads \$0.05; 5m / 1h writes \$1.25 / \$2 > * US-only inference adds 10% to all token rates: standard input / output \$2.20 / \$11; cache reads \$0.11; 5m / 1h writes \$2.75 / \$4.40 > * No long-context surcharge; fast mode is not available > * 1M context at standard rates > * 128K max output (300K with the provider's Batch API beta) > * Text, Image input; Text output > * Adaptive thinking on by default; default effort `high` > * Knowledge cutoff Jun 2026; released September 28, 2026 > * Disabled/budgeted thinking, forced `tool_choice` (`any` / `tool`), and non-default sampling parameters return 400 > **claude-sonnet-5** > > Reasoning · Speed > > `anthropic/claude-sonnet-5` > > Previous Sonnet generation for coding, agents, and everyday professional work. Superseded by Sonnet 5.5 at the same token prices. > > * \$2 / \$10 per 1M tokens (input / output) > * 1M context > * 128K max output > * Text, Image input > * Adaptive thinking > * Web search > * Knowledge cutoff Jan 2026 > **claude-opus-4-8** > > 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 > **claude-opus-4-7** > > 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 > **claude-opus-4-6** > > 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 > **claude-haiku-5-5** > > Reasoning · Speed > > `anthropic/claude-haiku-5-5` > > Latest and fastest Haiku for high-volume classification, extraction, routing, and subagent tasks. > > * Prompts up to 100K: \$0.10 / \$0.50 per 1M tokens (input / output); cache reads \$0.01; 5m / 1h cache writes \$0.125 / \$0.20 > * Prompts over 100K: input / output \$0.50 / \$2.50; cache reads \$0.05; 5m / 1h cache writes \$0.625 / \$1 (full request) > * 1M context > * 128K max output (300K with the provider's Batch API beta) > * Text, Image input; Text output > * Adaptive thinking on by default; default effort `medium` > * Knowledge cutoff Jun 2026; released October 7, 2026 > * Omit non-default `temperature`, `top_p`, and `top_k`; recount token budgets when migrating from Haiku 4.5 *** ## Legacy > **claude-haiku-4-5** > > Reasoning · Speed > > `anthropic/claude-haiku-4-5` > > Previous Haiku generation for office files, strategy planning, and business analysis. Superseded by Haiku 5.5. > > * \$1 / \$5 > * 200K context > * 64K max output > * Text, Image input > * Thinking > * Web search > * Knowledge cutoff Feb 2025 > **claude-fable-5** > > Reasoning · Speed > > `anthropic/claude-fable-5` > > Previous Fable generation for long-horizon agentic work, complex reasoning, and ambitious multi-day coding tasks. Superseded by Fable 5.1 at the same price. > > * \$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 > **claude-opus-4-5** > > 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 > **claude-sonnet-4-6** > > 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 > **claude-sonnet-4-5** > > Reasoning · Speed > > `anthropic/claude-sonnet-4-5` > > Previous Sonnet generation with strong all-around performance and 1M beta context support. Deprecated September 30, 2026; retires November 30, 2026. Migrate to `anthropic/claude-sonnet-5-5`. > > * \$3 / \$15 > * 200K context (1M beta) > * 64K max output > * Text, Image input > * Thinking > * Knowledge cutoff Jan 2025 ## Using Haiku 5.5 Set `ANTHROPIC_API_KEY` and select the full model ID: ```python theme={"dark"} from timbal import Agent agent = Agent( name="classifier", model="anthropic/claude-haiku-5-5", max_tokens=4096, model_params={"output_config": {"effort": "medium"}}, ) ``` Adaptive thinking is on by default, and thinking tokens count toward `max_tokens`. Timbal collects text and thinking blocks separately. Omit non-default sampling parameters. Haiku 5.5 uses the newer tokenizer, which produces approximately 30% more tokens for the same text than Haiku 4.5; recount prompts and budgets before migrating. Replay stored thinking blocks through the account that produced them or a linked account. Automatic prompt caching is enabled by Timbal. The 100K pricing threshold includes uncached input, cache reads, and cache writes; output uses the rate selected by the prompt length. `scripts/generate_costs_sql.py` emits both standard and `_long_context` token rates, including 5m and 1h cache writes. Apply the generated SQL when updating a billing database; changing the catalog alone does not update stored rates. Haiku 5.5 specs and pricing verified October 7, 2026 against the [model specifications](https://platform.claude.com/docs/en/models/haiku-5-5/overview), [migration guide](https://platform.claude.com/docs/en/models/haiku-5-5/migration-guide), and [API pricing](https://platform.claude.com/docs/en/about-claude/pricing). ## Using Sonnet 5.5 Set `ANTHROPIC_API_KEY` and select the full model ID: ```python theme={"dark"} from timbal import Agent agent = Agent( name="coder", model="anthropic/claude-sonnet-5-5", max_tokens=16000, model_params={"output_config": {"effort": "high"}}, ) ``` Omit `thinking` to use adaptive thinking. To turn off up-front thinking, pass `model_params={"thinking": {"type": "between_tools"}, "output_config": {"effort": "high"}}`; this requires `high` effort or below. Use `auto` with strict tools or structured outputs instead of forced tool selection. Leave `temperature`, `top_p`, and `top_k` at their defaults. With adaptive thinking, progress text between tool calls arrives in thinking blocks and is omitted by default. Set `thinking.display="summarized"` to receive it; `between_tools` returns it without a display setting. Preserve thinking blocks and the preceding conversation unchanged when replaying them. Native computer use on the Claude API requires `computer_toolset_20260801`; the older `computer_20251124` tool is rejected. Automatic prompt caching is enabled by Timbal; Sonnet 5.5 requires at least 512 cacheable tokens. All prices above are USD per 1M tokens except per-call/time-based tool fees. The 50% Batch API discount and 10% US-only inference premium stack across token categories. Partner cloud pricing and negotiated discounts may differ. See the [Sonnet 5.5 specifications](https://platform.claude.com/docs/en/models/sonnet-5-5/overview) and [migration notes](https://platform.claude.com/docs/en/models/sonnet-5-5/whats-new-sonnet-5-5). Sonnet 5.5 cache reads were halved on October 7, 2026; standard reads now cost \$0.10 per 1M tokens (\$0.05 with Batch, \$0.11 with US-only inference). Anthropic catalog token and cache prices verified October 7, 2026 against [Anthropic's price list](https://platform.claude.com/docs/en/about-claude/pricing). ## Using Opus 5.5 Set `ANTHROPIC_API_KEY` and select the full model ID: ```python theme={"dark"} from timbal import Agent agent = Agent( name="coder", model="anthropic/claude-opus-5-5", max_tokens=16000, model_params={"output_config": {"effort": "medium"}}, ) ``` Use effort to control thinking depth; omit `thinking` or use adaptive thinking. For tool use, choose `auto` with strict tools or structured output instead of forcing a tool. Progress text between tool calls now arrives in thinking blocks and is empty by default; `thinking.display="summarized"` returns text, while `"updates"` requires the `thinking-display-updates-2026-08-18` beta header. Preserve thinking blocks unchanged within the conversation. The older `computer_20251124` tool is no longer accepted on the Claude API; use `computer_toolset_20260801` if supplying native computer tools. See the [Opus 5.5 specifications](https://platform.claude.com/docs/en/models/opus-5-5/overview) and [migration guide](https://platform.claude.com/docs/en/models/opus-5-5/migration-guide) for model-bound thinking, refusal handling, and provider-specific differences. Pricing verified September 23, 2026 against [Anthropic's price list](https://platform.claude.com/docs/en/about-claude/pricing). ## Lifecycle audit Checked October 3, 2026 against the [model lifecycle](https://platform.claude.com/docs/en/about-claude/model-deprecations) and [model overview](https://platform.claude.com/docs/en/about-claude/models/overview). Opus 4.1 was removed from the catalog after its August 5 retirement. Sonnet 4.5 remains available until November 30. Haiku 4.5 remains active; its earliest-retirement commitment is not a shutdown announcement. Restricted Mythos models are not generally available catalog entries. # BytePlus Source: https://docs.timbal.ai/models/byteplus Current Seed, DeepSeek, and GLM models, verified pricing, and retirement dates > [!NOTE] > > All token prices are USD per 1M tokens (input / output). 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. ## Current additions These model services require activation in the Ark Console. Rates below are standard online inference, with cached input shown separately. > **dola-seed-2-1-turbo-260628** > > `byteplus/dola-seed-2-1-turbo-260628` > > Dola Seed 2.1 Turbo: multimodal reasoning and tools. > > * \$0.50 / \$2.50; cache \$0.10 > * 256K context > **seed-2-0-lite-260428** > > `byteplus/seed-2-0-lite-260428` > > April Seed 2.0 Lite with image, video, and audio input. > > * \$0.25 / \$2; cache \$0.05. Above 128K: \$0.50 / \$4; cache \$0.10 > * 256K context > **seed-2-0-mini-260428** > > `byteplus/seed-2-0-mini-260428` > > April Seed 2.0 Mini with image, video, and audio input. > > * \$0.10 / \$0.40; cache \$0.02. Above 128K: \$0.20 / \$0.80; cache \$0.04 > * 256K context > **deepseek-v4-pro-ga-260813** > > `byteplus/deepseek-v4-pro-ga-260813` > > DeepSeek V4 Pro GA for reasoning and tools. > > * \$1.32 / \$3.96; cache \$0.044 > * 1024K context > **deepseek-v4-flash-ga-260731** > > `byteplus/deepseek-v4-flash-ga-260731` > > DeepSeek V4 Flash GA for reasoning and tools. > > * \$0.44 / \$1.32; cache \$0.014 > * 1024K context > **glm-5-2-260617** > > `byteplus/glm-5-2-260617` > > GLM 5.2 for reasoning and tools. > > * \$1.40 / \$4.40; cache \$0.26 > * 1024K context > **glm-5-3-flash-260828** > > `byteplus/glm-5-3-flash-260828` > > GLM 5.3 Flash with multimodal input and tools. > > * \$0.15 / \$0.50; cache \$0.03 > * 1024K context > **deepseek-v4-1-flash-260910** > > `byteplus/deepseek-v4-1-flash-260910` > > DeepSeek V4.1 Flash with vision, video, reasoning, and tools. Supports JSON object output, but not strict JSON schema. > > * Off-peak \$0.15 / \$0.60; cache \$0.003. Peak \$0.30 / \$1.20; cache \$0.006 > * 1024K context ## Seed 2.0 > **seed-2-0-pro-260328** > > 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. > > > [!WARNING] > > > > Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use. > > * \$0.50 / \$3.00 (\$1 / \$6 above 128K prompt tokens) > * 256K context > * Text, Image, Video input > * Deep thinking > **seed-2-0-lite-260228** > > 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 / \$2 (\$0.50 / \$4 above 128K prompt tokens) > * 256K context > * Text, Image, Video input > **seed-2-0-mini-260215** > > 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 (\$0.20 / \$0.80 above 128K) > * 256K context > * Text, Image, Video input *** ## Seed 1.x > **seed-1-8-251228** > > 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 (\$0.50 / \$4 above 128K); cached input \$0.05 at both tiers > * 256K context > * 64K max output > * Text, Image, Video input > * Deep thinking > **seed-1-6-250915** > > 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 (\$0.50 / \$4 above 128K); cached input \$0.05 at both tiers > * 256K context > * Text, Image, Video input *** ## Third-party (Ark) > **deepseek-v4-pro-260425** > > Reasoning · Speed > > `byteplus/deepseek-v4-pro-260425` > > DeepSeek V4 Pro on BytePlus ModelArk: frontier open MoE for coding, reasoning, and long-context agent workloads. > > > [!WARNING] > > > > Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use. > > * \$1.32 / \$3.96; cached input \$0.044 > * 1024K context > * Text input > **deepseek-v4-flash-260425** > > Reasoning · Speed > > `byteplus/deepseek-v4-flash-260425` > > Cost-efficient DeepSeek V4 Flash on BytePlus ModelArk for fast reasoning and agent workloads. > > > [!WARNING] > > > > Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use. > > * \$0.44 / \$1.32; cached input \$0.014 > * 1024K context > * Text input > **deepseek-v3-2-251201** > > Reasoning · Speed > > `byteplus/deepseek-v3-2-251201` > > DeepSeek V3.2 on BytePlus ModelArk with improved reasoning and tool use over V3.1. > > * \$0.28 / \$0.42 (\$0.56 / \$0.84 above 32K); cached input \$0.056 at both tiers > * 128K context > * Text input > **gpt-oss-120b-250805** > > Reasoning · Speed > > `byteplus/gpt-oss-120b-250805` > > OpenAI GPT OSS 120B on BytePlus ModelArk. Apache 2.0. > > * \$0.10 / \$0.50 > * 128K context > * Text input > **glm-4-7-251222** > > Reasoning · Speed > > `byteplus/glm-4-7-251222` > > Zhipu GLM-4.7 on BytePlus ModelArk for coding, reasoning, and agentic tasks. > > * \$0.60 / \$2.20; cached input \$0.11 > * 256K context > * Text input > **seed-2-0-code-preview-260328** > > Reasoning · Speed > > `byteplus/seed-2-0-code-preview-260328` > > Seed 2.0 code-specialized preview on BytePlus ModelArk for agentic coding workloads. > > > [!WARNING] > > > > Activation required — enable in the [Ark Console](https://console.byteplus.com/ark/region:ark+ap-southeast-1/models) before first use. > > * \$0.50 / \$3 (\$1 / \$6 above 128K); cached input \$0.10 / \$0.20 > * 256K context > * Text, Image, Video input ## Pricing verification and limits Audited October 3, 2026 using the September 28 [pricing table](https://docs.byteplus.com/en/docs/modelark/model-pricing), [model list](https://docs.byteplus.com/en/docs/modelark/model-list), and [release announcements](https://docs.byteplus.com/en/docs/modelark/model-release-announcement). Old indexed July pricing for DeepSeek V4 differs; the catalog uses the newer table. Limits are recorded using the catalog's existing decimal K convention. DeepSeek V4.1 Flash peak periods are Monday–Friday, 09:00–12:00 and 14:00–18:00 UTC+8; all other periods are off-peak. Its catalog prices are `null` because the current schema cannot represent a time-based rate. This means unknown to the cost calculator, not free. April Seed Lite audio input / cached audio is \$3.75 / \$0.75, and April Seed Mini is \$1.50 / \$0.30; both double above 128K. These differ from the catalog's non-audio rates. Explicit cache storage is an additional \$0.0083 per million tokens per hour; implicit caching has no storage fee. Storage and audio charges require separate reconciliation. [Flex](https://docs.byteplus.com/en/docs/ModelArk/online-inference-flex) halves input/output prices on Seed 2.1 Turbo and DeepSeek V4 GA while leaving cache prices unchanged. It cannot be encoded as a uniform multiplier across all token types. The catalog records standard rates. Batch discounts also leave cached input unchanged; do not halve the whole bill. GPT OSS's model page lists a cache rate while the current aggregate price table shows no cache rate; no cache discount is assumed in the catalog. ## Retirements The [deprecation notice](https://docs.byteplus.com/en/docs/modelark/model-deprecation-notice) confirms Kimi K2, Kimi K2 Thinking, and DeepSeek R1 snapshots ceased service May 13, 2026; they have been removed. Seed 1.6, Seed 1.8, February Seed Lite/Mini, DeepSeek V3.2, GLM 4.7, and GPT OSS retire November 11, 2026 and remain listed for migration. The model-list page already labels some of them “Retired”; the dated deactivation notice governs the catalog. Seed Mini's February snapshot is already unavailable in eu-west-1, but remains available on the configured ap-southeast-1 endpoint until its scheduled shutdown. # Cerebras Source: https://docs.timbal.ai/models/cerebras Wafer-scale inference at world-record token speeds with specs, pricing, and capabilities > [!NOTE] > > Verified 2026-10-03 against [model catalog](https://inference-docs.cerebras.ai/models/overview), [pricing](https://www.cerebras.ai/pricing), and [Qwen specs](https://inference-docs.cerebras.ai/models/qwen-3.8-27b). Prices are USD per 1M tokens at standard online rates. Model IDs use the `cerebras/` prefix. Dedicated endpoints require provisioning and use deployment-specific billing. ## All Models > **gpt-oss-120b** > > 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 per 1M input / output tokens > > * 131,072 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Paid context is 131k; free trial is 65k. Pricing: [https://www.cerebras.ai/pricing](https://www.cerebras.ai/pricing) ## Newly verified models > **Qwen 3.8 27B (Cerebras)** > > `cerebras/qwen-3.8-27b` > > Qwen 3.8 27B with image input and configurable reasoning on Cerebras. > > * \$0.99 / \$1.49 per 1M input / output tokens > > * 128,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Paid context is advertised as 128k; free trial is 64k. Prices verified against [https://www.cerebras.ai/pricing](https://www.cerebras.ai/pricing) and [https://inference-docs.cerebras.ai/models/qwen-3.8-27b](https://inference-docs.cerebras.ai/models/qwen-3.8-27b) on 2026-10-03. # Fireworks Source: https://docs.timbal.ai/models/fireworks Open-source models via Fireworks serverless inference with specs, pricing, and capabilities > [!NOTE] > > Verified 2026-10-03 against [pricing](https://docs.fireworks.ai/serverless/pricing), individual [model pages](https://fireworks.ai/models), and [dated deprecations](https://docs.fireworks.ai/updates/changelog). Prices are USD per 1M tokens at standard online rates. Model IDs use the `fireworks/` prefix. Dedicated endpoints require provisioning and use deployment-specific billing. ## All Models > **deepseek-v4-flash-0731** > > Reasoning · Speed > > `fireworks/accounts/fireworks/models/deepseek-v4-flash-0731` > > Official DeepSeek V4 Flash on Fireworks dedicated deployments (supersedes the April preview). Fast, cost-efficient MoE with near-Pro reasoning at 1M context. > > * Dedicated deployment pricing; no fixed per-token rate > > * 1,048,576 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Serverless retired 2026-09-25. Dedicated deployment remains available and is billed by GPU time, not these historical token rates. See [https://docs.fireworks.ai/updates/changelog](https://docs.fireworks.ai/updates/changelog) > **qwen3p8-max** > > Reasoning · Speed > > `fireworks/accounts/fireworks/models/qwen3p8-max` > > Alibaba Qwen 3.8 Max on Fireworks serverless: flagship MoE for long-horizon coding and agentic work. Replacement for retired qwen3p7-plus. > > * \$2 / \$6 per 1M input / output tokens > > * Cached input: \$0.25 per 1M tokens > > * Context not published > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Fireworks model page reports context N/A; no limit is inferred from the upstream model. Standard prices; Priority is 1.5x. [https://app.fireworks.ai/models/fireworks/qwen3p8-max](https://app.fireworks.ai/models/fireworks/qwen3p8-max) > **kimi-k2p6** > > 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. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,144 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Serverless retired 2026-09-25. Dedicated deployment remains available and is billed by GPU time, not these historical token rates. See [https://docs.fireworks.ai/updates/changelog](https://docs.fireworks.ai/updates/changelog) > **kimi-k2p7-code** > > Reasoning · Speed > > `fireworks/accounts/fireworks/models/kimi-k2p7-code` > > Moonshot Kimi K2.7 Code on Fireworks dedicated deployments for long-context coding agents with thinking mode. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,144 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Serverless retired 2026-09-25. Dedicated deployment remains available and is billed by GPU time, not these historical token rates. See [https://docs.fireworks.ai/updates/changelog](https://docs.fireworks.ai/updates/changelog) > **glm-5p2** > > Reasoning · Speed > > `fireworks/accounts/fireworks/models/glm-5p2` > > Z.ai GLM-5.2 on Fireworks dedicated deployments: long-context text model for coding and tool use. Serverless access was retired September 25, 2026. > > * Dedicated deployment pricing; no fixed per-token rate > > * 1,048,576 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Serverless retired 2026-09-25. Dedicated deployment remains available and is billed by GPU time, not these historical token rates. See [https://docs.fireworks.ai/updates/changelog](https://docs.fireworks.ai/updates/changelog) > **minimax-m3** > > Reasoning · Speed > > `fireworks/accounts/fireworks/models/minimax-m3` > > MiniMax M3 text deployment on Fireworks serverless for chat, agents, and long-context workloads. > > * \$0.3 / \$1.2 per 1M input / output tokens > > * Cached input: \$0.06 per 1M tokens > > * 512,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Fireworks deployment does not support image input despite the multimodal upstream model. Context advertised as 512k. Standard prices; Priority is 1.5x. > **gpt-oss-120b** > > 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.6 per 1M input / output tokens > > * Cached input: \$0.015 per 1M tokens > > * 131,072 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Standard prices. Priority is 1.2x input, cached input, and output. ## Newly verified models > **DeepSeek V4.1 Flash (Fireworks)** > > `fireworks/accounts/fireworks/models/deepseek-v4p1-flash` > > Multimodal DeepSeek model on Fireworks serverless. > > * \$0.3 / \$1.2 per 1M input / output tokens > > * Cached input: \$0.006 per 1M tokens > > * 1,000,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Standard rates effective 2026-10-01; Priority is 1.25x. Replaces retired V4 Flash 0731. [https://app.fireworks.ai/models/fireworks/deepseek-v4p1-flash](https://app.fireworks.ai/models/fireworks/deepseek-v4p1-flash) > **Kimi K3 (Fireworks)** > > `fireworks/accounts/fireworks/models/kimi-k3` > > Moonshot Kimi K3 on Fireworks serverless. > > * \$3 / \$15 per 1M input / output tokens > > * Cached input: \$0.3 per 1M tokens > > * 1,000,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Standard rates; Priority is 1.25x and Fast is 1.5x. US-only routing adds a separate premium. [https://app.fireworks.ai/models/fireworks/kimi-k3](https://app.fireworks.ai/models/fireworks/kimi-k3) > **GLM-5.3 (Fireworks)** > > `fireworks/accounts/fireworks/models/glm-5p3` > > GLM-5.3 for text, coding, and tool use on Fireworks. > > * \$1.4 / \$4.4 per 1M input / output tokens > > * Cached input: \$0.26 per 1M tokens > > * 1,000,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Standard rates; Priority is 1.25x and Fast is 1.5x. Replaces retired serverless GLM-5.2. [https://app.fireworks.ai/models/fireworks/glm-5p3](https://app.fireworks.ai/models/fireworks/glm-5p3) > **GLM-5.3 Flash (Fireworks)** > > `fireworks/accounts/fireworks/models/glm-5p3-flash` > > GLM-5.3 Flash with text and image input on Fireworks. > > * \$0.15 / \$0.5 per 1M input / output tokens > > * Cached input: \$0.03 per 1M tokens > > * 1,000,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Standard rates; Priority is 1.25x. [https://app.fireworks.ai/models/fireworks/glm-5p3-flash](https://app.fireworks.ai/models/fireworks/glm-5p3-flash) > **Nemotron Lightning 3.5 30B A3B (Fireworks)** > > `fireworks/accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b` > > NVIDIA reasoning model for efficient text and tool workloads on Fireworks. > > * \$0.05 / \$0.2 per 1M input / output tokens > > * Cached input: \$0.01 per 1M tokens > > * 262,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Context advertised as 262k; Standard rates, Priority is 1.25x. [https://app.fireworks.ai/models/fireworks/nemotron-lightning-3p5-30b-a3b](https://app.fireworks.ai/models/fireworks/nemotron-lightning-3p5-30b-a3b) > **Nemotron 3 Ultra NVFP4 (Fireworks)** > > `fireworks/accounts/fireworks/models/nemotron-3-ultra-nvfp4` > > NVIDIA Nemotron 3 Ultra preview on Fireworks serverless. > > * \$0.6 / \$2.4 per 1M input / output tokens > > * Cached input: \$0.12 per 1M tokens > > * 262,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Preview; context advertised as 262k. Standard rates. Pricing docs list Priority at 1.25x despite the model page only listing Standard. [https://app.fireworks.ai/models/fireworks/nemotron-3-ultra-nvfp4](https://app.fireworks.ai/models/fireworks/nemotron-3-ultra-nvfp4) > **Ember-1 (Fireworks)** > > `fireworks/accounts/fireworks/models/ember-1` > > Fireworks research preview based on Kimi K3. > > * \$3 / \$15 per 1M input / output tokens > > * Cached input: \$0.3 per 1M tokens > > * 1,000,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Time-limited research preview; confirm ongoing availability before production use. Standard rates; pricing docs list Priority at 1.25x. [https://app.fireworks.ai/models/fireworks/ember-1](https://app.fireworks.ai/models/fireworks/ember-1) ## Serving modes and retirement All token prices above are Standard mode. Priority, Fast, US-only routing, batch, reserved throughput, and dedicated deployments have different rates. See the [current mode rate card](https://docs.fireworks.ai/serverless/pricing); this catalog does not automatically price nonstandard modes. The September 25 retirement applies to serverless DeepSeek V4 Flash 0731, Kimi K2.6, Kimi K2.7 Code, and GLM-5.2. Their dedicated deployments remain available. # Google Source: https://docs.timbal.ai/models/google Gemini API availability, verified Standard pricing, and migration guidance > [!NOTE] > > Reviewed October 17, 2026 against Google's [model catalog](https://ai.google.dev/gemini-api/docs/models), > [pricing](https://ai.google.dev/gemini-api/docs/pricing), [release notes](https://ai.google.dev/gemini-api/docs/changelog), > and [deprecations](https://ai.google.dev/gemini-api/docs/deprecations). > Unless explicitly labeled otherwise, prices below are USD per **1 million tokens**, paid **Standard** tier, using published post-promotion rates. > Output rates include thinking tokens for reasoning models. Batch, Flex, Priority, free-tier allowances, > cache storage, grounding, and media generation are separate charges or pricing plans. ## Gemini 4 Argon `google/gemini-4-argon` **Generally available since October 13, 2026**, through the paid Gemini API and AI Studio. Google lists `gemini-4-argon` as stable; it is no longer limited to the earlier trusted-tester release. See the [launch announcement](https://cloud.google.com/blog/products/ai-machine-learning/gemini-4-argon-available-on-google-cloud/). * Standard input / output / cached input: **\$2 / \$10 / \$0.10** through November 30, 2026; **\$4 / \$20 / \$0.20** from December 1. The catalog and generated cost SQL use the latter, post-promotion rates. * Batch and Flex: **\$1 / \$5 / \$0.05**, then **\$2 / \$10 / \$0.10** on December 1. * Priority: **\$3.60 / \$18 / \$0.18**, then **\$7.20 / \$36 / \$0.36** on December 1. * Invite-only Contributor tier: same token rates as Batch/Flex, with data used to improve Google's products. * No free API tier. Output prices include thinking. Implicit caching only; no explicit cache creation or storage charge. * Search and Maps grounding each have a shared 5,000-query monthly allowance, then **\$14 per 1,000 queries**. These native tool charges are separate from Timbal's text-token estimates. [Full pricing](https://ai.google.dev/gemini-api/docs/pricing#gemini-4-argon) The [model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-4-argon) give a **1,048,576-token unified input/output window**, with a default synchronous output limit of **65,536**. Text, image, audio, video, and PDF input are supported; output is text. Tools, structured output, and reasoning are supported. Thinking levels are `low`, `medium` (default), and `high`. Set `GEMINI_API_KEY` for direct access, or use an authorized Timbal platform configuration: ```python theme={"dark"} from timbal import Agent agent = Agent( name="argon", model="google/gemini-4-argon", max_tokens=32768, model_params={"reasoning_effort": "medium"}, ) ``` Timbal uses Google's [OpenAI-compatible streaming chat API](https://ai.google.dev/gemini-api/docs/openai). The model's default reasoning level also applies when `model_params` is omitted. Google ignores sampling parameters such as `temperature` and `top_p` on Argon. Explicit caching is unsupported. Native long decode can use the remaining unified window, but requires background execution or continuation handling; Timbal's Agent chat route does not implement that continuation protocol. See the [migration guide](https://ai.google.dev/gemini-api/docs/latest-model#migration-checklist). ## Gemini 3.8, 3.7, and 3.6 Flash > [!NOTE] > > All three use published post-promotion Standard input / output / cached-input rates of **\$1.50 / \$7.50 / \$0.15**, effective January 1, 2027. > This timing is published on the [pricing page](https://ai.google.dev/gemini-api/docs/pricing#gemini-3.8-flash). > The catalog already uses these post-promotion rates; temporary introductory discounts are excluded. > Post-promotion cache storage is an additional \$1 per million token-hours. > Batch and Flex token rates are half Standard; Priority token rates are 1.8 times Standard. These are not the default catalog prices. > **gemini-3.8-flash** > > `google/gemini-3.8-flash` > > Current stable Flash generation for coding and multi-step agent work. > > * Input / output: \$1.50 / \$7.50 (post-promotion) > * Cached input: \$0.15 > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Text, image, video, audio, and PDF input; text output > * Tools and thinking; `low`, `medium` (default), or `high`; `minimal` is rejected > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash) > **gemini-3.7-flash** > > `google/gemini-3.7-flash` > > Previous stable Flash generation, still supported. > > * Input / output: \$1.50 / \$7.50 (post-promotion) > * Cached input: \$0.15 > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Text, image, video, audio, and PDF input; text output > * Tools and thinking; `low`, `medium`, or `high`; `minimal` is rejected > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash) > **gemini-3.6-flash** > > `google/gemini-3.6-flash` > > Earlier stable Flash generation for multimodal and agent workloads. > > * Input / output: \$1.50 / \$7.50 (post-promotion) > * Cached input: \$0.15 > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Text, image, video, audio, and PDF input; text output > * Tools and thinking > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash) For Gemini 3.8 migration details, including removed sampling parameters and thinking configuration, see Google's [Gemini 3.8 model page](https://ai.google.dev/gemini-api/docs/models/gemini-3.8-flash). Timbal's Google Agent route uses the OpenAI-compatible chat API; model-level native capabilities do not imply every native tool or modality is exposed through that route. ## Other Gemini 3 models > **gemini-3.5-flash** > > `google/gemini-3.5-flash` > > * Current token pricing: unverified for this redirected ID > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > > Google now [routes this ID to `gemini-3.6-flash`](https://ai.google.dev/gemini-api/docs/deprecations). > It is no longer selectable in AI Studio; the API endpoint is not shutting down. Redirect billing rates are > unverified, so catalog prices are null and new cost SQL omits this ID. Use an explicit current Flash ID for new applications. > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash) > **gemini-3.5-flash-lite** > > `google/gemini-3.5-flash-lite` > > * Input / output: \$0.30 / \$2.50, including audio input at the same input rate > * Cached input: \$0.03 > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite) > **gemini-3.1-pro-preview** > > `google/gemini-3.1-pro-preview` > > * Input / output: \$2 / \$12 at up to 200,000 input tokens > * Above 200,000 input tokens: \$4 / \$18 for the entire request > * Cached input: \$0.20; \$0.40 above 200,000 input tokens > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > * Preview; no shutdown date announced > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview) > **gemini-3.1-pro-preview-customtools** > > `google/gemini-3.1-pro-preview-customtools` > > Variant that prioritizes custom tools and bash. Same token limits and Standard prices as Gemini 3.1 Pro Preview, > including its 200,000-input-token threshold. Preview quality can differ on tasks that do not benefit from tools. > > [Variant documentation](https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview#gemini-3.1-pro-preview-customtools) > **gemini-3.1-flash-lite** > > `google/gemini-3.1-flash-lite` > > * Text/image/video input / text output: \$0.25 / \$1.50 > * Audio input: \$0.50 > * Cached input: \$0.025 text/image/video; \$0.05 audio > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Tools and thinking > * Earliest announced shutdown: May 7, 2027; replacement: `gemini-3.5-flash-lite` > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3.1-flash-lite) > **gemini-3-flash-preview** > > `google/gemini-3-flash-preview` > > * Text/image/video input / text output: \$0.50 / \$3 > * Audio input: \$1 > * Cached input: \$0.05 text/image/video; \$0.10 audio > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Tools and thinking > * Preview; no shutdown date announced. Prefer a current stable Flash model for new applications. > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-3-flash-preview) Google's [Gemini 3 guide](https://ai.google.dev/gemini-api/docs/gemini-3) independently lists the 3.1 Pro, 3.1 Flash-Lite, and 3 Flash base prices. For current Standard prices and modality differences, the dedicated pricing page takes precedence over older preview summaries. Do not derive every cache tier from a flat multiplier: for example, 3.1 Pro's Batch/Flex cache read rate stays at the Standard rate, while 3.5 Flash-Lite's pricing page rounds Batch/Flex cached input to \$0.02. ## Gemini 2.5 legacy access > [!WARNING] > > Since September 18, 2026, Google limits Gemini 2.5 access to previous users. The general-purpose 2.5 Pro, Flash, > and Flash-Lite models are **not deprecated** and have no announced shutdown date. For new projects use 3.5 Flash-Lite or 3.8 Flash. > See the [access announcement](https://ai.google.dev/gemini-api/docs/changelog#september-18-2026). > **gemini-2.5-pro** > > `google/gemini-2.5-pro` > > * Input / output: \$1.25 / \$10 at up to 200,000 input tokens > * Above 200,000 input tokens: \$2.50 / \$15 for the entire request > * Cached input: \$0.125; \$0.25 above 200,000 input tokens > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro) > **gemini-2.5-flash** > > `google/gemini-2.5-flash` > > * Text/image/video input / text output: \$0.30 / \$2.50 > * Audio input: \$1 > * Cached input: \$0.03 text/image/video; \$0.10 audio > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash) > **gemini-2.5-flash-lite** > > `google/gemini-2.5-flash-lite` > > * Text/image/video input / text output: \$0.10 / \$0.40 > * Audio input: \$0.30 > * Cached input: \$0.01 text/image/video; \$0.03 audio > * Input limit: 1,048,576 tokens; output limit: 65,536 > * Multimodal input, text output, tools, and thinking > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash-lite) ## Image and speech models These require native Gemini media APIs. Image generation is available through Timbal's GeminiImages tools; audio synthesis is not provided by the Google Agent chat route. Media rates must not be used as text-output rates. > **gemini-2.5-flash-image — migrate** > > `google/gemini-2.5-flash-image` > > Google's [deprecation schedule](https://ai.google.dev/gemini-api/docs/deprecations) now lists **March 15, 2027**, > conflicting with the older October 2, 2026 notice still on the pricing page. The catalog retains this compatibility ID > and records the conflict. Google recommends `gemini-3.1-flash-lite-image` as its replacement. > > * Text/image input: \$0.30; text output: \$2.50 > * Image output: \$30 per million image tokens (about \$0.039 per image) > * Input and output limits: 32,768 tokens each > > [Pricing and shutdown notice](https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image) > **gemini-2.5-pro-preview-tts** > > `google/gemini-2.5-pro-preview-tts` > > * Text input: \$1; audio output: \$20 > * Input limit: 8,192 tokens; output limit: 16,384 > * Native TTS only; no tools, thinking, or caching > * Catalog text-output price is null because this model outputs audio > * Shuts down **November 17, 2026**; migrate to `gemini-3.8-flash-tts` or `gemini-3.8-flash-lite-tts` > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro-preview-tts) > **gemini-2.5-flash-preview-tts** > > `google/gemini-2.5-flash-preview-tts` > > * Text input: \$0.50; audio output: \$10 > * Input limit: 8,192 tokens; output limit: 16,384 > * Native TTS only; no tools, thinking, or caching > * Catalog text-output price is null because this model outputs audio > * Shuts down **November 17, 2026**; migrate to `gemini-3.8-flash-tts` or `gemini-3.8-flash-lite-tts` > > [Model specifications](https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash-preview-tts) The [current native model catalog](https://ai.google.dev/gemini-api/docs/models) also includes `gemini-3.8-flash-tts`, `gemini-3.8-flash-lite-tts`, `gemini-3.8-live`, `gemini-3.8-live-extended-thinking`, `gemini-3.5-transcribe`, `gemini-3.5-transcribe-live`, `gemini-3.5-live-translate-preview`, `gemini-omni-1.1-flash`, and the stable image models `gemini-3.1-flash-image`, `gemini-3.1-flash-lite-image`, and `gemini-3-pro-image`. It now also includes `gemini-nano-banana-2.1`. Their native endpoints and output types require separate integrations; listing them here does not register them as Agent chat models. Use the [native pricing sections](https://ai.google.dev/gemini-api/docs/pricing) for their modality-specific rates. ## Cost accounting boundaries The catalog's `input_price`, `output_price`, and `cached_input_price` describe text rates; `long_context` records full-request thresholds. Audio, generated images/video, grounding calls, and cache storage are not represented by these fields and are not included in text-only cost estimates. Even when a model prices audio input like text, that does not add an audio charge to the text cost table. Cached audio also has its own rates on some models; do not apply the text-cache rate to it. Standard cache storage for 3.1 Pro and 2.5 Pro is \$4.50 per million token-hours; the other listed general-purpose Flash models at post-promotion rates use \$1. Google Search grounding on Gemini 3 is billed per individual search query after its shared monthly allowance, while Gemini 2.5 uses grounded-prompt billing after its daily allowance. A request can trigger multiple search queries. Consult [Google's billing details](https://ai.google.dev/gemini-api/docs/pricing) before estimating an entire workflow. # Groq Source: https://docs.timbal.ai/models/groq Ultra-low latency inference via Groq LPU with specs, pricing, and capabilities > [!NOTE] > > Verified 2026-10-03 against [model catalog](https://console.groq.com/docs/models), [individual model specs](https://console.groq.com/docs/model/qwen/qwen3.8-27b), [cache pricing](https://console.groq.com/docs/prompt-caching), and [deprecations](https://console.groq.com/docs/deprecations). Prices are USD per 1M tokens at standard online rates. Model IDs use the `groq/` prefix. Dedicated endpoints require provisioning and use deployment-specific billing. ## All Models > **qwen/qwen3.8-27b** > > Reasoning · Speed > > `groq/qwen/qwen3.8-27b` > > Qwen3.8 27B multimodal with hybrid thinking/instruct modes, delivered at Groq's ultra-low latency. Preview tier; successor to Qwen3.6 27B in this catalog (3.6 retired for free/developer accounts on September 14, 2026). > > * \$0.8 / \$4 per 1M input / output tokens > > * 131,072 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Preview tier. Qwen3.6 27B was retired for free/developer accounts on 2026-09-14. See [https://console.groq.com/docs/deprecations](https://console.groq.com/docs/deprecations) > **openai/gpt-oss-120b** > > 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.6 per 1M input / output tokens > * Cached input: \$0.075 per 1M tokens > * 131,072 tokens context > * Text input > * Reasoning > **openai/gpt-oss-20b** > > 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.3 per 1M input / output tokens > * Cached input: \$0.0375 per 1M tokens > * 131,072 tokens context > * Text input > * Reasoning # Moonshot (Kimi) Source: https://docs.timbal.ai/models/moonshot Kimi K3 and K2.x models via Moonshot's OpenAI-compatible API > [!NOTE] > > Source: [Kimi API Platform](https://platform.kimi.ai). All model IDs use the prefix `moonshot/`. Base URL: `https://api.moonshot.ai/v1`. ## Flagship > **kimi-k3** > > 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` (`"low"`, `"high"`, or `"max"`; default `"max"`). 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 > **kimi-k2.7-code** > > Reasoning · Speed > > `moonshot/kimi-k2.7-code` > > Coding-focused multimodal model with always-on thinking for long-context programming agents. Leave sampling parameters at their fixed defaults and use `tool_choice="auto"` or `"none"`. > > * \$0.95 / \$4 (cache-hit input \$0.19) > * 256K context > * Text, Image, Video input > * Thinking > **kimi-k2.7-code-highspeed** > > 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 > **kimi-k2.6** > > 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 ## 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()) ``` ## Cache billing and lifecycle K3 cache reads cost \$0.30 per million tokens. Cache writes cost \$3 for the default 5-minute TTL or \$6 for 1 hour. Reads, writes, and uncached input are disjoint; do not add a write charge on top of ordinary input for the same tokens. The catalog stores the 5-minute write rate. The current Chat Completions collector does not distinguish write TTLs, so 1-hour writes require separate reconciliation against provider billing. See [cache pricing and usage fields](https://platform.kimi.ai/docs/guide/context-caching). K3 requires a successful account top-up of at least \$1. [K3 specifications](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart) and the [model list](https://platform.kimi.ai/docs/models) were checked October 3, 2026. K2.5 and Moonshot V1 were discontinued August 31; the K2 series was discontinued May 25. These are already absent from this provider's catalog. All listed token and cache prices were verified October 3, 2026 from the [pricing page's embedded model tables](https://platform.kimi.ai/docs/pricing/chat), crosschecked against the [platform model cards](https://platform.kimi.ai/) and [cache guide](https://platform.kimi.ai/docs/guide/context-caching). HighSpeed pricing is published in the detailed table only. Prices exclude applicable taxes. # OpenAI Source: https://docs.timbal.ai/models/openai GPT-6, GPT-5, GPT-4, and o-series models with specs, pricing, and capabilities > [!NOTE] > > Source: [OpenAI model docs](https://platform.openai.com/docs/models) · [API pricing](https://developers.openai.com/api/docs/pricing). Prices are USD per million tokens at Standard tier, checked October 3, 2026. Published post-promotion rates take precedence; promotional rates are retained when no post-promotion rates are published. Processing discounts and premiums are model-specific; see the GPT-6 notes below. ## GPT-6 Series > **gpt-6-astra** > > Reasoning · Speed > > `openai/gpt-6-astra` > > OpenAI's most capable model, built for the hardest end-to-end work: complex reasoning, agentic coding, computer use, research, and document creation. Successor to GPT-5.6 Sol. `reasoning.effort` accepts `low`, `medium`, `high`, `xhigh`, and `max` (API default `low`). > > * \$10 / \$50 per 1M tokens (input / output); cached input \$1 / 1M; cache writes \$12.50 / 1M > * Prompts over 272K input tokens bill the **full request** at long-context rates: \$20 / \$75, cached \$2, cache writes \$25 > * Function tools require the Responses API (Timbal's default). With `TIMBAL_OPENAI_API=chat_completions`, tool calls return 400 and `reasoning_effort: "none"` is rejected > * 1.05M context > * 128K max output > * Text, Image input > * Extended thinking > * Web search > * Knowledge cutoff Apr 30, 2026 > **gpt-6.1-sol** > > `openai/gpt-6.1-sol` > > Complex coding, computer use, and professional work. Released September 29, 2026. > > * \$2 input / \$10 output per 1M tokens; cached input \$0.10; cache writes \$2.50 > * Above 272K input tokens, the full request costs \$4 / \$15; cached input \$0.20; cache writes \$5 > * 1.05M context; 128K max output; text and image input, text output > * Reasoning: `low`, `medium` (default), `high`, `xhigh`, `max`; `none` and `minimal` are unsupported > * Function tools require Responses. Chat Completions supports requests without tools > * [Model reference](https://developers.openai.com/api/docs/models/gpt-6.1-sol), crosschecked against [API pricing](https://developers.openai.com/api/docs/pricing) and [release notes](https://developers.openai.com/api/docs/changelog) > **gpt-6-sol** > > `openai/gpt-6-sol` > > For complex coding and agentic workflows. > > * \$2 / \$10 per 1M tokens (input / output); cached input \$0.20; cache writes \$2.50 > * Over 272K input tokens: \$4 / \$15; cached input \$0.40; cache writes \$5, applied to the full request > * 1.05M context > * 128K max output > * Text and Image input; Text output > * Streaming, function calling, and structured outputs > * Reasoning: `none`, `low`, `medium` (default), `high`, `xhigh`, `max` > * Knowledge cutoff Apr 20, 2026 > **gpt-6-luna** > > `openai/gpt-6-luna` > > For focused tasks and high-volume workloads at low cost. > > * \$0.10 / \$0.50 per 1M tokens (input / output); cached input \$0.01; cache writes \$0.125 > * Over 272K input tokens: \$0.20 / \$0.75; cached input \$0.02; cache writes \$0.25, applied to the full request > * 1.05M context > * 128K max output > * Text and Image input; Text output > * Streaming, function calling, and structured outputs > * Reasoning: `none`, `low`, `medium` (default), `high`, `xhigh`, `max` > * Knowledge cutoff May 18, 2026 ### Using GPT-6.1 Sol and GPT-6 Set `OPENAI_API_KEY` and choose `openai/gpt-6.1-sol`: ```python theme={"dark"} from timbal import Agent agent = Agent( name="assistant", model="openai/gpt-6.1-sol", max_tokens=4096, model_params={"reasoning": {"effort": "medium"}}, ) ``` Use the Responses API (Timbal's default) for reasoning with tools. With `TIMBAL_OPENAI_API=chat_completions`, function calling on the older GPT-6 Sol and GPT-6 Luna requires `model_params={"reasoning_effort": "none"}`. GPT-6.1 Sol and Astra require Responses for tool calling. Astra and GPT-6.1 Sol offer [Ultrafast](https://developers.openai.com/api/docs/guides/ultrafast-mode) at **6x** Standard, including cache and long-context rates, using `model_params={"service_tier": "ultrafast"}`. Usage uses `_ultrafast` units. Sol's Ultrafast input / output / cached input / cache writes cost **\$12 / \$60 / \$0.60 / \$15** per 1M tokens; above 272K input tokens they cost **\$24 / \$90 / \$1.20 / \$30** for the full request. Availability and [rates](https://developers.openai.com/api/docs/pricing) rechecked October 17, 2026. All GPT-6 prices shown are Standard rates. Batch and Flex cost half the applicable rates; Fast mode costs twice as much, including cache and long-context rates. Opt into Flex or Fast with `model_params={"service_tier": "flex"}` or `{"service_tier": "fast"}`; metering follows the tier reported by the provider. Regional processing adds 10% where eligible. GPT-6 Sol and Luna support EU data residency with Standard, Flex, and Batch; Fast is unavailable there. GPT-6.1 Sol also supports EU residency without Fast. Regional premiums are not included in the catalog token rates. Sol and Luna launched September 22, 2026. Specs and prices verified September 23, 2026 against [Sol's model reference](https://developers.openai.com/api/docs/models/gpt-6-sol), [Luna's model reference](https://developers.openai.com/api/docs/models/gpt-6-luna), and [OpenAI pricing](https://developers.openai.com/api/docs/pricing). ### How long-context and cache pricing is tracked GPT-6.1 Sol, GPT-6 Astra/Sol/Luna, GPT-5.6 Sol/Terra/Luna, GPT-5.5/Pro, GPT-5.4 and GPT-5.4 Pro bill the **entire request** at long-context rates once the prompt exceeds **272K input tokens** — including cache reads, cache writes, and output. (GPT-5.5 Pro also has a long-context tier in the pricing table, although its model card omits it; no cache discount is published.) Timbal's collectors decide the tier from the raw prompt size reported by OpenAI (cache hits included; exactly 272K is still short context) and emit distinct usage units so each tier can be priced separately: | Request | Usage keys on `OutputEvent.usage` | | - | - | | ≤ 272K input | `input_text_tokens`, `input_cached_tokens`, `input_cache_write_tokens`, `output_text_tokens` | | > 272K input | the same units suffixed `_long_context`, e.g. `openai/gpt-6-astra:output_text_tokens_long_context` | `input_cache_write_tokens` is split out only for models whose catalog entry has a `cache_write_price` (OpenAI bills prompt-cache writes at 1.25x the input rate); on any other model those tokens stay in `input_text_tokens` so they are still billed. Reasoning tokens are folded into `output_text_tokens` (they are billed as output). GPT-6 responses served by Flex or Fast processing additionally use `_flex` or `_fast` (for example, `output_text_tokens_long_context_fast`) so the processing multiplier is preserved. Eval usage assertions treat all pricing tiers as the same underlying tokens. The per-model thresholds and tier rates live in `python/timbal/models.yaml` (`cached_input_price`, `cache_write_price`, `long_context`, `service_tiers`). *** ## GPT-5 Series > **gpt-5.5** > > 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 > **gpt-5.5-pro** > > 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 > * Above 272K input: \$60 / \$270 per 1M tokens for the full request, per the [pricing table](https://developers.openai.com/api/docs/pricing) > **gpt-5.5-2026-04-23** > > Reasoning · Speed > > `openai/gpt-5.5-2026-04-23` > > Dated snapshot of GPT-5.5 for reproducible runs; same capabilities and pricing as the rolling `gpt-5.5` slug. > > * \$5 / \$30 per 1M tokens (input / output); cached input \$0.50 / 1M > * 1.05M context > * 128K max output > * Text, Image input > * Extended thinking > **gpt-5.6-sol** > > 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. > > * \$4 input / \$20 output per 1M tokens; cached input \$0.40; cache writes \$5 > * Above 272K input: \$8 / \$30; cached input \$0.80; cache writes \$10 for the full request > * Promotional rates, available at least through November 21, 2026, retained because post-promotion rates are not published. Sources: [model card](https://developers.openai.com/api/docs/models/gpt-5.6-sol), [pricing table](https://developers.openai.com/api/docs/pricing). > * 1.05M context > * 128K max output > * Text, Image input > * Extended thinking > * Web search > **gpt-5.6-terra** > > 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 / \$12 per 1M tokens (input / output); cached input \$0.20 / 1M > * 1.05M context > * 128K max output > * Text, Image input > * Extended thinking > * Web search > **gpt-5.6-luna** > > Reasoning · Speed > > `openai/gpt-5.6-luna` > > GPT-5.6 fast, cost-efficient tier for high-volume, latency-sensitive, and budget-conscious workloads. > > * \$0.20 / \$1.20 per 1M tokens (input / output); cached input \$0.02 / 1M > * 1.05M context > * 128K max output > * Text, Image input > * Extended thinking > * Web search > **gpt-5.4** > > 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 > * Cached input: \$0.25 per 1M tokens > **gpt-5.4-pro** > > 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 > **gpt-5.4-mini** > > 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 > * Cached input: \$0.075 per 1M tokens > **gpt-5.4-nano** > > Reasoning · Speed > > `openai/gpt-5.4-nano` > > Deprecated October 1, 2026; scheduled retirement April 1, 2027. Migrate before that date. > > 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 > * Cached input: \$0.02 per 1M tokens > **gpt-5.2** > > 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 > * Cached input: \$0.175 per 1M tokens > **gpt-5.2-pro** > > 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 > **gpt-5.1** > > Reasoning · Speed > > `openai/gpt-5.1` > > Deprecated October 1, 2026; scheduled retirement April 1, 2027. Migrate before that date. > > 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 > * Cached input: \$0.125 per 1M tokens > **gpt-5** > > 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 > * Cached input: \$0.125 per 1M tokens > **gpt-5-mini** > > 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 > * Cached input: \$0.025 per 1M tokens > **gpt-5-nano** > > 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 > * Cached input: \$0.005 per 1M tokens *** ## GPT-4 Series > **gpt-4.1** > > 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 > * Cached input: \$0.5 per 1M tokens > **gpt-4.1-mini** > > 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 > * Cached input: \$0.1 per 1M tokens > **gpt-4.1-nano** > > 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 > * Cached input: \$0.025 per 1M tokens > **gpt-4o** > > 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 > * Cached input: \$1.25 per 1M tokens > **gpt-4o-mini** > > 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 > * Cached input: \$0.075 per 1M tokens *** ## o-Series (Reasoning) > **o4-mini** > > 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 > * Cached input: \$0.275 per 1M tokens > **o3** > > 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 > * Cached input: \$0.5 per 1M tokens > **o3-mini** > > 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 > * Cached input: \$0.55 per 1M tokens > **o3-pro** > > 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 > **o1** > > 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 > * Cached input: \$7.5 per 1M tokens > [!NOTE] > > Reasoning tokens are billed as output tokens on all o-series models. ## Retirement review — October 3, 2026 The active catalog no longer includes `gpt-5.1-codex`, `o3-deep-research`, or `o4-mini-deep-research`: their aliases and snapshots shut down July 23, 2026. OpenAI recommends GPT-5.6 Sol as the replacement. GPT-4.1 Nano, o1, o3 Mini, and o4 Mini remain listed until their October 23, 2026 shutdown. GPT-5.1 and GPT-5.4 Nano remain available until April 1, 2027. A newer generation alone is not a retirement notice. See [OpenAI deprecations](https://developers.openai.com/api/docs/deprecations). Specialized audio, image, Live, and restricted-access research models need their own endpoints and billing units; they are not general `Agent` text-model additions. Availability here reflects public documentation, not an authenticated access check for your account. # Overview Source: https://docs.timbal.ai/models/overview Specs, pricing, and capabilities for every supported model Model specifications, availability, and pricing reviewed October 3, 2026, with Haiku 5.5 and Sonnet 5.5 cache pricing updated October 7, 2026. **Price** means USD per 1M text tokens (input / output) at the provider's Standard short-context tier, preferring published post-promotion rates. Where only promotional rates are published, those rates are retained and explicitly labeled. Long-context thresholds vary by model: Anthropic Haiku 5.5 >100K, OpenAI >272K, Google Pro >200K, xAI ≥200K, and selected BytePlus models >128K input tokens. These models reprice the whole request; Timbal reports distinct `_long_context` usage keys — see the [OpenAI page](/models/openai#how-long-context-and-cache-pricing-is-tracked). Dedicated deployments, restricted access, unknown prices, conflicting sources, and future retirement dates are noted on each provider page. Unknown prices are not free. Audio, image generation, tool calls, cache storage, regional premiums, and time-dependent pricing need separate billing. Model capabilities do not imply every provider-native endpoint is available through `Agent`. `scripts/generate_costs_sql.py` generates catalog token rates, including published cache, long-context, and supported service-tier rates. It omits unknown prices and dedicated deployments instead of estimating them. It also omits ambiguous OpenAI search fees because the current usage metric does not distinguish standard search from the more expensive non-reasoning preview search. Generated SQL updates matching rates but does not delete existing rows for retired or omitted models; review those rows before applying a billing update. > **[Anthropic](/models/anthropic)** > > Claude Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 > **[BytePlus](/models/byteplus)** > > Dola Seed 2.1, Seed 2.0, DeepSeek, and GLM > **[Cerebras](/models/cerebras)** > > Wafer-scale inference at world-record token speeds > **[Fireworks](/models/fireworks)** > > Open-source models via Fireworks > **[Google](/models/google)** > > Gemini 3.8, 3.7, earlier Gemini models, and Gemini 4 availability > **[Groq](/models/groq)** > > Ultra-low latency via Groq LPU > **[Moonshot (Kimi)](/models/moonshot)** > > Kimi K3 / K2.x via Moonshot's OpenAI-compatible API > **[OpenAI](/models/openai)** > > GPT-6.1 Sol, GPT-6 Astra/Luna, and earlier GPT and o-series models > **[SambaNova](/models/sambanova)** > > High-throughput inference on custom RDU hardware > **[Timbal Auto](/models/timbal)** > > Platform model routing per turn: `timbal/auto`, `timbal/auto-cost`, `timbal/auto-intelligence` > **[TogetherAI](/models/togetherai)** > > Open-source models via TogetherAI > **[xAI](/models/xai)** > > Grok 4.7, Grok Build 0.1, and earlier Grok models > **[Xiaomi MiMo](/models/xiaomi)** > > MiMo V2.5 and V2.5 Pro ## Automatic routing: `timbal/auto` Set `model="timbal/auto"` (or `timbal/auto-cost`, `timbal/auto-intelligence`) and the Timbal platform picks the model and reasoning effort for each turn, with cross-vendor fallbacks. See [Timbal Auto](/models/timbal). ## 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. GPT-6 Astra, Claude Fable 5.1, o3-pro, 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 > [!NOTE] > > Verified 2026-10-03 against [model catalog](https://docs.sambanova.ai/docs/en/models/sambacloud-models), [pricing](https://cloud.sambanova.ai/plans/pricing), and [release notes](https://docs.sambanova.ai/docs/en/release-notes/sambacloud). Prices are USD per 1M tokens at standard online rates. Model IDs use the `sambanova/` prefix. Dedicated endpoints require provisioning and use deployment-specific billing. ## DeepSeek > **DeepSeek-V3.2** > > Reasoning · Speed > > `sambanova/DeepSeek-V3.2` > > DeepSeek V3.2 running on SambaNova RDU hardware. > > * \$3 / \$4.5 per 1M input / output tokens > > * 32,768 tokens context > > * Text input > > > [!NOTE] > > > > Preview. SambaCloud lists 32k context; structured output may fail per release notes. > **DeepSeek-V3.1** > > Reasoning · Speed > > `sambanova/DeepSeek-V3.1` > > DeepSeek V3.1 running on SambaNova RDU hardware. > > * \$3 / \$4.5 per 1M input / output tokens > * 131,072 tokens context > * Text input ## Meta Llama > **Meta-Llama-3.3-70B-Instruct** > > Reasoning · Speed > > `sambanova/Meta-Llama-3.3-70B-Instruct` > > Meta Llama 3.3 70B instruction-tuned model running on SambaNova RDU hardware. > > * \$0.6 / \$1.2 per 1M input / output tokens > * 131,072 tokens context > * Text input ## Google Gemma > **gemma-4-31B-it** > > Reasoning · Speed > > `sambanova/gemma-4-31B-it` > > Google Gemma 4 31B instruction-tuned model on SambaNova RDU hardware. > > * \$0.38 / \$1.15 per 1M input / output tokens > > * 131,072 tokens context > > * Text, Image, Video input > > > [!NOTE] > > > > Preview. Model catalog lists 128k context; July release notes mention 256k for text-only requests. Catalog uses the conservative 128k limit. No audio input. ## OpenAI OSS / MiniMax > **gpt-oss-120b** > > 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 per 1M input / output tokens > * 131,072 tokens context > * Text input > * Reasoning > **MiniMax-M2.7** > > Reasoning · Speed > > `sambanova/MiniMax-M2.7` > > MiniMax M2.7 large-scale MoE running on SambaNova RDU hardware. > > * Pricing not publicly verified > > * 196,608 tokens context > > * Text input > > > [!NOTE] > > > > Still listed as a production model, but the current public pricing table omits M2.7. Token and cache rates are unverified; do not reuse M3 pricing. See [https://cloud.sambanova.ai/plans/pricing](https://cloud.sambanova.ai/plans/pricing) ## Newly verified models > **MiniMax M3 (SambaNova)** > > `sambanova/MiniMax-M3` > > MiniMax M3 preview on SambaCloud; this deployment accepts text only. > > * \$0.6 / \$2.4 per 1M input / output tokens > > * Cached input: \$0.06 per 1M tokens > > * 1,000,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Preview. Image and video requests are rejected by SambaCloud. Context advertised as 1M. [https://docs.sambanova.ai/docs/en/models/sambacloud-models](https://docs.sambanova.ai/docs/en/models/sambacloud-models) # Timbal Auto Source: https://docs.timbal.ai/models/timbal Let the platform pick the model for each turn > [!NOTE] > > `timbal/auto*` models are served by the Timbal platform: there is no vendor key for them, and they need a platform subject (a deployed app, or `TIMBAL_API_KEY` with an app). Set `model="timbal/auto"` and the Timbal platform picks the model and reasoning effort for each turn, from the conversation and the attachments on the latest user message: a quick language turn, tool or retrieval work, or long reasoning each land on a different tier. Nothing else changes: no configuration and no new API. Routing adds roughly 250 ms once per turn and a routing fee of about \$0.0001 per turn next to the served model's own usage. ```python theme={"dark"} from timbal import Agent agent = Agent(name="assistant", model="timbal/auto", system_prompt="...", tools=[...]) ``` ## Models > **auto** > > `timbal/auto` > > Balanced. Alias: `timbal/auto-balanced`. > > * Claude Haiku 5.5, Sonnet 5.5 and Opus 5.5 by tier > * GPT-6 Luna for long-text digests > * 500K context > **auto-cost** > > `timbal/auto-cost` > > Haiku 5.5 for fast turns, Sonnet 5.5 for the rest, GPT-6 Luna for long-text digests; Astra specialists off. > > * 500K context > **auto-intelligence** > > `timbal/auto-intelligence` > > Opus 5.5 at every tier, with effort rising by tier; specialists on. > > * 1M context ## Tiers and fallbacks The three models share the same router and differ in which models, and at which reasoning effort, fill the tiers. Each tier carries ordered fallbacks: if a model answers 429/5xx before streaming, is unreachable, or is disabled by the organization's model policies, the request moves to the next one, cross-vendor where possible. | Model | fast | medium | extended | | - | - | - | - | | `timbal/auto` | Haiku 5.5 · low → GPT-6 Luna → Gemini 3.5 Flash-Lite | Sonnet 5.5 · high → Grok 4.7 → GPT-6.1 Sol | Opus 5.5 · max → GPT-6 Astra → Grok 4.7 | | `timbal/auto-cost` | Haiku 5.5 · low → GPT-6 Luna → Gemini 3.5 Flash-Lite | Sonnet 5.5 · high → Grok 4.7 | Sonnet 5.5 · xhigh → Grok 4.7 | | `timbal/auto-intelligence` | Opus 5.5 · medium → Sonnet 5.5 → GPT-6 Astra → Grok 4.7 | Opus 5.5 · high → GPT-6 Astra → Grok 4.7 | Opus 5.5 · max → Fable 5.1 → GPT-6 Astra → Grok 4.7 | Fast turns with a prompt above about 100K tokens go to GPT-6 Luna first, because Haiku 5.5 bills those at 5× its base rate. Requests that carry tools skip GPT-6.1 Sol and GPT-6 Astra, which don't accept tools on this path, and GPT-6 Luna serves them with reasoning off. In `timbal/auto` and `timbal/auto-cost`, two kinds of turn skip the medium tier: * **Long-text digests**: summarizing, extracting from, translating or answering questions about more than about 30K tokens the user provided (pasted text or attached pages) go to GPT-6 Luna · medium → Haiku 5.5 → Gemini 3.5 Flash-Lite instead of Sonnet 5.5. Analysis of the same text, or building a file from it, stays on the tiers above. * **Recordings**: transcribing or summarizing an audio or video attachment goes to Gemini 3.5 Flash-Lite → Gemini 3.8 Flash. Other audio or video work goes to Gemini 3.8 Flash → Gemini 3.5 Flash-Lite in every model; only Google takes audio and video natively, so those fallbacks stay on Google. 3D or CAD as code and hard math or science go to GPT-6 Astra (`timbal/auto` and `timbal/auto-intelligence` only). A request that already sets `reasoning_effort` keeps it on every model, mapped to the nearest level that model accepts. `timbal/auto-intelligence` sends every turn, small talk included, to a frontier model; that is the point of the profile. Why these models: on GDPval-AA (documents, spreadsheets and slides judged by professionals) Opus 5.5 and Sonnet 5.5 lead, Haiku 5.5 outscores GPT-6 Astra, and effort level moves scores more than model choice. Astra leads 3D-as-code, CAD and frontier math; Grok 4.7 is the best-calibrated non-Anthropic model for deliverables. GPT-6 Luna keeps its base price up to 272K tokens and is close to Opus 5.5 on long-context reasoning, which makes it the cheap reader for long documents. Gemini 3.5 Flash-Lite is the cheapest model with native audio and video input and the third vendor behind the fast tier. The maps are platform configuration and are re-checked against public leaderboards monthly. ## How it behaves in the SDK * **Endpoint**: always the platform chat-completions proxy, whatever `TIMBAL_OPENAI_API` is set to, because that is the only request shape the proxy can re-target across providers. * **Attachments**: the SDK names the latest turn's attachments in `metadata.timbal_attachments`; the platform removes `timbal_*` keys before the request reaches a provider. * **One model per turn**: the model is chosen once per turn, so every call of an agent's tool loop uses the same model. * **Usage and traces**: recorded under the model that served the turn (for example `anthropic/claude-haiku-5-5`), not `timbal/auto`. * **Context window**: each entry declares the smallest window among the models it can route to, so memory compaction and attachment bounding stay safe whichever model serves. * **Named configurations**: `timbal/auto:` (or `timbal/auto-cost:`, ...) routes with a configuration your organization saved under that name on the platform, and shares the profile's catalogue entry. An unknown name routes with the default and never fails the call. * **Tags**: `model_params={"metadata": {"timbal_tags": "vip,voice"}}` labels an agent's requests (customer tier, channel, workflow step) for the routing rules your organization configures. The platform removes `timbal_*` keys before the request reaches a provider. * **Response headers**: routed responses carry `x-timbal-route` (the tier), `x-timbal-routed-model` (the model that answered) and `x-timbal-route-pinned` (reused within the turn). # TogetherAI Source: https://docs.timbal.ai/models/togetherai Open-source models via TogetherAI inference with specs, pricing, and capabilities > [!NOTE] > > Verified 2026-10-03 against [serverless catalog](https://docs.together.ai/docs/serverless/models), [pricing](https://www.together.ai/pricing), and [deprecations](https://docs.together.ai/docs/deprecations). Prices are USD per 1M tokens at standard online rates. Model IDs use the `togetherai/` prefix. Dedicated endpoints require provisioning and use deployment-specific billing. ## Meta LLaMA > **Llama-3.3-70B-Instruct-Turbo** > > 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. > > * \$1.04 / \$1.04 per 1M input / output tokens > * 131,072 tokens context > * Text input *** ## Qwen > **Qwen/Qwen3.5-397B-A17B** > > 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. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Qwen3-235B-A22B-Instruct-2507-tput** > > 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. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,000 tokens context > > * Text input > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Qwen3-Coder-480B-A35B-Instruct-FP8** > > 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 deployment pricing; no fixed per-token rate > > * 262,000 tokens context > > * Text input > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Qwen3-Coder-Next-FP8** > > Reasoning · Speed > > `togetherai/Qwen/Qwen3-Coder-Next-FP8` > > Next-generation coding model with hybrid thinking mode for adaptive reasoning depth. > > * Dedicated deployment pricing; no fixed per-token rate > > * 256,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Qwen3-Next-80B-A3B-Instruct** > > 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 deployment pricing; no fixed per-token rate > > * 262,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Qwen2.5-7B-Instruct-Turbo** > > 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. > > * Dedicated deployment pricing; no fixed per-token rate > > * 128,000 tokens context > > * Text input > > > [!NOTE] > > > > Availability unresolved: marketing/pricing pages list serverless, but the current serverless catalog omits this ID. Existing dedicated-only restriction is retained pending provider confirmation; no dedicated per-token rate is assumed. *** ## DeepSeek > **DeepSeek-V3.1** > > 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 deployment pricing; no fixed per-token rate > > * 128,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **DeepSeek-V4-Pro** > > Reasoning · Speed > > `togetherai/deepseek-ai/DeepSeek-V4-Pro` > > DeepSeek V4 Pro on Together dedicated endpoints: hybrid attention, up to 512K context, strong coding and agent benchmarks. > > * Dedicated deployment pricing; no fixed per-token rate > > * 512,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) *** ## Kimi / MiniMax / GLM / Other > **Kimi-K2.6** > > Reasoning · Speed > > `togetherai/moonshotai/Kimi-K2.6` > > Moonshot Kimi K2.6 on Together dedicated endpoints: 1T-scale MoE with tool calling and JSON mode for agentic and multimodal workloads. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **Kimi-K2.7-Code** > > Reasoning · Speed > > `togetherai/moonshotai/Kimi-K2.7-Code` > > Moonshot Kimi K2.7 Code on Together for long-context programming agents with thinking mode. > > * Dedicated deployment pricing; no fixed per-token rate > > * 262,144 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **MiniMax-M2.7** > > 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 dedicated endpoints. > > * Dedicated deployment pricing; no fixed per-token rate > > * 202,752 tokens context > > * Text input > > > [!NOTE] > > > > Availability unresolved: marketing/pricing pages list serverless, but the current serverless catalog omits this ID. Existing dedicated-only restriction is retained pending provider confirmation; no dedicated per-token rate is assumed. > **MiniMax-M3** > > Reasoning · Speed > > `togetherai/MiniMaxAI/MiniMax-M3` > > MiniMax M3 multimodal model on Together for chat, agents, and long-context workloads. > > * \$0.3 / \$1.2 per 1M input / output tokens > * Cached input: \$0.06 per 1M tokens > * 524,288 tokens context > * Text, Image input > * Reasoning > **GLM-5.1** > > 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. > > * Dedicated deployment pricing; no fixed per-token rate > > * 200,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) > **GLM-5.2** > > Reasoning · Speed > > `togetherai/zai-org/GLM-5.2` > > Z.ai GLM-5.2 flagship on Together for coding, reasoning, and agentic tool use. > > * \$1.4 / \$4.4 per 1M input / output tokens > * Cached input: \$0.26 per 1M tokens > * 1,048,575 tokens context > * Text input > * Reasoning > **gpt-oss-120b** > > 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.6 per 1M input / output tokens > * 131,072 tokens context > * Text input > * Reasoning > **gpt-oss-20b** > > Reasoning · Speed > > `togetherai/openai/gpt-oss-20b` > > OpenAI's compact 20B MoE delivering o3-mini-level results on Together dedicated endpoints. Apache 2.0. > > * Dedicated deployment pricing; no fixed per-token rate > > * 128,000 tokens context > > * Text input > > * Reasoning > > > [!NOTE] > > > > Dedicated endpoint required. Provisioning is billed by GPU time; historical serverless per-token prices do not apply. Use the deployed endpoint ID. [https://docs.together.ai/docs/deprecations](https://docs.together.ai/docs/deprecations) ## Newly verified models > **Kimi K3 (Together)** > > `togetherai/moonshotai/Kimi-K3` > > Kimi K3 on Together serverless. > > * \$3 / \$15 per 1M input / output tokens > * Cached input: \$0.3 per 1M tokens > * 1,048,576 tokens context > * Text, Image input > * Reasoning > **DeepSeek V4 Pro 0813 (Together)** > > `togetherai/deepseek-ai/DeepSeek-V4-Pro-0813` > > DeepSeek V4 Pro 0813 on Together serverless. > > * \$1.32 / \$3.96 per 1M input / output tokens > * Cached input: \$0.13 per 1M tokens > * 1,048,576 tokens context > * Text input > * Reasoning > **DeepSeek V4 Flash 0731 (Together)** > > `togetherai/deepseek-ai/DeepSeek-V4-Flash-0731` > > DeepSeek V4 Flash 0731 on Together serverless. > > * \$0.14 / \$0.28 per 1M input / output tokens > * Cached input: \$0.03 per 1M tokens > * 1,048,576 tokens context > * Text input > * Reasoning > **DeepSeek V4.1 Flash (Together)** > > `togetherai/deepseek-ai/DeepSeek-V4.1-Flash` > > DeepSeek V4.1 Flash on Together serverless. > > * \$0.3 / \$1.2 per 1M input / output tokens > * Cached input: \$0.006 per 1M tokens > * 1,000,000 tokens context > * Text, Image input > * Reasoning > **GLM-5.3 (Together)** > > `togetherai/zai-org/GLM-5.3` > > GLM-5.3 on Together serverless. > > * \$1.4 / \$4.4 per 1M input / output tokens > * Cached input: \$0.26 per 1M tokens > * 1,048,575 tokens context > * Text input > * Reasoning > **GLM-5.3 Flash (Together)** > > `togetherai/zai-org/GLM-5.3-Flash` > > GLM-5.3 Flash on Together serverless. > > * \$0.15 / \$0.5 per 1M input / output tokens > * Cached input: \$0.03 per 1M tokens > * 1,048,575 tokens context > * Text, Image input > * Reasoning > **Inkling (Together)** > > `togetherai/thinkingmachines/Inkling` > > Inkling on Together serverless. > > * \$1 / \$4.05 per 1M input / output tokens > > * Cached input: \$0.17 per 1M tokens > > * 524,288 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Listed token rates cover text. Together also advertises native audio, but audio-specific billing and transport are not represented here. > **Muse Glimmer 30B (Together)** > > `togetherai/meta-models/Muse-Glimmer-30B` > > Muse Glimmer 30B on Together serverless. > > * \$0.35 / \$1.5 per 1M input / output tokens > * Cached input: \$0.04 per 1M tokens > * 131,072 tokens context > * Text, Image input > **Qwen 3.5 9B (Together)** > > `togetherai/Qwen/Qwen3.5-9B` > > Qwen 3.5 9B on Together serverless. > > * \$0.17 / \$0.25 per 1M input / output tokens > * 262,144 tokens context > * Text, Image input > * Reasoning > **Qwen 3.8 2.4T A95B (Together)** > > `togetherai/Qwen/Qwen3.8-2.4T-A95B` > > Qwen 3.8 2.4T A95B on Together serverless. > > * \$2 / \$6 per 1M input / output tokens > > * 1,000,000 tokens context > > * Text, Image input > > * Reasoning > > > [!NOTE] > > > > Cached-input pricing unresolved: serverless docs quote $0.50/1M, while pricing and model pages quote $0.25/1M. No cached rate is assigned until confirmed. [https://docs.together.ai/docs/serverless/models](https://docs.together.ai/docs/serverless/models) and [https://www.together.ai/models/qwen3-8-max](https://www.together.ai/models/qwen3-8-max) ## Retirement and pricing notes The deprecation ledger removes GLM-5, GLM-4.7, Gemma 3n E4B, Cogito v2.1, and Mistral Small 24B from serverless with no on-demand endpoint support. Gemma 3 27B was also retired from dedicated inference. These IDs are no longer in this catalog. Monthly reserved arrangements, where offered, require a separate agreement. GPT-OSS 20B and Kimi K2.7 Code remain listed above for dedicated deployments only. Qwen 3.8 cached input has conflicting official rates: \$0.50 in the serverless catalog versus \$0.25 on pricing and model pages. Its cached price is deliberately unassigned. MiniMax M2.7 and Qwen2.5 7B have conflicting availability listings; their existing dedicated restriction is retained pending confirmation. # xAI Source: https://docs.timbal.ai/models/xai Grok Build 0.1, Grok 4.7, Grok 4.6, Grok 4.5, and Grok 4.3 models with specs, pricing, and capabilities > [!NOTE] > > Source: [xAI pricing](https://docs.x.ai/developers/pricing). Web Search is $5/1K calls; X Search is $5/1K posts and \$10/1K profiles fetched. Cached input varies by model. ## Grok Build 0.1 > **grok-build-0.1** > > `xai/grok-build-0.1` > > Coding and agent model with text/image input, reasoning, function calling, and structured outputs. > > * Standard input / output / cached input: \$1 / \$2 / \$0.20 per 1M tokens > * At ≥200K prompt tokens: \$2 / \$4 / \$0.40, applied to the full request > * 256,000-token context; Batch API unsupported > * Canonical ID for the `grok-code-fast-1` aliases Verified October 3, 2026 against [model specifications](https://docs.x.ai/developers/models/grok-build-0.1) and [pricing](https://docs.x.ai/developers/pricing). Use `model="xai/grok-build-0.1"` with the existing xAI configuration. ## Grok 4.7 > **grok-4.7** > > Reasoning · Speed > > `xai/grok-4.7` > > xAI's latest flagship for coding, agentic tasks, and knowledge work. > > * \$2 / \$6 per 1M tokens (input / output); cached input \$0.50 > * At **≥200K prompt tokens**: \$4 / \$12; cached input \$1 per 1M tokens, applied to the full request > * 500K context; no separate text output limit > * Text, Image input; Text output > * Reasoning effort `low`, `medium`, `high` (default), `xhigh` > * Function calling and structured outputs; Responses and Chat Completions APIs > * Knowledge cutoff May 2026 > * Batch API not supported; Grok 4.7 Fast is available only in Cursor and Grok Build, not on the public API Set `XAI_API_KEY` and select the full model ID: ```python theme={"dark"} from timbal import Agent agent = Agent( name="coder", model="xai/grok-4.7", max_tokens=16000, model_params={"reasoning": {"effort": "high"}}, # Default Responses API path ) ``` When using `TIMBAL_OPENAI_API=chat_completions`, pass `model_params={"reasoning_effort": "high"}` instead. The Responses API always returns encrypted reasoning for Grok 4.7. Timbal preserves it automatically within Grok 4.7 tool loops and removes incompatible encrypted payloads when switching providers. A stable `prompt_cache_key` improves cache routing on Responses (`x-grok-conv-id` header on Chat Completions). Prices above are for the global API. The US regional endpoint adds 10% to input, output, and cached input at both context tiers. Timbal's catalog records global rates. Both collectors count cache hits toward the 200K threshold and emit `input_text_tokens_long_context`, `input_cached_tokens_long_context`, and `output_text_tokens_long_context` at or above it. Verified October 3, 2026: [model specifications](https://docs.x.ai/developers/models/grok-4.7), [usage guide](https://docs.x.ai/developers/grok-4-7), and [pricing](https://docs.x.ai/developers/pricing). *** ## Grok 4.6 > **grok-4.6** > > Reasoning · Speed > > `xai/grok-4.6` > > xAI's August 2026 flagship for coding, long-running agents, and ambitious interactive work. > > * \$2 / \$6 (\$4 / \$12 at ≥200K prompt tokens); cached input \$0.50 / 1M > * 500K context > * Text, Image input > * Configurable reasoning > * Web search *** ## Grok 4.5 > **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 at ≥200K prompt tokens); cached input \$0.30 / 1M > * 500K context > * Text, Image input > * Configurable reasoning > * Web search *** ## Grok 4.3 > **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 (\$2.50 / \$5.00 at ≥200K prompt tokens); cached input \$0.20 / 1M > * 1M context > * Text, Image input > * Reasoning > * Web search Priority processing, when confirmed by the response as `service_tier="priority"`, doubles all token rates. Regional, priority, Batch, and storage charges are separate from the catalog’s global standard rates. Grok 4.3 has a 20% Batch discount. Reconcile those modifiers separately; the catalog does not imply they apply to ordinary requests. # Xiaomi MiMo Source: https://docs.timbal.ai/models/xiaomi MiMo V2.5 models with specs, pricing, and capabilities > [!NOTE] > > 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 > **mimo-v2.5-pro** > > 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 per 1M tokens (input / output); cached input \$0.0036 > * 1M context > * Text input > **mimo-v2.5** > > 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 per 1M tokens (input / output); cached input \$0.0028 > * 1M context > * Text, Image, Video, Audio input Verified October 3, 2026 against Xiaomi’s [model and price cards](https://platform.xiaomimimo.com/docs/zh-CN/integration/roocode) and [release notices](https://platform.xiaomimimo.com/docs/en-US/news/v2.5-tts-release). Rates are USD per million tokens. UltraSpeed remains a closed beta with approval required; it is not included as a generally available model. MiMo Token Plan subscriptions and TTS/ASR pricing are separate products. # 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. > [!TIP] > > 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" ) ``` > [!NOTE] > > 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! > [!TIP] > > **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](https://github.com/timbal-ai/timbal). ## 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. # Background Audio Source: https://docs.timbal.ai/voice/background-audio Add a quiet continuous ambience track to the browser conversation Background audio runs continuously during a conversation. A [thinking sound](/voice/thinking-sounds) runs while tools are working, and a [spoken filler](/voice/fillers) is generated speech. Try each separately before combining them. ## Add a preset Keep a dict-based `voice_config` as in the [tool example](/voice/tools), and add: ```python theme={"dark"} agent.voice_config["ambient"] = {"source": "office", "volume": 0.2} ``` Restart the server and start a new session in `/voice`. You should hear low room tone with typing, including while neither side is speaking. Use the playground's local volume control to adjust what you hear. Available presets are `office`, `call-center`, `cafe`, `city`, and `typing`. Tracks are fetched on first use from the Timbal CDN, verified against a pinned checksum, and cached locally. `volume` defaults to `0.3` and accepts values from zero to one. ## Use your own track Set `source` to an existing server-side audio file instead of a preset. Prefer a clean loop with no intelligible speech; audio leaking through a speaker into the microphone can otherwise become a false transcript. Keep the track quiet enough that the caller can understand the assistant. The source path must exist when the config validates. A client cannot send an arbitrary file path through the configuration hello. Operator defaults use `TIMBAL_VOICE_AMBIENT_SOURCE` and `TIMBAL_VOICE_AMBIENT_VOLUME`. ## Transport limits The current playground fetches `/voice/ambience/current` and plays the loop in the browser. Its ambience picker and volume controls affect local playback. This repository's built-in media transports do not mix ambience into outgoing WebRTC, LiveKit, Twilio, or Telnyx tracks, and the server recording does not include this browser audio. A browser can play the track alongside received media, but a phone caller needs ambience mixing in the media host. Declaring `ambient` alone does not add a phone-call background track. A custom client needs access to the ambience asset endpoint or its own audio asset. Continue to [Turn-taking and interruptions](/voice/turn-taking). # Providers and Configuration Source: https://docs.timbal.ai/voice/configuration Reference for speech providers, audio formats, configuration inheritance, and operator defaults Start with [Your first voice agent](/voice/quickstart) for a working conversation. This page is the reference for changing providers and resolving configuration after the basic lessons. Set `Agent(voice_config=...)` to configure sessions created by the server. Use a sparse dict or `VoiceConfig` so the fields you omit inherit operator defaults. The server applies environment defaults, then the agent's declared settings, then allowed per-call client overrides. The client cannot supply credentials, arbitrary provider hosts, recording paths, or Python detector objects. ## Transcription providers Set `stt_provider` and optionally `stt_model`. These are adapter IDs and speech model IDs, separate from the `provider/model` IDs used by `Agent.model`. * **`deepgram`** or **`deepgram-flux`** (default): defaults to `flux-general-multi`; requires `DEEPGRAM_API_KEY`. Flux reports native end-of-turn events, so the session uses `provider` turn detection. When `DEEPGRAM_API_KEY` is unset, each session falls back to `elevenlabs`. * **`elevenlabs`**: defaults to `scribe_v2_realtime`; requires `ELEVENLABS_API_KEY`. * **`deepgram-nova`**: defaults to `nova-3`; requires `DEEPGRAM_API_KEY`. Select Nova explicitly or supply a `nova-*` model. * **`munsit`**: defaults to `munsit-en-ar`; requires `MUNSIT_API_KEY`. Reports native utterance boundaries. * **`openai`**: defaults to `gpt-transcribe`; requires `OPENAI_API_KEY`. Uses a transcription-only Realtime session with server VAD. Set `language` to a provider-supported language hint or omit it for automatic detection. Provider-specific transcription options belong in `stt_extra`. Each adapter supports its own options; switching providers does not make an option portable. ## Speech providers Set `tts_provider`, `tts_model`, and `voice` for the output provider: * **`elevenlabs`** (default): defaults to `eleven_v4_turbo`; requires `ELEVENLABS_API_KEY` and an accessible ElevenLabs voice ID. Supports incremental text through one streaming context per reply. Set `tts_model` to `eleven_flash_v2_5` for the previous default. * **`deepgram`**: defaults to `aura-2-thalia-en`; requires `DEEPGRAM_API_KEY`. The full Aura model ID identifies the voice and language. * **`fishaudio`**: defaults to `s2.1-pro`; requires `FISH_API_KEY`. Use a Fish reference voice ID for `voice` when selecting a reference voice. * **`munsit`**: defaults to `faseeh-v1-preview`; requires `MUNSIT_API_KEY`. The fallback voice is `MUNSIT_VOICE_ID` or `ar-uae-male-1`. * **`openai`**: defaults to `gpt-4o-mini-tts` and `coral`; requires `OPENAI_API_KEY`. Synthesizes complete text segments as they become ready. Use the chosen provider's voice ID. `language` controls transcription; it does not select a Deepgram Aura voice. Output style options belong in `tts_extra`. See [Speech examples](/examples/agents/tts) for detailed OpenAI and Deepgram settings. The default session audio is mono PCM16 little-endian at 16,000 Hz (`sample_rate=16000`, `encoding="pcm_s16le"`). Transports convert their wire formats where needed. OpenAI uses 24 kHz on the provider connection and requires the voice extra to resample other session rates. ## Mix providers With `OPENAI_API_KEY` configured, this selects OpenAI for transcription and speech while keeping `agent.model` as the conversation model: ```python theme={"dark"} agent.voice_config = { "stt_provider": "openai", "stt_model": "gpt-transcribe", "tts_provider": "openai", "tts_model": "gpt-4o-mini-tts", "voice": "coral", "language": "en", } ``` This assignment replaces the agent's declared config. Keep any greetings or other behavior fields you want to retain. The earlier lessons use a dict and add individual keys to preserve previous behavior; a `VoiceConfig` instance or callable needs its own update pattern. ## Turn detection See [Turn-taking and interruptions](/voice/turn-taking) for mode names, native provider boundaries, VAD endpointing, and a listening test. ## Greetings and silence See [Greetings](/voice/greetings) for fixed and generated openers, timing, and outbound behavior. [Silence and timeouts](/voice/silence) covers idle prompts, hangup, and the voice turn deadline. ## Tool-call fillers See [Spoken fillers](/voice/fillers) to enable a waiting phrase, tune generation, or repeat it during a long tool call. [Thinking sounds](/voice/thinking-sounds) are a separate browser feature. ## Operator defaults The server reads `TIMBAL_STT_PROVIDER`, `TIMBAL_STT_MODEL`, `TIMBAL_TTS_MODEL`, `ELEVENLABS_VOICE_ID` (before `TIMBAL_VOICE_ID`), `TIMBAL_VOICE_LANGUAGE`, and `TIMBAL_VOICE_GREETING`. Use `voice_config.tts_provider` to select the output provider; there is no `TIMBAL_TTS_PROVIDER` reader in the server. `TIMBAL_VOICE_FILLER=1` enables default fillers. The `TIMBAL_VOICE_FILLER_MODEL`, `TIMBAL_VOICE_FILLER_SYSTEM_PROMPT`, `TIMBAL_VOICE_FILLER_DELAY_SECS`, `TIMBAL_VOICE_FILLER_REPEAT_SECS`, and `TIMBAL_VOICE_FILLER_THINK_DELAY_SECS` variables customize them. See [Recording calls](/voice/recording) and [Background audio](/voice/background-audio) for their operator settings and transport limits. # Custom Voice Pipelines Source: https://docs.timbal.ai/voice/custom-pipelines Embed VoiceSession in your application and implement custom speech adapters ## Use VoiceSession directly `VoiceSession` consumes an async iterable of microphone PCM and emits an async event stream. Create a new session and fresh speech adapters for each live conversation: ```python theme={"dark"} from timbal import Agent from timbal.voice import ( AudioInputConfig, AudioOutput, AudioOutputConfig, OpenAIRealtimeSTT, OpenAIStreamTTS, SessionInterrupted, VoiceSession, ) async def converse(microphone_chunks, playback): agent = Agent( name="voice_assistant", model="openai/gpt-4.1-mini", system_prompt="Reply in short spoken sentences.", ) session = VoiceSession( agent=agent, stt=OpenAIRealtimeSTT(), tts=OpenAIStreamTTS(), audio_input=AudioInputConfig(model="gpt-transcribe", sample_rate=24000), audio_output=AudioOutputConfig( model="gpt-4o-mini-tts", voice="coral", sample_rate=24000, ), turn_detector="lexical", ) try: async for event in session.run(microphone_chunks): if isinstance(event, AudioOutput): await playback.write(event.data) elif isinstance(event, SessionInterrupted): await playback.clear() finally: await session.close() return session.transcript ``` `microphone_chunks` and `playback` are interfaces you implement for your device or transport. The example's bytes are mono PCM16 little-endian at 24 kHz. Set `OPENAI_API_KEY` before starting it. The session connects and closes its adapters. `Agent.voice_config` is interpreted by the built-in server; a direct `VoiceSession` receives its own constructor arguments. Supply greeting, filler, idle policy, model override, or timeout there when embedding it. Use a `PlaybackTracker` to report the position actually played by your transport. The default `BufferedPlaybackTracker` estimates playback from its buffered schedule until acknowledgements arrive. Accurate playback evidence helps the session truncate an interrupted reply to the portion the caller heard. ## Implement speech adapters Implement `SpeechToText` with `connect(AudioInputConfig)`, `push_audio(bytes)`, `commit()`, `events()`, and `close()`. Its event iterator yields `TranscriptEvent` values with `type="partial"`, `"committed"`, or `"error"`. Set `native_eou=True` only when committed transcripts represent provider-decided utterance boundaries. Implement `TextToSpeech` with `connect(AudioOutputConfig)`, `synthesize(text)`, and `close()`. `synthesize` is an async iterator of output PCM bytes. A provider supporting incremental text can implement `open_stream()` and return a `TTSStream` with `feed`, `end`, `abort`, and `audio`. Aborting must unblock the audio iterator so interruption does not leave a turn waiting for synthesis. Keep provider options in each audio config's `extra` mapping. These interfaces define the session seam; adding a custom adapter does not automatically add it to the server's provider selectors. Instantiate it directly or extend server resolution. ## Speech-to-speech extension interface `RealtimeModel` and `RealtimeSession` provide a sibling interface for a provider that owns audio input, reasoning, audio output, and turn-taking. No concrete provider implementation ships yet. The built-in `/voice` server does not select a `RealtimeSession` from `voice_config`. A `RealtimeModel` implements `connect`, `send_audio`, `events`, and `close`, and may implement `truncate(played_ms)`. It emits `RealtimeEvent` values that `RealtimeSession` maps to the same transcript, audio, interruption, and metrics event vocabulary. Conversation state stays in the provider session; this interface does not run a Timbal `Agent`, create its trace spans, or inherit its tools and memory. Latency fields for internal LLM and TTS stages remain unavailable. Implement and validate provider behavior before using this seam in production. # Spoken Fillers Source: https://docs.timbal.ai/voice/fillers Have the assistant acknowledge a slow tool call with a short spoken phrase A filler is a generated phrase such as "One moment, let me check." It can cover a tool's waiting period before the substantive answer arrives. It uses the session's speech output, so it can be heard on phone and browser transports. ## Enable one filler Keep the agent and slow lookup from [Using tools](/voice/tools). Add this line after the `Agent(...)` definition, preserving the existing greeting and language: ```python theme={"dark"} agent.voice_config["filler"] = {"enabled": True} ``` Restart the server and start a new session. Ask "Check order 101." When the lookup remains pending, the assistant can say a short waiting phrase, then give the delivery result. Turn the browser thinking sound off for this test so you can hear the filler behavior on its own. The default delay is one second; generation has a five-second timeout. A fast tool gets no filler. If generation is too slow or the reply starts first, the phrase is skipped. The default is at most one filler per turn. ## Control the wording and timing Replace that line with: ```python theme={"dark"} agent.voice_config["filler"] = { "enabled": True, "delay_secs": 1.0, "timeout_secs": 5.0, "system_prompt": ( "Write one brief, natural waiting phrase in the caller's language. " "Do not answer the question or claim the lookup succeeded. " "Return only the phrase, without quotes." ), } ``` Generation starts when the tool call is detected and overlaps the delay. The phrase is spoken only while the turn still needs a filler and no substantive reply has started. Omit `model` to use the session's LLM, or set a full `provider/model` ID to choose a separate generator. ## Longer waits For tools that legitimately take longer, opt into repeats: ```python theme={"dark"} agent.voice_config["filler"] = { "enabled": True, "delay_secs": 1.0, "repeat_secs": 6.0, "max_per_turn": 2, } ``` ## Before the first tool call A reasoning model can think for several seconds before it says or calls anything, and a tool-call filler cannot cover that. `think_delay_secs` arms the filler when the turn starts: if nothing has been said and no tool has been called that long after the user finished, the first filler is spoken anyway. A tool call before then still gets its filler after `delay_secs`, whichever comes first. A tool called after that filler gets one of its own, `delay_secs` after the later of the call and the filler, even without `repeat_secs`; `max_per_turn: 1` turns that off. Pair it with `phrases`, ready-made lines for the first filler of a turn. They are spoken with no LLM round trip, so the line lands on time; follow-ups are still generated from the request, so they can say what the assistant is working on. Within a call, every line is heard once before any repeats, and a line does not come back until at least half the others have been said. Key them by the call's language, with `"*"` for any other: ```python theme={"dark"} agent.voice_config["filler"] = { "enabled": True, "model": "google/gemini-2.5-flash-lite", "think_delay_secs": 1.5, "phrases": { "en": ["Mm, let me see.", "One second.", "Let me check."], "es": ["Mmm, déjame ver.", "Un segundo.", "Vale, lo miro."], }, "repeat_secs": 6.0, "max_per_turn": 4, } ``` The line said on the previous turn is avoided when there is another to choose. A language with no entry falls back to a generated first line. Regions are dropped on both sides, so an `"es-ES"` key serves a call configured as `"es"` and the other way round. The language is the session's configured `language`, not one the speech-to-text provider detects. With `language` unset (auto-detect), only the `"*"` lines are used, so set it per call when callers speak different languages. Only the session agent's own reply is spoken. An agent it calls as a tool streams its text through the same run, and that text — working notes, a document being drafted — is never read aloud. Repeats are bounded and stop being useful once the answer arrives or the turn is interrupted. Test with representative tool latency; repeated acknowledgements can distract from the actual answer. Fillers consume LLM and TTS usage and appear in transcripts as fillers. First-audio latency can measure a filler instead of the useful answer; see [Events and usage](/voice/observability). Set `agent.voice_config["filler"] = {"enabled": False}` to explicitly disable an inherited filler. Empty objects mean unset, rather than disabling a server default. Continue to [Background audio](/voice/background-audio) for a separate, optional room-tone layer, or [Turn-taking and interruptions](/voice/turn-taking). # Greetings Source: https://docs.timbal.ai/voice/greetings Start with a fixed hello, then control when the opener plays and how it is generated First complete [Your first voice agent](/voice/quickstart). Keep the same credentials and server command. The basic agent waits for the caller; add a greeting to make it speak when a session starts. ## A fixed hello Keep the quickstart's dict-based config and add this line after the `Agent(...)` definition: ```python theme={"dark"} agent.voice_config["greeting"] = "Hi, I'm your assistant. How can I help?" ``` Restart the server and start a new session. You should hear the opener before saying anything. A fixed greeting goes straight to speech synthesis and needs no LLM generation. ## Let the caller interrupt Greetings are non-interruptible by default. To let the caller cut the opener short, expand the string into a block: ```python theme={"dark"} agent.voice_config["greeting"] = { "text": "Hi, I'm your assistant. How can I help?", "interruptible": True, } ``` Start a new session and speak over the greeting. With interruption enabled, the opener can stop. With it disabled, the caller's committed words can still open a turn, but the reply waits behind the greeting. ## Choose when to speak `delay_ms` adds a pause before the opener. `after_user_silence_secs` gives the caller a chance to speak first; if they speak within that window, their words start the conversation instead of the greeting. ```python theme={"dark"} agent.voice_config["greeting"] = { "text": "Hello, how can I help?", "delay_ms": 300, "after_user_silence_secs": 2.0, } ``` The silence window starts after the initial delay. Try one session where you stay quiet and another where you immediately say hello. ## Generate the wording Use `instructions` when the opener needs generated wording: ```python theme={"dark"} agent.voice_config["greeting"] = { "instructions": "Introduce yourself as the support assistant and ask how you can help.", } ``` This adds an LLM call and its latency and usage. `text` wins if both fields are supplied. A fixed opener is a useful starting point before adding generation. ## Outbound calls An outbound host can select a separate opener with `outbound_greeting`: ```python theme={"dark"} agent.voice_config = { "greeting": "Hi, thanks for calling support.", "outbound_greeting": { "text": "Hi, this is support calling you back.", "after_user_silence_secs": 2.0, }, } ``` The host must identify the call as outbound. The config does not place a phone call; see [Transports and phone calls](/voice/transports). Omitting `outbound_greeting` inherits `greeting`; an empty string disables the outbound opener. Set `agent.voice_config["greeting"] = ""` to disable an inherited greeting. Empty optional objects mean unset. Continue to [Using tools](/voice/tools). # Voice Agents Source: https://docs.timbal.ai/voice/index Build live spoken conversations with streaming transcription, agents, tools, and speech output A voice agent listens to you and speaks its reply. Start with a simple browser conversation, then add one behavior at a time. The lessons reuse the same `agent.py`, credentials, and server command. ## From first conversation to a deployed application 1. **[Your first voice agent](/voice/quickstart)**: install, start, and talk. No tools, greeting, or added audio. 2. **[Greetings](/voice/greetings)**: add a fixed hello, then control timing and generated wording. 3. **[Using tools](/voice/tools)**: look up a mock delivery and hear the result. 4. **[Thinking sounds](/voice/thinking-sounds)**, **[spoken fillers](/voice/fillers)**, and **[background audio](/voice/background-audio)**: try each optional sound behavior separately. 5. **[Turn-taking and interruptions](/voice/turn-taking)** and **[silence and timeouts](/voice/silence)**: test how the conversation handles pauses, interruptions, and waiting. 6. **[Recording calls](/voice/recording)**, **[transports and phone calls](/voice/transports)**, and **[events and usage](/voice/observability)**: connect a client or carrier and operate the session. Use [Providers and configuration](/voice/configuration) as a reference when changing speech providers. Continue to [Custom pipelines](/voice/custom-pipelines) when you need to embed the session or implement an adapter. ## How it works The built-in pipeline runs a regular Timbal `Agent` between transcription and speech synthesis. It keeps the agent's tools, system prompt, memory, guardrails, and tracing. Speech providers and transports are configured separately from the language model. ```mermaid theme={"dark"} flowchart LR Mic[Caller audio] --> STT[Streaming transcription] STT --> Agent[Agent and tools] Agent --> TTS[Streaming speech] TTS --> Speaker[Caller playback] STT --> Turns[Turn detection] Turns --> Agent ``` The built-in server serves the same agent through text and voice endpoints. Open `/voice` for the browser playground, or connect your own client using WebSocket, WebRTC, LiveKit, or a Twilio/Telnyx media stream. Keep replies concise and ask one question at a time. Tables, URLs, and long lists are difficult to follow aloud. Start with a conversation you can understand and measure before adding optional behavior. ## Scope of the built-in pipeline The shipped provider adapters use a transcription → text agent → speech pipeline. `OpenAIRealtimeSTT` is a transcription-only adapter; it does not run the conversation model or synthesize responses. `RealtimeModel` and `RealtimeSession` define an extension interface for speech-to-speech models. No concrete speech-to-speech provider adapter ships yet, and the built-in server constructs `VoiceSession`. See [Custom pipelines](/voice/custom-pipelines#speech-to-speech-extension-interface) before planning an OpenAI Realtime or Gemini Live integration. For processing a recorded file, see [Audio files](/examples/agents/audio). For generating an audio file, see [Text-to-speech tools](/examples/agents/tts). # Voice Events and Usage Source: https://docs.timbal.ai/voice/observability Inspect transcripts, response latency, provider-reported usage, and call recordings ## Events and transcripts `VoiceSession.run()` yields typed events from `timbal.voice`. The server converts them to transport JSON; WebRTC and LiveKit carry speech on audio tracks instead of JSON audio frames. ```python theme={"dark"} from timbal.voice import ( AudioOutput, SessionInterrupted, TurnMetricsEvent, VoiceUsageEvent, ) # session is a VoiceSession; microphone_chunks yields PCM16 mono bytes. async for event in session.run(microphone_chunks): if isinstance(event, AudioOutput): await playback.write(event.data) elif isinstance(event, SessionInterrupted): await playback.clear() elif isinstance(event, TurnMetricsEvent): print(event.metrics.model_dump()) elif isinstance(event, VoiceUsageEvent): print(event.model_dump()) ``` `session.transcript` contains committed user and assistant entries with timestamps. Fillers are marked `filler=True`. At session end, the server sends `session_transcript` followed by `session_ended`. Transcript text reflects interruption handling and may contain only the heard prefix of a cancelled assistant reply. The stored trace follows the same rule. For an interrupted turn, the agent run's output and the assistant message in its memory hold the heard prefix, marked `metadata["interrupted"] = True`; the output is empty and the memory message dropped when nothing was heard. The model call's span keeps the full generated reply, so you can compare what was said with what was heard. ## Latency `TurnMetricsEvent` reports millisecond durations and a `run_id` for joining a voice turn to the agent trace. Unobserved stages have `None`, not zero. * `speech_end_to_transcript_ms`: speech-end evidence to the committed transcript. `speech_end_source` identifies VAD or the coarser partial-transcript estimate. * `eou_to_llm_first_token_ms`: committed transcript to the first model text delta. * `eou_to_first_audio_ms`: committed transcript to the first emitted speech bytes. This excludes transcription delay and network/client playback delay. * `turn_total_ms`, `llm_total_ms`, and `tts_total_ms`: turn and stage durations. * `interrupted`, `heard_bytes`, and `playback_acks_received`: interruption and playback evidence. * `filler_spoken` and `filler_count`: whether a latency-masking phrase was spoken. When present, first-audio latency can measure the filler rather than the substantive answer. Measure time to the useful answer as well as first audio. Compare providers on representative calls, including slow tools, short acknowledgements, background noise, and interruptions. ## Provider-reported speech usage OpenAI transcription and mini-TTS adapters emit `VoiceUsageEvent` independently of transcripts and audio. These events carry provider quantities, not dollar amounts. Other providers and custom adapters emit nothing unless they implement the usage capability. For billing, register a non-blocking listener on the adapters before the session starts. Delivery through the client connection alone can lose teardown events: ```python theme={"dark"} import asyncio from timbal.voice import VoiceUsageEvent usage_queue: asyncio.Queue[VoiceUsageEvent] = asyncio.Queue() listener = usage_queue.put_nowait session.stt.add_usage_listener(listener) session.tts.add_usage_listener(listener) # A separate consumer drains and persists the queue, including after disconnect. # Remove listeners when these adapters are no longer used. ``` Listeners are synchronous and must not block. Listener failures are logged and isolated from speech. Delivery is in-process; your consumer must persist records durably. `provider`, `operation` (`stt` or `tts`), and `model` identify the resolved operation. Deduplicate by `usage_id`; prefer a complete report over an earlier incomplete report with the same ID. `request_id` and `item_id`, when present, help reconcile provider operations. `status="complete"` means terminal provider counters arrived. `status="incomplete"` means quantities are missing, invalid, or unavailable after cancellation, error, or closure. Incomplete is not zero cost. `usage` preserves the provider object, including any partial details. OpenAI transcription can report token usage with separate input audio/text details, or duration usage with `seconds`. Mini-TTS reports input text and output audio tokens through the Speech API's SSE completion event. Legacy `tts-1` and `tts-1-hd` use PCM streaming and do not supply these SSE usage reports. Do not apply one text rate to `total_tokens` or charge both an estimate and the reported quantities. Generated or cancelled audio can be billed even when the caller never hears it. Apply the resolved speech model's published rates separately from the text agent's `OutputEvent.usage`. The text model catalog and cost SQL generator do not provide a complete speech rate card. Use an explicit estimate policy for incomplete operations, and label those estimates. ## Recordings and client audio See [Recording calls](/voice/recording) to save session audio locally or separate the speakers. [Thinking sounds](/voice/thinking-sounds) and [background audio](/voice/background-audio) are played by the browser and do not appear in the server's audio recording. Spoken fillers use speech synthesis and do appear in session audio and usage. # Your First Voice Agent Source: https://docs.timbal.ai/voice/quickstart Run a live voice agent locally and talk to it in the browser ## Install Use Python 3.11 or newer and install the server and voice extras: ```bash theme={"dark"} pip install "timbal[server,voice]" ``` Or add them to a uv project: ```bash theme={"dark"} uv add "timbal[server,voice]" ``` The server extra provides HTTP and WebSocket support. The voice extra adds WebRTC and local turn detection. Local detection models are downloaded on first use; the first session can take longer while they load. ## Configure credentials Create a `.env` beside `agent.py`: ```bash .env theme={"dark"} OPENAI_API_KEY=your_openai_key DEEPGRAM_API_KEY=your_deepgram_key ELEVENLABS_API_KEY=your_elevenlabs_key ELEVENLABS_VOICE_ID=your_accessible_voice_id ``` The example uses OpenAI for the text agent, Deepgram for transcription, and ElevenLabs for speech. Without `DEEPGRAM_API_KEY`, the server transcribes with ElevenLabs instead. Choose a voice accessible to your ElevenLabs account. Credentials remain on the server. ## Create the agent ```python agent.py theme={"dark"} from timbal import Agent agent = Agent( name="voice_assistant", model="openai/gpt-4.1-mini", system_prompt=( "You are a helpful voice assistant. Reply in one or two short spoken " "sentences. Ask one question at a time." ), voice_config={"language": "en"}, ) ``` `voice_config` configures the server's speech session. The defaults are Deepgram Flux (`flux-general-multi`) for transcription and for deciding when your turn ends, and ElevenLabs `eleven_v4_turbo` for speech. It can be a dict, a `VoiceConfig`, or a callable returning configuration. Unknown keys fail validation at server startup. ## Start and speak ```bash theme={"dark"} python -m timbal.server --import_spec agent.py::agent --host 127.0.0.1 --port 4444 ``` With uv, use `uv run python -m timbal.server` with the same arguments. The server loads `.env` from the working directory. `TIMBAL_RUNNABLE=agent.py::agent` can supply the import spec instead of the flag. Open `http://127.0.0.1:4444/voice`, allow microphone access, and start a session. Say "Hello, what can you help me with?" The agent should transcribe your words and speak a short reply. Ask a follow-up question to try a conversation. A browser microphone requires localhost or HTTPS. The agent waits for you to speak first. This example has no greeting, tools, fillers, or background audio. Leave the playground's thinking sound and ambience controls off for this first conversation. The agent is also available at `POST /run` and `POST /stream`. Voice routes require an `Agent` runnable; a `Workflow` or standalone `Tool` cannot be used as the voice conversation runnable. ## Next: let the agent say hello Keep this `agent.py`, its `.env`, and the same server command for the following lessons. After changing the file, restart the server and start a new browser session. Continue to [Greetings](/voice/greetings) to make the agent speak first, then [Using tools](/voice/tools) to give it a useful action. You can change speech providers later in [Providers and configuration](/voice/configuration). # Recording Calls Source: https://docs.timbal.ai/voice/recording Save a conversation locally, then separate caller and assistant channels Recording is opt-in and requires the voice dependencies. Configure it on the server after checking the basic conversation and [silence policy](/voice/silence). ## Save one recording Add to the earlier lessons' dict-based configuration: ```python theme={"dark"} agent.voice_config["recording"] = {"dir": "./call-recordings"} ``` Restart the server, start a session, have a short exchange, then end the session. The recorder writes an MP3 in the configured directory. Check the file after the session closes and the recorder finishes. ## Separate the speakers The default layout is mixed mono. To keep the caller on the left channel and the assistant on the right: ```python theme={"dark"} agent.voice_config["recording"] = { "dir": "./call-recordings", "layout": "split", "bitrate_kbps": 32, } ``` The default bitrate is 32 kbps. Recordings contain the session audio, including spoken fillers. Browser-local thinking sounds and ambience are not part of the server's recorded PCM. ## After the file is saved `recording.on_saved` accepts a Python async callback receiving the recording result. Use it for your storage integration after the files are written. Recordings are local files until your application uploads them; configure access, retention, and any required caller notice in your host application. Operator defaults use `TIMBAL_VOICE_RECORDING_DIR`, `TIMBAL_VOICE_RECORDING_LAYOUT`, and `TIMBAL_VOICE_RECORDING_BITRATE_KBPS`. Agent recording fields override them. File paths and callbacks are server-only settings and cannot be supplied by a browser hello. Direct `VoiceSession(record_audio=True)` keeps raw PCM in memory; it does not enable this file recorder. See [Custom pipelines](/voice/custom-pipelines) when embedding a session directly. Continue to [Transports and phone calls](/voice/transports) to connect your application, and [Events and usage](/voice/observability) to measure it. # Silence and Timeouts Source: https://docs.timbal.ai/voice/silence Add an idle check-in, then bound quiet calls and slow turns After a reply, the basic voice agent waits for the caller indefinitely. Add an idle policy when you want it to check whether the caller still needs help. ## Ask whether the caller is still there Keep the dict-based configuration from the earlier lessons and add: ```python theme={"dark"} agent.voice_config["user_idle"] = { "timeout_secs": 8.0, "text": "Are you still there?", "max_count": 1, } ``` Restart the server, have one exchange, then stay quiet after the assistant finishes. The prompt should play after the idle interval. The timer starts after playback drains and the turn finishes; it does not run while a tool is still working. ## Bound a quiet call Expand the policy to use two prompts and an eventual hangup: ```python theme={"dark"} agent.voice_config["user_idle"] = { "timeout_secs": 8.0, "text": ["Are you still there?", "Would you like any more help?"], "max_count": 2, "hangup_after_secs": 30.0, } ``` The assistant's own prompts do not reset the user-silence hangup clock. Omit `hangup_after_secs` to keep waiting after the prompts. `instructions` can generate a check-in instead of using fixed text; this adds model generation and speech usage. Empty optional blocks mean unset and preserve server inheritance. ## Recover an empty reply If the first model step finishes successfully with no speech or tool call on a caller turn, the session retries once using the same input messages. The retry shares the original voice-turn deadline and does not add another copy of the caller's prompt or the empty reply to conversation memory. Event turns may deliberately stay silent and are never retried. Turns that invoked a tool, were interrupted, failed, or paused for input or approval are also excluded. If the retry is empty too, the session returns to listening. ## A slow agent turn The voice turn timeout defaults to 60 seconds. To change it and its spoken fallback: ```python theme={"dark"} agent.voice_config["turn_timeout_secs"] = 45.0 agent.voice_config["turn_timeout_fallback"] = "Sorry, that took too long. Could you try again?" ``` On expiry the session emits an error and stays open for the next turn. The fallback is spoken only if no audio was produced. An empty string disables the fallback. This bounds the voice turn; `Agent.timeout` separately bounds the runnable's execution. Neither a timeout nor hangup rolls back completed side effects. Once the conversation behaves as intended, continue to [Recording calls](/voice/recording), [Transports and phone calls](/voice/transports), and [Events and usage](/voice/observability). # Thinking Sounds Source: https://docs.timbal.ai/voice/thinking-sounds Play a quiet browser sound while a tool is running Start with the slow lookup in [Using tools](/voice/tools). The local `/voice` playground has a **Thinking sound while tools run** checkbox. This adds a quiet keyboard loop to a tool's waiting period. ## Try it without changing Python 1. Run the tool example and open `/voice` on your local server. 2. Enable **Thinking sound while tools run** in the sound controls. 3. Start a session and ask "Check order 101." 4. Listen for typing during the lookup and for it to stop when the answer begins. 5. Disable the checkbox and repeat to compare the quiet gap. The checkbox defaults to off on a fresh browser and is remembered in that browser's local storage. It uses the `typing` ambience asset fetched from `/voice/ambience/typing`. The asset is downloaded on first use. ## When it plays The playground starts the loop on `agent_status` when a tool call is reported. It stops on reply text or speech audio, interruption, a request for approval/input, or disconnect. A spoken filler can also stop it when that speech begins. A fast tool may finish before the asset is loaded or any loop is audible. This sound indicates tool activity. It does not play throughout every LLM reasoning step and does not make the tool complete sooner. ## Where it works The sound is played locally by the browser. There is no `voice_config.thinking_sound` field, and enabling the checkbox does not add typing audio to Twilio, Telnyx, or another caller's media stream. The playground's platform target also needs an accessible ambience asset source; its local helper skips fetching when no voice HTTP base is available. A custom client can implement its own sound using the same event lifecycle. Stop and discard it when the session pauses or ends, and when speech begins. Browser-generated typing is not provider-generated speech and is not included in the server's PCM recording. [Spoken fillers](/voice/fillers) are the next step when you want the assistant itself to say something while a tool is running. For a continuous background track, see [Background audio](/voice/background-audio). # Using Tools Source: https://docs.timbal.ai/voice/tools Give a spoken assistant a function and hear it answer from the result A voice agent uses regular Timbal tools. The caller asks a question, the text agent decides to call a function, and its answer is spoken back. Speech adapters do not execute the tool themselves. ## Add one lookup After the [quickstart](/voice/quickstart) and [greetings](/voice/greetings), replace `agent.py` with this complete example. Keep the same `.env` and server command: ```python agent.py theme={"dark"} import asyncio from timbal import Agent async def get_delivery_status(order_id: str) -> dict: """Look up the delivery status of an order by its ID.""" # Demo delay so you can hear what waiting for a tool feels like. # Remove it when replacing this mock with your real service. await asyncio.sleep(4) orders = { "101": {"status": "shipped", "expected_delivery": "tomorrow"}, "102": {"status": "processing", "expected_delivery": "not yet scheduled"}, } return orders.get(order_id, {"status": "not found"}) agent = Agent( name="voice_assistant", model="openai/gpt-4.1-mini", system_prompt=( "You help callers check deliveries. Ask for the order ID if it is missing. " "Use get_delivery_status before giving a delivery status; never invent it. " "Reply in one or two short spoken sentences." ), tools=[get_delivery_status], voice_config={ "language": "en", "greeting": "Hi, I can help check your delivery. What's your order ID?", }, ) ``` Restart the server and start a new session. Say "Check order 101." You should see `Calling get_delivery_status…`, wait through the demo lookup, then hear that the order is shipped and expected tomorrow. An unknown ID should produce a not-found answer. These are mock records, not a live delivery integration. ## What the caller hears while waiting With the thinking sound off and no filler configured, a tool call can leave a quiet gap. This is a good baseline: first verify that the tool is called and its result is used. The next lessons add a [thinking sound](/voice/thinking-sounds) or a [spoken filler](/voice/fillers) to that gap. ## Speak before a tool call You can ask the model to say a short acknowledgement before calling a tool, such as "Let me check that…". Timbal flushes that text to speech while the tool runs, including phrases ending in an ellipsis. The tool does not wait for synthesis to finish. The final answer follows the acknowledgement in the same turn. For a short pause between the acknowledgement and the result, set: ```python theme={"dark"} agent.voice_config["tool_beat_secs"] = 0.6 ``` The default is `None` (disabled); `0` also disables the pause. A positive value sets the minimum silence after pre-tool playback and before post-tool synthesis. Tool execution continues during playback and the pause, so a sufficiently slow tool adds no extra wait. There is no pause before the first spoken response when the model called the tool without an acknowledgement. An enabled pause finishes the pre-tool streaming TTS context and uses a new context for the result. With the pause disabled, providers that support flushing and confirming delivery of each audio segment keep their context open across the tool call. Other providers finish the context at the boundary so interruption tracking never counts unspoken result text as heard. ## Add real actions carefully Replace the mock with an async service call when the basic flow works. For actions requiring approval, wrap the function in `Tool(handler=..., requires_approval=True)` and implement the approval UI and HTTP resume flow. The playground displays a pending request; it does not resolve approval from spoken words. See [Approval gates](/human-in-the-loop/approval-gates) and [Voice approval events](/voice/transports#approval-and-input-requests). A filler is an acknowledgement of waiting, not confirmation that an action succeeded. Give the final spoken confirmation only after the tool result supports it. ## Report back when slow work finishes Work that takes minutes should not hold the turn. Start it, let the call continue, and have the agent speak up when it lands. When the slow work is one of the agent's own tools, run it in the background and turn on `announce_background_results`. The tool returns at once, and when it finishes the agent takes a turn of its own to tell the caller: ```python agent.py theme={"dark"} import asyncio from timbal import Agent, Tool async def build_report(topic: str) -> str: """Build a sales report on a topic. It takes a while; the caller hears back when it is ready.""" await asyncio.sleep(20) # stands in for the slow job return f"Sales for {topic} are up 4% this quarter." agent = Agent( name="voice_assistant", model="openai/gpt-4.1-mini", system_prompt="You help callers with sales reports. Reply in one or two short spoken sentences.", tools=[Tool(handler=build_report, background_mode="always")], voice_config={"language": "en", "events": {"announce_background_results": True}}, ) ``` When the work happens elsewhere (another service, a job queue, a browser tab), report it with `session.notify(text)`. `current_voice_session()` returns the live session to a tool and to any task it starts: ```python theme={"dark"} from timbal.voice import current_voice_session async def on_job_finished(session, job) -> None: event = await session.notify( f"The {job.topic} report is ready: {job.summary}. Tell the caller briefly.", key=f"report:{job.id}", ) print(event.state) # "pending" until a turn takes it ``` Either way, the result reaches the model as a runtime message, never as something the caller said: it is not in the transcript or the captions, and it stays in memory for later turns. The agent waits for a quiet line (the caller not talking, its own audio finished, no greeting pending). If the caller speaks first, the result comes with their turn instead, ahead of what they said. Results waiting together are spoken in one turn, and a `notify` with the same `key` replaces one that has not been delivered yet. The model may decide there is nothing worth saying; then the agent stays quiet, and no filler plays. While the agent is waiting on the caller (an approval, `ask_user`), it does not speak up. `notify()` returns a receipt at once: `await event.settled()` reports `"delivered"` once a turn has it (`event.run_id`) or `"superseded"` when a newer event with the same key replaced it. Without `announce_background_results`, a finished background task still reaches the agent, on the caller's next turn. A client can send events over the call's data channel when the server allows it; see [Voice transports](/voice/transports#reporting-events). ### Choosing what speaks up Each tool and each event can override the call's default with an announce policy: ```python theme={"dark"} Tool(handler=book_table, background_mode="always", background_announce="when_idle") # speak up when done Tool(handler=sync_crm, background_mode="always", background_announce="silent") # wait for the caller's next turn await session.notify("The fire alarm test starts in one minute.", announce="interrupt") ``` * `"when_idle"`: take a turn once the line is quiet (the default for `notify`). * `"silent"`: never take a turn; the result reaches the caller's next turn. This is the default for background tools unless `announce_background_results` is on. * `"interrupt"`: speak now. The agent cuts its own reply short, as if the caller had barged in, and what the caller already heard stays in memory. It still waits while the agent is parked on the caller (an approval, `ask_user`). Keep it for things that cannot wait. Events from the caller's device cannot interrupt. ### Progress while the work runs A background tool can narrate its progress with `report_progress`. Each report is spoken like a `notify` once the line is quiet, a newer report replaces one not yet spoken, and the model is told the work is still running: ```python theme={"dark"} from timbal.state import report_progress async def book_flight(origin: str, destination: str) -> str: """Book a flight. Takes about a minute.""" options = await search_flights(origin, destination) report_progress(f"Found {len(options)} options; confirming the cheapest.") return await confirm(options[0]) ``` Pass `announce="silent"` to keep reports for the caller's next turn instead. ### Telling the client When the agent takes a turn of its own, the client gets an `event_turn_started` event (`task_ids`, `event_ids`, `keys`, `urgent`) before any text or audio, so a UI can show the reply as an announcement rather than an answer. Its `voice_turn_metrics` have `event: true`. A text turn on the same conversation while the call is live (a chat tab continuing from the call's run) is let in only while the call is not mid-turn. While a call turn runs, the chat turn is refused, or queued with `"events": {"on_busy": "enqueue"}` in the server's `voice_config`. Once let in, the chat turn continues from the call's latest turn, even if the chat sent an older run as its parent, so neither side loses what the other said. A caller who speaks while the chat turn runs is answered after it, and the call continues from what the chat said. An `"interrupt"` event goes ahead of a queued chat turn, but waits for one that is already running. ## Let the caller end the call `session.end_call()` hangs up once what the agent is saying has been heard. Give it to the model as a tool, and tell it to say goodbye in the same reply: ```python agent.py theme={"dark"} from timbal.voice import current_voice_session async def end_call() -> str: """End the call when the caller asks to. Say a short goodbye in this same reply.""" session = current_voice_session() if session is None: return "There is no call to end." session.end_call() return "If you have not said goodbye yet, say it now in one short sentence. The call ends once it has been heard." ``` The session waits for the turn to finish and its audio to play, then closes itself, as `session.close()` does. Some models call the tool before writing the goodbye, so the tool's result asks for one; the goodbye then comes from a second model call in the same turn, and the session still waits for it. If the caller says something first, the call stays up. If the goodbye never finishes playing, the call ends after 20 seconds anyway. Calling it again changes nothing. Continue to [Thinking sounds](/voice/thinking-sounds). # Voice Transports Source: https://docs.timbal.ai/voice/transports Connect browser clients, WebRTC, LiveKit rooms, and Twilio or Telnyx phone calls All built-in transports create the same transcription → agent → speech session. Speech credentials stay on the server. Use an `Agent` runnable and install `timbal[server,voice]`; LiveKit also needs `timbal[voice-livekit]`. First get a conversation working in the [quickstart](/voice/quickstart). Then choose one transport below. Browser-local [thinking sounds](/voice/thinking-sounds) and [background audio](/voice/background-audio) are not automatically included in a carrier's audio stream; spoken [fillers](/voice/fillers) are part of the session's speech output. ## Concurrent calls Voice admission is unbounded by default, including per-request LiveKit calls. Configure `TIMBAL_VOICE_MAX_CONCURRENT_SESSIONS` at server startup to opt into a per-process limit: * Unset, empty, or `0`: no admission cap. * A positive integer, such as `16`: admit up to that many concurrent sessions. * `auto`: derive the cap from available CPU, worker count, the server's voice configuration, and recording cost. With a cap enabled, excess calls are rejected; a LiveKit dial returns HTTP 503. Sessions are still counted when admission is unbounded. Custom hosts that bypass `timbal.server.http.lifespan` should call `configure_capacity(voice_config)` and `log_capacity()` from `timbal.server.capacity` once at startup with the server's resolved voice configuration. `active_sessions()` and `max_concurrent_sessions()` expose the process's active count and effective cap (`0` means uncapped). ## Browser playground Open `/voice` on the running server. The playground exposes provider and model selection, microphone controls, transcripts, and turn metrics. `GET /voice/meta` describes the loaded runnable and its declared configuration. `GET /voice_config` returns `{"voice_config": ...}` with portable declared settings; it is not the fully resolved environment configuration. ## WebSocket clients Connect to `/voice/ws` and send a JSON configuration hello without a `type` field: ```json theme={"dark"} {"sample_rate": 16000, "language": "en", "turn_detector": "local"} ``` The server waits up to two seconds for the hello. A binary first frame starts immediately with defaults. Send mono PCM16 little-endian chunks as binary frames, or as base64 JSON: ```json theme={"dark"} {"type": "audio", "data": "BASE64_PCM_BYTES"} ``` The server sends speech as `{"type": "audio", "data": "..."}` in the same PCM format. Decode and queue it for playback. Report cumulative milliseconds of output audio actually played: ```json theme={"dark"} {"type": "playback", "played_ms": 1250.0} ``` Count playback, not bytes received or merely scheduled. On `interrupted`, stop playback and discard queued audio. The session uses playback acknowledgements to align the caller's heard reply with conversation memory; without them it estimates the position from the buffered playback schedule. Closing the socket closes the session. Other JSON messages include `session_started`, `transcript_partial`, `transcript_committed`, `agent_status`, `agent_text_delta`, `agent_text_done`, `filler`, `metrics`, `voice_usage`, `interrupted`, `error`, `session_transcript`, and `session_ended`. `agent_status` reports tool activity. A committed transcript with `replace=true` replaces the previous user transcript bubble rather than appending another. ## WebRTC Create a peer connection with a microphone audio track **and a data channel before creating the offer**. Complete ICE gathering, then send: ```http theme={"dark"} POST /voice/rtc Content-Type: application/json {"sdp": "YOUR_OFFER_SDP", "type": "offer", "config": {"language": "en"}} ``` Apply the returned SDP answer. The server completes its ICE gathering before replying; this route does not use trickle ICE. Speech arrives on an audio track. Session JSON events arrive on the data channel, with no base64 audio messages and no client playback acknowledgements. Playback tracking uses the server's paced media clock. Configure `TIMBAL_STUN_URL` and, when needed, `TIMBAL_TURN_URL`, `TIMBAL_TURN_USERNAME`, and `TIMBAL_TURN_PASSWORD` for your network. `TIMBAL_VOICE_RTC_FORCE_RELAY=1` filters to relay candidates only when TURN is configured. The route returns 501 if WebRTC dependencies are unavailable. ## LiveKit Install the LiveKit extra: ```bash theme={"dark"} pip install "timbal[server,voice,voice-livekit]" ``` Create a room and an agent join token through your LiveKit integration, then ask a long-lived Timbal server to join it: ```http theme={"dark"} POST /voice/rtc Content-Type: application/json X-Timbal-Dial-Secret: YOUR_SERVER_DIAL_SECRET {"transport": "livekit", "url": "wss://YOUR_LIVEKIT_HOST", "token": "AGENT_JOIN_TOKEN", "room": "YOUR_ROOM"} ``` Set `TIMBAL_VOICE_DIAL_SECRET` to validate this header and `TIMBAL_LIVEKIT_URL` to restrict the destination. Clients join and publish microphone audio using LiveKit's client SDK. Audio uses room tracks; session events use the `timbal.events` data topic. `hello_wait_secs` controls the browser configuration-hello window (default two seconds), and `sip_hello_wait_secs` controls the SIP caller window (default zero). Phone callers normally have no data channel to send a hello. A session holds its capacity slot and room until it ends. On a long-lived server it ends when: * the room is deleted or the LiveKit connection is lost, before or after the session starts; * the caller leaves and does not rejoin within `TIMBAL_VOICE_ABANDON_SECS` (default 45; SIP hangups end immediately); * the caller's microphone does not arrive within `TIMBAL_VOICE_CALLER_WAIT_SECS` of the agent joining (default 120; `0` waits indefinitely). The separate boot-environment path uses `TIMBAL_VOICE_TRANSPORT=livekit`, `TIMBAL_LIVEKIT_URL`, and `TIMBAL_LIVEKIT_TOKEN` to join one room at startup. Reserve that path for a process created for a single call; long-lived deployments should dial per request. ## Twilio and Telnyx Expose the server through HTTPS with WebSocket upgrades and configure the carrier's voice webhook: * Twilio: `POST https://YOUR_HOST/voice/twilio/incoming`. * Telnyx: `POST https://YOUR_HOST/voice/telnyx/incoming`. The webhook returns TwiML or TeXML connecting a bidirectional media stream to `/voice/twilio/stream` or `/voice/telnyx/stream`. The bridge decodes carrier 8 kHz G.711 μ-law audio into the session format and converts speech back to the carrier format. Interruption clears carrier playback; mark echoes track playback position. Set `TWILIO_AUTH_TOKEN` or `TELNYX_PUBLIC_KEY` to enable incoming-webhook signature verification. Without these variables, the webhook accepts requests with a development-mode warning. Configure your reverse proxy so the externally visible URL used by the carrier is the URL validated by the webhook. The routes answer and bridge calls. Placing an outbound call requires your carrier integration to originate it and connect the media stream. A host that knows the call direction can select `outbound_greeting`; setting the greeting alone does not originate a call. ## Approval and input requests Voice sessions forward `agent_approval` and `agent_interaction` events when the agent pauses. Render the request and resume through `POST /stream` with the event's `run_id` as `parent_id` and the decision in `resume`. Speaking into the microphone is not an approval resolution. See [Client integration](/human-in-the-loop/client-integration). An `agent_text_done` event also carries the agent `run_id`, allowing an authorized application to continue that conversation through the text endpoint. Identity and prior-run continuation require host-controlled authorization; they are separate from speech tuning in the hello. ## Reporting events On WebRTC and LiveKit, a client can tell the agent that something happened, such as a background task finishing, so it speaks up without waiting for the caller. Send this on the data channel (LiveKit: the room data channel): ```json theme={"dark"} {"type": "notify", "text": "Task 'report' finished: sales are up 4%.", "key": "task:report"} ``` `key` is optional; a newer event with the same key replaces one that has not been delivered. The agent waits for a quiet line. An optional `announce` picks how it speaks up: `"when_idle"` (default), `"silent"` (only on the caller's next turn), or `"interrupt"` (cut in now), which only your backend may send; see [Choosing what speaks up](/voice/tools#choosing-what-speaks-up). The server answers at once with the event's receipt, or a rejection: ```json theme={"dark"} {"type": "notify_ack", "event_id": "06ad…", "key": "task:report", "state": "pending", "run_id": null} {"type": "notify_ack", "key": "task:report", "state": "rejected", "reason": "not_enabled"} ``` Rejection reasons: `not_enabled` (client events are off), `blocked` (a guardrail refused the text), `not_allowed` (`announce: "interrupt"` from the caller's device), `invalid` (no text, too long, or an unknown `announce`), and `closed` (the call has ended). Text from the caller's device is untrusted. The server accepts it only when the agent's `voice_config` sets `"events": {"client_notify": true}` (the hello cannot), runs it through the agent's input guardrails, and labels it for the model as information rather than instructions. On LiveKit, frames from other participants are ignored, and frames the room server API sends (`SendData`, no participant) come from your backend and need no opt-in. Server-side code calls `VoiceSession.notify` directly; see [Using tools](/voice/tools#report-back-when-slow-work-finishes). When the agent takes a turn of its own, for an event or an announced background result, the client receives this before the turn's text and audio: ```json theme={"dark"} {"type": "event_turn_started", "task_ids": [], "event_ids": ["06ad…"], "keys": ["task:report"], "urgent": false} ``` # Turn-Taking and Interruptions Source: https://docs.timbal.ai/voice/turn-taking Test pauses and interruptions before tuning end-of-turn detection Once the basic conversation and [tools](/voice/tools) work, check how the session handles a pause and a caller speaking over the assistant. Start with the default detector before changing thresholds. ## Try the defaults Use the same server and browser playground: 1. Say a complete question and pause. The assistant should take its turn. 2. Start a phrase, pause briefly, then finish it. Listen for a premature answer. 3. While the assistant speaks, ask a different question. Playback should stop and the next turn should respond to your new words. 4. Repeat with a short acknowledgement such as "yes" and with a longer request. An interruption cancels the current reply and clears queued playback. Conversation memory is adjusted to the portion the caller is estimated or known to have heard, and that message is marked `metadata["interrupted"] = True` (metadata is not sent to model providers). A cancelled tool may already have performed work; stopping speech does not undo external side effects. Greetings have their own `interruptible` setting, which defaults to false; see [Greetings](/voice/greetings). ## How the boundary is chosen Voice activity detection (VAD) identifies speech and silence. End-of-utterance detection decides whether a pause ends a thought. With Deepgram Flux, the default transcription provider, Flux makes that decision. With other providers, the server uses local audio detection when its dependencies are installed, otherwise lexical detection. The local path combines Smart Turn audio scoring, Namo text scoring, and Silero VAD endpointing. Inspect `session_started` for the resolved detector and endpointing settings. A requested mode can be changed by the server: STT adapters with native utterance boundaries, currently Flux and Munsit, use `provider` mode and disable local VAD endpointing. ## Choose another mode For a controlled comparison, add to the dict-based config: ```python theme={"dark"} agent.voice_config["turn_detector"] = "lexical" ``` Available modes are: * `local`: combines audio and text evidence and can hold an unfinished thought. Install the voice extra; explicitly choosing this mode without its dependencies loses audio detection. * `lexical`: uses punctuation and unfinished phrases to decide whether to hold a committed transcript. * `provider`: trusts the provider's committed transcript as the boundary. * `heuristic`: lightweight text filtering without holding unfinished thoughts. * `raw`: accepts committed transcripts with minimal policy; useful for debugging. `vad_endpointing=None` enables the local fast path when supported. Set it to `False` to disable it. A Python declaration may also supply a `TurnDetector` instance or zero-argument factory; clients may send supported mode names, and sessions own detector clones. ## Custom clients WebSocket clients must report cumulative milliseconds of audio actually played and discard queued audio on `interrupted`. Reporting received bytes as played can preserve words the caller never heard. WebRTC and LiveKit use the server's paced media clock. See [Transports](/voice/transports) for the wire contract and [Events and usage](/voice/observability) for interruption metrics. Continue to [Silence and timeouts](/voice/silence). # 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`:** ```text theme={"dark"} .input["user_id"] = "user_123" .output = "Processed user: user_123" .user_status = "active" ``` **`check_status`:** ```text 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 > [!NOTE] > > **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. > [!WARNING] > > 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, the step's own call returned an error (including timeout), or param/`when` evaluation failed | All dependent steps are blocked; workflow ends with `status.code == "error"` | | **Blocked** | A prerequisite failed or was itself blocked | Transitive dependents are also blocked, before their conditions, input resolvers, hooks, or handlers run | 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, unrelated branches may finish. The workflow reports the original step failure after those branches finish; it does not cancel all branches on the first error. An agent step can handle errors from its own tools or sub-agents. Those descendant events remain visible in the stream, but only the enclosing step's own result determines whether that step failed. A tool timeout does not fail the workflow if its agent recovers and completes successfully. ## Dependent steps A downstream step is blocked when any prerequisite fails. This applies to explicit `depends_on` links and dependencies inferred from `step_span()` calls, and propagates through the dependency chain. Conditions and input resolvers are not evaluated for blocked steps: ```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 to recover from a failed prerequisite. A failed step may retain partial output for inspection, but dependent steps do not run. ## Timeouts A step's own `timeout` ends that call with `status.code == "timeout"` and a `RunTimeout` error. Its dependents are blocked, and the workflow reports `status.code == "error"` with reason `step_failed` and the original timeout details. A `timeout` on the workflow instead bounds the entire foreground run. It cancels and joins active step tasks and closes their streams before the workflow reports `status.code == "timeout"`. Nested agents and workflows follow the same cleanup path. Cancellation is cooperative: cleanup may take longer than the deadline, already-completed side effects are not rolled back, and detached children use their separate `background_timeout`. ## 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"} workflow = ( Workflow(name="pipeline") .step(route) .step(handle_new, when=lambda: get_run_context().step_span("route").output == "new") .step(handle_existing, when=lambda: get_run_context().step_span("route").output == "existing") .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()) ``` > [!NOTE] > > 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 > **[Sequential steps](/examples/workflows/sequential-steps)** > > Chain steps with data passing between them > **[Parallel fan-out](/examples/workflows/parallel-fan-out)** > > Fetch from multiple sources concurrently, then merge > **[Conditional routing](/examples/workflows/conditional-routing)** > > Route to different handlers based on validation > **[Workflow composition](/examples/workflows/workflow-composition)** > > Nest an inner workflow as a step