This is a submission for the Hacktoberfest Open-Source AI Challenge, Week 2: Side Hustle.
There is a woman in a charity shop basement holding a denim jacket she cannot identify. Her phone has no signal down there. She has about ten seconds to decide whether eight dollars is a good idea.
She does what everyone does: opens a marketplace app, types a guess, squints at a dozen listings that may or may not be the same jacket, and buys on a hunch. Or doesn't. She will never find out which was right.
I built Sourcerer to answer the one question she actually has — what is the most I should pay, right now? — and it runs on her own laptop, offline, with no API key and no per-scan cost.
It also has exactly one rule, and the rule is the whole project:
The model never writes the price.
The model looks at the jacket. Plain TypeScript does the money. Everything interesting that happened while building this came out of that one line.
landing page · source · short on time? The verdict card is the product in one screen. Every claim below points at a file you can open, and docs/KEYS.md has the live-verification status of every keyed integration in one table.
What I Built
Photograph the item — or type what you see, if the light is bad — and a local 4B multimodal model reads it:
Levi's 501 "Big E" selvedge denim jacket, size 42, small hole at cuff
A few seconds later you get this:
VERDICT ● BUY
MAX OFFER $35.32
EST. RESALE $133.40 ($126.73 – $140.07)
AT $12 $93.96 profit, after eBay fees, shipping and supplies
BASIS 6 items from your own sold history
The model produced one line of that. The other four came out of packages/shared, because the actual problem is a solve:
max_offer = (resale − resale·fee − shipping − supplies) / target_multiple
eBay's 13.6% plus a $0.30 payment fee on the $133.40 sale is $18.44. Shipping $8, supplies $1, net $105.96. Divide by the 3× multiple she wants to make and you get $35.32. Change the price on the tag and the card re-prices instantly with the network switched off, because none of that is a model call.
Where it runs. No phone holds a 3.3 GB vision model, so Gemma lives on the laptop or home machine she already owns. The phone is the camera and the screen; the two talk over her own network. That covers the case that matters — the basement has no signal and the laptop is in the bag. With neither machine reachable the app still boots from the service-worker cache and prices from her own sold history, tagged estimate so it can never pass for a live comp. The browser-only fallback admits what it is — heuristics, not the model — instead of guessing silently.
Who pays for it. A weekend flipper doing 20–40 items a month, who is not optimising a warehouse. She is trying to stop buying things that sit in a tote for a year, and to stop walking past a $140 flip because she wasn't sure. The app writes her listings too, which is the other half of the job: the 11pm grind of titles and descriptions for a pile of things she bought that morning.
Demo
Four screenshots, in the order you would use them. If you want the whole thing unedited, the webm is a single run on this laptop.
The verdict card. One dominant signal — the biggest type on the screen is the number you can pay — plus a chip that says where the number came from. Resale is the comps median adjusted for condition: these comps are good, so ×0.92, which is why it sits just under the raw median in the panel below.
The run log is the part worth pausing on, and not just for the privacy line. It is a field in the scan response, not an overlay, so it cannot lie about a run that already happened:
What ran where. Four stages in the workflow, three rows here, because the verdict stage is arithmetic — it calls no provider and so has nothing to report. The header chip names the brain's primary model (gemma3:4b, the vision default) while each row names the model that actually ran that stage; text-only paths deliberately use the 1B, which is why Identify says gemma3:1b.
Every row here is a price this user actually got, not an asking price scraped from a stranger.
Network genuinely off, taken in the browser with the connection disabled. Dead spot in a charity shop is the intended operating condition, so the app had better boot there.
All 21 screenshots, with captions and alt text, are in docs/media/, catalogued in CAPTIONS.md. Every one of them came out of scripts/capture-evidence.ts, which drives the real app in a real browser against a real brain. They are not mockups. The cover image is the only drawn thing here, and even that is composed from one of the captures.
That script also caught a bug I would otherwise have shipped, which is worth 30 seconds of your time below.
Code
https://github.com/ItsPriyamSri/sourcerer
MIT. No API keys needed to see the core loop work:
pnpm install && bash scripts/setup.sh
pnpm --filter @sourcerer/brain dev
pnpm --filter @sourcerer/web dev
If you only read two files: the README says which technology does which job, and docs/ARCHITECTURE.md covers the pipeline and how every layer behaves when a provider is missing.
How I Built It
The whole thing in one picture
flowchart TD
web["apps/web (PWA)<br/>Scan · Listings · Insights<br/>IndexedDB is the source of truth for captures"]
brain["apps/brain (Hono)<br/>GET /capabilities · POST /scan (SSE)<br/>POST /advisor · /listing · /similar<br/>POST /train · /transcribe · /reprice<br/>GET /inventory · GET /insights"]
steps["core/steps<br/>the deterministic pipeline"]
mastra["mastra/*<br/>agents, tools, workflow, memory"]
durable["durable/*<br/>Temporal worker + client"]
providers["providers/<br/>llm · comps · pricing · vector · voice · tracing<br/>every one optional, every one swappable,<br/>every one reports its own truth"]
web -->|"POST /scan, streamed stage by stage"| brain
brain --> steps
brain --> mastra
brain --> durable
steps --> providers
mastra --> providers
durable --> providers
The middle row is where the deterministic code lives; the bottom row is where every paid or keyed service lives, and all of it is optional. That is why deleting every API key leaves a working app that still prices from your own sold history.
The rule exists because I tried it the other way first
The obvious move is to hand a 4B model the photo and ask it whether to buy. I did that. It is bad at the second half in a way that is hard to notice and expensive to ignore, because a language model is the wrong instrument for a solve. Ask it for a verdict and you get a confident, plausible, arithmetically wrong answer.
So the split is permanent. Read the item with the model; do the money with pure functions.
| Who does it | Why | |
|---|---|---|
| Read the item from a photo | Gemma 3 4B, locally | Fuzzy, tolerant of bad light, occasionally wrong in interesting ways. Perfect fit. |
| Name the keywords a reseller would search | Gemma 3 | It knows that a "Big E" is a thing. |
| Price the market | Live listings, or your own sold history | Ground truth beats generation. |
| Decide the money |
Plain functions — packages/shared/src/economics.ts
|
Deterministic, unit-tested, identical offline. |
The payoff is that the verdict your browser shows offline and the verdict the server computes online are the same code. decideVerdict is pure and takes no I/O, so a reseller typing a price in a dead zone gets an answer that agrees with the server to the cent. That is not a nicety. It is the reason the tool can be trusted with money.
The best fix in this project is a type annotation
Every experiment in this section is the same 30 hand-labelled items through four configurations, scored mechanically by what I wrote in the text field — a brand and an item type — so every failure below is checkable by reading the row. Full output: docs/evidence/identify-results.json.
| Configuration | Parseable JSON | Brand correct | Type correct | Both | Invented a brand | Median |
|---|---|---|---|---|---|---|
v1 prose prompt, no schema |
100% | 97% | 100% | 29/30 | 1/4 | 8.8 s |
v2 same prompt, + JSON schema |
100% | 100% | 100% | 30/30 | 0/4 | 8.6 s |
v3 full rules prompt, + schema |
100% | 100% | 100% | 30/30 | 0/4 | 8.5 s |
v4 v3 + anti-invention rules |
100% | 100% | 100% | 30/30 | 0/4 | 8.3 s |
Model gemma3:4b, temperature 0.1, 30 items, one machine, one afternoon.
I built the JSON path braced for a fight — fenced blocks, prose wrappers, objects truncated mid-string — and the naive prompt returned a parseable object 30 times out of 30 on the first try, by wrapping it in a json fence my extractor already handled. Prompt craft bought exactly zero points of parseability, because there was nothing to buy. If that table only had column two in it, I would have concluded the prompts did the work. Then item 21 happened.
Item 21 is "Eames style lounge chair, rosewood, no labels anywhere." Ground truth: no brand. Same model, same 30 items, same temperature — only the prompt and the schema change:
| Configuration |
brand field |
confidence |
caveats |
|---|---|---|---|
| naive prose prompt, no schema | "Eames (Style)" |
0.95 | — |
| same prompt, + JSON schema | (omitted) | 0.95 | — |
| + full rules prompt | (omitted) | 0.85 | — |
| + explicit anti-invention rules | (omitted) | 0.6 | "No visible labels, condition is based on visual assessment only." |
That first row is why this project is shaped the way it is. The model didn't produce garbage. It produced a clean, well-formed, confident record with a fabricated brand in a machine-readable field. Nothing about it looks wrong. It becomes a listing title and the basis of a price, and the person at the counter has no reason to doubt it.
It failed on exactly one item, too — the other three unbranded items came back "Unknown". The error isn't diffuse. It is optimistic, specifically on unbranded goods that resemble something valuable, which is the exact inventory a flipper overpays for and the error they are least likely to catch.
Two fixes, two different jobs:
-
The schema killed the fabrication.
brandis typed["string", "null"], so "I don't know" became a legal answer and the model took it. Inventions: 1 → 0. - The prompt changed calibration, not accuracy. Accuracy was already 100% by column two. The anti-invention rules moved stated confidence on the one unverifiable item from 0.95 to 0.6 and made the model volunteer a caveat nobody asked for. Same answer, better answer — because now it tells you how far to trust it.
So the fix for the worst failure in this project was a type annotation, not a paragraph of instructions. I would have bet the other way.
What's still wrong: in column four the title still reads "Eames Lounge Chair Rosewood." The doubt left brand, which carried the most weight, but the title still names a designer and the title is what gets pasted into a listing. The money is unaffected — the verdict is anchored to comps, not the title — but the listing writer should refuse a designer's name whenever brand is null. That's the next thing I'd build. I also left my scorer unable to catch it: it flags "Eames (Style)" mechanically and cannot flag "Eames Lounge Chair", and I chose not to tune it until the number looked better.
Two models, because the good reader can't hold a wrench
The sourcing advisor is a Mastra agent with five tools — search comps, estimate a price, do the fee math, save the item, look up similar past purchases — each one wrapping the same deterministic function the pipeline uses. The agent physically cannot invent a number it would have to fetch.
It runs on a different model from the one that reads the photo, and that is a capability boundary, not a bug report. Ollama reports Gemma 3's capabilities as completion, vision. Vision is exactly why it's the right reader, and tool calling is simply not part of the model:
$ ollama show gemma3:4b
Capabilities
completion
vision
So the loop runs beside it on llama3.2:3b — tools, no reasoning mode, and the same money question answered in about two seconds. The reasoning models were the obvious candidate and the wrong trade: Qwen 3 4B calls tools correctly, then deliberates before every single step. Roughly 35 KB of thinking per step across a six-step budget is over four minutes to answer a question about a $215 sale. Arithmetically right. Unshippable.
Two details fall out of the same boundary: think is read from the model's own reported capabilities rather than hard-coded, because a non-reasoning model rejects the request outright instead of ignoring the flag; and the tool schemas coerce, because this model sends {"resalePrice":"215"} — a string where a number belongs. Gemma keeps the jobs it's good at, reading and writing listings, and GET /capabilities reports the tool model separately. With no tool model installed the advisor degrades to a deterministic answer instead of a 500.
The model restated my numbers and then contradicted itself
The loop worked and the transcript was clean, which is when I noticed the same question giving different answers. A model that small will restate your numbers perfectly and then invert the conclusion. Asked about a $150 snare with a $58.82 ceiling, one run said the asking price was below the ceiling and the verdict was BUY. Both numbers correct. Comparison backwards.
Three runs, three verdicts:
run 1 PASS. Most I would pay is $58.82.
run 2 The item should be bought.
run 3 The reseller should pass on the snare drum.
A number a language model paraphrased is a number you cannot audit, and auditable numbers are this app's entire claim. So the money sentence stopped being the model's to write: feeMath composes the reply — verdict, ceiling, profit, every figure straight out of the same decideVerdict() the scan screen uses — and the route sends that instead. Same question, five times, one answer, character for character:
PASS. Most I would pay is $58.82. At $150 you would make $26.46 if it resells at $215.
What's left to the agent is what it's genuinely good at: understanding the request and calling the right tool with the right arguments. The three-turn transcript in docs/evidence/agent-transcript.json is the proof rather than the assertion — "I'm shipping cross-country, so put shipping at $35" becomes shippingCost: 35, the ceiling moves from $58.82 to $49.82, and the third turn still knows what the first one was about. The advisor picks the tool. It does not get to pick a number.
eBay took away free sold prices, so I didn't lie about it
eBay moved completed listings behind a login and the search API's sold filter went with them. There is no free source of real sold prices for eBay any more.
The tempting move, and by far the most common one, is to quietly return asking prices labelled as sold. A reseller who finds out you did that never trusts another number you show them.
So every comp is tagged kind: "asking", the UI says asking on every row, and prices get multiplied by a visible, named constant:
export const ASKING_TO_SOLD_FACTOR = 0.86; // labelled everywhere it is applied
A 14% haircut is not a sold price. It's a defensible range, presented as a range, with the reasoning attached. The whole thing sits behind a CompsProvider interface so a real sold-comps vendor drops in without touching the pipeline.
I killed the worker on purpose, mid-sweep
The job nobody watches is where durability stops being a slide and starts being a line item: comps go stale, and re-pricing a few hundred inventory items shouldn't be something a reseller sits and waits for.
apps/brain/src/durable/ is that job on Temporal. Each item is two activities — refresh comps, then apply the new price — with an explicit retry policy on the flaky search call and a paceMs delay between items. The pacing isn't a demo trick, it's a rate limit for a paid search API; it just happens to make the crash window wide enough to aim at.
So I aimed at it. SIGKILL, mid-sweep, no cleanup, no graceful shutdown — the way a laptop dies when someone shuts the lid at a sale:
items in the sweep : 12
worker #1 refreshed before the SIGKILL : 3
worker #2 refreshed after the restart : 9
distinct items across both worker logs : 12
items refreshed twice (should be 0) : 0
activity executions in the history : 24 (expected 24 = 2/item)
workflow status right after the kill : RUNNING
final result reported by the client : done: 12/12 refreshed, 0 failed
The second worker replays the workflow history, sees the first three activities already completed, and starts at item four. The number that matters is items refreshed twice: 0. The crash cost wall-clock time and nothing else. bash scripts/temporal-durability-demo.sh reproduces the whole thing including the kill, and the raw worker and client logs are committed in docs/evidence/temporal/.
Pointing real services at real code found the bugs that mattered
Three more defects, two of them silent:
-
Ollama 0.40 removed
POST /api/embeddingsin favour ofPOST /api/embed, so every upsert stored a NULL vector. "Have I bought this before?" quietly degraded to keyword matching and nothing raised an error anywhere. - A 32-item seed then blew the request timeout on every embed, because Ollama answers one request per model at a time. The writes are serialised now.
-
The comps step logged a network call as
provider: cache/locality: device— the one line whose job is to tell you whether your query left the machine — because SerpApi sits behind an on-disk cache that describes itself as local. It reads the result's ownsourcenow.
And one more that only a screenshot could catch. capture-evidence.ts renders the app and writes frames to disk, and every frame came back priced as if the item were free. The asking price you typed on the capture form was being silently discarded by the verdict card, which kept a private copy of the price initialised to zero. It survived every typecheck, every build and the entire test suite, because nothing tested a screen.
A screenshot is a test you cannot lie to.
Numbers
Every figure below is measured on 8 CPU cores, 15.4 GB of RAM and a GTX 1650 with 3.6 GB of VRAM — enough to hold part of the model, not all of it, so most inference runs on CPU. No cloud GPU, no paid inference, at any point.
-
101 unit tests pass (
pnpm test, no model, no network): eighteen inpackages/sharedon the parts that decide whether someone makes money — eBay's fee arithmetic, the max-offer solve, the verdict thresholds, trimmed-mean robustness to one seller asking ten times the going rate — and eighty-three inapps/brain, mostly pinning the seams where a model meets code. -
30–40 seconds to identify from a photo, and 8.3–8.8 seconds from a typed note, across 120 real model calls in the eval above. The photo is the product, so the photo is the honest headline. The first scan after a restart adds about 75 seconds of weight loading, paid once per session because the request sets
keep_alive: 30m. Thirty seconds is slower than the ten-second decision this tool is built for, and that gap is the main thing it doesn't yet win. - The advisor answers a money question in about two seconds warm, with the same sentence coming back character for character five times running. That last number cost the section above.
- Zero bytes uploaded on a local scan. Photos are downscaled on-device before anything else happens.
-
12 items, 3 + 9, 0 duplicates, after a
SIGKILLin the middle of the sweep.
Why Does Open Innovation Matter?
The glib answer is "privacy," and a third of this challenge's field has already said it, so let me be specific about the part that isn't ideology.
The decision happens where there is no signal. Not "sometimes" — characteristically. Basements, back rooms, barns, rural estate sales, the far corner of a flea market warehouse. Any architecture that needs a round trip to price an item fails at the exact moment the product exists for. That's not a cost problem, it's a physics problem, and the only fix is to put the model where the decision is.
Being open-weight is what makes that possible at all. I can ship a 3.3 GB vision model that runs on a laptop with no cloud GPU because it's open weights — no per-call pricing, no rate limit, no vendor that can deprecate the model version my prompts depend on, and no terms of service standing between a user and the file on their own disk.
The model is load-bearing, not a garnish. A reseller cannot price an item she cannot name, and naming it used to require a person who knows the category: the query that finds comps is big e levi's jacket, not denim jacket. That step belongs to the model, and when it fails it fails quietly — which is why so much of this post is about how it fails rather than how fast it is.
What it costs to run: zero per item, forever. The flipper this is for does 20–40 items a month plus the ones she puts back on the shelf — a few hundred scans a year, so a cent or two per call would be single-digit dollars annually. Cost was never really the argument; the round trip is. A cent is cheap right up until there is no signal, which is the exact moment this tool exists for. Electricity is the whole running cost, and it's what makes a $5/month price even arguable — though nobody has paid it yet.
Where a closed model would genuinely be better. A frontier model has a bigger world-knowledge base for genuinely obscure collectibles and no quantisation loss on fine label detail. This 4B model is confident about Levi's and Pyrex and shaky on a mid-century chair with no marks — which is exactly the row I left in the table above. If you're appraising one $4,000 item, call a proper model. Sourcerer optimises for the other case: forty $12 items and ten seconds each.
Honest limits.
- It's slow on CPU: 8–9 seconds from a typed note once warm, 30–40 from a photo, ~75 seconds on the very first scan of a session.
- The sold-comp data does not exist for free. An asking price is not a sold price, and the 0.86 haircut is a judgement call, labelled as one everywhere it's applied. So is the 3× target multiple, which is the single biggest lever on the ceiling.
- Nobody has paid for this yet. There's no first-user section and no testimonial, because inventing one isn't worth a paragraph. The persona is inferred from the shape of the problem, the price is a hypothesis, and the only demand signal so far is that I built it for myself and use it.
- The price model is empty on day one. With no sold history it falls back to condition-adjusted comps and a deliberately pessimistic guess, so the first week's verdicts lean PASS.
- The advisor degrades whenever the tool model isn't installed. On a machine with only Gemma, everything except the conversational advisor works — and the app tells you which, rather than failing quietly.
- Untested at scale. The largest eval here is 30 items, labelled by one person, on one machine, in one afternoon.
- Not typed for the browser's model. The on-device fallback used when the brain is unreachable is heuristics, not Gemma-in-the-tab. Real gap, obvious next piece of work.
Prize Categories
Two lists, because "genuinely used" should mean more than an import statement. The first has evidence in this post: a measured eval, raw logs, a screenshot you can reproduce. The second is integrated and called by the running app, with a real account behind it, where a provider's free tier — not this code — stopped the full demonstration. Merging the two would buy three extra category claims at the cost of the only thing this post has going for it.
Demonstrated end to end
-
Best Use of Gemma — Gemma 3 4B runs locally through Ollama's native API via
format: <JSON Schema>to read items and write listings, with the 1B variant taking the cheap text-only paths so a title costs a second instead of a model load. This is the core of the product, and its accuracy is measured over 30 labelled items across 4 configurations above. -
Best Use of Mastra — the agent runtime. Five tools wrap the deterministic pipeline functions, so the agent physically cannot invent a price it would have to fetch; a sourcing advisor runs them with LibSQL-backed memory across turns; a listing-writer agent drafts the post; and a four-step typed scan workflow is composed from the same step functions the HTTP route calls. It's also where the project's thesis got its hardest test — the model restated the numbers correctly and inverted the conclusion, so the money sentence is now composed by the tool and the agent's remaining job is choosing its arguments. It does that well enough to turn "put shipping at $35" into
shippingCost: 35mid-thread. -
Best Use of Temporal — the durable reprice workflow, with an explicit retry policy on the flaky search call. Durability demonstrated rather than asserted: a
SIGKILLmid-sweep, a second worker, 12 items refreshed exactly once, zero duplicates. -
Best Use of SerpApi — live eBay listing search with the condition filter, free-shipping filter, result cap and a JSON restrictor to pull only the fields needed. It's why
comps.sourcecan becomeliveinstead oflocal_history, verified against a real account: a scan for a brass narwhal bottle opener returned 51 real eBay listings for the querybrass bottle opener nautical, raw response committed atdocs/evidence/serpapi-live.json. The scan log names the query it sent and marks the network stepcloudwhile the two model steps staydevice. - Best Use of Tiger Data — Postgres + pgvector for "have I bought one of these before?" hybrid retrieval, exercised live against pgvector 0.8.7: 32 items embedded through the real pipeline, then a search for "something to cook with in the kitchen" returned Pyrex mixing bowls and a Griswold skillet with no shared keywords with the query, which is the entire point of a vector index. The same code talks to Tiger Cloud by connection string; the local container exists so the answer to "do I have to hand over a database?" is no.
-
Best Use of Sentry Agent Tracing —
@sentry/nodespans around identify, comps and price, so one scan becomes a waterfall instead of a wall-clock reading, plus error capture that carries the route it came from. Verified against a real project:pnpm keys:checkreads the SDK's own delivery receipt back (HTTP 200), a real scan ships its span envelope (Sending span envelope for trace 39adcde… with 2 spans), andGET /healthreportstracing: true. genAI input recording is explicitly off, because v11 removedsendDefaultPiiand an unsetdataCollectioncollects prompt and image content by default — so photos do not enter a trace. The framework's owngen_ai.*spans are not wired; that needs@mastra/observability, and I'd rather say so than let a span name imply token accounting that isn't there.
Wired, and exercised as far as the free tiers allow
These run in the app, sit in the capability registry, and are what the UI falls back from when a key is missing. Where a key exists, it's been pointed at a real account too.
| Partner | What's built | What stopped the full demonstration |
|---|---|---|
| Backboard | The second brain: the same agents against open-weight models through one key, which is what makes a GPU-less demo possible. | Its free credit excludes LLM chat, and a failed run returns HTTP 200 with status: "FAILED". The pipeline once read the billing notice as the item's title and sent misc Your free to eBay. Failed runs throw now, so llmChat falls through to local Gemma and the scan comes back correct: docs/evidence/backboard-credit-wall.json. |
| ElevenLabs |
POST /transcribe and POST /speak, both in the registry. Voice in is verified live: scribe_v2 returns HTTP 200 against a real key. |
The mic and play controls aren't in the UI yet, so this is a live integration rather than a button you can press. Voice out is refused by the free plan, not the code: 402 paid_plan_required on library voices, default included. |
| TabPFN | A price model fitted on the user's own sold history, so the offline number is personal rather than generic. | Implemented, not verified with a live token. It predicts price; sale speed is recorded on sold items but not modelled, and the repo says so. |
Not entered: GitHub Copilot is a development tool, not a product integration. MongoDB Atlas would duplicate the vector memory Tiger Data already provides. Arduino has no hardware here. Length wouldn't make the app better.
docs/KEYS.md states the verification status of every integration in one table, and GET /capabilities reports the same thing at runtime — so nothing above has to be taken on trust.
If you take one thing from this post: the model is the part that looks at the world, and it is genuinely good at that. The part that decides what to pay should be boring, deterministic, and testable, and I now trust it more than the intelligence sitting next to it.
I built this because I kept walking past things I should have bought. It's the first tool I've made where I trust the arithmetic more than the AI, and that turned out to be the whole design.





Top comments (0)