Every inbound form needs an owner before anyone can act on it. Dropdowns push that decision onto the submitter, while keyword rules can misroute a request that mentions billing but asks for help with account access. Jev, a System One model from TypeSafe AI, makes that decision from the whole submission, choosing one owner from a set of destinations you define.
Jev answers through AI SDK's experimental_evaluate, returning a typed destination with a confidence statistic. Your application accepts the answer when confidence is at least 95%, and anything lower, missing, or failed goes to openai/gpt-6-luna-fast for an independent decision. Both models run through AI Gateway, and the result can optionally be emailed with Resend.
Deploy the template now, or read on for a deeper look at how it all works.
Jev x AI SDK Form Router Template
Three forms routed by Jev with confidence, a fallback model for uncertain cases, and optional email delivery with Resend.
Copy link to headingQuick start with an AI coding agent
If you're working with an AI coding agent like Claude Code or Cursor, you can use this prompt to have it set up and extend the template for you:
I want to route my form submissions with Jev and AI SDK using the Jev x AI SDK Form Router template at https://github.com/vercel-labs/jev-ai-sdk-form-router. Clone it, then read README.md for setup, ARCHITECTURE.md for the module map and routing policy, and AGENTS.md for the routing invariants and code standards. Follow them when adding my own form fields, destinations, and routing criteria to lib/examples.ts.
Copy link to headingVercel Plugin
The Vercel Plugin turns your AI coding agent (e.g., OpenAI Codex, Claude Code, or Cursor) into a Vercel expert. It adds skills, slash commands, and current knowledge of the tools this template uses, including AI SDK, AI Gateway, and Next.js.
The plugin is optional; it isn't required to use the template or to follow this guide.
Copy link to headingHow the router decides
The application uses Jev's confidence statistic to decide whether to accept its suggested destination or ask the fallback model to complete a second review.
Each submission follows five steps:
- Validate the input against a Zod schema built from the form's registered fields. The schema trims values, checks required fields and length limits, and strips unregistered fields such as a client-supplied
destinationortoaddress. - Ask Jev one
choicequestion using the registry's destination IDs and routing criteria. Jev evaluates the complete submission and returns a destination, probabilities for each option, and a confidence statistic. - Accept Jev's destination when its confidence is a valid number between
0and1and meets the0.95threshold without rounding. - Send the same submission and criteria to
openai/gpt-6-luna-fastwhen confidence is below the threshold, missing, or invalid, or when Jev fails or times out. The fallback usesgenerateTextwithOutput.objectto select a registered destination. Its valid answer determines the final assignment. - Return the final destination, deciding model, available Jev statistics, and model timings, along with a React Email preview. If the submitter requested an email and delivery is configured, send it through Resend.
| Jev outcome | Deciding model | fallbackReason |
|---|---|---|
| Registered destination, confidence (≥95%) | Jev | null |
| Registered destination, confidence (<95%) | GPT-6 Luna | low-confidence |
| Valid confidence metadata absent | GPT-6 Luna | missing-confidence |
| Evaluation error or 12s timeout | GPT-6 Luna | jev-error |
| Fallback request fails | None | Not returned |
The result includes two statistics:
- Confidence summarizes the distribution, from
0when probability is spread evenly across destinations to1when it is concentrated on one destination. - Choice probability is the probability Jev assigned to its selected destination.
Copy link to headingThe three forms
Each form has its own destinations, and every form includes a triage destination for submissions that don't contain enough evidence to pick an owner.
| Form | Route | Destinations |
|---|---|---|
| Lead | /leads | Startup onboarding, startup technical advisory, growth sales, growth integrations, enterprise solutions engineering, enterprise procurement, and sales triage |
| Contact | /contact | Billing invoices, billing refunds, support account access, support technical help, general inquiries, and contact triage |
| Issue Report | /issues | Frontend interface, frontend accessibility, platform API, platform integrations, infrastructure reliability, identity authentication, and engineering triage |
Each form also includes three samples labeled Clear request, Overlapping needs, and Limited context. Samples populate the fields and always route live, so you see real model output rather than a recorded answer.
The overlapping samples test how each form handles submissions that could fit more than one destination. Use them to check whether the routing instructions establish a clear priority and whether the selected destination matches the team responsible for resolving the main request.
Copy link to headingSetup and deployment
Copy link to headingWhat you need before deploying
You need the following to deploy and run the router:
- A Vercel account
- AI Gateway access to
typesafe-ai/jevandopenai/gpt-6-luna-fast - A Resend account, if you want to email routed submissions
For local development, you also need Node.js 22+, pnpm, and the Vercel CLI.
Copy link to headingDeploy to Vercel
Deploy the template to create a copy in your GitHub account and a Vercel project. When deployment finishes, open the project URL to view the lead form at /leads.
The deployed app authenticates to AI Gateway automatically with a Vercel OpenID Connect (OIDC) token, so you don't need to configure a provider API key for routing.
Copy link to headingRun the router locally
Clone the repository the deploy flow created and install dependencies:
To test routing submissions locally, connect the app to AI Gateway using one of the following methods. You can pull an OIDC token from your linked Vercel project or add an AI Gateway API key to .env.local.
Copy link to headingOption 1: Vercel OIDC
Link the directory to your Vercel project and pull its development environment variables. This writes a VERCEL_OIDC_TOKEN to .env.local:
The local token expires after 12 hours. Re-run vercel env pull .env.local when a request returns an unauthorized error.
Copy link to headingOption 2: AI Gateway API key
Copy the .env.example file to .env.local, then create a key in the Vercel dashboard and set it as AI_GATEWAY_API_KEY:
Copy link to headingStart the dev server
Start the development server, then open localhost:3000:
You can explore the forms and load sample inputs without credentials. To submit them for routing, the server needs an OIDC token or API key in .env.local; otherwise, it returns a setup error in the app UI.
These are the commands you'll use day to day:
| Command | Does |
|---|---|
pnpm dev | Start the Next.js development server |
pnpm test | Run the Vitest suite with mocked models and Resend, no external calls |
pnpm exec vitest run lib/router.test.ts | Run the routing policy tests alone |
pnpm fix / pnpm check | Apply or check formatting and lint fixes |
pnpm typecheck | tsc --noEmit |
pnpm validate | Lint, typecheck, Knip, and tests in one |
pnpm build | Production build |
Copy link to headingConfigure email delivery (optional)
Email delivery is optional. Once a form's sender and receiving inboxes are configured, it displays an Email the receiving team checkbox. Submissions with this option selected are sent to the inbox assigned to the final destination.
Use the Vercel Marketplace Resend integration to create a Resend account and connect it to your Vercel project. If the local directory isn't already linked, run:
Select your Vercel project, then install the integration:
During setup, choose an existing Vercel domain or purchase one. Complete onboarding in Resend, add the DNS records, and wait for domain verification.
Pull the integration's environment variables locally:
In .env.local, confirm that RESEND_API_KEY is present and set RESEND_FROM to a sender address on your verified domain. If you're using an existing Resend account without the integration, add both variables manually.
In lib/recipients.ts, replace null with a receiving email address for every destination on the form you want to enable. For example, these entries assign two billing destinations to the same inbox and contact triage to a different inbox:
Fill in the remaining destinations for that form, too. Leaving any destination unconfigured keeps email delivery disabled for the form. Receiving addresses stay in a server-only module and aren't sent to the browser or either model.
To enable email delivery in production, add your sender address using the Vercel CLI, entering an address on your verified domain when prompted:
If you skipped the Resend integration, also add your API key:
Commit and push your changes to lib/recipients.ts to the production branch to deploy the updated email configuration:
Copy link to headingCode walkthrough
The workflow spans three files under lib/:
examples.tsdefines the forms, destinations, and routing criteria.router.tsselects a destination using Jev or the fallback model.submission.tsvalidates input, renders email previews, and handles delivery.
Copy link to headingThe registry defines destinations once
lib/examples.ts defines each form's fields, sample submissions, available destinations, and routing instructions shared by both models.
These definitions determine the Zod validation schema, Jev's choice criteria, the fallback's allowed destinations, and the required keys in the recipient map.
Each destination names a team and specialty, with criteria describing which submissions it should receive:
Criteria describe situations rather than labeling them.
Writing "The immediate blocker is signing in..." gives the model more to match against than "access" would, and it lets neighboring destinations state where their boundaries meet.
The form-level instructions then resolve overlaps explicitly:
DestinationId is derived from the registry's destination IDs, so every destination needs a matching entry in lib/recipients.ts. Missing entries fail the type check. Using null satisfies the type check but keeps email delivery disabled for that form until every destination has a valid inbox.
Copy link to headingOne question, shared by both models
routeSubmission builds one choice question from the destinations and routing criteria defined in the registry. The function in lib/router.ts passes it to Jev through experimental_evaluate and includes the same question and submission data as JSON in the fallback model's prompt.
Three parts of this question do specific work:
- The
criteriakeys define the allowed destination IDs for the evaluation.Object.fromEntriesproduces a string-keyed map, so it doesn't preserve those IDs as a literal union in the answer type.findDestinationchecks the returned choice against the form's registry. - The appended sentence about untrusted evidence is the first line of defense against a submission that tries to override routing rules in its message body.
stateis a JSON object rather than a concatenated string, so the model sees field names alongside values with no serialization on your side.
Copy link to headingCalling Jev and gating on confidence
The Jev call has a 12-second timeout that applies to the initial attempt and one SDK retry for transient failures. The application validates the returned confidence metadata with Zod before comparing it with the acceptance threshold.
TypeSafe returns confidence as provider metadata, keyed by question ID. The template validates it with Zod's safeParse. Missing metadata, string values, and numbers outside 0 to 1 become null, sending the submission to the fallback with the reason missing-confidence.
The threshold comparison uses the unrounded value, so confidence of 0.94999 triggers the fallback even though the UI displays 95.00%.
findDestination checks that the returned choice belongs to the current form's registered destinations. Both model paths use this check to prevent an unregistered destination from becoming the final routing result.
Copy link to headingThe independent fallback
When Jev's result doesn't meet the acceptance criteria, generateText sends the same question and submission to openai/gpt-6-luna-fast. The prompt contains the serialized questions and state objects, excluding Jev's answer and statistics so the fallback selects a destination independently.
Output.object validates the fallback's response against a z.enum of the form's destination IDs, rejecting any destination outside that list. The call uses a 25-second timeout and requests low reasoning effort with reasoning: "low".
The application accepts a valid fallback destination as the final assignment without another confidence check. Evaluate these assignments against labeled submissions to measure the accuracy of the complete workflow, including cases where the fallback replaces Jev's choice.
Copy link to headingValidation and trust boundaries
submissionSchema in lib/router.ts validates submitted values against a Zod schema built from the form's registered fields.
It strips unregistered fields, so client-supplied values such as destination or to cannot override the routing result or receiving inbox:
Before routing, the workflow checks that the example ID matches one of the three registered forms, the request includes a UUID submissionId, and AI Gateway credentials are available.
The browser receives application-defined error messages without raw provider details. If the fallback call returns 403, the message directs you to check the Gateway account's model access and paid-credit configuration.
Assigning a submission to billing_refunds identifies the team responsible for reviewing it. Approval still depends on the customer's account and your refund policy, as the destination's routing criteria make clear.
Copy link to headingEmail delivery preserves the routing result
Email delivery follows a successful routing decision. In lib/submission.ts, processSubmission checks whether sendEmail is the exact string "true":
- If an email wasn't requested,
delivery.statusispreviewand nothing is sent. - If an email was requested,
deliverEmailchecks forRESEND_API_KEY, a validRESEND_FROM, and a valid inbox for every destination on the form. Incomplete configuration returnsfailed. - If Resend returns a message ID, the status is
accepted. If acceptance cannot be confirmed after the send attempts, the status isfailed.
The routing result remains available whether delivery is skipped, accepted, or fails.
The recipient map is typed against the registry and marked server-only:
The email workflow handles retries, rendering, and delivery status as follows:
- Send up to two sequential requests with the same payload and idempotency key, preventing a retry from duplicating an accepted send if the first response is lost.
- Use the validated submitter address as
replyTo. - Render the preview and outgoing HTML from one React Email template, escaping submitted text.
- Set delivery status to
acceptedwhen Resend returns a message ID. This confirms acceptance for sending, but not inbox delivery.
Copy link to headingTesting the policy without calling a model
lib/router.test.ts checks the routing policy by passing mock jev and luna models to routeSubmission.
These models use helpers from ai/test to return fixed answers, so the tests can verify confidence thresholds and fallback behavior without making network calls.
The tests check how the policy handles threshold values and invalid metadata:
- Accept Jev's destination at confidence
0.95,0.98, and1. - Use the fallback at confidence
0.94999, even when the selected option's probability is0.999. - Accept confidence
0.96with a selected-option probability of0.7, confirming that confidence controls the decision. - Return
missing-confidencewhen confidence is missing, non-numeric, or outside the valid range of0to1.
Another test checks that the fallback receives the same questions and state as Jev, without Jev's answer or statistics in its prompt.
These fixed responses test the application's routing rules. Evaluate the complete workflow on labeled submissions to determine whether the 0.95 cutoff produces suitable assignments.
Copy link to headingReading the routing result
The result panel and the RoutingDecision object it renders expose everything the policy used, so you can audit a decision after the fact.
| Field | Meaning |
|---|---|
destination | The final registered owner, which may differ from Jev's original choice |
model | typesafe-ai/jev or openai/gpt-6-luna-fast, whichever supplied the final destination |
threshold | The acceptance floor, 0.95 |
fallbackReason | low-confidence, missing-confidence, jev-error, or null when Jev was accepted |
jev.destination | Jev's original choice, retained even when the fallback changes the owner |
jev.confidence | Unrounded TypeSafe confidence, or null for missing or invalid metadata |
jev.selectedProbability | The probability Jev assigned to its own choice. |
jev.probabilities | Jev's full distribution across destinations, keyed by ID |
timings.jevMs / timings.lunaMs | Elapsed call durations including SDK retries; lunaMs is null when no fallback ran |
The UI labels the fallback reason in plain language and lets you expand Jev's destination probabilities. Use the distribution to identify which alternatives need closer inspection, then review the submission and routing criteria.
Uncertainty may reflect overlapping categories, missing context, or a model mistake; it doesn't establish that triage is the correct destination. Jev can also select triage with high confidence.
Copy link to headingCustomize the router
Most form changes start in lib/examples.ts, which defines the fields, samples, destinations, and routing criteria used by the UI and both models.
Validation follows the same definitions, while new destinations also need matching entries in lib/recipients.ts.
| To change | Edit | Notes |
|---|---|---|
| Form fields, samples, destinations, or routing criteria | lib/examples.ts | Validation, Jev's criteria, and the fallback enum all derive from here |
| Models, the confidence threshold, timeouts, or retries | lib/router.ts | CONFIDENCE_THRESHOLD, JEV_TIMEOUT_MS, and LUNA_TIMEOUT_MS are the constants at the top |
| Receiving inboxes | lib/recipients.ts | One entry per destination ID; null leaves a destination unconfigured |
| Validation, email rendering, or the delivery workflow | lib/submission.ts | Keep app/actions.ts as a thin entrypoint for submissions. |
| Shared form and result UI | components/router-form.tsx and components/routing-result.tsx | All three forms share these components |
| Email design | emails/routed-submission.tsx | Inline styles are intentional for email client compatibility |
Copy link to headingAdd a destination
- Add an entry with an
id,team,specialty, andcriteriato the form'sdestinationsarray inlib/examples.ts. - Add the same
idtoroutingRecipientsinlib/recipients.ts, usingnullif it has no inbox yet. TypeScript reports the missing key until you do. - Update the form's
instructionsto specify which destination takes priority when the new and existing criteria overlap. - Add a test in
lib/router.test.tsif the change affects how overlaps resolve.
Copy link to headingAdd a form
- Register the form in
lib/examples.tsand add its ID toExampleId. - Add each destination ID to
routingRecipientsinlib/recipients.ts, using a receiving address ornull. - Add the new ID to the
z.enuminprocessSubmissioninlib/submission.ts. - Add the form to the navigation array in
app/[example]/page.tsx.
The shared components render the form and results from its registry definition.
Copy link to headingTune the threshold
The 0.95 threshold is a starting point. How confidence relates to routing errors depends on your destinations and your submissions, so measure it against labeled examples before relying on it:
- Test each cutoff on the same set of representative labeled submissions.
- Compare every final destination with the expected owner, including assignments made by the fallback.
- Measure routing errors, fallback frequency, response time, and cost to assess whether sending more submissions to the fallback improves the results.
- Keep confidence as the threshold input and retain the test that checks it against the selected option's probability.
Copy link to headingSwap the fallback model
Choose an AI Gateway model that supports structured output, then update:
- The default fallback model ID in
lib/router.ts. - The
RoutingDecision.modeltype and the model ID returned with the decision. - The Luna-specific error message in
lib/submission.ts, along with affected labels and test expectations.
Keep Jev's answer and statistics out of the fallback prompt so the replacement evaluates the submission independently. Run the mocked tests to check the routing policy, then use labeled submissions to assess the replacement's assignments. Models from different vendors can still make the same mistakes.
Copy link to headingTroubleshooting
Copy link to headingSubmitting returns a setup error about AI_GATEWAY_API_KEY
Neither AI_GATEWAY_API_KEY nor VERCEL_OIDC_TOKEN is set in the dev server's environment, so processSubmission stops before calling either model. Set one of them in .env.local as described in Run the router locally, then restart pnpm dev. Environment changes aren't picked up by a running server.
Copy link to headingRouting worked yesterday and now returns a 401 locally
The OIDC token written by vercel env pull expires after 12 hours. Re-run vercel env pull .env.local and restart the dev server. Deployments on Vercel receive a fresh token automatically, so this only affects local development.
Copy link to heading"AI Gateway denied access to the Luna review model"
The fallback call returned a 403, which means the Gateway account linked to the project doesn't have access to openai/gpt-6-luna-fast or has no paid credits configured. Jev may still be working; the message appears because Jev fell back and the fallback couldn't run. Check model access and credit configuration for the team in the Vercel dashboard, then resubmit.
Copy link to headingEvery result shows "Jev's confidence was unavailable" and fallback kicks in
The returned confidence metadata is missing, non-numeric, or outside 0 to 1, so it fails validation. Check that the Gateway call uses typesafe-ai/jev.
If you've switched evaluation providers, check which statistics the replacement returns and update the metadata validation and threshold logic accordingly. The current implementation expects TypeSafe's confidence metadata.
Copy link to headingMost submissions fall back with low-confidence
Expand Jev's destination probabilities and compare the submission with the criteria for destinations receiving similar probabilities. Look for missing information or overlapping criteria that could explain the uncertainty, and check whether Jev overlooked evidence in the request.
Clarify ambiguous criteria in lib/examples.ts and use the form's instructions to specify which destination takes priority when multiple categories apply. Test the changes on labeled submissions, checking both routing accuracy and fallback frequency to see whether the revised criteria improve the final assignments.
Copy link to headingThe "Email the receiving team" checkbox doesn't appear
isEmailConfigured checks for RESEND_API_KEY, a valid sender address in RESEND_FROM, and a valid inbox for every destination on the form. Any destination still set to null in lib/recipients.ts keeps the checkbox hidden.
Fill in the missing inboxes and verify both environment variables, then reload the page so the dev server checks the configuration again.
Copy link to headingRouting succeeded but delivery says failed
The routing result remains available when email delivery fails. Check the delivery message to see whether configuration was incomplete or Resend's acceptance couldn't be confirmed.
Check Resend before submitting again, since an email may have been accepted even if its response was lost. Automatic retries reuse the same idempotency key to prevent duplicate sends, while resubmitting the form creates a new operation that can send another email.
Copy link to headingTypeScript error in lib/recipients.ts after editing the registry
Every destination in lib/examples.ts needs a matching key in routingRecipients, which TypeScript checks through RecipientMap. Add the missing key with a valid inbox, or use null until one is available. Using null resolves the type error but keeps email delivery disabled for that form.
Copy link to headingThe deployed page times out on slow submissions
The page's maxDuration = 60 covers the full request, including model calls, email rendering, and delivery. Jev's 12-second timeout and the fallback's 25-second timeout provide a combined model-call budget of 37 seconds, with retries sharing each call's deadline.
Identify which stage is taking too long before adjusting the limits. You can reduce JEV_TIMEOUT_MS or LUNA_TIMEOUT_MS in lib/router.ts, or increase maxDuration in app/[example]/page.tsx if your plan supports it.
Copy link to headingNext steps
- Review the Jev x AI SDK Form Router template source to read the full routing policy, tests, and email template alongside this guide
- Read How to classify, route, and score with Jev and AI SDK to add
scoreandbooleanquestions to the sameexperimental_evaluaterequest, alongside thechoicetype this template uses - Follow How to automatically approve tool calls in eve with Jev to review an eve agent's proposed tool calls before execution, allowing routine actions automatically and requesting human approval for calls classified as
caution - Apply the same threshold-and-fallback approach to user-generated content in Moderate product reviews with Jev, TanStack AI, and AI Gateway, where clear reviews publish and flagged or uncertain ones go to a moderator
- Read What is Jev, TypeSafe AI's System One model? and When should you use Jev? to find other decisions in your application that fit a typed question
- Explore seven practical Jev use cases to find other decisions to automate, such as prioritizing support tickets or categorizing documents.
- Check the AI SDK evaluation contract for the full
experimental_evaluateAPI,Experimental_EvaluationMockModelV4, and the rounding tolerance the SDK allows when validating distributions - See Evaluation models on AI Gateway and the Jev model page for Zero Data Retention and No Training options, current pricing, and context limits