The saved folder is where good articles go to die. GraveStack digs one up for you every day.
A PWA that fights the Substack save-but-never-read habit. Instead of a graveyard list (paradox of choice → paralysis → back to the fresh feed), GraveStack surfaces one saved article a day, pitches it with a real pull quote, and renders the full text inside the app so you never bounce to Substack's feed. Built for an ADHD brain: the fix is design, not discipline.
- Sync your Substack saved list once (paste your session cookie, or paste the saved list JSON). Articles + full text are stored locally.
- Each day the app picks one article and generates an honest pitch: the original title (the hook that already worked) + one sentence on what it actually is + one verbatim pull quote from the piece.
- A daily push notification carries that pitch (big-picture style with the cover image on Android). Tapping it opens straight into the article.
- The home screen is the article — cover + pitch, scroll down and you're already reading. No menu, no list.
- Miss? One tap: "Not today." It's logged, buried for two weeks, and a fresh pick comes tomorrow. No same-day reroll — that's the slot-machine loop we're escaping.
Every interaction (opened / read / abandoned / completed / not-today) is logged for a v2 self-improving recommender.
go build -o gravestack .
DATA_DIR=./data ANTHROPIC_API_KEY=sk-ant-... ./gravestack
# open http://localhost:8080Then open ⚙ Settings → paste your cookie → Sync now → set a notify time.
- Log into Substack in a desktop browser.
- DevTools (F12) → Application → Cookies →
https://substack.com. - Copy the value of
connect.sid(orsubstack.sid). Paste it into Settings. You can paste the bare value or a fullname=value; name2=value2string.
The cookie stays valid for months. It's stored server-side and only used for Substack requests.
Substack's saved-list endpoint is undocumented and occasionally changes. Two fixes:
- Override the URL: open your Substack Saved page, DevTools → Network, find the request that returns your saved posts, copy its URL into Settings → "override the saved-list URL."
- Paste the JSON directly: copy that request's JSON response and paste it into Settings → "Paste saved-list JSON." This path never touches the live API, so it always works.
Full article text is fetched per-post from each publication's
/api/v1/posts/{slug} — full when your cookie has access, a preview otherwise.
| Var | Default | Purpose |
|---|---|---|
APP_PASSWORD |
(none) | Optional. Gates the app. Leave unset and the app runs with no login. Recommended on a public URL since your saved-articles data and Substack cookie live server-side (the cookie value is never returned by any endpoint). |
ANTHROPIC_API_KEY |
(none) | Optional server-side fallback key for pitches. You can instead enter the key in Settings, where it's stored only in your browser (see below). Without any key, pitches fall back to the subtitle. |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Pitch model. |
DATA_DIR |
./data |
SQLite location. Point at a persistent volume in production. |
PORT / ADDR |
8080 / :8080 |
Listen port. |
CRON_TOKEN |
(none) | Protects /internal/cron/daily for the external-cron fallback. |
VAPID_SUBJECT |
mailto:admin@… |
Contact for push services. |
VAPID keys are generated and persisted automatically on first run.
Open ⚙ Settings → Pitches and paste your key. It's saved in your browser's
localStorage only, sent as a per-request header, and never written to the server's
disk. Pitches are generated when you sync or open an article (while the browser has the
key) and stored server-side, so the daily push still works without the browser open. The
ANTHROPIC_API_KEY env var remains as an optional server-side fallback.
Recommended — Fly.io (always-on machine + free persistent volume → reliable daily push and a durable event log):
fly launch --no-deploy
fly volumes create gravedata --size 1
fly secrets set APP_PASSWORD=... ANTHROPIC_API_KEY=... CRON_TOKEN=...
fly deploySee fly.toml (keep min_machines_running = 1 so the scheduler runs).
Any other host / a sleep-prone free tier: deploy the Docker image anywhere, then let
GitHub Actions fire the daily push: set repo secrets GRAVESTACK_URL and
GRAVESTACK_CRON_TOKEN, and .github/workflows/daily-push.yml will POST the
token-protected cron endpoint hourly (the app enforces your real notify time and sends at
most once per day).
On Android/Chrome: open the site → Add to Home screen. The installed app gets its own entry in Android's notification settings (its own channel), so you can keep notifications off everywhere else and on for just this. Enable them in Settings → "Enable notifications on this device." (iOS 16.4+ works when installed to the home screen, without the big-picture cover image.)
go test ./... # unit tests: defensive JSON parse, verbatim-quote check, pick/reroll logic
go vet ./...Architecture and design rules live in CLAUDE.md.
- Behavioral re-ranking from the logged events (the self-improving "brain").
- Skip-decay / resurfacing tuning.
- Automatic weekly re-sync.
- Native wrap (Expo) only if PWA push disappoints.