https://a.storyblok.com/f/270183/1368x665/72cf89851e/26sep-tool_and_human_transfer_vonage_deepgram_agent-blog_r2.jpg

Ajouter des outils et le transfert vers un interlocuteur humain à un agent vocal Vonage + Deepgram

Publié le September 22, 2026

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 

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=15557654321

 Vous 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 dev

Vous 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 ?

Restez connecté et tenez-vous au courant des dernières nouvelles, astuces et événements concernant les développeurs.

Partager:

https://a.storyblok.com/f/270183/384x384/e4e7d1452e/benjamin-aronov.png
Benjamin AronovDéfenseur des développeurs

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.