Skip to content

ClientToolsConfig return type is narrower than the runtime accepts #972

Description

@carlos-cubas

The declared return type for a client tool does not admit the object results that
BaseConversation already coerces at runtime, so TypeScript callers cannot express
something the SDK supports on purpose.

What the type says

packages/client/src/BaseConversation.ts on main:

export type ClientToolsConfig = {
  clientTools: Record<
    string,
    (
      parameters: any
    ) => Promise<string | number | void> | string | number | void
  >;
};

What the runtime does

Same file, in the client_tool_call handler:

const result =
  (await ...) ?? "Client tool execution successful."; // default client-tool call response

// The API expects result to be a string, so we need to convert it if it's not already a string
const formattedResult =
  typeof result === "object" ? JSON.stringify(result) : String(result);

An object result is serialised and sent. The comment states the intent, and #41
("Always cast tool result to string", March 2025) added the coercion for exactly this
reason: "The API expects the result of a client tool to always be a string, this
enforces that on the SDK level."

So returning an object is supported behaviour that the published type rejects.

Why it is more than cosmetic

The type is also load-bearing for a second signature. useConversation in
packages/react types startSession as:

startSession: T extends SessionConfig
  ? (options?: HookOptions) => Promise<string>
  : (options: SessionConfig & HookOptions) => Promise<string>;

A handler map that does not satisfy ClientToolsConfig prevents the hook argument
from satisfying Partial<Options>, so T extends SessionConfig cannot hold and every
startSession({ overrides }) call resolves to the second branch. That branch requires
a conversationToken, which is not applicable to an agentId session. The reported
error therefore names conversationToken and gives no indication that the cause is the
client tool return type.

Concretely, this is what a caller sees:

Argument of type '{ overrides: { conversation: { textOnly: boolean } } }' is not
assignable to parameter of type 'SessionConfig & HookOptions'.
  Property 'conversationToken' is missing in type ...

Suggested change

Widen the return type to include what the runtime handles:

(parameters: any) => Promise<unknown> | unknown

A narrower version that keeps the intent visible would also work:

(parameters: any) =>
  | Promise<string | number | object | void>
  | string | number | object | void

Either makes the type agree with formattedResult, and neither changes behaviour.

Workaround, for anyone finding this first

Serialise on the caller's side before handing the map to useConversation. Two details
matter if you do:

  1. Objects only: strings and numbers are already accepted, so converting them adds
    nothing.
  2. Leave nullish results alone: a handler returning nothing must stay nullish so the
    ?? "Client tool execution successful." default can apply. Stringifying it produces
    "undefined" and makes that default unreachable.

Versions checked

  1. @elevenlabs/client 1.21.0 and main at time of writing: type and runtime both as
    quoted above.
  2. @elevenlabs/client 0.13.1: same type, same coercion, but no
    ?? "Client tool execution successful." default. A void handler produced the string
    "undefined" there.
  3. Discussions are not enabled on the repository, so there is no discussion thread to
    reference.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions