← Scryboard/App API/App Manifest

App manifest (scryboard.json)

Every app submitted to the Marketplace ships a scryboard.json alongside its code. It's what lets Scryboard know which file to start, what the app claims it needs, and what to prompt a buyer for — without anyone having to read the source to find out.

This replaces the free-text "setup instructions" box as the machine-readable half of a listing. That box still exists for prose a human should read; the manifest is for things software acts on.

{
  "name": "Recap Bard",
  "version": "1.0.0",
  "description": "Turns last session's synopsis into a sung recap for the start of the next one.",
  "entry": "agent.mjs",
  "runtime": "node_cli",
  "scopes": {
    "reads": ["sessions"],
    "writes": ["widgets"]
  },
  "dependencies": ["@anthropic-ai/sdk"],
  "secrets": [
    {
      "key": "ANTHROPIC_API_KEY",
      "label": "Anthropic API key",
      "help": "From console.anthropic.com. Used to write the recap's lyrics.",
      "required": true
    }
  ],
  "poll": { "activeSeconds": 30, "idleSeconds": 300 },
  "external": [
    {
      "name": "Bardy.ai",
      "reads": "a backing track for the recap",
      "writes": "the recap lyrics",
      "requires_paid_account": true,
      "cost_note": "Requires an active Bardy.ai subscription (bardy.ai/pricing) — paid to Bardy, separate from this app's price."
    }
  ]
}

Fields

Field Required Meaning
name yes Display name, at most 60 characters.
version yes A semantic version — 1.0.0, 1.2.3-beta.1. Updates are ordered by it, so anything else is rejected.
description yes One or two sentences, at most 1000 characters. Put the detail in your listing.
entry yes Which file the runner starts. Must exist in the archive.
runtime yes node_cli or browser_sandboxed — see below.
scopes.reads yes Resources you read. Must match what your code actually calls. Includes actions if you poll clicks on your own widgets' action buttons — see the action node — and events if you read the structured campaign event log (DM-only, so declaring it forces a DM installer, same as transcript or members).
scopes.writes yes widgets, widgets:video (YouTube embeds in widgets — requires widgets too; see the video node), widgets:link (anything clickable that leaves Scryboard — a link node, or [label](https://…) inside a richtext value — requires widgets too; see Links, and the widgets:link scope), character, media (upload images Scryboard stores and serves — see Uploading media), extractions (DM-scope only — see Proposing canon; declaring it forces your listing to require a DM installer, same as a DM-only read), extractions:attributes (merge structured data into one confirmed item's attributes — see Attaching data to confirmed canon; DM-scope only and forces a DM installer, same as extractions), and/or events (propose structured campaign events — encounter, scene, spell_cast, xp_award — that stay unconfirmed until the DM reviews them; see Proposing events; DM-scope only and forces a DM installer, same as extractions), and/or canon_import (import structured items the DM already treats as truth — e.g. their own Kanka wiki — into the World Bible as confirmed canon with a visible source tag, resolved against existing canon rather than overwriting it; see Importing canon; DM-scope only, world_bible only, forces a DM installer).
requires_dm_scope no true to say "only the campaign's DM may install this, and issue my token with DM scope" even though none of your resources is DM-only by name. Use it when your job needs the DM's full view: extractions is readable by both roles, but a player-scope token sees only homebrew_registry — an app that works with the World Bible is blind without this. Reading a DM-only resource or writing a DM-only one implies it anyway. Surfaced on the listing as a permission and to the reviewer as a finding.
capabilities no Device capabilities — things your app may do on the machine it runs on, as opposed to scopes, which govern Scryboard data. Currently: plays_audio (play sound through the buyer's speakers — see below). node_cli only.
dependencies no Vetted packages only — see the list below.
secrets no Anything the buyer must supply (API keys): [{ "key", "label"?, "help"?, "required"? }], at most 10. key must be usable as an environment-variable name (letters, digits, _). The Runner prompts for these and stores them in the OS keychain. node_cli only.
settings no Short text values you want the buyer to fill in — Scryboard collects them, you read them back with getSettings(). See below. Not to be confused with inputs, which is the App Runner's separate file picker.
poll no How often the runner ticks you, in seconds. activeSeconds/idleSeconds split by whether a session is live; defaults to 30/300. Optional encounterSeconds ticks faster than activeSeconds while the session has an active encounter (only checked if you set this — it costs your token an extra combatants read every tick, and needs combatants in scopes.reads). Each is a number of seconds from 1 to 86400 (under 5 gets a warning; the Runner may clamp short intervals). Ignored by browser_sandboxed apps, which tick every 5s during a live session and 30s otherwise.
external no Third-party services you talk to directly, outside Scryboard's own API (Bardy, CharGen, Kanka, World Anvil, …) — see below. node_cli only: a sandboxed app can't reach another service.
inputs no The App Runner's install-time file picker — see the note under settings. node_cli only.

Any other top-level field is reported back to you at upload as a warning ("did you mean scopes?") and otherwise ignored. A field with the wrong type — "reads": "sessions" instead of ["sessions"] — is an error, not quietly dropped.

scopes is checked, not trusted

At submission, Scryboard scans your code and compares what it actually calls against what you declared. Reading transcript without declaring it is a rejection, not a warning. Declaring transcript also means only a DM can install your app, since player tokens can't read it — as does declaring any DM-only write, or setting requires_dm_scope: true yourself.

Changing scopes in a new version

A token's role and granted scopes are fixed when it is minted. If a new version of your app needs more (a new read/write scope, or DM scope where the old version ran as a player), a buyer clicking Update is shown exactly what changed and offered Update and re-issue token — the old token is revoked and a new one minted with the listing's current access. For node_cli the new token is shown once, with the App Runner link; Runner v0.2.1+ swaps it on the app it already has (settings and state kept). Say in your changelog when a version needs this.

secrets is how API keys stop being painful

Declare what you need and the runner handles the rest: it prompts the buyer once, stores the value in the operating system's credential store, and sets it as an environment variable when your app runs. Your code just reads process.env.ANTHROPIC_API_KEY. Nobody edits a config file, and the secret never sits in plain text.

Never put a secret value in the manifest. This file is public.

capabilities is for what your app does on the buyer's machine

scopes govern Scryboard data; capabilities govern the computer your app runs on. They're enforced by the App Runner, not the server — the buyer consents to each one explicitly at install time, on top of whatever data scopes the listing already discloses.

Currently one exists:

  • plays_audio — your app may play sound through the machine's speakers, via scryboard.playMedia(...) / playAudio(...) (see the client library below). Playback goes through the Runner's own built-in player; your code never touches an audio device, a child process, or a native binding. One thing plays at a time — a new play replaces whatever any app was playing, and the Runner always shows what's playing with a Stop button the buyer can hit.

Capability strings are deliberately one-per-media-type (plays_audio now; video would be its own plays_video consent if it ever lands) — "can make sound" and "can put video on my screen" are different things for a buyer to agree to, even though the machinery underneath is shared.

Like scopes, capabilities are checked, not trusted: calling playMedia without declaring plays_audio is a submission rejection, and declaring it flags the listing for the reviewer — audio has no content constraint the platform can enforce, so the reviewer is told to actually listen to what your app produces. Say what you play and why in your description.

settings is how you ask the buyer a question

Some apps need a short piece of text only the buyer can supply — "describe your character's appearance", "what tone should this be", "what's your party called". Widgets can't collect it: the layout schema deliberately has no text-input node (the input node is only a bookmark for where Scryboard draws its own box), because an app-rendered input field is a spoofing surface the platform won't open.

So settings works exactly like secrets: you declare, Scryboard collects, you read back. Your app never renders the box.

"settings": [
  {
    "key": "appearance",
    "label": "Describe your character's appearance",
    "help": "Hair, build, scars, what they wear. Used to draw the portrait.",
    "type": "text",
    "max_length": 500,
    "required": false
  }
]
Field Required Meaning
key yes Stable identifier you read the value back by. Lowercase a-z, 0-9, _, starting with a letter, ≤40 chars.
label yes The prompt shown above the box. ≤80 chars.
help no A line of explanation under the label. ≤300 chars.
type no text — the only kind today, and the default.
max_length no Your own cap on the answer. Defaults to (and can never exceed) the platform's 2000.
required no Shows the buyer it's expected. Not enforced — your code still has to cope with an empty value.
scope no install (default) — one box for your whole app. item — one box per thing your app lists. See One box, or one per thing? below.

At most 12 settings per app.

Read them back with scryboard.getSettings(). Declared-but-unanswered keys come back as '', so "not answered" is one case rather than two. No read scope is needed: declaring the setting is the declaration.

const { appearance } = await scryboard.getSettings()

Two things to design around:

Values are per-install. The same person installing your app on two campaigns answers twice, independently — which is what you want when the answer is "my character looks like X" and they play different characters.

A buyer can edit any value at any time, from the Settings control on their installed app — not just at install. A campaign runs for months and answers change. So read the value fresh each tick rather than caching it from the first one, and expect it to change under you.

Critically: changing a value never triggers anything on its own. You see the new value on your next tick and decide what to do with it. If acting on it costs the buyer money at a third party — an image generation, an AI call — do not act automatically. Show that the result is out of date and let them press a button. An edit that silently spends someone's balance is how an app gets uninstalled. (The player edition of CharGen Portraits does exactly this: it compares the current text against what the existing portrait was drawn from, and says "regenerate to update it" rather than redrawing.)

Every declared setting is shown to the reviewer at submission, with its label and help text — nothing mechanical can judge whether a question is reasonable to ask or where the answer ends up going, so a setting that feeds a third-party service should say so in its help.

One box, or one per thing?

The example above is one box for the whole app. That's right when your app has one subject — the player edition of CharGen Portraits draws one character, so one "describe your character" box is that character's box.

It falls apart the moment your app has a list. A DM edition drawing every NPC in a World Bible can't use one shared box for "extra detail": whatever was typed last would silently apply to whichever character is drawn next. For a paid image generation, that means paying for the wrong picture — and nothing about it looks wrong until the picture arrives.

So a setting can say which it is:

"settings": [
  { "key": "art_direction", "label": "How should these look?" },
  { "key": "portrait_note",  "label": "Extra detail for this character",
    "scope": "item", "max_length": 300 }
]

art_direction has no scope, so it's app-wide — one box, applying to everything you draw. portrait_note is per-item.

You push the list of items at runtime, because only you know what your items are and they change constantly:

await scryboard.setSettingItems([
  {
    key: 'npc-123',                         // your own stable id
    label: 'Marta the Innkeeper',           // what the buyer reads
    placeholder: 'Runs the Thornwatch Inn…' // greyed-out suggestion
  },
])

and read the answers back grouped by item:

const s = await scryboard.getSettings()
s.art_direction                   // 'grim oil painting'
s.items['npc-123'].portrait_note  // 'tired, greying, missing a tooth'

items is always present, possibly empty — you never have to guard for it.

You decide how many boxes the buyer sees. This is the important part. An app with 300 NPCs should push the one its widget currently has selected, not all 300 — click a character in your widget, and the box below becomes that character's box. The 50-item cap exists to stop abuse, not to be the thing you design against.

placeholder is a suggestion, never a value. It shows only while the box is empty, so you can show a buyer exactly what they'd be overriding — the NPC's own description, say — without putting words in their mouth. Nothing you send is ever stored as their answer. An app still cannot write a settings value; that boundary is what makes the whole feature safe.

Values outlive an item leaving your list. Stop sending npc-123 and what the buyer typed against it is kept, not deleted — send it again and it comes back. But it isn't returned by getSettings() while the item is absent, so you never act on something you no longer list. (Uninstalling clears everything, as always.)

Field Required Meaning
key yes Your own stable id for the thing. ≤100 chars. Opaque to Scryboard.
label yes What the buyer reads above the group. ≤80 chars.
placeholder no Greyed-out suggestion text, shown while empty. ≤300 chars.

At most 50 items. Pushing items is rejected if your manifest declares no "scope": "item" setting — a list with nothing to render against it is almost always a mistake worth hearing about.

Not the same as inputs. The App Runner has its own separate manifest field called inputs — a file picker (accept, multiple) that it prompts for once, at install, dropping the chosen files in input/<key>/ next to your code. That is a different feature at a different moment, and it is collected by the Runner, not by Scryboard. Use inputs when you need a file from the buyer's machine; use settings when you need a line of text they can change later. Putting accept/multiple inside settings is rejected at submission, since it almost always means the wrong field was used.

external is how a bridge agent declares the other service it talks to

scopes only covers Scryboard's own API. A bridge agent — one that also reads from or writes to a third-party service like Bardy, CharGen, Kanka, or World Anvil — declares that separately, so buyers and reviewers can see it:

"external": [
  {
    "name": "Kanka",
    "reads": "your campaign wiki (NPCs, locations, factions)",
    "writes": "session summaries and new canon",
    "requires_paid_account": false
  }
]

name is required (which service). reads/writes are short plain-language descriptions — free text, since there's no fixed resource list for an arbitrary partner API the way there is for Scryboard's own scopes.

Scryboard never bills on a partner's behalf. If the partner service itself requires its own paid tier or subscription — separate from whatever you charge for this app on the Marketplace — set requires_paid_account: true and fill in cost_note (required whenever requires_paid_account is true; submission is rejected without it) explaining what it costs and where. This gets shown to buyers on the listing page, before Buy or Install, clearly separated from your app's own price — a buyer should never discover a second bill mid-setup. Scryboard has no way to verify a partner's actual pricing, so this is your responsibility to keep accurate; the reviewer is shown it as a flag on every submission, and a false or missing disclosure is treated as a policy violation like any other misleading listing content.

Runtimes

node_cli — a real Node program. Multiple files, vetted packages, full access to the machine it runs on. Runs via the Scryboard App Runner (a Windows desktop app the buyer installs once) or by hand during development. This is the tier for anything needing an outside service, an API key, or memory between ticks: AI, PDFs, images, other platforms, a roster it builds up over time.

browser_sandboxed — runs automatically in the buyer's browser with no install at all, but only while they have the campaign page open, and with no network beyond Scryboard's API, no packages, no files, no secrets, and a single code file (this manifest aside). A DM's sandboxed app has no storage at all between ticks. Good for reshaping data Scryboard already has into a view you want. The exact list of scryboard methods it gets is in the table below.

The full side-by-side comparison, with a "can I use the sandbox?" checklist, is in Choosing a runtime. A sandboxed app installs in one click, so use it whenever it's sufficient — but check the list first.

Vetted packages

node_cli apps may depend on the packages below and nothing else. They ship inside the runner, so there's no install step for the buyer, no version drift between apps, and everything that executes has been reviewed once rather than pulled fresh from the internet on someone's machine.

Package For
@anthropic-ai/sdk Calling Claude
pdf-parse Extracting text from PDFs
jimp Image resizing and conversion
zod Validating data shapes
date-fns Date handling

Most apps need none of these — reading Scryboard and pushing a widget uses only built-in Node features plus the client library below.

To request an addition, write to us through the Contact page (email or Discord) with the package name and what your app needs it for. Packages are judged on whether they're widely used, actively maintained, reasonably small, and free of native compiled binaries (which are painful to bundle across platforms — this is why jimp is on the list and sharp isn't).

The tick convention and the client library

Export an async tick() function. The runner hands it the Scryboard client — you don't import or install anything:

export async function tick(scryboard) {
  const session = await scryboard.getActiveSession()
  if (!session) return

  const combatants = await scryboard.get('combatants', { session_id: session.id })

  await scryboard.pushWidget({
    widget: 'Initiative',
    placement: 'session_dashboard',
    visibility: 'table',
    session_id: session.id,
    layout: {
      type: 'table',
      columns: ['Name', 'Initiative'],
      rows: combatants.map((c) => [c.name, c.initiative]),
    },
  })
}

The Runner calls tick() on the poll schedule from your manifest (a sandboxed app is ticked every 5 seconds during a live session and every 30 otherwise, whatever poll says) and owns the loop, retries, and backoff — so you never write setInterval, and a mistake in your timing can't hammer the API. Sandboxed apps get the same tick() shape and the same core methods, so the basics read identically in both runtimes — but the sandbox exposes a smaller scryboard object (see below), so check the list before relying on a method there.

tick() should be safe to call repeatedly. Pushing the same widget name replaces its content rather than duplicating it, so re-pushing every tick is the normal pattern, not a leak.

What's on scryboard depends on the runtime:

Method node_cli browser_sandboxed
get, getActiveSession, pushWidget yes yes
setCharacterData, updateCharacterData yes yes
getActions, getEvents, writeEvent yes yes
getSettings, setSettingItems yes no — not yet
proposeExtractions, setItemAttributes yes no — not yet
importCanon, canonImportStatus yes no — not yet
uploadMedia, setMediaTier yes no — needs raw bytes, which the sandbox can't send
playMedia, playAudio, stopMedia yes (App Runner only) no — device playback is node_cli only

In a sandboxed app, get('settings') returns the same thing getSettings() does (see Reading what the buyer told you); for the other "not yet" writes, build a node_cli app for now. The sandbox also gives you console.log / console.error, which print to the buyer's browser console.

Full source of the Node client: scryboard.mjs, at the root of the node_cli starter template zip (on the Downloads list), next to dev-runner.mjs, which runs your tick() locally the way the Runner does. A browser_sandboxed app can't run outside Scryboard: test it by uploading it as a personal app — see "Trying your app on your own campaign" in Before you start.

The three playback methods only actually play under the App Runner (and need the matching capability declared — see above). playMedia({ data or file, contentType, title?, volume? }) takes raw bytes or a path; playAudio(bytesOrPath, opts?) is sugar for the audio case; stopMedia() stops your app's own playback. Outside the Runner (standalone script, dev-runner) playMedia throws a clear "no player here" error — wrap playback in try/catch so a missing player degrades to silence rather than failing your tick.

If you need to remember something between ticks, write it to a file next to your app (node_cli), or use setCharacterData (player scope only). A DM-installed browser_sandboxed app has nowhere to keep anything.