The voices of books and ideas, returning in rhythm.
A single-user personal knowledge resurfacing system. Quotes you've saved in Notion get shuffled into a prepared playlist and delivered three at a time, once a day, over WhatsApp (via Twilio). It is not a SaaS, not multi-tenant, and not built for scale — it is built to be quiet, dependable, and boring.
Notion the bookshelf
Echoes the librarian who prepares reading slips
Playlist a shuffled deck of quote cards
WhatsApp a daily envelope
Sunday restocking day
Exhaustion reshuffle the deck
Failure the librarian leaves a note explaining what happened
- How it works
- Getting started
- Every command, in one place
- Configuration
- WhatsApp delivery (via Twilio)
- Where the playlist is stored
- Deployment (GitHub Actions)
- Project layout
- Tracing the code flow — for anyone reading the codebase for the first time
- Invariants and things that look like bugs but aren't
- Design values
Two sources, one pattern. Both are a filtered database query in Notion;
every "callout" block (the highlighted box you get from typing /callout)
in a matching page's body becomes one quote.
| Books pool | Standalone pool | |
|---|---|---|
| Database | Books! Books! Books! |
The Me Section |
| Filter | Status = Completed and Completion Date ≥ 2024-04-24 | Tags contains Quote |
| Quotes per day | 2 | 1 |
| Message | quote carries the book title | quote is bare |
To add another standalone quotes page, tag it Quote in Notion. The next
weekly refresh picks it up automatically — no code change needed.
Calendar-keyed playlist. When a pool is built, every date it will cover gets its own bundle of quotes assigned up front, all at once. Each day's run just reads that date's bundle out of the saved file — so a skipped or delayed run leaves no drift to recover from. There is no counter that could fall out of step, only a calendar.
Independent pools. Books and standalone quotes drain at different speeds and don't share a horizon. Each one assigns dates forward until its own quotes run out, then reshuffles and rebuilds on its own, without touching the other. A 40-quote standalone pool might recycle twice while a 180-quote books pool is still on its first pass — that's intended: no book quote gets skipped just because you added personal quotes faster than you finish books.
Randomness happens once, at the moment a pool is built or topped up. Once a date has been assigned its quotes, that assignment never changes.
1. Love wins. Love always wins. - Tuesdays With Morrie
2. Learn to detach. - Tuesdays With Morrie
3. If you are not busy being born, you are busy dying. — Bob Dylan
Lines 1–2 are book quotes and carry their titles. Line 3 is the standalone quote; if you wrote an attribution beneath it in Notion (like "— Bob Dylan"), it gets folded onto the same line. On a day when the standalone pool has nothing at all, all three lines fall back to book quotes and all three carry titles.
Once a week (Sunday by default — see REFRESH_WEEKDAY), Echoes checks Notion
for anything new and appends it to the playlist — after that day's message
has already gone out, so a refresh failure can never block delivery.
New-quote detection compares Notion's internal block IDs (a permanent ID every block gets, invisible in the UI) against a record of every ID Echoes has ever scheduled — the "seen index." This one mechanism catches three different cases: a newly completed book, a newly tagged quotes page, and new quotes added to a page that already existed. Because that record never shrinks or resets, a missed refresh — say you didn't run Echoes for two weeks — is caught up automatically the next time it runs, with nothing lost.
Appending only ever adds to the future. The one exception: if the very last day of the current schedule doesn't have a full three quotes (because the pool didn't divide evenly) and that day hasn't happened yet, new quotes top it up first. Today and every day before it are never touched.
The message goes out even when preparation fails. If Notion is unreachable when Echoes tries to check for new quotes, it falls back to whatever playlist is already saved on disk, delivers today's quotes from that, and sends a separate alert explaining what went wrong. A broken Notion connection should never mean a missed day.
One consequence worth knowing: if Notion is down on a day whose quotes were already scheduled in advance, you won't see any error at all — Echoes simply doesn't need to call Notion that day. That's the resilience working as intended, not a bug being missed. (More of these in "Invariants and things that look like bugs but aren't" below.)
./setup_env.sh # creates the conda environment, installs Echoes, scaffolds .env
conda activate echoesThis uses conda to create an environment named
echoes (see environment.yml), installs the project into it in editable
mode, and copies .env.example to .env if you don't already have one.
Echoes reads from Notion using an internal integration — a token scoped only to the databases you explicitly share with it.
- Go to notion.so/my-integrations
and create a new integration (read-only capability is enough). Copy the
secret it gives you — it starts with
ntn_. - Open each of your two databases (
Books! Books! Books!andThe Me Section) in Notion, click the•••menu, and add your new integration under Connections. Skipping this step doesn't produce an error — Notion just returns an empty result, which looks identical to "no quotes yet" and can cost you an hour of confusion. - Open each database as a full page and copy its ID out of the browser URL:
notion.so/<workspace>/<DATABASE_ID>?v=<view_id>— the 32-character string right after the workspace name.
Put all three values in .env:
NOTION_API_KEY=ntn_...
NOTION_BOOKS_DATABASE_ID=...
NOTION_ME_SECTION_DATABASE_ID=...
That's enough to run everything except actual WhatsApp delivery — see
WhatsApp delivery below when you're ready
for that. Until then, DELIVERY_MODE=console prints the message instead of
sending it, and everything else (collection, scheduling, refresh, failure
handling) behaves exactly as it will in production.
Run these in order the first time, so each one confirms the previous step before you move on:
echoes collect # read-only: prints every quote Echoes can see, touches no files
echoes run --dry-run # does everything else, but sends nothing and saves nothing
echoes run # the real thing (prints to your terminal by default, doesn't send WhatsApp yet)
echoes show # prints whatever is scheduled for today, straight from the saved playlistStart with echoes collect. It's the fastest way to confirm your Notion API
key, database IDs, and filters are all correct — including whether
attributions are being folded in properly — before any scheduling or state
gets involved.
| Command | What it does | Touches Notion? | Writes state/sends messages? |
|---|---|---|---|
echoes collect |
Prints both quote pools as Echoes currently sees them in Notion | Yes | No — read-only |
echoes run |
The real daily run: prepares the playlist, delivers today's quotes, runs the weekly refresh if it's the configured day | If needed | Yes |
echoes run --dry-run |
Same as echoes run, but nothing is sent and nothing is saved to disk |
If needed | No |
echoes refresh |
Runs just the weekly refresh step on its own, without a daily delivery | If needed | Yes |
echoes show |
Prints what's scheduled for a date, straight from the saved playlist | No | No |
echoes show --date 2026-09-01 |
Same, for a specific date instead of today | No | No |
--dry-run and --log-level DEBUG|INFO|WARNING|ERROR both work either
before or after the subcommand — echoes --dry-run run and
echoes run --dry-run are equivalent, use whichever reads better.
Exit codes, if you're scripting around it: 0 success, 1 completed but
something needed an alert, 2 fatal error.
pytest # runs the full test suite (network-free — nothing here calls real Notion)
ruff check src tests # lints the code for style and common mistakesscripts/dump_quotes.py is a small, separate helper — not part of the
echoes CLI, not run in production, and not covered by the test suite (it
calls real Notion, and the test suite deliberately never does that). Its only
job is to let you see what would be scheduled, using your real Notion data,
without disturbing anything already saved:
python scripts/dump_quotes.py # today onward, for as far as the playlist currently reaches
python scripts/dump_quotes.py --days 7 # today plus the next 6 days
python scripts/dump_quotes.py --start 2026-09-01 --days 14It writes the result to quotes_snapshot.json in the project root — open it
in any editor to read exactly what's scheduled for each date. It behaves like
echoes run --dry-run: it may call Notion to build or check a pool, but it
never writes to state/ and never sends anything, so it's safe to run as
often as you like. The file is gitignored — it's a throwaway snapshot for
your own eyes, regenerated fresh every time you run the script, not something
the project tracks.
Every setting is an environment variable. Locally they're read from .env;
on GitHub Actions the exact same names come from repository secrets instead.
Both paths end up in the same place (os.environ), so the application code
can't tell — and there's no code path anywhere that reads a secret from a
file that could accidentally get committed.
See .env.example for the complete, commented list. Full reference:
| Variable | Default | What it means |
|---|---|---|
NOTION_API_KEY |
(required) | Internal integration token |
NOTION_BOOKS_DATABASE_ID |
(required) | Books! Books! Books! database ID |
NOTION_ME_SECTION_DATABASE_ID |
(required) | The Me Section database ID |
NOTION_API_VERSION |
2022-06-28 |
Pinned so a Notion platform change can't silently alter behaviour |
NOTION_TIMEOUT_SECONDS / NOTION_MAX_RETRIES |
30 / 3 |
HTTP timeout and retry count for Notion calls |
NOTION_BOOKS_STATUS_PROPERTY / _VALUE |
Status / Completed |
Which property + value marks a book "done" |
NOTION_BOOKS_DATE_PROPERTY |
Completion Date |
Which property holds the completion date |
NOTION_BOOKS_COMPLETED_ON_OR_AFTER |
2024-04-24 |
Cutoff date for eligible books |
NOTION_ME_SECTION_TAG_PROPERTY / _VALUE |
Tags / Quote |
Which tag marks a standalone quotes page |
QUOTES_PER_DAY_BOOKS |
2 |
Book quotes per day |
QUOTES_PER_DAY_STANDALONE |
1 |
Standalone quotes per day — 0 disables that pool entirely |
QUOTES_PER_DAY_BOOKS_FALLBACK |
3 |
Book quotes per day when the standalone pool is completely empty |
RANDOM_SEED |
(unset) | Set to a number to make shuffling reproducible (testing only) |
BOOK_SEPARATOR |
" - " |
Joins a book quote to its title |
ATTRIBUTION_SEPARATOR |
" — " |
Joins a standalone quote to its attribution |
DELIVERY_MODE |
console |
console prints the message; whatsapp sends it via Twilio |
TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN |
(required if whatsapp) | From the Twilio Console home page |
TWILIO_WHATSAPP_FROM |
(required if whatsapp) | The sending WhatsApp number, no +, no spaces |
WHATSAPP_RECIPIENT_NUMBERS |
(required if whatsapp) | Comma-separated recipient numbers, same format |
TWILIO_DAILY_CONTENT_SID |
(required if whatsapp) | Approved Content Template SID for the daily message |
TWILIO_ALERT_CONTENT_SID |
(optional) | Approved Content Template SID for alerts; unset = alerts are only logged |
TWILIO_TIMEOUT_SECONDS / TWILIO_MAX_RETRIES |
30 / 3 |
HTTP timeout and retry count for Twilio calls |
TIMEZONE |
Asia/Kolkata |
Which timezone "today" and the daily schedule are measured in |
STATE_DIR |
state |
Where the playlist/seen-index JSON files live |
LOG_LEVEL |
INFO |
DEBUG / INFO / WARNING / ERROR |
DRY_RUN |
false |
Same effect as --dry-run on every command |
ALERTS_ENABLED |
true |
Set false to suppress sending alerts (they're still logged) |
SUNDAY_REFRESH_ENABLED |
true |
Set false to disable the weekly refresh entirely |
REFRESH_WEEKDAY |
6 |
Which day runs the weekly refresh (0 = Monday … 6 = Sunday) |
Not required to start — everything above works with DELIVERY_MODE=console,
which just prints the message instead of sending it. This section is for when
you're ready to actually receive quotes on WhatsApp.
Echoes sends WhatsApp messages through Twilio rather than calling Meta's Graph API directly. Twilio is a "Business Solution Provider" — an officially authorized reseller of the same underlying WhatsApp Business Platform, with its own account signup and console instead of Meta's developer console. Practically: Twilio's own phone verification (independent of Meta's), a free Sandbox for instant testing, and Twilio support if something gets stuck.
Echoes never receives a WhatsApp message from you, so there's never an open conversation window the way there is when you message a business first. Every message Echoes sends is "business-initiated," and the underlying WhatsApp Business Platform requires every business-initiated message to use a pre-approved Content Template — a fixed message shape with blanks that have been reviewed in advance. That means two templates need approval: one for the daily quotes, one for the failure alert.
1. Create a Twilio account. twilio.com/try-twilio. Verification is via Twilio's own system, independent of Meta's.
2. Complete Trust Hub identity verification.
This is easy to miss and blocks every real send until it's done, regardless
of which sender you use. In the Console, go to Account → Trust Hub →
Customer Profile, choose Individual (for personal/hobbyist use, not a
registered business), and complete the form: name, date of birth, address, a
photo ID upload, and a live selfie for verification. Submit and wait for
status "Twilio Approved" — this is Twilio's own KYC check, separate from
Meta's WhatsApp review, and every message send fails with a
"Primary compliance profile is not approved" error (Twilio error 63051)
until it clears.
3. Get your Account SID and Auth Token. Console home page → Account SID and Auth Token (click "Show" to reveal it).
4. Join the WhatsApp Sandbox (for testing).
Messaging → Try it out → Send a WhatsApp message shows a Sandbox number
(+1 415 523 8886) and a join code like join <two-words>. Send that code
as a WhatsApp message to the Sandbox number from any account you want to
test with.
5. Register a real Sender (for actual daily use). The Sandbox is for testing only — for unattended daily sending, register a proper Sender: Messaging → Senders → WhatsApp Senders → Create new sender. This links a phone number to your WhatsApp Business Account (WABA) through Meta (via "Continue with Facebook", an embedded, guided version of Meta's own business verification). If your Twilio account already has a WABA linked (e.g. from Sandbox use), you must select that same WABA for the new sender — creating a second one gets the request rejected.
6. Set your recipients.
WHATSAPP_RECIPIENT_NUMBERS: one or more numbers, comma-separated,
international format with no + and no spaces (India = 91XXXXXXXXXX). The
same message goes to every number in the list independently — this fans a
message out to individuals, it is not a WhatsApp group. On the Sandbox,
each recipient must join first (step 4); with a real registered Sender, no
join step is needed.
7. Create the two Content Templates. Messaging → Content Template Builder → Create new, twice:
- Daily quotes — type Text, category Utility, body:
Three separate variables, since template parameters can't contain line breaks. Important: the variables must hold bare quote text with no leading number — the numbering ("1. ", "2. ", "3. ") is already in the template body above. Echoes' own code already accounts for this (see
Hello! This is Echoes sending you your quotes for today: {{1}} {{2}} {{3}} Have a lovely day ahead! Echoes Signing Out!deliver/twilio.py); adding numbering on both sides would double it up in the delivered message. - Alert — type Text, category Utility, body:
Heads up — something needs your attention: {{1}} — Echoes
One placement rule that isn't obvious and will get a template rejected: a variable can't be the very first or very last thing in the body. There must be real static text both before the first variable and after the last one — both templates above satisfy this already.
Submit both — Twilio forwards them to Meta for approval, typically minutes to
about a day. Once each shows Approved, copy its Content SID (starts
with HX).
8. Fill in .env and switch delivery mode on.
DELIVERY_MODE=whatsapp
TWILIO_ACCOUNT_SID=...
TWILIO_AUTH_TOKEN=...
TWILIO_WHATSAPP_FROM=... # Sandbox number while testing, your real Sender once registered
WHATSAPP_RECIPIENT_NUMBERS=919999999999
TWILIO_DAILY_CONTENT_SID=HX...
TWILIO_ALERT_CONTENT_SID=HX...
Then echoes run --dry-run to check the config loads without errors,
followed by echoes run for a real send.
- A parameter-count mismatch gets rejected outright. On a short day — when a pool doesn't divide evenly and the last day has only 1 or 2 quotes instead of 3 — the unused template slots are padded with an em dash so the send is still accepted.
- One recipient failing doesn't block the others. If you've added more than one number and one of them fails to receive a message, Echoes still delivers to the rest and logs which one failed — the run only counts as fully failed if every recipient failed.
- The Sandbox is for testing, not daily production use. Joined numbers can need rejoining after a period of inactivity, and it's meant for verifying the pipeline works end to end — not as the permanent delivery channel. Moving to a real registered Sender is a Twilio Console step, not a code change.
- A newly registered Sender can still fail to send with error 63051
("WhatsApp Sender or Account is Locked") even when the Sender itself shows
ONLINE/HIGH qualityin Twilio's own Senders API — this points at an account-level restriction rather than the sender resource, and is a Twilio Support case, not something fixable through more Console configuration. - Pricing scales per recipient, not per message: sending to 3 numbers is billed as 3 independent conversations, not one shared cost. Twilio also adds its own small per-message fee on top of the underlying WhatsApp fee. Check Twilio's current WhatsApp pricing page for exact rates, since they vary by recipient country and change over time. The Sandbox itself is free.
Two files, committed to the repository itself:
state/quotes_schedule.json the prepared playlist — every date, and the quotes assigned to it
state/seen_blocks.json every quote block ID Echoes has ever scheduled, so refreshes know what's new
These are deliberately not gitignored. GitHub Actions runners start fresh every single run and throw everything away afterward — committing these files back to the repo is the only way the playlist survives from one day to the next. Without this, every run would rebuild the entire playlist from scratch, with no memory of what was already sent, risking real repeats. Quotes are written out in full inside the file rather than referenced by ID elsewhere, on purpose: you should be able to open the file and read it, not need to run code to decode it.
Writes are atomic (written to a temp file, then swapped into place), so an interrupted run can never leave a half-written playlist behind. Since this repository is private, the quote text inside these files stays private too.
The workflow (.github/workflows/echoes-daily.yml) runs daily at 32 1 * * *
UTC — 07:02 IST. It's deliberately not exactly on the hour, since GitHub's
shared runners are busiest right at the top of the hour and scheduled runs
are best-effort anyway. A late run is harmless here: the playlist is
calendar-keyed, so even a delayed run still reads the correct day's quotes.
Add these under Settings → Secrets and variables → Actions → Secrets:
NOTION_API_KEY
NOTION_BOOKS_DATABASE_ID
NOTION_ME_SECTION_DATABASE_ID
TWILIO_ACCOUNT_SID
TWILIO_AUTH_TOKEN
TWILIO_WHATSAPP_FROM
WHATSAPP_RECIPIENT_NUMBERS
Non-secret settings (DELIVERY_MODE, TIMEZONE, the per-day rates, Content
Template SIDs) go under the Variables tab instead, or can be left unset
entirely to use the defaults baked into config.py.
workflow_dispatch is enabled, so you can trigger a run by hand from the
Actions tab — with a dry-run toggle and a log-level picker — without
waiting for the schedule.
A run takes roughly 1–3 minutes. At around 90 minutes a month, that's well inside the 2,000 free Linux minutes a private repository gets on GitHub's Free plan.
src/echoes/
├── config.py resolves settings from .env or GitHub secrets - Settings, NotionSettings, TwilioSettings
├── models.py Quote, PoolSchedule, Playlist, SeenIndex, DailyBundle - the shared data shapes
├── errors.py the fatal-vs-recoverable exception hierarchy
├── logging_setup.py stdout logging, secret masking for logs
├── cli.py the echoes command: run / refresh / collect / show
│
├── collect/ Class 1 - Notion client, callout extraction (read-only)
│ ├── notion_api.py thin Notion REST client: pagination, retries, error translation
│ └── collector.py turns Notion pages/callouts into Quote objects
│
├── playlist/ Class 2 - state storage, the scheduler, playlist orchestration
│ ├── state_store.py reads/writes state/*.json (atomic writes)
│ ├── scheduler.py pure functions: build_schedule, append_schedule (no I/O, no clock)
│ └── service.py PlaylistService - the daily "prepare and pick" + weekly refresh logic
│
├── deliver/ Class 3 - message formatting, console and Twilio senders
│ ├── base.py the Sender interface
│ ├── formatter.py format_quote / format_lines / format_bundle
│ ├── console.py ConsoleSender - prints instead of sending
│ ├── twilio.py TwilioSender - sends via Twilio's WhatsApp API
│ └── factory.py build_sender() - picks Console or Twilio based on DELIVERY_MODE
│
└── pipeline/ daily orchestration and failure safety (Class 2.3)
├── daily.py run_daily() - the full daily run, in order
└── refresh.py perform_refresh() / run_refresh() - the weekly refresh
scripts/
└── dump_quotes.py manual, network-using helper - see "Preview real quotes" above
tests/ the pytest suite - network-free, fakes Notion at the transport boundary
state/ the committed playlist and seen-index JSON files
The conceptual classes from the original design doc map onto these packages
directly. playlist/scheduler.py is deliberately pure — no I/O, no reading
the clock beyond what's explicitly passed in — which is what makes the date
arithmetic straightforward to test.
This section is for reading the codebase for the first time: where execution starts, which function calls which, and how different scenarios branch. Read it alongside the project layout above.
pyproject.toml registers the console script:
[project.scripts]
echoes = "echoes.cli:main"So typing echoes run calls main() in cli.py.
(python -m echoes goes through __main__.py,
which just calls the same main().)
main() does the same four things regardless of which command was typed:
- Parse arguments (
build_parser()). - Configure logging twice — once with a safe default (
"INFO"), so a configuration error is still visible, then again once the realLOG_LEVELis known from.env. Settings.from_env()(config.py) — resolves every environment variable into one frozenSettingsobject. RaisesConfigurationError(fatal, exit code2) if something required is missing.- Dispatch on
args.commandto one of four places.
This is the main path — everything else is a variant of it.
cli.main()
└─ run_daily(settings) pipeline/daily.py
├─ builds NotionAPI collect/notion_api.py
├─ wraps it in QuoteCollector collect/collector.py
├─ wraps store+collector in PlaylistService playlist/service.py
├─ builds a Sender via build_sender() deliver/factory.py
│
├─ _prepare()
│ └─ service.prepare_for(today)
│ ├─ for STANDALONE, then BOOKS pool:
│ │ └─ _ensure_pool() → _rebuild_reason() decides
│ │ if a rebuild is needed; if so:
│ │ collector.collect_*() → build_schedule() playlist/scheduler.py (pure)
│ └─ bundle_for(playlist, today) - reads today's
│ quotes straight out of the in-memory Playlist
│ (back in daily.py) if state changed: store.save_playlist()/save_seen() playlist/state_store.py
│
├─ _deliver()
│ └─ sender.send_daily(bundle)
│ → TwilioSender (deliver/twilio.py) or ConsoleSender (deliver/console.py)
│ both call formatter.format_quote()/format_lines() deliver/formatter.py
│
├─ _maybe_refresh() (only if today is the configured refresh weekday)
│ └─ perform_refresh() pipeline/refresh.py
│ └─ service.refresh(playlist, seen, today)
│ └─ collector.collect_*() → seen.unseen() → append_schedule() playlist/scheduler.py
│
└─ _raise_alerts() - sends anything in report.alerts via sender.send_alert(),
swallowing any exception so alerting can never mask
the original failure
Identical call path to above, with one flag threaded through: settings.dry_run = True. Concretely:
_prepare()still rebuilds pools in memory (may still call Notion), but skips thestore.save_playlist()/save_seen()calls.TwilioSender.send_daily()/send_alert()log"DRY RUN - would send..."and return without calling Twilio at all.perform_refresh()still detects new quotes but skipsstore.save_playlist()/save_seen().
Nothing on disk changes and nothing is sent — everything else runs for real, which is what makes this useful for checking config and Notion connectivity.
service.prepare_for() lets a CollectionError propagate up.
_prepare() in pipeline/daily.py catches it:
except (CollectionError, StateError) as exc:
report.degraded = True
report.add_alert(...)
playlist = store.load_playlist() # fall back to what's already on disk
seen = store.load_seen()
return playlist, seen, service.bundle_for(playlist, today)
Delivery then proceeds normally from the existing playlist. This is why an
outage on a day whose quotes were already scheduled produces no error at all
— _ensure_pool() never needed to call Notion in the first place, so there
was nothing to fail.
Inside service.prepare_for():
standalone_has_quotes = bool(standalone_schedule and standalone_schedule.total_quotes > 0)
books_rate = (
settings.quotes_per_day_books
if standalone_has_quotes
else settings.quotes_per_day_books_fallback # 3, by default
)The books pool gets rebuilt (if needed) at the fallback rate, and
bundle_for() computes used_fallback by checking whether more book quotes
were picked than the normal per-day rate. This is resolved once, at build
time — not re-decided every day at delivery time.
Inside TwilioSender.send_daily() (deliver/twilio.py): each recipient is
sent to independently, in a loop, with failures collected rather than
raised immediately:
for recipient in recipients:
try:
self._post(payload)
except DeliveryError:
failed.append(recipient)
if len(failed) == len(recipients):
raise DeliveryError(...) # only a *total* failure is reported as undeliveredSo one bad number logs a warning and still lets everyone else receive the message that day.
Bypasses PlaylistService and the pipeline package entirely —
_command_collect() in cli.py builds a NotionAPI + QuoteCollector
directly, calls collect_books()/collect_standalone(), and prints. Nothing
is scheduled, nothing is saved. This is the shortest path through the
codebase and the fastest way to sanity-check Notion connectivity.
_command_show() in cli.py never touches Notion at all: it loads the
playlist straight from disk (StateStore.load_playlist()) and calls the same
PlaylistService.bundle_for() used internally by the real run, passed a
collector=None — safe, because bundle_for() only reads what's already
scheduled and never triggers a rebuild.
run_refresh() in pipeline/refresh.py is the same perform_refresh() used
inside a real echoes run, just invoked standalone without a daily delivery
around it — useful for manually pulling in new quotes without waiting for
Sunday.
Not part of the CLI at all — a separate script that builds its own
Settings, NotionAPI, QuoteCollector, and PlaylistService, calls
prepare_for() to build/check pools (real Notion calls, like --dry-run),
then loops bundle_for() across a date range and writes the result to
quotes_snapshot.json. It never calls store.save_playlist(), so it can be
run repeatedly without side effects.
Worth reading before "fixing" anything below — each of these is intentional, not an oversight.
- An outage on a day the playlist already covers is invisible. No Notion
call is made on a prepared day, so
degradedstaysFalse. That's the resilience property described above, not a missed error path. - An empty pool is only rechecked once per day. Guarded by
PoolSchedule.built_on. Recovery from the fallback lands on the next run, not the same one — this stops a genuinely empty Notion from being hammered every single run. - Pools intentionally desynchronise. Books and standalone drain at different rates and each rebuilds on its own exhaustion, without touching the other. Syncing their horizons would mean book quotes get reshuffled before they've all been seen.
- Short tail days are fine. When a pool doesn't divide evenly, the last day of a cycle sends 1 or 2 quotes instead of 3. Intended.
state/*.jsonis deliberately not gitignored. Runners are ephemeral; committing state back is what carries the playlist between days. See "Where the playlist is stored".- Quotes are stored inline in the playlist, not normalised by reference. Transparency over deduplication — the file should be readable by opening it directly.
- Quote identity is the Notion block UUID, never text hashing. A typo fix in Notion would resurface as a "new" quote under text hashing.
- The seen index is a complete record, not a moving cursor. This is what makes a missed refresh self-healing — there's no "last refreshed at" timestamp to fall behind.
- The playlist is calendar-keyed, not counter-based. A skipped run leaves no drift; there's no day-pointer that increments.
- The refresh is append-only, with one permitted exception: topping up a short tail day that's still in the future. Today and the past are never written to.
- Randomness happens at build/append time only — never at delivery time. Once a date has quotes assigned, they don't change.
- Alerting must never raise. An alert failure is caught and logged, so it can never mask the original failure it was trying to report.
Preserved from the original specification. Any change that goes against one of these needs an explicit reason stated up front, not buried in the code.
Deterministic > clever
Scheduled > reactive
Quiet > noisy
Transparent failures > silent failures
Conceptual clarity > technical purity
Explicit non-goals: no recommendation engine, no AI-generated quotes, no real-time sync, no analytics, no dashboards, no tagging UI, no feedback loops, no ML.
The system succeeds when it becomes boring and dependable.