Skip to content

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 continues

The TS client sends the response automatically. You only need to provide the handler.


Quick Start (TypeScript)

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:

csharp
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)

typescript
const client = new AgentClient({
    baseUrl: 'http://localhost:5000',
    clientToolKits: [myToolKit],
    onClientToolInvoke: async (req) => { /* ... */ }
});

2. Per stream

typescript
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

typescript
// 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:

typescript
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:

typescript
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:

typescript
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

typescript
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

typescript
// 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):

FieldWhere it goesWhen shownUse for
functionResultConversation historyOnce, when group expandsExtra context beyond the auto-generated expansion message
systemPromptSystem promptEvery turn while expandedCritical 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.

typescript
{
    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:

typescript
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

FieldTypeDescription
injectToolKitsclientToolKitDefinition[]Add new tool groups
removeToolKitsstring[]Remove groups by name
expandToolKitsstring[]Pre-expand groups
collapseToolKitsstring[]Collapse groups
hideToolsstring[]Hide individual tools
showToolsstring[]Reveal hidden tools
addContextContextItem[]Inject context items into the agent's context
removeContextstring[]Remove context items by key
updateStateunknownReplace the full shared state object
patchStateunknownPartial patch of shared state

Stream Options

When calling client.stream(), you can pass additional options to control the session:

typescript
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:

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 optionally ToolKitName), not name.


State Persistence Across Turns

Registered tool groups persist across message turns by default. You only need to register them once:

typescript
// 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:

typescript
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

csharp
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();
StrategyBehavior
FallbackMessageReturn error message to agent and continue (default)
FailFastThrow immediately
RetryWithBackoffRetry 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.

typescript
// 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:

csharp
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 error

Tool Group Not Visible to Agent

If the agent never tries to call your tool, check:

  1. Group is collapsed and agent didn't expand it — set startCollapsed: false or pass expandedContainers: ['GroupName'] in stream options
  2. Group registered after the stream started — use clientToolKits in stream options, or register before calling stream()
  3. Handler registered on wrong instanceonClientToolInvoke must be on the same AgentClient instance that's streaming

requestId Mismatch

Always use request.requestId from the incoming request — never generate your own:

typescript
// 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:

typescript
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:

typescript
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 shape

Troubleshooting

SymptomCauseFix
Agent never calls the toolTool group is collapsed, agent didn't expand itSet startCollapsed: false or pass expandedContainers in stream options
Agent hangs for ~30 secondsHandler didn't return a responseAlways return a response — use createSuccessResponse/createErrorResponse
requestId mismatch errorResponse sent with wrong IDUse the response helpers — they set requestId automatically
Tool not found after loginNeed to inject group dynamicallyUse augmentation.injectToolKits in the response
Handler throws, agent continuesUnhandled promise rejection swallowedWrap handler in try/catch, always return error response
Tools disappear between turnsPer-stream registration without persistenceRegister at client level or use client.registerToolKit() before streaming
Skills references not workingWrong field nameUse toolName (not name) in ClientSkillReference

Released under the MIT License.