Skip to main content
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:
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:
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:
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):
The run replays, ask_user returns "postgres", and the stream ends with a normal success OUTPUT:
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: { "<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 of Cancel:
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: { "<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 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