
Compartir:
Benjamin Aronov es desarrollador de Vonage. Es un constructor de comunidades con experiencia en Ruby on Rails. Benjamin disfruta de las playas de Tel Aviv, a la que llama hogar. Su base en Tel Aviv le permite conocer y aprender de algunos de los mejores fundadores de startups del mundo. Fuera de la tecnología, a Benjamin le encanta viajar por el mundo en busca del perfecto pain au chocolat.
Añadir herramientas y transferencia a un agente de voz de Vonage + Deepgram
Tiempo de lectura: 10 minutos
Introducción
La Voice API de Vonage y la guía de Deepgram te permite llegar sorprendentemente lejos.
Al finalizar esta guía, podrás llamar a un número de Vonage, hablar con un agente de IA, interrumpirlo mientras habla y mantener una conversación de voz natural gracias a la API de agente de voz de Deepgram.
Pero, en realidad, el agente todavía no puede hacer gran cosa por ti (¡no dispone de herramientas!).
Si le pides al agente que realice una acción, y a menos que la información sobre dicha acción figure en la indicación, no tiene dónde buscarla. Si le asignas una tarea que depende de otro sistema, te encontrarás con otro problema: el usuario que llama tendrá que esperar a que ese sistema responda.
La voz hace que esos detalles se noten muy rápidamente. Una llamada a la API lenta no se ve como un indicador de carga, sino que suena como un silencio absoluto.
Este problema específico del audio para los agentes de IA se presentó en «Don’t Loop the Latency: Where Agent Loops Belong in Voice AI». Una de las ideas que plantea en su entrada es que el trabajo que se realiza durante una llamada telefónica en directo sea reducido y predecible.
El objetivo de esta entrada es convertir la demostración conversacional en un agente capaz de realizar tareas útiles, fallar de forma predecible y, lo más importante, dejar suficiente información para mejorarlo más adelante. En este tutorial, ampliaremos el agente existente de Vonage + Deepgram para que pueda:
proporcionar al agente una herramienta para comprobar el estado de un pedido
establecer un tiempo de espera fijo para la búsqueda
gestionar los errores sin que el usuario que realiza la llamada tenga que esperar
desviar las solicitudes que el agente no pueda gestionar a un número de teléfono real
guardar un registro de lo que ocurrió durante cada llamada
En resumen La guía de inicio rápido está disponible en GitHub.
Requisitos previos
Necesitarás:
Una aplicación de Vonage con la función de voz activada
Un número de teléfono de Vonage vinculado a esa aplicación
Un segundo número de teléfono que puedes utilizar como contacto de asistencia por parte de una persona
Una account de Deepgram con acceso a Voice Agent
Dale una herramienta al agente
Vamos a mantener el caso de uso deliberadamente sencillo: comprobar el estado de un pedido. El usuario dice:
«¿Dónde está el pedido A1001?»
Deepgram detecta que el agente necesita información externa y solicita a nuestra aplicación que llame a:
getOrderStatus("A1001")
Nuestra aplicación realiza la búsqueda y envía el resultado a Deepgram. A continuación, el modelo convierte ese resultado en una respuesta hablada de forma natural.
En este tutorial, el backend de pedidos es una simulación. Más adelante podrás sustituirlo por tu propia base de datos o API; solo necesitamos una consulta que pueda tener éxito, fallar o tardar demasiado:
// src/tools/order-status.ts
type OrderResult =
| { status: "in_transit"; estimatedDelivery: string }
| { status: "delivered"; deliveredAt: string }
| { status: "not_found" }
| { status: "error"; reason: string };
const KNOWN_ORDERS: Record<string, OrderResult> = {
A1001: {
status: "in_transit",
estimatedDelivery: "2026-07-29"
},
A1002: {
status: "delivered",
deliveredAt: "2026-07-24T16:42:00Z"
}
};También hay un par de números de pedido especiales para realizar pruebas:
Los ID que empiezan por SLOW tardan dos segundos en aparecer.
Los ID que empiezan por FAIL simulan un fallo temporal de la red.
Cualquier otra cosa devuelve «not_found».
Esto nos permite reproducir el tipo de comportamiento que, en última instancia, obtendríamos de una base de datos real o de una API externa sin necesidad de desarrollar un backend completo para este tutorial.
Registrar la herramienta en Deepgram
La API de Deepgram para agentes de voz admite la llamada a funciones del lado del cliente. Describimos la función en el mensaje «Configuración» del agente:
// src/agent/agent-config.ts
const GET_ORDER_STATUS_FUNCTION = {
name: "getOrderStatus",
description:
"Look up the current shipping status of a single order by its order number.",
parameters: {
type: "object",
properties: {
orderId: {
type: "string",
description: 'The caller\'s order number, for example "A1001".'
}
},
required: ["orderId"]
}
};No proporcionamos a Deepgram un punto final HTTP para la función. En su lugar, Deepgram envía una «FunctionCallRequest» a nuestro WebSocket. Esto significa que nuestra aplicación mantiene el control sobre la ejecución efectiva de la herramienta.
Eso resulta útil porque nos permite decidir:
si la herramienta está permitida
cuánto tiempo puede funcionar
si se debe volver a intentar tras un fallo
lo que oye la persona que llama si no funciona
El ámbito de actuación del agente también sigue siendo limitado:
// src/agent/agent-config.ts (SYSTEM_PROMPT constant)
You are an order-status voice agent for a live phone call.
You may ONLY help the caller check the status of an order.
Rules:
- Ask for the caller's order number if they have not provided it.
- Call getOrderStatus exactly once with the order number.
- You can look up only one order per call.
- If the result is clear, read it back in one or two natural spoken sentences.
- If the lookup fails or times out, use the fallback response.
- If the caller asks about billing, returns, disputes, or anything else,
use the handoff response immediately.
- Do not answer general questions.
- Do not make up information.La indicación le dice al modelo qué debe hacer, pero no nos basamos únicamente en ella. Antes de ejecutar una consulta de pedidos, la aplicación comprueba que la solicitud esté dentro del ámbito de aplicación y que la herramienta no se haya utilizado ya en esa llamada. Puedes ver cómo funciona esto en la sección «Gestionar la llamada a la función».
Establece un plazo para la búsqueda
Aquí es donde el desarrollo de un sistema para una llamada telefónica empieza a diferir del desarrollo de un chatbot normal. Imagina que el servicio de pedidos tarda seis segundos en responder. Desde el punto de vista del servidor, eso no es más que una solicitud lenta.
Desde el punto de vista de la persona que llamaba, esta formuló una pregunta y, a continuación, escuchó seis segundos de silencio.
Así pues, en este ejemplo, la consulta tarda 1500 milisegundos:
// src/agent/tool-policy.ts
export const TOOL_TIMEOUT_MS = 1500; El envoltorio compara la llamada al backend con ese plazo:
// src/tools/order-status.ts
export async function lookupOrderStatus(
orderId: string,
timeoutMs: number = TOOL_TIMEOUT_MS
): Promise<OrderLookupOutcome> {
const startedAt = Date.now();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<"__timeout__">((resolve) => {
timer = setTimeout(() => resolve("__timeout__"), timeoutMs);
});
try {
const raced = await Promise.race([
getOrderStatus(orderId),
timeout
]);
if (raced === "__timeout__") {
return {
kind: "timeout",
durationMs: Date.now() - startedAt
};
}
return {
kind: "result",
result: raced,
durationMs: Date.now() - startedAt
};
} catch (err) {
return {
kind: "transport_error",
error: new TransportError(
err instanceof Error ? err.message : "unknown tool failure"
),
durationMs: Date.now() - startedAt
};
} finally {
if (timer) clearTimeout(timer);
}
}Lo importante es que hay tres resultados posibles:
Resultado | Qué significa | Qué hacemos |
resultado | El backend respondió | Devuelve el resultado |
tiempo de espera | No respondió en un plazo de 1500 ms | Utiliza la opción alternativa |
error_de_transporte | La conexión ha fallado | Inténtalo de nuevo una vez |
Una respuesta «not_found» sigue siendo una consulta satisfactoria. El backend ha respondido; simplemente no ha encontrado el pedido.
Eso es diferente a un tiempo muerto.
Limita lo que puede suceder durante la llamada
En este ejemplo, las reglas son intencionadamente mínimas:
// src/agent/tool-policy.ts
export const MAX_TOOL_CALLS = 1;
export const MAX_RETRIES = 1;
export const ALLOWED_TOOLS = ["getOrderStatus"] as const; Cada llamada telefónica tiene su propia instancia de póliza:
// src/agent/tool-policy.ts
export function createToolPolicy() {
let callsMade = 0;
let retriesUsed = 0;
return {
authorizeCall(toolName: string) {
if (toolName !== "getOrderStatus") {
return { allowed: false };
}
if (callsMade >= MAX_TOOL_CALLS) {
return { allowed: false };
}
return { allowed: true };
},
recordCall() {
callsMade += 1;
},
authorizeRetry(outcome: OrderLookupOutcome) {
if (outcome.kind !== "transport_error") {
return { allowed: false };
}
if (retriesUsed >= MAX_RETRIES) {
return { allowed: false };
}
return { allowed: true };
},
recordRetry() {
retriesUsed += 1;
}
};
}No se vuelve a intentar tras un tiempo de espera.
Ya hemos esperado 1,5 segundos. Volver a iniciar la misma solicitud lenta podría suponer simplemente otros 1,5 segundos de silencio.
Un fallo de transporte da derecho a un reintento, ya que una conexión interrumpida o un problema temporal en el enlace de salida pueden solucionarse de inmediato.
Gestionar la llamada a la función
Cuando Deepgram envía una FunctionCallRequest, el controlador de WebSocket aplica esas reglas antes de ejecutar la herramienta.
El proceso principal es el siguiente:
// src/voice/websocket-handler.ts
async function handleFunctionCall(fn: DeepgramFunctionCall) {
// Is this request supported?
const handoffReason =
classifyHandoffReason(lastCallerUtterance);
if (handoffReason) {
requestHandoff(handoffReason);
respondToFunction(
fn,
JSON.stringify({ status: "handoff" })
);
return;
}
// Has the tool already been called?
const auth = policy.authorizeCall(fn.name);
if (!auth.allowed) {
fallbackUsed = true;
inject(FALLBACK_RESPONSE);
respondToFunction(
fn,
JSON.stringify({ status: "blocked" })
);
return;
}
policy.recordCall();
// Run the lookup
let outcome = await lookupOrderStatus(orderId);
// Retry one transport failure
if (
outcome.kind === "transport_error" &&
policy.authorizeRetry(outcome).allowed
) {
policy.recordRetry();
outcome = await lookupOrderStatus(orderId);
}
// Return a successful result
if (outcome.kind === "result") {
respondToFunction(
fn,
JSON.stringify(outcome.result)
);
return;
}
// Otherwise use the fallback
fallbackUsed = true;
inject(FALLBACK_RESPONSE);
}El controlador propiamente dicho también registra la llamada a la herramienta y el tiempo, pero esta es la parte que controla lo que experimenta el usuario que realiza la llamada.
Utiliza una opción alternativa fija
Si la búsqueda falla, no es conveniente que el modelo elabore su propio plan de recuperación.
La aplicación inserta una respuesta fija:
// src/agent/fallback-responses.ts
export const FALLBACK_RESPONSE =
"I'm having trouble retrieving that order right now. " +
"Please try again later, or contact our support team and they can help.";La función InjectAgentMessage de Deepgram nos permite insertar esa respuesta directamente en la conversación:
// src/voice/websocket-handler.ts
sendJson(dg, {
type: "InjectAgentMessage",
message: FALLBACK_RESPONSE,
behavior: "interrupt"
}); En este caso, el comportamiento de las interrupciones es importante.
Si se utiliza el comportamiento predeterminado, puede ocurrir que se rechace un mensaje insertado mientras el interlocutor está hablando. Eso no es algo que quieras descubrir cuando el mensaje insertado sea tu vía de escape en caso de fallo.
Fíjate también en el nombre del campo: es «mensaje», no «contenido».
Ambos son pequeños detalles que, de otro modo, pueden hacer que la llamada se quede en silencio sin que haya apenas indicios de por qué.
Transferir las solicitudes no compatibles a un operador humano
Un agente encargado de atender consultas sobre el estado de los pedidos no debería intentar responder a todo.
Si alguien dice:
«Quiero impugnar un cargo.»
Lo clasificamos como una solicitud de facturación:
// src/agent/tool-policy.ts
export type HandoffReason =
| "billing"
| "returns"
| "cancellation"
| "account"
| "unsupported"; En esta demostración, la clasificación se ha limitado deliberadamente y es determinista:
// src/agent/tool-policy.ts
const HANDOFF_KEYWORDS = [
["cancellation", ["cancel", "cancellation"]],
["returns", ["return", "returns", "refund", "exchange"]],
[
"billing",
["billing", "invoice", "charge", "payment", "dispute"]
],
["account", ["account", "password", "login", "access"]]
];Aquí no hay una segunda convocatoria de modelos.
Si la persona que llama solicita algo que no entra dentro de las funciones del agente, sabemos que queremos que el resultado sea siempre el mismo: derivar la llamada a otra persona.
// src/voice/websocket-handler.ts
function requestHandoff(reason: HandoffReason): void {
if (handoffRequested) return;
handoffRequested = true;
handoffReason = reason;
void transferToHuman(callUuid, reason).catch((err) => {
console.error[transfer] failed: ${String(err)});
});
} La función `transferToHuman()` actualiza la llamada activa de Vonage con un nuevo NCCO.
El NCCO primero explica a la persona que llama qué está pasando y, a continuación, transfiere la llamada al número de asistencia:
// src/voice/transfer-to-human.ts
const ncco = [
{
action: "talk",
text: "That's something our support team handles directly. Let me connect you now.",
language: "en-US"
},
{
action: "connect",
from: process.env.VONAGE_NUMBER,
endpoint: [
{
type: "phone",
number: process.env.SUPPORT_PHONE_NUMBER
}
]
}
];A continuación, transferimos la llamada activa:
// src/voice/transfer-to-human.ts
await fetch(
https://api.nexmo.com/v1/calls/${callUuid},
{
method: "PUT",
headers: {
Authorization: Bearer ${jwt},
"Content-Type": "application/json"
},
body: JSON.stringify({
action: "transfer",
destination: {
type: "ncco",
ncco
}
})
}
);Para realizar pruebas locales, SUPPORT_PHONE_NUMBER puede ser simplemente otro teléfono que tengas a mano.
Llama a tu número de Vonage desde un teléfono, solicita que se revise un cargo y el otro teléfono debería sonar.
Es una solución alternativa mucho mejor que hacer que la IA finja que puede resolver un problema de facturación al que nunca se le ha dado acceso.
Configurar la transferencia
Para que el traspaso funcione correctamente, añade estos valores a tu entorno:
# .env.example
VONAGE_APP_ID=your_application_id
VONAGE_PRIVATE_KEY_PATH=./vonage_private.key
VONAGE_NUMBER=15551234567
SUPPORT_PHONE_NUMBER=15557654321También necesitarás la configuración actual:
# .env.example
DEEPGRAM_API_KEY=your_deepgram_key
BASE_URL=https://your-ngrok-url.ngrok-free.app
DB_PATH=./calls.db
AGENT_VERSION=order-status-v1 Utiliza números en formato E.164 para los números de Vonage y de asistencia técnica.
Guardar lo que ocurrió durante la llamada
La aplicación también guarda un pequeño registro en SQLite cuando finaliza la llamada.
Esto no es la base de datos de pedidos. Es un registro de la propia interacción de voz.
-- src/storage/db.ts
CREATE TABLE IF NOT EXISTS call_records (
call_id TEXT PRIMARY KEY,
agent_version TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
transcript TEXT NOT NULL,
tool_calls TEXT NOT NULL,
latency TEXT NOT NULL,
fallback_used INTEGER NOT NULL,
handoff_requested INTEGER NOT NULL,
handoff_reason TEXT,
outcome TEXT NOT NULL
);Un registro nos puede indicar:
lo que dijeron la persona que llamó y el agente
¿qué herramienta se llamaba?
cuánto tiempo tardó
si se utilizó la alternativa
si se transfirió la llamada
por qué se produjo el traspaso
cómo terminó la llamada
Por ejemplo, una llamada de facturación podría terminar más o menos así:
{
"agentVersion": "order-status-v1",
"fallbackUsed": false,
"handoffRequested": true,
"handoffReason": "billing",
"outcome": "handoff"
} Eso resulta útil aunque nunca llegues a crear un sistema de evaluación complejo a partir de ello.
Si las llamadas se siguen transfiriendo por el mismo motivo, podrás verlo. Si los tiempos de espera empiezan a aumentar, también podrás verlo. Y si más adelante cambias el mensaje de bienvenida, el modelo o las herramientas, el campo «agent_version» te ofrece una forma sencilla de saber qué versión gestionó cada llamada.
Cómo ejecutar el agente
Inicia primero ngrok:
ngrok http 3000 Introduce esa URL HTTPS en BASE_URL y en los webhooks «Answer» y «Event» de tu aplicación de Vonage.
A continuación, inicia la aplicación:
npm run devPuedes comprobar que el webhook de respuesta funciona correctamente antes de realizar una llamada telefónica:
curl "https://YOUR-NGROK-URL/answer?uuid=test123&from=15551234567" El NCCO devuelto debería contener un punto de conexión WebSocket similar a:
wss://YOUR-NGROK-URL/socket?callUuid=test123
Prueba con cuatro llamadas
Prueba a comprobar los comportamientos principales mediante llamadas telefónicas independientes.
Llamar | ¿Qué decir? | ¿Qué debería pasar? |
1 | «¿Dónde está el pedido A1001?» | El agente confirma el estado de la entrega |
2 | «¿Dónde está el pedido SLOW999?» | Se agota el tiempo de espera de la búsqueda y se reproduce el contenido alternativo predeterminado |
3 | «¿Dónde está el pedido XYZ123?» | El agente te dice que no se ha podido encontrar el pedido |
4 | «Quiero impugnar un cargo.» | La llamada se transfiere a tu teléfono de asistencia |
Realiza una llamada nueva para cada uno. En este ejemplo, solo se permite una consulta de pedido por llamada.
A continuación, revisa los registros:
sqlite3 calls.db \
"SELECT
call_id,
agent_version,
outcome,
fallback_used,
handoff_requested,
handoff_reason
FROM call_records
ORDER BY started_at DESC
LIMIT 4;" Deberías ver las diferentes rutas representadas en los datos:
completado
alternativa
completado
traspaso
En la última llamada, el campo «handoff_reason» debe indicar «facturación».
Conclusión
El tutorial original de Vonage + Deepgram muestra cómo integrar un agente de IA conversacional en una llamada telefónica real. En este caso, le hemos asignado a ese agente una tarea útil: consultar un pedido, dejar de esperar cuando el sistema de fondo sea demasiado lento, gestionar correctamente los errores cuando algo falle y transferir las solicitudes que no pueda atender a un operador humano.
El backend de pedidos es una simulación, pero el patrón es el mismo si sustituyes getOrderStatus() por tu propia base de datos o API.
Así es como se aplica en la práctica la idea de Vince de «no crear bucles de latencia»: mientras el solicitante espera, hay que limitar el volumen de trabajo, establecer plazos para las llamadas externas y ofrecer una vía de salida útil.
Una vez finalizada la llamada, esas limitaciones de latencia desaparecen. Es ahí donde resultan útiles los registros que hemos guardado: las transcripciones, las llamadas a herramientas, los resultados, los motivos del traspaso y las versiones de los agentes.
Si, por ejemplo, las devoluciones empiezan a aparecer repetidamente como motivo de transferencia, podemos utilizar esas llamadas para decidir qué desarrollar a continuación, añadir una función de devoluciones a una versión candidata y probarla con las llamadas guardadas antes de ponerla a disposición de los usuarios.
Ese es el comienzo de un flujo de trabajo de ingeniería de bucles. ¡Pero eso lo dejaremos para la próxima entrada!
¿Tiene alguna pregunta o quiere compartir lo que está construyendo?
Suscríbase al Boletín para desarrolladores
Síguenos en X (antes Twitter) para estar al día
Ver tutoriales en nuestro canal de YouTube
Conéctese con nosotros en la página Vonage Developer en LinkedIn
Ayúdanos a mejorar nuestra experiencia como desarrolladores rellenando nuestro cuestionario «La voz del desarrollador»
Mantente conectado y entérate de las últimas noticias, consejos y eventos para desarrolladores.
Compartir:
Benjamin Aronov es desarrollador de Vonage. Es un constructor de comunidades con experiencia en Ruby on Rails. Benjamin disfruta de las playas de Tel Aviv, a la que llama hogar. Su base en Tel Aviv le permite conocer y aprender de algunos de los mejores fundadores de startups del mundo. Fuera de la tecnología, a Benjamin le encanta viajar por el mundo en busca del perfecto pain au chocolat.