ThoughtsWednesday, October 21, 2026
How I Built The Daily Sort
I built a scratch pad for Connections, then made it load each day’s board by itself. This is how it works, where the AI code lives, and the prompt it sends.
I set this article up as a puzzle too. Each group of four is one section. Sort them, or show the groups and jump in.
How I play
HERRING, DECOY, OVERLAP, FIFTH
Before I make a guess in Connections, I like to sort all sixteen words into groups first. While I sort, I’m looking for the red , the word that fits two groups. It usually shows up as an : five words that seem to fit one group, where the one belongs somewhere else.
FOLD is also a poker move, next to RAISE, CALL and BLUFF.
I wanted a place to work that out before guessing, so I asked Claude Code for a scratch pad. The Daily Sort loads the day’s board and lets you paint each word a color. If five tiles end up the same color, the counter shows 5/4 in red.
It doesn’t have the answers. It only has the sixteen words.
Before any of the daily-board code, I asked Claude for a write-up of how it would work, the same way I start every Shark Thief depth in plan mode. The write-up caught the hard parts early: where the key lives, how to make sure the lookup only happens once, and what to do when two people click at the same time.
How a board gets here
CACHE, LOCK, POLL, SAVE
Claude finds the board with web search. That costs money and takes 10 to 40 seconds, so it only happens once a day and everyone shares the result.
When you load the page, it checks the for today’s board. If it’s there, you get it in about a second. If not, the site takes a for the day, so two people clicking at once still make one lookup. It starts the lookup in a background function, and your browser starts to , asking again every 3 seconds. When the lookup finishes, the site will the board for everyone else that day.
Already loaded today
- browser asks for today
- saved copy found
- about a second, no AI
First load of the day
- browser asks for today
- nothing saved yet
- one lookup per day
- web search, 10–40 s
- 16 clean words
- for everyone
Meanwhile the browser asks again every 3 seconds until the board is saved.
I also set up a job that runs just after midnight Eastern and does the first lookup itself. Connections resets at midnight in your own time zone, and most people who’ll see this are in the US, so most days nobody waits at all.
I wanted the page to show its work, too. Under the board it lists every request it made, the steps the server says it took, and a plain-English line on what that means.
- +0.0s
GET /api/connections-today?date=2026-10-21202 loading- date 2026-10-21 accepted
- cache miss: no saved copy yet
- lock free: started the background lookup
First visitor today. The lookup runs in a background function, which can take longer than a normal request is allowed to.
- +3.0s
GET /api/connections-today?date=2026-10-21202 loading- date 2026-10-21 accepted
- cache miss: no saved copy yet
- lock held: a background lookup is already running
Still searching. Asking again every 3 seconds.
- +21.0s
GET /api/connections-today?date=2026-10-21200 ready- date 2026-10-21 accepted
- cache hit: saved copy found (looked up 0 s ago)
The background lookup finished and saved the board. Everyone after you gets this copy for free.
- Model
- Claude Opus 5.5
- Web searches
- 2
- Tokens in / out
- 38.4k / 645
- Lookup took
- 20.3 s
Asking Claude
PROMPT, MODEL, SEARCH, PARSE
All the code that talks to Claude lives in one file: netlify/.
Nothing else calls the API. The rest of the code decides when to ask, and what to do with
the answer.
It’s the same habit as keeping every Shark Thief setting in one config file. When I want to change how the lookup works, I know where to look, and so does Claude.
-
netlify/The prompt, the models and the Claude API call. The only file that talks to Claude.connections/ claude-source.mjs -
netlify/Checks Claude’s answer before anything is saved.connections/ words.mjs -
netlify/The background lookup: takes the lock, applies the limits, asks, checks, saves.connections/ fetch.mjs -
netlify/The fast endpoint the page calls. Never calls Claude.connections/ today.mjs -
netlify/The overnight job that does the first lookup of the day.connections/ prefetch.mjs -
netlify/Signs requests so only the site can start a lookup.connections/ internal-auth.mjs -
src/The page itself: tiles, inkwells, wire report. No AI.components/ connections/
The
It names the exact date, asks for JSON and nothing else, and says what to send back when the board isn’t online yet. The last line is there so Claude sends an empty list instead of guessing.
netlify/connections/claude-source.mjs
const prompt = (date) => `Find the 16 words on the New York Times Connections puzzle for ${date}.
Search for that exact date and make sure the board you report is for ${date}, not a neighbouring day.
Spell each word exactly as it appears on the board.
Reply with only this JSON and nothing else:
{"words": ["WORD1", "WORD2", "..."]}
If you can't find the board for ${date}, reply with {"words": []}.` The
This job is just finding sixteen words, so the cheapest model goes first. If the API rejects the request, the next one runs. Each model only gets the settings it accepts.
netlify/connections/claude-source.mjs
export const MODEL_CHAIN = [
{
model: 'claude-haiku-5-5',
// Basic search: results go straight into context, which is cheap here
tool: { type: 'web_search_20250305', name: 'web_search', max_uses: 3 },
extra: {},
},
{
model: 'claude-sonnet-5-5',
tool: { type: 'web_search_20260209', name: 'web_search', max_uses: 3 },
// If a safety filter declines, the API retries on another model
extra: { betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' },
},
] The
This is the actual call. The search tool comes from the list above, capped at three searches, and effort is set to low.
netlify/connections/claude-source.mjs
response = await client.beta.messages.create({
model,
max_tokens: 4000,
output_config: { effort: 'low' },
tools: [tool],
...extra,
messages,
}) There’s no list of allowed sites. My first version had one, and the first real lookup failed. Two of those sites block Anthropic’s crawler, and the API rejects the whole request instead of skipping them.
The
Claude’s answer comes back as text mixed in with search results. This pulls the JSON out. If it isn’t JSON, the first 300 characters go into the error, so the logs show what Claude actually said.
netlify/connections/claude-source.mjs
export function parseWordsReply(content) {
const text = content.filter((b) => b.type === 'text').map((b) => b.text).join('')
const json = text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)
try {
const { words } = JSON.parse(json)
return Array.isArray(words) ? words : []
} catch {
throw new SourceError('bad-reply', `Reply wasn’t the JSON we asked for: ${text.slice(0, 300)}`)
}
} Safe and cheap
SIGN, CAP, FILTER, HAIKU
Once it worked, I asked Claude for a security audit. The biggest problem it found: the lookup runs at a public URL, and anyone could call it directly for a date with no board yet. Every call would spend money. In a local test, fifty calls made fifty lookups.
Now the page’s own server has to every request with a key only it has, and the lookup refuses anything unsigned.
netlify/connections/internal-auth.mjs
export function signDate(date, secret) {
if (!secret) return null
return createHmac('sha256', `connections-fetch:${secret}`).update(String(date)).digest('hex')
} Both sides also enforce a : a 10-minute wait after any lookup that didn’t save a board, and at most six lookups per date. The same fifty calls now make one.
It also flagged that Claude reads whatever pages the search turns up, and a page could try to steer it. So a checks the answer before anything is saved: sixteen unique words, short, plain characters, no web addresses and no bad words.
netlify/connections/words.mjs
export function checkWords(words) {
if (!Array.isArray(words)) return null
const clean = words.map((w) => (typeof w === 'string' ? w.trim() : '')).filter(Boolean)
if (clean.length !== BOARD_SIZE || !clean.every(isCleanWord)) return null
const unique = new Set(clean.map((w) => w.toLowerCase()))
return unique.size === BOARD_SIZE ? clean : null
} I also checked the cost. The first real lookup ran on Opus, which this job doesn’t need. It runs on now, with Sonnet as the fallback.
What broke along the way
- The first time I clicked the button, it failed. The list of allowed sites included two that block Anthropic’s crawler. I took the list out and added logging that records why a lookup fails.
- The next morning the page showed yesterday’s board. The server had the right one, but my browser had saved the old one. Now a board from an earlier day gets dropped.
- On phones, long words wrapped in the middle, like HAZAR / D. The tile text now sizes itself from the word’s length.
- A fix didn’t show up on the live site because the deploy was 12 minutes behind the push. Now I check which version is live before I test.
Try today’s board on The Daily Sort →
The Daily Sort is a sorting aid. It isn’t affiliated with The New York Times, and it never stores or shows the groups. Play the real game at nytimes.com/games/connections.