D-ID Client SDK - v3.0.0-0
    Preparing search index...

    Client tools

    A client tool is a function that runs in the user's browser when the agent's LLM decides to call it. It is how an agent reaches things only the page knows — the contents of a cart, the user's locale, the slide currently on screen — and how it acts on them. The tool itself is declared in the agent's configuration; the SDK's job is to bind a name from that configuration to an implementation with registerClientTool() and to report what happens through onToolEvent.

    Client tools are an Expressive (V4) feature. They travel on the real-time session's RPC channel, which Talks (V2) and Clips (V3) agents do not have, so registerClientTool() throws a ValidationError on those rather than registering a handler the agent could never call. The check is on the agent, not on the connection, so it applies before connect() too.

    Register before connect(), so the agent can call a tool from the moment the session starts. Registering the same name again replaces the handler.

    import * as sdk from '@d-id/client-sdk';

    const agentManager = await sdk.createAgentManager('agt_fumf1234', {
    auth: { type: 'key', clientKey: 'YOUR_CLIENT_KEY' },
    callbacks: {
    onSrcObjectReady(value) {
    videoElement.srcObject = value;
    },
    },
    });

    agentManager.registerClientTool('get_cart_total', async args => {
    const total = await cart.total(args.currency as string);
    return JSON.stringify({ total, currency: args.currency });
    });

    // Nothing to await: a synchronous handler may return the string directly.
    agentManager.registerClientTool('get_locale', () => JSON.stringify({ locale: navigator.language }));

    await agentManager.connect();

    A handler is a ClientToolHandler: (args: Record<string, unknown>) => string | Promise<string>.

    • It is given the LLM's arguments, already parsed. The SDK parses the JSON the agent sent and hands you the object. The values are typed unknown, because their shape is the agent's tool schema rather than anything the SDK knows — narrow or cast them yourself.
    • It must return a JSON string. Not an object: whatever the handler resolves with is sent back to the agent verbatim, so serialize it. JSON.stringify is the whole of it.
    • The result is capped at 15 KiB. That is the transport's response limit, not the SDK's, and a larger result fails the call. Return an id or a summary rather than a document.
    • Throwing is how a tool reports failure. The SDK rejects the call and forwards the error message to the agent, which can then say something sensible instead of waiting. Do not swallow errors into a { "error": … } string unless you want the LLM to treat the call as a success.
    agentManager.registerClientTool('book_appointment', async args => {
    const slot = args.slot as string;

    if (!(await calendar.isFree(slot))) {
    // The message reaches the agent, which can offer another time.
    throw new Error(`${slot} is no longer available`);
    }

    const booking = await calendar.book(slot);
    return JSON.stringify({ bookingId: booking.id });
    });

    Whether the agent waits for a call is a property of the tool in the agent's configuration, not of the handler, and it reaches the SDK as ToolExecutionMode on RunningToolCall.executionMode and ToolCallStartedPayload.executionMode:

    • blocking — the agent is suspended until the handler resolves. Anything the user is waiting on an answer for belongs here, and it is what makes a spinner worth showing.
    • async — the agent keeps talking while the handler runs, and the call can outlive the turn that started it. Fire-and-forget side effects belong here.

    A blocking call also suspends interrupting: onInterruptibleChange goes false while any blocking call is outstanding and back to true when the last one finishes, because there is nothing to interrupt while the agent is waiting.

    onToolEvent is called once when a call starts, and again when it finishes or fails. Its type, ToolEventCallback, pairs each ToolCallEvent with the payload that event carries — ToolCallEvent.Started with a ToolCallStartedPayload, ToolCallEvent.Done with a ToolCallDonePayload, ToolCallEvent.Error with a ToolCallErrorPayload.

    Branching on event is what narrows data, inside the handler and not only at the call site, so nothing has to be asserted. callId, name, input and timestamp are on all three payloads and can be read before the branch.

    import { ToolCallEvent, type AgentManagerCallbacks } from '@d-id/client-sdk';

    const callbacks: AgentManagerCallbacks = {
    onToolEvent(event, data) {
    if (event === ToolCallEvent.Started) {
    console.log('started', data.callId, data.name, data.input);
    } else if (event === ToolCallEvent.Done) {
    console.log('done', data.name, data.output, `${data.durationMs}ms`);
    } else {
    // `error` is the reason when the server gave one; the structured failure is in
    // `extra.error`, and `output` is typed `unknown` — narrow it before use.
    console.warn('failed', data.name, data.error ?? 'no reason given');
    }
    },
    };

    ToolCallEvent members are also accepted as their plain string values, so event === 'tool-call/done' works without importing the enum.

    onRunningToolCallsChange carries the whole set of calls running right now, as RunningToolCall entries, every time it changes. A call appears when it starts and disappears when it finishes or fails — or, for a blocking call, when its turn ends. On disconnect it fires with an empty array if any call was still running, so a spinner driven by it always clears — with nothing outstanding there is nothing to clear and nothing is emitted.

    isAwaitingTool answers the only question most UIs have of that array: is the agent suspended on a blocking call?

    import { isAwaitingTool, type AgentManagerCallbacks } from '@d-id/client-sdk';

    const callbacks: AgentManagerCallbacks = {
    onSrcObjectReady(value) {
    videoElement.srcObject = value;
    },
    onRunningToolCallsChange(calls) {
    setSpinnerVisible(isAwaitingTool(calls));
    setStatusText(calls.map(call => call.name).join(', '));
    },
    onInterruptibleChange(interruptible) {
    setStopButtonEnabled(interruptible);
    },
    };

    unregisterClientTool() removes a handler. Unlike registerClientTool() it never throws — an unknown name, and any avatar type, is a no-op — so it is safe in a React cleanup path that runs whatever the agent turned out to be.

    useEffect(() => {
    agentManager.registerClientTool('get_cart_total', getCartTotal);
    return () => agentManager.unregisterClientTool('get_cart_total');
    }, [agentManager]);

    After a tool is unregistered the agent's calls to it fail rather than reaching your code, which is the right outcome: the agent hears that the tool is gone.