Skip to content

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.

app/tools/route.ts
// 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).

type

Tool arguments as the model produced them (JSON). defineTool narrows them per tool.

type ToolArgs = Record<string, any>;
interface

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;
}
interface

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;
}
type

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" };
interface

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;
}
interface

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;
}
type
type ToolHandlerResult = ToolCallResponse | undefined | void;
type
type ToolHandlerFn<A = ToolArgs> = (args: A, ctx: ToolCallContext) => ToolHandlerResult | Promise<ToolHandlerResult>;
type
type JsonSchemaType = "string" | "number" | "integer" | "boolean" | "object" | "array" | "null";
interface

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;
}
type

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;
interface

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>;
}
type

Tool name → handler. Each handler narrows its own arguments.

type ToolHandlers = Readonly<Record<string, ToolHandlerFn<any> | ToolDefinition<any>>>;
function

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>;
type
type ToolErrorPhase = "verify" | "parse" | "lookup" | "arguments" | "handler" | "timeout" | "response";
interface
interface ToolErrorInfo {
phase: ToolErrorPhase;
tool?: string;
ctx?: ToolCallContext;
}
interface
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;
}
constant
const DEFAULT_TOOL_TIMEOUT_MS = 25_000;
function

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;
class

Thrown (and reported through onError) when a handler returns something that is not a ToolCallResponse.

class ToolResponseError extends Error {
readonly name = "ToolResponseError";
}
function

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>;
interface

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>;
}
interface

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;
}
function

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>;
constant

The tool helpers as one namespace: import { tools } from "@morevoice/sdk".

const tools: {
handler: typeof createToolHandler;
nodeHandler: typeof createNodeToolHandler;
defineTool: typeof defineTool;
};