Dev.to · 9 min read

Every Tool Parameter Is a Question Your Voice Agent Has to Ask

Every Tool Parameter Is a Question Your Voice Agent Has to Ask

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. What a custom function is, in API terms 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. Shape one: the schema that asked the model for IDs 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. Shape two: only what a caller can say 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. The description is the only documentation the model reads Compare the two descriptions for "check availability": Shape one: "Check GHL calendar for available free consultation slots" Shape two: "Check if a specific inspection time slot is available on the calendar. Pass requestedTime in format 2026-04-15T09:00:00 (Eastern, no offset). Call this after the caller names a specific day and time." 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. Your response body is a second prompt 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: The availability mock returns the slot list prefixed with "ONLY EVER READ OUT MAX 2 AVAIABLE SLOTS PER DAY." (typo included, it is a real file) The slots mock ends with "Present exactly two options to the caller." The unavailable branch returns "The requested time is unavailable, tell the user to ask for a different day or time". 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: Format for speech in the backend. "Tuesday at 10:00 AM and 2:00 PM" beats forty ISO timestamps. The model should never convert timezones while a caller waits. Cap the output in the backend, not just the prompt. If the rule is two options, return two options. An instruction in the response is a backup, not the mechanism. Make every failure speakable. "I could not reach the calendar" plus a next step is a valid response. A stack trace is not. The rule I enforce in the prompt: the agent only says "you're booked" if the tool returned a confirmation. A confident fake booking is the most expensive hallucination a voice agent can produce. The two settings I shipped on defaults 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: A short timeout on the Retell side, and a faster one inside the workflow, so the backend can return a speakable fallback before Retell gives up. 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. One lookup per tool, and cached data for things that never change mid-call, like service areas or business hours. The n8n side of that wiring is in An AI Voice Agent Is Just Two Webhooks. Testing it 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. The short version Treat each tool as a public API whose client is a model taking dictation. Only ask for what a caller can say. Resolve IDs, timezones and durations on the server. Write descriptions as instructions with preconditions, because prose is the only contract the model reads. Treat the response body as a prompt: speakable, capped, and honest about failure. Do not ship a two-minute timeout on a phone call. I did, so you do not have to. 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News