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.

Open The Daily Sort →

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.

5/4

FOLD is also a poker move, next to RAISE, CALL and BLUFF.

Fig. 1 From one of the practice puzzles. All five look like laundry, but only four of them can be.

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

  1. browser asks for today
  2. saved copy found
  3. about a second, no AI

First load of the day

  1. browser asks for today
  2. nothing saved yet
  3. one lookup per day
  4. web search, 10–40 s
  5. 16 clean words
  6. for everyone

Meanwhile the browser asks again every 3 seconds until the board is saved.

Fig. 2 The two ways a board reaches the browser. Almost everyone takes the top path.

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.

  1. +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.

  2. +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.

  3. +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
Fig. 3 The report from the first real lookup, with the middle checks trimmed.

Asking Claude

PROMPT, MODEL, SEARCH, PARSE

All the code that talks to Claude lives in one file: netlify/connections/claude-source.mjs. 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/connections/claude-source.mjs The prompt, the models and the Claude API call. The only file that talks to Claude.
  • netlify/connections/words.mjs Checks Claude’s answer before anything is saved.
  • netlify/connections/fetch.mjs The background lookup: takes the lock, applies the limits, asks, checks, saves.
  • netlify/connections/today.mjs The fast endpoint the page calls. Never calls Claude.
  • netlify/connections/prefetch.mjs The overnight job that does the first lookup of the day.
  • netlify/connections/internal-auth.mjs Signs requests so only the site can start a lookup.
  • src/components/connections/ The page itself: tiles, inkwells, wire report. No AI.
Fig. 4 Where the code lives. The highlighted file is the only one that talks to Claude.

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.

the first live lookup: 38k tokens read, 645 written, 2 searches
estimate: the same tokens at Haiku’s list price ($0.10 / $0.50 per million)
Fig. 5 What one daily lookup costs. Search is $10 per thousand searches on any model, so the tokens are where the model matters.

What broke along the way

  1. 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.
  2. 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.
  3. On phones, long words wrapped in the middle, like HAZAR / D. The tile text now sizes itself from the word’s length.
  4. 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.