Tool calls
When an assistant uses a custom tool during a call, MoreVoice sends a signed POST to the tool’s URL. tools.handler() (Fetch API runtimes) and tools.nodeHandler() (Node http, Express, Fastify) turn your functions into that endpoint: they verify the signature before your code runs, check the arguments against the tool’s JSON schema, enforce a timeout, and send back result, say, end_call or transfer.
A failing handler answers HTTP 200 with an error the assistant can talk around, so a broken tool never drops the call.
// A custom tool endpoint: MoreVoice calls it when an assistant uses the tool// during a call. tools.handler verifies the signature before your code runs.import { defineTool, tools } from "@morevoice/sdk";
export const POST = tools.handler({ secret: process.env.MOREVOICE_TOOL_SECRET!, // whsec_… handlers: { order_status: defineTool({ parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"], }, async handler({ order_id }) { const status = await lookUpOrder(order_id); return { result: { order_id, status }, say: `Order ${order_id} is ${status}.` }; }, }), },});
async function lookUpOrder(orderId: string): Promise<string> { return orderId.startsWith("A") ? "shipped" : "being prepared";}Exported from @morevoice/sdk (source: packages/sdk-ts/src/lib/tools.ts).
ToolArgs
Section titled “ToolArgs”Tool arguments as the model produced them (JSON). defineTool narrows them per tool.
type ToolArgs = Record<string, any>;ToolCallInfo
Section titled “ToolCallInfo”The call a tool runs in (doc 07 §2.4d). Fields other than id arrive with the v1 sender (WS05 W2).
interface ToolCallInfo { /** `call_…` */ id: string; direction?: "inbound" | "outbound" | (string & {}); from?: string | null; to?: string | null; assistant_id?: string | null; flow_id?: string | null; language?: string | null; metadata?: Record<string, unknown>; variables?: Record<string, unknown>; [field: string]: unknown;}ToolCallRequest
Section titled “ToolCallRequest”The signed request body.
interface ToolCallRequest<A = ToolArgs> { type?: "tool.call"; /** `tc_…`; absent from today's sender, where `webhook-id` carries it. */ tool_call_id?: string; tool: string; arguments: A; call: ToolCallInfo; livemode?: boolean;}ToolTransfer
Section titled “ToolTransfer”Hand the call over after the assistant speaks.
type ToolTransfer = | { queue_id: string; mode?: "warm" | "cold" } | { number: string; mode?: "warm" | "cold" } | { assistant_id: string; mode?: "warm" | "cold" };ToolCallResponse
Section titled “ToolCallResponse”What a handler returns. Every field is optional.
interface ToolCallResponse<R = unknown> { /** Data for the model to phrase an answer from (any JSON). */ result?: R; /** An exact sentence to speak instead of letting the model phrase it. */ say?: string | null; /** Hang up after speaking. */ end_call?: boolean; transfer?: ToolTransfer | null;}ToolCallContext
Section titled “ToolCallContext”Per-call context passed to a handler.
interface ToolCallContext { tool: string; /** `tc_…`: unique per tool call. Dedupe on it if your handler must not run twice. */ toolCallId: string; /** The verified `webhook-id` header. */ webhookId: string; /** Unix seconds from the verified `webhook-timestamp`. */ timestamp: number; call: ToolCallInfo; /** False for test-mode calls; undefined when the sender does not say (today's sender). */ livemode: boolean | undefined; /** Aborts when the handler times out: pass it to fetch / your DB client to stop the work. */ signal: AbortSignal; /** The full verified request body. */ request: ToolCallRequest;}ToolHandlerResult
Section titled “ToolHandlerResult”type ToolHandlerResult = ToolCallResponse | undefined | void;ToolHandlerFn
Section titled “ToolHandlerFn”type ToolHandlerFn<A = ToolArgs> = (args: A, ctx: ToolCallContext) => ToolHandlerResult | Promise<ToolHandlerResult>;JsonSchemaType
Section titled “JsonSchemaType”type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "object" | "array" | "null";JsonSchema
Section titled “JsonSchema”The JSON-schema subset tool parameters use (the same object you put in the assistant’s tool definition).
interface JsonSchema { type?: JsonSchemaType | readonly JsonSchemaType[]; description?: string; enum?: readonly (string | number | boolean | null)[]; properties?: Readonly<Record<string, JsonSchema>>; required?: readonly string[]; items?: JsonSchema; additionalProperties?: boolean | JsonSchema; [keyword: string]: unknown;}FromJsonSchema
Section titled “FromJsonSchema”The TypeScript type a JSON schema describes (enough for tool parameters: objects, arrays, primitives, enums).
type FromJsonSchema<S> = S extends { enum: readonly (infer E)[] } ? E : S extends { type: "array"; items: infer I } ? FromJsonSchema<I>[] : S extends { type: "object"; properties: infer P } ? ObjectFromSchema<P, S extends { required: readonly (infer R)[] } ? R : never> : S extends { type: infer T } ? T extends readonly (infer U)[] ? TypeOfName<U> : TypeOfName<T> : unknown;ToolDefinition
Section titled “ToolDefinition”A handler plus, optionally, the JSON schema its arguments are checked against before it runs.
interface ToolDefinition<A = ToolArgs> { readonly description?: string; readonly parameters?: JsonSchema; readonly handler: ToolHandlerFn<A>;}ToolHandlers
Section titled “ToolHandlers”Tool name → handler. Each handler narrows its own arguments.
type ToolHandlers = Readonly<Record<string, ToolHandlerFn<any> | ToolDefinition<any>>>;defineTool()
Section titled “defineTool()”Type a tool’s arguments. With parameters, the argument type is derived from the schema and the arguments are
checked against it before the handler runs (a mismatch is reported to the model, which can correct itself):
find_slots: defineTool({ parameters: { type: "object", properties: { doctor: { type: "string" }, date: { type: "string" } }, required: ["date"] }, async handler({ doctor, date }) { … }, // doctor: string | undefined, date: string}),function defineTool<A extends object = ToolArgs>(handler: ToolHandlerFn<A>): ToolDefinition<A>;function defineTool<const S extends JsonSchema>(definition: { description?: string; parameters: S; handler: ToolHandlerFn<FromJsonSchema<S>> }): ToolDefinition<FromJsonSchema<S>>;function defineTool<A extends object = ToolArgs>(definition: { description?: string; parameters?: JsonSchema; handler: ToolHandlerFn<A> }): ToolDefinition<A>;ToolErrorPhase
Section titled “ToolErrorPhase”type ToolErrorPhase = "verify" | "parse" | "lookup" | "arguments" | "handler" | "timeout" | "response";ToolErrorInfo
Section titled “ToolErrorInfo”interface ToolErrorInfo { phase: ToolErrorPhase; tool?: string; ctx?: ToolCallContext;}ToolHandlerOptions
Section titled “ToolHandlerOptions”interface ToolHandlerOptions { /** The org's tool signing secret (`whsec_…`). Several while you rotate your copy. */ secret: WebhookSecret | readonly WebhookSecret[]; handlers: ToolHandlers; /** * Called on every failure (bad signature, bad body, handler error, timeout, invalid return value), e.g. to log it. * For `handler`, `timeout` and `response` failures it may return the response to send instead of the default. */ onError?: (error: unknown, info: ToolErrorInfo) => ToolCallResponse | undefined | void | Promise<ToolCallResponse | undefined | void>; /** Give up on a handler after this long and answer with a `say` fallback (default 25 000 ms). */ timeoutMs?: number; /** What the assistant says on a timeout. Default: a short apology in the call's language (he / en). */ timeoutSay?: string | null | ((ctx: ToolCallContext) => string | null); /** Signature timestamp tolerance in seconds (default 300). */ toleranceSec?: number; /** Clock for the tolerance check, ms since the epoch (tests). */ now?: () => number; /** Refuse larger bodies with 413 (default 1 MiB). */ maxBodyBytes?: number;}DEFAULT_TOOL_TIMEOUT_MS
Section titled “DEFAULT_TOOL_TIMEOUT_MS”const DEFAULT_TOOL_TIMEOUT_MS = 25_000;checkJsonSchema()
Section titled “checkJsonSchema()”The first way value breaks schema, as a sentence the model can act on, or null.
function checkJsonSchema(schema: JsonSchema, value: unknown, path = "arguments"): string | null;ToolResponseError
Section titled “ToolResponseError”Thrown (and reported through onError) when a handler returns something that is not a ToolCallResponse.
class ToolResponseError extends Error { readonly name = "ToolResponseError";}createToolHandler()
Section titled “createToolHandler()”A Fetch-API request handler (Next.js route handlers, Remix/React Router actions, Hono, Bun.serve, Deno.serve,
Cloudflare Workers): (request: Request) => Promise<Response>. Verifies on WebCrypto, so it runs on edge runtimes.
function createToolHandler(options: ToolHandlerOptions): (request: Request) => Promise<Response>;NodeToolRequest
Section titled “NodeToolRequest”The parts of a Node IncomingMessage / Express Request / Fastify FastifyRequest the handler reads.
interface NodeToolRequest { method?: string; headers: Readonly<Record<string, HeaderValue>>; /** Express `express.raw()` / Fastify buffer parser: the raw body. A parsed object cannot be verified. */ body?: unknown; /** Set by raw-body plugins (fastify-raw-body, NestJS `rawBody: true`, Firebase). Preferred over `body`. */ rawBody?: unknown; readableEnded?: boolean; [Symbol.asyncIterator]?: () => AsyncIterator<unknown>;}NodeToolResponse
Section titled “NodeToolResponse”A Node ServerResponse / Express Response (statusCode + setHeader + end) or a Fastify FastifyReply (code + header + send).
interface NodeToolResponse { statusCode?: number; /** Node / Express: set once a response started (we then leave it alone). */ headersSent?: boolean; /** Fastify: set once the reply was sent. */ sent?: boolean; setHeader?: (name: string, value: string) => unknown; end?: (chunk?: string) => unknown; code?: (status: number) => unknown; header?: (name: string, value: string) => unknown; send?: (payload: string) => unknown;}createNodeToolHandler()
Section titled “createNodeToolHandler()”A (req, res) handler for Node http, Express and Fastify. It needs the raw body: Express
app.post("/tools", express.raw({ type: "application/json" }), tools.nodeHandler(…)); Fastify a buffer content-type
parser or fastify-raw-body; plain http / Express without a body parser are read from the stream. A bad request
never throws: it is answered (4xx, or 500 for a misconfigured server) and reported through onError.
function createNodeToolHandler(options: ToolHandlerOptions): (req: NodeToolRequest, res: NodeToolResponse) => Promise<void>;The tool helpers as one namespace: import { tools } from "@morevoice/sdk".
const tools: { handler: typeof createToolHandler; nodeHandler: typeof createNodeToolHandler; defineTool: typeof defineTool;};