Skip to content

Streaming & hooks

Two ways to get inside the loop while it runs: stream its events for a live UI, and hook every tool call to guard, rewrite, or transform it.

stream emits events as they happen: text (assistant token deltas), tool_call, tool_result, usage, pending (a suspension), and a terminal done carrying the final result. With an id it’s stateful, exactly like ask — the thread loads first and saves on done.

for await (const ev of client.stream("Summarize the repo.", { toolkit: tk, id: "user-42" })) {
if (ev.type === "text") process.stdout.write(ev.delta)
else if (ev.type === "tool_call") console.log(`\n${ev.name}`)
else if (ev.type === "done") console.log(`\n(${ev.result.turns} turns)`)
}

Hooks — guard and transform every tool call

Section titled “Hooks — guard and transform every tool call”

beforeTool runs before a tool executes: return an override with args to rewrite the call, or with result to short-circuit it (deny, cache-hit, dry-run — the real tool never runs). afterTool runs after, and can replace the result (redact, annotate). Here’s a policy guard that blocks a dangerous bash:

const client = createClient({
baseUrl, style: "openai", model,
hooks: {
beforeTool: ({ name, args }) => {
if (name === "bash" && /rm -rf/.test(String(args.command)))
return { result: { output: "denied by policy", isError: true } } // tool never runs
},
afterTool: ({ result }) => ({ result: { ...result, output: redact(result.output) } }),
},
})

There are beforeLLM / afterLLM hooks too — beforeLLM can trim or inject history and swap the tool set before each model call. A beforeTool short-circuit that returns a suspension is treated as a guard-raised pending, not an error — the same suspension guarantees apply.