A nightly, prepared feed of project ideas, codebase suggestions, and learning material — built to replace late-night doomscrolling with something that nudges your brain toward making things instead of consuming them.
Once a day (and on demand), a single pipeline takes three inputs:
- Recent GitHub activity — events from your configured GitHub user, via the public events API
- Pending requests — anything you've explicitly asked for through the UI
- Your preference profile — topic weights and card-type appetite, built up from your reactions
…and produces three kinds of typed cards into one consumption queue:
- discovery — model-synthesized cards drawing on real online seed sources (currently Hacker News). Followable. Age out fast.
- codebase — model reasoning over one of your own repos (security updates, dead deps, refactor ideas). Generated only when you ask for them by name.
- learning — bite-sized cards on goals you've stated. Tidbits, questions, flashcards, quizzes. Carry spaced-repetition state and resurface on a schedule.
You read the queue at bedtime. Reactions shape what tomorrow's run produces. Save the good ones to a to-do list, hand them off to Claude Code, ship them.
- Runtime: Google Cloud Run via the Functions Framework (no Dockerfile)
- Storage: MySQL on Cloud SQL (Unix-socket connection)
- LLM: Claude (default
claude-opus-4-7) via the Anthropic SDK - Frontend: vanilla JS/HTML/CSS from
/public(no React, no build step) - Activity: GitHub Events API + repo metadata/READMEs/manifests
- Seeds: Hacker News Algolia search
You need:
- Node 20+ and npm
- A local MySQL 8 (or remote)
- An Anthropic API key
- (Optional) a GitHub personal access token, to lift the unauthenticated rate limit
npm install
cp .env.example .env # then fill in MYSQL_* and ANTHROPIC_API_KEY
# Bootstrap the schema:
mysql -u <user> -p <database> < src/schema.sql
# Run the server:
npm start
# Visit http://localhost:8080The first time the run pipeline executes, it seeds default prompts into the prompts table. The settings page (/settings) lets you edit them with a per-prompt reset-to-default.
This project is built for the "no Dockerfile, use buildpacks" path:
- Create a Cloud SQL MySQL 8 instance.
- Run
src/schema.sqlagainst the database. - Create a Cloud Run service. The Node.js buildpack picks up
package.jsonand runsnpm start. - Connect the Cloud SQL instance via the Cloud Run console (Connections → Cloud SQL).
- Set environment variables on the service — see
.env.example.CLOUD_SQL_CONNECTION_NAMEis the instance connection name (project:region:instance); when it's set, the pool uses the Unix socket at/cloudsql/<name>and ignoresMYSQL_HOST/MYSQL_PORT. - Optionally wire Cloud Scheduler to
POST $SERVICE_URL/api/scheduler/runon whatever cadence you want (default settings assume roughly one run per night).
These are non-obvious decisions visible across the codebase:
- No silent defaults. Missing env vars, malformed payloads, blank prompt instructions, missing schema rows — they throw loudly. Nothing is papered over with
|| []or "if it's broken, use this." If you see something handled gracefully, it's because the schema says it's allowed, not because we shrugged. - One database hit for the feed. The feed read is a single indexed query (
status, score, created_at) joined togoalsso paused goals naturally drop out without per-card writes. - Single payload choke point. Everything that goes into
cards.payloadruns throughserializeAndValidate(type, payload)insrc/payload.js. Everything coming out runs throughparsePayload(type, str). Nothing in the codebase hand-builds the JSON string or skips the parse. - Modular over forked. Code paths are parameterized rather than duplicated —
runOnce({ trigger })is the same orchestrator for scheduled, manual, immediate, and refill cases. payloadis LONGTEXT, not MySQL JSON. No query ever looks inside the payload; everything queryable is a real column or a separate table. LONGTEXT avoids the JSON column's coercion quirks.- The run-lock is non-optional. Scheduled and manual runs collide on a single-row
SELECT ... FOR UPDATEadvisory lock; concurrent attempts refuse rather than double-generate.
+-----------------+
| GitHub Events |
| (public) |
+--------+--------+
|
v
+-----------------+----------------+
| The run pipeline |
| |
| 1. expire stale discoveries |
| 2. read activity + preferences |
| 3. drain pending requests |
| 4. theme + retrieve + synthesize|
| 5. refresh learning cards |
| 6. persist activity cursor |
+-----------------+----------------+
|
v
+--------+--------+
| cards table | <-- one feed query reads from here
+--------+--------+
|
v
+--------+--------+
| UI (vanilla) |
| feed / todos |
| library / etc. |
+-----------------+
The run pipeline triggers from four places. All four call the same runOnce({ trigger }) and all four hit the run-lock:
- Cloud Scheduler →
POST /api/scheduler/run - "Run now" button on settings →
POST /api/run-now immediate: trueon a submitted request → fires the run synchronously- Live feed dropping below
queue_refill_threshold→ fires a refill in the background
- LLM-synthesized from retrieved online content
- Carries real
source_urls(every URL came from a seed, never invented) card_sourcesjoin rows for the followable provenance trail- Score blends theme weight + HN points (log-scaled) + seed recency
- Ages out after
staleness_days(default 7) - Followable: thumbs/heart shape topic weights; follow on a source shapes which sources retrieval visits first
- Optional
videopayload: YouTube gets in-house iframe, Twitter/X/Instagram/Reddit get link-outs (embedding is unreliable/blocked there)
- Model reasoning over your repo (metadata, README, manifest)
- Scoped to the repo you requested — never speculatively generated
- Findings tagged
security/dead_dep/efficiency/refactor - File/line references must come from the real repo data; the prompt explicitly forbids inventing paths
- NOT followable (no real "source" beyond the repo itself)
- Belongs to a
goal(lifecycle:active/paused/mastered/cancelled) - Subtypes:
tidbit,question,flashcard,quiz - Carry SM-2-ish spaced-repetition state (
interval_days,ease,due_at,reviews) - Resurface rather than expire
- Pausing the parent goal cascades to all its cards via the feed-read JOIN — no per-card writes
- Auto-mastery: configurable streak length OR rolling correct-percentage over a window
Every card also carries a hidden discussion_context string, written by the model at generation time. It's not rendered in the feed; it becomes part of the system prompt if you open a discussion thread on the card.
Each channel acts on a different stage of the pipeline:
| Channel | Affects | Where it lands |
|---|---|---|
| Thumbs (up/down) | Topic weights | topic_preferences |
| Heart | Topic weights (bigger) | topic_preferences |
| Follow on a source | Source weights | sources.followed + weight |
| Learning outcome | Spaced repetition + mastery | learning_reviews + goals |
| Save (to-do) | To-do queue + small topic bump | cards.status='saved' + topic_preferences |
| Engagement (≥ N msgs/thread) | Topic + type appetite | topic_preferences + type_appetite |
Each pipeline step has a named prompt (themes, synthesize_discovery, synthesize_codebase, synthesize_learning, discuss_card). Prompts split into two parts:
instruction_text— editable prose (role, tone, constraints). Lives in thepromptstable.- Data block — programmatically constructed in code (activity, themes, source content, card context). Appended after the instruction; never interpolated into it. No
{user_name}-style templating.
Each prompt also stores a default_text so the settings UI can show a diff and let you reset.
A blank instruction_text is treated as an error — the pipeline refuses to run rather than silently use no instruction.
On upgrade, ensureDefaultPrompts() rebases any row where the user hasn't customized (instruction_text == old default_text) so structural changes reach existing deploys; customized rows stay untouched and the user can merge manually.
Cards you find interesting get a saved status, which drops them out of the feed via the existing WHERE status='queued' filter — no new query path. The /todos page lists them grouped by saved and done. Each has a "copy for Claude" button that puts a Markdown handoff (title, summary, body, sources, repo references, prepared discussion context) on the clipboard, ready to paste into Claude Code.
Every card has a discuss button. Opening it lazily loads any prior thread; sending a message calls Claude with the card payload + discussion_context cached in the system prompt and the thread history as user/assistant messages. Replies render as Markdown. A toggle inside the drawer reveals the prepared context for trust/debugging.
If you send engagement_boost_threshold or more messages on a single card (default 3), a one-time topic-weight boost fires for that card's topics (engagement_boost_amount, default 0.4) plus a smaller boost to its type appetite. The UI shows an inline banner when this happens.
All stored as strings in the settings table; seeded with defaults so it works on day one.
| Key | Default | What it does |
|---|---|---|
queue_target_size |
20 | Refill ceiling per run |
queue_refill_threshold |
8 | Below this, the live UI fires a background refill |
staleness_days |
7 | Discovery cards expire this many days after creation |
mastery_streak_required |
5 | Consecutive correct reviews to auto-master a goal |
mastery_recent_window |
10 | Rolling window size for the alternative auto-mastery rule |
mastery_recent_pct |
0.9 | Required correctness ratio in that window |
discovery_per_run |
8 | Max discovery cards per run |
learning_per_run |
4 | Max learning cards per run |
codebase_per_run |
4 | Max codebase cards per run |
run_max_minutes |
15 | Soft budget for a run (advisory; not yet a hard cap) |
llm_model |
claude-opus-4-7 |
Model used for all pipeline + discussion calls |
llm_effort |
medium |
Effort parameter (low / medium / high / max) |
github_username |
(empty) | Your GitHub username for activity ingestion; blank = skip activity |
discussion_max_history |
20 | Max thread messages sent to the model per discussion turn |
engagement_boost_threshold |
3 | User messages required on a card before the topic boost fires |
engagement_boost_amount |
0.4 | Topic-weight delta applied at the threshold |
GET / feed UI
GET /settings settings + prompts UI
GET /library learning goals UI
GET /todos to-do list UI
GET /api/feed?limit=N cards (single indexed query)
POST /api/feedback thumbs / heart / follow / learning_outcome / consume / save
POST /api/requests submit a deferred request (immediate: true fires run now)
POST /api/run-now manual trigger
POST /api/scheduler/run cloud scheduler entry point
GET /api/status run-lock state + queue depth
POST /api/lock/force-release emergency reset for a stuck lock
GET /api/settings settings + prompts
PUT /api/settings bulk set (body: {key: value, ...})
PUT /api/prompts/:key update one prompt
POST /api/prompts/:key/reset reset to default
GET /api/library goals + their cards
POST /api/goals/:id/status change goal status
GET /api/todos saved + done cards
POST /api/todos/:id/done mark done
POST /api/todos/:id/undone un-mark done
POST /api/todos/:id/delete remove from to-do view (consumes the card)
GET /api/cards/:id/messages discussion history + card payload
POST /api/cards/:id/messages send a message, get a reply
GET /api/cards/:id/sources provenance sources for a card
.
├── index.js # functions-framework entry; routes to src/routes.js
├── package.json # buildpack reads this (main + engines.node + start)
├── src/
│ ├── schema.sql # apply once on a fresh DB; idempotent migrations appended
│ ├── db.js # Cloud SQL Unix-socket pool, loud env-var validation
│ ├── payload.js # serialize-and-validate / parse choke point for all card payloads
│ ├── runLock.js # single-row advisory lock via SELECT ... FOR UPDATE
│ ├── settings.js # typed settings access (getInt / getNumber / getString)
│ ├── prompts.js # editable prompts + DEFAULT_INSTRUCTIONS + ensureDefaultPrompts
│ ├── llm.js # Anthropic SDK boundary for the pipeline (themes, synthesis)
│ ├── conversation.js # Anthropic SDK boundary for per-card discussion threads
│ ├── github.js # GitHub Events / repo metadata / README / manifest fetchers
│ ├── hackernews.js # Algolia HN search for discovery seeds
│ ├── sources.js # upsert + follow + attach
│ ├── cards.js # the feed query, insert path, card lifecycle helpers
│ ├── run.js # the one orchestrator (scheduled/manual/immediate/refill)
│ ├── routes.js # HTTP routing — static files + JSON API
│ └── pipeline/
│ ├── activity.js # GitHub activity → compact summary for the LLM
│ ├── themes.js # LLM step: activity + preferences → themes
│ ├── discovery.js # LLM step: theme + HN seeds → discovery card
│ ├── codebase.js # LLM step: repo data → codebase card
│ └── learning.js # LLM step + spaced repetition + auto-mastery + goal lifecycle
└── public/
├── index.html # feed
├── app.js # feed + discussion drawer
├── settings.html # settings + run-now + request submission + prompt editor
├── settings.js
├── library.html # learning goals by state + their cards
├── library.js
├── todos.html # saved + done cards with copy-for-Claude
├── todos.js
├── markdown.js # vendored client-side markdown renderer
└── styles.css
Concurrent runs would double-generate cards and confuse the activity cursor. Every run path acquires the single row in run_lock via SELECT ... FOR UPDATE before flipping running=1. A second caller blocks on its SELECT FOR UPDATE until the first commits, then sees running=1 and refuses.
Lock release is durable (a follow-up UPDATE, not a transaction commit), so a crashed process leaves the lock set. The settings page exposes a "force release" button for this case.
If GitHub has nothing new (or no github_username is configured), the run still produces cards. The themes step is fed the topic-preference list as the seed and the instruction explicitly tells the model to explore adjacent topics. The habit replacement only works if the queue isn't empty on quiet nights.
- Cloud Run + Cloud SQL costs are bounded by the run cadence and queue size — the live UI does no LLM calls, only the run pipeline and the discussion drawer do.
- The Anthropic call in
src/llm.jsuses prompt caching on the system slot (the editable instruction), so re-runs against the same prompts pay ~0.1× for that portion. - All write paths through the API validate input before touching the database; payloads validate before insert through the choke point.
- The pipeline never invents URLs; the synthesis prompts forbid it and
src/pipeline/discovery.jsfilters out any URL the model returns that wasn't in the seed list.
See LICENSE.