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
Thebackground_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 settingrun_in_background=Trueon the tool input
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: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:
role="user" (provider wire formats require it mid-history) but is tagged:
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.
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.
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:
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 withstop_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".
"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’sOUTPUTwhile one is in flight.aclose()also closes the in-flight run.admitis 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 returnsNonebefore the session’s first spawn.
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: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, callreport_progress from inside the
background task:
announce="silent" to
only let it ride the next turn. Outside a background task, report_progress
raises RuntimeError.
Timeouts
Setbackground_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
Setbackground_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.
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 envTIMBAL_MAX_CONCURRENT_BACKGROUND(0/none/unlimited= no cap). - Depth — max nesting of background spawns (default unlimited).
max_background_depth=1means only top-level code may detach; a background child cannot spawn further background work. Env:TIMBAL_MAX_BACKGROUND_DEPTH.
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 sameafter 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).
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, usewait_for_background. It returns the same snapshot as get_background_task and does not ack completion notices.
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 HTTPJobStore). 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:
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.
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
finally instead of being abandoned mid-flight.
Session Scope
The task store is bound to theRunContext 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
Agentobject — no sharedparent_id— get isolated stores. Neither sees the other’s tasks, andget_background_taskwith a foreigntask_idreturnsnot_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 anasyncio.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.