Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

cl-triage

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.

How it works

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.

Environment variables

All configuration is in env vars, because a free-tier val is public and its source is world-readable.

variablerequiredwhat it is
CRITERIA_JSONno (but the point){"default": "<prompt>", "searches": {"<saved search name, lowercased>": "<prompt>"}} — what you actually want, per saved search
ANTHROPIC_API_KEYnoWithout it the val runs in DRY_RUN: everything except the model call, verdict stored as dry
JUDGE_MODELnoDefaults to claude-sonnet-5
FEEDBACK_SECRETyes for the buttonsHMAC key for the nope / more-like-this links. Without it the feedback endpoint answers 503 and the digest ships without buttons
FEEDBACK_URLnoThe public URL of feedback.http.tsx. Discovered automatically at runtime if unset
SEND_IN_DRY_RUNno1 makes DRY_RUN still send the digest, for testing the email path

Setup

  1. Set CRITERIA_JSON, ANTHROPIC_API_KEY and FEEDBACK_SECRET in the val's environment variables (sidebar in the Val Town editor), and set the cron interval on judge.cron.tsx (15 minutes is the free-tier minimum).
  2. Turn on the alert checkbox for each saved search in your craigslist account.
  3. In Gmail: Settings → Forwarding → add this val's inbound address (shown on inbox.email.tsx in the Val Town editor). Gmail sends a verification code to it; the handler stores that mail in blob last-other-email, so read the code with blob.getJSON("last-other-email").
  4. Add a Gmail filter from:alerts@alerts.craigslist.orgForward it to that address, and probably Skip the inbox so only judged results land in front of you.
  5. 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.

Being a good citizen

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.

Development

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