hashbrown

Tool calling

A tool is an application function a model can ask to call. Its name, description, and argument schema tell the model when to use it. Your handler supplies the actual result, and the model can use that result in its next request.

Use tools when an answer depends on current application data or a checked action. A model cannot infer today’s inventory from its training data. Give it a focused way to read inventory instead of pasting every product into every prompt. Keep reads separate from writes so a useful lookup does not also change application state.

A tool connects the model to application code

Scroll horizontally to see the full diagram, or expand it.

Chat clientModelBrowserhandler1 History and tools2 Tool name andarguments3 Run registered handler4 Result or error5 Continue withresult6 Reply using theresult
The model requests a call. Your registered handler runs the application function and returns its result.
Read the diagram

A client sends tool descriptions with a request. The model asks for a named tool with arguments. The client calls its browser handler, receives application data, and sends that result for the next model response.

A tool connects the model to application code

The model requests a call. Your registered handler runs the application function and returns its result.

Read the diagram

A client sends tool descriptions with a request. The model asks for a named tool with arguments. The client calls its browser handler, receives application data, and sends that result for the next model response.

Give the assistant a current value

Use the endpoint and client configuration from the Quickstart. Render the component below inside the existing HashbrownProvider.

Here the application owns a selected book. The read-only tool returns that selection when the model needs it. Returning a detached value keeps the tool result focused; no workbench store or recording helper is needed.

import { useChat, useTool } from '@hashbrownai/react';

export function ReadingAssistant() {
  const selectedBook = 'Pride and Prejudice';
  const readSelectedBook = useTool({
    name: 'readSelectedBook',
    description: 'Read the title of the book currently selected in the app.',
    handler: async () => ({ title: selectedBook }),
    deps: [selectedBook],
  });
  const chat = useChat({
    system:
      'Use readSelectedBook before answering questions about the selected book.',
    tools: [readSelectedBook],
  });
  return (
    <main>
      <button
        type="button"
        disabled={chat.isLoading}
        onClick={() =>
          chat.sendMessage({
            role: 'user',
            content: 'Who wrote the selected book?',
          })
        }
      >
        Ask about this book
      </button>
      {chat.error && <p role="alert">The request failed.</p>}
      {chat.messages.map((message, index) =>
        message.role === 'assistant' ? (
          <p key={index}>
            {typeof message.content === 'string'
              ? message.content
              : JSON.stringify(message.content)}
          </p>
        ) : null,
      )}
    </main>
  );
}

The model chooses whether to call a tool; the system instruction makes the lookup part of this feature’s expected behavior. For tools that accept arguments, add a schema. Forward the handler’s AbortSignal to cancellable I/O. Enforce access to remote data on the server even when the browser tool initiates the lookup.

Explore it in the workbench

The workbench below replays a recorded run of this lesson.

Follow readContext from browser tool to continuation, then read the accepted plan in the second turn.

Recorded in the real React workbench with OpenAI requests, browser tools, Hashbrown streaming, and application state. Footage slowed to half speed for readability. Replay reads captured frames. No narration. Read the text walkthrough.

Open full workbench
Open full workbench

Our two-step plan has an accepted version and a revision number. Before proposing a change, the model needs to read both. Let’s give it one small browser tool: readContext.

The same conversation appears in Generative UI. In this lesson, we’ll follow the request, the tool result, and the continuation that produces the card.

Run the same conversation

Use the panel above, or run the workbench on your machine.

The replay uses this checked React client with real provider requests. The local server keeps credentials, instructions, tools, and response schema on the server. Refresh clears the in-memory conversations and accepted plan.

Read application state

useTool() describes a function the model can call. This handler reads the current application snapshot. Recording the result makes it inspectable; reading context does not change the plan.

react/src/Workbench.tsx
const readContext = useTool({
  name: 'readContext',
  description:
    'Read the current accepted plan and application revision. This tool never changes state.',
  handler: async () => {
    const result = store.getSnapshot().application;
    store.record('tool', { name: 'readContext', result });
    return result;
  },
  deps: [store],
});

The React version uses deps to keep handler dependencies current; the Angular version reads the session store inside its handler. Both pass this tool in the tools collection supplied to useUiChat().

Follow the round trip

  1. Send the first prompt. The server streams a readContext call.
  2. Hashbrown runs the browser handler. Its result contains revision: 0 and plan: null.
  3. Hashbrown sends that result in a continuation request. The model now has the context it needs to propose a plan.
  4. Review the card and select Accept plan. This explicit user action advances application state to revision 1.
  5. Send the follow-up. The next tool result contains revision 1 and the accepted plan.

Select Requests to see each request and tool result in a table grouped by turn. Tool calls sit under the request that called them. Select a tool row to see its Call and Result, or select the request to see Raw request (exactly as sent) and Response (every streamed event).

Keep reads and writes deliberate

A handler runs when the model calls it. Keep a read like readContext separate from a write like accepting the plan. A schema describes values; application code decides whether an action is allowed.

The workbench checks the current turn, application revision, successful completion, and plan rules before accepting. A cancelled or failed response cannot be accepted. Historical playback is read-only, including when you revisit a tool result.

For a remote write, enforce identity, authorization, and validation on the server. See Securing a public endpoint.

Tool options and arguments

This tool needs no arguments. Add schema when the model must supply values, and validate application rules inside the handler. Pass the provided AbortSignal to any asynchronous work that supports cancellation.

UseToolOptions

OptionTypeRequiredDescription
namestringYesThe name of the function that the LLM will call
descriptionstringYesDescription of what the function does
schemas.SchemaInputNoSchema defining the function arguments
handlerFunctionYesThe function to execute when called
depsReact.DependencyListYesDependencies used to memoize the handler; pass like you would to useCallback

Schema Inputs

The schema option accepts:

  • Skillet schemas (s.string, s.object, etc.)
  • Standard JSON Schema objects (the ~standard spec, e.g. Zod/ArkType)
  • Raw JSON Schema objects

Standard JSON Schema inputs are normalized to Skillet (draft-07) before streaming and validation. Unsupported keywords throw at runtime.


API Reference


Handler Signatures

With input Arguments:

handler
handler: (input: s.InferSchemaInput<Schema>, abortSignal: AbortSignal) =>
  Promise<Result>;

Without input Arguments:

handler
handler: (abortSignal: AbortSignal) => Promise<Result>;

Server-Executed Tool Calls

Not every tool the model calls runs in the browser. An agent server can execute its own tools and stream only the call and its result to Hashbrown. Those calls appear on the assistant message as serverToolCalls, a display-only list that Hashbrown never executes. Each entry carries the tool's name, its toolCallId, a status, and args: the arguments resolved so far, growing as the server streams them, or null before any JSON has parsed.

StatusMeaning
inProgressArgument deltas are still arriving; args may be partial
executingThe arguments are complete; the server is running the tool
completeThe server reported the tool's result

Server calls also describe themselves. These fields are optional, and a server sends only the ones it knows:

FieldMeaning
outcomeHow the call ended: success, failure, denied or cancelled
labelA short, human-readable description of what the call is doing or did
iconA generic icon name, such as search
kindprovider when the model provider ran the tool, server when the agent server did
builtinThe provider built-in tool, such as web_search
queriesThe queries the call ran, such as search terms
sourcesThe sources the call produced: id, kind, title, and optionally href
errorWhy the call failed: { message, code? }
timing{ startedAt, settledAt? } in epoch milliseconds
parentToolCallIdThe id of the tool call that spawned this one

status stays inProgress, executing or complete, and a settled call is always complete. outcome says how it ended:

OutcomeMeaning
successThe call completed
failureThe call failed; result is rejected with { message, code? }
deniedThe call was not allowed to run
cancelledThe call was cancelled before it finished

A provider call is never dispatched to a client tool, even one with the same name.

The b4.step Contract

An AG-UI server describes its tool calls by sending a b4.step step record (version 1) in one of two carriers: the metadata['b4.step'] key on the tool-call events, or a CUSTOM event named b4.step. Hashbrown merges the records into the matching serverToolCalls entry, so any server that emits b4.step, such as a B4.run agent, has its steps appear automatically, with no client code to add. Servers that do not emit it keep working: the fields are simply absent.

Display Tool Steps

getServerToolCallLabel and groupServerToolCalls provide pure display data. They render no components and preserve original calls and sources. Application callbacks override producer labels; missing or invalid wording falls back to a bounded lifecycle label without exposing arguments or results.

import {
  getServerToolCallLabel,
  groupServerToolCalls,
  type ServerToolCallLabelOverrides,
  type UiAssistantMessage,
} from '@hashbrownai/react';

const labels: ServerToolCallLabelOverrides = {
  lookup: {
    label: (call) =>
      call.status === 'complete' ? undefined : 'Checking context…',
    group: (calls) => `Checked context ${calls.length} times`,
  },
};

function stepRows(answer: UiAssistantMessage<never>) {
  return groupServerToolCalls(answer.serverToolCalls ?? [], {
    labels,
    exclude: ['task', 'writeTodos'],
  }).map((row) => ({
    item: row,
    label:
      row.kind === 'group' ? row.label : getServerToolCallLabel(row, labels),
  }));
}

Only adjacent successful, complete calls with matching name, kind, built-in, provider identity, and parent call can group. Open, failed, denied, cancelled, or excluded calls remain individual rows and break groups. The exclusions above are application policy: no tool names are hardcoded by the helper. Groups keep all original calls, and collect sources by ID with the first occurrence winning.

formatDuration, noFlashRemaining, NO_FLASH_MS (300 ms), and ELAPSED_TICK_MS (one second) are also available from @hashbrownai/react. These are optional caller presentation policy. noFlashRemaining(startedAt, now) computes a remaining delay; it never reads a clock, starts a timer, hides a row, or discards calls. Both timestamps must use the same clock. Skip the delay for restored history or unknown timing. If your application refreshes elapsed labels, it owns the clock and scheduling; the one-second constant does not install a timer. formatDuration(undefined) leaves unknown duration unknown. See turn summaries for explicit turn-clock options.

Citations

When the answer cites its sources, the assistant message carries citations. Each citation's sourceId is the id of a source on one of the message's server tool calls, or of the message's own sources. Alongside quote, a citation may carry block and, only when the provider gives true offsets, start and end. Some providers also require an attribution to be shown with the answer; each one has mustRender: true, so show it unmodified. Show citations clearly and make them clickable.

Collect turn sources separately from required provider attribution:

import {
  groupTurns,
  collectTurnSources,
  collectTurnAttributions,
  type UiChatMessage,
} from '@hashbrownai/react';

function answerAnnotations(messages: readonly UiChatMessage<never>[]) {
  return groupTurns(messages).map((turn) => ({
    sources: collectTurnSources(turn),
    attributions: collectTurnAttributions(turn),
  }));
}

collectTurnSources walks assistants in order, visiting each answer's own sources before its server-call sources. It deduplicates by ID and preserves the first original source object. collectTurnAttributions keeps separate records for every attribution, including duplicates, with the owning assistantMessageId. Render each required attribution beside that answer, unmodified, at full width, without link tracking. Preserve provider HTML and widget tokens verbatim. Use trusted provider/backend data and the provider's supported rendering integration; the collector does not sanitize, interpret, or authorize markup. A deduplicated source list does not replace attribution.

Because args resolve progressively, a component can paint from a server call before it finishes, the way it does for streaming text.

server tool calls
export function Answer({ message }: { message: UiAssistantMessage<never> }) {
  return (
    <>
      {message.serverToolCalls?.map((call) =>
        call.name === 'render' && call.args ? (
          <Draft key={call.toolCallId} args={call.args} />
        ) : null,
      )}
      {message.ui}
    </>
  );
}

Tools you register with useTool keep appearing in toolCalls exactly as before; only calls with no matching client tool land in serverToolCalls.


Next steps

Read the complete React client, or return to the workbench. Continue with Structured output to use the same tool with typed plan data, or Generative UI to focus on the trusted component.

Connect tools to native subagents

Native AG-UI subagents can expose which invocation requested a tool and which invocation produced its result. Import SubagentRun from @hashbrownai/react to type the records exposed by the chat's subagents property (a readonly array in React, a signal of a readonly array in Angular). Plain, structured, and UI chat share this projection; see Activities for reactive grouping examples and lifecycle states.

import type { SubagentRun, UiChatMessage } from '@hashbrownai/react';

function callsRequestedBy(message: UiChatMessage<never>, run: SubagentRun) {
  return message.role === 'assistant'
    ? (message.serverToolCalls ?? []).filter(
        (call) => call.subagentRunId === run.subagentRunId,
      )
    : [];
}

An assistant message's subagentRunId identifies its producer. Each projected tool call's subagentRunId identifies the requester; resultSubagentRunId independently identifies the result producer. A child can request a tool that the parent or another child executes. When a result exists and resultSubagentRunId is absent, its producer is the parent; do not inherit the requester's ID. An absent result is not evidence that the parent executed anything. Compare opaque IDs directly, including empty strings.

The producer supplies parentSubagentRunId, parentToolCallId, and parentMessageId when it can link the child to its enclosing invocation, spawning call, and message. Reusable names are labels, not invocation IDs. Hashbrown does not orchestrate these agents, infer tool success from a child finishing, or create per-child shared state. A child error does not settle tool outcomes or fail the whole run.

Suspended children can appear as awaiting; approval handling still belongs to pendingInterrupts and resume(). A resume acknowledgement resolves the answered waits without proving tool execution or child completion, even if a subsequent continuation uses a new ID or fails. Native snapshots retain message/result provenance; restored lifecycle details are unknown unless explicit retained interrupts establish a wait.

Tool callingGive the assistant a current valueExplore it in the workbenchRun the same conversationRead application stateFollow the round tripKeep reads and writes deliberateTool options and argumentsServer-Executed Tool CallsNext stepsConnect tools to native subagents