Headless tools let your agent call tools whose real execution must happen in the
user’s app instead of on the server. The agent still sees a normal tool schema,
but the implementation lives in the frontend, where it can access browser APIs
like IndexedDB, geolocation, clipboard, canvas, or file pickers.
This pattern is especially useful when data should stay local to the device.
The playground example on this page uses a small browser-memory toolkit backed
by IndexedDB plus a geolocation tool that runs entirely on the client.
At a high level, headless tools split the tool schema from the browser-only implementation.
- Register a schema-only tool definition on the agent.
- Implement the matching tool in the frontend with
.implement(...).
- Pass those implementations to
useStream({ tools: [...] }).
- When the agent emits a matching tool call, the client runs it and resumes
the interrupted run with the tool result.
Keep tool definitions and implementations in separate modules. Share the
definitions between your agent and your frontend so the tool names and schemas
stay aligned, then keep browser-only code in a client-only impl module.
The playground defines a small set of client-side tools that follow the same
pattern: the agent exposes a tool schema, and the frontend handles the actual
execution.
Define the tools once in a shared tools.ts file and use that file from both
the agent and the frontend.
import * as z from "zod";
import { tool } from "langchain";
export const memoryPut = tool({
name: "memory_put",
description: "Store a memory in the user's browser.",
schema: z.object({
key: z.string(),
value: z.unknown(),
}),
});
export const memoryGet = tool({
name: "memory_get",
description: "Look up a memory stored in the user's browser.",
schema: z.object({
key: z.string(),
}),
});
export const geolocationGet = tool({
name: "geolocation_get",
description: "Get the user's current location from the browser.",
schema: z.object({
save: z.boolean().optional(),
}),
});
Implement the browser behavior
Put the client-only behavior in a separate module and attach it with
.implement(...). The real playground includes a fuller IndexedDB store with
search, listing, expiration, and delete operations. The following example shows
the same shape at a higher level:
import {
geolocationGet as geolocationGetDefinition,
memoryGet as memoryGetDefinition,
memoryPut as memoryPutDefinition,
} from "./tools";
async function saveMemory(key: string, value: unknown) {
localStorage.setItem(`agent-memory:${key}`, JSON.stringify(value));
}
async function getMemory(key: string) {
const value = localStorage.getItem(`agent-memory:${key}`);
return value ? JSON.parse(value) : null;
}
export const memoryPut = memoryPutDefinition.implement(async ({ key, value }) => {
await saveMemory(key, value);
return { success: true, key };
});
export const memoryGet = memoryGetDefinition.implement(async ({ key }) => {
const value = await getMemory(key);
return value === null ? { found: false, key } : { found: true, key, value };
});
export const geolocationGet = geolocationGetDefinition.implement(
async ({ save = true }) => {
const position = await new Promise<GeolocationPosition>((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject),
);
const location = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
};
if (save) {
await saveMemory("user_location", location);
}
return location;
},
);
Wire the implementations into useStream
Pass the implemented tools to useStream. When the agent emits a matching tool
call, the hook runs the client implementation and resumes the run for you.
The agent state can be inferred from the agent definition:
import type { myAgent } from "./agent";
export type AgentState = typeof myAgent;
import { useStream } from "@langchain/react";
import { geolocationGet, memoryGet, memoryPut } from "./impl";
import type { AgentState } from "./types";
const AGENT_URL = "http://localhost:2024";
export function Chat() {
const stream = useStream<AgentState>({
apiUrl: AGENT_URL,
assistantId: "headless_tools",
tools: [memoryPut, memoryGet, geolocationGet],
});
return <ChatView messages={stream.messages} toolCalls={stream.toolCalls} />;
}
The playground renders each memory or geolocation operation as its own card and
keeps a small memory stats panel near the input. The key step is matching each
entry in stream.toolCalls back to the AI message that triggered it:
import type { ToolCallWithResult, DefaultToolCall } from "@langchain/react";
function Message({ message, toolCalls }: {
message: AIMessage,
toolCalls: ToolCallWithResult[]
}) {
const messageToolCalls = toolCalls.filter((tc) =>
message.tool_calls?.some((call) => call.id === tc.call.id),
);
return (
<div>
{message.text && <p>{message.text}</p>}
{messageToolCalls.map((tc) => (
<HeadlessToolCard key={tc.call.id} toolCall={tc} />
))}
</div>
);
}
This works especially well with the richer UI patterns from
Tool calling, where each tool result can
render as a specialized card instead of raw JSON.
Use cases
Use headless tools when the work depends on APIs or data that only exist in the
client:
- Local memory in IndexedDB or
localStorage
- Device APIs like geolocation, clipboard, camera, or file pickers
- Canvas, audio, or other browser-only rendering primitives
- Privacy-sensitive data that should stay on the user’s device
- UI actions that need direct access to in-memory frontend state
Best practices
- Keep tools small and typed. Prefer many narrow tools over one generic
“run arbitrary browser code” tool.
- Return JSON-serializable results. Do not try to return DOM nodes, file
handles, or other non-serializable browser objects.
- Share definitions, separate implementations. The agent and client should agree
on tool names and schemas, but only the client should load browser APIs.
- Surface tool state in the UI. Use
stream.toolCalls and onTool to show
pending, success, and error states.
- Add review when needed. For sensitive client-side actions, pair this pattern
with Human-in-the-loop.