
Partager:
Benjamin Aronov est un défenseur des développeurs chez Vonage. C'est un bâtisseur de communauté qui a fait ses preuves, avec une formation en Ruby on Rails. Benjamin apprécie les plages de Tel Aviv, où il vit. Sa base à Tel Aviv lui permet de rencontrer et d'apprendre de certains des meilleurs fondateurs de startups du monde. En dehors de la technologie, Benjamin aime voyager à travers le monde à la recherche du parfait pain au chocolat.
Ajouter des outils et le transfert vers un interlocuteur humain à un agent vocal Vonage + Deepgram
Temps de lecture : 10 minutes
Introduction
Le Voice API de Vonage et le guide Deepgram vous permet d’aller étonnamment loin.
À la fin de ce guide, vous serez en mesure d’appeler un numéro Vonage, de parler à un agent IA, de l’interrompre pendant qu’il parle et d’avoir une conversation vocale naturelle grâce à l’API API Voice Agent de Deepgram.
Mais l'agent ne peut toujours pas faire grand-chose pour vous (il n'a pas d'outils !).
Demandez à l'agent d'effectuer une action, et à moins que les informations relatives à cette action ne figurent dans la consigne, il n'aura nulle part où les trouver. Confiez-lui une tâche qui dépend d'un autre système, et vous vous retrouvez face à un autre problème : l'appelant doit attendre que ce système réponde.
La voix permet de percevoir ces détails très rapidement. Un appel API lent ne se traduit pas par une icône de chargement, mais par un silence radio.
Ce problème spécifique à l'audio pour les agents IA a été présenté dans « Don’t Loop the Latency : Where Agent Loops Belong in Voice AI ». L'une des idées développées dans cet article est de veiller à ce que le travail effectué pendant un appel téléphonique en direct reste limité et prévisible.
L'objectif de cet article est de transformer la démo conversationnelle en un agent capable d'effectuer des tâches utiles, de rencontrer des échecs prévisibles et, surtout, de laisser suffisamment d'informations pour permettre son amélioration ultérieure. Dans ce tutoriel, nous allons développer l'agent Vonage + Deepgram existant afin qu'il puisse :
fournir à l'agent un outil lui permettant de vérifier l'état d'une commande
définir un délai d'expiration fixe pour la recherche
gérer les échecs sans faire attendre l'appelant
transférer les demandes que l'agent ne peut pas traiter vers un numéro de téléphone réel
conserver une trace de ce qui s'est passé lors de chaque appel
En bref Le guide de démarrage rapide est disponible sur GitHub.
Conditions préalables
Il vous faudra :
Une application Vonage avec la fonctionnalité « Voice » activée
Un numéro de téléphone Vonage associé à cette application
Un deuxième numéro de téléphone que vous pouvez utiliser pour contacter le service d'assistance humaine
Un Account Deepgram avec accès à Voice Agent
Donnez un outil à l’agent
Nous allons délibérément choisir un cas d'utilisation simple : vérifier le statut d'une commande. L'appelant dit :
« Où se trouve la commande A1001 ? »
Deepgram détecte que l'agent a besoin d'informations externes et demande à notre application d'appeler :
getOrderStatus("A1001")
Notre application effectue la recherche et renvoie le résultat à Deepgram. Le modèle transforme ensuite ce résultat en une réponse vocale naturelle.
Dans ce tutoriel, le backend de gestion des commandes est simulé. Vous pourrez le remplacer ultérieurement par votre propre base de données ou API ; nous avons simplement besoin d'une requête qui puisse aboutir, échouer ou prendre trop de temps :
// 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"
}
};Il existe également quelques numéros de commande spécifiques destinés aux tests :
Les identifiants commençant par « SLOW » mettent deux secondes à s'afficher.
Les identifiants commençant par FAIL simulent une panne réseau temporaire.
Dans tous les autres cas, la fonction renvoie « not_found ».
Cela nous permet de reproduire le type de comportement que l'on obtiendrait à terme avec une véritable base de données ou une API externe, sans avoir à développer un backend complet pour ce tutoriel.
Enregistrer l'outil auprès de Deepgram
L'API Voice Agent de Deepgram prend en charge l'appel de fonctions côté client. Nous décrivons la fonction dans le message « Paramètres » de l’agent :
// 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"]
}
};Nous ne fournissons pas à Deepgram de point de terminaison HTTP pour la fonction. À la place, Deepgram envoie une requête « FunctionCallRequest » à notre WebSocket. Cela signifie que notre application garde le contrôle de l'exécution effective de l'outil.
C'est utile, car cela nous permet de décider :
si l'outil est autorisé
combien de temps il peut fonctionner
s'il convient de réessayer en cas d'échec
ce que l'appelant entend si cela ne fonctionne pas
Le champ d'action de l'agent reste lui aussi restreint :
// 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 consigne indique au modèle ce qu’il doit faire, mais nous ne nous fions pas uniquement à celle-ci. Avant d’effectuer une recherche de commande, l’application vérifie que la requête entre bien dans le champ d’application et que l’outil n’a pas déjà été utilisé pour cet appel. Vous pouvez voir comment cela fonctionne dans la section « Gérer l’appel de fonction ».
Fixer une date limite pour la recherche
C'est là que la conception d'un système destiné à un appel téléphonique commence à différer de celle d'un chatbot classique. Imaginons que le service de prise de commande mette six secondes à répondre. Du point de vue du serveur, il s'agit simplement d'une requête lente.
Du point de vue de l'appelant, celui-ci a posé une question, puis a entendu six secondes de silence.
Dans cet exemple, la recherche prend donc 1 500 millisecondes :
// src/agent/tool-policy.ts
export const TOOL_TIMEOUT_MS = 1500; Le wrapper exécute l'appel au backend en respectant ce délai :
// 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);
}
}Ce qu'il faut retenir, c'est qu'il existe trois issues possibles :
Résultat | Ce que cela signifie | Ce que nous faisons |
résultat | Le serveur a répondu | Renvoyer le résultat |
délai d'expiration | Il n'a pas répondu dans les 1 500 ms. | Utilisez la solution de secours |
erreur_de_transport | La connexion a échoué | Réessayez une fois |
Une réponse « not_found » correspond tout de même à une recherche réussie. Le backend a répondu ; il n'a simplement pas trouvé la commande.
Ce n'est pas la même chose qu'un temps mort.
Définir les limites de ce qui peut se passer pendant l'appel
Dans cet exemple, les règles sont volontairement réduites au minimum :
// src/agent/tool-policy.ts
export const MAX_TOOL_CALLS = 1;
export const MAX_RETRIES = 1;
export const ALLOWED_TOOLS = ["getOrderStatus"] as const; Chaque appel téléphonique dispose de sa propre instance de police :
// 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;
}
};
}En cas de délai d'expiration, aucune nouvelle tentative n'est effectuée.
Nous avons déjà attendu 1,5 seconde. Relancer cette même requête lente risquerait simplement de nous valoir encore 1,5 seconde de silence.
Une erreur de transport donne droit à une seule tentative de reprise, car une connexion interrompue ou un problème temporaire en amont peut se résoudre immédiatement.
Gérer l'appel de fonction
Lorsque Deepgram envoie une requête FunctionCallRequest, le gestionnaire WebSocket applique ces règles avant d'exécuter l'outil.
Le déroulement général est le suivant :
// 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);
}Le gestionnaire proprement dit enregistre également l'appel de l'outil et son timing, mais c'est cette partie qui détermine ce que perçoit l'appelant.
Utiliser une solution de repli fixe
Si la recherche échoue, vous ne voulez pas que le modèle élabore lui-même un plan de reprise.
L'application insère une réponse fixe :
// 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 fonction `InjectAgentMessage` de Deepgram nous permet d'intégrer directement cette réponse dans la conversation :
// src/voice/websocket-handler.ts
sendJson(dg, {
type: "InjectAgentMessage",
message: FALLBACK_RESPONSE,
behavior: "interrupt"
}); Le comportement des interruptions joue ici un rôle important.
Si vous utilisez le comportement par défaut, un message injecté risque d'être rejeté pendant que l'appelant est en train de parler. Ce n'est pas le genre de chose que l'on souhaite découvrir lorsque ce message injecté constitue votre scénario de défaillance.
Notez également le nom du champ : il s'agit de « message », et non de « contenu ».
Ce sont là deux petits détails qui, sans cela, pourraient entraîner une coupure de la communication sans que l'on sache vraiment pourquoi.
Transférer les demandes non prises en charge à un opérateur humain
Un agent chargé du suivi des commandes ne doit pas essayer de répondre à toutes les questions.
Si quelqu'un dit :
« Je souhaite contester un prélèvement. »
Nous classons cela comme une demande de facturation :
// src/agent/tool-policy.ts
export type HandoffReason =
| "billing"
| "returns"
| "cancellation"
| "account"
| "unsupported"; Dans cette démonstration, la classification est délibérément restreinte et déterministe :
// 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"]]
];Il n'y a pas de deuxième appel de mannequins ici.
Si l'appelant demande quelque chose qui ne relève pas des attributions de l'agent, nous savons que nous voulons toujours la même chose : transférer l'appel à une personne.
// 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 méthode `transferToHuman()` met à jour l'appel Vonage en cours en lui attribuant un nouveau NCCO.
Le NCCO explique d'abord à l'appelant ce qui se passe, puis transfère l'appel vers le numéro d'assistance :
// 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
}
]
}
];Ensuite, nous transférons l'appel en cours :
// 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
}
})
}
);Pour les tests en local, SUPPORT_PHONE_NUMBER peut tout simplement correspondre à un autre téléphone que vous avez à portée de main.
Appelez votre numéro Vonage depuis un téléphone, demandez à contester un prélèvement, et l'autre téléphone devrait sonner.
C'est une solution de secours bien meilleure que de laisser l'IA faire comme si elle pouvait résoudre un problème de facturation auquel elle n'a jamais eu accès.
Configurer le transfert
Pour que le transfert de contrôle fonctionne correctement, ajoutez ces valeurs à votre environnement :
# .env.example
VONAGE_APP_ID=your_application_id
VONAGE_PRIVATE_KEY_PATH=./vonage_private.key
VONAGE_NUMBER=15551234567
SUPPORT_PHONE_NUMBER=15557654321Vous aurez également besoin de la configuration actuelle :
# .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 Utilisez des numéros au format E.164 pour les numéros de Vonage et d'assistance.
Enregistrer le contenu de l'appel
L'application enregistre également un petit enregistrement dans SQLite à la fin de l'appel.
Il ne s'agit pas de la base de données des commandes. Il s'agit d'un enregistrement de l'interaction vocale elle-même.
-- 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 enregistrement peut nous indiquer :
ce qu'ont dit l'appelant et l'agent
quel outil s'appelait
combien de temps cela a pris
si la solution de secours a été utilisée
si l'appelant a été transféré
pourquoi ce transfert a eu lieu
comment l'appel s'est terminé
Par exemple, un appel concernant la facturation pourrait se dérouler à peu près comme suit :
{
"agentVersion": "order-status-v1",
"fallbackUsed": false,
"handoffRequested": true,
"handoffReason": "billing",
"outcome": "handoff"
} Cela s'avère utile, même si vous ne mettez jamais en place un système d'évaluation complexe autour de cela.
Si les appelants sont sans cesse transférés pour la même raison, vous pouvez le constater. Si les délais d'attente commencent à s'allonger, vous pouvez également le constater. Et si vous modifiez ultérieurement le message d'accueil, le modèle ou les outils, le champ « agent_version » vous permet de déterminer facilement quelle version a traité quels appels.
Comment exécuter l'agent
Commencez par lancer ngrok :
ngrok http 3000 Indiquez cette URL HTTPS dans la variable BASE_URL ainsi que dans les webhooks « Answer » et « Event » de votre application Vonage.
Lancez ensuite l'application :
npm run devVous pouvez vérifier la validité du webhook de réponse avant de passer un appel téléphonique :
curl "https://YOUR-NGROK-URL/answer?uuid=test123&from=15551234567" Le NCCO renvoyé devrait contenir un point de terminaison WebSocket similaire à celui-ci :
wss://YOUR-NGROK-URL/socket?callUuid=test123
Essayez « Four Calls »
Essayez de tester les principaux comportements sous forme d'appels téléphoniques distincts.
Appeler | Que dire ? | Que devrait-il se passer ? |
1 | « Où se trouve la commande A1001 ? » | L'agent confirme l'état de la livraison |
2 | « Où se trouve la commande SLOW999 ? » | La recherche expire et la solution de secours prédéfinie est lancée |
3 | « Où se trouve la commande XYZ123 ? » | L'agent vous indique que la commande est introuvable |
4 | « Je souhaite contester un prélèvement. » | L'appel est transféré vers votre numéro d'assistance |
Effectuez un nouvel appel pour chaque opération. Cet exemple permet une seule recherche de commande par appel.
Ensuite, vérifiez les registres :
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;" Vous devriez voir les différents chemins représentés dans les données :
terminé
solution de repli
terminé
transfert
Pour le dernier appel, la valeur de « handoff_reason » doit être « billing ».
Conclusion
Le tutoriel original de Vonage + Deepgram permet à un agent IA conversationnel de participer à un véritable appel téléphonique. Ici, nous avons confié à cet agent une tâche utile : rechercher une commande, cesser d’attendre lorsque le backend est trop lent, gérer proprement les échecs et transférer les demandes non prises en charge à un humain.
Le backend de gestion des commandes est simulé, mais le principe reste le même si vous remplacez la méthode `getOrderStatus()` par votre propre base de données ou API.
Voici comment l'idée de Vince, « ne pas mettre la latence en boucle », se traduit dans la pratique : pendant que l'appelant attend, limiter la charge de travail, fixer des délais pour les appels externes et prévoir une issue de secours utile.
Une fois l'appel terminé, ces contraintes de latence disparaissent. C'est là que les enregistrements que nous avons conservés, les transcriptions, les appels aux outils, les résultats, les motifs de transfert et les versions des agents prennent toute leur utilité.
Si, par exemple, les « retours » commencent à apparaître de manière récurrente comme motif de transfert, nous pouvons nous appuyer sur ces appels pour déterminer les fonctionnalités à développer ensuite, intégrer une fonctionnalité de gestion des retours dans une version candidate, puis la tester sur les appels enregistrés avant de la mettre à la disposition des utilisateurs.
C'est ainsi que commence un processus de conception de boucles. Mais ça, ce sera pour le prochain article !
Vous avez une question ou souhaitez partager ce que vous construisez ?
S'abonner à la Bulletin d'information du développeur
Suivez-nous sur X (anciennement Twitter) pour les mises à jour
Regardez les tutoriels sur notre chaîne YouTube
Connectez-vous avec nous sur la page Vonage Developer sur LinkedIn
Aidez-nous à améliorer l'expérience des développeurs en remplissant notre formulaire de commentaires « Voice of the Developer »
Restez connecté et tenez-vous au courant des dernières nouvelles, astuces et événements concernant les développeurs.
Partager:
Benjamin Aronov est un défenseur des développeurs chez Vonage. C'est un bâtisseur de communauté qui a fait ses preuves, avec une formation en Ruby on Rails. Benjamin apprécie les plages de Tel Aviv, où il vit. Sa base à Tel Aviv lui permet de rencontrer et d'apprendre de certains des meilleurs fondateurs de startups du monde. En dehors de la technologie, Benjamin aime voyager à travers le monde à la recherche du parfait pain au chocolat.