Client Tools
Client Tools are tools executed by the client application (browser, IDE extension, mobile app) rather than the server. The agent calls them like any other tool, but the TypeScript client intercepts the request, runs the logic locally, and returns the result — all automatically.
How It Works
Agent (server) TypeScript Client
│
│ calls: OpenFile("notes.txt")
│ ────────────────────────────────────────►
│ client receives
│ ClientToolInvokeRequestEvent
│ │
│ onClientToolInvoke()
│ runs your handler
│ │
│ result: "File opened" sends response
│ ◄────────────────────────────────────────
│
Agent continuesThe TS client sends the response automatically. You only need to provide the handler.
Quick Start (TypeScript)
import { AgentClient, createTextResult } from '@hpd/hpd-agent-client';
const client = new AgentClient({
baseUrl: 'http://localhost:5000',
// Define what tools exist
clientToolKits: [{
name: 'IDE',
description: 'VS Code interaction tools',
tools: [
{
name: 'OpenFile',
description: 'Open a file in the editor',
parametersSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path' },
line: { type: 'integer', description: 'Line number' }
},
required: ['path']
}
}
]
}],
// Handle invocations
onClientToolInvoke: async (request) => {
if (request.toolName === 'OpenFile') {
const path = request.arguments['path'] as string;
await vscode.workspace.openTextDocument(path);
return createSuccessResponse(request.requestId, 'File opened');
}
return createErrorResponse(request.requestId, `Unknown tool: ${request.toolName}`);
}
});
// Stream a message — tools are active for this and all subsequent streams
await client.stream(sessionId, branchId, [{ content: 'Open notes.txt' }], {
onTextDelta: (text) => process.stdout.write(text),
onComplete: () => console.log('Done')
});Backend: Enable Client Tools
On the C# side, call .WithClientTools() so the agent knows to accept client-side tool registrations:
var agent = await new AgentBuilder()
.WithProvider("anthropic", "claude-sonnet-4-5")
.WithClientTools()
.BuildAsync();No C# handler needed — the tools execute in the TypeScript client.
Registering Tools
Tools can be registered at three levels:
1. Client config (all streams)
const client = new AgentClient({
baseUrl: 'http://localhost:5000',
clientToolKits: [myToolKit],
onClientToolInvoke: async (req) => { /* ... */ }
});2. Per stream
await client.stream(sessionId, branchId, messages, handlers, {
clientToolKits: [myToolKit]
});The per-stream handler on handlers.onClientToolInvoke takes precedence over the client-level handler.
3. Dynamically at runtime
// Add a group after construction
client.registerToolKit(myToolKit);
// Add multiple
client.registerToolKits([groupA, groupB]);
// Remove it later
client.unregisterToolKit('IDE');
// Replace the handler
client.setToolHandler(async (req) => { /* ... */ });Tool Group Definition
Tools are organized into groups. A group appears as a collapsible container in the agent's tool list:
const ideGroup: clientToolKitDefinition = {
name: 'IDE',
description: 'VS Code interaction tools',
startCollapsed: true, // Start hidden behind container (default: true)
systemPrompt: 'Always confirm with the user before modifying files.',
tools: [
{
name: 'OpenFile',
description: 'Open a file in the editor',
parametersSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path' },
line: { type: 'integer', description: 'Line number' }
},
required: ['path']
}
},
{
name: 'GetSelection',
description: "Get the user's current text selection",
parametersSchema: { type: 'object', properties: {} }
},
{
name: 'DeleteFile',
description: 'Permanently delete a file',
parametersSchema: {
type: 'object',
properties: { path: { type: 'string' } },
required: ['path']
},
requiresPermission: true // Agent must get user approval first
}
]
};Helper constructors:
import { createCollapsedToolKit, createExpandedToolKit } from '@hpd/hpd-agent-client';
// Starts hidden, agent must expand before using
const group = createCollapsedToolKit('IDE', 'VS Code tools', tools, { systemPrompt: '...' });
// Always visible
const group = createExpandedToolKit('IDE', tools);Handling Invocations
The onClientToolInvoke function receives a ClientToolInvokeRequestEvent and must return a ClientToolInvokeResponse:
onClientToolInvoke: async (request) => {
const { requestId, toolName, arguments: args } = request;
try {
const result = await dispatch(toolName, args);
return createSuccessResponse(requestId, createTextResult(result));
} catch (err) {
return createErrorResponse(requestId, String(err));
}
}Always return a response — if no response arrives, the agent will hang until InvokeTimeout expires (default 30s).
Response Helpers
import {
createSuccessResponse,
createErrorResponse,
createTextResult,
createJsonResult
} from '@hpd/hpd-agent-client';
// Success with text
createSuccessResponse(requestId, 'File opened successfully');
// Success with structured data
createSuccessResponse(requestId, createJsonResult({ line: 42, column: 10 }));
// Success with augmentation
createSuccessResponse(requestId, 'Logged in', augmentation);
// Error
createErrorResponse(requestId, 'File not found');Result Content Types
// Text
{ type: 'text', text: 'File opened successfully' }
// JSON (structured data)
{ type: 'json', value: { line: 42, column: 10 } }
// Binary (file, screenshot, etc.)
{ type: 'binary', mimeType: 'image/png', data: base64String, filename: 'screenshot.png' }
// or by URL
{ type: 'binary', mimeType: 'image/png', url: 'https://...', filename: 'screenshot.png' }Dual-Context Instructions
Each tool group can carry two kinds of instructions (same pattern as server-side Skills):
| Field | Where it goes | When shown | Use for |
|---|---|---|---|
functionResult | Conversation history | Once, when group expands | Extra context beyond the auto-generated expansion message |
systemPrompt | System prompt | Every turn while expanded | Critical rules the agent must follow |
The framework automatically generates a base expansion message:
"IDE expanded. Available functions: OpenFile, GetSelection, ShowMessage"
functionResult is appended to this. Pass null if the auto-generated message is sufficient.
{
name: 'IDE',
functionResult: 'Tip: always call GetSelection before editing.', // appended to auto-message
systemPrompt: 'Never delete files without explicit user confirmation.', // every turn
tools: [...]
}Dynamic Tool Changes (Augmentation)
A tool's response can modify the active tool set — inject new groups, remove old ones, hide/show individual tools, and update shared state:
onClientToolInvoke: async (request) => {
if (request.toolName === 'Login') {
await performLogin(request.arguments);
return createSuccessResponse(request.requestId, 'Logged in as admin', {
injectToolKits: [adminToolKit], // Add new group
removeToolKits: ['GuestTools'], // Remove old group
expandToolKits: ['AdminTools'], // Pre-expand new group
collapseToolKits: ['PublicTools'], // Collapse a group
hideTools: ['Login'], // Hide this tool
showTools: ['Logout'], // Reveal another
addContext: [{ key: 'user', description: 'Current user', value: { role: 'admin' } }],
updateState: { authenticated: true } // Replace shared state
});
}
}Full Augmentation Reference
| Field | Type | Description |
|---|---|---|
injectToolKits | clientToolKitDefinition[] | Add new tool groups |
removeToolKits | string[] | Remove groups by name |
expandToolKits | string[] | Pre-expand groups |
collapseToolKits | string[] | Collapse groups |
hideTools | string[] | Hide individual tools |
showTools | string[] | Reveal hidden tools |
addContext | ContextItem[] | Inject context items into the agent's context |
removeContext | string[] | Remove context items by key |
updateState | unknown | Replace the full shared state object |
patchState | unknown | Partial patch of shared state |
Stream Options
When calling client.stream(), you can pass additional options to control the session:
await client.stream(sessionId, branchId, messages, handlers, {
clientToolKits: [ideGroup], // Tool groups for this stream
context: [{ key: 'env', description: 'Environment', value: 'production' }],
expandedContainers: ['IDE'], // Pre-expand these containers
hiddenTools: ['DeleteFile'], // Hide specific tools
resetClientState: true, // Clear all registered tool groups and state
signal: abortController.signal // AbortSignal for cancellation
});Client Skills
Group tools into named workflows with activation instructions. Skills work the same way as server-side Skills but defined in TypeScript:
{
name: 'Checkout',
tools: [validateCart, processPayment, confirmOrder],
skills: [{
name: 'CompleteCheckout',
description: 'Process a customer checkout end to end',
functionResult: null, // auto-generated is sufficient
systemPrompt: `
CHECKOUT WORKFLOW:
1. ValidateCart — check stock and prices
2. ProcessPayment — charge the card
3. ConfirmOrder — send confirmation to user`,
references: [
{ toolName: 'ValidateCart' },
{ toolName: 'ProcessPayment' },
{ toolName: 'ConfirmOrder' }
]
}]
}Note: Skill references use
toolName(and optionallyToolKitName), notname.
State Persistence Across Turns
Registered tool groups persist across message turns by default. You only need to register them once:
// Turn 1: tools registered
await client.stream(sessionId, branchId, [{ content: 'Hello' }], handlers, {
clientToolKits: [ideGroup]
});
// Turn 2: IDE tools still available — no need to re-register
await client.stream(sessionId, branchId, [{ content: 'Open notes.txt' }], handlers);
// Reset everything
await client.stream(sessionId, branchId, [{ content: 'Start fresh' }], handlers, {
resetClientState: true
});Event Handling
Beyond client tools, the stream() handlers give you access to all agent events:
await client.stream(sessionId, branchId, messages, {
onTextDelta: (text, messageId) => appendText(text),
onTextMessageStart: (messageId, role) => startMessage(role),
onTextMessageEnd: (messageId) => endMessage(),
onToolCallStart: (callId, name, messageId) => showToolCall(name),
onToolCallResult: (callId, result) => showResult(result),
onPermissionRequest: async (request) => {
const approved = await showPermissionDialog(request.functionName);
return { permissionId: request.permissionId, approved };
},
onClarificationRequest: async (request) => {
return await promptUser(request.question);
},
onclientToolKitsRegistered: (event) => {
console.log(`Registered ${event.totalTools} tools: ${event.registeredToolKits.join(', ')}`);
},
onEvent: (event) => console.log('Raw event:', event), // All events
onError: (message) => console.error(message),
onComplete: () => console.log('Done')
});Backend Configuration Options
var agent = await new AgentBuilder()
.WithClientTools(config =>
{
config.InvokeTimeout = TimeSpan.FromSeconds(30); // Default: 30s
config.DisconnectionStrategy = ClientDisconnectionStrategy.FallbackMessage;
config.MaxRetries = 3;
config.FallbackMessageTemplate = "Client unavailable. Tool '{0}' skipped.";
})
.BuildAsync();| Strategy | Behavior |
|---|---|
FallbackMessage | Return error message to agent and continue (default) |
FailFast | Throw immediately |
RetryWithBackoff | Retry up to MaxRetries times |
Common Failure Modes
Handler Never Returns
The most common mistake. If your handler throws an unhandled exception or the promise never resolves, the agent hangs until InvokeTimeout expires.
// Bad: unhandled rejection hangs the agent
onClientToolInvoke: async (request) => {
const result = await riskyOperation(); // throws → agent hangs
return createSuccessResponse(request.requestId, result);
}
// Good: always catch and return an error response
onClientToolInvoke: async (request) => {
try {
const result = await riskyOperation();
return createSuccessResponse(request.requestId, result);
} catch (err) {
return createErrorResponse(request.requestId, String(err));
}
}Client Disconnects Mid-Turn
If the client disconnects while the agent is waiting for a tool response, the backend DisconnectionStrategy determines what happens:
config.DisconnectionStrategy = ClientDisconnectionStrategy.FallbackMessage;
// → Agent receives: "Client unavailable. Tool 'OpenFile' skipped."
// → Agent continues the turn without the result
config.DisconnectionStrategy = ClientDisconnectionStrategy.FailFast;
// → Turn fails immediately with an errorTool Group Not Visible to Agent
If the agent never tries to call your tool, check:
- Group is collapsed and agent didn't expand it — set
startCollapsed: falseor passexpandedContainers: ['GroupName']in stream options - Group registered after the stream started — use
clientToolKitsin stream options, or register before callingstream() - Handler registered on wrong instance —
onClientToolInvokemust be on the sameAgentClientinstance that's streaming
requestId Mismatch
Always use request.requestId from the incoming request — never generate your own:
// Bad
return { requestId: crypto.randomUUID(), success: true, content: [] };
// Good — use the helpers which do this automatically
return createSuccessResponse(request.requestId, 'Done');Testing Client Tools
Unit Testing Handlers
Test your onClientToolInvoke handler in isolation by calling it directly with a mock request:
import { describe, it, expect } from 'vitest';
import { createSuccessResponse, createErrorResponse } from '@hpd/hpd-agent-client';
import type { ClientToolInvokeRequestEvent } from '@hpd/hpd-agent-client';
const handler = async (request: ClientToolInvokeRequestEvent) => {
if (request.toolName === 'OpenFile') {
return createSuccessResponse(request.requestId, 'File opened');
}
return createErrorResponse(request.requestId, `Unknown tool: ${request.toolName}`);
};
describe('OpenFile handler', () => {
it('returns success for valid path', async () => {
const result = await handler({
requestId: 'test-123',
toolName: 'OpenFile',
callId: 'call-1',
arguments: { path: '/workspace/notes.txt' }
} as ClientToolInvokeRequestEvent);
expect(result.success).toBe(true);
expect(result.requestId).toBe('test-123');
});
it('returns error for unknown tool', async () => {
const result = await handler({
requestId: 'test-456',
toolName: 'UnknownTool',
callId: 'call-2',
arguments: {}
} as ClientToolInvokeRequestEvent);
expect(result.success).toBe(false);
expect(result.errorMessage).toContain('Unknown');
});
});Integration Testing with Mock Server
For end-to-end tests, point the client at a mock server that emits pre-canned CLIENT_TOOL_INVOKE_REQUEST events:
import { AgentClient } from '@hpd/hpd-agent-client';
const client = new AgentClient({
baseUrl: 'http://localhost:9999', // mock server
clientToolKits: [ideGroup],
onClientToolInvoke: handler
});
// Your mock server emits a CLIENT_TOOL_INVOKE_REQUEST SSE event,
// the client calls your handler and POSTs the response back,
// assert that the mock server received the expected response shapeTroubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Agent never calls the tool | Tool group is collapsed, agent didn't expand it | Set startCollapsed: false or pass expandedContainers in stream options |
| Agent hangs for ~30 seconds | Handler didn't return a response | Always return a response — use createSuccessResponse/createErrorResponse |
requestId mismatch error | Response sent with wrong ID | Use the response helpers — they set requestId automatically |
| Tool not found after login | Need to inject group dynamically | Use augmentation.injectToolKits in the response |
| Handler throws, agent continues | Unhandled promise rejection swallowed | Wrap handler in try/catch, always return error response |
| Tools disappear between turns | Per-stream registration without persistence | Register at client level or use client.registerToolKit() before streaming |
| Skills references not working | Wrong field name | Use toolName (not name) in ClientSkillReference |