Três semanas atrás um cliente me ligou, um fundador de SaaS baseado em Edimburgo, e perguntou se eu poderia construir um "assistente inteligente" que pudesse responder perguntas sobre seu banco de dados de produtos, lembrar conversas passadas, e tomar ações como criar tickets de suporte. O orçamento era apertado, o prazo era ainda mais apertado. Eu estava querendo investigar adequadamente o Vercel AI SDK há um tempo, e isso pareceu o momento.
O que se seguiu foram três dias de construção real: algumas partes elegantes, alguns erros embaraçosos, muito código-fonte para ler. Este post é o tutorial funcionando que eu gostaria de ter tido no início.
---
O Que Estamos Realmente Construindo
Um agente de IA. Não um chatbot. A distinção importa mais do que as pessoas pensam.
Um chatbot recebe entrada e produz saída. Um agente faz isso e decide quais ferramentas chamar, em que ordem, e pode voltar a si mesmo quando algo falha. Ele tem memória entre sessões. Ele pode agir, não apenas responder.
Nosso agente irá:
- Receber perguntas em linguagem natural dos usuários
- Consultar um banco de dados Supabase Postgres usando chamadas de ferramentas
- Lembrar histórico de conversa entre sessões (persistido no Supabase)
- Retornar respostas estruturadas e fundamentadas
A stack é Next.js (App Router), Vercel AI SDK, Supabase para o banco de dados e autenticação, e gpt-4o da OpenAI como modelo. Você poderia trocar OpenAI por Anthropic ou Mistral com cerca de dez linhas de mudança, o SDK abstrai o provedor de forma limpa.
---
Configuração do Projeto
Comece com um projeto Next.js fresh.
`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``
Instale apenas as dependências que você realmente precisa:
`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``
Zod faz validação de schema nas entradas das suas ferramentas. Sem ele você está confiando que o modelo passe argumentos sensatos, o que normalmente acontece até que não acontece.
Defina suas variáveis de ambiente em .env.local:
`` OPENAI_API_KEY=sk-... NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_SERVICE_ROLE_KEY=eyJ... ``
Use a chave de serviço somente no servidor. A chave anon é ok para fluxos de autenticação no cliente. Nunca misture essas duas. (Eu misturei, em um ambiente de staging em 2022, e brevemente dei a cada usuário acesso de leitura com nível de admin ao CRM de um cliente. Não foi uma tarde de sexta-feira divertida.)
---
Configurando o Supabase
Você precisa de duas coisas do Supabase: uma tabela para seus dados reais, e uma tabela para a memória de conversa.
A Tabela de Dados
Para este tutorial, digamos que você está construindo sobre um catálogo de produtos. Execute isto no editor SQL do Supabase:
```sql create table products ( id uuid primary key default gen_random_uuid(), name text not null, description text, price_gbp numeric(10, 2), category text, in_stock boolean default true, created_at timestamptz default now() ); ```
Popule-a com 20-30 linhas. Dados realistas melhoram significativamente os testes, e eu sempre uso Mockaroo para isto porque gera valores específicos do domínio em vez de "string1, string2" sem sentido.
A Tabela de Memória
É aqui que o histórico de conversas fica entre sessões.
```sql create table conversation_messages ( id uuid primary key default gen_random_uuid(), session_id text not null, role text not null check (role in ('user', 'assistant', 'tool')), content jsonb not null, created_at timestamptz default now() ); create index on conversation_messages (session_id, created_at); ```
create index on conversation_messages (session_id, created_at); ```
O tipo jsonb em content é intencional. O SDK de IA passa o conteúdo da mensagem como objetos estruturados (partes de texto, partes de chamada de ferramenta, partes de resultado de ferramenta), não strings simples. Armazená-lo como texto e depois tentar fazer parse dele novamente é uma dor de cabeça que você não precisa.
---
A Rota Principal do Agent
Crie app/api/agent/route.ts. É aqui que a lógica real fica.
```typescript import { openai } from '@ai-sdk/openai'; import { streamText, tool } from 'ai'; import { createClient } from '@supabase/supabase-js'; import { z } from 'zod';
const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! );
export async function POST(req: Request) { const { messages, sessionId } = await req.json();
// Carregue o histórico do Supabase const { data: history } = await supabase .from('conversation_messages') .select('role, content') .eq('session_id', sessionId) .order('created_at', { ascending: true }) .limit(40);
const priorMessages = (history ?? []).map((row) => ({ role: row.role, content: row.content, }));
const allMessages = [...priorMessages...messages];
const result = await streamText({ model: openai('gpt-4o'), system: You are a helpful product assistant. You have access to a product database. Always use the queryProducts tool when the user asks about products, pricing, or availability. Never guess at product details. If the tool returns no results, say so plainly., messages: allMessages, tools: { queryProducts: tool({ description: 'Query the product catalogue by category, name, or stock status.', parameters: z.object({ category: z.string().optional().describe('Product category to filter by'), searchTerm: z.string().optional().describe('Name or keyword to search'), inStockOnly: z.boolean().optional().describe('Filter to in-stock products only'), }), execute: async ({ category, searchTerm, inStockOnly }) => { let query = supabase.from('products').select('*');
if (category) query = query.eq('category', category); if (inStockOnly) query = query.eq('in_stock', true); if (searchTerm) query = query.ilike('name', %${searchTerm}%);
const { data, error } = await query.limit(10);
if (error) return { error: error.message }; return { products: data ?? [] }; }, }), }, maxSteps: 5, onFinish: async ({ response }) => { // Persist the new messages const newMessages = response.messages.map((msg) => ({ session_id: sessionId, role: msg.role, content: msg.content, }));
await supabase.from('conversation_messages').insert(newMessages); }, });
return result.toDataStreamResponse(); } ```
Alguns pontos vale a pena destacar aqui.
maxSteps: 5 é o loop do agente. O SDK continuará chamando ferramentas e alimentando resultados de volta para o modelo, até cinco vezes, antes de forçar uma resposta final. Defina isso muito baixo e o agente desiste no meio da tarefa. Defina muito alto e um modelo confuso pode gerar custos de API rapidamente. Cinco é um padrão sensato para a maioria das tarefas.
O callback onFinish é onde você persiste a memória. Não tente salvar mensagens antes do stream ser concluído, você não terá os pares completos de chamada de ferramenta/resultado ainda.
---
Construindo o Frontend
Mantenha isso simples. Um hook useChat do SDK de IA faz quase todo o trabalho pesado.
```typescript // app/page.tsx 'use client';
import { useChat } from 'ai/react'; import { useState } from 'react';
export default function AgentPage() { const [sessionId] = useState(() => crypto.randomUUID());
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/agent', body: { sessionId }, });
return ( <div className="max-w-2xl mx-auto p-6"> <div className="space-y-4 mb-6"> {messages.map((m) => ( <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}> <span className="inline-block bg-gray-100 rounded px-3 py-2 text-sm"> {typeof m.content === 'string' ? m.content : '[tool interaction]'} </span> </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input value={input} onChange={handleInputChange} className="flex-1 border rounded px-3 py-2 text-sm" placeholder="Pergunte sobre produtos..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> Enviar </button> </form> </div> ); } ```
O sessionId é gerado uma vez por montagem da página. Em uma app real, vincule isso ao ID do seu usuário autenticado ou a um cookie de sessão persistido. Caso contrário, cada atualização de página é um apagamento de memória limpo.
---
Tornando o Agente Realmente Útil
Aqui está a coisa: um agente básico que consulta um banco de dados é uma demo. Um agente útil lida com casos extremos. Especificamente:
Erros de Ferramentas Com Elegância
Se sua função execute lançar uma exceção, o SDK a captura e passa a string de erro de volta ao modelo. O modelo normalmente tenta explicar o erro ao usuário, o que está tudo bem. Mas você quer que suas funções execute retornem erros como dados em vez de lançá-los; o modelo lida melhor com objetos de erro retornados do que com exceções capturadas, pela minha experiência.
Fundamentação e Alucinação
O prompt do sistema é extremamente importante. "Nunca adivinhe detalhes de produtos" não é preenchimento, sem essa instrução, o gpt-4o ocasionalmente fabrica especificações de produtos quando uma query retorna nada. Juro, testei isso sem a proteção, perguntei sobre um produto que não existia, e o modelo inventou um preço e descrição plausíveis. Impressionante e completamente inútil.
Controlando o Comprimento do Histórico
Carregar 40 mensagens do histórico (o .limit(40) acima) é um limite razoável. Além disso você está queimando tokens em contexto antigo que raramente ajuda. Para agentes de longa duração, considere sumarização: a cada 30 mensagens, peça ao modelo para sumarizar a conversa até agora e armazene isso como uma única mensagem "summary".
A documentação do Vercel AI SDK sobre chamadas de ferramenta em múltiplas etapas entra em mais profundidade sobre o formato de mensagem se você quiser se aprofundar em como as partes de resultado de ferramenta são estruturadas.
---
Implantando no Vercel
Assumindo que você está usando o App Router, isso é genuinamente simples.
- Faça push para o GitHub.
- Importe o repositório no painel do Vercel.
- Adicione suas variáveis de ambiente nas configurações do projeto.
- Faça o deploy.
O detalhe importante: respostas com streaming requerem um runtime que suporte Web Streams. O App Router no Vercel lida com isso nativamente. Se você estiver no Pages Router com uma API estilo Express antiga, precisará configurar as coisas de forma diferente, honestamente, é só usar o App Router.
Vale a pena verificar também o connection pooling do Supabase. Projetos Supabase no tier gratuito têm um limite de conexões em torno de 60. Se você estiver acionando o banco de dados a cada requisição do agent com múltiplas queries, pode saturar isso mais rápido do que esperaria sob carga. Use o connection pooler do Supabase (PgBouncer) em transaction mode para deployments em produção.
---
O que Eu Faria Diferente da Próxima Vez
Quando fiz o deploy disso para o cliente de Edimburgo, algumas coisas me causaram problemas:
- Inicialmente armazenei o conteúdo das mensagens como text no Supabase, não jsonb. Reconstruir mensagens de ferramentas estruturadas a partir de uma string foi genuinamente doloroso e perdi a maior parte de uma tarde com isso.
- Não adicionei rate limiting na rota. A equipe do cliente imediatamente começou a martelar com queries longas e complexas durante o UAT. Adicione algo como Upstash Rate Limit antes de entregar qualquer coisa para humanos.
- Defini maxSteps como 10 nos testes iniciais. Em uma query mal formulada, o modelo passou por seis chamadas de ferramentas antes de concluir que o produto não existia. São seis round-trips de banco de dados e muitos tokens. Cinco é quase sempre suficiente.
A arquitetura principal, porém, funcionou bem. O Vercel AI SDK me poupou de escrever meu próprio parser de streaming e máquina de estado de chamadas de ferramentas, o que sozinho já valeu a dependência.
---
FAQ
Quais modelos funcionam com o Vercel AI SDK além do OpenAI?
O SDK suporta Anthropic (Claude 3.5 Sonnet e outros), Google (Gemini), Mistral, Cohere e mais via pacotes de provider como @ai-sdk/anthropic. Trocar providers é genuinamente apenas uma linha na maioria dos casos, substitua openai('gpt-4o') por anthropic('claude-3-5-sonnet-20241022') e o streaming, tool-calling e os hooks useChat funcionam de forma idêntica. Alguns providers têm particularidades em relação ao suporte de tool-calling, então consulte a tabela de compatibilidade do SDK antes de se comprometer.
O agent pode escrever no Supabase, não apenas ler?
Sim, e é aqui que agents ficam interessantes e perigosos em igual medida. Você pode escrever uma ferramenta createSupportTicket ou updateProductStock da mesma forma que escreveu queryProducts. A função execute apenas executa um insert ou update em vez de um select. Recomendo fortemente políticas de segurança em nível de linha em qualquer tabela que o agent possa escrever, e mantenha operações destrutivas (delete) completamente fora do conjunto de ferramentas, a menos que você tenha uma etapa de confirmação na sua UI.
Como faço autenticação para que usuários vejam apenas seus próprios dados?
A abordagem mais limpa: gere o cliente Supabase dentro do route handler usando o JWT do usuário (de um cookie ou header Authorization) em vez da chave service role. Dessa forma, as políticas de segurança em nível de linha do Supabase se aplicam automaticamente. O pacote @supabase/ssr tem helpers para extrair a sessão dos cookies do Next.js. Não passe IDs de usuário como parâmetros simples para o agent, o modelo pode ser enganado para consultar os dados de outro usuário se o system prompt não for impecável.
O Vercel AI SDK está pronto para produção?
Eu diria que está pronto para produção com ressalvas. É mantido ativamente, a Vercel lança features em ritmo acelerado, e as primitivas core de streaming e tool-calling são estáveis. As partes que se movem mais são as features experimentais (como generateObject com schemas de union complexos). Fixe sua versão e leia o changelog antes de fazer upgrade. A Seahawk tem dois projetos client ao vivo rodando nele agora sem problemas, mas a gente faz version-pinning agressivo.
Por que Supabase especificamente e não Postgres no Railway ou PlanetScale?
Supabase te dá Postgres mais um cliente tipado, auth, real-time, storage e edge functions tudo sob o mesmo teto. Para um projeto como esse, a integração de auth e o editor SQL para iteração rápida economizam tempo de verdade. Dito isso, o código do agent funciona com qualquer banco de dados compatível com Postgres. Troque o cliente Supabase por pg ou Drizzle ORM e nada estrutural muda.
---
Honestamente, essa stack me surpreendeu com a velocidade de sair do zero para um agent funcional e com persistência de memória. O Vercel AI SDK faz muito trabalho invisível: protocolo de streaming, serialização de tool calls, abstração de provider. Supabase lida com persistência e auth sem pedir muito de você. A complexidade restante, que é a parte que ninguém consegue abstrair para você, é escrever um system prompt que faça o modelo realmente se comportar. Essa parte exige iteração. Comece rigoroso, solte gradualmente, e teste com as perguntas mais mal formuladas que você conseguir imaginar.
Leitura relacionada: Pesquisa de palavras-chave com IA em 2026: o que é, por que é tradicional, SEO técnico e busca com IA.
