# Writing a Plugin

> The diceytable.plugin.json manifest end to end - origins, endpoints, card mapping, deck import, exposed API, capabilities, resources and quotas.

- Source: https://diceytable.com/docs/plugins/authoring
- Last verified: 2026-09-12

A plugin is a public GitHub repository with `diceytable.plugin.json` at its root, registered
against an immutable commit sha. This page walks the manifest field by field and explains the
runtime rules an author cannot see from the schema alone: the quota you actually get, the
breaker you share, and the four words a caller will ever hear when something fails.

Read [Plugins](https://diceytable.com/docs/plugins) first if you have not — the two-half split it describes is the
reason most of the fields below are shaped the way they are.

## The manifest at a glance

```json
{
  "schemaVersion": "1.0",
  "id": "org.example.cards",
  "name": "Example Card Data",
  "version": "1.0.0",
  "breakingVersion": 1,
  "summary": "Card search and lookup for Example TCG.",
  "compatibility": { "engine": "^0.1.0" },
  "capabilities": { "version": "1", "allowed": ["network"] },
  "origins": ["api.example.com"],
  "endpoints": [
    {
      "name": "searchCards",
      "origin": "api.example.com",
      "method": "GET",
      "path": "/v1/cards/search",
      "query": { "format": "json" },
      "params": [
        { "name": "q", "location": "query", "format": "text", "maxLength": 120, "required": true },
        { "name": "page", "location": "query", "format": "token", "required": false }
      ],
      "auth": { "kind": "bearer" },
      "returns": "cards"
    }
  ],
  "cardMapping": {
    "root": "data",
    "nextPagePath": "next_page",
    "identityField": "key",
    "fields": {
      "key": { "path": "id", "required": true },
      "title": { "path": "name", "transforms": ["trim"], "required": true },
      "art": { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] }
    }
  },
  "exposedApi": [
    {
      "name": "searchCards",
      "summary": "Search the provider's card database by name text.",
      "endpoint": "searchCards",
      "params": { "type": "object", "fields": { "q": { "schema": { "type": "string", "maxLength": 120 } } } },
      "returns": { "type": "object", "fields": { "cards": { "schema": { "type": "array", "items": { "type": "string" }, "maxItems": 250 } } } }
    }
  ],
  "attribution": "Card data © Example Games, used under the Example API terms.",
  "termsUrl": "https://example.com/api/terms",
  "rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 2 },
  "license": "MIT",
  "tags": ["cards", "tcg"]
}
```

**The manifest is strictly parsed.** An unrecognised key is a rejection, not something ignored.
That is deliberate: it is what makes "no credential in a manifest" mechanical rather than
advisory, because `"apiKey": "…"` does not parse at all.

## Origins: hostnames, and nothing that is not a hostname

`origins` is the complete list of hostnames the plugin may reach — at most eight, and every
endpoint's `origin` must be one of them. They are **bare lowercase hostnames**:

```json
"origins": ["api.example.com", "images.example.com"]
```

Every richer spelling is rejected at publish time, and each rejection closes a specific hole:

| Rejected | Why |
|---|---|
| `https://api.example.com` | A scheme invites a downgrade, and `//evil.example` is a protocol-relative form. |
| `*.example.com` | Makes the allowlist depend on whoever controls a subdomain. |
| `api.example.com:8443` | A port reaches an internal service on a host that is otherwise legitimate. |
| `user@api.example.com` | Puts an attacker-chosen authority in front of the real one. |
| `127.0.0.1`, `[::1]`, `169.254.169.254` | An address literal is the target of the attack this list exists to stop. |
| `localhost` | Not fully qualified — an origin needs at least one dot. |

The schema is the first of two gates. The fetch layer resolves the hostname, pins the address it
resolved to, and refuses private and link-local ranges **after** resolution, so a hostname that
resolves inward is refused at request time even though it parsed.

## Endpoints: you name one, the platform builds the request

**There is no `url` field and there will never be one.** The platform composes
`https://{origin}{path}` from values it validated at publish time, substitutes typed parameters,
and refuses everything else. A plugin cannot express a destination, which is why the origin
allowlist holds regardless of what any plugin does.

At most sixteen endpoints. Each carries:

| Field | Rule |
|---|---|
| `name` | A letter followed by letters, digits or underscores, up to 64 characters. Unique within the manifest. |
| `origin` | One of the declared `origins`. |
| `method` | `GET` or `POST`, defaulting to `GET`. `POST` exists only for providers whose bulk lookup demands it — there is no `PUT`, `DELETE` or `PATCH`, because a card-data plugin reads. |
| `path` | A path template (below). |
| `query` | Fixed query values the platform always sends, e.g. `{ "format": "json" }`. At most sixteen. Never URL-shaped. |
| `params` | At most sixteen typed parameters (below). |
| `auth` | Where the platform puts your credential. See [Credentials](https://diceytable.com/docs/plugins/authoring#credentials-never-in-the-manifest). |
| `returns` | `cards`, `raw` or `catalogue` — see below. Nothing passes through unmapped. |

### Which `returns` kind to use

| Kind | Fetched | Bounded by | Use it when |
|---|---|---|---|
| `cards` | Per user, in their critical path | 2 MiB | Your provider has a search endpoint that returns a page of cards. |
| `raw` | Per mod call | 2 MiB | The response is not cards — a rulings lookup, a decklist. Validated against the exposed function's declared return schema. |
| `catalogue` | **By us, on a schedule** | 16 MiB | Your provider publishes its **whole dataset** in one document and offers no search. |

`catalogue` exists because a large class of providers — community and fan card databases especially
— have no search API at all: they publish one file containing everything and expect the client to
filter it. That shape was previously unrepresentable, and the 2 MiB request ceiling refused it
anyway.

A catalogue endpoint is fetched **with no user waiting**, normalized once through your `cardMapping`,
and held for the deck builder to search locally. So:

- it must be `GET`, and it **may not have required parameters** — nothing is present at fetch time
  to supply one. Optional parameters carry their declared defaults;
- it is refreshed periodically rather than per request, which is *cheaper* for your provider than
  per-user search: one read per interval shared by everyone;
- if a refresh fails, the previous ingest keeps being served, marked stale, for up to a week. An
  empty response counts as a failure — a shipped card game is never genuinely cardless;
- it cannot be selected by a caller. The `/cards` route resolves only `cards` endpoints, which is
  what keeps the larger read ceiling off any user-triggered path.

### Path templates

A path is a leading `/`, literal segments, and `{param}` placeholders:

```
/v1/cards/search
/v1/cards/{cardId}
/v1/sets/{setCode}/cards
```

Rejected outright: a scheme, a leading `//`, `.` or `..` segments, a backslash, a percent-escape,
an `@`, a `?` or `#`, whitespace and control characters. What survives cannot name a host.

Every `{placeholder}` must have a matching parameter that declares `location: "path"` and
`required: true`. A missing path segment changes which resource is addressed, so an optional one
is a publish error rather than a runtime surprise.

### Parameter formats

There is deliberately **no author-supplied regex**. A regex evaluated on our servers against
attacker-influenced input is a denial-of-service surface, and it cannot be checked statically for
"can this match a slash?" — which is the only question a path parameter has to answer. Closed
formats answer it by construction.

| Format | Accepts | Legal in a path? |
|---|---|---|
| `token` | `[A-Za-z0-9._-]`, 1–120 characters — the shape of a provider's opaque id | yes |
| `uuid` | Canonical 8-4-4-4-12 | yes |
| `integer` | Digits, optional leading `-`, up to 15 digits | yes |
| `enum` | One of a declared `options` list, at most 64 entries | yes |
| `boolean` | `true` or `false` | no |
| `text` | Free text up to `maxLength` (200 maximum) | **no** — query only |

A `query` value is percent-encoded and cannot change the host or the path, so free text is fine
there; a search box is free text. A `path` value becomes part of the URL, where a `/` or a `..`
re-points the request.

`options` is required for `enum` and forbidden otherwise; `maxLength` applies only to `text`; a
`default` must itself satisfy the format it sits on.

## The card mapping

Decision D-S1: **the data half is a declarative mapping, not author JavaScript running on our
servers.** There is no `normalize.js`, no expression language, no callbacks, and nothing that
could grow into one. A reviewer can read a mapping end to end and know exactly which upstream
keys it touches.

`cardMapping` is required when any endpoint declares `returns: "cards"`, and forbidden when none
does.

```json
"cardMapping": {
  "root": "data",
  "nextPagePath": "next_page",
  "totalPath": "total_cards",
  "identityField": "key",
  "fields": {
    "key":   { "path": "id", "required": true },
    "title": { "path": "name", "transforms": ["trim"], "required": true },
    "art":   { "path": "image_uris.normal", "fallbackPaths": ["card_faces[0].image_uris.normal"] },
    "cost":  { "path": "mana_cost", "default": "" },
    "types": { "path": "type_line", "transforms": ["join"], "joinSeparator": " / " }
  }
}
```

- `root` addresses the array of card records inside the response — `"data"` for a wrapped
  response, `""` when the response *is* the array.
- `fields` maps deck-schema field keys to one read each. Field keys are lowercase snake_case, at
  most 40 characters, because they become keys of the normalized card's data bag and are
  validated again when that bag is read.
- `identityField` names the field carrying the card's stable id. It **must** be one of `fields`
  and **must** be `required` — a saved deck stores that value, and a card with no id cannot be
  re-resolved. Changing what it means is the definition of a breaking change; see
  [`breakingVersion` and migration](https://diceytable.com/docs/plugins/breaking-changes).

**Read paths are a JSONPath-ish subset, and the "-ish" is the point.** Dotted keys and numeric
indices, at most eight segments deep. No `$`, no `*`, no `..` descent, no filters, no function
calls, no slices — those are the features that turn a path language into an expression language.
`__proto__`, `constructor` and `prototype` are rejected as key segments: each is a syntactically
valid dotted path that reaches the prototype chain, and no provider's response legitimately
contains one.

Each field is resolved as `path`, then each of `fallbackPaths` in order (at most four), then
`default`. Real providers need the fallbacks — a double-faced card's art lives at
`image_uris.normal` on one shape and `card_faces[0].image_uris.normal` on another. Transforms
then run left to right, at most four of them, from a closed list:

`trim` · `lowercase` · `uppercase` · `number` · `boolean` · `string` · `join` · `first`

`joinSeparator` applies only to `join`. A field that is `required` may not also declare a
`default` — a record that yields nothing for a required field is dropped rather than emitted with
a placeholder.

A single page maps at most **2000 records**; anything beyond that is dropped and counted in a
host-side diagnostic you can read and the caller cannot.

### `artUrl` — when your provider returns the pieces of an image URL

Many providers never return an image URL. They return its parts:

```json
{ "name": "Anakin Skywalker (T)", "set": "CWSO", "imageFrag": "SO001_Anakin_Skywalker_T_v3" }
```

The transform list cannot join those into a URL, on purpose — a template or format string is the
author-supplied mini-language the closed vocabulary exists to refuse. Instead, declare the URL the
same way you declare a request: an origin and a path template that **the platform** composes.

```json
"cardMapping": {
  "identityField": "card_id",
  "fields": {
    "card_id": { "path": "imageFrag", "required": true },
    "set":     { "path": "set" },
    "art":     { "path": "image_url" }
  },
  "artUrl": {
    "field": "art",
    "origin": "images.example.com",
    "path": "/sets/{set}/{frag}.jpg",
    "params": { "set": "set", "frag": "card_id" }
  }
}
```

| Key | Rule |
|---|---|
| `field` | Which **mapped field** receives the composed URL. Must be one of `fields`. Whatever that field mapped to is overwritten — declaring `artUrl` says the composed value is the real one. |
| `origin` | A bare hostname that **must be one of your declared `origins`**. |
| `path` | Literal segments and `{placeholder}`s. Unlike an endpoint path, a placeholder may sit inside a segment, so `{frag}.jpg` is fine. |
| `params` | `{placeholder}` → mapped field key. Every placeholder needs one, and every entry needs a placeholder. |

Values are substituted **after** their transforms have run, and each is percent-encoded, so a value
carrying `/`, `?`, `#` or `..` becomes inert text rather than re-pointing the URL. If any
placeholder has no usable value the card gets **no art** rather than a URL with a literal `{set}`
in it.

> **Your art host must be a declared origin.** Before `artUrl`, a card's art was whatever string a
> field mapped to, handed to the viewer's browser unexamined — the origin allowlist governed only
> the calls *we* make. A card source could therefore point every viewer at any host. Composing art
> brings the host a viewer is sent to inside the same reviewed list.
>
> Note this is a separate question from whether the browser will *load* it: the app's
> Content-Security-Policy pins image and fetch hosts to a fixed list, so an art host that is not on
> it renders a placeholder tile in the deck builder and a blank card on the table.

#### Which art hosts actually load

Two hosts:

| Host | Use it for |
|---|---|
| `raw.githubusercontent.com` | Art committed to a public GitHub repository — the usual answer. |
| `api.diceytable.com` | Art the platform itself serves. |
| `cdn.diceytable.com` | The same GitHub repository, through DiceyTable's asset CDN. Requires an `assetRepos` declaration — see below. |

Registering a plugin whose `artUrl.origin` is anything else succeeds, but returns an
`art-host-not-loadable` **warning** naming the host. It is a warning and not a rejection on
purpose: the policy is deployment configuration rather than part of the plugin contract, so a
plugin that registered cleanly today must not stop parsing the day the policy is edited, and a
plugin whose host we are about to add should not be stranded.

The list is the **intersection** of the `img-src` and `connect-src` directives, because card art is
read twice by two different mechanisms — the deck builder puts the URL in an `<img src>`, and the
table runtime `fetch`es it to compose a sprite sheet. A host present in only one of them produces
the worst possible symptom: art that renders perfectly while browsing a deck, and blank cards on
the table, with a console message nowhere near the deck code.

#### Serving art through the asset CDN

Art on `raw.githubusercontent.com` reaches players over a distant origin. Pointing it at
`cdn.diceytable.com` instead puts it on the same edge as the rest of the app — but the edge serves
only repositories the platform can trace to a pack, so you must name the one you are reading:

```json
"origins": ["api.example.com", "cdn.diceytable.com"],
"assetRepos": [{ "owner": "SWTCG", "repo": "SWTCG-LACKEY", "ref": "refs/heads/release" }],
"cardMapping": {
  "artUrl": {
    "field": "art",
    "origin": "cdn.diceytable.com",
    "path": "/SWTCG/SWTCG-LACKEY/refs/heads/release/sets/{set}/{frag}.jpg",
    "params": { "set": "set", "frag": "card_id" }
  }
}
```

Two rules, both **errors** at registration rather than warnings, because both are decidable from the
manifest alone and getting either wrong means every card 404s with nothing to fall back to:

- the first three segments of the path must be **literal** — a `{placeholder}` in owner, repo or ref
  means the manifest does not actually name the repository it reads;
- that repository must appear in `assetRepos`, at the **same ref**.

At most four repositories, and they are shown on the pack pages of games that use your plugin. Your
plugin's own repository is never eligible here — the only files a browser fetches from it are the
ones you declare as [resources](https://diceytable.com/docs/plugins/authoring#resources-files-a-project-can-copy). A branch ref is
allowed but warns — see [Asset Delivery and the CDN](https://diceytable.com/docs/mods/publishing/asset-delivery) for the
caching trade-off and the full rule set.

## Settings: letting a mod configure your plugin

A plugin often needs one or two decisions from the pack using it — which sets to include, whether
to pull promos, how many results to ask for. `settings` is how you ask, and it is the same trade
the rest of this contract makes: **you declare the shape, the platform renders the form.**

```json
"settings": [
  { "key": "set_filter", "label": "Sets to include", "control": "select",
    "options": [
      { "value": "all",  "label": "All sets" },
      { "value": "core", "label": "Core only" }
    ],
    "default": "all",
    "help": "Narrows every card search this plugin performs." },
  { "key": "include_promos", "label": "Include promos", "control": "toggle", "default": false }
]
```

A mod author opens `diceytable.mod.json` in the editor, finds your plugin on the
[**Plugins** tab](https://diceytable.com/docs/editor/interface/topbar#mod-details-the-plugins-tab), and fills in exactly
that form on your plugin's card — either after **Add to game**, or with **Configure only** if their
script never calls you and they use you only as a deck source. Their answers land on **their** manifest under `pluginSettings`, keyed by your plugin id
— so the question and the answer are both public, commit-pinned documents a reviewer can read side
by side.

| Key | Rule |
|---|---|
| `key` | Lowercase snake_case, unique within your plugin. This is what the answer is keyed by, so renaming one strands the old value. |
| `label` | What the author sees beside the control. |
| `control` | One of `text`, `number`, `toggle`, `select`. Closed set — see below. |
| `help` | One line under the control. Rendered as **text**, never as markup. |
| `default` | Used when the author does not answer. Must match the control's type, and for a `select` must be one of its options. |
| `options` | `select` only, and required for it. Literal value/label pairs — an option list is data, never a fetch. |
| `min` / `max` | `number` only. |
| `maxLength` | `text` only. |

At most **12 settings**, and **32 options** per select. A modifier on the wrong control is a
publish-time error rather than a value that is quietly ignored — `min` on a toggle means you
believe something is happening that is not.

> **The control list is closed, and that is the point.** You declare *which form element*, never
> how it looks, how wide it is, where it sits, or what markup it is — every one of these renders as
> one of the platform's own components. That is the same relationship [`ui`](https://diceytable.com/docs/plugins/authoring#ui) has with the deck
> builder's search controls, and it is the reason a plugin is allowed to extend the editor at all:
> there is no path by which your document becomes your UI inside somebody else's editor.

**Answering is always optional.** Every setting falls back to its `default`, so adding a setting
never breaks a mod that already uses your plugin. Removing or renaming one does strand the old
answer — the editor shows it as a stale key with a Remove button rather than discarding it — so
treat it the way you would any other breaking change and bump `breakingVersion`.

## Deck import: letting a plugin supply a deck, not just cards

A plugin could always supply *card data*. Until `deckImport` it could not supply **a deck**. A
deck database's whole reason to exist is that people build decks there and share links, and the
only route those decklists had into DiceyTable was `api.callPlugin` from a mod's table script —
so an imported deck landed on the table as a pile of cards rather than as a saved deck. It could
not be re-opened, edited or played again without re-importing; every mod that wanted import had
to ship its own text box; and because the call came from a script it needed the `plugin-call`
capability, which also permits every other function that mod names.

`deckImport` is an **optional** block that closes that gap without introducing a new mechanism. A
decklist response is just JSON with an array in it, which is exactly what [the card
mapping](https://diceytable.com/docs/plugins/authoring#the-card-mapping) already describes — so this block reuses the same closed path grammar
and the same transform vocabulary. Nothing in it is an expression language, and nothing in it can
name a destination.

```json
"deckImport": {
  "endpoint": "deckById",
  "param": "deckId",
  "label": "Deck id or swtcg-deckdb.com link",
  "matchField": "name",
  "namePath": "name",
  "root": "cards",
  "entry": {
    "count": { "path": "count", "transforms": ["number"], "default": 1, "required": false },
    "match": { "path": "name", "transforms": ["trim"], "required": true }
  },
  "partitions": [{ "root": "supply", "partitionId": "supply" }]
}
```

| Key | Rule |
|---|---|
| `endpoint` | A declared endpoint, which **must** be `returns: "raw"`. |
| `param` | The declared parameter the person's input fills. Must be `required`, and must be that endpoint's **only** required parameter. |
| `label` | Your label for the input box, 1–60 characters. Sanitized on render; the platform's own sentence leads. |
| `matchField` | The **catalogue** field an imported entry matches on. |
| `setField` | Optional *second* catalogue key, for a catalogue whose `matchField` values repeat across sets. Only meaningful with `entry.set`. |
| `namePath` | Optional path to the deck's own name in the response, so an imported deck arrives named. Up to 120 characters are relayed. |
| `root` | Path to the primary entry array — `""` when the response *is* the array. |
| `entry.match` | Where one entry's join value is read. **Must** be `required`. |
| `entry.count` | Copies. Absent, unreadable or below 1 all mean one copy; capped at 1000. |
| `entry.set` | Where the entry's set/edition is read. Only with `setField`, and vice versa. |
| `partitions` | Up to 8 further arrays, each landing in a named deck partition — a sideboard, a supply pile. |

At most **2000 entries** are taken across all of a response's arrays: that is the ceiling a saved
decklist has, so mapping more would be work spent producing rows the save path must reject.

### Every cross-check, and the failure each one prevents

All of these run at publish, so an authoring mistake is a parse error naming the exact path rather
than an import button that answers nothing at a player's screen.

- **`endpoint` must be declared, and must be `returns: "raw"`.** Same rule and same reason as
  `exposedApi`: a `cards` endpoint's response has already been consumed by the card mapping and
  comes back as a normalized card page, not as the provider's decklist document. There is nothing
  left to map a decklist out of.
- **`param` must be a declared parameter of that endpoint.** Otherwise the import composes a
  request the endpoint does not describe.
- **`param` must be `required`.** An optional one means an import that supplied nothing would
  still fetch *something* — a provider's default deck rather than the one the person asked for.
- **`param` must be the endpoint's only required parameter.** The platform sends exactly one
  value, because the person typed one thing into one box. A second required parameter would make
  every import fail at compose time, far from the manifest that caused it.
- **`entry.match` must be `required`.** An entry with nothing to match on can be neither resolved
  *nor* usefully reported, so it could only be dropped in silence.
- **`setField` and `entry.set` arrive together or not at all.** One without the other is either a
  catalogue key with nothing to compare against or a value read for no reason.
- **No two entry arrays may read the same `root`.** They would import the same entries twice.
- **`partitionId`s must be unique** within the block.
- **When the plugin itself declares a card mapping, `matchField`/`setField` must be mapped card
  fields of it** — checked against every mapping the manifest carries. If this plugin supplies the
  cards then it is this plugin's catalogue that gets loaded, so a field it never maps could never
  match one of its own cards.

### Two rules that were considered and deliberately are not enforced

> ⚠ **A `deckImport` block does *not* require the plugin to declare a card source.** The first
> real provider was exactly the case such a rule would have refused: `swtcg-deckdb.com` shipped as
> a *deck* database supplying no card data at all, while the game using it shipped a `static`
> catalogue. (It supplies cards today, through a `catalogue` endpoint — but only because the
> platform gained one; nothing about its import block changed when it did, which is the point.) The
> join runs in the browser and works identically for a static card source and a plugin one, so
> demanding a card source here would refuse the only plugin the feature exists for.
>
> The consequence is yours to get right: `matchField` names a field of **whatever catalogue the
> game loaded**, which is not necessarily yours. It is only cross-checkable at publish when the
> plugin itself supplies the cards.

> ⚠ **`partitionId` is not checked against a game's deck schema at publish.** A plugin manifest
> cannot see the schema of every game that will use it, and the same provider legitimately serves
> games that partition differently — so `partitionId` is checked for *shape* here and resolved at
> import time. An id the game does not declare puts those entries in the game's **default
> partition** and **reports** the mismatch; it never silently retargets them.

### How the join works, and why it is in the browser

The import route returns flat entries — `{count, match, set?, partitionId?}` — and **no card
ids**. It cannot produce them: the server does not know which catalogue the deck builder loaded.
The join therefore runs in the browser against the catalogue already on screen, which costs no
extra request and behaves identically whether that catalogue came from a mod's static card list
or from a plugin.

Matching is **case- and whitespace-insensitive, and nothing more**: the key is `trim()` +
`toLowerCase()` on both sides. `"Hidden Beks (A)"` and `"hidden beks (a)"` are the same card;
`"Hiden Beks"` is not, and the import will not pretend otherwise. Every step past that is a
similarity heuristic, and a similarity heuristic is a silently wrong card in a saved deck.

`setField` is a **tie-breaker only**. A single match on `matchField` is taken as-is and is never
narrowed by set — a provider's set code need not equal the catalogue's, and narrowing a unique
match would only invent a way for a correct entry to fail. When the broad match is ambiguous and
both sides carry a set, the set narrows it; if narrowing finds nothing the broad list stands, so
the outcome is a reported ambiguity rather than a false "no such card".

### Nothing is guessed, and nothing is silently dropped

Every entry ends in exactly one of three states:

- **resolved** — exactly one catalogue card matched, and it becomes a decklist row. Two lines
  naming the same card in the same partition merge, because a provider that lists a card twice
  means "this many in total".
- **unresolved** — nothing matched. Listed, with the value that failed, verbatim, so a person can
  search for it.
- **ambiguous** — two or more matched. Also listed, and **never** resolved by picking one.

A decklist naming cards a catalogue lacks is *normal*: a new set, a typo, a provider whose card
pool is wider than the game's. So the deck is created from what resolved and the rest is shown as
a list the person can act on. A partial deck with a visible gap beats a refusal, and it beats a
deck that is quietly wrong.

### No capability is involved

Deck import is initiated by **a person in the deck builder**, against an endpoint you declared, so
no mod script runs and no mod capability is implicated. It is a first-party platform action with
the same trust posture as `/cards`, and `POST /api/plugins/:id/deck-import` is unauthenticated for
the same reason: what it returns is a function of the deck id the caller already typed and of
nothing anybody holds. Saving the resulting deck still needs an account.

Three things follow that are worth planning for.

- **It spends your quota.** The route sits under the plugin-catalogue rate-limit prefix, so an
  import draws on the dedicated inbound bucket and on a share of your plugin's daily budget —
  not on any one caller's.
- **The pasted value is checked against your declared parameter format** before anything is
  composed. A deck link is reduced to its last path segment first, so a shared
  `https://…/deck/AB12CD` works against a `token` parameter, and the format check still runs on
  what is left. Nothing a person pastes can name a host: the destination is composed from your
  declared origin and path template exactly as it is everywhere else.
- **The server never learns which cards an import resolved to.** It relays `matchField` and
  `setField` to the browser, and nothing more.

### A game opts in from its own `cardSchema.json`

```json
"deckImport": { "pluginId": "com.example.deckdb" }
```

That names the plugin whose import block the deck builder offers for this game. A game whose cards
*already* come from a plugin normally omits it — the deck builder falls back to `source.pluginId`
— and names one here only to import from a **different** provider.

⚠ This is deliberately a separate reference from the mod manifest's `plugins` list. That list is
the **table script's** declared reach and drags the `plugin-call` capability in with it, whereas a
deck import involves no mod code at all.

## The exposed API

`exposedApi` is what a **mod** may call (at most 24 functions). Each entry names a declared
endpoint, so the chain *mod call → plugin function → endpoint → origin* is followable statically
by both the scanner and a human reviewer.

| Field | Rule |
|---|---|
| `name` | Identifier, unique within the manifest. This is the string a mod passes to `api.callPlugin`. |
| `summary` | 1–200 characters. A mod can read this; it is the only description of the function it gets. |
| `endpoint` | Must name a declared endpoint, and that endpoint must be `returns: "raw"` — see below. |
| `params` | A declared value schema. For a mod-callable function this must be an **object** whose member names are all parameters the endpoint declares. |
| `returns` | A declared value schema. The response is validated against it before it reaches the caller. |
| `acceptsTableData` | `true` marks a function as one that receives table-derived data. Requires `combined`, and is refused by the mod call path. |

A declared value schema is data, not code — a manifest cannot ship a Zod object and must not ship
a function. The platform compiles it server-side. Types are `string` (with optional `maxLength`
or `enum`), `number` (with `integer`, `min`, `max`), `boolean`, `array` (with `items` and a
required `maxItems`) and `object` (with `fields`, each optionally `optional`). Nesting is capped
at five levels and 32 fields per object.

### `nullable` — for a provider that sends `null`

Any type may declare `"nullable": true`, which permits the value to be `null`.

**`optional` and `nullable` are different claims and neither implies the other.** `optional` says
the key may be **absent**; `nullable` says its value may be **`null`**. Most REST APIs use `null`
for "no value" rather than omitting the key, so a field like this needs both:

```jsonc
"format": { "schema": { "type": "string", "maxLength": 60, "nullable": true }, "optional": true }
```

Without `nullable`, a single `null` from your provider fails the whole call. If you only ever see
a field populated in your own testing, prefer declaring it nullable anyway — the cost is nothing
and the failure it prevents reaches players as a bare `unavailable`.

### Strictness runs in only one direction

The two sides of a call are validated differently, and deliberately so:

| | Compiled as | Effect on an undeclared key |
|---|---|---|
| `params` (a mod's arguments, inbound) | strict | **Rejected.** A mod must not be able to smuggle a field past your declared surface. |
| `returns` (your provider's response, outbound) | strip | **Dropped.** A mod receives only the fields you declared. |

So you do **not** need to enumerate every key your provider sends — declare the ones you want and
the rest are discarded before any mod sees them. This also means a provider adding an unrelated
field cannot break your plugin, which strict validation on this side used to cause.

Stripping does not soften the fields you *did* declare. A declared field with the wrong type, or a
missing non-optional field, still fails the call.

**There is no "forward this request" and no "send this payload" function shape.** Either would
launder a mod's traffic through your allowlist, which is the one thing this surface must not
permit.

### ⚠ Do not point an exposed function at a `returns: "cards"` endpoint

A card endpoint's response is not the provider's body — the platform runs your `cardMapping` over
it and returns a normalized card page. That shape is chosen by the platform, so it cannot be
described by your function's `returns` declaration, and a mod calling such a function gets
`{ ok: false, reason: "refused" }` every single time.

Point exposed functions at `returns: "raw"` endpoints. Your card endpoints are reached by the deck
builder's prefetch, which is the surface they exist for — see
[calling a plugin from a mod](https://diceytable.com/docs/plugins/calling-from-a-mod).

## Capabilities

`capabilities.allowed` is zero to three of `network`, `table`, `combined`. See
[the capability table](https://diceytable.com/docs/plugins#the-three-capabilities) for what each permits.

Consistency rules the manifest enforces:

- An empty list is accepted only when `resources` declares at least one file — see
  [Using a plugin only for its resources](https://diceytable.com/docs/plugins/authoring#using-a-plugin-only-for-its-resources).
- `origins` and `endpoints` require `network`, and a `network` plugin must declare at least one
  origin.
- `tableScript` requires `table`, and a `table` plugin must name a `tableScript`. It must be a
  repo-relative `.js` file with no traversal.
- Your table script is scanned by the **same** `scanModScriptSafety` a mod's script is. It gets no
  more permissive rulebook for belonging to a plugin.

### The combined capability

`combined` means the data half may receive table-derived data from the table half, over a
server-side channel that is schema-validated, logged and quota-accounted. It requires `network`
and `table` as well — it is the union, declared explicitly — plus a `combinedDisclosure` block.

**The warning text is platform-owned.** Your manifest repeats it verbatim or it does not parse:

```json
"capabilities": {
  "version": "1",
  "allowed": ["network", "table", "combined"],
  "combinedDisclosure": {
    "warningVersion": 1,
    "warning": "This plugin can read table state and send data to its own servers. A plugin with this capability, running on the host, is a cheating vector: it can observe hidden information such as other players' hands and face-down cards and transmit it off the table. Only enable it if you trust the author.",
    "reason": "Match results are posted to the Example League ladder after a game ends. Nothing else leaves the table."
  }
}
```

Author-owned warning text would be worthless, so the sentence is ours and you cannot soften it,
shorten it, or reframe it as a feature. What you **do** own is `reason` (40–500 characters): the
specific, reviewable claim about what leaves the table and why. That is the part a reviewer and a
seated player can actually evaluate.

That warning is shown to **every seated player**, not only whoever installed the plugin, and it
appears in the join flow before a player commits to the table. The reason is a consent asymmetry:
*the person who accepts is not the person at risk.* The host installs and accepts; the hidden
information that could leak belongs to the other players. A persistent indicator stays visible
while such a plugin is active, and neither plugin nor mod code can suppress it.

`combined` is deliberately the hard path. If the split shape can do what you need, take it.

## Credentials: never in the manifest

Decision D-S2: **the plugin author owns the provider relationship** — its terms, its attribution,
its commercial-use position, its rate-limit agreement, and its API credential. DiceyTable holds no
relationship with any card-data provider.

A credential can never live in the manifest, and the manifest will not let you put one there:

- Plugin source is **public on GitHub** by design — "the reviewed source is what runs" is only
  checkable if anyone can read it.
- It is pinned to an **immutable commit sha**, so a leaked key cannot be rotated away by a
  force-push: the sha that leaked it is the sha that was reviewed and the sha that is served.
- The registry API echoes the manifest back to unauthenticated callers.

Two mechanisms enforce it. Any key named `apiKey`, `api_key`, `secret`, `token`, `password`,
`authorization`, `credential`, `privateKey` and their sixteen relatives is rejected at any depth
with a `manifest-secret` issue that says why. Separately, any *value* that looks like a live
credential — a `Bearer …` prefix, a `ghp_…`, a `github_pat_…`, an `sk-live-…`, an `xoxb-…`, an
`AKIA…`, a JWT — is flagged even under an innocent key name.

What the manifest declares is only **where** a credential goes:

```json
"auth": { "kind": "bearer" }
"auth": { "kind": "header", "headerName": "X-Api-Key" }
"auth": { "kind": "none" }
```

`headerName` must be a plain header token, and `Authorization`, `Cookie`, `Host`,
`Content-Length`, `Transfer-Encoding` and `Connection` are refused — use `kind: "bearer"` for the
first, and the rest are platform-controlled. **There is no `query` kind**, because credentials in
query strings end up in access logs, referrers and upstream caches, and there is no way for us to
un-log a provider's copy.

The value itself is written server-side through an authenticated route on your own plugin, stored
encrypted, never returned to any client, never logged, and injected by the fetch layer at request
time. It is not readable through your plugin's own declared surface either — you cannot read back
your own key. A client can learn only *whether* one is configured and when it was last written.

A plugin whose provider needs no credential simply declares `auth: { "kind": "none" }` and runs on
our address under our quota.

## Quotas, and the neighbour you share an origin with

`rateLimits` is your declaration of what your agreement with the provider permits:

```json
"rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 2 }
```

⚠ **This is a ceiling you accept, never a budget the platform grants.** The fetch layer enforces
`min(declared, platform ceiling)`, so declaring a large number buys nothing and declaring a small
one is honoured. It is written this way round because you hold the provider relationship and the
credential, but the egress and the address reputation are ours.

| Limit | Platform ceiling | Your declaration may be |
|---|---|---|
| `requestsPerMinute` | 120 | 1–600 |
| `requestsPerDay` | 50,000 | 1–1,000,000 |
| `maxConcurrent` | 4 | 1–16 (defaults to 2) |

Quotas are persistent — they survive a server restart, because an in-process counter is not a
quota. They are also checked **before** the circuit breaker, so a greedy plugin normally exhausts
its own budget before it can affect anyone else.

Separately, inbound catalogue reads have their own budget of **60 requests per minute per client
address**, spent across `/api/plugins/:id/catalogue`, `/cards` and `/invoke`. It is spent *instead
of* the caller's general request budget rather than in addition to it, and that is the whole point
of having it: without a separate bucket, a search-heavy plugin would silently exhaust a user's
general budget and unrelated features would start failing for reasons nobody could see. It is a
dedicated budget, not an exemption — the requests are still counted.

### The circuit breaker is shared per origin

⚠ **Two plugins that declare the same hostname share one circuit breaker, and one can trip it for
the other.** This is the noisy-neighbour effect, and it is a deliberate trade, not an oversight.

The breaker exists to protect our egress address reputation, and the party who would ban us sees
only our address — it cannot tell which plugin sent the traffic. A per-plugin breaker would fail
to protect the one thing it exists for. The per-plugin quota being the *first* line is what makes
cross-tenant denial rare rather than routine.

| Behaviour | Value |
|---|---|
| Consecutive upstream failures that open the circuit | 5 |
| Time open before a single probe is allowed through | 30 seconds, backing off exponentially to 5 minutes |
| While open | Requests fail immediately and cheaply; nothing queues |

What counts as a failure is narrow on purpose: **5xx, 429 and 408 trip it.** A **404 does not** —
a card that does not exist is a correct, cheap, healthy answer, and counting it would let one
plugin's typo'd lookups deny an origin for everyone. **401 and 403 do not** — the upstream is up
and answering, and backing off the whole origin fixes nobody's expired key. Other 4xx do not
either, for the same reason: our request was wrong, not theirs.

Practical consequence: if your provider is one that many plugins use, keep your failure rate low.
A plugin that reliably asks for things its provider rejects with a 5xx is a plugin that
periodically takes the origin down for every other plugin on it.

## Responses are cached

Successful responses are cached by `(pluginId, endpoint, normalized params)`, storing the
**normalized** shape rather than the provider's envelope. Upstream cache headers are honoured
within a five-minute default and a one-hour ceiling, and a stale entry is retained for a day so
it can be served after a failure or a quota refusal.

When that happens the **deck builder** receives `stale: true` and `ageMs`, which is exactly enough
to render "showing cached results from 12 minutes ago" instead of an empty page.

⚠ **A mod calling your function receives neither.** The cache is keyed only on the plugin, endpoint
and parameters — not on who asked — so it is shared by every room and every user. Telling a script
whether its query hit the cache would tell it whether *anyone else* had recently run the same query,
and with a stale entry kept for a day, one bit per card name adds up to another table's decklist.
The first-party UI gets the staleness banner because it is showing it to a person; `api.callPlugin`
returns `{ ok: true, data }` and nothing else.

## What a caller learns when something fails

Four words, in a closed enum, carrying nothing else:

| Reason | Means |
|---|---|
| `unavailable` | The provider did not answer usefully, or a server-side step failed. |
| `rate-limited` | A quota or the shared breaker refused the request. |
| `not-found` | No such plugin, endpoint or function is reachable from here. |
| `refused` | The request or the response did not satisfy a declared schema. |

There is no message, no status code, no hostname and no upstream body — a caller that could read
those could probe your provider, and `rate-limited` is additionally made **indistinguishable from
`unavailable` in timing**, so it cannot be used to measure a remaining budget or (because the
breaker is shared) to infer another tenant's activity.

The real cause is not lost. It goes host-side to the author-facing diagnostics, scrubbed of your
credential, where you can read it and untrusted code cannot.

## Resources: files a project can copy

Besides card data, a plugin can ship **files** — models, textures, materials and deck definitions —
as library items mod authors use in their projects. Each is declared individually under `resources`.
The scanner fetches and content-checks every declared file at the registered commit sha, and the
asset CDN then serves [exactly those paths](https://diceytable.com/docs/mods/publishing/asset-delivery#plugin-resources-exact-paths-at-a-pinned-commit),
never anything else in your repository.

Resources are data for the editor and renderer. They are not a network path for a mod script, which
still has none, and declaring them needs no capability.

This section is the manifest side. For what a mod author sees — your plugin as a source in the
editor's Library pane, with resources grouped by `folder` — see
[The Asset Explorer § Library sources](https://diceytable.com/docs/editor/interface/asset-explorer#library-sources).

```json
"resources": [
  {
    "path": "textures/card-back.png",
    "kind": "texture",
    "name": "Card back",
    "folder": "Cards/Backs",
    "credit": { "origin": "own-work" },
    "thumbnail": "thumbnails/card-back.webp"
  },
  {
    "path": "materials/card-back-satin.json",
    "kind": "material",
    "name": "Card back (satin)",
    "folder": "Cards/Backs",
    "credit": {
      "origin": "third-party",
      "title": "Satin card finish",
      "creator": "Example Studio",
      "sourceUrl": "https://example.com/card-finishes",
      "licenseName": "CC-BY-4.0"
    }
  }
]
```

`materials/card-back-satin.json` is a bare material props object, and the texture it names is the
texture resource declared just above it:

```json
{ "diffuseMap": "textures/card-back.png" }
```

`resources` is optional; absence declares nothing. At most **500** entries.

| Field | Rule |
|---|---|
| `path` | Repo-relative file path. See [Paths](https://diceytable.com/docs/plugins/authoring#paths). Unique within the manifest, compared **case-insensitively**. |
| `kind` | `model`, `texture`, `material` or `deck`. An AI model (`.onnx`) is not a resource kind: a plugin cannot ship one, and the plugin scanner refuses an `.onnx` path under `resources` (as a `path` or a `thumbnail`) as `plugin-resource-onnx`. Models belong to the game that runs them — see [`aiModels`](https://diceytable.com/docs/mods/manifest/reference#aimodels). |
| `name` | 1–120 characters, shown in the library. No control or bidirectional formatting characters. |
| `folder` | Optional display grouping, up to 120 characters, `/`-separated — `"Cards/Backs"`. No empty, `.` or `..` segments, no leading or trailing spaces on a segment, and no control or path-special characters (backslash, colon, `*`, `?`, quotes, angle brackets, pipe, `%`, `#`). It is a label, not a repository path. |
| `credit` | Required. See [Credit](https://diceytable.com/docs/plugins/authoring#credit). |
| `thumbnail` | Optional preview image. See [Thumbnails](https://diceytable.com/docs/plugins/authoring#thumbnails). |

### Kinds and extensions

| Kind | Extensions | What the scan checks inside the file |
|---|---|---|
| `model` | `.glb`, `.gltf` | Magic bytes (GLB). Every `images[].uri` and `buffers[].uri` is embedded or names a declared resource — see [The closed set](https://diceytable.com/docs/plugins/authoring#the-closed-set). |
| `texture` | `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp`, `.avif`, `.ktx2`, `.basis` | Magic bytes: a `.png` must really be a PNG. |
| `material` | `.json` (not `*.deck.json`) | A bare material props object, parsed strictly — an unknown key is refused. Texture slots name declared textures. |
| `deck` | `<name>.deck.json` | A valid deck definition. Every image it names is a declared texture. |

There is **no `sound` kind**, deliberately. Resources are a browsable, copyable library, and the
platform keeps sound clips out of every library, its own presets included. A `.lua`, `.obj`, `.mtl`,
`.js` or `.bin` file is never a resource either.

### Paths

A resource path is compared **exactly** — by the scanner, by the CDN and in the manifest — so it has
one spelling and nothing is normalised for you.

- Repo-relative: no leading `/`, no URL or `scheme:`, no `//`, no backslashes.
- Each segment uses only letters, digits, `.`, `_` and `-`, and may not start with `.` — which rules
  out `..`, `./`, dotfile folders such as `.git`, and spaces or `%`-encoding anywhere.
- No empty segments, so `a//b.png` and a trailing slash are refused, not tidied.
- At most 300 characters and 16 folders deep.

Use the path exactly as the file is named in your repository, case included: the CDN matches it
case-sensitively.

### Credit

Every resource carries its own credit, and it describes that one file. The fields are a subset of a
mod's credit record: `origin`, `title`, `creator`, `creatorUrl`, `sourceUrl`, `licenseName`,
`licenseUrl` and `notes`. Anything else — `id`, `paths`, `recordedAt`, an attestation — is a parse
error.

- **`origin` is `own-work` or `third-party`.** `disclaimed` is refused: in a mod it means "unknown,
  and I sign to take responsibility", and a plugin manifest has no signature to carry. Your
  responsibility is taken when the plugin is registered.
- **`third-party` needs three things:** a `creator`; a link — `creatorUrl` or `sourceUrl`; and a
  licence — `licenseName` or `licenseUrl`.

### The closed set

A resource may reference only **other resources declared by the same plugin**. Nothing outside your
own declaration can be reached through a file that is inside it.

- **Materials.** Every non-null texture slot (`diffuseMap`, `normalMap`, `metalnessMap` and the rest)
  must be the exact `path` of a declared `texture` resource.
- **Decks.** `face.texturePath`, every `extraFaceSheets[].texturePath`, the back's texture path and
  every card's `assetPath` must be the exact `path` of a declared `texture` resource.
- **Models.** Every `uri` is checked, wherever it appears:
  - An **image** is embedded (a buffer view, or a base64 `data:` URI of `image/png`, `image/jpeg`,
    `image/webp` or `image/ktx2`), or a relative path — resolved against the model's folder — that is
    a declared `texture`.
  - A **buffer** is embedded: the GLB's binary chunk, or a base64 `data:` URI of
    `application/octet-stream` or `application/gltf-buffer`. `.bin` is not a declarable kind, so an
    external `.bin` buffer is always refused. **Export a `.glb`**, or embed the buffer.
  - No URL, no leading `/`, no `..`, `.` or empty segment, no query string or fragment, no backslash.
    A `uri` anywhere other than `images[]` or `buffers[]` — inside an extension, say — is refused.
  - A GLB must be glTF 2.0, its header length must equal the file size, and it may carry one JSON
    chunk followed by at most one binary chunk. Any other chunk is refused rather than skipped.

### Thumbnails

`thumbnail` is an optional preview for the library. It follows the same path grammar, must be a
browser-decodable image — `.png`, `.jpg`, `.jpeg`, `.webp`, `.gif`, `.bmp` or `.avif`, not
`.ktx2`/`.basis` — and is fetched, magic-byte-checked and served exactly like a resource. It is not
itself a library item.

### What the scan refuses, and how to fix it

Manifest problems — a bad path, a wrong extension for the kind, a missing credit field, a duplicate
path — are reported by manifest validation with a `schema-…` code and a `path` naming the field. The
plugin does not register, and **nothing is fetched** until the manifest is clean.

After that, each declared `path` and `thumbnail` is fetched at the commit sha. Every refusal below is
an **error**, and one refused file registers the **whole plugin** `incompatible` — which serves none
of its resources.

| Code | What it means | Fix |
|---|---|---|
| `plugin-resource-not-in-commit` | The registered commit has no regular file at that path (it is missing, or a symbolic link). | Commit the file itself, check the path's case, and register the commit that contains it. |
| `plugin-resource-blob-mismatch` | The file GitHub served for that path is not the file in the registered commit. Usually a branch or tag named like the commit sha is shadowing it. | Delete the branch or tag named like the sha, then register again. |
| `plugin-resource-tree-unavailable` | GitHub's git data API did not return the registered commit or its file tree. The API answered with a definite error or the wrong object. | Check that the commit exists in this repository, then register again. |
| `missing-asset` | The file is listed in the commit but could not be fetched from that path. | Check the path's case and register the commit that contains it. |
| `asset-too-large` | Larger than 50 MB. | Compress it, or downscale a texture. |
| `asset-content-type-missing`, `asset-content-type-mismatch` | What GitHub serves does not match the policy for the extension. | Give the file the extension of the format it actually is. |
| `asset-read-failed` | The file could not be read back for checking. | Usually transient — register again. |
| `plugin-resource-content-mismatch` | The bytes are not the format the extension claims — a JPEG renamed `.png`. | Re-export in the declared format, or rename it to match. |
| `plugin-resource-invalid-json` | A `.json`/`.gltf` file is not valid UTF-8 JSON. | Fix the JSON. |
| `plugin-model-invalid-glb` | The GLB container breaks the rules above. | Re-export as glTF 2.0 binary with no extra chunks. |
| `plugin-model-invalid` | The glTF document is not an object, or a `uri` is not a string. | Re-export the model. |
| `plugin-model-external-ref` | A `uri` is a URL, absolute, uses `..`, a query or fragment, sits outside `images[]`/`buffers[]`, or is a `data:` URI of a type not listed above. | Embed the image or buffer, or point it at a declared texture. |
| `plugin-model-undeclared-ref` | A relative image is not a declared texture, or a buffer is external. | Declare the texture as a resource; export a `.glb` so the buffer is embedded. |
| `plugin-material-invalid` | The material body has an unknown key or a wrong value type. | Keep to material props; remove anything else. |
| `plugin-material-external-ref` | A texture slot holds a URL, an absolute path, a `..`, or another unsafe path. | Use the texture resource's exact `path`. |
| `plugin-material-undeclared-texture` | A texture slot names a path that is not a declared `texture`. | Declare that texture, or correct the path. |
| `plugin-deck-invalid` | The `.deck.json` is not a valid deck definition. | Fix the field the message names. |
| `plugin-deck-external-ref` | A deck image path is a URL or an unsafe path. | Use the texture resource's exact `path`. |
| `plugin-deck-undeclared-texture` | A deck image path is not a declared `texture`. | Declare that texture, or correct the path. |

One **warning** also applies, and does not stop registration: `plugin-resource-large-for-cdn`. It means
a resource is larger than the asset CDN currently verifies (2 MiB). The CDN checks every resource's bytes
before serving them, within a per-request compute limit, and refuses larger files. A resource that size
may therefore not load for players until that limit is raised. Compress the file or downscale the
texture to stay under it.

`unsupported-asset-type` and `plugin-resource-unsupported-type` also exist as defence in depth.
Manifest validation refuses the same things earlier, so a plugin that validates should never see them.

`plugin-resources-unpinned` appears alongside `unpinned-scan` when the registration could not be pinned
to a commit the scanner can prove, and nothing is fetched. Resources are only scanned, and only served,
at a **full 40-character commit sha**. When you supply `commitSha` yourself, the server asks GitHub's
commits API for that sha and requires the same sha back. An abbreviated sha, a sha that is not a
commit in your repository, or a branch or tag that happens to be named like a sha is refused. So is a
registration made while GitHub cannot answer; register again later.

For every file that passes, the registry records its **git blob sha**. That record is what the CDN
admits — a declared file that failed is not served — and it is how a changed file is told apart from
an unchanged one. The CDN also checks each file it serves against that record. It hashes what it
fetched from GitHub and refuses anything that does not match with a `502`, so players receive exactly
the bytes that were scanned. The files are fixed at the registered commit: see
[Changing a resource](https://diceytable.com/docs/plugins/breaking-changes#changing-a-resource).

### Using a plugin only for its resources

A plugin can ship files and nothing else. It declares an **empty** capability list and no `origins`,
endpoints, `cardMapping`, `exposedApi` or `tableScript`:

```json
{
  "schemaVersion": "1.0",
  "id": "org.example.card-backs",
  "name": "Example Card Backs",
  "version": "1.0.0",
  "compatibility": { "engine": "^0.1.0" },
  "capabilities": { "version": "1", "allowed": [] },
  "attribution": "Art by Example Studio.",
  "termsUrl": "https://example.com/terms",
  "rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 2 },
  "license": "MIT",
  "resources": [
    {
      "path": "textures/card-back.png",
      "kind": "texture",
      "name": "Card back",
      "credit": { "origin": "own-work" }
    }
  ]
}
```

An empty `capabilities.allowed` is accepted **only** when `resources` has at least one entry. With
neither, validation refuses the manifest: *"Declare at least one capability, or ship resources — a
plugin with neither does nothing."* `attribution`, `termsUrl`, `rateLimits` and `license` are still
required, even though a resources-only plugin makes no provider requests.

A mod that uses only a plugin's files lists it as `{ "id": "…", "functions": [] }`, which grants no
call and needs no `plugin-call` capability — see
[Calling a plugin from a mod](https://diceytable.com/docs/plugins/calling-from-a-mod#using-a-plugin-only-for-its-resources).

## Practical notes the schema will not tell you

### Declare a page parameter, or a big deck will not fully resolve

A plugin-sourced game resolves **the whole deck's cards up front, on load**. The platform's own
loader never falls back to a per-card request: an id absent from the prefetched set renders as a
visibly unresolved row and stays that way, because per-card request timing and ordering are chosen
by whoever wants the card, which would make them a channel out of a table's hidden state.

⚠ **That is a property of the loader, not a guarantee about the plugin surface.** If you declare an
ordinary by-id card endpoint — `path: "/v1/cards/{id}"`, the shape most card APIs have — then
`GET /api/plugins/:id/cards?endpoint=byId&p.id=…` **is** a per-card lookup, reachable without a
signed-in caller. The declared parameter composes exactly as designed and nothing forbids it; the
rule was not made structural because many providers offer only by-id access, and excluding them
would cost more than it bought. What bounds the residual is your per-plugin quota, the shared
per-origin breaker and your declared-origin allowlist — not the request shape. **Declare a by-id
card endpoint only when your provider offers no bulk or search access**, and prefer the bulk one
when it does.

The prefetch walks at most **eight pages**, and it can only page when two things are both true:

1. Your `cardMapping` declares `nextPagePath`, so the platform can read the provider's cursor.
2. The game's `source.options` declares `_pageParam`, naming the endpoint parameter the cursor
   should be fed back into.

Without either, the walk stops after the first page and reports itself as truncated. A decklist
entry outside the resolved set then renders as a **visibly unresolved row** and stays that way —
it never triggers a follow-up request.

So: declare `nextPagePath` on any endpoint that can return more than one page, declare a
paging parameter on the endpoint, and tell mod authors to set `_pageParam`. Mod authors should
also scope `source.options` narrowly — a query that returns the game's whole card universe is a
query that will truncate.

The game's `source.options` bag reserves exactly three keys, all beginning with `_` (which no
declared parameter name may start with): `_endpoint` picks a card endpoint by name, `_searchParam`
names the parameter the search box feeds, `_pageParam` names the paging parameter. Everything else
in the bag is a declared parameter value, passed through untouched.

### 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, which is precisely what makes
the request pattern a function of the plugin and the query rather than of which cards anybody
holds. Do not design around a separate search endpoint on our side; declare one *upstream*
endpoint and let the search box feed a declared parameter through `_searchParam`.

The two routes a browser reaches are:

- `GET /api/plugins/:id/catalogue` — registry data only. Names your card endpoints, their declared
  parameters, your attribution and terms URL. Makes no upstream request and spends no quota. A
  blocked or incompatible plugin is returned here **carrying its status** rather than 404'd, so a
  page can say "the plugin this deck's cards come from has been withdrawn" instead of showing the
  same blank as a mistyped id.
- `GET /api/plugins/:id/cards?endpoint=<name>&p.<param>=<value>` — one page of normalized cards.
  An unknown query key is a 400 rather than something ignored: ignoring it would let a caller
  believe it had constrained a search it had not, which for a paid provider is somebody else's
  bill.

`POST /api/plugins/:id/invoke` is the third, and it is the mod call path. It requires a signed-in
caller, accepts only a **function name** (never an endpoint — the server re-derives that from your
manifest), and refuses `acceptsTableData` functions.

### Attribution and terms are not optional

`attribution` (1–300 characters) is displayed wherever your plugin's data appears, and `termsUrl`
must be an `https://` URL for the provider terms **you** accepted on your own behalf. Most card
providers' terms demand credit; the author is the party who agreed to them, so the text is yours
to get right and ours to render mechanically.

## Testing a change before you publish

There is no dry-run endpoint today. What there is:

1. **Validate the manifest locally.** Registration runs `validatePluginManifest`, which reports
   every issue at once with a `path` per issue. Registering against a scratch plugin id in a
   throwaway repository gives you the same report without touching your live plugin.
2. **Register the exact commit sha.** Registration pins a sha, and any sha change re-triggers
   review — so a branch you push to is not what runs until it is registered. Register the commit
   you actually intend to ship, as the full 40-character sha: an abbreviated one is refused, and a
   supplied sha is checked against GitHub's commits API before anything is pinned to it.
3. **Read `GET /api/plugins/:id/catalogue`.** It confirms which endpoints the platform sees, which
   parameters it will accept, and which mapped field it will treat as the card identity — all
   without spending a request against your provider.
4. **Then read `GET /api/plugins/:id/cards`.** This one does hit the provider. Check the mapped
   card shape, and check that `hasMore`/`nextPageToken` behave the way your `nextPagePath` claims.
5. **Diff the identity values.** If any card's identity value changed, you have made a breaking
   change whether you meant to or not. See
   [`breakingVersion` and migration](https://diceytable.com/docs/plugins/breaking-changes) before you publish it.

## See also

- [Plugins](https://diceytable.com/docs/plugins) — the split, the capabilities, and the four rules in short form.
- [A worked example](https://diceytable.com/docs/plugins/scryfall-example) — every field on this page, as one complete
  validated manifest against a real provider, plus what that shape cannot do yet.
- [`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) — the other side of
  `exposedApi`.
- [Script safety](https://diceytable.com/docs/mods/scanner/script-safety) — the scanner your table half is subject to.
- [Mod capabilities](https://diceytable.com/docs/mods/manifest/capabilities) — the capability model a table half
  declares under.
- [Working with decks](https://diceytable.com/docs/editor/working/decks) — how a game declares its card source.
- [The Asset Explorer § Library sources](https://diceytable.com/docs/editor/interface/asset-explorer#library-sources) —
  your declared resources as a mod author browses them.
