
banner_website
Os agentes de IA já não são apenas chatbots que respondem com base naquilo que “decoraram”. A grande mudança aconteceu quando os modelos de linguagem ganharam a capacidade de chamar funções: de sair para o mundo real, ir buscar dados atualizados e desencadear ações em nosso nome. Um modelo que só consegue falar sobre aquilo que já sabe está mais próximo de um autocomplete bem-educado do que de um verdadeiro agente. As coisas ficam interessantes quando ele passa a poder agir: consultar algo, verificar um estado real, fazer uma alteração por alguém e raciocinar sobre o que aprendeu pelo caminho. É isso que uma “tool” (ferramenta) lhe dá.
Este artigo mostra como construir exatamente isso, usando o AI SDK v7 da Vercel, através de um exemplo pequeno mas completo: um assistente de IA para uma aplicação web de biblioteca. 📚
Um utilizador pergunta por um livro, o agente pesquisa no catálogo, verifica se há mesmo um exemplar disponível e, se lhe for pedido, faz uma reserva. Três ferramentas, um fluxo natural, e só as peças suficientes para mostrar como um agente com “cara de produção” é realmente montado, sem nos perdermos em código repetitivo.
Antes de tocarmos em código, vale a pena ser precisos sobre o que estamos mesmo a construir, porque a expressão “agente de IA” é usada de forma bastante solta.
Um agente é um modelo de linguagem envolto num ciclo: o modelo lê a conversa, decide se precisa de mais informação ou de fazer algo, chama uma ferramenta se for o caso, lê o resultado dessa ferramenta e ou chama outra ferramenta ou responde ao utilizador. As “ferramentas” não são mais do que funções tipadas que nós escrevemos — o modelo não executa o nosso código diretamente, apenas produz um pedido para chamar uma ferramenta com determinado nome e argumentos, e é o nosso servidor que efetivamente a executa e devolve o resultado. Essa fronteira é muito importante para a segurança e o controlo, e vamos apoiar-nos nela diretamente na nossa terceira ferramenta.
Para o nosso assistente de biblioteca, três ferramentas cobrem uma interação completa e realista:
Há ainda outra decisão, e é importante: vamos partir do princípio de que o utilizador já está autenticado na aplicação da biblioteca quando chega ao agente. Vale também a pena esclarecer o que é, de facto, o library.com: um sistema separado, ao qual temos acesso através de uma API REST.
Dois projetos independentes, a falar um com o outro via HTTP, exatamente como foi pedido:
O modelo por trás de tudo isto é o Gemini 3.5 Flash, da Google, através do @ai-sdk/google — uma escolha sólida e rápida para um agente de chat que precisa de raciocinar sobre algumas chamadas de ferramentas por interação, sem grande latência. O input de cada ferramenta é descrito com um schema Zod, que tem uma dupla função: é como o AI SDK valida os argumentos que o modelo devolve, e é também como o modelo é informado sobre que tipo de argumentos sequer pode produzir.
“`
library-agent/
├── server/ # Fastify + AI SDK backend
└── client/ # React frontend
“`
Vais precisar de Node.js 18+ e de uma chave de API da Gemini, exportada como GOOGLE_GENERATIVE_AI_API_KEY.
Nada de estranho por aqui: um projeto Fastify com o AI SDK, o provider da Google e o Zod:
“`bash
mkdir server && cd server
npm init -y
npm i fastify ai @ai-sdk/google zod
npm i -D typescript tsx @types/node
“`

Há dois aspetos deste contrato mais importantes do que os nomes dos campos: cada pedido transporta um bearer token, e cada resposta é JSON que não controlamos — vem de um sistema mantido por outra equipa, e pode mudar de forma sem aviso prévio. Ambos os factos apontam na mesma direção: validar o que nos chega, e nunca inventar a nossa própria noção de “quem é o utilizador”.
Este é o coração do artigo, por isso vale a pena ser cuidadoso. Uma “tool” no AI SDK é criada com o helper tool(), e tem três partes: uma description em linguagem natural (é isto que o modelo lê para decidir quando recorrer a esta ferramenta — escreve-a como documentação, não como um comentário de código), um inputSchema construído com Zod (que restringe e valida como o modelo pode chamá-la), e uma função execute (o código que realmente corre no nosso servidor depois de a chamada do modelo ser validada).
Um pequeno helper partilhado trata de anexar o bearer token e verificar o status da resposta, para que cada ferramenta só tenha de descrever que endpoint contacta e que formato esperar de volta. E já que recorremos ao Zod para validar o input do modelo, faz sentido usá-lo também para validar a resposta do library.com. Não é uma API nossa, por isso não devemos confiar na forma da sua resposta mais do que confiaríamos na do modelo:


Com este helper implementado, cada ferramenta fica curta: construir o caminho, chamar o library.com, entregar a resposta em bruto ao schema Zod correspondente, e devolver o que sair do outro lado. O bookSearchResponseSchema.parse(…) e os seus equivalentes têm aqui uma dupla função: dão-nos um valor devidamente tipado, sem nenhum any à vista, e disparam um erro claro no momento em que o library.com devolve algo que não corresponde ao esperado — em vez de deixar uma incompatibilidade de formato tornar-se, silenciosamente, um bug três funções mais à frente.

Repara no que está ausente do inputSchema em todas estas ferramentas: nada sobre quem é o utilizador. O libraryAuthToken chega como parâmetro à própria buildLibraryTools e fica capturado no closure de cada função execute — tal como um objeto de utilizador fictício estaria numa versão anterior deste exemplo, exceto que agora já não é um substituto: é a credencial real de que o library.com precisa, e é o library.com, não nós, quem decide a quem pertence a reserva criada. O modelo nunca vê o token, nunca recebe um campo para o preencher, e não tem forma de fazer uma reserva na conta errada.
O handler da rota é curto, mas acontece bastante coisa em poucas linhas, por isso vale a pena percorrê-lo por partes em vez de o despejar de uma vez.
Primeiro, precisa do bearer token que o frontend está a reencaminhar, já que é isso que legitima cada chamada subsequente ao library.com. Vamos buscá-lo ao cabeçalho Authorization padrão, com uma verificação explícita — sem fallback silencioso, sem assumir que ele lá está:

Segundo, o corpo do pedido recebido contém a conversa até ao momento, como um array de UIMessage, o formato de mensagem do AI SDK pensado para o frontend. Essas mensagens são convertidas para o formato mais simples de “mensagem de modelo” com convertToModelMessages, antes de serem entregues a streamText.
Terceiro, streamText recebe o modelo, as mensagens convertidas, um prompt de system a descrever como o agente se deve comportar (incluindo uma instrução para confirmar antes de chamar a ferramenta que altera dados — cinto e suspensórios, a somar à descrição da própria ferramenta) e as três ferramentas que acabámos de construir, montadas de raiz com o token deste pedido específico.
Quarto — e é esta a parte que torna isto um verdadeiro chat, em vez de um pedido único: streamText não se limita a chamar uma ferramenta e parar. Corre um ciclo: o modelo responde, talvez chame uma ferramenta, o resultado volta para o modelo, o modelo responde de novo — até decidir que já tem o suficiente para dar ao utilizador uma resposta real. Todo esse ciclo acontece dentro do próprio streamText; não precisamos de o escrever.
Por fim, o resultado é transformado num stream de mensagens de UI e enviado ao cliente à medida que é gerado, para que o frontend possa apresentar os tokens (e as chamadas de ferramentas) assim que chegam, em vez de esperar pela resposta completa:

O lado React é um projeto standard, mais o pacote ai e o @ai-sdk/react para o hook de chat:

Por predefinição, useChat assume que a nossa API vive em /api/chat, na mesma origem que a página — o que faz sentido numa app Next.js em que frontend e backend são o mesmo deployment, mas não se aplica aqui, já que dividimos deliberadamente tudo em dois servidores. Para o redirecionar, entregamos-lhe um DefaultChatTransport com o URL completo do servidor Fastify.
É também aqui que a lacuna de CORS que deixámos por resolver se torna real: o cliente corre na porta de desenvolvimento do Vite e o servidor na 8080, o que para um browser são origens diferentes. Se experimentares assim, tal como está, o browser vai bloquear o pedido antes sequer de chegar ao Fastify. Consoante o que estiveres a planear, ou adicionas @fastify/cors ao servidor e permites a origem do cliente, ou colocas um proxy reverso partilhado à frente de ambos, para que pareçam a mesma origem — qual das opções faz mais sentido depende do teu deployment, por isso deixamos essa decisão em aberto, em vez de a fixar no exemplo.
É também aqui que a identidade “já autenticada” volta a entrar em cena, desta vez do lado do frontend, e de forma diferente de uma versão anterior deste exemplo. Como o nosso backend já não inventa a sua própria noção de utilizador, o transport só precisa de reencaminhar o mesmo bearer token que o browser já tem, vindo do login existente no library.com, como um cabeçalho Authorization normal. Onde é que esse token realmente vive — uma cookie, um auth store em memória, ou onde quer que a gestão de sessão já existente da tua app o guarde — está fora do âmbito deste artigo; aqui é apresentado como uma constante fictícia, só para manter a ligação visível:

useChat devolve um array de messages e uma função sendMessage, e isso já é, no fundo, a maior parte do que uma interface de chat precisa. A única coisa que vale a pena explicar é o message.parts: em vez de uma simples string, cada mensagem é dividida em partes tipadas à medida que chega em stream. O texto simples chega como partes do tipo text. Cada chamada de ferramenta e o respetivo resultado chegam como uma parte tool-<nomeDaFerramenta>, cujo campo state avança por um pequeno ciclo de vida (grosso modo: o modelo ainda está a decidir os argumentos, depois os argumentos ficam prontos, e por fim a ferramenta já correu e produziu um resultado). Mantemos aqui a apresentação deliberadamente simples — uma tag <pre> a mostrar o nome da ferramenta e o seu estado atual — para que o fluxo de dados se mantenha visível, em vez de se perder em estilos:

E é isto o frontend todo. Sem chamadas manuais a fetch, sem parser de streaming feito à mão — o useChat e o transport tratam do protocolo de comunicação, e ficamos apenas com aquilo que é, no fundo, uma lista com um formulário.
Com os dois servidores a correr, aqui fica uma troca realista, incluindo o que cada ferramenta efetivamente devolve, já que é essa a parte que costuma ficar de fora nas transcrições. Repara no que está ausente enquanto lês: o agente nunca pergunta quem está a falar, nunca vê um ID de utilizador ou um token em lado nenhum da conversa, e nunca tem hipótese de fazer uma reserva na conta errada — tudo isso é resolvido pelo próprio library.com, através da credencial que acompanha cada chamada de ferramenta, de forma invisível para o modelo.
> **Utilizador:** Têm alguma coisa do Tony Fadell?
O modelo não conhece o nosso catálogo, por isso recorre a findBook({ query: “Tony Fadell” }). Essa chamada sai para o library.com, passa pelo bookSearchResponseSchema, e a ferramenta devolve isto ao modelo:

O modelo transforma isto numa frase normal:
> **Agente:** Sim, encontrei o Build: An Unorthodox Guide to Making Things Worth Making, do Tony Fadell. É um guia sobre construir produtos, equipas e empresas, com base na experiência dele a criar o iPod e a Nest. Queres que verifique se há um exemplar disponível? 🔍
> **Utilizador:** Sim, por favor
Agora corre checkAvailability({ bookId: “bk_build_fadell” }), que devolve:

O modelo reporta o número real, em vez de adivinhar:
> **Agente:** Há 1 exemplar disponível, num total de 3. Queres que faça uma reserva?
> **Utilizador:** Força aí
Só agora, depois de um “sim” explícito — conforme tanto a descrição da ferramenta como o prompt de sistema — é que requestBook dispara, e o library.com devolve:

Que o modelo transmite assim:
> **Agente:** Feito, reservei o Build para ti. Vais ser notificado assim que estiver pronto para levantamento. ✅
A ideia central a reter é mais pequena do que o código faz parecer: dar ao modelo ferramentas estreitas e bem descritas; manter tudo o que é sensível fora daquilo que o modelo pode fornecer; e deixar o ciclo do streamText fazer o trabalho de decidir quando chamar o quê. Tudo o resto neste artigo é apenas uma forma concreta de pôr isso em prática. 🙂

Diogo Oliveira
Full Stack Developer