---
title: "Custom collections"
description: "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."
canonical_url: "https://puzzles.agntn.dev/guide/custom"
---
# 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:

```ts [mine.ts]
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();
```

```ts
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 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")`, and nothing else: `get("0x7")` and `get(" 7 ")` are misses.
- Build the puzzles with `puzzle()` 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.
- A hint every puzzle shares goes to the constructor once, `super(key, author, puzzles, [official(…)])`, and a puzzle's own `hints` stack on top of it through `hintsFor(query)`.
- `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 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.


## Sitemap

See the full [sitemap](https://puzzles.agntn.dev/sitemap.md) for all pages.
