On this page
Customer HTTP handlers reference
When an agent needs information from your application, Cumulus sends an HTTP request to your server. This module helps your Node application receive that request safely, run your business function and return its result. Your application owns the endpoint and its data; Cumulus runs the conversation.
Using Python? Start with the Python SDK for API calls. To receive tools, context and events in a Python server, implement the customer HTTP callback contracts; this page documents the TypeScript adapters.
Import from @cumulus/sdk/server. This page documents SDK 0.3.4. Follow the customer http handlers guide for a worked workflow.
Build and test a ticket lookup
This complete server reads two fictional tickets. It makes no bookings, changes no records and places no calls. Use Node 22 or newer in a project with SDK 0.3.4 installed. Save the code as ticket-server.mts.
import { createServer } from "node:http";
import { pathToFileURL } from "node:url";
import { defineTool } from "@cumulus/sdk/agent";
import { implementTool, createToolHandler, nodeHandler } from "@cumulus/sdk/server";
export const lookup = defineTool({
key: "lookup_ticket", description: "Read the status of a service ticket.",
input: {
type: "object", properties: { reference: { type: "string" } },
required: ["reference"], additionalProperties: false,
},
output: {
type: "object", properties: {
found: { type: "boolean" },
status: { type: "string", enum: ["open", "closed", "not_found"] },
},
required: ["found", "status"], additionalProperties: false,
},
execution: {
type: "http", binding: "tickets", path: "/tools/lookup",
method: "POST", timeoutMs: 3000, idempotency: { mode: "effect_key" },
},
});
export function createTicketServer(secret: string) {
// Synthetic, read-only data. Replace this map with your authorized application lookup.
const tickets = new Map<string, "open" | "closed">([
["TICKET-1001", "open"], ["TICKET-1002", "closed"],
]);
const implementation = implementTool(lookup, ({ reference }, context) => {
context.signal.throwIfAborted();
const status = tickets.get(reference);
return { found: status !== undefined, status: status ?? "not_found" } as const;
});
const handleLookup = createToolHandler(implementation, {
auth: { scheme: "bearer", secret, integration: "tickets" },
maxBodyBytes: 16_384,
});
return createServer(nodeHandler(request => {
if (new URL(request.url).pathname === "/tools/lookup") return handleLookup(request);
return Promise.resolve(new Response("Not found", { status: 404 }));
}));
}
// Start when executed directly; importing the tool contract does not open a port.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const secret = process.env.CUSTOMER_TOOL_SECRET;
if (!secret || secret.length < 16) throw new Error("Set CUSTOMER_TOOL_SECRET to at least 16 characters");
createTicketServer(secret).listen(8787, "127.0.0.1", () => {
console.log("Ticket lookup listening on http://127.0.0.1:8787/tools/lookup");
});
}
Compile and start it from your application directory. The shared secret below is only a local example; use your secret manager for a deployed endpoint.
npm install --save-dev typescript @types/node
npx tsc ticket-server.mts --module nodenext --target es2022 --outDir dist --skipLibCheck
export CUSTOMER_TOOL_SECRET=local-ticket-example-secret
node dist/ticket-server.mjs
In another terminal, set the same secret and send a synthetic tool request:
export CUSTOMER_TOOL_SECRET=local-ticket-example-secret
curl --fail-with-body http://127.0.0.1:8787/tools/lookup \
-H "Authorization: Bearer $CUSTOMER_TOOL_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: local-ticket-lookup-0001" \
-d '{"args":{"reference":"TICKET-1001"},"call":{"call_id":"example-conversation","run_id":"example-run","agent_id":"ticket_agent"}}'
The HTTP 200 response is {"found":true,"status":"open"}. TICKET-1002 returns closed; an unknown reference returns {"found":false,"status":"not_found"}. The contract describes both outcomes explicitly.
Check authentication by omitting the bearer header:
curl -i -X POST http://127.0.0.1:8787/tools/lookup
The response is HTTP 401 with {"error":{"code":"unauthenticated","message":"Authentication failed"}}. The lookup does not run.
What happens to a request
- Your Node server accepts
/tools/lookupand passes the request tohandleLookup; other paths return404. - The adapter checks the method, bounds the body, authenticates it and validates the envelope and arguments.
- Your callback reads the ticket. In a real application, this is where you check the requesting account’s permissions and query your service.
- The adapter validates the result against the output schema and returns JSON. The tool’s three-second deadline bounds the wait; cancellation does not undo work already sent.
To connect this endpoint to an agent, reuse lookup in the agent definition and map its tickets binding to your deployed HTTPS service and matching secret reference. Follow bindings and integrations. Cumulus cannot reach this loopback-only server until you deploy it. For Python or another framework, implement the same customer HTTP callback contract.
Serve a tool
implementTool
Connect a tool contract to the function that performs its work. The SDK infers the function’s arguments and result from the contract. This step opens no endpoint; pass the returned implementation to createToolHandler.
// Inside createTicketServer above, using its ticket map.
const implementation = implementTool(lookup, ({ reference }, context) => {
context.signal.throwIfAborted();
const status = tickets.get(reference);
return { found: status !== undefined, status: status ?? "not_found" } as const;
});
Related types and helpers: ToolDefinition · ToolImplementation.
Complete TypeScript declaration
export declare function implementTool<T extends ToolDefinition>(tool: T, handler: ToolImplementation<T>["handler"]): ToolImplementation<T>;
createToolHandler
Create the HTTP handler for a tool implementation. It authenticates and validates each request, calls your function with its arguments and context, then validates the result before replying.
// Inside createTicketServer above: implementation and secret are already defined.
const handleLookup = createToolHandler(implementation, {
auth: { scheme: "bearer", secret, integration: "tickets" },
maxBodyBytes: 16_384,
});
The tool definition supplies its method, input/output schemas and execution.timeoutMs. options.auth supplies the receiver’s integration name and shared secret. maxBodyBytes defaults to 65,536; this example lowers it to 16,384.
POST arguments arrive as { args, call }; GET arguments arrive in the query and have no run/conversation identity. In context, runId identifies the runs API resource and conversationId identifies the conversation. effectId comes from Idempotency-Key. The adapter requires that header for effect_key tools but does not store results or deduplicate work. A write operation needs a durable receipt keyed by integration and effect ID.
Bad authentication returns 401; invalid input returns 400. A deadline or invalid result after execution returns an uncertain 500; reconcile any business operation before retrying. See tool execution payloads.
Related types and helpers: ToolDefinition · ToolImplementation · HandlerOptions · WebHandler.
Complete TypeScript declaration
export declare function createToolHandler<T extends ToolDefinition>(implementation: ToolImplementation<T>, options: HandlerOptions): WebHandler;
nodeHandler
Connect an SDK HTTP handler to Node’s createServer or Express. It translates Node requests and responses to the Web Request/Response objects used by the SDK.
const server = createServer(nodeHandler(handleLookup));
server.listen(8787, "127.0.0.1");
Use this with Node’s createServer, as above, or mount it before Express JSON middleware. It preserves original body bytes for signature verification and aborts the request signal when the client disconnects.
Related types and helpers: WebHandler.
Complete TypeScript declaration
/** Express/Node adapter; mount before a JSON body parser so signature verification sees the raw bytes. */
export declare function nodeHandler(handler: WebHandler): (request: IncomingMessage, response: ServerResponse) => void;
Resolve inbound context
createInboundContextHandler
Receive an incoming call’s details and return the initial variables its agent needs. Use this before the conversation to supply authorized application context or decline the call.
// contextSecret comes from your application’s secret manager.
const handleContext = createInboundContextHandler(async (input, signal) => {
signal.throwIfAborted();
// Caller and destination are in input.call_inbound.
return { dynamic_variables: { service_channel: "phone" } };
}, {
auth: { scheme: "bearer", secret: contextSecret, integration: "tickets" },
variables: ["service_channel"], timeoutMs: 3000,
});
Your resolver receives (input, signal), with the full { event: "call_inbound", call_inbound: { agent_id, agent_version, from_number, to_number } } envelope. Return { dynamic_variables } using only strings named in variables, or { reject: true }. The adapter wraps this as { call_inbound: result }.
variables must be nonempty and unique. timeoutMs defaults to 3,000; the published action permits at most 10,000. maxBodyBytes defaults to 65,536. Configure the same output names in the integration’s contextOutputs. Invalid variables return 400. See initial inbound context for complete request and response JSON.
Related types and helpers: InboundContextInput · InboundContextOutput · HandlerOptions · WebHandler.
Complete TypeScript declaration
export declare function createInboundContextHandler(resolve: (input: InboundContextInput, signal: AbortSignal) => Promise<InboundContextOutput> | InboundContextOutput, options: HandlerOptions & {
variables: string[];
timeoutMs?: number;
}): WebHandler;
Receive events and signed requests
createEventHandler
Receive signed notifications such as run.ended or recording.updated. Your consume callback saves the event; the handler replies only after that callback resolves.
// eventStore is your application’s durable storage interface.
const handleEvents = createEventHandler({
secret: eventSecret,
maxBodyBytes: 262_144,
consume: async event => {
await eventStore.persistOnce(event.event_id, event);
},
});
Provide a signing secret of at least 32 characters and an async consume callback. The handler verifies the original body’s X-Talos-Signature, validates the event and awaits consume before returning 200 with { received: true }. It does not deduplicate events for you. In the example, eventStore.persistOnce represents your durable event store: it must save or recognize event.event_id before resolving.
Set maxBodyBytes: 262144 to accept the sender’s full 256 KiB event range; the adapter defaults to 65,536. Signature tolerance is five minutes. The sender allows 20 seconds, so persist the event and any job intent promptly. The adapter imposes no separate processing deadline. See lifecycle event payloads and signing.
Related types and helpers: components · SemanticEvent · WebHandler.
Complete TypeScript declaration
export declare function createEventHandler(options: {
secret: string;
consume: (event: components["schemas"]["SemanticEvent"]) => Promise<void>;
maxBodyBytes?: number;
}): WebHandler;
verifyWebhook
Verify a lifecycle notification’s signature and return its validated event. Use this directly when your framework handles the HTTP response itself.
const event = verifyWebhook(rawBody, signatureHeader, eventSecret);
Use this when your framework handles routing and raw-body reads itself. It returns the validated SemanticEvent or throws. Verify before parsing or modifying the bytes. The signature uses Unix seconds and HMAC-SHA256 over timestamp + "." + rawBody; tolerance defaults to 300 seconds. options.now is in seconds and toleranceSeconds changes the window.
Related types and helpers: components · SemanticEvent.
Complete TypeScript declaration
export declare function verifyWebhook(raw: Uint8Array, signature: string, secret: string, options?: {
now?: number;
toleranceSeconds?: number;
}): components["schemas"]["SemanticEvent"];
verifyRetellSignature
Verify a Retell-compatible tool signature against the original body bytes. Use this for that tool authentication scheme; lifecycle events use verifyWebhook.
verifyRetellSignature(rawBody, signatureHeader, toolSecret);
This verifies the separate tool-signature protocol and returns nothing on success; it does not validate the JSON or execute a tool. The signature uses Unix milliseconds and HMAC-SHA256 over rawBody + timestamp. options.now and toleranceMs are in milliseconds; tolerance defaults to 300,000. Invalid or stale signatures throw AuthenticationError.
Complete TypeScript declaration
export declare function verifyRetellSignature(raw: Uint8Array, signature: string, secret: string, options?: {
now?: number;
toleranceMs?: number;
}): void;
readBounded
Read a request body into bytes while enforcing a size limit. Use the bytes for signature verification before parsing JSON.
const rawBody = await readBounded(request, 262_144);
Read original bytes before signature verification. limit defaults to 65,536 and applies to both declared content length and streamed bytes. Exceeding it throws DefinitionError. This function does not authenticate or parse the body.
Complete TypeScript declaration
export declare function readBounded(request: Request, limit?: number): Promise<Uint8Array>;
Serve private tokenization
createTokenizationHandler
Receive private keypad input at your vault endpoint and return a UUID token. Use this dedicated adapter so the response exposes only the configured token field.
const handleCapture = createTokenizationHandler(vaultImplementation, {
auth: vaultAuth, tokenIdPath: "id",
});
Use a dedicated tool with purpose: "tokenization", POST execution and effect_key idempotency. tokenIdPath must match the capture policy, for example id or vault.id. The response contains only the UUID token at that path. The request cap is 16,384 bytes; a lower maxBodyBytes is allowed. The tool supplies the deadline.
Persist vault intent and outcome by integration and effect ID without retaining private digits. Throw TokenizationDeclined only when the vault definitely created no token (422). Other failures after invocation are uncertain (500). See private workflow setup and vault HTTP payloads.
Related types and helpers: ToolDefinition · ToolImplementation · HandlerOptions · WebHandler.
Complete TypeScript declaration
/** Dedicated private endpoint. Never log/retain input; deduplicate durably by context.effectId.
* Only the configured UUID token is returned. JS strings cannot be reliably zeroed. */
export declare function createTokenizationHandler<T extends ToolDefinition>(implementation: ToolImplementation<T>, options: HandlerOptions & {
tokenIdPath: string;
}): WebHandler;
Handle endpoint failures
AuthenticationError
Endpoint authentication failed before your callback was invoked. The HTTP adapter returns unauthenticated with status 401.
Complete TypeScript declaration
export declare class AuthenticationError extends Error {
constructor();
}
ToolOutcomeUncertain
The customer callback may have executed but its result was not confirmed. The adapter returns tool_outcome_uncertain with status 500; reconcile using the effect identity.
Complete TypeScript declaration
export declare class ToolOutcomeUncertain extends Error {
constructor();
}
TokenizationDeclined
A private capture provider definitively declined without creating a token. The private adapter returns tokenization_declined with status 422.
Complete TypeScript declaration
/** Throw only when the provider definitively rejected capture without creating a token. */
export declare class TokenizationDeclined extends Error {
constructor();
}
Types and declarations
EndpointAuth
Choose bearer, named header or supported Retell signature authentication. Each configuration identifies the integration; signatures also support a time tolerance.
All three variants require secret and integration. Bearer uses Authorization: Bearer …; header auth also requires headerName; Retell-compatible signing accepts optional toleranceMs (default 300,000). These are tool/context/vault credentials. Lifecycle events use the separate signing-secret option.
Complete TypeScript declaration
export type EndpointAuth = {
scheme: "bearer";
secret: string;
integration: string;
} | {
scheme: "header";
headerName: string;
secret: string;
integration: string;
} | {
scheme: "retell_signature";
secret: string;
integration: string;
toleranceMs?: number;
};
HandlerOptions
Required endpoint auth plus optional maxBodyBytes. Ordinary adapters default to the raw reader’s 65,536-byte limit; tokenization applies its tighter cap.
auth is required. Choose bearer, a named header or Retell-compatible tool signing, with a server-side secret of at least 16 characters and a receiver integration name. maxBodyBytes defaults to 65,536 for tools and context; tokenization additionally caps it at 16,384. Event handlers have their own options described above.
Related types and helpers: EndpointAuth.
Complete TypeScript declaration
export interface HandlerOptions {
auth: EndpointAuth;
maxBodyBytes?: number;
}
InboundContextInput
The native inbound call envelope includes agent identity/version and from/to phone numbers.
The callback receives this full envelope; the phone details are nested under call_inbound. agent_version identifies the selected publication. See complete inbound JSON.
Complete TypeScript declaration
export interface InboundContextInput {
event: "call_inbound";
call_inbound: {
agent_id: string;
agent_version: number;
from_number: string;
to_number: string;
};
}
InboundContextOutput
Your inbound resolver may reject the call or supply declared dynamic variables as strings.
Return declared string dynamic_variables, or reject: true. The handler adds the outer call_inbound wrapper. See complete responses.
Complete TypeScript declaration
export interface InboundContextOutput {
reject?: boolean;
dynamic_variables?: Record<string, string>;
}
ToolContext
Callback context: authenticated integration alias, optional run/conversation/agent identities, metadata, effectId and cancellation signal. runId identifies the run API resource; conversationId is the native call identity. An integration credential does not establish the caller’s business identity.
integration comes from your receiver configuration. runId, conversationId and agentId come from the request’s optional/required call fields; GET tools have none of these. metadata defaults to an empty object. effectId is the request’s idempotency header. Pass signal to cancellable application work. The shared credential authenticates the integration; your application still authorizes the account or operation.
Complete TypeScript declaration
export interface ToolContext {
integration: string;
runId?: string;
conversationId?: string;
agentId?: string;
metadata: Record<string, unknown>;
effectId?: string;
signal: AbortSignal;
}
ToolImplementation
A tool definition and its input/output-typed callback. The callback may return synchronously or asynchronously.
Related types and helpers: ToolDefinition · ToolInput · ToolContext · ToolOutput.
Complete TypeScript declaration
export interface ToolImplementation<T extends ToolDefinition = ToolDefinition> {
tool: T;
handler: (input: ToolInput<T>, context: ToolContext) => Promise<ToolOutput<T>> | ToolOutput<T>;
}
WebHandler
A portable asynchronous Web Request to Web Response function. Pass it directly to a compatible server or wrap it with nodeHandler.
Complete TypeScript declaration
export type WebHandler = (request: Request) => Promise<Response>;
Export index
- Serve a tool: implementTool · createToolHandler · nodeHandler
- Resolve inbound context: createInboundContextHandler
- Receive events and signed requests: createEventHandler · verifyWebhook · verifyRetellSignature · readBounded
- Serve private tokenization: createTokenizationHandler
- Handle endpoint failures: AuthenticationError · ToolOutcomeUncertain · TokenizationDeclined
- Types: EndpointAuth · HandlerOptions · InboundContextInput · InboundContextOutput · ToolContext · ToolImplementation · WebHandler