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 an envelope with the LLM-generated arguments nested under args. Read them from there, not from the body root — see what gets sent over the wire. 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 { args } = (await c.req.json()) as { args?: { name?: string } };
const name = args?.name;
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 { args } = JSON.parse(raw) as { args?: { 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.
Per-call secrets and URLs (toolSecrets)
authHeaders covers credentials that are the same on every call. When a credential is minted per call — a short-lived access token scoped to the person being called, or a tenant-specific API base URL — pass it on the session or dial request as toolSecrets:
{
"agentId": "agent_abc123",
"to": "+12015551234",
"toolSecrets": {
"base_url": "https://acme.example.com",
"access_token": "eyJhbGciOi..."
}
}Then reference the names from the tool definition:
-
Webhook tools —
{{name}}inurland inheadersvalues:{ "url": "{{base_url}}/api/interviews/answer", "headers": { "authorization": "Bearer {{access_token}}" } } -
Custom code tools —
session.secrets.<name>(andsession.variables.<name>for the promptvariables):const res = await fetch(`${session.secrets.base_url}/api/interviews/answer`, { method: 'POST', headers: { authorization: `Bearer ${session.secrets.access_token}` }, body: JSON.stringify(args), }); return await res.json();
What makes toolSecrets different from variables:
- Never visible to the model.
variablesrender into the system prompt;toolSecretsare never compiled into any prompt, never appear in the transcript or call metadata, and are not returned by any read endpoint. They are stored encrypted and released only to tool execution. - Redacted on the way back. A value echoed verbatim in a tool result is replaced with
[redacted:name]before the model sees it (best-effort — derived values such as a base64 encoding survive). - Unresolved names fail the call. A
{{name}}with no matching entry returnswebhook_template_invalidinstead of sending the braces. Promptvariablesare also available to{{name}}in webhook tools; atoolSecretsentry wins on a name collision. - A
{{base_url}}host may only carry per-call values. A template may choose the path freely, but when it chooses the host, nothing constant configured on the tool may ride along: the tool must have noauthHeaders, nobodytemplate, and everyheadersvalue must be exactly one per-call{{name}}, optionally behindBearer,Basic,Token,ApiKeyorDigest(Bearer {{access_token}}) — no other literal text (org-key-{{nonce}}is refused). A constant header, anauthHeadersentry or a body template is somewhere a long-lived credential can hide, and none of them may follow a caller-chosen host — hard-code the origin for those tools. - Limits. Names are identifiers (
[A-Za-z_][A-Za-z0-9_]*), up to 32 entries, 6 characters to 4 KB per value, 16 KB total (the floor exists because shorter values cannot be redacted from tool output — put short non-secret values invariables). Values are inserted verbatim into URLs and headers; the assembled URL must behttp(s)and still passes the SSRF guard.
HTTP method
method, body, responseMode and asyncAck take effect on a live call from
v0.0.505. Earlier releases accepted them, stored them, and read them back,
but the tool snapshot handed to a dispatched session dropped all four — a tool
configured PUT with a body template delivered a POST with the default
envelope, and an async tool ran sync. Nothing needs re-saving: the fix is in
the snapshot, not in your stored configuration.
Webhook tools POST by default. Set method to PUT, PATCH, DELETE or GET when the endpoint you are calling expects a different verb:
{
"source": {
"kind": "webhook",
"url": "https://your-endpoint.example.com/interviews/42",
"secret": "<32-char hex you supply>",
"method": "PATCH"
}
}GET and DELETE send no request body — not a body template, and not the default envelope either. Pairing either with body is rejected at save time with 422 WEBHOOK_TEMPLATE_INVALID rather than dropping the body on every call. The Standard Webhooks signature is still sent (computed over an empty payload), so webhook-id and webhook-timestamp stay authenticated and replay protection still works.
Because a body-less request carries no args, a GET or DELETE tool cannot convey the model's arguments today — {{name}} in the url resolves session values only, not arguments. Use POST/PUT/PATCH when the endpoint needs the arguments.
Shaping the request body
By default Speko sends one fixed envelope, in which only args varies:
{
"tool": "ATS_Interview_Success",
"args": { "conversation_id": "85e2de9c-a8cc-41e3-a5f4-7f25b0bab7fa" },
"idempotency_key": "85e2de9c-…:graph_9084b1d0140e",
"session_id": "85e2de9c-…",
"tool_call_id": "graph_9084b1d0140e"
}That works when you own the endpoint and can unwrap it. When the endpoint is a third-party API with its own payload contract, set body to a JSON template. Every string leaf interpolates {{name}}, and the template replaces the envelope entirely:
{
"source": {
"kind": "webhook",
"url": "https://api.vendor.example.com/v2/events",
"secret": "<32-char hex you supply>",
"method": "POST",
"body": {
"event_type": "interview_completed",
"tenant": "{{tenant}}",
"candidate": { "name": "{{args.name}}", "score": "{{args.score}}" },
"trace": { "call": "{{session_id}}", "dedupe": "{{idempotency_key}}" }
}
}
}Omit body and the envelope above is sent exactly as it always was.
Here is a template and the request one endpoint actually received from it:
{
"event_type": "candidate_withdrew",
"conversation": "{{args.conversation_id}}",
"speko_session": "{{session_id}}",
"idem": "{{idempotency_key}}",
"tenant": { "host": "{{api_hostname}}", "token": "{{api_token}}" },
"raw_args": "{{args}}"
}{
"event_type": "candidate_withdrew",
"conversation": "priya_raman_delivery_driver",
"speko_session": "b782ba43-…",
"idem": "b782ba43-…:toolu_01ShrY3g…",
"tenant": { "host": "your-sink.example.com", "token": "precall-tagged-token" },
"raw_args": { "conversation_id": "priya_raman_delivery_driver" }
}Read raw_args closely: it arrived as an object, not as a JSON string. "{{args}}" is a whole-value placeholder, so the quotes belong to the template and never reach the payload — a receiver that calls JSON.parse on that field will throw. tenant.host shows that a session value may name a host inside the body; only the url's origin has to be constant.
Reserved names
Inside body you can reference every session variables and toolSecrets entry, plus these:
| Name | Substitutes |
|---|---|
{{tool_name}} | The tool's name |
{{session_id}} | The session id |
{{tool_call_id}} | The model's tool-call id |
{{idempotency_key}} | <session_id>:<tool_call_id> |
{{args.<name>}} | One argument, with its JSON type preserved |
{{args}} | The whole arguments object — as a whole value only |
A reserved name wins over a session value of the same name, so a caller cannot change what {{session_id}} means by passing a variable called session_id.
Rules
Two of these refuse the whole configuration at save time rather than a single field, so read them first:
- A templated origin forbids a body template. When a session value chooses the host (
{{base_url}}/events), the tool may not carry abodytemplate at all — for the same reason it may not carryauthHeadersor a constant header. A template can hold a credential ({"api_key": "sk_live_…"}is how many vendors authenticate) and we cannot tell one from a discriminator by looking, so nothing configured on the tool follows a caller-chosen host. Hard-code the origin if you need to shape the body; a templated path under a constant origin is fine. GETandDELETEforbid a body template. Those verbs send no request body at all, so pairing either withbodyis refused at save time instead of dropping the template on every call — see HTTP method.
The rest apply per field:
- Types are preserved in a whole value.
"score": "{{args.score}}"sends the number42, not the string"42", when the argument is a number. Placeholders inside a longer string always produce text:"note": "scored {{args.score}}"sends"scored 42". {{args}}must be the whole value."payload": "{{args}}"substitutes the object."payload": "args: {{args}}"is rejected at save time — silently JSON-stringifying an object into a text slot is a footgun, so name a single{{args.<field>}}instead.- Only values interpolate, never keys. A key containing
{{is rejected rather than sent with the braces intact. - Unresolved names fail the call. Same as the url and headers:
webhook_template_invalid, before any request goes out. - Limits. 8 KB serialized, 8 levels of nesting, and 64 KB after substitution.
__proto__,constructorandprototypeare rejected as keys.
Values are never spliced into serialized JSON — Speko builds a value tree and serializes it once, so a substituted value containing quotes, braces or newlines is escaped and cannot alter the structure of the payload.
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.
Tool calls in test runs
Speko drives your agent through simulated calls to check it. Reliability checks run automatically after a deploy, a publish, or an agent create. You can also run a Test Set eval or a test call yourself. By default, reliability checks never call a side-effecting tool. The model still makes the call and tool_called assertions still see it. The tool just returns a stub result instead of reaching your endpoint. Evals and test calls you start yourself call the tool for real. Real calls always do.
Set simulation on the tool to change that:
simulation | Reliability checks | Evals and test calls you start |
|---|---|---|
| omitted (default) | mocked | live |
{ "mode": "live" } | live | live |
{ "mode": "mock", "response"?: <JSON> } | mocked | mocked |
A mocked call returns response as the tool result. A string is sent as-is and anything else as JSON, up to 8,192 bytes (UTF-8). Workflow output bindings read it, so give a tool the fields its next node needs:
curl -X PATCH "https://api.speko.dev/v1/agents/$SPEKO_AGENT_ID/tools/$SPEKO_TOOL_ID" \
-H "Authorization: Bearer $SPEKO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "simulation": { "mode": "mock", "response": { "question": "What days can you start?" } } }'Without response, the model gets a generic "not executed" result. Send "simulation": null to return a tool to the default. In the dashboard, the setting is In test runs on the tool page. It is copied when you duplicate an agent. The knowledge-base search and end-call tools always run, since they change nothing outside the call.
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: sess_2KQfP3QH8Gv7B:call_9084b1d0140e
webhook-timestamp: 1735603214
webhook-signature: v1,F7ZxQk8j3p6m2N9...
{
"tool": "lookup_pet",
"args": { "name": "Luna" },
"idempotency_key": "sess_2KQfP3QH8Gv7B:call_9084b1d0140e",
"session_id": "sess_2KQfP3QH8Gv7B",
"tool_call_id": "call_9084b1d0140e"
}The model's arguments are under args; the envelope around them identifies the call so you can deduplicate retries on idempotency_key. Set body to send a different shape instead — see Shaping the request body.
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.