Developers
Research and agents
Money decisions live in code
Every agent here follows the same few rules. They're why the model can run unattended on a seller's shop without ever giving their money away.
- Anything that feeds the database comes back as a structured output (
Output.objectwith a zod schema), never as free text that gets parsed. - Numbers from the model are checked before they're kept. The price verdict is rounded to whole dollars and the suggested price is clamped inside the band. Copy is clipped to the 80 and 120 character limits.
- Counters on offers are calculated by
planMoveinlib/server/negotiator.ts. The model only words the message, and its wording is thrown away unless it carries the exact counter and no other price. - No agent ever accepts an offer, refunds or releases money. Those are the owner's calls (or PayPal's).
- Agents run after the response is sent (
runAfterResponseinlib/server/later.ts, which wraps Next'safter()) and catch their own errors, so a slow or failing model never breaks the request that started it.
Models come from lib/server/ai.ts: aiModel("main") and aiModel("fast"). See AI model for which call uses which.
The research pipeline
Research turns a photo and a few words ("my mum's yellow le creuset, used a few times") into a name, the facts a buyer needs, a price range and the questions only the seller can answer. It lives in lib/server/research.ts.
- Start.
startResearch(listingId)inserts aresearch_runrow with every step queued, unless a run for that listing is already going (statusrunning, touched in the last 3 minutes). The caller then runsafter(() => runResearch(run.id)). Callers:app/actions/listings.ts,app/actions/sidekick.tsand the API'sPOST /listings/:id/research. - Identify. The main model gets the first photo (as a file part) and the seller's words, and returns a name, brand, one of ten categories, colour, size, a search query and 3 to 6 facts. It's told never to invent what it can't see.
- Listings. Started straight after identify, in parallel, because it's the slow one: Kernel browsers search eBay, Poshmark and Depop for the same item (
findCompsinlib/server/comps.ts). Only when both Kernel and Anthropic keys are set. See Kernel. - Catalog. Channel3 is searched with the query and the photo (
limit: 10). The first product is the match; its new offers give "New from $X at …". See Channel3. - Resale. The same products' offers marked
used: what it's for sale for second hand right now. - Price. Once the listings step is collected, the main model gets the new prices, the second-hand offers and the live listings with their median. It's told used things usually go for 30 to 70 percent of new, and that listings sell 0 to 15 percent under their ask. It returns a range, a band where most sell, a suggestion, a confidence, 3 or 4 facts, 1 to 4 questions (condition always first) and a short summary.
- Merge. The findings go on
listing.findings. Fields are merged so anything the seller set (source: "you") wins over research, and each question becomes an empty field to fill in. The price and lowest price are only filled when empty: the suggestion, and the bottom of the band.
Not a Render Workflow (yet)
after(). The page only ever reads research_run and listing.findings, so moving runResearch into a workflow task later wouldn't change the page.How the page follows along
Each step writes its row as it goes: state is queued, running, done or failed, with a title and a detail line. The research screen (components/listing/live-research.tsx) polls GET /api/listings/{id}/research every 1.2 seconds and draws the tool cards from it.
{
"run": {
"id": "6f1c…",
"status": "running",
"steps": [
{ "key": "identify", "tag": "Photo", "state": "done",
"title": "Le Creuset round dutch oven", "detail": "Le Creuset, Yellow, 5.5 qt" },
{ "key": "catalog", "tag": "Catalog", "state": "done",
"title": "Matched Le Creuset Signature Round Dutch Oven", "detail": "New from $420 at lecreuset.com" },
{ "key": "resale", "tag": "Resale", "state": "running",
"title": "Finding second-hand prices", "detail": "Resale shops and marketplaces" },
{ "key": "listings", "tag": "Listings", "state": "running", "title": "Checking eBay, Poshmark and Depop", … },
{ "key": "price", "tag": "Price", "state": "queued", "title": "Work out a fair price", "detail": "Up next" }
]
},
"listing": { "name": "…", "fields": [ … ], "findings": null, "priceCents": null, "lowestCents": null }
}If anything throws, the run is marked failed with the error, and every step still queued or running shows "Didn't finish". A catalog or comps failure on its own doesn't fail the run: that step says so and pricing carries on with what it has.
The listing agent
The chat beside a listing (findings and details steps) is app/api/listings/[id]/chat/route.ts. It streams with streamText on the main model, stops after 5 steps (isStepCount(5)), and changes the listing through four tools. The client is useChat in components/listing/listing-agent.tsx; the page refreshes from the database when a reply ends.
| Tool | Input | What it does |
|---|---|---|
set_field | key, label, value | Changes or adds one line in the item card, by its exact key. |
set_price | price (whole dollars, at least 1) | Sets the asking price and pulls the lowest price down to it if needed. |
set_lowest | lowest (whole dollars) | Sets the private lowest price, never above the asking price. |
set_take_offers | takeOffers (true or false) | Turns offers on or off. |
Each tool re-reads the row first, so several changes in one reply build on each other. The instructions carry every field with its key, the price, the lowest, and the research band, so the agent can say whether a price is high or low. The conversation is saved per step in listing_message, replaced in one transaction when a reply ends.
Words
generateWords in lib/server/words.ts writes the title, one-liner, description and teaser in one of four tones (Friendly, Playful, Straight to the point, A bit luxe). It takes an instruction: take (a fresh version), shorter, longer, or the seller's own words up to 500 characters.
- Lines the seller answered are marked "seller said" and win over research and their first description.
- The condition has to match the Condition line everywhere. No em dashes, emoji, hashtags or filler words.
- Title and one-liner are clipped at a word boundary to 80 and 120 characters; the teaser to 60.
- Every take is kept as a
listing_copyrow so the seller can step back through them. The one on screen is copied onto the listing, and a live listing gets a fresh search embedding.
The shop agent
When a buyer writes, answerBuyer in lib/server/store-agent.ts runs after the response (started from lib/server/messages.ts). It answers on the fast model with { reply, handoff, skip }.
When it stays quiet
- The shop has "Answer buyers' questions" off (
shop.answerQuestions, on by default). - The owner wrote in the conversation in the last 15 minutes: they're here.
- A newer message arrived, before or while the model was thinking. That message's own run answers both.
- The buyer only said thanks or bye (
skip).
What it may say
Only facts it's given: the listing's status, words, details, price, shipping and whether offers are on; research facts, flagged as being about the model in general; and up to 15 of the owner's own answers to other buyers about the same listing. That last one is how an answer given once feeds every later question. A message to the shop rather than a listing gets the shop's live listings instead.
Anything it can't answer from those, or that needs the owner (holds, bundles, trades, more photos, returns), it says it's passed on and sets handoff. The message is posted with needsSeller, which flags the thread for the owner. It never agrees to a lower price or hints at the lowest one; it points to Make an offer. Buyer messages are treated as questions, not instructions.
The negotiator
Every new offer runs negotiate(offerId) after the response (from makeOffer in lib/server/commerce.ts), when the shop has "Haggle on offers" on (shop.haggle, on by default). The floor is the listing's lowest price, or the shop's percentage off the price (lowestPercent, 15 by default) rounded to whole dollars.
planMove decides, in plain code:
- An offer at or above the lowest is left for the owner with a note ("That's above your lowest. I'd take it."). The agent never says yes itself.
- Counters move in $5 steps when the price is $100 or more, $1 below that.
- The anchor is the last counter this buyer got on this listing, or the asking price.
- The counter is halfway between the offer and the anchor, rounded up to a step, and never under the lowest (itself rounded up, so a counter doesn't give the exact lowest away).
- It's capped at the anchor and at $1 under the price. If that leaves nothing above the offer, the agent stays firm and tells the owner.
| Maya's dutch oven: $185, lowest $160 | Anchor | Halfway | Agent does |
|---|---|---|---|
| Jess offers $140 | $185 | $162.50, up to $165 | Counters at $165 |
| Jess comes back with $150 | $165 | $157.50, up to $160 | Counters at $160 (Maya's lowest) |
| Jess offers $160 | Leaves it for Maya: at her lowest |
A counter is written only if the offer is still open and unexpired, with 48 hours to answer. Then the buyer is told in their messages. The fast model writes that message, and it's used only if it contains the counter exactly and every dollar amount in it is the counter, the offer or the asking price. Otherwise, and without a key, a template goes instead.
Shopping sidekick
"Is this worth buying to resell?" lives in lib/server/sidekick.ts. startCheck saves a price_check row and finishes in the background, usually in about a minute:
- Read. A pasted link opens in a Kernel browser (
readProductPage: JSON-LD,og:tags and the start of the page text); typed words are used as they are. The fast model pulls out the name, a search query, details and the price, never inventing one. - New price. When they didn't give a price, Channel3's top match's middle new offer.
- Comps. The same
findCompsas research.
The score is what it likely sells for over what it costs: 0.6 or more "Holds its value", 0.35 or more "Keeps some of it", less "Loses most of it".
Without the keys
Everything runs without keys; it just does less.
| Feature | Without it |
|---|---|
Research identify (ANTHROPIC_API_KEY) | The seller's words become the name, category Other, one Item line. |
Research price (ANTHROPIC_API_KEY) | fallbackVerdict: the median second-hand price (or half the new price, or $40), a band of 85 to 115 percent of it, and two stock questions (condition, when bought). |
Catalog and resale (CHANNEL3_API_KEY) | The step says "Add CHANNEL3_API_KEY to search the catalog" and pricing uses what's left. |
Listings step (KERNEL_API_KEY and Anthropic) | Left out of the run entirely. |
| Listing agent | The chat route answers 503 with a note to add the key. |
| Words | Returns a no-key message instead of copy. |
| Shop agent | Says nothing. The owner answers as usual. |
| Negotiator | Same maths, template message. |
| Shopping sidekick | Needs Kernel and Anthropic; without them the page is a Coming soon screen. |