# A Worked Example: Scryfall Card Data

> A complete, validated plugin manifest read block by block - origins, endpoints, the card mapping, the UI descriptor, and the four things it cannot do yet.

- Source: https://diceytable.com/docs/plugins/scryfall-example
- Last verified: 2026-08-18

Every other page in this track describes a field. This one describes a whole plugin: a real,
validated manifest for a real card-data provider, with every block explained and every limitation
stated rather than left for you to discover.

**This is an example of writing a plugin *for* a provider. It is not an integration, a partnership
or an endorsement.** DiceyTable maintains no relationship with any card-data provider. The plugin
author owns that relationship — its terms, its attribution, its rate-limit agreement and any
credential it needs. The `attribution` and `termsUrl` below are the example author's own
declarations, exactly as they would be in any plugin you write, and nothing on this page
characterises anybody's terms on your behalf. Read the provider's own documentation and write your
own.

Scryfall was chosen for a narrow, practical reason: it needs **no credential**, so you can register
this manifest, open the deck builder and see cards without a credential route anywhere in the loop.
That makes it a good first example, not a recommended dependency.

Read [Plugins](https://diceytable.com/docs/plugins) and [Writing a plugin](https://diceytable.com/docs/plugins/authoring) first. This page
assumes their vocabulary and does not repeat their field tables.

## Where the files are

`examples/plugins/scryfall-mtg/` in the DiceyTable repository holds the manifest and a short
README. In a real plugin that directory *is* the repository root — a plugin is a public GitHub
repository with `diceytable.plugin.json` at its top level, registered against an immutable commit
sha.

It lives there rather than under `mods/` because that directory holds git submodules, and rather
than in the docs content tree because the example gate there compiles every file it finds as a
table script or a mod script, and a JSON manifest has no script surface to compile against.

The manifest is not decorative. `apps/web/src/ui/docs/__tests__/pluginExample.test.ts` parses it
with the real `pluginManifestSchema`, runs the real `validatePluginManifest` — the function the
publish scanner calls — and the real secret scan, compiles its `ui` descriptor with the real
`compilePluginUiToDeckSchema`, and checks that the listing below matches the file byte for byte.

## The whole manifest

`examples/plugins/scryfall-mtg/diceytable.plugin.json`

```json
{
  "schemaVersion": "1.0",
  "id": "example.scryfall-mtg",
  "name": "Scryfall Card Data (example)",
  "version": "1.0.0",
  "breakingVersion": 1,
  "summary": "Worked example: Magic: The Gathering card data, read from the Scryfall API.",
  "description": "A reference plugin published as a worked EXAMPLE, not as a DiceyTable integration. DiceyTable holds no relationship with Scryfall or with any other card-data provider: the author of a plugin owns that relationship, its terms, its attribution and any credential it needs. Read this manifest alongside /docs/plugins/scryfall-example, which walks every block below and states what this shape cannot do yet.",
  "compatibility": { "engine": ">=0.1 <1.0" },
  "capabilities": { "version": "1", "allowed": ["network"] },
  "origins": ["api.scryfall.com"],
  "endpoints": [
    {
      "name": "searchCards",
      "origin": "api.scryfall.com",
      "method": "GET",
      "path": "/cards/search",
      "query": { "format": "json", "order": "name" },
      "params": [
        {
          "name": "q",
          "location": "query",
          "format": "text",
          "maxLength": 200,
          "required": true,
          "description": "A provider search query, e.g. set:neo type:creature."
        },
        {
          "name": "unique",
          "location": "query",
          "format": "enum",
          "options": ["cards", "art", "prints"],
          "default": "cards",
          "required": false,
          "description": "Whether near-duplicate printings collapse to one row."
        },
        {
          "name": "page",
          "location": "query",
          "format": "integer",
          "default": "1",
          "required": false,
          "description": "1-based page number. See the docs page: the platform cannot drive this automatically."
        }
      ],
      "auth": { "kind": "none" },
      "returns": "cards",
      "description": "One page of cards matching a search query."
    },
    {
      "name": "cardRulings",
      "origin": "api.scryfall.com",
      "method": "GET",
      "path": "/cards/{scryfallId}/rulings",
      "params": [
        {
          "name": "scryfallId",
          "location": "path",
          "format": "uuid",
          "required": true,
          "description": "The card id this mapping stores as the card identity."
        }
      ],
      "auth": { "kind": "none" },
      "returns": "raw",
      "description": "Official rulings for one card, looked up by its provider id."
    }
  ],
  "cardMapping": {
    "root": "data",
    "nextPagePath": "has_more",
    "totalPath": "total_cards",
    "identityField": "scryfall_id",
    "fields": {
      "scryfall_id": { "path": "id", "required": true },
      "name": { "path": "name", "transforms": ["trim"], "required": true },
      "art": {
        "path": "image_uris.normal",
        "fallbackPaths": ["card_faces[0].image_uris.normal"]
      },
      "mana_cost": {
        "path": "mana_cost",
        "fallbackPaths": ["card_faces[0].mana_cost"],
        "default": ""
      },
      "mana_value": { "path": "cmc", "transforms": ["number"], "default": 0 },
      "type_line": {
        "path": "type_line",
        "fallbackPaths": ["card_faces[0].type_line"],
        "transforms": ["trim"],
        "default": ""
      },
      "rarity": { "path": "rarity", "transforms": ["lowercase"], "default": "common" },
      "colors": {
        "path": "color_identity",
        "transforms": ["join"],
        "joinSeparator": "",
        "default": "C"
      },
      "set_name": { "path": "set_name", "transforms": ["trim"], "default": "" },
      "collector_number": { "path": "collector_number", "transforms": ["string"], "default": "" }
    }
  },
  "ui": {
    "fields": [
      { "key": "scryfall_id", "label": "Card id", "type": "text" },
      { "key": "name", "label": "Card name", "type": "text" },
      { "key": "art", "label": "Card image", "type": "text" },
      { "key": "mana_cost", "label": "Mana cost", "type": "text" },
      { "key": "mana_value", "label": "Mana value", "type": "number" },
      { "key": "type_line", "label": "Type line", "type": "text" },
      {
        "key": "rarity",
        "label": "Rarity",
        "type": "select",
        "options": ["common", "uncommon", "rare", "mythic", "special", "bonus"]
      },
      { "key": "colors", "label": "Colour identity", "type": "text" },
      { "key": "set_name", "label": "Set", "type": "text" },
      { "key": "collector_number", "label": "Collector number", "type": "text" }
    ],
    "roles": {
      "key": "scryfall_id",
      "title": "name",
      "art": "art",
      "group": "type_line"
    },
    "columns": [
      { "fieldKey": "name", "header": "Card" },
      { "fieldKey": "mana_cost", "header": "Cost", "align": "right" },
      { "fieldKey": "mana_value", "header": "MV", "align": "right" },
      { "fieldKey": "type_line", "header": "Type" },
      { "fieldKey": "rarity", "header": "Rarity" },
      { "fieldKey": "set_name", "header": "Set" }
    ],
    "stats": [
      { "kind": "histogram", "fieldKey": "mana_value", "label": "Mana curve" },
      { "kind": "sum", "fieldKey": "mana_value", "label": "Total mana value" },
      { "kind": "breakdown", "fieldKey": "rarity", "label": "Rarity" },
      { "kind": "breakdown", "fieldKey": "colors", "label": "Colour identity" }
    ],
    "search": {
      "endpoint": "searchCards",
      "controls": [
        { "param": "q", "control": "text", "label": "Search query" },
        { "param": "unique", "control": "enum-chips", "label": "Printings" }
      ]
    }
  },
  "exposedApi": [
    {
      "name": "rulingsForCard",
      "summary": "Official rulings for one card, by the card id this plugin stores.",
      "endpoint": "cardRulings",
      "params": {
        "type": "object",
        "fields": {
          "scryfallId": { "schema": { "type": "string", "maxLength": 36 } }
        }
      },
      "returns": {
        "type": "object",
        "fields": {
          "object": { "schema": { "type": "string", "maxLength": 32 } },
          "has_more": { "schema": { "type": "boolean" } },
          "next_page": { "schema": { "type": "string", "maxLength": 400 }, "optional": true },
          "warnings": {
            "schema": { "type": "array", "items": { "type": "string", "maxLength": 400 }, "maxItems": 16 },
            "optional": true
          },
          "data": {
            "schema": {
              "type": "array",
              "maxItems": 200,
              "items": {
                "type": "object",
                "fields": {
                  "object": { "schema": { "type": "string", "maxLength": 32 } },
                  "oracle_id": { "schema": { "type": "string", "maxLength": 64 } },
                  "source": { "schema": { "type": "string", "maxLength": 32 } },
                  "published_at": { "schema": { "type": "string", "maxLength": 32 } },
                  "comment": { "schema": { "type": "string", "maxLength": 4000 } }
                }
              }
            }
          }
        }
      }
    }
  ],
  "attribution": "Card data from Scryfall (scryfall.com). This example plugin is not affiliated with, endorsed by, or supported by Scryfall or Wizards of the Coast.",
  "termsUrl": "https://scryfall.com/docs/api",
  "rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 1 },
  "license": "MIT",
  "tags": ["cards", "tcg", "mtg", "example"]
}
```

The rest of this page walks it block by block.

## Identity and versions

```json
"id": "example.scryfall-mtg",
"version": "1.0.0",
"breakingVersion": 1,
"compatibility": { "engine": ">=0.1 <1.0" }
```

`id` is reverse-DNS and is the string a game's card source and a mod's manifest both name. It is
also the id saved decks key against, so it is not a thing to rename casually.

`version` is bumped on every publish. `breakingVersion` is bumped **only** when the card model
changes incompatibly — a different identity field, a different id space. Saved decks key to
`(pluginId, breakingVersion)`, so a patch release leaves every deck intact and a `breakingVersion`
bump forks the deck pool. See [`breakingVersion` and
migration](https://diceytable.com/docs/plugins/breaking-changes) before you touch it.

`compatibility.engine` is a range, and the range form the engine actually reads is
`>=<major>.<minor>` and `<<major>.<minor>`. A caret range parses as a string and constrains
nothing, so write the explicit bounds.

## Capabilities: `network`, and nothing else

```json
"capabilities": { "version": "1", "allowed": ["network"] }
```

This plugin ships no table half, so there is no `tableScript`, no sandboxed script and no code of
any kind — a `network` plugin is pure declaration. It also means no `combined`, so nothing about
this plugin can read table state.

That is the shape to aim for. `combined` exists, is allowed, and is deliberately the hard path: a
plugin that can read table state and send data to its own servers is, on the host, a cheating
vector. If the split shape can do what you need, take it.

## Origins: one hostname

```json
"origins": ["api.scryfall.com"]
```

A bare lowercase hostname — no scheme, no port, no path, no wildcard. Every endpoint's `origin`
must be one of these, and the platform composes `https://{origin}{path}` itself. The plugin has no
field in which to express a destination, which is what makes the allowlist hold regardless of what
any plugin does.

## Endpoints

Two, and they are deliberately different shapes.

### `searchCards` — query parameters, `returns: "cards"`

```json
{
  "name": "searchCards",
  "origin": "api.scryfall.com",
  "method": "GET",
  "path": "/cards/search",
  "query": { "format": "json", "order": "name" },
  "params": [
    { "name": "q", "location": "query", "format": "text", "maxLength": 200, "required": true },
    { "name": "unique", "location": "query", "format": "enum", "options": ["cards", "art", "prints"], "default": "cards", "required": false },
    { "name": "page", "location": "query", "format": "integer", "default": "1", "required": false }
  ],
  "auth": { "kind": "none" },
  "returns": "cards"
}
```

- `query` holds the values the platform sends on **every** request to this endpoint. They are not
  caller-supplied and there is no way for a caller to change them.
- `q` is `text`, which is free text capped at `maxLength`. Free text is only legal in a query
  string — a query value is percent-encoded and cannot change the host or the path.
- `unique` is an `enum`, so its `options` are the complete set of values that can ever be sent.
  Anything else is refused before a request is composed.
- `page` is an `integer`. Its `default` is validated against the format at publish time, so
  `"default": "one"` would not parse.
- `auth: { "kind": "none" }` is the whole credential story here. A provider that needed one would
  declare `bearer` or a `header` name and the value would be written server-side, never here — see
  [Credentials](https://diceytable.com/docs/plugins/authoring#credentials-never-in-the-manifest).

### `cardRulings` — a path parameter, `returns: "raw"`

```json
{
  "name": "cardRulings",
  "origin": "api.scryfall.com",
  "method": "GET",
  "path": "/cards/{scryfallId}/rulings",
  "params": [
    { "name": "scryfallId", "location": "path", "format": "uuid", "required": true }
  ],
  "auth": { "kind": "none" },
  "returns": "raw"
}
```

Every `{placeholder}` needs a matching parameter that declares `location: "path"` and
`required: true` — an absent segment changes which resource is addressed, so an optional one is a
publish error. The format must be one that provably cannot contain a path separator: `token`,
`uuid`, `integer` or `enum`. Here it is `uuid`, which is exactly the shape of the id this plugin's
mapping stores as the card identity, so a mod holding a card id already holds a valid argument.

It is `returns: "raw"` rather than `"cards"`, and the reason is a real constraint rather than a
preference — see [one `root` per manifest](https://diceytable.com/docs/plugins/scryfall-example#one-cardmapping-root-per-manifest-so-two-response-shapes-do-not-fit),
below.

## The card mapping

This is the block that turns a provider's JSON into cards. It is a **declaration**, not code:
dotted read paths and a closed list of transforms, with no expression language anywhere.

```json
"cardMapping": {
  "root": "data",
  "nextPagePath": "has_more",
  "totalPath": "total_cards",
  "identityField": "scryfall_id",
  "fields": { }
}
```

`root` addresses the array of card records inside the response. The provider wraps its search
results as `{ "object": "list", "total_cards": …, "has_more": …, "data": [ … ] }`, so `root` is
`"data"`.

`identityField` names the field carrying the card's stable id. It must be one of `fields` and must
be `required`, because a saved deck stores that value and a card with no id cannot be re-resolved
later.

Field by field:

| Key | Read | Why it is written that way |
|---|---|---|
| `scryfall_id` | `id`, required | The identity. Required, so a record without one is dropped rather than emitted unusable. |
| `name` | `name`, `trim`, required | The card's printed name. A double-faced card's name is `"Front // Back"` at the top level, which is what a decklist row should say. |
| `art` | `image_uris.normal`, falling back to `card_faces[0].image_uris.normal` | The fallback is the whole reason `fallbackPaths` exists: a double-faced card has **no** top-level `image_uris`, and without the fallback every one of them would render art-less. |
| `mana_cost` | `mana_cost`, falling back to `card_faces[0].mana_cost`, default `""` | Same split shape as the art, for the same reason. |
| `mana_value` | `cmc` through the `number` transform, default `0` | The provider sends a JSON number; `number` makes the field's type explicit so a `histogram` and a `sum` over it are legal. |
| `type_line` | `type_line`, falling back to `card_faces[0].type_line`, `trim` | Bound to the `group` role below, so a decklist groups by card type. |
| `rarity` | `rarity` through `lowercase`, default `"common"` | Normalised, because the `select` field type declares a closed option list and a stray `"Rare"` would sit outside it. |
| `colors` | `color_identity` through `join` with an empty `joinSeparator`, default `"C"` | The provider sends an array like `["W","U"]`; `join` flattens it to `"WU"`. A colourless card sends `[]`, which joins to an empty string — and an empty string is treated as *no value*, so the `"C"` default lands exactly where it should. |
| `set_name` | `set_name`, `trim`, default `""` | Plain passthrough. |
| `collector_number` | `collector_number` through `string`, default `""` | The provider sends this as a string that is *usually* digits. `string` makes the field's type unambiguous rather than leaving it to the first record seen. |

Two rules worth internalising from that table:

1. **A candidate path wins only if it produces a usable value all the way through its transforms.**
   An empty string, an empty array joined to nothing, or a number transform that cannot parse all
   fall through to the next `fallbackPath`, then to `default`.
2. **A `required` field may not declare a `default`.** A record that yields nothing for a required
   field is dropped, which is the honest outcome — the alternative is a card whose id is a
   placeholder.

### `nextPagePath` is a truth-teller here, not a cursor

The provider's own `next_page` is a **fully-qualified URL**. The platform refuses URL-shaped
cursors by construction — handing one to the fetch layer would hand it an attacker-chosen
destination and defeat the origin allowlist outright — so `next_page` is unusable as a paging
cursor no matter how it is declared.

What this manifest declares instead is `nextPagePath: "has_more"`, a boolean. That does not enable
paging, and it is not pretending to: it makes the prefetch **report itself as truncated** after the
first page instead of stopping silently and implying it saw everything. Declaring nothing at all
would be the worse outcome, not the neutral one.

`totalPath: "total_cards"` is read for display only.

## The UI descriptor: what it puts on screen

`ui` is how this plugin's cards are *presented*, declared as data. There is no HTML, no CSS, no URL
and no length unit in the grammar — an author says which of their own mapped fields plays which
part, and DiceyTable renders it with the same components a game's own card schema renders through.

```json
"ui": {
  "fields": [ ],
  "roles": { "key": "scryfall_id", "title": "name", "art": "art", "group": "type_line" },
  "columns": [ ],
  "stats": [ ],
  "search": { }
}
```

**`fields`** declares the *type* of each mapped key — `text`, `number`, `boolean` or `select`. The
mapping said how a value is extracted; this says what it is, and the type is what makes a `sum`
over a text field a publish-time error instead of a column of `NaN`. Every key here must be one the
`cardMapping` produces, or no card would ever carry a value for it.

**`roles`** binds four jobs to four fields:

| Role | Bound to | What it does on screen |
|---|---|---|
| `key` | `scryfall_id` | The card identity a saved deck stores. Must equal `cardMapping.identityField`. |
| `title` | `name` | The tile caption and the decklist row label. |
| `art` | `art` | The card image. Absent would mean name-only placeholder tiles. |
| `group` | `type_line` | The heading a decklist is grouped under. Absent would mean a flat list. |

**`columns`** are the result grid's columns, in order, each naming a field and an optional header
and alignment. There is deliberately **no `width`** — a column width is a free-form length string,
and layout on a public page is the platform's, not a third party's.

**`stats`** are the aggregates beside the deck. This example declares all three kinds so you can
see what each is for: a `histogram` over `mana_value` is the mana curve, a `sum` over the same
field is a single total, and a `breakdown` over `rarity` and over `colors` is a count per distinct
value. A `histogram` or `sum` over a text field, or a `breakdown` over a number, fails at publish.

### How the compiled result actually reaches the screen

When the deck builder loads a game whose card source is this plugin, the descriptor arrives from
the registry, is re-parsed by the strict UI schema — control characters, bidirectional-formatting
characters and zero-width characters in any author string are rejected here, not just at publish —
and is then compiled into a real deck schema. That compiled schema **replaces** the game's
`fields`, `roles`, `columns` and `stats`; everything else the game declared (its formats,
partitions, layouts, counters, attachment rules and its own filters) survives untouched, and the
merged document is validated again.

Two consequences you should design around:

- **`roles.key` must match the game's `roles.key`.** If they disagree the source fails with a named
  error and a banner, rather than rendering a grid of blank cells. A game keying its decks on
  `scryfall_id` works with this plugin; a game keying on something else does not.
- **A game filter over a field this plugin does not declare is a visible failure**, because the
  merged schema is re-validated. That is a genuine incompatibility between two documents, and it
  says so.

The `attribution` string is displayed above the deck builder and beside the results.

## The exposed API

`exposedApi` is what a **mod** may call through `api.callPlugin`, under the `plugin-call`
capability. This example exposes one function:

```json
{
  "name": "rulingsForCard",
  "summary": "Official rulings for one card, by the card id this plugin stores.",
  "endpoint": "cardRulings",
  "params": { "type": "object", "fields": { "scryfallId": { "schema": { "type": "string", "maxLength": 36 } } } },
  "returns": { }
}
```

The chain *mod call → plugin function → endpoint → origin* is followable statically, which is what
lets the mod scanner and a human reviewer see where a mod's traffic actually goes. See [Calling a
plugin from a mod](https://diceytable.com/docs/plugins/calling-from-a-mod) for the caller's side.

Three things the schema does not tell you, all of which this example is built around:

1. **Every declared param name must be a parameter its endpoint declares.** The URL composer
   refuses an undeclared parameter outright, so a mismatch here is a call that always fails with
   `refused` — the least debuggable outcome there is. Nothing cross-checks it at publish; the test
   beside this example does.
2. **The value-schema grammar has no pattern, so `uuid` is enforced by the *endpoint*, not by the
   function.** `params.scryfallId` is declared as a string with a length cap; the actual
   8-4-4-4-12 check happens when the URL is composed, and a bad value comes back as `refused`.
3. **A declared return schema strips what it does not name.** Keys the provider sends and the
   manifest does not declare are dropped before the caller sees them, so you declare what you want
   rather than enumerating the provider's whole response. Declaring `next_page` and `warnings` as
   optional here is still worth doing — a rulings response rarely carries them, but a caller that
   wants them needs them named. What stripping does *not* relax is the fields you did declare: a
   wrong type or a missing non-optional field is still `refused`.

   > This used to be strict, and a single undeclared key turned a perfectly successful call into
   > `unavailable`. It was changed after a second provider proved the rule unworkable — see
   > [Value schemas](https://diceytable.com/docs/plugins/authoring) for the params-strict / returns-strip split.

## Attribution, terms, licence and quotas

```json
"attribution": "Card data from Scryfall (scryfall.com). …",
"termsUrl": "https://scryfall.com/docs/api",
"license": "MIT",
"rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 1 }
```

`attribution` is displayed wherever this plugin's data appears and `termsUrl` points at the terms
**the author** read and accepted on their own behalf. Write your own; do not copy these.

`license` is the licence of the *manifest source in your repository*. It says nothing about the
licence of the data the provider serves, which is the provider's business and yours.

`rateLimits` is a **ceiling you accept, never a budget the platform grants** — the fetch layer
enforces `min(declared, platform ceiling)`, so a large number buys nothing and a small one is
honoured. `maxConcurrent: 1` here is a deliberate choice rather than a default: one request at a
time is the politest thing a plugin can do to a provider that asks for restraint, and the deck
prefetch is sequential anyway. Read your provider's own guidance and declare accordingly; the
numbers above are this example author's, not a recommendation. The
[quota and circuit-breaker rules](https://diceytable.com/docs/plugins/authoring#quotas-and-the-neighbour-you-share-an-origin-with)
apply unchanged, including the breaker you share with every other plugin on the same hostname.

## Wiring it into a game

A game reaches this plugin through its own card schema, not through any code:

```json
{
  "version": 1,
  "breakingVersion": 1,
  "name": "Magic: The Gathering",
  "source": {
    "kind": "plugin",
    "pluginId": "example.scryfall-mtg",
    "options": {
      "_endpoint": "searchCards",
      "_searchParam": "q",
      "q": "set:neo",
      "unique": "cards"
    }
  },
  "fields": [{ "key": "scryfall_id", "type": "text" }, { "key": "name", "type": "text" }],
  "roles": { "key": "scryfall_id", "title": "name" }
}
```

The `options` bag reserves exactly three keys, all beginning with `_`, which no declared parameter
name can collide with because a parameter name must start with a letter:

| Key | What it names |
|---|---|
| `_endpoint` | Which card endpoint to use. Omitted, the platform takes the first one. |
| `_searchParam` | The declared parameter the deck builder's free-text search box feeds. |
| `_pageParam` | The declared parameter a paging cursor is fed back into. Useless with this provider — see below. |

Everything else in the bag is a declared parameter value, passed through untouched. `q` is
**required** on this endpoint, so a game that omits it gets a failed source rather than an empty
one — and because the prefetch stops after the first page here, `q` should be scoped narrowly. A
query that returns the game's whole card universe is a query that will truncate.

The game's own `fields`/`roles` are replaced by the plugin's compiled descriptor at load, but they
still have to be *declared* and their `roles.key` still has to agree with the plugin's identity
field.

**This manifest declares no `deckImport`,** and it should not: Scryfall is a card database, not a
deck database, so there is no endpoint returning somebody's decklist for an import block to map.
A game sourced from this plugin can still offer import from a *different* provider by naming that
plugin in its own `cardSchema.json` — see [Deck import](https://diceytable.com/docs/plugins/authoring#deck-import-letting-a-plugin-supply-a-deck-not-just-cards).

## What this example cannot do yet

Four limits, all real, all reached while building this manifest. None of them is a bug in the
example.

### Plugin-declared search controls do not render

The manifest declares this, and it validates:

```json
"search": {
  "endpoint": "searchCards",
  "controls": [
    { "param": "q", "control": "text", "label": "Search query" },
    { "param": "unique", "control": "enum-chips", "label": "Printings" }
  ]
}
```

**These render.** Each declared control appears in the deck builder's search pane, above the game's
own filters and under a platform sentence saying the options come from the card provider and change
what is requested.

They do **not** arrive through the deck-schema compiler, and that distinction is worth keeping
straight: a deck schema's filters bind to card **field keys** and narrow cards that are already
loaded, while these bind to declared **endpoint parameters** and change what gets fetched. Same
widgets, different target, so the compiler still ignores `search` and the controls are resolved
separately against the endpoint the request will actually go to.

Three rules apply, and a control that breaks one is dropped rather than rendered broken:

- its `param` must be a parameter that endpoint declares;
- the widget must be able to drive that parameter's format — `text` for `text`/`token`/`uuid`,
  `enum-chips` for `enum`, `boolean` for `boolean`;
- an `enum-chips` needs declared `options`.

`range` and `enum-multi` are not available here at all. Each produces more than one value, and a
declared parameter takes one — inventing a `"3..7"` encoding would be inventing your provider's
syntax. `enum-chips` is single-choice on this surface for the same reason; pressing the active chip
clears it.

Values are validated against the declared format before being sent, so a half-typed uuid narrows
nothing instead of producing a refusal the searcher cannot interpret. A game's `_searchParam`
free-text box still works and is sent alongside them.

So: declare `search` if you want to record your intent — it is legal and it costs nothing — but do
not design a plugin whose usability depends on it. Declare the parameters you want reachable, and
tell game authors which one to point `_searchParam` at.

### There is no `/search` route

`GET /api/plugins/:id/cards` serves **both** the deck prefetch and the deck-builder search. They
are the same request shape with different declared parameter values. Do not design around a
separate search route on our side; declare one *upstream* endpoint and let the search box feed a
declared parameter. The [route list](https://diceytable.com/docs/plugins/authoring#there-is-no-search-route) has the
detail.

### One `cardMapping` root per manifest, so two response shapes do not fit

`cardMapping` is **per manifest**, not per endpoint, and its `root` is a single path. This
provider — like most — wraps a search result as `{ "data": [ … ] }` but returns a bare card object
from its by-id route. `root: "data"` addresses the first and finds nothing in the second, and
`root: ""` does the reverse.

That is why `cardRulings` is `returns: "raw"` and why this manifest has exactly one card endpoint.
A plugin whose provider offers only by-id access can declare a by-id endpoint as `returns: "cards"`
with `root: ""` and it will work — but it will be the *only* card shape that manifest can serve,
and it makes `GET /api/plugins/:id/cards?endpoint=…&p.id=…` a per-card lookup on your plugin.
Prefer a bulk or search endpoint when your provider offers one.

### A mod sees at most 32 fields of any one object

A declared return schema is capped at **32 fields per object**. A card object from a provider of
this size carries well over 32 top-level keys, so a mod can never be handed a whole one — you would
declare the 32 that matter and the rest are stripped.

That cap is a real constraint but not the reason a card lookup is unexposable. Card endpoints are
barred from `exposedApi` outright, because card data reaches a table through the platform's prefetch
rather than through mod calls — see [above](https://diceytable.com/docs/plugins/scryfall-example#do-not-point-an-exposed-function-at-a-cards-endpoint).

Small, stable envelopes are the comfortable case, which is why this example exposes a rulings
lookup: three keys at the top level and five per record.

## Testing it before you publish

The [pre-publish checklist](https://diceytable.com/docs/plugins/authoring#testing-a-change-before-you-publish) applies in
full. Two additions this example demonstrates:

1. **Validate the manifest in a test, not by eye.** `validatePluginManifest` reports every issue at
   once with a path per issue, and it is a plain function — a five-line test that feeds it your
   manifest file catches a typo before a registration does.
2. **Record one real response and assert your read paths against it.** The test beside this example
   keeps a trimmed recording of a search page and asserts that every required mapping path resolves
   on it, that every optional one either resolves or declares a default, and — the one that
   actually matters — that the double-faced fallback is load-bearing rather than cargo cult. A
   recording cannot notice the provider changing, but it catches the far more common failure: a
   mapping edited without the shape it reads being re-checked.

## See also

- [Plugins](https://diceytable.com/docs/plugins) — the split, the capabilities, and the four rules in short form.
- [Writing a plugin](https://diceytable.com/docs/plugins/authoring) — the field-by-field reference this page is a worked
  instance of.
- [`breakingVersion` and migration](https://diceytable.com/docs/plugins/breaking-changes) — the one field whose value
  strands other people's saved decks.
- [Calling a plugin from a mod](https://diceytable.com/docs/plugins/calling-from-a-mod) — `listPlugins`, `callPlugin` and
  the `plugin-call` capability.
- [Deck import](https://diceytable.com/docs/plugins/authoring#deck-import-letting-a-plugin-supply-a-deck-not-just-cards) — the block this
  example does not declare, and the one route into the deck builder that needs no capability.
- [Working with decks](https://diceytable.com/docs/editor/working/decks) — how a game declares its card source.
- [Building a deck](https://diceytable.com/docs/editor/workflows/build-a-deck) — the deck builder this descriptor renders
  through.
