Streaming and human-in-the-loop (HITL)
July 29, 2026 · View on GitHub
Streaming
runtime.stream(agent, prompt) returns an AgentStream you can for await
over. Events have a type ('thinking', 'tool_call', 'tool_result',
'waiting', 'handoff', 'message', 'done', ...). You can also
runtime.start(...) and call handle.stream().
const stream = await runtime.stream(agent, 'Plan a 3-day trip to Tokyo.');
for await (const event of stream) {
if (event.type === 'thinking') console.log('[thinking]', event.content);
else if (event.type === 'tool_call') console.log('[tool]', event.toolName, event.args);
else if (event.type === 'tool_result') console.log('[result]', event.toolName, event.result);
else if (event.type === 'done') console.log('[done]', event.output);
}
const result = await stream.getResult(); // terminal AgentResult after the stream ends
Don't treat streamed content as a final result until the terminal 'done'
event (or stream.getResult()) arrives — an intermediate 'message'/
'thinking' event can still be followed by a retry or guardrail-triggered
correction.
SSE fallback
Streaming rides Server-Sent Events (SSE) by default. If the SSE connection can't be established (a non-2xx response, or the connection drops without delivering a byte), the stream falls through to status polling automatically — no action needed from the caller, and no error surfaces for this case specifically. A stream that repeatedly fails to reconnect mid-flight retries with backoff before also falling back to polling.
Human-in-the-loop (HITL)
A tool with approvalRequired: true, or a humanTool, pauses execution and
emits a waiting event. Resolve it via the handle / stream: approve(output?),
reject(reason?), send(message), or respond(body).
const deleteData = tool(
async (args: { table: string }) => ({ deleted: args.table }),
{
name: 'delete_data',
description: 'Delete a table. Destructive — requires approval.',
inputSchema: { type: 'object', properties: { table: { type: 'string' } }, required: ['table'] },
approvalRequired: true,
},
);
const agent = new Agent({ name: 'ops', model, tools: [deleteData], instructions: '…' });
const handle = await runtime.start(agent, 'Delete the stale_cache table.');
for await (const event of handle.stream()) {
if (event.type === 'waiting') {
// The waiting event carries the pending tool batch on event.pendingTool,
// or fetch the full status:
const status = await handle.getStatus();
console.log('Approval needed for:', status.pendingTool?.toolCalls);
await handle.approve(); // approve, or:
// await handle.reject('Not allowed');
// await handle.respond({ approved: true, note: 'go ahead' });
} else if (event.type === 'done') {
console.log('done', event.output);
}
}
One HUMAN task gates the whole batch of pending tool calls with a single
{ approved, reason } verdict — iterate pendingTool.toolCalls to see every
tool covered. The pendingTool is mirrored onto the waiting event so you
can read it without a getStatus() round-trip.
humanTool works the same way but lets the LLM ask the human a structured
question; the response schema is on pendingTool.response_schema.
Approval pattern
Keep the executionId (or handle) around across the approval wait — the
pause is a durable Conductor task, not an in-memory continuation, so it
survives a process restart on your side as long as you can look the
execution back up. Resolve pauses through the handle/client control plane
rather than an in-memory web request continuation, and make approval actions
idempotent, since a caller (e.g. a webhook retry) may submit the same
approval twice.
Next steps
See tools, agent client, and callbacks.