Guide

Custom collections

Extend NamedCollection or NumericCollection with a key and an author and a puzzle list. Register a loader. From then on the registry treats it like a built-in.

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:

mine.ts
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 key is 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 a SingletonCollection. NamedCollection answers get("first") and get("mine/first"). NumericCollection answers get(7), get("7") and get("mine/7").
  • Build the puzzles with the chain factories and the parts builders. A handwritten class extending BitcoinPuzzle works 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 way get, all, stats, dataVersion and 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.

@agntn/puzzles·MIT license· Public data about public puzzles. Balances come from the chains' explorers through the worker, cached for five minutes. Every key here was public before it landed in a record.