← Home

DECK The Newsletter · sankettambare.in

A month, in one letter.

At the end of every month the site gathers everything I ran, climbed, read and wrote into one issue, and I add a letter about what it meant. This is how that works, and what you can take from it.

12 issues, Oct 2025 → Sep 2026289 items gathered2 layouts, 1 modelv20.0.0

02 The problem

A log is not a memory.

The site already records everything. A race goes into sports, a fort into treks, a book into books, a thought into microblog. Eight years of it, in one Postgres.

Nobody reads a table of fifty races. Races blur into each other, and books into shelves.

And /now, the page meant to tell the story of a month, had become a retyped copy of what the tables already held — typed by hand, so it drifted.

  1. treksLingya Waterfall01-08-2026
  2. microblog#14422026-08-04
  3. sportsSelf Paced Sinhagad HalfAugust 8, 2026
  4. treksGhangad Fort09-08-2026
  5. eventsYung Foundation, Independence Day15 Aug
  6. microblog#14472026-08-17
  7. booksपट्यारा — Santosh ShindeAug
  8. projectsRunFolio, and six moreAug
  9. sportsNMDC Hyderabad Marathon 2026August 30, 2026

…but what did August mean?

03 The idea

The machine does the remembering.
The person says what it meant.

The month, gathered

Every race, trek, book, blog and short post logged that month, pulled straight from the archive. Nothing retyped, nothing forgotten.

Then written by hand

A short letter on what it all meant — the part a list of links cannot say. The numbers are the month's own; the words are mine.

And you can write back

React to a section, vote on next month's question, rate the letter, or ask the archive about anything in it.

The /newsletter hub page: the heading Newsletter, a paragraph on why it exists, three buttons, and three cards — The month, gathered; Then written by hand; And you can write back.
/newsletter — the hub says what the letter is and why, before any issue.

04 What a reader gets

Eight postcards from one feature.

Postcard from

The hub

What the letter is, why it exists, the latest issue, every issue by year.

Postcard from

An issue

Headline, three numbers, the letter, a calendar of the month, then each section.

Postcard from

Two looks

Letter in the atlas theme. Magazine in the classic theme.

Postcard from

Share

A 1200×630 link card, a 1080×1350 portrait, an image per section and per item.

Postcard from

Talk back

Reactions per section, a poll, a 1–5 rating with the part worth reading, a private reply.

Postcard from

Ask

"How did the Satara Hill Half go?" — a chat pinned to this issue and what it links to.

Postcard from

Email

"Copy for Substack" in the admin pastes the whole issue, and Substack mails it.

Postcard from

Months like this

Related issues from the same embeddings as the rest of the site. No model call.

05 Tour · the atlas theme

A letter that arrives sealed.

A cream envelope with its flap closed, an amber stamp reading Aug 2026, a Hometown Square postmark and an Open the letter button.
Stamped with the month, postmarked from Hometown Square. Opening it is remembered per issue; reduced motion skips it.
The August 2026 letter on ruled paper in a handwriting font, headlined Waterfalls, forts and seven builds, with a race photo pinned at the top right.
The letter in Caveat on ruled paper; the photo is the month's race or trek.
The September letter on a 390-pixel phone screen, one column.
Same letter at 390 px.

06 Tour · the atlas theme

Each section is a postcard. The month is a calendar.

Four tilted postcards: On the road with two half marathons, Treks and outings with four entries, On the shelf with one book, and Written with Marathi and English blog posts. Each has reactions and a share button.
Postcards tilt a degree either way; each carries its own reactions and its own share image.

August 2026, at a glance

13 of 31 days had something in them

RaceTrek / outingShort post

Built by issueDays() and monthGrid(). The share card draws the same grid, so the page and the unfurl can never disagree. Short posts fold after six behind "Show all N" — July had twenty.

07 Tour · the classic theme

The same issue, as a magazine.

The September issue in the classic theme: a dark photo cover titled Satara hills and a second brain with 21 km raced, 1 book and 10 posts written, then the letter with a red drop cap.
Photo cover, three numbers in amber, a drop cap.
A numbered spread: 02, Treks and outings, four entries, with reactions on the left and the four items on the right.
Numbered spreads instead of postcards.
// src/pages/NewsletterIssue.js
const model = issueModel(row);   // sections, stats, days, refs…
view === "atlas"
  ? <IssueLetter   model={model} … />
  : <IssueMagazine model={model} … />

Different in every pixel, one model underneath. The share cards, the OG card, the crawler meta and the /ask source read the issue through the same issueModel() — so nothing can disagree about what a month held.

08 The backfill

Twelve months on /now became the first twelve issues.

The hub's issue grid: six cards for March to August 2026, each with a dark share card showing the headline, three numbers and a calendar of lit days.
Every issue's card is its own share image — headline, three numbers, the month's calendar.

Items gathered per issue

20
14
11
17
18
17
22
31
40
45
32
22
289items
125active days
119short posts
1,900words of letters

Also 68 blog posts, 34 site changes, 26 treks and outings, 16 books, 14 races and 9 projects. Every letter was written by Claude from its month's facts, then checked line by line.

09 The monthly workflow

One command at the end of the month.

1

You

/newsletter 2026-09

in Claude Code — the project skill.

2

Script

npm run newsletter:draft

Merges every dated row of the month into the issue. Idempotent.

3

Script

media/og/…2026-09.png

Renders the share card with satori and uploads it to Storage.

4

Script

2026-09.brief.json

The month's facts, the last two letters, last month's poll result.

5

Claude

2026-09.md

Headline ≤ 6 words, a letter, next month's poll — from the brief only.

6

Script

--note 2026-09.md

Writes the prose back and re-renders the card with the headline.

7

You

/admin/now/months

Read it, edit it, tick Publish on save.

8

You

Copy for Substack

Paste into Substack, press send.

Nothing is published by a machine. The script never touches published_at; only a person pressing Publish does.

10 The decision

A terminal command, or a prompt that writes HTML?

That was the first question asked. Both, split along one line.

A script onlyA prompt → a new HTML page each monthScript + Claude skill (chosen)
DataExactRetyped by a model — can drift or inventExact
DesignOne designA new design every monthOne design
Share meta, crawlersAutomaticHand-written tagsAutomatic
/ask indexingAutomaticNoneAutomatic
Re-runnableYesA different page each runYes
The letterYou type all of itModel writes itModel drafts from facts; you edit

Deterministic pipeline for data. AI for prose only.

11 The human half

The model writes from the brief, and nothing else.

// knowledge_base/newsletter/2026-09.brief.json (trimmed)
{
  "stats": ["21 km raced", "1 book", "10 posts written"],
  "sections": {
    "On the road": ["Satara Hill Half Marathon 2026"],
    "On the shelf": ["त्सुबाकी स्टेशनर्स — इतो ओगावा"],
    "Written": [
      "Breaking the barriers within: Hyderabad…",
      "The Second Brain: Decoding the complex…",
      … 8 more
    ]
  },
  "previousNotes": [ // the last two letters, for voice ],
  "lastPoll": null
}
Headline ≤ 6 words150–250 wordsNo number the brief lacksNever publishes

Dear reader,

September's race climbed: the Satara Hill Half Marathon 2026. … One of them, The Second Brain, is on the eight-plus years of notes and archives that became the "ask the archive" on this site. …

On the shelf, Ito Ogawa's त्सुबाकी स्टेशनर्स, a quiet book about letters…

Every letter was checked against its data before publishing. Across the twelve, that cut "the hilliest half marathon" and a date that was a guess — neither was in a brief.

12 Entity map

Five tables in, one row in the middle, six ways out.

sports, treks, books, blogs and microblog merge into one now_months row through mergeMonthRecords with a ref on every item. From that row come the issue page, /now, the ask index, the share card in Storage, the feedback table and the Substack copy. the archive — single source of truth sportsraces · "Month DD, YYYY" treksforts · DD-MM-YYYY booksdate_finished, if exact blogsSubstack · WordPress microblog1,600+ short posts one issue = one row now_months sections (frozen snapshot) slug · headline · note poll · published_at card_url every item carries ref {type, id} mergeMonthRecords() — idempotent, dedupes, backfills refs /newsletter/:slugLetter | Magazine · issueModel() /nowthe current month only content_chunksnow.mjs → /api/ask focus Storage cardsatori at build → og:image newsletter_feedbackowner-only · counts via RPC SubstackissueToHtml() → clipboard

13 The data model

No issues table. An issue is a now_months row.

/now already had a row per month whose sections blob was a frozen copy of that month. A separate issues table would have been a second copy of the same month. Migration 0032 added only what makes a month publishable:

  • slug text unique — 2026-09, the URL
  • headline — the card's title, ≤ 6 words
  • note — the letter, in markdown
  • poll jsonb — {q, options[]}
  • published_at — null means draft
  • card_url — the rendered share card
-- who may read a month
using (published_at is not null
       or is_current or public.is_owner())

Refs tie every item back to the archive

// one item in sections.running
{
  "event": "Tata Ultra Marathon",
  "distance": "50",
  "date": "2026-02-22",
  "ref": { "type": "sport", "id": 19 }
}

Link

The item opens its own page.

Share

With its own image adapter.

Ask

Pinned into the issue's chat.

A snapshot, on purpose: editing a race next year does not rewrite last September's letter, and a deleted row leaves no hole in it.

14 Converging the data

Five sources, five date formats, one choke point.

sportsdateAugust 30, 2026→2026-08 treksdate09-08-2026→2026-08 blogsblog_date2026-08-26→2026-08 microblogdate2026-08-17→2026-08 booksdate_finishedonly if precision = day→2026-08

parseContentDate() in monthDigest.js turns each into a local calendar date, and itemMonthKey() buckets it. Everything else — the page, the card, the script — trusts that one tested function.

Two traps it exists to avoid

new Date("09-08-2026").toDateString()
// "Tue Sep 08 2026" — Ghangad was 9 August

new Date(2026, 9, 1, 0, 30).toISOString()
// "2026-09-30T19:00:00.000Z"
// 00:30 IST on 1 Oct lands in September

15 What the backfill caught

Twelve real months found three real bugs.

Races counted twice

Tata Ultra Marathontyped on /now · 50 · Substack link✓ one race
Tata Ultra Marathon 2026sports #19 · 50 Kms · February 22, 2026

sameRace() compares the name's core — years, "Kms", "run" stripped — plus the distance. The typed row keeps its words and adopts the archive's ref and real date.

13585 km in February

January had shown 84 km for one marathon.

June "read" 37 books

47 books were imported into the database on one day, 13 June 2026, so they all looked like June reads.

370 books in June

Rule: five or more books added the same day are an import, skipped unless they carry a real finish date.

Cards too heavy to unfurl

before
after

Nine cards with a duotoned photo came out at 500 KB – 1 MB. WhatsApp drops an image over about 300 KB (dashed line).

~145 KB each, now

Over the cap, the script re-renders the card without the photo — calendar only.

16 Share cards

Every issue unfurls with its own card — drawn before anyone asks.

The August 2026 share card: dark indigo, the headline Waterfalls, forts and seven builds, 42 km raced, 2 treks, 1 book, and a grid of the month's days with active days lit in amber.
The hub's share card, an envelope.
Workers Free budget
One satori render

10 ms of CPU per request, against ~400 ms per render.

An earlier on-demand renderer returned error 1102 in production, and links unfurled with no image at all. So newsletter:draft renders the card under Node, with the same layout as every other card on the site, and uploads it as media/og/newsletter-2026-08.png — a flat name, so the middleware declares its size.

PNG 1200×630Headline ≤ 6 words≤ 3 numbersOne accent< 300 KB
A portrait share image of the September issue: its race photo, the headline Satara hills and a second brain, three stats and the start of the letter.
The portrait "my month" image — the site's share editor, one new adapter.

17 Feedback, privately

Readers talk back. Only the author reads what they say.

BrowserPOST + Turnstile token
/api/newsletter-feedbacka Pages Function: hashes the real IP (salted, truncated), checks Turnstile
newsletter_feedback_add()service role only · validates · 40 writes per visitor per day · one vote each
newsletter_feedbackowner-only RLS — no anon select policy at all
newsletter_feedback_summary()anyone may call it · counts only, never a reply
Reactions❤️ 🔥 👏 🤯 per section
Polltallies after you vote
Rating1–5 + the part worth reading
Replya message, a name if you like
The feedback block: This month's question, What should the October letter dig into, with four options; a five-star rating; and chips for the part worth reading.

No email, no account. A new vote replaces the old one; a repeated reaction is a no-op. The browser remembers what you did so the buttons show it — courtesy, not enforcement. Postgres cannot see a client's IP through PostgREST, which is why writes go through the edge.

18 Ask about this issue

Ask about this issue — and get this issue.

How did the race go?

Letter · September 2026focus
On the road · September 2026focus
Satara Hill Half Marathon 2026ref
NMDC Hyderabad Marathon 2026search
Breaking the barriers withinsearch
Self Paced Sinhagad Halfsearch
Tata Ultra Marathon 2026search

Illustrative. Without focus, "the race" searches the whole archive and answers about whichever race ranks first.

// the issue page sends
focus: { type: "now", id, refs }

// functions/api/ask.js — read by key, beside the search
const { picked } = selectChunks(…);
const chunks = withFocus(focused, picked, match_count);
Ask about this month: a chat box with three starter questions built from the September issue, including How did the Satara Hill Half Marathon 2026 go?
The starter chips come from the issue itself (issueQuestions), so each one names something the archive can answer.

Each issue is indexed as a letter chunk plus one chunk per section, at its own /newsletter/<slug> URL. Any page can pin itself this way.

19 If you build one

Nine decisions that made this cheap.

1

Derive, never retype

Every item already existed as a row. The newsletter is a view over the archive, not a second archive.

2

Freeze a snapshot

An issue keeps a copy of its month. Next year's edit does not rewrite this letter.

3

Facts by machine, meaning by people

A script collects; a model drafts from its output alone; a person publishes. Never let a model be the source of a number.

4

One date choke point

Every source stores dates differently. One tested function, in local time.

5

Idempotent scripts

Fixing a month is "add the missing race, run it again".

6

Render images at build

Cards are static files. On-demand rendering times out on free tiers.

7

Privacy-first feedback

Owner-only storage, counts-only reads, identity hashed at the edge, no accounts.

8

Link items to their source

A ref on every item is what makes sharing, linking and asking work later.

9

One model, two looks

Letter and Magazine share issueModel(). Presentation is cheap when the model is right.

20 Next

What's next, and what it does not do yet.

Roughly in order of value for effort

  • Listen to this issue — the site already has read-aloud.
  • Month-over-month — "three more races than August", from two issueStats calls.
  • On this day, last year — the on-this-day RPC exists.
  • Goals and check-ins — intentions in one letter, checked in the next.
  • A December year-in-review built from twelve issues.
  • A map of the month's places — races and treks have places.
  • An RSS feed of issues, for readers who do not want email.
  • A GitHub Action on the 1st that collects the month before you sit down to write.

Known limits

The current month's draft is readable

over raw REST before it is published, because /now must show the current month. Publishing follows writing within hours.

Card URLs never change

WhatsApp caches unfurls by URL, so a re-rendered card can take a while to show on a link already shared.

The feedback cap is fixed

at 40 writes per visitor per day, in the SQL.

21 Posted

Write back.

The letters are at sankettambare.in/newsletter. Built in one Claude Code session: 58 files, about 3,800 lines, 551 tests passing — shipped as v20.0.0.

— Sanket