← Scryboard/App API

Downloads

Publishing either template to the Marketplace? See the scryboard.json manifest reference for the full field list, the vetted package list, and how secrets work.

Scryboard App API

Bring your own app to your campaign's session data. Scryboard's dashboard is one consumer of the ambient table data — this API lets you build your own: spell suggesters, live maps, usage histograms, character-image pinboards, or anything else that reads the session stream and renders something useful.

Before you start

Prerequisites: underneath everything below is a plain REST API (JSON over HTTPS), so any language works, and you can run an app yourself — self-hosted, on your own machine or server — without ever touching the Marketplace. The example apps on the Downloads list are Node.js scripts written this way — Node.js 18+ (for the built-in fetch) is the only thing you need installed to run or adapt them. If you've just installed Node.js and a command like node --version still says it's not recognized, open a new terminal window — Windows in particular won't pick up a PATH change in a window that was already open.

If you want to list on the Marketplace instead, what a buyer's install actually downloads depends on your app's runtime: a browser_sandboxed app is one code file, submitted as a .zip with its scryboard.json — no git clone needed, and there's a ready-to-submit starter template on the Downloads list at the top of the App API docs page. A node_cli app — anything calling an outside API, using a package, or needing a secret — ships as a small .zip (your code plus a scryboard.json manifest) and runs through the Scryboard App Runner, a small desktop app, rather than the buyer's browser. See Choosing a runtime below and the manifest reference before you start writing code — the runtime you pick determines what you can build with.

Trying your app on your own campaign before (or without) publishing: upload it as a personal app. Sign in, open /agents, and under Your Own Apps click Upload your app (.zip). It gets the same automatic checks a Marketplace submission gets, but nobody reviews it and nobody else can see it. Then pick one of your campaigns under Connect to: and click Connect. A browser_sandboxed app then runs whenever you have that campaign's page open; a node_cli app offers Get Runner link, which hands its token to the App Runner. To try a change, click Replace code on the app and pick the new .zip. This is the test loop for browser_sandboxed apps, which can't run anywhere outside Scryboard.

Testing without touching production: there's currently no separate staging deployment of Scryboard — the only live environment is production. Rather than pointing a new app under development at your real campaign with a real token, use the mock server first: it's a zero-dependency local stand-in for this entire API, backed by fixture data, with a preview page so you can see your pushed widgets rendered without writing anything to a real database. Do one final pass against a real token before publishing, since the mock is a fixture and can drift from actual behavior over time.

Getting a token

DMs: on your campaign page, open App Access Tokens → + New token. Choose a data scope:

  • Player-visible data only — the app sees what a player at the table sees (verified homebrew, combat state with hidden stat blocks masked, rules lookups, session list). Use this for anything players will look at.
  • Full DM data — adds the live transcript, all verified prep layers, and the member roster. Treat this token like a password to your campaign notes.

Players: on your own campaign landing page (/campaigns/{id}/player), open Your Apps → + New token. Player-issued tokens are always player-scoped — same read access a DM-issued player token gets, just self-service, and you can only see/revoke tokens you personally created.

The token (scry_…) is shown once. Store it securely; revoke it any time — revocation takes effect immediately: the token can't read or push anything more, and the widgets it pushed stay on the page showing their last output, marked Disconnected, until someone removes them (or a new token for the same app takes them over — see widget under "Writing widgets"). Uninstalling a Marketplace app, or disconnecting a personal app from a campaign, removes its widgets there as well.

All tokens currently have the observer capability: read anything in scope, and write only what's explicitly documented below (widget output, uploaded media, a player's own character data, or — DM-scope only — proposed canon and proposed campaign events, neither of which bypasses human review, and per-item attributes patches, which can't touch anything a DM reviewed). An observer app cannot alter live session state, combat, or anything a DM hasn't reviewed, so the blast radius of a buggy app stays limited to its own output and to canon that's sitting in the normal verify queue, not published.

Reading data

GET https://<your-scryboard-host>/api/agent/{resource}
Authorization: Bearer scry_...
Resource Scope Params Returns
campaign any — { id, name }
sessions any limit { id, status, starting_scene, synopsis, ended_at }, the live session (status: "active", ended_at: null) first, then ended ones newest-first
transcript DM only session_id (required), after_ms, limit Ordered segments { speaker, text, timestamp_ms }
intelligence any session_id (required) Live AI state, or null before the first analysis: surface_now, new_canon, rules_trigger, … (player scope: rules_trigger only). See Response shapes
combatants any session_id (required) Initiative order; monster_block is null for hidden stat blocks on player-scope tokens
extractions any layer [{ layer, items }] for verified layers only (player scope: homebrew only). See Response shapes
rules_lookups any session_id, since, before, order, limit Append-only history of every rule surfaced at the table. With since: oldest-first after that timestamp. Without: the newest rows, newest-first. See Paging through a feed
members DM only — Roster { id, role, display_name, joined_at }
character any — Player scope: own { display_name, character_data }. DM scope: every player's, as an array — see Importing character data
actions any since, before, order, limit Clicks on this app's own action buttons, oldest-first { id, widget_id, widget, action_id, clicked_by_role, created_at } — see the action node and Paging through a feed
digest any — The campaign's running "story so far": { digest, last_session_id, sessions_count, updated_at }, or null before the first confirmed session review lands
events DM only event_type, session_id, since, before, order, limit Structured campaign events, oldest-first, each with a participants array — confirmed events plus this app's own pending proposals; see Proposing events and Paging through a feed

since and before are ISO 8601 timestamps — normally a created_at you got back earlier, passed through unchanged. limit is 1–1000 (default 200). A malformed parameter (a session_id that isn't a UUID, an unknown layer or event_type, a bad timestamp) is refused with a 400 that names it.

Responses are { "data": … } on success, { "error": "…" } otherwise. The status tells you what kind of problem it was: 401 missing, invalid or revoked token; 403 the token's role or your manifest's scopes don't allow this; 404 no such resource, session or item; 400 the request itself is malformed; 413 the body is too large; 429 rate limited (120 requests a minute per token, 600 a minute across all the tokens one person has issued, and 1500 a minute across every app in one campaign). Notes:

  • extractions items carry type (beat, character, faction, location, item, rule, or history) and an optional subtype for finer classification (e.g. character → pc/npc/monster, item → weapon/spell/consumable/etc.) — subtype is null when not confidently inferable, so don't assume it's always present. Items may also carry an optional attributes object — a free-form bucket for structured data title/description text can't hold well (a monster's stat block, an item's numeric properties). No fixed schema; nothing in Scryboard's own extraction pipeline reads or writes it today, it's yours to use — including writing it per item, without touching the rest of the layer: see Attaching data to confirmed canon. Items that arrived through canon import carry source: { provider, id, url, hash, importedAt, mode } (mode imported = created by the import; merged = a session item an import folded into) — if you sync canon out to that same provider, skip these.
  • Player-scope tokens see only homebrew_registry from extractions — every other layer, World Bible included, is DM-only. An app that needs the DM's layers must be installed by the DM with a DM-scope token: read a DM-only resource, write a DM-only one, or set requires_dm_scope: true in the manifest (see the manifest guide), otherwise the Marketplace mints a player token and the app is silently blind.
  • Find the live session by polling sessions and filtering status === "active".
  • transcript + after_ms supports incremental polling: keep the largest timestamp_ms you've processed and pass it as after_ms next time. timestamp_ms is milliseconds since the session's first recorded line and keeps rising across the DM pausing and resuming, so it is a safe cursor within one session. Start from 0 (or omit after_ms) for a new session.
  • rules_lookups + since is the incremental feed for usage analytics (e.g. a spell histogram). With since, results come back oldest-first after that timestamp — keep the newest created_at you've processed as your next since and a burst of lookups is never skipped, just spread over a few ticks. Without since you get the newest rows, newest-first — a snapshot, not a feed.
  • actions + since is the same incremental pattern for button clicks — declare actions under scopes.reads to use it. Clicks are only retained for about 30 days, and only ever on your own app's widgets — two apps in the same campaign can't see each other's. Read Paging through a feed before you write this loop: without since, actions returns the oldest clicks, and a click that happened before your app started (or before it lost its saved state) must not be acted on as if it were new.
  • digest is a single running prose account of the whole campaign, incrementally folded forward by Scryboard after each confirmed session review — built exclusively from DM-confirmed synopses (the same vetted text players see as "Recap"), so any token role may read it. Use it instead of re-summarizing full session history yourself: it's cheaper, stable between reads, and already hallucination-hardened at the source.
  • events is the structured half of the campaign record — encounter, scene, spell_cast, and xp_award events with typed payloads and linked participants, written by Scryboard's own capture points and by apps (see Proposing events). It only ever returns confirmed events plus your own app's still-pending proposals (so you can track what you've proposed) — never another app's unconfirmed proposals, and not Scryboard's own not-yet-reviewed encounter snapshots either. Declare events under scopes.reads; it's DM-only, so declaring it means a DM must be the installer, same as transcript or members. With since and the oldest-first ordering it's the same incremental polling pattern as rules_lookups — and the same warning applies: with no since you get the oldest 200, so on a long-running campaign an app that never pages sees nothing new. Filter by event_type and page as described below.
  • GET /api/agent/info (not a resource — no read scope needed) says what the token belongs to: { source, agent_name, campaign_name, runtime }, where source is marketplace (an installed listing) or personal (an app registered from your own machine). The App Runner calls it before fetching anything; most apps never need it. It returns 401 for a revoked token, 404 once the app has been uninstalled, and 400 for a token made by hand under App Access Tokens (it isn't tied to any app).
  • Poll politely (every few seconds is fine). There is no push/webhook yet.

Example:

curl -s -H "Authorization: Bearer $SCRY_TOKEN" \
  "https://scryboard.vercel.app/api/agent/rules_lookups?since=2026-07-01T00:00:00Z"

Paging through a feed

actions, events and rules_lookups are feeds: rows only ever get added, each has a created_at, and a read returns at most limit rows (default 200, max 1000). Three parameters control which rows you get:

  • since — only rows created after this timestamp.
  • before — only rows created before this timestamp.
  • order — oldest (oldest-first; the default for actions and events, and for rules_lookups when since is given) or newest.

Every feed response also carries a page block beside data:

{
  "data": [ … ],
  "page": {
    "order": "oldest",
    "limit": 200,
    "has_more": true,
    "next": { "since": "2026-09-30T19:02:11.483912+00:00" },
    "newest_created_at": "2026-09-30T19:02:11.483912+00:00"
  }
}

has_more is true whenever the page came back full, so there may be more (a full page followed by an empty one is possible). next is the parameter to add to your previous ones to get the following page. newest_created_at is the value to save as your since high-water mark. The client libraries' get() returns only data, so there the rule is simply: a page shorter than your limit means you've caught up.

The pattern every polling app should use:

// 1. First run only (nothing saved yet): start from NOW, not from the
//    beginning. Any click that already exists is history -- a DM's
//    old button press must never trigger work (or a charge) again just
//    because your app started, was reinstalled, or lost its state file.
if (!state.actionsSince) {
  const [latest] = await scryboard.get('actions', { order: 'newest', limit: 1 })
  state.actionsSince = latest?.created_at ?? new Date().toISOString()
  saveState()
}

// 2. Every tick: page forward from the saved mark until a short page.
let since = state.actionsSince
for (;;) {
  const clicks = await scryboard.get('actions', { since, limit: 200 })
  for (const click of clicks) {
    handle(click)
    since = click.created_at
  }
  if (clicks.length < 200) break
}
state.actionsSince = since
saveState()

The same loop works for events (add event_type to read one kind at a time) and rules_lookups. To walk backwards through history instead — "the newest 50 encounters" — use order: 'newest' and pass the last row's created_at as before for the next page.

get(resource, { order: 'newest', limit: 1 }) is also the cheap way to ask "has anything happened since I last looked?".

Response shapes

extractions returns one row per verified layer:

{
  "data": [
    {
      "layer": "world_bible",
      "items": [
        {
          "id": "4f6c…",
          "type": "character",
          "subtype": "npc",
          "title": "Marta the Innkeeper",
          "description": "Runs the Thornwatch Inn; knows local rumours.",
          "aliases": ["Old Marta"],
          "confirmed": true,
          "flagged": false,
          "attributes": null
        }
      ]
    }
  ]
}
  • Only verified layers are returned. A layer the DM hasn't reviewed yet — fresh notes on a new campaign, or a layer an app just proposed changes to — is left out entirely, not returned empty. If your app sees nothing on a campaign that clearly has notes, that's why: the DM needs to review them on the campaign's verify page first.
  • verified (the layer) means the DM reviewed the whole layer. confirmed (an item) means the DM ticked that individual item during review. Saving a review discards the items left unticked, so in practice every item you get back is confirmed: true; if you ever see false, treat that item as not yet approved. flagged marks an item Scryboard wants the DM to look at again (with a flagReason).
  • aliases lists other names the same entity has been called — Scryboard merges "Old Marta" into "Marta the Innkeeper" rather than keeping two entries. Match against aliases as well as title when you de-duplicate, or you'll count the same NPC twice. Optional: treat a missing aliases as []. Items extracted before October 2026 may have no aliases and the nickname in brackets in the title instead ("Marta the Innkeeper (Old Marta)"); if you match names, strip a trailing "(…)" from a character, faction or location title and treat it as an alias too.
  • Also optional: subtype, priority, attributes, source (see below) and conflict (an open disagreement the DM hasn't resolved yet).

intelligence is the live analysis for one session. Its new_canon array is what Scryboard noticed this session that isn't in the notes yet:

{ "type": "character", "subtype": "npc", "name": "Marta the Innkeeper", "description": "Runs the Thornwatch Inn." }

Note name, not title — these are detections, not reviewed items. They become extraction items (with title) only once the DM accepts them at session review. An app that wants "every NPC so far" should merge the two, matching on name against title and aliases.

Don't re-extract what Scryboard already extracted. If your app needs "the NPCs" or "the locations", read extractions (reviewed) plus intelligence.new_canon (live). Running your own LLM over the transcript to find the same things costs your buyer money and will disagree with what the DM sees elsewhere in Scryboard.

Writing widgets

Your app's output can render inside Scryboard — as a card in the live session's dashboard, on the DM's campaign landing page, or on a player's own campaign landing page. Apps send a layout — a tree of nodes describing structure (grids, sections, fields, tables) and a small set of tokens (spacing, semantic color); Scryboard's own components turn that into the actual page. There is no HTML, CSS, or code in a layout — every prop is either a length-capped string or a value from a closed set. App code never runs in the dashboard.

POST https://<your-scryboard-host>/api/agent/widgets
Authorization: Bearer scry_...
Content-Type: application/json
{
  "widget": "Spell usage",
  "placement": "session_dashboard",
  "visibility": "dm",
  "session_id": "<optional uuid>",
  "preferred_height": 4,
  "layout": {
    "type": "table",
    "columns": ["Spell", "Casts"],
    "rows": [["Fireball", 3], ["Bless", 5]]
  }
}
  • widget — stable name; pushing again with the same name updates the same widget (latest output wins, last 20 kept). Pushing again with the same name but a different placement moves it — it doesn't create a second copy, so correcting a wrong placement just means pushing again correctly. A widget belongs to the app that pushed it. A new token for the same app — a Marketplace install's re-issued token, a personal app's new Runner link, a browser app's per-page token — takes over that app's existing cards, so the next push updates them instead of adding a second copy (the card's button clicks from before the new token are dropped, so a new token never sees old clicks). Tokens you create by hand under App Access Tokens are each their own app: two of them pushing the same name make two cards, each headed with its token's name.
  • placement — session_dashboard (live session view, either role), dm_landing (the DM's persistent campaign page), or player_landing (a player's own persistent landing page). "Dashboard" always means in-session; "landing" always means the persistent page you'd see between sessions. Always set this explicitly. If omitted it defaults to session_dashboard — fine for something meant to be watched live, but a common mistake for a personal widget (a player's spell log, gold tracker, backstory lookup): leave placement out and it lands in the live session view instead of the player's own landing page, where it's also visible on the DM's session dashboard even at visibility: "dm" — see the note below on why. If your widget is meant to live on a player's landing page, set "placement": "player_landing" every time.
  • visibility — dm (default — for a player-issued token this means "just me," not literally the DM; see below) or table (everyone at the campaign sees it, including other players).
  • Your own widgets are always visible to you, regardless of visibility — a player's personal tracker (their gold, their spell log) set to dm visibility is private to that player, not hidden from them. table is for when you deliberately want to share it with the rest of the group.
  • preferred_height — optional integer, 1-12 (the dashboard grid's own row units; omit it and the current default is used). This is a hint, not an instruction. It only ever sets the card's height the very first time it's placed, with no saved size yet. The moment anyone drags or resizes it, their size is what's saved from then on — you cannot set a height that overrides a size someone chose, and there's no way to "reclaim" a card's size once a player has picked their own. Use it to say "this one's usually a quick line" (1-2) or "this one wants room" (6+) so the card doesn't open cramped or oversized before anyone's had a chance to adjust it.
  • The DM can disable any widget from their dashboard; you can disable your own from wherever it's shown. Pushes to a disabled widget are rejected with 403.
  • The DM can always see every widget in their own campaign, regardless of who created it or what visibility it's set to — that's deliberate (oversight over anything an app pushes into their campaign), not a privacy leak of your data. Combined with the placement default above, this is why a player widget pushed without an explicit placement shows up on the DM's live session dashboard even though it's visibility: "dm" ("just me") — it's sitting in the shared session_dashboard slot, which the DM's session view always shows regardless of visibility. Setting placement correctly is what actually keeps it off the DM's screen, not visibility.

layout node types

Every node is { "type": "<one of these>", ...props, "children"?: [...] }. Unknown types or prop values outside their allowed set are rejected at push time with a specific error, not silently dropped.

Type Props
container direction: "row"|"column", columns?: 1-6, gap: "xs"|"sm"|"md"|"lg", variant?: "plain"|"panel"|"well"|"card" (give the group a visible surface — panel is an inset box with a hairline border, well a soft fill, card a raised one), align?: "start"|"center"|"between" (how a row packs — between pushes the last child to the right edge), background? (either "tone:<tone>" for a themed surface that follows the player's chosen theme, or a fixed "#rrggbb" hex — see below; wins over variant if both are sent), collapsed_label? (≤60 chars — renders the container closed behind a one-line toggle showing this label, instead of its children)
section title (≤60 chars)
field label (≤40 chars), value (≤120 chars), tone?, emphasis?: "normal"|"large", layout?: "inline"|"stacked" (stacked puts the label above a larger value — the stat-tile shape; pair it with a container's columns and variant for a stat row)
text value (≤500 chars), tone?, size?: "sm"|"md"|"lg", weight?: "normal"|"bold", italic?: boolean
table columns: string[], rows: (string | number)[][] — each cell is shown as text, so numbers are fine (["Fireball", 3]); null, objects and arrays in a cell are not useful, convert them first
badge label (≤24 chars), tone?
divider —
meter value (required, number), max (required, number > 0), label? (≤40 chars), tone?, style?: "bar"|"pips" (pips for a small countable quantity like spell slots — falls back to a bar when max is over 12, since pips stop being countable at a glance), show_value?: boolean (default true — shows value / max)
icon exactly one of name (from the curated set — a typo is rejected at push time, not silently blank) or media_id (your own uploaded artwork, same path as image), plus size?: "sm"|"md"|"lg", tone? (named icons only — see below), alt?
stat value (required, ≤24 chars), label? (≤24 chars), sub? (≤40 chars), trend?: "up"|"down"|"flat", tone?, icon? (a curated icon name). The dashboard tile — built to sit in a row of four and be read across a table.
callout text (required, ≤300 chars), title? (≤60 chars), tone?, icon? (a curated icon name). A toned box for "this one matters" — reads as a callout rather than as ordinary prose.
richtext value (required, ≤2000 chars — plain text with a small markup subset, see below), tone?, size?: "sm"|"md"|"lg". Using a link inside the value requires the widgets:link write scope.
link href (required, https only), label (required, ≤80 chars), tone?. Requires the widgets:link write scope — see below. At most 20 per widget.
gallery columns?: 2-6 (default 4), children = gallery_item nodes. A wrapper — put the thumbnails in children.
gallery_item exactly one of media_id or url (https only), plus caption? (≤60 chars), selected?: boolean (marks the one already chosen), alt?, and action_id? — give it an action_id and the thumbnail becomes the pick control: clicking the image fires a click you poll back from actions, exactly like an action button. At most 24 per widget.
chart points (required, an array of numbers, 1–60), kind?: "bar"|"line"|"donut" (default bar), labels? (must have exactly as many entries as points — a mismatch is refused rather than silently mislabelled), label? (≤40 chars), tone?, show_value?: boolean. You send numbers; Scryboard draws the SVG — there is no way to send a path, a pixel or a color. Deliberately small: one series, no axes, no legend, no tooltips. If you need a real chart, link out to one.
input setting_key (required, a 1–64 char lowercase slug), item_key? (≤64 chars). A bookmark, not an input. It says "the settings box for this goes here" — Scryboard moves a box it was already going to draw into your layout, instead of appending it below everything. You never see or set the value through this node; you read it back with getSettings() as always. setting_key must be a setting your manifest declares, or the push is refused — and since only a Marketplace install has a manifest, a hand-issued or personal-app token pushing an input node gets 400 "input nodes need an installed app with declared settings" (leave the node out while testing that way). Omit item_key for an app-wide setting; give it for a per-item one. Needs no extra write scope.
steps children = step nodes. A progress track for multi-stage work.
step label? (≤40 chars), state?: "todo"|"current"|"done" (default todo).
list variant?: "plain"|"divided"|"boxed", children = list_item nodes. A wrapper — put the rows in children.
list_item title? (≤80 chars), subtitle? (≤120 chars), tone?, state?: "normal"|"active"|"dim" (active marks the row the table is on right now, dim one that's finished), exactly one of icon? (curated name) or media_id? (your own artwork) for the leading image, plus children — nest a meter, a badge, even an action button inside a row and it just works.
image exactly one of url (https only, proxied and validated server-side, 5MB max) or media_id (a UUID from Uploading media — must be live media in the same campaign), plus alt, fit?: "contain"|"cover", size?: "thumb"|"sm"|"md"|"full" (named buckets — Scryboard decides the pixels; there is no way to state a height), shape?: "square"|"rounded"|"circle" (circle gives you an avatar; put one in a direction: "row" container to sit it beside text)
video video_id (an 11-character YouTube video ID — not a URL), title? (≤100 chars). Requires the widgets:video write scope — see below.
action action_id (a 1–64 char slug: lowercase a-z, 0-9, _, ., :, -, starting with a letter or digit), label (required, ≤40 chars), tone?. Renders as a clickable button — see below. At most 40 per widget.

tone? is the only color channel, mapped to Scryboard's own palette — there is no way to send a raw color. It comes in two groups:

  • State — "default", "gold", "muted", "danger", "success". Use these for how something is going: urgent, resolved, secondary.
  • Subject — "arcane", "frost", "ember", "nature". Use these for what something is: a school of magic, a damage type, a faction, a terrain. They carry no good/bad meaning, so a reader doesn't misread "this NPC is fire-aligned" as "this NPC is a problem."

Every tone follows the player's chosen theme, so pick by meaning and it will look right in all of them. A tree may nest up to 6 levels deep and 400 nodes total, within an overall 60KB per push.

Give each gallery_item its own action_id and the thumbnail is the button — clicking the picture fires a click you read back from the actions resource, the same way an action button does. That's the whole "generate four variants, let them choose one" flow:

{
  "type": "gallery",
  "columns": 4,
  "children": [
    { "type": "gallery_item", "media_id": "…", "caption": "01", "action_id": "pick:01", "selected": true },
    { "type": "gallery_item", "media_id": "…", "caption": "02", "action_id": "pick:02" },
    { "type": "gallery_item", "media_id": "…", "caption": "03", "action_id": "pick:03" },
    { "type": "gallery_item", "media_id": "…", "caption": "04", "action_id": "pick:04" }
  ]
}

Notes:

  • Who may click is enforced server-side, identically to an action button: the campaign's DM, or whoever's token pushed the widget. Nothing about that is the app's to decide.
  • selected marks the item that's already been chosen. It's for showing a decision that's been made, not for making one — the pick still happens through action_id.
  • Leave action_id off and the item is just a picture. That's the right choice for a contact sheet, and better than a click target that does nothing.
  • Re-clicking while a click is still pending is coalesced, not queued — same guard as action (see the action node).

richtext — a small markup subset

richtext takes plain text, and Scryboard recognises four things in it:

Write this Get this
**bold** bold
*italic* or _italic_ italic
lines starting - or * a bulleted list
[label](https://example.com) a link (needs widgets:link)

Everything else is text. This is a parser over those four constructs, not an HTML sanitiser — if you write <script>, your reader sees the literal characters <script>, because nothing you send is ever treated as markup. That also means malformed markers are harmless: an unclosed ** just shows up as asterisks rather than failing your push.

Two consequences worth knowing:

  • Bare URLs are not auto-linked. Mentioning https://example.com in prose stays text. Turning a mention into a click target without being asked is exactly the kind of helpfulness that surprises a reader, so you have to write the [label](url) form deliberately.
  • A non-https link degrades to visible text, not to a silently dropped link. [x](javascript:alert(1)) renders as those literal characters.

Anything clickable that leaves Scryboard needs the widgets:link write scope — whether it's a link node or a link written inside a richtext value. Declare it in scryboard.json alongside widgets:

{ "scopes": { "writes": ["widgets", "widgets:link"] } }

It's gated for the same reason widgets:video is: a clickable third-party destination in front of a whole table is a bigger surface than declarative data, so a buyer sees it on the install screen rather than trusting review alone. widgets:link on its own grants nothing — it must accompany widgets.

What Scryboard does with every link, regardless of what you ask for:

  • https only. No http:, no javascript:, no data:, no protocol-relative //host, and no embedded whitespace or control characters (the usual way a scheme gets smuggled past a naive check).
  • Opens in a new tab with rel="noopener noreferrer", so a click never navigates a DM away mid-session and the destination can't reach back.
  • Carries a visible ↗ marker and shows the real destination on hover — so a link can't pass itself off as Scryboard's own navigation, whatever its label says.

Icon names

114 curated names, drawn from Lucide. An unknown name is rejected at push time with a specific error, so a typo fails loudly rather than leaving a blank space on a card.

Group Names
Combat & danger sword, swords, shield, shield-check, skull, flame, zap, target, crosshair, bone, ghost
Magic & the arcane sparkles, wand, wand-sparkles, star, moon, sun, atom, orbit, biohazard
People & parley user, users, user-plus, crown, handshake, message-square, footprints
Health & condition heart, heart-crack, heart-pulse, activity, pill, bandage, stethoscope, flask-conical
Items & treasure gem, coins, backpack, package, box, key, key-round, lock, lock-open, vault, scale
Places & travel map, map-pin, compass, tent, mountain, trees, castle, home, building, church, door-open, door-closed
Creatures & nature leaf, bug, rat, bird, fish, cat, dog, rabbit, turtle, shell
Weather & elements snowflake, cloud, cloud-lightning, wind, droplet, waves
Time & tracking clock, hourglass, timer, calendar, bell, gauge
Craft & tools hammer, axe, pickaxe, anvil, wrench, pencil, feather
Lore & knowledge scroll, scroll-text, book, book-open, library, bookmark
Food & rest utensils, beer, wine, coffee, wheat
Status & direction check, check-check, x, plus, minus, arrow-up, arrow-down, trending-up, trending-down, circle-alert, triangle-alert, info, eye
Dice dices, dice-1, dice-6

A named icon takes its colour from tone, like any other node.

Need something outside this set? Use media_id instead — your own uploaded artwork, through the same path image uses. Two honest differences: an uploaded icon is a fixed full-colour image (the media pipeline re-encodes everything to WebP and rejects SVG, so there is no line art to tint, and tone does nothing), and unlike image.media_id it is not checked at push time for being live media in your campaign — a stale id renders as a broken image rather than a rejected push.

container.background — your own surface

Two forms, and nothing else is accepted:

  • "tone:arcane" — you pick the meaning, the player's theme picks the actual colour. The same card renders purple-on-brown in Parchment/Tavern, indigo-on-slate in Frostpunk and hot magenta in Arcane Neon, and your app never learns which. Reach for this if you want your card to look like it belongs.
  • "#1a0e2e" — a fixed six-digit hex. You pick the colour and the theme has no say, so it looks the same in every preset. Reach for this if the colour is your app's identity. Be aware it will look foreign to a player who chose a different theme.

Only a hex or a tone: string is accepted — never a CSS value, so there is no url() and therefore no request leaving the page from a background.

Scryboard always picks the text colour. Whatever background you send, we measure its brightness and put our light or dark text on it, so you cannot make a card unreadable by accident. One caveat: an explicit tone on a node inside a coloured container still resolves from the theme and can clash (danger red on a red ground). If you set a strong background, leave the tones inside it alone.

Naming your widget

The widget name is yours, with three rules — all rejected at push time with a specific error, never silently rewritten:

  • 40 characters or fewer.
  • No reserved words: scryboard, official, verified, first-party, built-in, billing, payment, password.
  • Latin letters, digits, spaces and ordinary punctuation only. Accented characters are fine; other scripts are not.

Your card's title bar shows the name of your app from its Marketplace listing — which you can't set from your code — followed by this widget name in quieter type. On a narrow screen the widget name is what gets dropped first, so put the identifying word early.

video — YouTube embeds, gated behind their own scope

A video node renders as a click-to-play YouTube player: a static thumbnail until the viewer clicks, then a sandboxed youtube-nocookie.com embed. Because a playable third-party embed is a bigger capability than a static image, it's opt-in on top of plain widget access:

  • Declare both "widgets" and "widgets:video" under scopes.writes in your manifest. A push containing a video node from a token whose app didn't declare widgets:video is rejected, even if plain widget pushes work fine.
  • video_id is the 11-character ID only — from https://www.youtube.com/watch?v=dQw4w9WgXcQ, send "dQw4w9WgXcQ". URLs of any kind are rejected: your app picks which video plays, never where the embed points.
  • At most 3 video nodes per widget.
  • The embed can't autoplay before the viewer clicks, can't navigate the page, and can't open popups — Scryboard owns the iframe and its sandbox, not your layout.
  • Buyers see widgets:video spelled out on your listing's permissions panel, so say what you play and why in your description.
{ "type": "video", "video_id": "dQw4w9WgXcQ", "title": "How counterspell actually works" }

action — a button your app polls for clicks

The layout schema is deliberately display-only, with one narrow exception: an action node renders as a button — Scryboard's button, not yours. Your app declares a stable action_id and a human-readable label; a click is recorded server-side, and your app reads it back on its next poll via the actions read resource (see the table under Reading data). No callback URLs, no code in the dashboard — a click is just data flowing back the other way. This is the intended replacement for file-based triggers ("drop a file named sync-now next to the app") for anything that needs a human-initiated "do it now."

  • Declare actions under scopes.reads in your manifest to read clicks back. The node itself needs only plain widgets write access — the button renders either way, but without the read scope you'll never see the clicks.
  • Who can click, deliberately conservative for now: the campaign's DM, or the user who issued the widget's token (its owner). Each click records which of the two it was as clicked_by_role: "dm" | "owner" — no user identities.
  • The clicked action_id must exist in the widget's latest output, or the click is rejected — a click is a response to what your app is currently showing, so keep action_ids stable across pushes for buttons that mean the same thing.
  • Poll with since set to the newest created_at you've already processed, and persist that high-water mark between ticks. On first run, seed it from the newest existing click, so clicks from before your app started are treated as history — see Paging through a feed. Clicks are retained for about 30 days.
  • One pending click per button. While your app hasn't pushed the widget again since a click (for up to 10 minutes), further clicks on the same action_id are folded onto the pending one — nothing new is queued, and the button keeps showing "sent". So an impatient DM never hands you a burst of duplicates; you'll see one row. Still treat a click as "do it (again) now" and keep the handler idempotent — after you re-push, a fresh click is a fresh row.
  • Push the widget again after acting on a click (most apps push every tick anyway). The pushed output is what tells the button — and the pending-click rule above — that the app has caught up.
  • Cap: 40 action nodes per widget (the whole layout is capped at 400 nodes).
  • Client library: scryboard.getActions({ since }) — or the plain scryboard.get('actions', { since }), which is all a node_cli app needs.
{ "type": "action", "action_id": "sync_now", "label": "Sync from Kanka", "tone": "gold" }

Minimal poll loop (works against the mock server as-is — push a widget with the node above, click it on http://localhost:4747, then run this):

if (!state.actionsSince) {                // first run: old clicks are history
  const [latest] = await scryboard.get('actions', { order: 'newest', limit: 1 })
  state.actionsSince = latest?.created_at ?? new Date().toISOString()
}
let since = state.actionsSince            // persisted between ticks
let syncRequested = false
for (;;) {
  const clicks = await scryboard.get('actions', { since, limit: 200 })
  for (const click of clicks) {
    since = click.created_at
    if (click.action_id === 'sync_now') syncRequested = true
  }
  if (clicks.length < 200) break
}
state.actionsSince = since
if (syncRequested) await doTheSync()      // once, however many clicks arrived
await scryboard.pushWidget({ /* ...fresh layout... */ })

Example: a compact stat block, using a grid and semantic color together.

{
  "type": "container", "direction": "column", "gap": "sm",
  "children": [
    { "type": "field", "label": "Elowen", "value": "Ranger 5", "emphasis": "large" },
    { "type": "container", "direction": "row", "columns": 6, "gap": "xs", "children": [
      { "type": "field", "label": "STR", "value": "12 (+1)" },
      { "type": "field", "label": "DEX", "value": "18 (+4)", "tone": "gold" },
      { "type": "field", "label": "HP", "value": "12 / 44", "tone": "danger" }
    ]}
  ]
}

Whole-push content is capped at 60KB.

Reading what the buyer told you

If your manifest declares settings (see the manifest guide), the buyer fills those boxes in on Scryboard's own installed-apps page and you read the answers back here.

GET https://<your-scryboard-host>/api/agent/settings
Authorization: Bearer scry_...

Returns your app-wide answers at the top level, plus an items object holding the per-item ones:

{
  "data": {
    "art_direction": "grim oil painting, muted colours",
    "items": {
      "npc-123": { "portrait_note": "tired, greying, missing a tooth" }
    }
  }
}

items is always present, possibly empty, so you never have to guard for it. It contains only the items you most recently pushed (below) — a value belonging to something you've stopped listing is kept, but not reported, so you can't act on a thing you no longer have.

  • No read scope needed. settings is not a resource on the read table above and takes nothing in scopes.reads — declaring the setting in your manifest is the declaration. A scope gate on values the buyer typed for your app specifically would protect nothing.
  • Declared-but-unanswered keys come back as "", so "not answered" is one case to handle rather than two. An app with no declared settings — or a hand-issued token during local development — gets { "items": {} } rather than an error, so the same code runs in both places.
  • Values are per install. The same person installing your app on two campaigns answers separately.
  • The buyer can edit any value at any time, so read it fresh each tick instead of caching the install-time answer.
  • A change never triggers anything on its own. You see the new value on your next tick and decide. If acting on it spends the buyer's money at a third party, show that the previous result is out of date and wait for a button — don't redo the work automatically.

Client library: scryboard.getSettings().

Asking about each thing in a list

An app with one subject can use one settings box. An app with a list can't: a single shared "extra detail" box would silently apply whatever was typed last to whichever thing you act on next. If acting costs the buyer money, that means paying for the wrong result — with nothing looking wrong until it arrives.

Declare the setting with "scope": "item" in your manifest, then tell Scryboard which things to render a box for:

POST https://<your-scryboard-host>/api/agent/settings/items
Authorization: Bearer scry_...
Content-Type: application/json
{
  "items": [
    {
      "key": "npc-123",
      "label": "Marta the Innkeeper",
      "placeholder": "Runs the Thornwatch Inn; knows local rumours…"
    }
  ]
}
  • This is the only thing you may write about settings — which items exist, never what any value is. The buyer's answer only ever comes from the buyer typing it. That split is what makes settings safe, and it's why placeholder is greyed-out suggestion text shown while a box is empty rather than a value you can set.
  • Replaced wholesale on every call, like a widget's content. Send your full current list each time; a list that only ever grew would fill with things the buyer deleted months ago.
  • You choose how many boxes appear. An app with 300 NPCs should send the one its widget currently has selected — click a character, and the box below becomes that character's box. Limit is 50, as a backstop.
  • key is your own id (≤100 chars, opaque to Scryboard), label is what the buyer reads (≤80), placeholder is optional (≤300).
  • Needs a Marketplace install. A hand-issued token or a personal app's token gets 404 "Setting items need a Marketplace install": there is no install for the boxes to belong to. When testing with dev-runner and a hand token, catch that 404 and carry on (getSettings() already returns { "items": {} } there), or test settings through a real install.
  • Rejected with 403 if your manifest declares no "scope": "item" setting — a list with nothing to render against it is a mistake worth hearing about rather than storing silently.

Read the answers back from GET /api/agent/settings above, under items.

Client library: scryboard.setSettingItems([...]).

Uploading media

Apps with the media write scope can upload images for Scryboard to store and serve itself — how you show generated art (a portrait, a map tile, a render preview) in a widget without hosting it anywhere. This is distinct from the image node's external-url path: Scryboard never fetches a URL your app hands it for stored media — the bytes come in the request body, once, and get served from Scryboard's own private storage after that.

POST https://<your-scryboard-host>/api/agent/media
Authorization: Bearer scry_...
Content-Type: multipart/form-data

Send a file field with the raw image bytes, optionally an alt field (≤200 chars) describing the image, and optionally a visibility field.

Who can see an upload:

  • From a DM-scope token, media is DM-only by default — players can't list it or download it, the same as a visibility: "dm" widget. Send visibility: "table" when the image is meant for everyone at the table.

  • Showing a DM-only image on a visibility: "table" widget also puts it on the table: pushing the widget is the reveal. This is one-way. So an image you only ever show on DM widgets stays private, and one you show to the table works for the players without any extra step.

  • From a player-scope token, media is always visible to the whole campaign; visibility is ignored.

  • PNG, JPEG, or WebP in — no SVG. 8MB max, and sources over 8192px on a side are rejected outright.

  • Every upload is decoded and re-encoded server-side (resized to at most 2048px, stored as WebP, EXIF/metadata stripped) — the stored object is never your literal bytes, which is also why an animated upload becomes its first frame.

  • Returns { media_id, campaign_id, tier: "draft", expires_at, visibility }.

  • Uploads start as drafts that expire in 30 days. Once the DM has accepted the asset (e.g. attached it to a confirmed entity), promote it: PATCH /api/agent/media/{media_id} with { "tier": "accepted" } removes the expiry. One-way — there's no demoting back to draft. Drafts are cheap to regenerate; accepted art lives as long as the campaign.

  • Uploads have their own budget — 30/hour per token — on top of the general 120/min API limit, since storage and processing are the expensive path.

To show a stored image in a widget, put its id on an image node:

{ "type": "image", "media_id": "9f4c1e2a-…", "alt": "Portrait of Marta the Innkeeper" }

Exactly one of url / media_id per image node, and the media_id must be live (unexpired) media in the same campaign, or the push is rejected. Scryboard serves it via GET /api/media/{media_id} to the signed-in users allowed to see it (above) — the renderer builds that URL itself, so your layout never controls where an image request goes.

Client library: scryboard.uploadMedia({ data, alt, visibility }) (where data is a Buffer or Uint8Array of image bytes, and visibility is optional) and scryboard.setMediaTier(media_id, 'accepted'). The visibility option needs a current App Runner; an older Runner uploads without it (DM-only), and the table-widget rule above still makes the image visible wherever you show it to the table.

Buyers see media spelled out on your listing's permissions panel, so say what you upload and why in your description.

Importing character data

Player-scope tokens can also write structured character data — stats, inventory, spells known, whatever a source system has — for the token's owner to read back and turn into a widget. This is how you'd build "my character's gold," "my spell list," or "my backstory" from a D&D Beyond export, an OCR'd paper sheet, or anything else: import once, read back, render as a widget.

POST https://<your-scryboard-host>/api/agent/character
Authorization: Bearer scry_...
Content-Type: application/json
{
  "category": "ability_scores",
  "data": { "str": 16, "dex": 12, "con": 14, "int": 10, "wis": 8, "cha": 13 }
}
  • Player-scope tokens only — a DM-scope token isn't tied to a single character, so it can't use this endpoint.
  • category — a free-form key you choose (1–60 chars), e.g. ability_scores, inventory, spells_known, backstory. No fixed schema, since source systems vary wildly — pick names that make sense for what you're importing.
  • data — any JSON, up to 20KB per category. Pushing again with the same category replaces that category's data entirely (not a deep merge); other categories are untouched. 200KB total cap across all categories for one character.
  • Read it back via the character resource on the read API (a different app can read what another one imported, as long as both authenticate as the same player) — see the table above. character_data comes back keyed by category, e.g. { "ability_scores": {...}, "inventory": {...} }.
  • From there, build whatever widget makes sense — a grid of field nodes for ability scores, a table for inventory, a text node for a backstory — and push it to player_landing (see Writing widgets above).

Proposing canon

DM-scope tokens can propose new or updated content for one of a campaign's extraction layers — the same layers a DM's own note uploads land in. This is how you'd build a world-bible sync from an external source (a wiki, a campaign-notes tool, anything with structured lore) without asking the DM to copy-paste it in by hand.

POST https://<your-scryboard-host>/api/agent/extractions
Authorization: Bearer scry_...
Content-Type: application/json
{
  "layer": "world_bible",
  "items": [
    { "id": "...", "type": "location", "title": "The Sunken Archive", "description": "...", "flagged": false, "confirmed": false }
  ]
}
  • DM-scope tokens only — there is no player-write path for canon, anywhere in the API, and this endpoint is no exception.
  • layer — one of session_history, session_notes, world_bible, adventure_structure, pc_notes, homebrew_registry.
  • items — the layer's full array (this replaces the layer's current items, it does not append or merge), up to 500KB. Same item shape as the extractions read resource (type, optional subtype, title, description, …) — see the note on that resource above. If what you're proposing has structured data that doesn't fit title/description text (a monster's stat block, an item's numeric properties), put it in attributes — see the same note.
  • This never publishes anything. However it's called — a Marketplace app, your own script, anything — the write always lands unverified, exactly like a fresh extraction batch the DM hasn't reviewed yet. It only becomes trusted, and player-visible where a layer allows that, once the DM confirms it through Scryboard's own review flow (/campaigns/{id}/verify/{layer}). There is no way for a token to mark its own write verified.
  • Declare extractions under writes in your manifest's scopes to use this. Because it's DM-scope-only, a listing that declares it automatically requires a DM to be the one who installs it, the same as declaring a read on transcript or members does.
  • If all you want is to attach structured data to one item the DM already confirmed — not propose new canon — this is the wrong tool: it replays your possibly-stale copy of every other item and drops the whole layer back to unverified. Use the per-item attributes write below instead.
  • If the material is something the DM already treats as truth — their own wiki, their own world-building tool — and re-approving it item by item is the friction rather than the safeguard, see Importing canon.

Importing canon

DM-scope tokens with the canon_import write scope can import structured items into the World Bible as confirmed canon, each with a visible source tag, without overwriting anything a session already established. This is for material the DM already owns and trusts (their Kanka.io wiki, say), not for scraped or generated content — the review gate the listing goes through will ask.

POST https://<your-scryboard-host>/api/agent/canon-import
Authorization: Bearer scry_...
Content-Type: application/json
{
  "layer": "world_bible",
  "items": [
    {
      "type": "location", "subtype": null,
      "title": "Emon", "description": "Capital of Tal'Dorei …",
      "source": { "provider": "kanka", "id": "8650692", "url": "https://app.kanka.io/w/366388/entities/8650692" }
    }
  ]
}
  • DM-scope tokens only; canon_import must be in the token's granted writes; layer must be world_bible (v1). pc_notes and homebrew_registry are never written by an import, whatever it matches.
  • Up to 100 items per call; title ≤ 200 chars, description ≤ 20 000 chars; source.provider [a-z0-9_-]{1,40}, source.id ≤ 100 chars, source.url an https URL if given. Same rate limit as every other call.
  • What happens to each item (the same entity resolution the DM's own review-save runs: names, aliases and the obvious variants of a name are matched, and a description that contradicts the existing one is flagged rather than merged):
    • already imported (same provider+id) → unchanged, or updated in place if the content changed (idempotent — re-send freely);
    • matches an existing item in a DM layer without contradiction → merged: the existing item keeps its text, gains the new text under a "[New details merged in …]" marker, an alias, and the review flag;
    • matches with a contradiction → conflict: an open conflict on the existing item for the DM to resolve; nothing is replaced;
    • matches an item in pc_notes/homebrew_registry, or one that already has an open conflict → skipped (with a reason);
    • genuinely new → imported: inserted into the target layer, confirmed: true, flagged: false, with source: { provider, id, url, hash, importedAt, mode: 'imported' }.
  • Response: { imported, updated, unchanged, merged, conflicts, skipped: [{ title, reason }], results: [{ source, title, outcome, reason? }], layers_written } — results is one entry per input, same order.
  • If the DM saves something between the resolution and the write, the call fails with 409 … changed during import — retry rather than clobbering; the endpoint retries once itself.
  • Imported and merged items carry source on the extractions read too, so an app that syncs the other way can (and should) leave them alone — Kanka Codex never pushes a source.provider === 'kanka' item back.
  • On the verify page they show as a small "from <provider>" tag with a filter for imported vs session-detected items.
  • GET /api/agent/canon-import is a status probe: { role, allowed, reason, layers } — never an error for a valid token, so an app installed with the wrong scope can tell its DM rather than fail quietly.
  • Client: scryboard.importCanon({ layer, items }) and scryboard.canonImportStatus() (agent-runtime; App Runner v0.2.1+). Declaring canon_import forces a DM installer, same as extractions. browser_sandboxed apps don't have it yet.

Attaching data to confirmed canon

DM-scope tokens with the extractions:attributes write scope can merge keys into one extraction item's open-ended attributes object — the free-form bucket described under Reading data — without touching anything else. This is how an app that just produced an artifact for a confirmed entity (a portrait's media_id, an STL file's reference, a computed stat) records the pointer on that entity.

POST https://<your-scryboard-host>/api/agent/item-attributes
Authorization: Bearer scry_...
Content-Type: application/json
{
  "layer": "world_bible",
  "item_id": "...",
  "attributes": { "my_app": { "portrait_media_id": "9f4c1e2a-…" } }
}
  • Deliberately narrower than Proposing canon: it cannot create or delete items, cannot touch title, description, confirmed, or verified, and — unlike the full extractions write — it does not drop the layer back to unverified. The DM's review stays intact; only the opaque attributes bucket changes.
  • DM-scope tokens only, same rule as extractions. Declaring extractions:attributes in your manifest likewise forces a DM to be the installer.
  • The merge is shallow, key by key: existing keys you don't mention are untouched, and a null value deletes that key — so your app can clean up after itself.
  • Caps: 10KB per patch, 20KB per item's attributes after the merge.
  • Every call is logged to the campaign's event log (which keys were touched, attributed to your app by name), so the DM can always see who wrote what.
  • attributes has no fixed schema and no enforcement between apps — namespace your keys (e.g. { "my_app": { ... } }) so apps don't clobber each other. Convention, not enforcement.
  • Client library: scryboard.setItemAttributes(layer, item_id, attributes).

Proposing events

DM-scope tokens with the events write scope can propose structured campaign events — an encounter fought, a scene played out, a notable spell cast, an XP award — for the campaign's permanent event record. This is the write half of the events read resource above: where extractions holds the campaign's lore as prose items, events hold its history as typed payloads with linked participants, queryable across types.

POST https://<your-scryboard-host>/api/agent/events
Authorization: Bearer scry_...
Content-Type: application/json
{
  "event_type": "encounter",
  "session_id": "<optional uuid>",
  "payload": { "outcome": "victory", "rounds": 3 },
  "participants": [
    { "participant_type": "member", "member_id": "...", "display_name": "Thugsley", "role": "pc" },
    { "participant_type": "name", "display_name": "Ghoul", "role": "monster", "detail": { "cr": "1" } }
  ]
}
  • DM-scope tokens only — events can describe DM-private material (an unrevealed monster's CR, an unconfirmed proposal's contents), so both the read and the write require DM scope. Declaring events in your manifest (under reads or writes) forces a DM to be the installer, same as extractions.
  • event_type — exactly one of encounter, scene, spell_cast, xp_award. This is a closed set: Scryboard's own internal event types (rules_lookup and friends) are pipeline records, not app-writable.
  • payload — a JSON object, up to 20KB. No fixed schema per type beyond the xp_award rule below — shape it for what you're recording.
  • participants — optional, up to 50, each { participant_type, member_id?, item_id?, display_name, role?, detail? }:
    • participant_type — member (a campaign member: member_id must be a member of this campaign — ids come from the members read resource), extraction_item (a canon entity: pass its item_id), or name (just a display name — how free-text combatants are recorded).
    • display_name — required, ≤80 chars, always present so the event stays renderable even if the linked record moves on.
    • role — optional, ≤40 chars (e.g. pc, monster, caster).
    • detail — optional JSON object, ≤2KB, for per-participant data like an XP amount.
  • Every proposal lands unconfirmed. However it's called, the write never bypasses review: the DM confirms it, rejects it, or amends the payload (the original stays visible as proposed_payload) on the Approval Queue page (/campaigns/{id}/events) — the same affirm-or-modify trust model as Proposing canon. There is no way for a token to confirm its own event.
  • xp_award events must cite their source: the payload must carry a source_event_id referencing a confirmed encounter or scene event in the same campaign — an award proposed against a still-pending encounter is rejected, so wait for the DM's review before proposing XP for it. Convention for the rest of the payload: total_xp, a per_character map, and per-recipient participants each carrying detail: { "xp": ... }.
  • Scryboard itself writes events through the same record: ending an encounter snapshots the combatants into an unconfirmed encounter event awaiting the same review, and DM-approved items from the post-session review land as already-confirmed scene events. Read those back via the events resource and build on them (an XP suggester citing a confirmed encounter, say) rather than re-deriving what happened yourself.
  • Client library: scryboard.writeEvent({ event_type, payload, session_id, participants }) and scryboard.getEvents(params) — both available to browser_sandboxed apps too.

Publishing to the Marketplace

Every Marketplace submission ships a scryboard.json manifest next to your code — it declares your entry file, your runtime, the resources you read and write, any package dependencies, and any secrets a buyer needs to supply. Full field-by-field reference: the manifest doc. At submission time, Scryboard scans your code and checks it against what you declared — reading transcript without declaring it is a rejection, not a warning.

Pick a runtime before you write anything — see Choosing a runtime below.

Either way, a buyer's "install" only mints them a scoped token and hands their client (browser or the Runner) your code — it does not run your app by itself, and it has no separate placement setting of its own. Nothing shows up anywhere in their campaign until your tick() actually runs with that token.

Once it does run, where its widgets land is determined entirely by the placement value your own code passes to pushWidget (see Writing widgets above) — the marketplace has no way to override or configure this from outside your code, by design (app code runs on the buyer's own machine, not on Scryboard's servers).

Because of that, say where your widget(s) show up in your listing's setup instructions — "shows up in the live session dashboard," "appears on the DM's landing page," "lands on your own player landing page" — so a buyer knows what to expect before they run something you wrote. This is the same reasoning behind declaring your scopes on the manifest: buyers shouldn't have to read your source to know what they're installing.

Choosing a runtime

Every app declares one runtime in its manifest. Decide first: it fixes what your app can remember, call and install.

browser_sandboxed node_cli
Where it runs In the browser of whoever installed it, only while they have that campaign's page open. If the page is open in two tabs, the newest one runs it and the older one stops. On the buyer's computer, through the Scryboard App Runner — a desktop app (Windows only for now) the buyer installs once and leaves running.
Buyer setup None. Install is one click. Install the Runner once; after that, each app is one click.
How often tick() runs Every 5 seconds while a session is live, every 30 seconds otherwise. poll in the manifest is ignored. On the poll schedule in your manifest.
Code One file, no packages, no import. Any number of files, plus the vetted packages.
Network Scryboard's API only, through scryboard.*. Your code runs in a Web Worker inside a sandboxed frame with its own opaque origin: it can't see the buyer's cookies, storage or page, and the browser refuses any fetch, XHR or socket it tries. Anything — an LLM, an image service, another platform.
Long or stuck runs tick() never runs twice at once — the next tick waits for the last one to finish. A single tick that takes longer than 60 seconds, or code that fails to load, stops the app; Manage apps shows the reason ("Stopped: …") until the buyer disables and re-enables it, or reloads. Up to you and the Runner.
API keys (secrets) No. Yes — the Runner asks the buyer and keeps them in the system keychain.
Remembering things between ticks Player installs: setCharacterData (the player's own character data, 20KB per category). DM installs: nothing. There is no storage for a DM's sandboxed app — it must rebuild what it needs from Scryboard's data every tick. Files next to your code (a state/ folder). Treat them as a cache you can rebuild: an update or reinstall can lose them, so never let a missing file replay work — or a charge — that already happened.
Settings boxes (settings) App-wide ones, read with get('settings'). Per-item boxes (setSettingItems) are not available. All of it, including per-item boxes.
Writes Widgets, character data, events. Everything in this document, including media uploads, proposed canon and canon import.

You can use browser_sandboxed if all of these are true:

  1. Everything your app needs is already in Scryboard (no outside service, no API key, no package).
  2. It can work out everything from scratch each tick — or it is installed by a player and fits in that player's character data.
  3. It's fine that it only runs while someone has the campaign page open.
  4. It doesn't need per-item settings, media uploads or canon writes.

Otherwise, build node_cli. A DM-facing app that has to remember something (a roster it builds up, which clicks it has handled) is the common case that rules the sandbox out.

Before reaching for an outside service at all, check whether Scryboard already has the answer: "the NPCs", "the locations" and "what happened" are already in extractions, intelligence.new_canon and events — see Response shapes.

Example: minimal app loop

This is the plain-REST pattern — you own the polling loop, you manage the token yourself. It's how the example apps on the Downloads list work, and it's a fine way to self-host an app outside the Marketplace entirely. If you're publishing a browser_sandboxed or node_cli app, skip setInterval and the token: export an async tick(scryboard) function instead, and the runner (or the buyer's browser) drives the loop and handles the token for you — see the tick convention.

const BASE = 'https://scryboard.vercel.app'
const H = { Authorization: `Bearer ${process.env.SCRY_TOKEN}` }

async function tick() {
  const sessions = (await (await fetch(`${BASE}/api/agent/sessions`, { headers: H })).json()).data
  const live = sessions.find((s) => s.status === 'active')
  if (!live) return

  const lookups = (await (await fetch(
    `${BASE}/api/agent/rules_lookups?session_id=${live.id}`, { headers: H }
  )).json()).data

  const counts = {}
  for (const l of lookups) counts[l.name] = (counts[l.name] ?? 0) + 1

  await fetch(`${BASE}/api/agent/widgets`, {
    method: 'POST',
    headers: { ...H, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      widget: 'Spell usage',
      placement: 'session_dashboard', // watched live, during the session — set explicitly, don't rely on the default
      session_id: live.id,
      layout: {
        type: 'table',
        columns: ['Rule / Spell', 'Times surfaced'],
        rows: Object.entries(counts).sort((a, b) => b[1] - a[1]),
      },
    }),
  })
}

setInterval(tick, 15000)