voice-tools.json - Custom Tools Reference
One file lets you turn built-in voice tools on or off, and wire up your own custom HTTPS endpoints - your CRM, your booking system, your internal API. The voice agent calls them directly, in well under a second, no round-trip through OpenClaw.
The 30-second mental model
Your voice agent has two ways to take action during a call:
- Through OpenClaw via
openclaw_query- anything your chat agent can do (email, calendar, custom skills). Powerful, but takes 2–6 seconds round-trip. - Direct fast path - the gateway calls an HTTPS endpoint itself and reads back the result. Sub-second. This is what
voice-tools.jsonopens up for you.
On a phone call, sub-second matters. The difference between a 500 ms tool and a 4-second tool is the difference between a snappy agent and an awkward silence the caller can’t stand.
voice-tools.json lives in your voice agent’s workspace at:
~/.openclaw/workspace/data/voice-tools.jsonThe file is optional. If it doesn’t exist, all built-in tools are enabled and there are no custom tools - same behavior as before this feature shipped.
Five-minute path: a working custom tool
Let’s wire up a free public weather API as a custom voice tool. Save this file at ~/.openclaw/workspace/data/voice-tools.json:
{
"version": 1,
"customTools": [
{
"name": "lookup_weather",
"description": "Get the current weather for a city. Returns temperature in Celsius and a short condition phrase.",
"endpoint": {
"method": "GET",
"url": "https://wttr.in/_placeholder"
},
"urlTemplate": "https://wttr.in/{{city}}?format=j1",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g. 'Toronto' or 'New York'"
}
},
"required": ["city"]
},
"auth": { "type": "none" },
"responseTemplate": "It is {{current_condition.0.temp_C}} degrees and {{current_condition.0.weatherDesc.0.value}} in {{nearest_area.0.areaName.0.value}}."
}
]
}That’s it. No restart needed - the gateway re-readsvoice-tools.json at the start of every call. Call your agent and ask “What’s the weather in Toronto?”. The voice AI will call your new tool and read back the answer in well under a second.
Notice the URL pattern: endpoint.url is the literal (security-checked at startup), urlTemplate is the templated form actually used at request time. The placeholder {{city}}is replaced with whatever the AI passes in - URL-encoded for safety.
Toggling built-in tools
The voice agent ships with four built-in tools you can turn off if you don’t want them. Add a builtins block to your config:
{
"version": 1,
"builtins": {
"openclaw_query": true,
"kb_search": true,
"get_current_time": true
},
"customTools": []
}Set any of these to false to disable that built-in.
| Tool | What it does | Disable when |
|---|---|---|
openclaw_query | The bridge to your OpenClaw agent (email, calendar, skills). | Info-only public bot with no actions wanted at all. |
kb_search | Search your knowledge base for facts. | No KB installed, or all answers are already in the playbook. |
get_current_time | Returns the date and time in your timezone. | Almost never - turn it off only for niche scenarios. |
Two things you cannot toggle: end_call (the bot must always be able to hang up) and save_caller_info (server-side caller capture, not exposed to the model). Those are non-overridable for safety.
Custom tools - the full schema
Each entry in customTools[] declares one HTTPS endpoint the voice AI can invoke during a call. Below is every field, what it does, and what the validator enforces.
Required fields
{
"name": "lookup_customer",
"description": "Look up a customer's account status and billing email by their account number.",
"endpoint": {
"method": "GET",
"url": "https://api.acme.com/v1/customers/__validate__"
},
"parameters": {
"type": "object",
"properties": {
"accountNumber": { "type": "string", "description": "8-digit account number" }
},
"required": ["accountNumber"]
},
"auth": { "type": "bearer", "envVar": "ACME_API_TOKEN" }
}name- lowercase, starts with a letter, up to 41 chars,[a-z0-9_]only. Must not collide with a built-in name. This is the function name the AI sees, and what shows up in journal logs.description- 5 to 500 chars. The model reads this to decide when to call the tool. Be specific: “Look up a customer’s account status by account number”, not “customer lookup tool”.endpoint.method-GET,POST,PUT,PATCH, orDELETE.endpoint.url- the literal HTTPS URL. Must be HTTPS to a public host (private/internal IPs are blocked, see security below). The validator checks this URL at load time; it must already be safe.parameters- JSON Schema for the call args.typemust be"object";propertiesvalues arestring,number, orboolean(optionally with anenum).
Optional fields
auth- see authentication section below. Defaults to{ "type": "none" }when omitted.urlTemplate- same shape asurlbut with{{argName}}placeholders. Used at request time; re-validated post-substitution. Values are URL-encoded.bodyTemplate- JSON-shaped string with{{argName}}placeholders. Sent asapplication/json. Ignored forGET.headers- extra literal headers (the auth header is added automatically). Keys must match/^[\w-]+$/.responseTemplate- Mustache-lite that turns the JSON response into voice-ready text. Supports{{path.to.field}}dotted lookups.timeoutMs- per-tool timeout in ms. Default3000, hard cap5000.
Why two URL fields? Putting the literal in endpoint.url lets the validator security-check the base host at load time, even if the templated URL only resolves at request time. Use any safe HTTPS placeholder for endpoint.url as long as the host matches what your urlTemplate will resolve to.
Templates - URL, body, response
URL templates ({{argName}} → URL-encoded value)
Whatever the AI passes for {{city}} is encodeURIComponent’d into the URL. Bad characters, slashes, query separators - all safely escaped:
"urlTemplate": "https://api.example.com/q?city={{city}}&limit={{limit}}"Body templates ({{argName}} → JSON-encoded value)
For POST / PUT / PATCH requests,bodyTemplate is JSON. Substituted values are JSON-encoded so quotes/escapes stay intact:
"bodyTemplate": "{ \"name\": {{customer_name}}, \"qty\": {{qty}} }"Place {{argName}} where a JSON value is expected, NOT inside a string. The placeholder above produces e.g. { "name": "Sarah", "qty": 3 } - quotes come from the substitution, not the template.
Response templates (dotted lookup → spoken text)
responseTemplate turns a JSON response into voice text. Dotted paths drill into the response object:
Response JSON:
{ "user": { "name": "Maya", "tier": "gold" }, "creditsLeft": 142 }
Template:
"{{user.name}} is a {{user.tier}} member with {{creditsLeft}} credits left."
Spoken to caller:
"Maya is a gold member with 142 credits left."Missing fields render as empty string. The dotted-path lookup refuses __proto__, constructor, and prototype segments to block prototype-pollution-via-template.
No responseTemplate? The raw JSON is handed to the AI, which extracts the relevant info itself. Works fine, but burns more tokens and is slightly slower than a templated voice-ready response.
Authentication
API tokens never live in voice-tools.json. They live in ~/ninja-talk/.env on the server and are referenced by name:
Bearer tokens
"auth": { "type": "bearer", "envVar": "STRIPE_SECRET_KEY" }
// Adds: Authorization: Bearer <value of STRIPE_SECRET_KEY>API keys (custom header)
"auth": {
"type": "api-key",
"envVar": "OPENWEATHER_KEY",
"headerName": "X-API-Key"
}
// Adds: X-API-Key: <value of OPENWEATHER_KEY>Custom header (any value)
"auth": {
"type": "header",
"envVar": "ACME_AUTH_HEADER",
"headerName": "X-Acme-Token"
}No authentication
"auth": { "type": "none" }envVar must be UPPER_SNAKE_CASE so it’s easy to drop into an env file. If the variable is missing at runtime, the agent says “That tool is not configured. The owner needs to add credentials.” and the journal logs which env var was missing.
To add a credential to your voice agent:
# Edit ~/ninja-talk/.env on the server, append:
STRIPE_SECRET_KEY=sk_live_...
# Then restart the voice service so the env reloads:
sudo systemctl --user restart ninja-talkWorked examples
Example 1 - Stripe customer lookup (read)
The caller gives an email; the agent looks up their Stripe customer record and reads back their plan and total spend. Bearer-auth, response template renders one clean sentence:
{
"name": "lookup_stripe_customer",
"description": "Look up a customer's Stripe account by email. Returns name, plan, and lifetime spend.",
"endpoint": {
"method": "GET",
"url": "https://api.stripe.com/v1/customers/__validate__"
},
"urlTemplate": "https://api.stripe.com/v1/customers/search?query=email%3A%22{{email}}%22",
"parameters": {
"type": "object",
"properties": {
"email": { "type": "string", "description": "Customer's email address" }
},
"required": ["email"]
},
"auth": { "type": "bearer", "envVar": "STRIPE_SECRET_KEY" },
"responseTemplate": "Customer {{data.0.name}} on the {{data.0.metadata.plan}} plan, lifetime spend of {{data.0.metadata.total_spent_usd}} dollars."
}Example 2 - Create a CRM ticket (write)
The caller reports an issue; the agent opens a ticket in your CRM and reads back the ticket ID. POST request, body template carries the substituted args:
{
"name": "open_support_ticket",
"description": "Open a new support ticket on behalf of the caller. Use ONLY after the caller confirms they want to file a ticket.",
"endpoint": {
"method": "POST",
"url": "https://crm.acme.com/api/tickets/__validate__"
},
"urlTemplate": "https://crm.acme.com/api/tickets",
"parameters": {
"type": "object",
"properties": {
"title": { "type": "string", "description": "One-line summary" },
"details": { "type": "string", "description": "Detailed description" },
"priority": { "type": "string", "enum": ["low", "normal", "high"] }
},
"required": ["title", "details", "priority"]
},
"bodyTemplate": "{ \"title\": {{title}}, \"details\": {{details}}, \"priority\": {{priority}}, \"source\": \"voice\" }",
"auth": { "type": "api-key", "envVar": "ACME_CRM_KEY", "headerName": "X-API-Key" },
"responseTemplate": "Ticket {{ticket.id}} opened, priority {{ticket.priority}}.",
"timeoutMs": 4000
}Example 3 - Weather (no auth, public API)
Same one we used in the five-minute path, repeated here as a clean reference point:
{
"name": "lookup_weather",
"description": "Get the current weather for a city.",
"endpoint": {
"method": "GET",
"url": "https://wttr.in/_placeholder"
},
"urlTemplate": "https://wttr.in/{{city}}?format=j1",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
},
"auth": { "type": "none" },
"responseTemplate": "It is {{current_condition.0.temp_C}} degrees and {{current_condition.0.weatherDesc.0.value}} in {{nearest_area.0.areaName.0.value}}."
}Security model (already enforced)
Every custom tool runs through the same hardening as the platform-native integrations:
- HTTPS only. Plain HTTP URLs are rejected at validation.
- No SSRF. Private/internal hosts (
127.x,10.x,192.168.x, IPv6 link-local, cloud metadata endpoints) are blocked at load AND again post-substitution. - Strict argument validation. Args that don’t match your declared
parametersschema are rejected before any HTTP call.requiredentries must be present;enumvalues are checked. - No prototype pollution. Both URL substitution and response-template lookup refuse
__proto__,constructor, andprototypesegments. - Per-tool timeout (default 3 s, hard cap 5 s) and response cap (4000 bytes) - voice can’t wait longer and can’t read longer.
- Tool count cap - at most 10 custom tools per agent. Past that you blow the model’s context window.
- Auth values never leak. Tokens stay in
~/ninja-talk/.envand onAuthorization/ custom headers. They’re never logged and never returned to the caller. - Failures are voice-friendly. A misconfigured tool, a missing env var, or an unreachable server returns a short spoken sentence to the AI - never a stack trace, never a 500 error in the caller’s ear.
Operational guide
When changes apply
voice-tools.json is re-read at the start of every call. Save the file, hang up if you’re mid-call, dial again - the new tool (or the disabled built-in) is active. No restart, no deploy.
When validation fails
If the file is malformed or any tool fails validation, the agent logs the error and falls back to all built-ins enabled, no custom tools. The voice service does NOT crash.
# Tail the journal during a call to see config events:
journalctl --user -u ninja-talk -n 100 --no-pager | grep voice-tools
# Healthy load looks like:
# [voice-tools] loaded voice-tools.json { customToolCount: 2, enabledBuiltins: '...' }
# Validation failure looks like:
# [voice-tools] voice-tools.json validation failed: customTools[0].name "End_Call" collides with a reserved or built-in toolValidate without calling
Quickest way to confirm the file parses and the tool list is what you expect: SSH in, run the JSON parser, then tail the next call’s log:
# 1. Make sure the file is valid JSON
jq . ~/.openclaw/workspace/data/voice-tools.json
# 2. Make a test call, then check what got registered:
journalctl --user -u ninja-talk -n 200 --no-pager | grep -E "custom tool|tools registered"Troubleshooting
“The AI doesn't call my new tool”
- Check the journal for
[voice-tools] loaded voice-tools.json. If it saysvalidation failed, the file didn’t load and your tool isn’t registered. - Make the
descriptionmore specific. Vague descriptions (“customer tool”) get ignored; specific ones (“Look up a customer’s account status by their account number”) get used. - Did you exceed 10 custom tools? Past 10 the validator rejects the whole file.
“The agent says 'That tool is not configured'”
- Your
auth.envVarisn’t set in~/ninja-talk/.env. - Check the journal - it logs which env var was missing.
- After editing
.env, restart the service:sudo systemctl --user restart ninja-talk.
“The agent says 'I could not reach that service right now'”
- The HTTP request failed (timeout, DNS, 5xx). Hit the URL with
curlfrom the same server to confirm. - Check the journal for the duration - if it’s right around your
timeoutMs, raise it (up to 5000 ms max). - If the URL has private/internal segments after substitution, the SSRF guard rejects the call. Make sure
{{argName}}values can’t produce a private host.
“The agent's spoken answer is gibberish JSON”
- You skipped
responseTemplate. The raw JSON is fed to the model, which sometimes reads field names aloud. Add a template that turns the JSON into a sentence. - Your dotted path is wrong. Check the response shape with
curlfirst, then write the template against that shape.
“My JSON body is rejected by the API”
- Most common cause: you placed
{{argName}}inside a JSON string (e.g."name": "{{name}}"). The substitution is JSON-encoded, so this becomes"name": ""Sarah""- broken. - Place placeholders where a JSON value is expected, NOT inside quotes:
"name": {{name}}. The substitution adds the quotes for you.
Further reading
- Voice Agent Tools & Skills Guide - overall picture of how voice tools work, including the platform- native fast path (
integrations/). - Private & Public Mode - how mode selection gates which tools the AI can offer, and how the public-mode security rules block adversarial inputs.
- Setup & Settings - config layers, audio tuning, the
.envfile location.
Questions? Reach out at hello@talktomyagent.io