If you have ever designed a REST endpoint, you already know most of what it takes to give a voice agent tools. Name it well, keep the parameters tight, validate inputs, return something useful. The difference is the client. Your endpoint is not called by a frontend developer reading your docs. It is called by a language model that is taking dictation from a stranger on a phone line, in real time, while that stranger waits in silence.
That one difference changes how I design every tool. I build production voice agents on Retell AI, with n8n and GoHighLevel behind them, and I went back through the nine client agent exports I keep in one folder to see what my tool definitions actually look like. They fall into two clear shapes. Comparing them taught me more than any guide I could have written up front.
In Retell, a custom function is four things: a name, a description, a JSON schema for the arguments, and a URL. During the call, the model decides it needs the tool, fills in the arguments from the conversation, Retell POSTs them to your URL, and whatever you send back lands in the model's context. Then it keeps talking.
So it is an RPC where the caller is an LLM and the arguments come from speech. Keep that framing, because every lesson below falls out of it.
Five of my nine exports book through a pair of tools called get_slots_from_ghl and create_appointment_using_ghl. The booking tool has eight parameters:
{
"required": ["email", "first_name", "phone", "start_time", "end_time"],
"properties": {
"start_time": { "type": "string", "description": "Appointment start time in ISO 8601 format (e.g. 2026-04-15T14:00:00-07:00)" },
"end_time": { "type": "string", "description": "Appointment end time in ISO 8601 format" },
"phone": { "type": "string", "description": "Contact's phone in E.164 format (e.g. +17025551234)" },
"calendarId": { "type": "string", "const": "REPLACE_WITH_CALENDAR_ID" },
"locationId": { "type": "string", "const": "REPLACE_WITH_LOCATION_ID" },
"title": { "type": "string", "const": "Free Legal Consultation" },
"first_name": { "type": "string" },
"email": { "type": "string" }
}
}
(IDs replaced with placeholders, and descriptions trimmed.)
Read that as an API reviewer and a few things jump out.
The model is passing infrastructure IDs. calendarId and locationId are pinned with const, which helps, but the model is still the thing emitting them on every call. A const is a hint the model follows most of the time. It is not the same as the server simply knowing which calendar it owns.
The model is doing time math. It has to turn "Thursday around two" into an ISO timestamp with the right UTC offset, then compute an end time. The slots tool even needs an IANA timezone, and its description has to plead: "Never use abbreviations like PST or EST." When you have to write "never" in a parameter description, the parameter is in the wrong place.
The model is formatting a phone number. E.164 from speech is a parsing job. Parsing jobs belong in code.
None of this is broken, exactly. It mostly works. But "mostly" is doing a lot of work on a phone call, where a wrong offset books someone an hour off and nobody notices until they show up.
The other four exports use a different trio: checkCalendar, bookCalendar and getWeekAvailability. Here is the booking tool from the roofing agent:
{
"required": ["name", "reason", "requestedTime"],
"properties": {
"name": { "type": "string", "description": "Caller's full name" },
"reason": { "type": "string", "description": "Type of roofing service the caller needs (e.g. roof repair, full replacement, storm damage inspection, gutter installation)" },
"requestedTime": { "type": "string", "description": "Confirmed inspection time in format 2026-04-15T09:00:00 (Eastern time, no offset suffix)" }
}
}
Three parameters, and every one of them is something a human would actually say out loud. No calendar ID, no location ID, no end time, no offset. The time is local wall-clock time in the one timezone the business works in, and the backend owns everything else: which calendar, how long the slot is, what the offset is today.
getWeekAvailability goes further and takes no parameters at all.
This is the rule I now design by: every required parameter is a question the agent has to ask. If the caller cannot answer it, the model should not be filling it. Things the server can know (tenant, calendar, slot length, timezone) get resolved on the server, from the agent or the dialed number, never from the conversation. I wrote about the same idea for prompt variables in A Prompt With Placeholders Is a Function Signature Nobody Type-Checks, and the security version of it in Nobody Hacked It. The Lookup Tool Just Took a Phone Number.
Compare the two descriptions for "check availability":
The first is a label. It tells the model what the tool is. The second is an instruction. It tells the model when to call it, what to pass, and implicitly when not to call it. The booking tool in shape two says "Call only after the caller has confirmed a specific time", which is a precondition written in English because there is no other place to write it.
That is the uncomfortable part of tool design for LLMs. In a normal API, preconditions live in code and the caller gets a 400. Here, the model decides when to call based mostly on prose. So the prose is the contract. I write descriptions the way I would write the docstring for a junior developer who is going to call this function under pressure: when, with what, and what must already be true.
Prose contracts also conflict with each other. My last post, All Nine of My Voice Agents Are One Giant Prompt, is about exactly that: a tool description and a global prompt rule disagreed, and the model had to pick one.
This one surprised me when I reread my own n8n workflows. The mock workflows I test agents against do not return JSON for the model to interpret. They return plain sentences, and some of those sentences are instructions:
At first that looked hacky to me. Now I think it is correct, and I would just do it more deliberately. Whatever you return gets read by the model and, very often, read out loud. So:
Being honest about my own exports: every custom tool in all nine has speak_during_execution set to false and timeout_ms at 120000.
Two minutes is a fine timeout for a background job. On a phone call it is an eternity. If the calendar API hangs, the caller hears nothing for far longer than anyone will tolerate. What I recommend now, and what I am moving builds toward:
speak_during_execution on for anything that does real I/O, so the agent says something like "let me check that for you" instead of going silent.
The n8n side of that wiring is in An AI Voice Agent Is Just Two Webhooks.
I test every tool three ways: call the endpoint directly with bad arguments (a weekend date, a malformed time), run scripted calls that should and should not trigger it (the "should not" calls catch weak descriptions), and break the backend on purpose to hear what the agent says. Silence or a fake confirmation means it is not ready. Why this is harder than normal testing is in There Is No Repro for a Phone Call.
I run VoiceDash, a white-label portal where agencies show their clients every Retell call, recording and transcript, so a client can actually hear the agent check a slot and book it. The original, more agency-focused version of this guide is on the VoiceDash blog.
If you build tools for LLM agents, voice or not: where do you put preconditions today, in the description, in the schema, or in the server? I am curious how others handle it.