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:
text/event-stream), one data: line per event. When the agent calls ask_user, the client receives an INTERACTION event:
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:
- Render UI from the
INTERACTIONevent’skind+payload(here: a question with three option buttons). For anAPPROVALevent, renderprompt+inputand offer Approve/Deny. - Stash
run_idandinteraction_id(orapproval_id). - A run is paused (not finished) whenever the terminal
OUTPUThasstatus.reasonofinput_requiredorapproval_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):
ask_user returns "postgres", and the stream ends with a normal success OUTPUT:
resume map: { "<id1>": ..., "<id2>": 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 ofCancel:
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: { "<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:
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
typeand 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, wheredatais a base64 slice. Concatenate byseq, base64-decode, and parse one message. This matters for approvals: a card carrying a long brief inui/inputroutinely exceeds the cap. (session_transcriptkeeps 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 aparent_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 — the in-process and durable-provider story
- Suspend & interaction tools —
InteractionEventand response schemas - Approval gates — the
APPROVALevent and its fields