< BACK Conmutador telefónico vintage con cables de cobre enredados iluminados por una única bombilla cálida arriba, fotografiado en película de 35 mm

Construir un Agente de IA con Vercel AI SDK y Supabase

Hace tres semanas un cliente me llamó, un fundador de SaaS con base en Edimburgo, y me preguntó si podía construirle un "asistente inteligente" que pudiera responder preguntas sobre su base de datos de productos, recordar conversaciones pasadas, y tomar acciones como crear tickets de soporte. El presupuesto era apretado, la fecha límite era más apretada aún. Llevaba un tiempo queriendo investigar a fondo el Vercel AI SDK, y esto se sintió como el momento.

Lo que siguió fueron tres días de construcción real: algunas partes elegantes, algunos errores vergonzosos, mucha lectura de código fuente. Este post es el tutorial funcional que hubiera querido tener al inicio.

---

Qué Estamos Construyendo Realmente

Un agente de IA. No un chatbot. La distinción importa más de lo que la gente piensa.

Un chatbot toma entrada y produce salida. Un agente hace eso y decide qué herramientas llamar, en qué orden, y puede volver sobre sí mismo cuando algo falla. Tiene memoria entre sesiones. Puede actuar, no solo responder.

Nuestro agente:

  • Toma preguntas en lenguaje natural de los usuarios
  • Consulta una base de datos Supabase Postgres usando llamadas a herramientas
  • Recuerda el historial de conversación entre sesiones (persistido en Supabase)
  • Devuelve respuestas estructuradas y fundamentadas

El stack es Next.js (App Router), Vercel AI SDK, Supabase tanto para la base de datos como para autenticación, y gpt-4o de OpenAI como modelo. Podrías intercambiar OpenAI por Anthropic o Mistral con aproximadamente diez líneas de cambio; el SDK abstrae el proveedor limpiamente.

---

Configuración del Proyecto

Comienza con un proyecto Next.js nuevo.

`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``

Instala las dependencias que realmente necesitas:

`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``

Zod valida esquemas en tus inputs de herramientas. Sin él, estás confiando en que el modelo pase argumentos sensatos, lo que generalmente hace hasta que no lo hace.

Establece tus variables de entorno en .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... ``

Usa la service role key solo del lado del servidor. La anon key está bien para flujos de autenticación del cliente. Nunca confundas estas. (Yo lo hice, en un ambiente de staging en 2022, y brevemente le di a cada usuario acceso de lectura a nivel admin a la CRM de un cliente. No fue una tarde de viernes divertida.)

---

Configurando Supabase

Necesitas dos cosas de Supabase: una tabla para tus datos reales, y una tabla para la memoria de conversación.

La Tabla de Datos

Para este tutorial, digamos que estás construyendo sobre un catálogo de productos. Ejecuta esto en el editor SQL de 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() ); ```

Poblalo con 20-30 filas. Los datos realistas hacen que las pruebas sean significativamente mejores, siempre uso Mockaroo para esto porque genera valores específicos del dominio en lugar de la tontería de "string1, string2".

La Tabla de Memoria

Aquí es donde vive el historial de conversaciones entre sesiones.

```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); ```

El tipo jsonb en content es intencional. El AI SDK pasa el contenido del mensaje como objetos estructurados (partes de texto, partes de llamadas de herramientas, partes de resultados de herramientas), no cadenas simples. Almacenarlo como texto e intentar analizarlo de vuelta es una molestia que no necesitas.

---

La Ruta del Agente Principal

Crea app/api/agent/route.ts. Aquí es donde vive la lógica real.

```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();

// Carga el historial desde 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 }) => { // Persistir los nuevos mensajes 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(); } ```

Hay algunos puntos que vale la pena destacar aquí.

maxSteps: 5 es el bucle del agente. El SDK seguirá llamando herramientas y alimentando resultados al modelo, hasta cinco veces, antes de forzar una respuesta final. Si lo estableces demasiado bajo, el agente se rinde a mitad de la tarea. Si lo estableces demasiado alto, un modelo confundido puede acumular costos de API rápidamente. Cinco es un valor predeterminado sensato para la mayoría de las tareas.

El callback onFinish es donde persistes la memoria. No intentes guardar mensajes antes de que el flujo se complete, aún no tendrás los pares completos de llamada de herramienta/resultado.

---

Construyendo el Frontend

Mantén esto simple. Un hook useChat del SDK de AI hace casi todo el trabajo 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="Ask about products..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> Send </button> </form> </div> ); } ```

El sessionId se genera una sola vez cuando la página se carga. En una aplicación real, vinculalo al ID del usuario autenticado o a una cookie de sesión persistente. De lo contrario, cada actualización de página borra la memoria.

---

Hacer que el Agente Sea Realmente Útil

Esto es importante: un agente básico que consulta una base de datos es una demostración. Un agente útil maneja casos especiales. Específicamente:

Errores de Herramientas con Elegancia

Si tu función execute lanza una excepción, el SDK la captura y devuelve el mensaje de error al modelo. Normalmente el modelo intentará explicar el error al usuario, lo cual está bien. Pero prefieres que tus funciones execute devuelvan errores como datos en lugar de lanzar excepciones; el modelo maneja mejor los objetos de error devueltos que las excepciones capturadas, en mi experiencia.

Anclaje y Alucinación

El system prompt es enormemente importante. "Nunca adivines detalles del producto" no es relleno; sin esa instrucción, gpt-4o ocasionalmente fabricará especificaciones de producto cuando una consulta no devuelve nada. Te lo juro, lo probé sin el control, pregunté por un producto que no existía, y el modelo inventó un precio y una descripción que sonaban plausibles. Impresionante y completamente inútil.

Controlando la Longitud del Historial

Cargar 40 mensajes del historial (el .limit(40) arriba) es un techo razonable. Más allá de eso estás gastando tokens en contexto antiguo que raramente ayuda. Para agentes de larga duración, considera la sumarización: cada 30 mensajes, pídele al modelo que resuma la conversación hasta ese momento y almacena eso como un único mensaje "resumen".

La documentación del Vercel AI SDK sobre llamadas de herramientas multietapa profundiza más en el formato de mensaje si quieres investigar cómo se estructuran las partes de resultado de herramienta.

---

Desplegando en Vercel

Asumiendo que usas el App Router, esto es genuinamente simple.

  1. Haz push a GitHub.
  2. Importa el repositorio en el panel de Vercel.
  3. Añade tus variables de entorno en la configuración del proyecto.
  4. Despliega.

El único problema: las respuestas en streaming requieren un runtime que soporte Web Streams. El App Router en Vercel lo maneja nativamente. Si estás usando Pages Router con una API antigua estilo Express, necesitarás configurar las cosas de forma diferente; honestamente, solo usa el App Router.

También vale la pena revisar el connection pooling de Supabase. Los proyectos de Supabase en el plan gratuito tienen un límite de conexiones alrededor de 60. Si estás accediendo a la base de datos en cada solicitud del agente con múltiples consultas, podrías saturar ese límite más rápido de lo esperado bajo carga. Usa el connection pooler de Supabase (PgBouncer) en modo transacción para despliegues en producción.

---

Qué haría diferente la próxima vez

Cuando desplegué esto para el cliente de Edimburgo, algunas cosas me causaron problemas:

  • Inicialmente almacené el contenido de los mensajes como text en Supabase, no jsonb. Reconstruir mensajes de herramienta estructurados a partir de una cadena de texto fue genuinamente doloroso y perdí la mayor parte de una tarde en ello.
  • No añadí rate limiting en la ruta. El equipo del cliente inmediatamente comenzó a bombardearla con consultas largas y complejas durante UAT. Añade algo como Upstash Rate Limit antes de entregar cualquier cosa a humanos.
  • Establecí maxSteps en 10 durante las pruebas iniciales. En una consulta mal redactada, el modelo pasó por seis llamadas de herramientas antes de concluir que el producto no existía. Eso son seis viajes de ida y vuelta a la base de datos y muchos tokens. Cinco casi siempre es suficiente.

La arquitectura central, sin embargo, funcionó bien. Vercel AI SDK me ahorró escribir mi propio analizador de streaming y máquina de estados para llamadas de herramientas, lo cual por sí solo valió la pena la dependencia.

---

FAQ

¿Qué modelos funcionan con Vercel AI SDK además de OpenAI?

El SDK soporta Anthropic (Claude 3.5 Sonnet y otros), Google (Gemini), Mistral, Cohere, y más a través de paquetes de proveedores como @ai-sdk/anthropic. Cambiar proveedores es genuinamente solo una línea en la mayoría de los casos, reemplaza openai('gpt-4o') con anthropic('claude-3-5-sonnet-20241022') y el streaming, llamadas de herramientas, y hooks useChat funcionan idénticamente. Algunos proveedores tienen particularidades alrededor del soporte de llamadas de herramientas, así que revisa la tabla de compatibilidad del SDK antes de comprometerte.

¿Puede el agente escribir en Supabase, no solo leer?

Sí, y aquí es donde los agentes se vuelven interesantes y peligrosos en igual medida. Puedes escribir una herramienta createSupportTicket o updateProductStock de la misma manera que escribiste queryProducts. La función execute simplemente ejecuta un insert o update en lugar de un select. Te recomendaría fuertemente políticas de seguridad a nivel de fila en cualquier tabla en la que el agente pueda escribir, y mantén las operaciones destructivas (delete) completamente fuera del conjunto de herramientas a menos que tengas un paso de confirmación en tu UI.

¿Cómo manejo la autenticación para que los usuarios solo vean sus propios datos?

El enfoque más limpio: genera el cliente de Supabase dentro del controlador de ruta usando el JWT del usuario (de una cookie o encabezado Authorization) en lugar de la clave de rol de servicio. De esa manera, las políticas de seguridad a nivel de fila de Supabase se aplican automáticamente. El paquete @supabase/ssr tiene ayudantes para extraer la sesión de las cookies de Next.js. No pases IDs de usuario como parámetros simples al agente, el modelo podría ser engañado para consultar datos de otro usuario si el aviso del sistema no es impecable.

¿El Vercel AI SDK está listo para producción?

Yo diría que está listo para producción, pero con salvedades. Se mantiene activamente, Vercel lanza funcionalidades a ritmo rápido, y las primitivas centrales de streaming y tool-calling son estables. Las partes que cambian más son las funcionalidades experimentales (como generateObject con esquemas de unión complejos). Fija tu versión y lee el changelog antes de actualizar. Seahawk tiene dos proyectos de clientes en vivo ejecutándose en él ahora sin problemas, pero versionamos de forma agresiva.

¿Por qué Supabase específicamente y no Postgres en Railway o PlanetScale?

Supabase te da Postgres más un cliente tipado, auth, real-time, almacenamiento y edge functions bajo un mismo techo. Para un proyecto como este, la integración de auth y el editor SQL para iteración rápida ahorran tiempo genuinamente. Dicho esto, el código del agente funciona con cualquier base de datos compatible con Postgres. Reemplaza el cliente de Supabase con pg o Drizzle ORM y nada estructural cambia.

---

Honestamente, este stack me sorprendió con qué tan rápido es pasar de cero a un agente funcional y persistente en memoria. El Vercel AI SDK hace mucho trabajo invisible: protocolo de streaming, serialización de tool calls, abstracción de proveedores. Supabase maneja persistencia y auth sin pedirte mucho. La complejidad que queda, que es la parte que nadie puede abstraer por ti, es escribir un system prompt que haga que el modelo se comporte realmente. Esa parte requiere iteración. Empieza estricto, afloja gradualmente, y prueba con las preguntas peor formuladas que puedas imaginar.

Lectura relacionada: Investigación de palabras clave con búsqueda por IA en 2026: qué es, por qué es importante para SEO técnico y búsqueda por IA.

< BACK