A rubric-based curation pipeline for agentic-AI-tooling links. The model scores and summarises. Deterministic code owns fetching, validation, filtering, and publication.
Node.js, ES modules, zero dependencies. Anthropic Messages API over fetch.
Live output: ctaio.dev
Most LLM content pipelines let the model decide what ships. This one doesn't.
RUBRIC.md is loaded as the system prompt. The model applies that rubric to a fixed candidate set and returns a score, a category, and a one-line summary. That is the entirety of its authority. Every other step — what gets fetched, what counts as valid, what clears the threshold, what order it publishes in — is ordinary code with no model in the loop.
The model informs. The code decides.
A response that fails validation is rejected and logged with a reason. It is never coerced into shape.
Repairing malformed model output is tempting and almost always wrong: once the parser has been loosened to accept bad output, you can no longer tell whether the model understood the task or whether your code hid the fact that it didn't. The failure gets buried exactly where you needed to see it.
Two layers enforce this.
Pipeline-level — hard exit. The run aborts rather than degrading:
ANTHROPIC_API_KEYunset- Any HN Algolia request returns non-2xx
- Anthropic API returns non-2xx
stop_reason === "max_tokens"— a truncated response cannot be trusted, even if the prefix parses- Response body is not parseable JSON
- Response parses but is not an array
Item-level — reject with a reason. Each element is checked independently; failures are collected, not thrown:
| Check | Rejection reason |
|---|---|
| Element is an object | element is not an object |
id appears in the candidate set |
id "…" is not in the candidate set (hallucinated item) |
score is an integer 0–10 |
score … is not an integer between 0 and 10 |
category is in the allowlist |
category … is not in the allowed list |
summary is non-empty |
summary is empty |
summary ≤ 160 chars |
summary is N chars (max 160) |
summary contains no URL |
summary contains "http" |
The id check is the one that matters most. The model is handed a candidate set and asked to return one element per story using the id verbatim. Any id it returns that wasn't in that set is a fabricated item, and it is caught by set membership rather than by a judgement call.
Rejections are not discarded. They are written into reads.json alongside the published items, with the reason attached — so a run that is quietly degrading is visible in the output rather than silently thinner.
fetch candidates HN Algolia · 8 queries · last 14 days · >20 points
↓ dedup by objectID · cap 40
score ← model · one batched Anthropic call · RUBRIC.md as system prompt
↓
validate ← code · reject, never repair
↓
filter ← code · score >= 6
sort + cap ← code · score desc · max 12
↓
write reads.json published items + rejections + run counts
↓
render index.html
Hacker News via the Algolia search_by_date endpoint, across eight queries — Claude Code, Cursor, Aider, MCP, agent SDK, AI coding agent, LLM evals, agentic workflow. Stories only, last 14 days, more than 20 points. Deduplicated by objectID, capped at 40 candidates.
A single batched call to claude-sonnet-4-6. RUBRIC.md becomes the system prompt, followed by an explicit output contract: a bare JSON array, no prose, no markdown fences, first character [ and last character ]. Category must match the allowlist exactly. Summary one sentence, factual, no links. Score 0 if the story isn't about agentic AI coding tools, workflows, or infrastructure — however good it is otherwise.
The prompt asks for summaries under 140 characters; the validator rejects above 160. The tolerance is deliberate — instruct tighter than you enforce, so a marginal overrun doesn't cost an otherwise valid item.
Valid items scoring 6 or above, sorted by score descending, capped at 12. reads.json carries the run's counts — candidates fetched, passed validation, published — so the shape of each run is inspectable without re-running it.
| File | Purpose |
|---|---|
curate.mjs |
The pipeline. Fetch, score, validate, filter, publish. |
RUBRIC.md |
Editorial rubric, loaded verbatim as the system prompt. Change this to change what ships. |
reads.json |
Output: published items, rejections with reasons, and run counts. |
index.html |
Renders the output. |
Node 18+ (uses global fetch and top-level await). Nothing to install.
export ANTHROPIC_API_KEY=sk-ant-...
node curate.mjsWrites reads.json in place. Open index.html to view.
Built and deployed inside a 45-minute timed assessment.
Partway through the build a malformed model response hit the validation path for real. The fast fix would have been to loosen the parser and accept the output. Instead I fixed it at the prompt layer — tightening the output contract so the model produced valid JSON, rather than teaching the code to tolerate invalid JSON.
That decision is the reason the repo is worth reading. Under time pressure, the fail-closed rule held.