Built with
AI model
Models
lib/server/ai.ts builds an AI SDK provider registry and hands out two tiers: a main model for the careful work and a fast one for quick replies. Each is named in env as provider:model. Out of the box both point at Anthropic models (the defaults are in .env.example and render.yaml).
| Tier | Env | Used for |
|---|---|---|
main | AI_MODEL | Seeing photos, pricing, matching listings, writing, the listing agent |
fast | AI_MODEL_FAST | Search queries, buyer answers, counter messages, reading links |
// lib/server/ai.ts
const registry = createProviderRegistry({ anthropic });
export function aiModel(kind: "main" | "fast" = "main"): LanguageModel {
const id = kind === "fast" ? process.env.AI_MODEL_FAST : process.env.AI_MODEL;
return registry.languageModel(id as ModelId); // "provider:model"
}
export const aiConfigured = Boolean(process.env.ANTHROPIC_API_KEY);Changing the model
Nothing outside lib/server/ai.ts knows which model or company it's talking to, so switching is a settings change.
- Another Anthropic model: change
AI_MODELorAI_MODEL_FASTand restart. No code changes. - Another provider (OpenAI, Google, Mistral, a local model, or any other AI SDK provider): install its AI SDK package, add it to the registry, set its API key, and point
AI_MODELandAI_MODEL_FASTat it.
// lib/server/ai.ts: add the provider's AI SDK package to the registry
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
const registry = createProviderRegistry({ anthropic, openai });
// then point the env at it
AI_MODEL=openai:<model>
AI_MODEL_FAST=openai:<smaller model>What the new model needs
aiConfigured check to look for the new provider's key, since features switch on from it.Every call
| Feature | Tier | API | File |
|---|---|---|---|
| Identify the item (with the photo) | main | generateText + Output.object | lib/server/research.ts |
| Price verdict and questions | main | generateText + Output.object | lib/server/research.ts |
| Comps: marketplace search queries | fast | generateText + Output.object | lib/server/comps.ts |
| Comps: is each listing the same item, and its price | main | generateText + Output.object | lib/server/comps.ts |
| Listing agent chat | main | streamText with 4 tools, up to 5 steps | app/api/listings/[id]/chat/route.ts |
| Listing words | main | generateText + Output.object | lib/server/words.ts |
| Shop agent answering buyers | fast | generateText + Output.object | lib/server/store-agent.ts |
| Counter-offer message | fast | generateText (plain text, then checked) | lib/server/negotiator.ts |
| Sidekick: what is this and what does it cost | fast | generateText + Output.object | lib/server/sidekick.ts |
Comps matching uses the main model on purpose: the fast one let look-alikes through in testing. How each one fits into its feature is on Research and agents.
How it's called
Almost everything is generateText with output: Output.object({ schema }), a zod schema whose .describe() strings tell the model what each field means. The result is typed and validated before any code uses it. Photos go in as file parts. AI SDK v7 names: instructions for the system prompt, stopWhen: isStepCount(n) for tool loops, and toUIMessageStream for the chat response that useChat reads.
const { output } = await generateText({
model: aiModel("main"),
output: Output.object({ schema: identitySchema }),
instructions: "You identify second-hand items people want to sell. … never invent details you can't tell.",
messages: [{ role: "user", content: [
{ type: "file", mediaType: photo.contentType, data: photo.data },
{ type: "text", text: `The seller says: "${prompt}". What is it?` },
] }],
});Guardrails
- Structured outputs for everything stored. Nothing is parsed out of free text.
- Numbers checked in code. The price verdict is rounded to whole dollars, the band is kept in order, and the suggestion is clamped inside it. Listing tools are integers with minimums, and the lowest price can never go above the asking price.
- Validated counters. The counter is calculated by
planMove. The model's message is used only if it contains that exact amount and no dollar figure other than the counter, the offer and the asking price. Otherwise a template goes. - Answers only from facts. The shop agent is given the listing's facts and the owner's own past answers, told not to guess, and hands off to the owner (
handoff) when the facts don't cover it. Research facts are marked as being about the model in general. - Buyer text is data. The shop agent is told buyer messages are questions, not instructions, and never to reveal the lowest price.
- No money moves. No model call can accept an offer, refund or release. The listing agent's four tools only edit the seller's own draft.
- Copy rules. Condition must match everywhere, no invented facts, and lengths are clipped in code.
Because these checks live in code, they hold whichever model is plugged in.
What it unlocks
- Sellers list from a photo and a sentence: named, priced and described, with the questions only they can answer.
- An agent beside each listing that changes it when asked ("make it $170 and turn offers off").
- Buyers get answers in seconds, any time, and the owner only sees what needs them.
- Lowball offers get a reasonable counter straight away, in a friendly message.
- Shoppers can paste a link and learn whether it holds its resale value.
Without a key
| Feature | Without an AI key |
|---|---|
| Research | Still runs. The seller's words become the name; the price comes from catalog and comps numbers alone (fallbackVerdict). |
| Comps and sidekick | Off: they need Kernel and the AI model together. |
| Listing agent | The chat route answers 503. |
| Words | A message asking for the key. |
| Shop agent | Stays quiet; the owner answers. |
| Negotiator | Counters as usual, with a template message. |
Setup
ANTHROPIC_API_KEY=…
# optional: pick the models, as provider:model
AI_MODEL=…
AI_MODEL_FAST=…The defaults for both models are already in .env.example and render.yaml. Tests blank the key, so nothing in the suite calls a model.