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

Adicionar ferramentas e transferência para atendimento humano a um agente de voz da Vonage + Deepgram

Publicado em September 22, 2026

Tempo de leitura: 9 minutos

Introdução

O Voice API da Vonage e o guia da Deepgram leva você surpreendentemente longe. 

Ao final deste guia, você poderá ligar para um número da Vonage, falar com um agente de IA, interrompê-lo enquanto ele estiver falando e ter uma conversa natural por voz, com tecnologia da API API do Voice Agent da Deepgram. 

Mas o agente ainda não pode fazer muita coisa por você (ele não tem ferramentas!). 

Peça ao agente para realizar uma ação e, a menos que as informações sobre essa ação estejam no prompt, ele não terá onde procurá-las. Atribua a ele uma tarefa que dependa de outro sistema e, nesse caso, você terá outro problema: o usuário que fez a chamada fica esperando enquanto esse sistema responde. 

A Voice torna esses detalhes perceptíveis muito rapidamente. Uma chamada de API lenta não se parece com um indicador de carregamento, mas soa como silêncio total. 

Esse problema específico de áudio para agentes de IA foi apresentado em “Não repita a latência: onde os loops de agentes se encaixam na IA de voz”. Uma das ideias apresentadas nesse post é manter o trabalho realizado durante uma ligação telefônica ao vivo mínimo e previsível. 

O objetivo desta postagem é transformar a demonstração conversacional em um agente capaz de realizar tarefas úteis, apresentar falhas previsíveis e — o mais importante — deixar informações suficientes para que possa ser aprimorado posteriormente. Neste tutorial, vamos ampliar o agente existente da Vonage + Deepgram para que ele possa: 

  • fornecer ao agente uma ferramenta para verificar o status de um pedido 

  • definir um tempo limite rígido para a consulta 

  • lidar com falhas sem deixar o chamador em espera 

  • encaminhar as solicitações de transferência que o agente não puder atender para um número de telefone real 

  • manter um registro do que ocorreu durante cada ligação 

Resumo O guia de início rápido está disponível no GitHub.   

Pré-requisitos

Você vai precisar de: 

  • Um aplicativo da Vonage com a função de voz ativada 

  • Um número de telefone da Vonage vinculado a esse aplicativo 

  • Um segundo número de telefone que você pode usar para entrar em contato com o suporte humano 

Dê uma ferramenta ao agente

Vamos manter o caso de uso deliberadamente simples: verificar o status de um pedido. O solicitante diz: 

“Onde está o pedido A1001?” 

O Deepgram reconhece que o agente precisa de informações externas e solicita que nosso aplicativo chame: 

getOrderStatus("A1001") 

Nosso aplicativo executa a consulta e envia o resultado de volta ao Deepgram. Em seguida, o modelo transforma esse resultado em uma resposta falada de forma natural. 

Neste tutorial, o backend de pedidos foi simulado. Você poderá substituí-lo por seu próprio banco de dados ou API mais tarde; precisamos apenas de uma consulta que possa ser bem-sucedida, falhar ou demorar muito:

// 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" 
  } 
};

Há também alguns IDs de pedidos especiais para testes: 

  • Os IDs que começam com SLOW levam dois segundos para serem retornados. 

  • Os IDs que começam com FAIL simulam uma falha temporária na rede. 

  • Qualquer outra coisa retorna “not_found”. 

Isso nos permite reproduzir os tipos de comportamento que, eventualmente, obteríamos de um banco de dados real ou de uma API externa, sem precisar criar um backend completo para este tutorial. 

Registrar a ferramenta no Deepgram

A Voice API do Voice Agent da Deepgram oferece suporte a a chamada de funções no lado do cliente. Descrevemos a função na mensagem “Configurações” do 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"] 
  } 
};

Não fornecemos ao Deepgram um endpoint HTTP para a função. Em vez disso, o Deepgram envia um FunctionCallRequest ao nosso WebSocket. Isso significa que nosso aplicativo mantém o controle sobre a execução efetiva da ferramenta. 

Isso é útil porque podemos decidir: 

  • se a ferramenta é permitida 

  • por quanto tempo ele pode funcionar 

  • se uma falha deve ser tentada novamente 

  • o que a pessoa que está ligando ouve caso não funcione 

O escopo do prompt do agente também permanece restrito:

// 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.

O prompt indica ao modelo o que fazer, mas não dependemos apenas dele. Antes de executar uma consulta de pedido, o aplicativo verifica se a solicitação está dentro do escopo e se a ferramenta ainda não foi utilizada nessa chamada. Você pode ver como isso funciona na seção “Tratamento da chamada de função”. 

Estabeleça um prazo para a pesquisa

É aí que a programação para uma chamada telefônica começa a se diferenciar da programação de um chatbot comum. Imagine que o serviço de pedidos demore seis segundos para responder. Do ponto de vista do servidor, isso é apenas uma solicitação lenta. 

Do ponto de vista da pessoa que ligou, ela fez uma pergunta e, em seguida, ouviu seis segundos de silêncio. 

Portanto, neste exemplo, a consulta leva 1.500 milissegundos:

// src/agent/tool-policy.ts 
export const TOOL_TIMEOUT_MS = 1500; 

O wrapper executa a chamada ao backend dentro desse prazo:

// 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); 
  } 
}

O importante é que há três resultados possíveis: 

Resultado 

O que isso significa 

O que fazemos 

resultado 

O backend respondeu 

Retornar o resultado 

tempo limite 

Não respondeu em 1.500 ms 

Use a opção alternativa 

erro_de_transporte 

A conexão falhou 

Tente novamente uma vez 

Uma resposta “not_found” ainda é considerada uma consulta bem-sucedida. O backend respondeu; só não encontrou o pedido. 

Isso é diferente de um tempo técnico. 

Limite o que pode acontecer durante a ligação

Neste exemplo, as regras são intencionalmente 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 chamada telefônica recebe sua própria instância de política:

// 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; 
    } 
  }; 
}

Uma interrupção por tempo limite não é repetida. 

Já esperamos 1,5 segundo. Iniciar novamente a mesma solicitação lenta poderia simplesmente nos render mais 1,5 segundo de silêncio. 

Uma falha de transporte tem direito a uma nova tentativa, pois uma conexão interrompida ou um problema temporário no lado do remetente pode ser resolvido imediatamente. 

Tratar a chamada de função

Quando o Deepgram envia uma FunctionCallRequest, o manipulador do WebSocket aplica essas regras antes de executar a ferramenta. 

O fluxo principal é:

// 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); 
}

O manipulador propriamente dito também registra a chamada da ferramenta e o tempo, mas essa é a parte que controla o que o chamador percebe. 

Use um plano alternativo fixo

Se a consulta falhar, você não quer que o modelo crie seu próprio plano de recuperação. 

O aplicativo insere uma resposta fixa:

// 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.";

O InjectAgentMessage do Deepgram nos permite inserir essa resposta diretamente na conversa:

// src/voice/websocket-handler.ts 
sendJson(dg, { 
  type: "InjectAgentMessage", 
  message: FALLBACK_RESPONSE, 
  behavior: "interrupt" 
}); 

O comportamento da interrupção é importante neste caso. 

O uso do comportamento padrão pode fazer com que uma mensagem inserida seja rejeitada enquanto o chamador estiver falando. Isso não é algo que você queira descobrir quando a mensagem inserida for sua rota alternativa em caso de falha. 

Observe também o nome do campo: é “mensagem”, e não “conteúdo”. 

Ambos são pequenos detalhes que, de outra forma, podem fazer com que a ligação fique em silêncio, sem que haja praticamente nenhuma indicação do motivo. 

Encaminhar solicitações não atendidas pelo sistema para um atendente humano

Um atendente responsável pelo status dos pedidos não deve tentar responder a tudo. 

Se alguém disser: 

“Quero contestar uma cobrança.” 

Classificamos isso como uma solicitação de cobrança:

// src/agent/tool-policy.ts 

export type HandoffReason = 
  | "billing" 
  | "returns" 
  | "cancellation" 
  | "account" 
  | "unsupported"; 

Nesta demonstração, a classificação é deliberadamente restrita e determinística:

// 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"]] 
];

Aqui não há uma segunda convocação de modelos. 

Se o interlocutor solicitar algo que esteja fora das atribuições do atendente, sabemos que queremos o mesmo resultado sempre: encaminhar a ligação para outra pessoa.

// 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)}); 
  }); 
} 

O método `transferToHuman()` atualiza a chamada ativa da Vonage com um novo NCCO. 

O NCCO primeiro informa ao autor da chamada o que está acontecendo e, em seguida, transfere a ligação para o número de suporte:

// 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 
      } 
    ] 
  } 
];

Em seguida, transferimos a chamada ativa:

// 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 testes locais, SUPPORT_PHONE_NUMBER pode ser simplesmente outro telefone que você tenha por perto. 

Ligue para o seu número da Vonage de um telefone, peça para contestar uma cobrança, e o outro telefone deve tocar. 

Essa é uma alternativa muito melhor do que fazer com que a IA finja que pode resolver um problema de cobrança ao qual nunca teve acesso.

Configurar a transferência

Para que a transferência funcione corretamente, adicione estes valores ao seu ambiente:

# .env.example 

VONAGE_APP_ID=your_application_id 
VONAGE_PRIVATE_KEY_PATH=./vonage_private.key 
VONAGE_NUMBER=15551234567 
SUPPORT_PHONE_NUMBER=15557654321

 Você também precisará da configuração atual:

# .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 

Utilize números no formato E.164 para os números da Vonage e de suporte. 

Salvar o que aconteceu durante a ligação

O aplicativo também grava um pequeno registro no SQLite quando a chamada é encerrada. 

Este não é o banco de dados de pedidos. É um registro da própria interação por 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 
);

Um registro pode nos informar: 

  • o que o interlocutor e o atendente disseram 

  • qual ferramenta foi chamada 

  • quanto tempo levou 

  • se o plano alternativo foi utilizado 

  • se a chamada foi transferida 

  • por que a transferência aconteceu 

  • como a ligação terminou 

Por exemplo, uma ligação para cobrança poderia ficar mais ou menos assim:

{ 
  "agentVersion": "order-status-v1", 
  "fallbackUsed": false, 
  "handoffRequested": true, 
  "handoffReason": "billing", 
  "outcome": "handoff" 
} 

Isso é útil mesmo que você nunca crie um sistema de avaliação complexo com base nisso. 

Se as chamadas continuarem sendo transferidas pelo mesmo motivo, você poderá perceber isso. Se os tempos de espera começarem a aumentar, você também poderá perceber. E se você alterar o prompt, o modelo ou as ferramentas posteriormente, o campo `agent_version` oferece uma maneira simples de identificar qual versão atendeu a quais chamadas. 

Como executar o agente

Inicie o ngrok primeiro:

ngrok http 3000 

Insira essa URL HTTPS em BASE_URL e nos webhooks “Answer” e “Event” do seu aplicativo Vonage. 

Em seguida, inicie o aplicativo:

npm run dev

Você pode verificar se o webhook de resposta está correto antes de fazer uma ligação:

curl "https://YOUR-NGROK-URL/answer?uuid=test123&from=15551234567" 

O NCCO retornado deve conter um endpoint WebSocket semelhante a: 

wss://YOUR-NGROK-URL/socket?callUuid=test123 

Experimente fazer quatro ligações

Tente testar os principais comportamentos como chamadas telefônicas separadas. 

Ligar 

O que dizer 

O que deveria acontecer 

1 

“Onde está o pedido A1001?” 

O agente confirma o status da entrega 

2 

“Onde está o pedido SLOW999?” 

A consulta expira e o conteúdo alternativo predefinido é reproduzido 

3 

“Onde está o pedido XYZ123?” 

O agente informa que o pedido não foi encontrado 

4 

“Quero contestar uma cobrança.” 

A chamada é transferida para o seu telefone de suporte 

Faça uma chamada separada para cada um. Neste exemplo, é permitida uma consulta de pedido por chamada. 

Em seguida, verifique os 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;" 

Você deve observar os diferentes caminhos representados nos dados: 

concluído 
alternativa 
concluído 
transferência 

Para a última chamada, o campo handoff_reason deve ser “faturamento”. 

Conclusão

O tutorial original do Vonage + Deepgram mostra como integrar um agente de IA conversacional a uma chamada telefônica real. Aqui, atribuímos a esse agente uma tarefa útil: consultar um pedido, interromper a espera quando o backend estiver muito lento, fazer uma transição suave quando algo der errado e transferir solicitações não suportadas para um atendente humano. 

O backend de pedidos está simulado, mas o padrão é o mesmo se você substituir getOrderStatus() pelo seu próprio banco de dados ou API. 

Essa é a ideia do Vince de “não entrar em um loop de latência” na prática: enquanto o chamador está aguardando, mantenha o trabalho em pequenas tarefas, estabeleça prazos para chamadas externas e ofereça uma rota de saída útil. 

Após a chamada, essas restrições de latência desaparecem. É aí que os registros que salvamos — transcrições, chamadas de ferramentas, resultados, motivos de transferência e versões dos agentes — se tornam úteis. 

Se, por exemplo, as devoluções começarem a aparecer repetidamente como motivo de transferência de chamada, podemos usar essas chamadas para decidir o que desenvolver em seguida, adicionar um recurso de devoluções a uma versão candidata e testá-lo com base nas chamadas armazenadas antes de disponibilizá-lo aos usuários. 

Esse é o início de um fluxo de trabalho de engenharia de loops. Mas isso fica para o próximo post!

Tem alguma dúvida ou quer compartilhar o que está criando?

Fique conectado e acompanhe as últimas notícias, dicas e eventos para desenvolvedores.

Compartilhar:

https://a.storyblok.com/f/270183/384x384/e4e7d1452e/benjamin-aronov.png
Benjamin AronovDeveloper Advocate

Benjamin Aronov is a developer advocate at Vonage. He is a proven community builder with a background in Ruby on Rails. Benjamin enjoys the beaches of Tel Aviv which he calls home. His Tel Aviv base allows him to meet and learn from some of the world's best startup founders. Outside of tech, Benjamin loves traveling the world in search of the perfect pain au chocolat.