Tool calling
Give a Speko voice agent the ability to invoke webhook tools mid-call. Register once in the dashboard, fire from any voice session.
Tool calling lets the LLM driving your voice session take action: query a database, schedule a visit, transfer to a human. The model decides when to invoke a tool from your prompt; Speko POSTs a Standard Webhooks-signed request to your endpoint, folds the JSON response back into the model's next turn, and the agent verbalizes the result.
This guide walks through registering a tool, hooking it into a Speko voice session, and confirming it fires.
Architecture
Voice session Speko proxy Your endpoint
───────────── ─────────── ─────────────
LLM emits tool call ─→ /v1/complete loop ─→ POST /your/webhook
(signed body)
LLM verbalizes result ←─ response folded back ←─ 200 + JSONThree pieces meet:
- Your endpoint — a public HTTPS URL that receives the tool call and returns JSON.
- The Speko dashboard — where you register the tool (name, description, JSON Schema parameters, your endpoint URL). Speko stores an HMAC signing secret you save once.
- A Speko voice session — the worker fetches your registered tools at session start, exposes them to the LLM, and routes invocations through the executor.
1. Build your endpoint
The executor POSTs the LLM-generated arguments as JSON. Whatever you return becomes the model's next observation, so keep responses small and specific.
import { Hono } from 'hono';
const PETS: Record<string, unknown> = {
luna: { name: 'Luna', species: 'corgi', age: 3, status: 'available' },
max: { name: 'Max', species: 'tabby cat', age: 5, status: 'available' },
};
const app = new Hono();
app.post('/lookup', async (c) => {
const { name } = (await c.req.json()) as { name?: string };
const pet =
PETS[
String(name ?? '')
.toLowerCase()
.trim()
];
if (!pet) return c.json({ error: 'Pet not found' }, 404);
return c.json(pet);
});
export default { port: Number(process.env.PORT ?? 8080), fetch: app.fetch };Deploy this anywhere with a public HTTPS URL — Cloud Run, Fly.io, Render, Vercel functions.
Verifying the signature
Production endpoints MUST verify the Standard Webhooks signature on every request. Speko sends three headers:
| Header | Meaning |
|---|---|
webhook-id | Idempotency key for this delivery. Skip duplicates. |
webhook-timestamp | Unix seconds when Speko signed the body. Reject anything older than ~5 minutes to prevent replay. |
webhook-signature | v1,<base64(HMAC-SHA256("{webhook-id}.{webhook-timestamp}.{body}", secret))>. Multiple comma-separated signatures may appear during rotation; accept if any one matches. |
Use the standardwebhooks package — constant-time comparison and clock-skew tolerance are tricky to roll yourself.
Secrets issued before 2026-07-28 need { format: 'raw' }. Those secrets were
minted with the base64url alphabet, and the package's strict base64 decoder throws
Base64Coder: incorrect characters for decoding on the - and _ they contain —
at construction, before any request is examined. If your signing secret contains
- or _, either rotate it from API keys → Organization credentials to get a
current-format one, or construct the verifier as
new Webhook(secret, { format: 'raw' }). Secrets issued after that date, and any
custom signingSecret you supply that is not whsec_ + standard base64, behave
the same way. Speko signs every request under both key derivations, so whichever
form you use will match.
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.LOOKUP_PET_SIGNING_SECRET!);
app.post('/lookup', async (c) => {
const raw = await c.req.text();
try {
wh.verify(raw, Object.fromEntries(c.req.raw.headers));
} catch {
return c.text('signature mismatch', 401);
}
const { name } = JSON.parse(raw) as { name?: string };
// …
});2. Register the tool
Via the dashboard
Open Tools in the dashboard, click Add tool, fill in:
-
Name —
snake_case, ≤ 64 chars (e.g.lookup_pet). The model sees this; pick something it'll match against the user's intent. -
Description — tell the model when to call this. Be explicit ("ALWAYS call this when the user asks about a specific pet by name").
-
Parameters — a JSON Schema. Strict typing works; vague typing leads to the model passing garbage args.
-
Webhook URL — your public HTTPS endpoint from step 1. Speko rejects HTTP, private/loopback hosts, and known cloud-metadata IPs at registration time.
-
Signing secret — leave it blank and Speko generates one (shown once on create — copy it into your secrets manager), or supply your own to pre-configure verification on your side. You can rotate it any time from the tool's edit page by entering a new value; the existing secret is never displayed again.
-
Auth headers — credentials Speko must send to your endpoint (see Authenticating to your endpoint).
Via the API
curl -X POST "https://api.speko.dev/v1/agents/$SPEKO_AGENT_ID/tools" \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"name": "lookup_pet",
"description": "Look up a pet by name. ALWAYS call this when the user asks about a specific pet.",
"parameters": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "description": "First name of the pet." }
}
},
"source": {
"kind": "webhook",
"url": "https://your-endpoint.example.com/lookup",
"secret": "<32-char hex you supply — Speko stores it encrypted>"
}
}'The secret you POST is what Speko uses to sign webhook deliveries. The server stores an encrypted copy and never echoes it back, so keep your local copy. Omit secret on a PATCH to keep the existing one; supply a new value to rotate it.
Timeouts
The optional timeoutMs on a webhook source accepts 100-4000 and defaults to 4000. The executor clamps every webhook read to 4000ms regardless of the value sent — a live call cannot wait longer on a tool. builtin tools run on the same 4000ms budget; integration actions get 8000ms. If your endpoint cannot answer inside the budget, return a small "still working" payload quickly and let the model call the tool again, or use an async webhook (below) when the caller does not need the result.
Authenticating to your endpoint
The signing secret lets your endpoint verify that a request genuinely came from Speko. To go the other way — so Speko can authenticate to an endpoint that requires its own credential (a Bearer token, an X-Api-Key, …) — attach auth headers. Each value is encrypted at rest in Speko's secrets store and injected on every delivery; it never lives on the tool definition and the API never returns it.
In the dashboard, open the webhook tool's auth section and add a header name and its secret value. Via the API, pass authHeaders:
curl -X PATCH "https://api.speko.dev/v1/agents/$SPEKO_AGENT_ID/tools/$TOOL_ID" \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"source": {
"kind": "webhook",
"url": "https://your-endpoint.example.com/lookup",
"authHeaders": [
{ "name": "Authorization", "value": "Bearer your-endpoint-token" }
]
}
}'Speko resolves each authHeaders value at call time and merges it into the outbound request alongside the Standard Webhooks signature. A few rules:
- Reserved names are rejected. Speko's signer always controls
content-type,accept,user-agent, and thewebhook-*headers, so you can't set those. - The list replaces the stored set. On update, omit a header's
valueto keep the secret already stored under that name; drop a header from the list to remove it; add one with avalueto set or rotate it. - Plaintext headers vs. secrets.
authHeadersvalues are encrypted. If you only need a non-sensitive static header, the separateheadersmap carries plaintext as-is.
Workspace lifecycle endpoints accept the same authHeaders and can use either the organization signing secret or one custom signing secret per endpoint. Configure them in Settings → Webhooks or with speko.webhooks; agent-level lifecycle webhook fields are deprecated.
Async webhook tools
Webhook tools default to responseMode: "sync": Speko waits for your endpoint response and feeds the JSON body into the next model turn. For work that should not block the conversation, set responseMode: "async" and provide an asyncAck:
{
"source": {
"kind": "webhook",
"url": "https://your-endpoint.example.com/create-ticket",
"secret": "<32-char hex you supply>",
"responseMode": "async",
"asyncAck": "I started that request and will continue helping while it runs."
}
}In async mode, Speko dispatches the signed webhook in the background and immediately returns the acknowledgement text to the model. Use this for ticket creation, CRM updates, notifications, and other side effects where the caller does not need the result before the next assistant turn.
Use the actual agent id
Tools are scoped to one persisted agent. Use the agent id returned by POST /v1/agents or shown on the dashboard agent page. The unique key is (organization, agentId, toolName), so two agents can use the same tool name without sharing webhook config.
3. Wire the worker
If you run a LiveKit Agents worker, the adapter loads your registered tools at session start and merges them with anything the framework provides at runtime. Use createSpekoComponents with the registered-tools options:
import { defineAgent, voice } from '@livekit/agents';
import * as silero from '@livekit/agents-plugin-silero';
import { Speko } from '@spekoai/sdk';
import { createSpekoComponents } from '@spekoai/adapter-livekit';
const speko = new Speko({
apiKey: process.env.SPEKO_API_KEY!,
baseUrl: process.env.SPEKO_BASE_URL,
});
export default defineAgent({
prewarm: async (proc) => {
proc.userData.vad = await silero.VAD.load();
},
entry: async (ctx) => {
const vad = ctx.proc.userData.vad as silero.VAD;
const { stt, llm, tts } = createSpekoComponents({
speko,
vad,
intent: { language: 'en-US', optimizeFor: 'latency' },
// Enable the registered-tools loader. The adapter calls
// speko.agents.tools.listChatTools(agentId) once per session — reusing
// the Speko client above for auth and base URL — and merges the result
// with whatever LiveKit's ToolContext provides. Registered tools win on
// name collision.
agentId: process.env.SPEKO_AGENT_ID!,
onRegisteredToolsError: (err) =>
console.error('SpekoWorker: tools fetch failed', err),
});
const session = new voice.AgentSession({ vad, stt, llm, tts });
await session.start({
agent: new voice.Agent({
instructions:
'You are a brief, friendly assistant. ' +
'When the user asks about a specific pet by name, ' +
'IMMEDIATELY call lookup_pet — never make up information.',
}),
room: ctx.room,
});
await ctx.connect();
},
});Without agentId, the loader stays disabled and the agent only sees runtime tools — useful when you want to opt in selectively.
Outside a LiveKit worker, load the same tools yourself with speko.agents.tools.listChatTools(agentId) and pass them to speko.complete({ tools }). It returns every source kind (inline, webhook, builtin, integration) already in the ChatTool[] shape /v1/complete accepts.
4. Run a call
The simplest client is a browser using @spekoai/client:
import { VoiceConversation } from '@spekoai/client';
const res = await fetch('/api/session', { method: 'POST' });
const { transportToken, transportUrl } = await res.json();
const conv = await VoiceConversation.create({
transportToken,
transportUrl,
onModeChange: (mode) => console.log(mode), // 'listening' | 'speaking'
});Your /api/session server route mints browser-safe transport credentials via Speko:
const r = await fetch(process.env.SPEKO_BASE_URL + '/v1/sessions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + process.env.SPEKO_API_KEY,
},
body: JSON.stringify({
mode: 'cascade',
agentId: process.env.SPEKO_AGENT_ID!,
ttlSeconds: 900,
}),
});
const { transportToken, transportUrl } = await r.json();What gets sent over the wire
When the model invokes a registered tool, Speko's executor signs the request with your secret and POSTs to your URL:
POST https://your-endpoint.example.com/lookup
content-type: application/json
webhook-id: msg_2KQfP3QH8Gv7B
webhook-timestamp: 1735603214
webhook-signature: v1,F7ZxQk8j3p6m2N9...
{
"name": "Luna"
}Your response body is what the model sees as the tool result. Errors propagate too — if your endpoint returns 4xx/5xx, the executor surfaces the error so the agent can apologize or retry instead of silently swallowing it.
Debugging
Common failure modes:
- Tool never invoked. The model didn't decide to call it. Tighten the description (be explicit about when to call), or set
toolChoice: "required"in your call options to force one. - Webhook never lands. Check the worker logs for the executor span. Common: 403 from your endpoint (signature mismatch), 5xx (your code threw), or timeout (your endpoint is too slow — the executor hard-caps webhook reads at 4000ms; see Timeouts above).
- Agent says "couldn't find" instead of the real result. Your endpoint returned 4xx. Either the query genuinely missed, or the model passed empty/wrong args. During development, have your endpoint echo back the body it received so you can spot the latter.
- Two voices overlap in the room. A second agent dispatched into the same room without ending the previous session. Always call
endSession()on yourVoiceConversation(or disconnect the participant) before opening a new conversation.
Beyond webhooks
Webhook tools are the most common, but a registered tool's source can also be:
builtin— Speko-managed helpers you opt into without running your own endpoint. Current built-ins includesearch_knowledge_base,transfer_call, andend_call(always enabled — the agent can hang up once the conversation is done; the legacyendCallcreate field is accepted but ignored).transfer_callsupports warm or blind transfers from the active phone session when configured with destinations.integration— an action from an org-installed Speko app (Google Calendar, Slack, …), resolved and executed server-side.inline— your own worker runs the tool; Speko just ships the schema to the model and returns the call to you.
All four kinds come back from speko.agents.tools.listChatTools(agentId) ready to hand to speko.complete.
What's next
- Streaming tool results for long-running queries.
Track progress on the public roadmap.