
Compartilhar:
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.
Adicionar ferramentas e transferência para atendimento humano a um agente de voz da Vonage + Deepgram
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
A Account no Deepgram com acesso ao Voice Agent
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=15557654321Você 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 devVocê 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?
Inscreva-se no Boletim Informativo para Desenvolvedores
Siga-nos no X (antigo Twitter) para ficar por dentro das novidades
Assista aos tutoriais no nosso canal do YouTube
Conecte-se conosco na página de desenvolvedores da Vonage no LinkedIn
Fique conectado e acompanhe as últimas notícias, dicas e eventos para desenvolvedores.
Compartilhar:
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.