Skip to content

feat(client): widen the client tool return type to what the SDK coerces - #986

Open
chinmayv095 wants to merge 1 commit into
elevenlabs:mainfrom
chinmayv095:feat/client-tool-result-type
Open

chinmayv095 wants to merge 1 commit into
elevenlabs:mainfrom
chinmayv095:feat/client-tool-result-type

Conversation

@chinmayv095

Copy link
Copy Markdown
Contributor

Fixes #972.

BaseConversation has serialised object results from client tools since #41, and that PR says so on purpose: "The API expects the result of a client tool to always be a string, this enforces that on the SDK level." The declared type never followed.

// what the type allowed
(parameters: any) => Promise<string | number | void> | string | number | void

// what the handler does with the result
const formattedResult =
  typeof result === "object" ? JSON.stringify(result) : String(result);

So returning an object, an array or a boolean is supported behaviour that TypeScript rejects, and a caller has to JSON.stringify on their own side to satisfy a type whose only consumer stringifies it again.

The two copies of the type had drifted

@elevenlabs/react declares its own:

// packages/react/src/conversation/types.ts
export type ClientToolResult = string | number | void;
export type ClientTool<...> = (parameters: Parameters) => Promise<Result> | Result;

while ConversationClientTools.tsx stores handlers as ClientToolsConfig["clientTools"][string], which is the client's type. Both were narrow in the same way, so the duplication was invisible. Widening one of them makes it visible at once: a handler that satisfies clientTools stops satisfying ClientTool, and ClientTool is what useConversationClientTool takes. Measured by reverting only the react file on this branch:

src/conversation/ConversationClientTools.test.tsx(240,11): error TS2322:
  Type '(parameters: any) => ClientToolResult | Promise<ClientToolResult>'
  is not assignable to type 'ClientTool'.

So the fix belongs in both, from one definition. @elevenlabs/client now exports ClientToolResult and @elevenlabs/react re-exports it instead of keeping its own.

What the type says now

export type ClientToolResult = string | number | boolean | object | void;

object covers arrays, which JSON.stringify already handles. boolean is in there because String(false) is "false" and false is not nullish, so it never reaches the ?? "Client tool execution successful." default. That is easy to get wrong by reasoning about ?? alone, so it has its own test.

The issue offered Promise<unknown> | unknown as one option and this narrower form as another. I took the narrower one: unknown would type-check the same handlers but stop describing the coercion, and describing it is the only job this annotation has.

Verification

Nothing changes at runtime, so the type check is the discriminating evidence here, not the test run. Both are included.

  • Type check. Reverting only the source files on this branch, keeping the new tests, produces 7 errors in @elevenlabs/client and 3 in @elevenlabs/react. They had to be measured separately because the client build fails first. With the change, pnpm -w check-types is green across all 16 tasks.
  • 4 runtime tests in BaseConversation.test.ts covering an object, an array, a boolean and a handler that returns nothing, asserting the exact client_tool_result payload sent. These pass with and without the change, deliberately: they pin the behaviour the type now claims, so a later narrowing of the coercion fails too. Two compile-time assertions carry the part that does discriminate.
  • pnpm -w lint green across all 29 tasks. 262 client tests and 142 react tests pass.
  • The full workspace run has one failure, convai-widget-core's DismissButton.test.ts. Confirmed pre-existing by stashing this branch's changes and getting the identical failure on a clean tree.

Changeset included as a minor for both packages, since this adds an export and widens two published types.

BaseConversation serialises an object result with JSON.stringify and sends
everything else through String, but ClientToolsConfig declared the return type
as string | number | void, so a handler returning an object or a boolean was a
type error for behaviour the SDK supports on purpose.

Add an exported ClientToolResult covering what the coercion handles, and have
@elevenlabs/react re-export it rather than keep its own narrower copy, so the
hook API and the client agree on what a tool may return.
@cursor

cursor Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

PR Summary

Low Risk
Type-only widening and a shared export; runtime client-tool handling is unchanged and covered by new regression tests.

Overview
Aligns TypeScript with behavior that already existed at runtime: client tool handlers may return objects, arrays, booleans, strings, numbers, or nothing, and BaseConversation still stringifies results for the wire (JSON.stringify for objects, String otherwise, default message when void).

@elevenlabs/client adds and exports ClientToolResult (string | number | boolean | object | void) and wires ClientToolsConfig to it. @elevenlabs/react drops its narrower duplicate and re-exports the same type so ClientTool, useConversationClientTool, and clientTools options stay interchangeable.

Adds tests that pin the outgoing client_tool_result payloads (object, array, boolean, empty return) plus compile-time checks; changeset marks both packages minor. No runtime change.

Reviewed by Cursor Bugbot for commit 392ef65. Bugbot is set up for automated code reviews on this repo. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientToolsConfig return type is narrower than the runtime accepts

1 participant