Agents
Fifteen tools, three surfaces
MCP server, Pi extension and OMP extension serve the same definitions from src/tools.ts, through the adapters of @agntn/tools. They answer identically. A fix lands once.
| Tool | Does | Arguments |
|---|---|---|
puzzles_stats | Totals, status counts, prize sums and technique counts | none |
puzzles_collections | Every collection with its author and its count per status | none |
puzzles_authors | Every author with its kind, its collections and its puzzle count | none |
puzzles_author | One author's record: aliases, profiles, addresses, sourced facts and technique counts | key, an author key or a collection key |
puzzles_solvers | Every named solver with its kind, its solves and the collections it also published | none |
puzzles_solver | One solver's record: every solve with date and prize, profiles and sourced facts | key, a solver key or a puzzle identifier |
puzzles_show | One puzzle's complete record | id, allTransactions |
puzzles_hints | The hints that hold for one puzzle, then the hint files it ships | id |
puzzles_stages | The stages of a multi-stage puzzle in the author's order, with pages, files and published answers | id |
puzzles_assets | The files a puzzle ships and the archived copies of the pages it cites, or one of them read | id, file |
puzzles_list | Puzzles filtered by address, collection, chain, status, technique and public key availability | address, collection, chain, status, technique, withPubkey, limit, offset |
puzzles_verify | A published key and its recipe against the stored address, one puzzle or every filter match | id, or the puzzles_list filters without limit and offset |
puzzles_balance | The live balance of a puzzle address | id, apiKey |
puzzles_watch | What the chain, and with since the source page, knows that the record doesn't | id, since, apiKey |
puzzles_eligibility | The checklist before working on a prize, with a missing row per field nobody can fill | query, chain, apiKey |
Every tool is annotated read only and idempotent. Four are open world. puzzles_balance reaches a block explorer. puzzles_eligibility does too. puzzles_watch does as well, and the Wayback Machine when it gets since. puzzles_assets lists offline. Give it file and it fetches from GitHub, then from the author's URL and the Wayback Machine. A host that lets a model call them should know that. The rest never leave the bundled dataset.
MCP
puzzles mcp
claude mcp add puzzles --scope user -- npx -y @agntn/puzzles mcp
Or in a client's config:
{
"mcpServers": {
"puzzles": { "command": "npx", "args": ["-y", "@agntn/puzzles", "mcp"] }
}
}
puzzles mcp speaks MCP over stdio. Every call is checked against the tool's JSON Schema before an executor sees it, here and in both extensions. Bringing your own transport? createMcpServer() is exported from @agntn/puzzles/mcp.
Remote MCP
No Node on your side? This site runs the same tools over Streamable HTTP, at https://puzzles.agntn.dev/mcp. Give it to any MCP client that takes a URL.
claude mcp add --transport http puzzles https://puzzles.agntn.dev/mcp
{
"mcpServers": {
"puzzles": { "type": "http", "url": "https://puzzles.agntn.dev/mcp" }
}
}
It's the Docus MCP server with the fifteen tools added to it. toToolkitTools from @agntn/tools/toolkit hands Docus the same definitions puzzles mcp serves from src/tools.ts. So the answers match to the byte, and a new tool shows up here on the next deploy. Nothing to copy, nothing to forget. Two more come from Docus. list-pages lists the pages of this site and get-page returns one as Markdown.
A few things differ, though fewer than you'd think. The toolkit takes Zod schemas only. So every tool hands it a blank one that waves any object through, and tools/list still shows the real schema. A bad argument then fails the library's own check. Same words, same control characters stripped. No arguments at all? Stdio reads {}, and now so does the site. puzzles_verify takes an id here and nothing else. A filtered replay burns seconds of CPU, and the site's worker isn't anyone's CI. Run puzzles mcp for that, on your own CPU. puzzles_balance runs on the site's worker, which lends no key of its own. Ethereum doesn't need one, Blockscout answers. Pass apiKey and the lookup goes to Etherscan instead, with the key passing through the worker on the way. And a web page on another origin gets a 403. Clients that call from their own process, like Claude Code or Cursor, send no origin and get in.
A pair of tools lives only here, both for ChatGPT. In ChatGPT Desktop, type @ and a bit of an id, say bits/7. The picker fills with puzzles. Pick one and your message carries a link to that exact record. No more spelling out bits/71 and hoping the model finds it. That's puzzles_mentions, and ChatGPT keeps it away from the model. Its _meta marks it for the app. The server names it in the openai/mentions capability, right where ChatGPT looks. The whole id comes first, then a prefix, and open puzzles go ahead of solved ones. puzzles mcp doesn't serve it at all. Why would a terminal agent want a picker it can't click?
The other one you open yourself. puzzles_registry sits in ChatGPT's sidebar and as a tab in any thread. The whole registry is a click away, and nobody has to ask the model for it. It's an MCP App: the tool points at a ui:// page built from one Vue file, and _meta["openai/ui"] lists both entrypoints.
Every collection is a row, the largest first. Pick one and its puzzles fold out with status and prize. Search takes a key, an author or a puzzle id, so 71 digs up every puzzle with 71 in its id. A name opens its page on this site. ask drops Show me the puzzle bits/71. into the chat.
And the model? It gets one line of totals. The rows ride in the result's _meta, which ChatGPT hands to the app and never to the model. A whole registry for fewer tokens than this paragraph.
The limits live once
id is 1 to 100 characters. file 1 to 200. collection up to 50. address 1 to 128. apiKey up to 200. since 10 to 40, a date or an ISO 8601 moment. limit an integer from 1 to 500, 50 by default. status one of the five values. Those numbers sit in one facts table next to the tool names and descriptions. The schemas are built from it. The executors check the same numbers themselves, so code that calls them straight from @agntn/puzzles/tools hits the same wall, as InvalidArgumentError. A limit of 600 is rejected, not clamped. A model that asked for 600 should learn the limit, not get 500 back and wonder.
Read the next page
puzzles_list starts at offset: 0. The offset counts matching puzzles, after all filters, in dataset order. For bits, the default first page ends with Next page: offset=50. Keep the same filters. Pass that offset to get the next rows without reading the first page again. No next offset means the end. An offset at or beyond the match count returns an empty page.
An address narrowed by other filters can come back empty while a puzzle still pays to it. Then the page names that puzzle and the filter that dropped it: zden/level-halv pays to this address, but its collection is zden (not bits). Without that line a model reads the empty page as an address nobody recorded.
A typo empties the page too. Then the last line reads No supported chain accepts this address; its checksum or format is off. And details gets addressValid: false. A flipped character used to look like a wallet nobody had heard of. Nobody had. It doesn't exist.
{ "collection": "bits", "limit": 50, "offset": 50 }
Offsets are nonnegative safe integers, up to 9007199254740991. Keep the same dataset and filters while paging. After updating the package or changing registered collections, start again at zero. The CLI's list command remains unpaged.
What the text carries
An MCP client sees the text a tool returns and nothing else. So the text carries the whole record, one name: value line per field:
bits/1 solved 0.001 BTC 1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH
chain: bitcoin address kind: p2pkh
hash160: 751e76e8199196d454941c45d1b3a323f1433bd6
public key: 0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798 (compressed)
private key: 0000000000000000000000000000000000000000000000000000000000000001 (hex)
wif: KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn
started: 2013-01-09 11:59:15
solved: 2013-01-10 02:54:44 (14h 55m)
pre-genesis: yes
transactions: 2
funding 2013-01-09 11:59:15 0.03 BTC 9223da07e858c6f153fbb8a24db52374ca19d2639098207c71710610cfda808e
claim 2013-01-10 02:54:44 0.03 BTC 3da9b8e4a9c056b22d4fd09784402fd1caab1ecf621ba074efc20dc03ff04277
claim: https://blockstream.info/tx/3da9b8e4a9c056b22d4fd09784402fd1caab1ecf621ba074efc20dc03ff04277
collection hints: 1
official 2017-04-27 06:41:08 There is no pattern. It is just consecutive keys from a deterministic wallet (masked with leading 000...0001 to set difficulty). source: https://bitcointalk.org/index.php?topic=1306983.msg18765941#msg18765941 confirmation: https://web.archive.org/web/20200509045914/https://bitcointalk.org/index.php?topic=1306983.msg18765941 (Wayback capture of the thread page)
explorer: https://blockstream.info/address/1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH
source: https://bitcointalk.org/index.php?topic=5218972
key range: 1..1 (hex, 1 bit)
The key prints in every form the record has. Hex and WIF here, a BIP38 payload with its passphrase on a Ballet card, an xpub with the published shares on bitaps. Then the dates, the solver, the transactions with their txids, the claim link, the assets and the hints, with the collection's hints ahead of the puzzle's own. Absent things say so: public key: unknown, private key: unknown. A line that vanishes reads as not shown, and a model fills it from memory.
Dust is the first thing that gets squeezed. Strangers keep sending tiny amounts to famous prize addresses, and gsmg got 120 of them since July 2024, most of them right at the dust limit. Nice of them, but listed one by one they were nearly two thirds of its answer. So a run of more than three increases, each under 1% of the largest transaction, prints as one line with the count, the first and last date and the total. The count line still says how many went in, transactions: 127, 120 small increases folded, and an author's top up keeps its own line, because that's the one a model actually needs. allTransactions: true brings every txid back, puzzles show --all-transactions does the same on the CLI.
Long hint lists are the second. The genesis author answered questions paid to the prize address with new hints, right up to the sweep. So that list kept growing, and in puzzles_show it was almost the whole answer. Then the model called puzzles_hints and got the same list again. Why pay for it twice?
A block of more than three hints now prints as one line, hints: N, folded, puzzles_hints lists them. Three or fewer still print in full, and that's most records. The CLI folds the same block and points at puzzles hints <id>.
Stages are the third. GSMG runs in seven phases, and their block was four fifths of its puzzles_show answer. Seven phases of ciphertext pages in a summary? That isn't a summary anymore, and walking them is what puzzles_stages is for. So past three, the stages block folds the same way, stages: 7, folded, puzzles_stages lists them. teikhos/4 has three and still prints them all. On the CLI the pointer says puzzles stages <id>.
puzzles_list prints 12 of 77 matching puzzles: when the limit cut the list, followed by the rows and a next offset. puzzles_collections prints the same rows as puzzles collections. A model learns the keys before it guesses one.
The files, not a link to them
A record cites pages. Pages rot. Reddit threads lose comments, and the Satoshi birthday quiz kept its whole answer key in one. So what does a model do with a URL that answers 404?
It asks puzzles_assets. Without file it lists what the puzzle has. First the files the record ships: image, hint files, solution, stage artifacts. Each one with the size and SHA-256 the record pins. Then the archived sources. Those are the reading copies this repo keeps of the cited pages, with the transcript and every surviving comment:
quizchain2/34: 0 files, 3 archived sources
source assets/sources/quizchain2/aoinakamoto-2019-06-09-byqc7s.md published 2019-06-09 cites https://www.reddit.com/r/Grycoin/comments/byqc7s/7_mbtc_quizchain2_block_34/ screenshot .png
source (author) assets/sources/satoshi-birthday-quiz/aoinakamoto-2019-04-05-b9l37o.md published 2019-04-05 cites https://www.reddit.com/r/Bitcoin/comments/b9l37o/ screenshot .png
Every source has a screenshot, and it used to get a row of its own. Same path, .png on the end, nothing new. Over the whole dataset that was a fifth of every listing spent saying each path twice. Now it's the screenshot .png column on the source's row. Want the image? Swap .md for .png and pass that as file. details.files and --json still list it.
Pass one of those paths as file and the content comes back. Text as text. A PNG, JPEG, GIF or WebP as an image block. A model with eyes looks at the actual puzzle, not at somebody's description of it. The read tries the repository copy under the release tag first. A file merged after the last release isn't there yet, so main comes next, instead of a 404 that lasts until somebody cuts a release. Run it from a checkout, or through puzzles.agntn.dev, and the link names the running commit instead. A collection merged this morning? Its files are there on the first read, no release ceremony. For a pinned file it then tries the author's URL and the archive capture. Only bytes that match the pin come back. A copy with other bytes isn't the puzzle, however close it looks. If nothing matches, the error names every URL it tried and what each one answered.
So puzzles_show, puzzles_stages, puzzles_hints and puzzles_eligibility don't hand a model download links. A file prints as its path, asset: assets/gsmg/puzzle.png, and the answer ends with one line that says where to take it:
files: 6 files, 0 archived sources; puzzles_assets lists them and reads one by its path
Why not the link? Give a model a URL and a shell, and it'll curl the thing. Those bytes never meet the pin. The archived sources don't show up in a record at all, so the count is what tells a model they exist. The CLI keeps its links, though. A person in a terminal wants something to click.
A (author) row is a page about the collection's author, so every puzzle of that author lists it. The screenshots are 2026 renders, fine for checking a transcript and useless for image analysis. An image block caps at 3.75 MiB. Zden's 20 MB codex protocol PNG? An error with the URL to download. The CLI has no such cap. puzzles assets gsmg --read assets/gsmg/puzzle.png > puzzle.png just writes it.
What sets isError
An unverifiable puzzle is an answer. puzzles_verify on bits/71 returns bits/71: unverifiable (Puzzle has no private key) with isError unset. Only a thrown error sets it: an unknown id, an argument outside its limits, a provider failure. Error text is sanitized. Every control character, format character and line separator becomes a space, and an echoed tool name is quoted. Those values arrive from the model or from a provider. One raw newline would forge a line that reads as the server's own answer.
Pi and OMP
pi install npm:@agntn/puzzles
Both extensions are declared in package.json. They add details next to the text, the structured object the harness renders: the puzzle record and every hint that holds for it for a show, { id, hints, hintAssets } for a hints call, { id, stages } for a stages call, { matched, returned, offset, ids } plus nextOffset when more matches remain for a list, the verify result, the balance in base units as strings. A files listing adds { id, files }, a read { id, file, bytes, sha256, servedBy }. A filtered verify gives { matched, misses, results }. MCP drops details and keeps the text. OMP gets the same JSON Schema, labels the tool group Puzzles, and renders a call line like Show Puzzle: bits/71 with control characters stripped.
The extensions load the shared definitions through literal import() specifiers, src/ in a checkout and dist/ when installed, so a bundling host can see the dependencies. The packed layout is a CI gate. pnpm test:packed unpacks the tarball and runs both extensions and the MCP server from it with no src/ next to them.