Skip to main content
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
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.

Checking Task Status

Background tools return immediately with a task handle:
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:
The notice stays role="user" (provider wire formats require it mid-history) but is tagged:
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 <background_task_completed> 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.
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 arrives. Ordinary agent(...) calls keep their existing single-turn behavior.
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:
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:
  • "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). 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

  • 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). 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:
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). 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 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:
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.
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.
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.

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).
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.

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.
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:
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.
A streaming handler can yield either form and both reach the log. Raw delta items are wrapped into DeltaEvents 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

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.