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, viascryboard.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 calledinputs— a file picker (accept,multiple) that it prompts for once, at install, dropping the chosen files ininput/<key>/next to your code. That is a different feature at a different moment, and it is collected by the Runner, not by Scryboard. Useinputswhen you need a file from the buyer's machine; usesettingswhen you need a line of text they can change later. Puttingaccept/multipleinsidesettingsis 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.