Speko Docs
Build

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 + JSON

Three pieces meet:

  1. Your endpoint — a public HTTPS URL that receives the tool call and returns JSON.
  2. 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.
  3. 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:

HeaderMeaning
webhook-idIdempotency key for this delivery. Skip duplicates.
webhook-timestampUnix seconds when Speko signed the body. Reject anything older than ~5 minutes to prevent replay.
webhook-signaturev1,<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 the webhook-* headers, so you can't set those.
  • The list replaces the stored set. On update, omit a header's value to keep the secret already stored under that name; drop a header from the list to remove it; add one with a value to set or rotate it.
  • Plaintext headers vs. secrets. authHeaders values are encrypted. If you only need a non-sensitive static header, the separate headers map 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}} in url and in headers values:

    {
      "url": "{{base_url}}/api/interviews/answer",
      "headers": { "authorization": "Bearer {{access_token}}" }
    }
  • Custom code tools — session.secrets.<name> (and session.variables.<name> for the prompt variables):

    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. variables render into the system prompt; toolSecrets are 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 returns webhook_template_invalid instead of sending the braces. Prompt variables are also available to {{name}} in webhook tools; a toolSecrets entry 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 no authHeaders, no body template, and every headers value must be exactly one per-call {{name}}, optionally behind Bearer, Basic, Token, ApiKey or Digest (Bearer {{access_token}}) — no other literal text (org-key-{{nonce}} is refused). A constant header, an authHeaders entry 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 in variables). Values are inserted verbatim into URLs and headers; the assembled URL must be http(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:

body template
{
  "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}}"
}
delivered body
{
  "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:

NameSubstitutes
{{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 a body template at all — for the same reason it may not carry authHeaders or 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.
  • GET and DELETE forbid a body template. Those verbs send no request body at all, so pairing either with body is 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 number 42, 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__, constructor and prototype are 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:

simulationReliability checksEvals and test calls you start
omitted (default)mockedlive
{ "mode": "live" }livelive
{ "mode": "mock", "response"?: <JSON> }mockedmocked

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 your VoiceConversation (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 include search_knowledge_base, transfer_call, and end_call (always enabled — the agent can hang up once the conversation is done; the legacy endCall create field is accepted but ignored). transfer_call supports 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.

On this page