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 { NamedCollection, bitcoinPuzzle, hex, p2pkh, party, profile } from "@agntn/puzzles";
export const minePuzzleFirst = bitcoinPuzzle({
id: "mine/first",
address: p2pkh("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, verifyPuzzle } from "@agntn/puzzles";
registerCollection({ key: "mine", load: () => import("./mine").then((m) => m.mine) });
const puzzle = await get("mine/first"); // loads ./mine now, not before
verifyPuzzle(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"). - Build the puzzles with the chain factories and the parts builders. A handwritten class extending
BitcoinPuzzleworks too, when a puzzle needs behavior of its own. The registry doesn't care which. 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 six tools see it on their next call.
What you get for free
count(), solved(), unsolved(), withPubkey(), verify(query), balance(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.