The Vercel AI SDK is the layer almost every agent in production is built on, whether or not the code says so. generateObject, streamText, tool calling — that's the primitive layer. What most write-ups skip is what sits on top of it once a single call has to become a real feature: multi-tenant auth, a set of tools scoped to exactly what the task needs, a model that can't be trusted to hand back a perfect object every time, and a browser waiting on a stream.

eve is Vercel's framework for that layer — a filesystem-first way to author a durable backend agent, built on the AI SDK rather than instead of it. We used it to ship the quiz-drafting agent inside Qüizo, our event-quiz platform: a client types a rough prompt, and the agent hands back a structured, editable draft of single-choice questions, options, and correct answers, translated in parallel across every language the quiz needs.

This post walks that agent end to end, from agent.ts to the React panel that renders its drafts, using our actual code at every step — including a few production lessons we only learned by shipping it: a model that quietly mismarked correct answers, a schema too strict for its own good, and a framework detail that turns "validation failed" into "the user got nothing back."

01

Where a Single generateObject Call Runs Out

The AI SDK on its own gets you further than it looks like it should. A generateObject call with a Zod schema is enough for a working demo:

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.

That's genuinely most of what a quiz-drafting agent does — until it has to become a feature instead of a script. Now it needs to know which client is asking, so it can't read someone else's quiz. It needs to check what's already in that quiz before drafting more, so tools enter the picture, and tool calls mean a loop, not one request. It needs to survive a request that gets cancelled mid-turn, and stream partial progress to a browser rather than block on one round trip. And a client can be switched off from AI generation entirely, which has to be enforced somewhere no client-side toggle can bypass it.

None of that is a reason to abandon the AI SDK — it's still what makes the actual model call. It's a reason to reach for something that owns the parts around that call: the loop, the tools, the auth, the transport. That's the gap eve fills.

02

What eve Adds: the Directory Is the Contract

An eve agent is authored as a directory on disk, not a single file. Ours lives at agent/ in the Qüizo repo:

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

Each piece is typed and has exactly one job: agent.ts picks the model, instructions.md is the always-on system prompt, channels/ decides who's allowed to call this agent's HTTP routes, tools/ is what it can actually do, and lib/ is ordinary shared code the tools call into. Nothing here is bespoke plumbing — it's the same shape eve expects from any agent, which is what makes an unfamiliar agent/ directory readable on sight.

The framework compiles this directory, mounts an HTTP API for it (eve/v1/session, .../stream, .../cancel), and hands you a React hook, useEveAgent, to drive it from the browser — all without hand-rolling a streaming protocol or a session store.

03

Picking the Model

agent.ts is deliberately the smallest file in the directory — model selection, nothing else:

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",
});

The comment is the point. This started on Haiku, on the assumption that drafting quiz questions is a cheap, low-stakes task. It wasn't: Haiku mismarked the correct option often enough — on a task where the review screen shows a green checkmark next to whichever option the model flagged — that a wrong mark could slip past a skim and ship in a live quiz.

The fix wasn't a better prompt. It was accepting that "pick the cheapest model that produces valid JSON" and "pick the model whose answers are actually right" are different questions, and this task needed the second one answered first.

04

instructions.md as the Output Contract

instructions.md is markdown, not a prompt-engineering DSL — but it carries real weight, because this is a review-and-save flow, not a chat. A reply that's only clarifying questions leaves the person with nothing to look at or edit, so the instructions rule that out explicitly and give the model defaults to fall back on instead:

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.

The multilingual rule is the one that actually breaks things if it's vague. A quiz's questions and options are each an object keyed by language, and the review screen discards any question missing a key for one of the quiz's languages — silently, from the model's point of view. "Write it in English and Spanish" left translations to chance; spelling out that a 5-question, 4-option draft in 3 languages is 65 strings, and that this is expected, is what stopped the model from quietly shortening drafts to save effort.

None of this is enforced by the framework. It's a contract the model is asked to honor, backed by validation on the other end — which is exactly why the next two sections exist.

05

Tools, Scoped by Session Auth — Not by Argument

eve ships a set of default tools — shell, filesystem, web search, sub-agent delegation, a human-in-the-loop question tool. A quiz drafter has no legitimate use for any of them, so every one gets an explicit, one-line refusal:

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.

What's left is two read-only tools: get_quiz, to see what's already in a quiz before drafting more into it, and list_quizzes, to avoid repeating a topic covered elsewhere in the same client's pool. The tenancy rule is the part worth close reading:

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 never appears as a tool argument the model could set or the prompt could leak into. It comes off ctx.session.auth — resolved once, by the channel, from the actual HTTP request — and the Prisma query underneath filters on { id: quizId, clientId } together. There is no quizId the model could pass, honestly or by hallucination, that reads a different client's quiz.

Put together, one request traces a path that's worth seeing end to end before the structured-output details get more specific:

Send

Browser

useEveAgent().send() posts the prompt plus a per-turn outputSchema and clientContext.

Channel auth

eve channel

Resolves the session from the raw cookie header; 401 if unauthenticated, 403 if AI generation is disabled for this client.

Agent turn

Sonnet + tools

Reads instructions.md, optionally calls get_quiz or list_quizzes, both scoped to the caller's clientId.

final_output

Framework tool

The model closes its turn by calling the compiled outputSchema as a tool; the result lands on the message metadata.

Human review

Person

The draft renders as an editable card; every question is re-validated against the real, strict form schema.

Persist

Server action

Only the questions that pass are saved, through the same actions a hand-filled form calls.

One request, traced end to end. The agent's own turn — instructions plus tools — is only the middle of the chain; it never writes to the database.
06

The Two-Schema Strategy

eve turns a per-request Zod outputSchema into a framework tool, final_output, that the model must call to end its turn — this is what replaces generateObject once tool calls are in the loop. Qüizo sends one of two schemas depending on the task:

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.",
),
});

The comments in that file are the most useful thing we can hand you, because they all trace back to one framework fact: eve does not retry a final_output call that fails schema validation. It silently drops the entire turn — no draft, no visible error, nothing for the person to look at. Every rule below exists to keep that failure mode from ever being the outcome of an ordinary request.

Refinements don't survive lowering

A Zod .refine() or .superRefine() — like "exactly one correct option" — doesn't carry over when the schema is lowered to the JSON Schema the model is constrained against. There's nothing to gain by encoding it here.

A failed final_output drops the whole turn

eve doesn't retry a schema validation failure — it silently discards the entire turn. A constraint plausible enough for the model to violate can cost the person the whole draft, not just one field.

safeParse is all-or-nothing

Parsing the result back is one parse over the whole draft. One unexpected value on one option — a hallucinated field, a stale enum — can drop every other question along with it.

.describe() carries what the schema can't

Length limits, exact counts, and format rules the schema won't enforce still reach the model as guidance text, backed up by instructions.md and the real validation at save time.

The practical effect is two schemas doing different jobs, not one schema doing both:

Sent to the model (outputSchema)

No length caps or .refine() rules
No "exactly one correct option" check
Only required fields are name and languages
Loose imageId: a string, not a closed enum

Enforced before save

makeQuestionFormSchema(languages)

Exactly one correct option, per question

500 / 200 character caps, per language

The same schema a hand-filled form must pass

Two schemas, two jobs — the model never sees the schema that actually decides what gets saved.

None of this is the real guardrail, though — it's tuned to survive the model, not to be trusted. The schema that actually gates a save is the same one a hand-filled form has to pass:

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

The Channel: Auth, and a Kill Switch That Actually Enforces

channels/eve.ts decides who is allowed to reach this agent's HTTP routes at all — the framework's equivalent of route middleware. The agent runs as its own process, separate from the Next.js app, so there's no cookies() helper and no request context to lean on; it has to re-derive identity from the raw request itself:

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 },
};
};
}

Two outcomes, deliberately distinct: returning null means "not authenticated," and the channel responds 401. Throwing ForbiddenError means "authenticated, but not permitted," and the channel responds 403. That distinction is what makes the per-client kill switch real rather than cosmetic — a client can be toggled off from AI generation entirely, and the check runs here, after identity resolution but before any tool executes, so hiding the panel in the UI is a convenience, not the enforcement. A stale tab or a direct request hits the same 403 either way.

Nothing granted here reaches further than an authenticated manager or client session could already reach in the Next app — the channel resolves the same session, just from a cookie header instead of a framework helper.

08

Wiring the UI: useEveAgent and clientContext

On the browser side, there's no server action and no API route to hand-write — a client component calls useEveAgent() from eve/react, which talks to the same-origin /eve/v1/* routes the channel mounted:

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 is worth calling out: it's ephemeral turn context, not a durable chat message. "Add questions" mode passes the target quiz's name, languages, and existing question texts so the model can avoid repeating a topic — without that context permanently cluttering a conversation the person never asked to see. The panel doesn't render a chat transcript at all, on purpose; only the structured draft that comes back is shown, so the interaction reads as "describe what you want" → "here's an editable draft," not a back-and-forth with an assistant.

And the draft is exactly that — a draft. It renders into an editable review card, any question can be opened in the same editor a hand-authored one would use, and "Save" runs through the identical server actions a manual form submits through. The agent never writes to the database; it only ever proposes.

09

What We'd Do Differently

The imageId field is the honest exception to "validate everything strictly." Its real type is a closed enum of a client's image library, but the drafting schema leaves it as a loose, permissive string — because a safeParse against the strict draft schema fails all-or-nothing across the whole draft, and one hallucinated imageId on one option would have silently dropped every other question in a five-question draft along with it. We chose to degrade that one field to null on the client instead of losing the draft. It's a deliberate compromise, not an oversight, but it's also a reminder that a schema doing double duty — as both the model's contract and the parse gate — will eventually ask you to loosen something you'd rather keep strict.

We'd also start with Sonnet next time instead of discovering the need for it in production. "Which model is cheap enough" and "which model gets the one detail right that a human won't double-check" turned out to be worth asking separately, before shipping rather than after.

What we wouldn't change: keeping the agent read-mostly and putting the real validation at the save boundary, not the generation boundary. Every hard lesson here came from treating the model's output as a draft to negotiate with, never as a value to trust — and that one decision is what kept a wrong answer, a missing translation, or a hallucinated field from ever becoming an outage instead of a bug.