Client tools
Let the agent call your code — including pauses that survive a reload.
A client tool runs in your app, not on the server. The agent decides to call it, the SDK dispatches to your handler, and the return value goes back as the tool result so the run can continue.
This is how an agent reads the DOM, asks the user something, opens a file picker, or touches anything only the browser can reach.
Handlers
function Assistant() {
const { snapshot } = useInlayChat({
...conn,
tools: {
ask_question: ({ question }) => window.prompt(String(question)) ?? "",
set_timer: async ({ seconds, label }) => {
await schedule(Number(seconds), String(label));
return "scheduled";
},
},
});
return <p>{snapshot.status}</p>;
}Handlers may be sync or async, and are read live at each dispatch — they can close over current state and never need memoizing. Changing them does not recreate the conversation.
Whatever you return is stringified and sent back as the result. Throwing is reported to the agent as a tool failure, which it can react to.
Args are unknown
A bare handler receives unknown. The SDK will not pretend to know an arbitrary
agent's tool schema, and the server does not validate client-tool arguments —
they came from a model.
Generate a client and both halves are solved: arguments are typed from the tool's JSON Schema, and the SDK validates them against that schema before your handler runs. See Typed clients.
Durable pauses
Some tool calls should not be lost if the tab closes. When the agent calls a tool, the server records that it is waiting — so the pause is durable, and reopening the conversation later finds the call still pending.
function Approval() {
const { snapshot, submitToolResult } = useInlayChat(conn);
const pending = snapshot.pendingTools[0];
if (!pending) return null;
return (
<div>
<p>
{pending.toolName} — {pending.argumentsJson}
</p>
<button onClick={() => void submitToolResult(pending.toolCallId, "approved")}>
Approve
</button>
<button onClick={() => void submitToolResult(pending.toolCallId, "denied")}>Deny</button>
</div>
);
}snapshot.pendingTools is the queue of calls awaiting a result. It is derived
from the rows — a tool-call with no matching tool-result — rather than kept
as separate bookkeeping, so it cannot drift out of sync with what you render.
Reopening never re-runs your handler
This is deliberate, and it is the important bit.
When you reopen a conversation that is paused on a tool, the SDK does not silently invoke your handler again. The side effect may already have happened — the card may already have been charged, the email already sent. Re-running it because a page refreshed would be a real bug in your product, not a convenience.
Instead the call is surfaced in pendingTools and you resolve it deliberately:
call submitToolResult(id, result) with the answer, whether that comes from
re-running the handler yourself or from a human clicking a button.
That is also what makes this the natural shape for human-in-the-loop: an approval step is just a client tool whose result arrives when someone decides.
The other kind of tool row
Not every tool row is yours to answer. Server-side tools — the agent's own
memory, web.search, an MCP connector's tool — run and finish on the server
and appear in the feed as ordinary tool-call / tool-result pairs with
requiresClientResponse: false. They never enter pendingTools, never invoke
a handler, and a private one arrives redacted (real name, input of
"{}"). Render them for observability if you want; nothing else is required.
Server tools covers what the platform gives the agent.
Delivery
submitToolResult posts the result and resumes the run. The pending call clears
when the server acknowledges it, not when your handler returns — so if the
network drops mid-delivery, the call stays visible as pending rather than
vanishing while the server is still waiting for it.
If delivery fails, reload: the conversation reopens with the call still pending and still deliverable.
One pause at a time
A turn can pause on exactly one client tool — durable resume handles one pending call per turn, so there is no approval tray stacking three sign-offs inside one conversation.
The blessed shape for a queue of approvals is one conversation per work item, with an inbox that spans conversations:
const { conversations } = useInlayHistory({ ...conn, status: "awaiting_client_tool" });Each row opens into its own conversation with its own pending call, each approval resolves its own run, and every one of them survives a reload. That is what turns "an approvals queue" into a legal, durable shape.
Status
snapshot.status is the single lifecycle answer:
| Status | Meaning |
|---|---|
idle | Nothing started. |
streaming | The agent is working. |
awaitingInput | Paused for input — snapshot.awaitingPrompt may hint at what. |
awaitingTool | Paused on a client tool. |
completed / failed / cancelled | How it ended; on failed, snapshot.error says why. |
Prefer canSend and canCancel over reading status yourself for button state
— canSend already accounts for the cases where send continues vs. starts a
conversation (always-continuable: a held id continues).
Next
Conversations — reopening, listing, and starting over.