El Vercel AI SDK es la capa sobre la que está construido casi cualquier agente en producción, lo diga el código o no. generateObject, streamText, tool calling — eso es la capa primitiva. Lo que la mayoría de las guías se saltean es qué va arriba de eso una vez que un solo llamado tiene que convertirse en una feature real: auth multi-tenant, un set de herramientas acotado a exactamente lo que la tarea necesita, un modelo al que no le podés confiar que devuelva un objeto perfecto siempre, y un browser esperando un stream.

eve es el framework de Vercel para esa capa — una forma filesystem-first de escribir un agente de backend durable, construida sobre el AI SDK en lugar de en su reemplazo. Lo usamos para lanzar el agente que redacta preguntas dentro de Qüizo, nuestra plataforma de trivias para eventos: un cliente tipea un prompt más o menos armado, y el agente le devuelve un borrador estructurado y editable de preguntas de opción única, sus opciones y la respuesta correcta, traducido en paralelo en cada idioma que la trivia necesita.

Este artículo recorre ese agente de punta a punta, desde agent.ts hasta el panel de React que renderiza sus borradores, con nuestro código real en cada paso — incluidas algunas lecciones que solo aprendimos al ponerlo en producción: un modelo que marcaba mal la respuesta correcta sin que se notara, un schema demasiado estricto para su propio bien, y un detalle del framework que convierte un "falló la validación" en "el usuario no recibió nada".

01

Dónde se Queda Corto un Solo Llamado a generateObject

El AI SDK solo te lleva más lejos de lo que parece. Un llamado a generateObject con un schema de Zod alcanza para una demo que funciona:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { generateObject } from 'ai';
import { z } from 'zod';
const { object } = await generateObject({
model: 'anthropic/claude-sonnet-5',
schema: z.object({
questions: z.array(
z.object({
text: z.string(),
options: z.array(z.object({ text: z.string(), isCorrect: z.boolean() })),
}),
),
}),
prompt: userPrompt,
});
// Works for a demo. In production this one call also has to be a tool,
// hold multi-tenant auth, survive a retry, and stream to a browser — none
// of which `generateObject` gives you for free.

Eso es, honestamente, la mayor parte de lo que hace un agente que redacta trivias — hasta que tiene que dejar de ser un script y convertirse en una feature. Ahora necesita saber qué cliente está preguntando, para no poder leer la trivia de otro. Necesita revisar qué hay ya en esa trivia antes de sumar más preguntas, así que entran las herramientas, y las herramientas significan un loop, no un solo request. Necesita sobrevivir a un request que se cancela a mitad de turno, y stremear progreso parcial a un browser en lugar de bloquear en un solo round trip. Y a un cliente se le puede apagar la generación por IA por completo, algo que hay que hacer cumplir en un lugar que ningún toggle del lado del cliente pueda esquivar.

Nada de eso es motivo para abandonar el AI SDK — sigue siendo lo que hace el llamado real al modelo. Es motivo para buscar algo que se haga cargo de lo que rodea a ese llamado: el loop, las herramientas, el auth, el transporte. Ese es el hueco que llena eve.

02

Lo que Suma eve: el Directorio Es el Contrato

Un agente de eve se escribe como un directorio en disco, no como un solo archivo. El nuestro vive en agent/ dentro del repo de Qüizo:

bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
agent/
├── agent.ts # model config
├── instructions.md # always-on system prompt
├── channels/
│ └── eve.ts # HTTP auth for this agent's routes
├── tools/
│ ├── get_quiz.ts # the two tools this agent actually has
│ ├── list_quizzes.ts
│ ├── bash.ts # everything else: explicitly disabled
│ ├── write_file.ts
│ ├── web_search.ts
│ └── ...
└── lib/
├── auth.ts # session -> clientId resolution
├── get-quiz.ts # tenant-scoped Prisma queries
└── list-quizzes.ts

Cada pieza tiene un tipo y exactamente un trabajo: agent.ts elige el modelo, instructions.md es el system prompt que siempre está activo, channels/ decide quién puede llamar a las rutas HTTP de este agente, tools/ es lo que puede hacer de verdad, y lib/ es código compartido común al que las herramientas llaman. Nada acá es plomería a medida — es la misma forma que eve espera de cualquier agente, y eso es lo que hace que un agent/ desconocido se pueda leer a primera vista.

El framework compila este directorio, monta una API HTTP para él (eve/v1/session, .../stream, .../cancel), y te da un hook de React, useEveAgent, para manejarlo desde el browser — todo sin armar a mano un protocolo de streaming o un almacén de sesiones.

03

Elegir el Modelo

agent.ts es, a propósito, el archivo más chico del directorio — selección de modelo, nada más:

typescript
1
2
3
4
5
6
7
8
9
import { defineAgent } from "eve";
// Claude Sonnet 5, routed through the Vercel AI Gateway (AI_GATEWAY_API_KEY
// locally; Vercel OIDC in production). Upgraded from Haiku: Haiku was
// mismarking the correct option on drafted questions often enough to be
// worth the extra cost for better accuracy.
export default defineAgent({
model: "anthropic/claude-sonnet-5",
});

El comentario es el punto central. Esto arrancó en Haiku, asumiendo que redactar preguntas de trivia es una tarea barata y de bajo riesgo. No lo era: Haiku marcaba mal la opción correcta lo suficientemente seguido — en una tarea donde la pantalla de revisión muestra un tilde verde al lado de la opción que el modelo eligió — como para que una marca equivocada pasara desapercibida en una repasada rápida y terminara en una trivia en vivo.

La solución no fue un mejor prompt. Fue aceptar que "elegir el modelo más barato que devuelva un JSON válido" y "elegir el modelo cuyas respuestas están efectivamente bien" son preguntas distintas, y que esta tarea necesitaba responder la segunda primero.

04

instructions.md Como Contrato de Salida

instructions.md es markdown, no un DSL de prompt engineering — pero pesa de verdad, porque esto es un flujo de revisar-y-guardar, no un chat. Una respuesta que es solo preguntas aclaratorias deja a la persona sin nada para mirar o editar, así que las instrucciones lo descartan explícitamente y le dan al modelo valores por defecto en los que apoyarse:

markdown
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Output contract
Every request you receive asks for a structured result matching a fixed
schema. **Always finish your turn by producing a value that satisfies it.**
This is a review-and-save flow, not a back-and-forth — a reply that's only
clarifying questions leaves the person with nothing to look at or edit, so
never do that. Missing details are not a reason to stop:
- Ambiguous scope (how many questions, how hard, what language(s))? Default
to 5 questions, moderate difficulty, the quiz's stated language(s) or
English if none was given.
# Quiz and question rules
- **Every question's `text` and every option's `text` is an object keyed by
language** (ENGLISH, SPANISH, ARABIC). You must supply a key for every
language in the quiz's languages list, on every question and on every
option — a question missing any of them is discarded by the review
screen and the person gets nothing for it. A 5-question, 4-option draft
in 3 languages is 65 strings — that is expected, not a reason to
shorten the draft.

La regla multilingüe es la que de verdad rompe cosas si queda ambigua. Las preguntas y opciones de una trivia son, cada una, un objeto con una clave por idioma, y la pantalla de revisión descarta cualquier pregunta a la que le falte una clave de alguno de los idiomas de la trivia — en silencio, desde el punto de vista del modelo. "Escribila en inglés y español" dejaba las traducciones libradas al azar; aclarar que un borrador de 5 preguntas con 4 opciones en 3 idiomas son 65 strings, y que eso es lo esperado, fue lo que frenó al modelo de acortar borradores en silencio para ahorrar esfuerzo.

Nada de esto lo hace cumplir el framework. Es un contrato que se le pide al modelo que respete, respaldado por validación del otro lado — que es exactamente por qué existen las dos secciones que siguen.

05

Herramientas, Acotadas por el Auth de la Sesión — No por Argumento

eve viene con un set de herramientas por defecto — shell, filesystem, búsqueda web, delegación a subagentes, una herramienta de pregunta human-in-the-loop. Un redactor de trivias no tiene un uso legítimo para ninguna, así que cada una recibe un rechazo explícito de una línea:

typescript
1
2
3
4
5
6
7
8
9
10
11
import { disableTool } from "eve/tools";
// This agent only drafts quiz content from a prompt (plus the two read-only
// lookup tools in this directory) — it has no legitimate use for the
// framework's shell, filesystem, web, delegation, or HITL question defaults.
export default disableTool();
// One file like this per framework default: bash.ts, write_file.ts,
// web_search.ts, web_fetch.ts, read_file.ts, glob.ts, grep.ts, todo.ts,
// ask_question.ts, agent.ts. Ten lines of "no" for every "yes" this agent
// actually needs.

Lo que queda son dos herramientas de solo lectura: get_quiz, para ver qué hay ya en una trivia antes de sumarle más preguntas, y list_quizzes, para evitar repetir un tema ya cubierto en otra trivia del mismo cliente. La regla de tenancy es la parte que vale la pena leer con atención:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { defineTool } from "eve/tools";
import { z } from "zod";
import { getQuizForClient } from "../lib/get-quiz";
// Read-only, scoped by the caller's clientId from route auth (never a tool
// argument — same rule the server actions follow with `getWritableQuiz`).
export default defineTool({
description:
"Get one of this client's quizzes by id, including its existing " +
"questions and options. Use it before adding questions to an existing " +
"quiz so you don't repeat what's already there.",
inputSchema: z.object({
quizId: z.string().min(1).describe("The quiz id to look up."),
}),
async execute({ quizId }, ctx) {
const clientId = ctx.session.auth.current?.attributes.clientId;
if (typeof clientId !== "string" || !clientId) {
throw new Error("No authenticated client session for this request.");
}
return getQuizForClient(clientId, quizId);
},
});
// getQuizForClient filters Prisma on { id: quizId, clientId } — clientId
// comes from the session, not from the model's arguments, so there is no
// quizId the model could pass that reads another client's quiz.

clientId nunca aparece como un argumento de herramienta que el modelo pueda setear o que el prompt pueda filtrar. Sale de ctx.session.auth — resuelto una sola vez, por el channel, a partir del request HTTP real — y la query de Prisma de abajo filtra por { id: quizId, clientId } juntos. No hay ningún quizId que el modelo pueda pasar, sea honesto o alucinado, que lea la trivia de otro cliente.

Juntando todo, un request traza un camino que vale la pena ver de punta a punta antes de meterse más a fondo en la salida estructurada:

Enviar

Browser

useEveAgent().send() postea el prompt junto con un outputSchema y un clientContext por turno.

Auth del channel

Channel de eve

Resuelve la sesión a partir del header de cookie crudo; 401 si no está autenticado, 403 si la generación por IA está apagada para este cliente.

Turno del agente

Sonnet + herramientas

Lee instructions.md, opcionalmente llama a get_quiz o list_quizzes, ambas acotadas al clientId de quien llama.

final_output

Herramienta del framework

El modelo cierra su turno llamando al outputSchema compilado como herramienta; el resultado queda en la metadata del mensaje.

Revisión humana

Persona

El borrador se renderiza como una tarjeta editable; cada pregunta se revalida contra el schema real y estricto del formulario.

Persistir

Server action

Solo se guardan las preguntas que pasan, a través de las mismas actions que llama un formulario a mano.

Un request, trazado de punta a punta. El turno del agente en sí — instrucciones más herramientas — es solo la mitad del camino; nunca escribe en la base de datos.
06

La Estrategia de Dos Schemas

eve convierte un outputSchema de Zod por request en una herramienta del framework, final_output, que el modelo tiene que llamar para terminar su turno — esto es lo que reemplaza a generateObject una vez que hay herramientas en el loop. Qüizo manda uno de dos schemas según la tarea:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// The permissive shape: also used to PARSE whatever the model sends back.
// No .max() on name, no duplicate-language check, no "exactly one correct
// option" refinement — see the note below on why.
export const quizDraftSchema = z.object({
name: z.string().optional(),
languages: z.array(z.enum(LANGUAGES)).optional(),
questions: z.array(quizDraftQuestionSchema).min(1),
});
// The outputSchema for "new quiz" mode. name/languages are required HERE
// because eve lowers a required Zod field into the JSON Schema `required`
// array the model is constrained against — this is what stops a draft
// coming back with questions but no name, which used to leave "Create
// quiz" permanently disabled.
export const newQuizDraftSchema = quizDraftSchema.extend({
name: z.string().min(1).describe(
"A short, descriptive title for the quiz, in its primary language.",
),
languages: z.array(z.enum(LANGUAGES)).min(1).describe(
"Every language this quiz is written in, most important first.",
),
});

Los comentarios en ese archivo son lo más útil que te podemos pasar, porque todos vuelven a un mismo hecho del framework: eve no reintenta un llamado a final_output que falla la validación del schema. Descarta el turno entero en silencio — sin borrador, sin error visible, nada para que la persona mire. Cada regla de abajo existe para que ese modo de falla nunca sea el resultado de un request normal.

Los refinements no sobreviven la conversión

Un .refine() o .superRefine() de Zod — como "exactamente una opción correcta" — no se traslada cuando el schema se convierte al JSON Schema contra el que se restringe al modelo. No hay nada que ganar codificándolo acá.

Un final_output que falla descarta todo el turno

eve no reintenta una falla de validación de schema — descarta el turno entero en silencio. Una restricción lo suficientemente plausible como para que el modelo la viole le puede costar a la persona el borrador entero, no solo un campo.

safeParse es todo o nada

Parsear el resultado de vuelta es un solo parse sobre el borrador entero. Un valor inesperado en una sola opción — un campo alucinado, un enum desactualizado — puede tirarse abajo a todas las demás preguntas junto con él.

.describe() lleva lo que el schema no puede

Los límites de longitud, los conteos exactos y las reglas de formato que el schema no hace cumplir igual llegan al modelo como texto de guía, respaldados por instructions.md y la validación real al momento de guardar.

El efecto práctico son dos schemas haciendo trabajos distintos, no uno haciendo los dos:

Enviado al modelo (outputSchema)

Sin topes de longitud ni reglas .refine()
Sin chequeo de "exactamente una opción correcta"
Solo name y languages son obligatorios
imageId suelto: un string, no un enum cerrado

Exigido antes de guardar

makeQuestionFormSchema(languages)

Exactamente una opción correcta, por pregunta

Topes de 500 / 200 caracteres, por idioma

El mismo schema que tiene que pasar un formulario a mano

Dos schemas, dos trabajos — el modelo nunca ve el schema que de verdad decide qué se guarda.

Nada de esto es, sin embargo, la barrera real — está afinado para sobrevivir al modelo, no para que se confíe en él. El schema que efectivamente habilita un guardado es el mismo que tiene que pasar un formulario llenado a mano:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// components/quiz-draft-review.tsx — nothing the agent produced is trusted
// until it passes the SAME schema a hand-filled form would have to pass.
const questionChecks = useMemo(
() =>
questions.map((question) =>
makeQuestionFormSchema(languages).safeParse(question),
),
[questions, languages],
);
// Save only sends the questions that pass — everything else stays on
// screen with a human-readable reason ("missing Spanish text", a duplicate
// flag) so a person can fix it or discard it, instead of the whole draft
// vanishing over one bad field.
const saveQuestions = async (quizId: string) => {
for (const [index, check] of questionChecks.entries()) {
if (!check.success || duplicateIssues[index]) continue;
await createQuestion({ quizId, ...check.data }); // same action a manual save uses
}
};
07

El Channel: Auth, y un Interruptor que Realmente Se Cumple

channels/eve.ts decide quién puede llegar a las rutas HTTP de este agente — el equivalente del framework a un middleware de rutas. El agente corre como su propio proceso, separado de la app de Next.js, así que no hay un helper cookies() ni un request context en el que apoyarse; tiene que re-derivar la identidad a partir del request crudo:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// agent/channels/eve.ts
import { eveChannel } from "eve/channels/eve";
import { quizoutSession } from "../lib/auth";
export default eveChannel({ auth: [quizoutSession()] });
// agent/lib/auth.ts — the agent runs as its own process, so there's no
// Next.js request context here. It re-derives the session from the raw
// cookie header through the same resolver the Next app uses.
export function quizoutSession(): AuthFn<Request> {
return async (request) => {
const clientId = await resolveWritableClientIdFromCookieHeader(
request.headers.get("cookie"),
);
if (!clientId) return null; // 401: not authenticated at all
if (!(await isAiGenerationEnabledForClient(clientId))) {
// 403: authenticated, but this client has the agent turned off
throw new ForbiddenError({
code: "ai_generation_disabled",
message: "AI quiz generation is disabled for this account.",
});
}
return {
authenticator: "quizout-session",
principalId: clientId,
principalType: "user",
attributes: { clientId },
};
};
}

Dos resultados, deliberadamente distintos: devolver null significa "no autenticado", y el channel responde 401. Tirar ForbiddenError significa "autenticado, pero no permitido", y el channel responde 403. Esa distinción es lo que hace que el interruptor por cliente sea real y no cosmético — a un cliente se le puede apagar la generación por IA por completo, y el chequeo corre acá, después de resolver la identidad pero antes de que corra cualquier herramienta, así que esconder el panel en la UI es una comodidad, no la aplicación de la regla. Una pestaña vieja o un request directo pegan contra el mismo 403 de cualquier forma.

Nada de lo que se otorga acá llega más lejos de lo que ya podría alcanzar una sesión autenticada de manager o cliente en la app de Next — el channel resuelve la misma sesión, solo que a partir de un header de cookie en lugar de un helper del framework.

08

Conectar la UI: useEveAgent y clientContext

Del lado del browser no hay ni una server action ni una API route para escribir a mano — un client component llama a useEveAgent() de eve/react, que habla con las rutas /eve/v1/* del mismo origen que montó el channel:

typescript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
"use client";
import { useEveAgent } from "eve/react";
export function QuizAgentPanel(props: QuizAgentPanelProps) {
const agent = useEveAgent();
const onSubmit = async (message: string) => {
if (props.mode === "add-questions") {
await agent.send({
message,
outputSchema: quizDraftSchema, // looser: the quiz already has these
clientContext: {
quizName: props.quizName,
languages: props.languages,
existingQuestions: props.existingQuestions.map((q) => q.text),
},
});
return;
}
await agent.send({
message,
outputSchema: newQuizDraftSchema, // strict: model must supply both
clientContext: { task: "new-quiz" },
});
};
// The finished draft lands on the assistant message's metadata.result.
// Re-parsed here with the PERMISSIVE schema on purpose: a draft missing
// something should still render as a reviewable card, not disappear.
const drafts = agent.data.messages.flatMap((message) => {
if (message.role !== "assistant" || !message.metadata?.result) return [];
const parsed = quizDraftSchema.safeParse(message.metadata.result);
return parsed.success ? [{ id: message.id, draft: parsed.data }] : [];
});
// ...render one QuizDraftReview card per draft
}

clientContext vale la pena resaltarlo: es contexto efímero del turno, no un mensaje de chat durable. El modo "agregar preguntas" pasa el nombre de la trivia destino, sus idiomas y los textos de las preguntas existentes para que el modelo evite repetir un tema — sin que ese contexto ensucie para siempre una conversación que la persona nunca pidió ver. El panel directamente no renderiza una transcripción de chat, a propósito; solo se muestra el borrador estructurado que vuelve, así que la interacción se lee como "describí qué querés" → "acá tenés un borrador editable", no como un ida y vuelta con un asistente.

Y el borrador es exactamente eso — un borrador. Se renderiza en una tarjeta de revisión editable, cualquier pregunta se puede abrir en el mismo editor que usaría una escrita a mano, y "Guardar" corre a través de las mismas server actions exactas por las que pasa un formulario manual. El agente nunca escribe en la base de datos; solo propone.

09

Qué Haríamos Distinto

El campo imageId es la excepción honesta a "validar todo con rigor". Su tipo real es un enum cerrado de la biblioteca de imágenes de un cliente, pero el schema de borrador lo deja como un string suelto y permisivo — porque un safeParse contra el schema estricto falla todo-o-nada sobre el borrador entero, y un imageId alucinado en una sola opción se hubiera llevado puesto, en silencio, a todas las demás preguntas de un borrador de cinco junto con él. Elegimos degradar ese único campo a null del lado del cliente antes que perder el borrador. Es un compromiso deliberado, no un descuido, pero también es un recordatorio de que un schema que cumple doble función — como contrato del modelo y como puerta de parseo — tarde o temprano te va a pedir que aflojes algo que preferirías mantener estricto.

También arrancaríamos con Sonnet la próxima vez, en lugar de descubrir la necesidad en producción. "Qué modelo es lo suficientemente barato" y "qué modelo acierta el detalle puntual que un humano no va a volver a chequear" resultaron ser preguntas que valía la pena hacer por separado, antes de lanzar y no después.

Lo que no cambiaríamos: mantener al agente mayormente de solo lectura y poner la validación real en la frontera del guardado, no en la de la generación. Cada lección dura acá salió de tratar la salida del modelo como un borrador para negociar, nunca como un valor de confianza — y esa única decisión fue lo que evitó que una respuesta equivocada, una traducción faltante o un campo alucinado se convirtieran en una caída en lugar de en un bug.