InlayDocs

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:

StatusMeaning
idleNothing started.
streamingThe agent is working.
awaitingInputPaused for input — snapshot.awaitingPrompt may hint at what.
awaitingToolPaused on a client tool.
completed / failed / cancelledHow 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.

On this page