Custom collections
One class, one file
Every built-in is a class extending one of the three collection bases with its puzzle list: NamedCollection, NumericCollection, or SingletonCollection for exactly one puzzle whose id is the key. Yours is the same shape:
import { hex, NamedCollection, party, profile, puzzle } from "@agntn/puzzles";
export const minePuzzleFirst = puzzle({
id: "mine/first",
chain: "bitcoin",
address: "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
sourceUrl: "https://example.com/first",
startedAt: "2026-01-01 00:00:00",
key: hex("0000000000000000000000000000000000000000000000000000000000000001", 1),
});
export class MineCollection extends NamedCollection {
static readonly key = "mine";
static readonly author = party("you", { profiles: [profile("website", "https://example.com")] });
static readonly puzzles = [minePuzzleFirst];
constructor() {
super(MineCollection.key, MineCollection.author, MineCollection.puzzles);
}
}
export const mine = new MineCollection();
import { get, registerCollection, verify } from "@agntn/puzzles";
registerCollection({ key: "mine", load: () => import("./mine").then((m) => m.mine) });
const puzzle = await get("mine/first"); // loads ./mine now, not before
(await verify(puzzle!)).verified; // true, key 1 derives 1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH
What the registry assumes
The registry checks none of this. It files the collection under its key and trusts the ids, so a puzzle whose id starts with another key is simply unreachable through get.
static readonly keyis what the registry files the collection under and the first segment of every id in it. Lowercase letters, digits and underscores. Registering the same key again replaces what it held.- Every puzzle's id starts with
<key>/, or equals the key for aSingletonCollection.NamedCollectionanswersget("first")andget("mine/first").NumericCollectionanswersget(7),get("7")andget("mine/7"), and nothing else:get("0x7")andget(" 7 ")are misses. - Build the puzzles with
puzzle()and the parts builders. A handwritten class extendingBitcoinPuzzleworks too, when a puzzle needs behavior of its own. The registry doesn't care which. - A hint every puzzle shares goes to the constructor once,
super(key, author, puzzles, [official(…)]), and a puzzle's ownhintsstack on top of it throughhintsFor(query). registerCollection({ key, load })keeps the collection lazy.registerCollection(instance)registers it resolved, and the same instance twice is a no-op. Either wayget,all,stats,dataVersionand the fifteen tools see it on their next call.
What you get for free
count(), solved(), unsolved(), withPubkey(), verify(query), balance(query), hintsFor(query) and the identifier lookups come from Collection. dataVersion() changes, because your records are part of the serialized dataset now. That's the right answer. A consumer caching the snapshot should notice.
Where it stops
The CLI and the MCP server load the built-ins. To serve a custom collection through them, build your own binary around createMcpServer from @agntn/puzzles/mcp and register before connecting. There's no plugin path. Saying so beats inventing one that half works.