Craigslist saved-search alerts → an LLM judge → one email with only the listings worth looking at. Runs on Val Town's free tier.
Craigslist emails a saved-search alert whenever matching posts appear. Those alerts re-fire on every renew, repost and edit, so the same item arrives over and over, and most of what arrives is not what you meant. This val parses each alert, fetches the real posting and its photos, asks Claude whether it matches criteria you wrote in plain English, and emails you only the good ones — with a nope button that suppresses that listing (and its reposts and crossposts) forever and teaches the judge what you don't want.
craigslist alert -> inbox.email.tsx parse, cheap dedup, enqueue, then judge inline
| SQLite: posts(uuid, pid, search, title, ..., status)
|
+--------> lib/drain.ts claim a row -> detail API -> dedup ladder
| -> images -> one LLM call -> verdict
| -> one email per drain
v
judge.cron.tsx the same drain, for the overflow: the rest of a
| multi-result alert, backlog, retries
v
feedback.http.tsx the nope / more-like-this links, HMAC signed
An alert is judged the moment it arrives — most alerts are a single result, and waiting for the next cron tick would be latency for nothing. That is safe because the drain measures its 40-second deadline from the email handler's start, so parsing counts against the same budget, and it will not begin a post without 38 seconds still in hand. Val Town's free tier kills a run at 60 seconds and repeated overruns temporarily pause the val, so the rule is that the inline pass degrades by doing less, never by running longer: whatever it declines to start, the cron takes. Because two drains can overlap, each post is claimed with a conditional UPDATE and only one of them wins it.
Duplicates are caught by a ladder of keys, strongest first: the numeric post id (stable across
renew), repostOf (craigslist records repost lineage itself), the sorted set of image ids
(catches crossposts under different ids and different coordinates), and finally
title + price + lat/lon rounded to three decimals. A duplicate inherits the original's verdict
and costs no model call.
All configuration is in env vars, because a free-tier val is public and its source is world-readable.
| variable | required | what it is |
|---|---|---|
CRITERIA_JSON | no (but the point) | {"default": "<prompt>", "searches": {"<saved search name, lowercased>": "<prompt>"}} — what you actually want, per saved search |
ANTHROPIC_API_KEY | no | Without it the val runs in DRY_RUN: everything except the model call, verdict stored as dry |
JUDGE_MODEL | no | Defaults to claude-sonnet-5 |
FEEDBACK_SECRET | yes for the buttons | HMAC key for the nope / more-like-this links. Without it the feedback endpoint answers 503 and the digest ships without buttons |
FEEDBACK_URL | no | The public URL of feedback.http.tsx. Discovered automatically at runtime if unset |
SEND_IN_DRY_RUN | no | 1 makes DRY_RUN still send the digest, for testing the email path |
- Set
CRITERIA_JSON,ANTHROPIC_API_KEYandFEEDBACK_SECRETin the val's environment variables (sidebar in the Val Town editor), and set the cron interval onjudge.cron.tsx(15 minutes is the free-tier minimum). - Turn on the alert checkbox for each saved search in your craigslist account.
- In Gmail: Settings → Forwarding → add this val's inbound address (shown on
inbox.email.tsxin the Val Town editor). Gmail sends a verification code to it; the handler stores that mail in bloblast-other-email, so read the code withblob.getJSON("last-other-email"). - Add a Gmail filter
from:alerts@alerts.craigslist.org→ Forward it to that address, and probably Skip the inbox so only judged results land in front of you. - Forward one alert by hand first and check the log line
ALERT {...}.
Tick the alert box on the new saved search in craigslist. Nothing here needs to change: the
alert subject is (N new results) <saved search name> and that name is the key. Add an entry
under searches in CRITERIA_JSON when you want it judged on its own terms — until you do, it
falls back to default and logs CRITERIA_FALLBACK, so a new search is never silently dropped.
Roughly one 2 KB JSON call and at most five cached images per alerted post — three orders of
magnitude under the 1,000 pages per 24 hours craigslist's own terms name. Browser-like
User-Agent, GET only, never ?format=rss, never /reply or anything else robots.txt
disallows. If craigslist ever answers with its block page, the run logs the blockID, marks the
post blocked_by_cl and stops. Do not retry it and do not move it to another IP.
deno task test # unit tests for the email parser, the dedup ladder and the link signing
deno task check # typecheck, including the esm.town imports
vt push # deploy