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:
extractionsitems carrytype(beat,character,faction,location,item,rule, orhistory) and an optionalsubtypefor finer classification (e.g.character→pc/npc/monster,item→weapon/spell/consumable/etc.) —subtypeisnullwhen not confidently inferable, so don't assume it's always present. Items may also carry an optionalattributesobject — a free-form bucket for structured datatitle/descriptiontext 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 carrysource: { provider, id, url, hash, importedAt, mode }(modeimported= 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_registryfromextractions— 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 setrequires_dm_scope: truein 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
sessionsand filteringstatus === "active". transcript+after_mssupports incremental polling: keep the largesttimestamp_msyou've processed and pass it asafter_msnext time.timestamp_msis 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 from0(or omitafter_ms) for a new session.rules_lookups+sinceis the incremental feed for usage analytics (e.g. a spell histogram). Withsince, results come back oldest-first after that timestamp — keep the newestcreated_atyou've processed as your nextsinceand a burst of lookups is never skipped, just spread over a few ticks. Withoutsinceyou get the newest rows, newest-first — a snapshot, not a feed.actions+sinceis the same incremental pattern for button clicks — declareactionsunderscopes.readsto 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: withoutsince,actionsreturns 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.digestis 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.eventsis the structured half of the campaign record —encounter,scene,spell_cast, andxp_awardevents 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. Declareeventsunderscopes.reads; it's DM-only, so declaring it means a DM must be the installer, same astranscriptormembers. Withsinceand the oldest-first ordering it's the same incremental polling pattern asrules_lookups— and the same warning applies: with nosinceyou get the oldest 200, so on a long-running campaign an app that never pages sees nothing new. Filter byevent_typeand 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 }, wheresourceismarketplace(an installed listing) orpersonal(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 foractionsandevents, and forrules_lookupswhensinceis given) ornewest.
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 isconfirmed: true; if you ever seefalse, treat that item as not yet approved.flaggedmarks an item Scryboard wants the DM to look at again (with aflagReason).aliaseslists other names the same entity has been called — Scryboard merges "Old Marta" into "Marta the Innkeeper" rather than keeping two entries. Match againstaliasesas well astitlewhen you de-duplicate, or you'll count the same NPC twice. Optional: treat a missingaliasesas[]. Items extracted before October 2026 may have noaliasesand 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) andconflict(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 differentplacementmoves 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), orplayer_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 tosession_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): leaveplacementout 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 atvisibility: "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) ortable(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
dmvisibility is private to that player, not hidden from them.tableis 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
visibilityit's set to — that's deliberate (oversight over anything an app pushes into their campaign), not a privacy leak of your data. Combined with theplacementdefault above, this is why a player widget pushed without an explicitplacementshows up on the DM's live session dashboard even though it'svisibility: "dm"("just me") — it's sitting in the sharedsession_dashboardslot, which the DM's session view always shows regardless of visibility. Settingplacementcorrectly is what actually keeps it off the DM's screen, notvisibility.
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.
Building a picker with gallery
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
actionbutton: the campaign's DM, or whoever's token pushed the widget. Nothing about that is the app's to decide. selectedmarks 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 throughaction_id.- Leave
action_idoff 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 theactionnode).
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.comin 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.
Links, and the widgets:link scope
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:, nojavascript:, nodata:, 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"underscopes.writesin your manifest. A push containing avideonode from a token whose app didn't declarewidgets:videois rejected, even if plain widget pushes work fine. video_idis the 11-character ID only — fromhttps://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:videospelled 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
actionsunderscopes.readsin your manifest to read clicks back. The node itself needs only plainwidgetswrite 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_idmust 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 keepaction_ids stable across pushes for buttons that mean the same thing. - Poll with
sinceset to the newestcreated_atyou'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_idare 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
actionnodes per widget (the whole layout is capped at 400 nodes). - Client library:
scryboard.getActions({ since })— or the plainscryboard.get('actions', { since }), which is all anode_cliapp 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.
settingsis not a resource on the read table above and takes nothing inscopes.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
placeholderis 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.
keyis your own id (≤100 chars, opaque to Scryboard),labelis what the buyer reads (≤80),placeholderis 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. Sendvisibility: "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;
visibilityis 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 samecategoryreplaces 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
characterresource 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_datacomes back keyed by category, e.g.{ "ability_scores": {...}, "inventory": {...} }. - From there, build whatever widget makes sense — a grid of
fieldnodes for ability scores, atablefor inventory, atextnode for a backstory — and push it toplayer_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 ofsession_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 theextractionsread resource (type, optionalsubtype,title,description, …) — see the note on that resource above. If what you're proposing has structured data that doesn't fittitle/descriptiontext (a monster's stat block, an item's numeric properties), put it inattributes— 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
extractionsunderwritesin your manifest'sscopesto 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 ontranscriptormembersdoes. - 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_importmust be in the token's granted writes;layermust beworld_bible(v1).pc_notesandhomebrew_registryare 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.urlan 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,
aliasesand 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, orupdatedin 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, withsource: { provider, id, url, hash, importedAt, mode: 'imported' }.
- already imported (same
- Response:
{ imported, updated, unchanged, merged, conflicts, skipped: [{ title, reason }], results: [{ source, title, outcome, reason? }], layers_written }—resultsis 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 — retryrather than clobbering; the endpoint retries once itself. - Imported and merged items carry
sourceon theextractionsread too, so an app that syncs the other way can (and should) leave them alone — Kanka Codex never pushes asource.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-importis 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 })andscryboard.canonImportStatus()(agent-runtime; App Runner v0.2.1+). Declaringcanon_importforces a DM installer, same asextractions.browser_sandboxedapps 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, orverified, and — unlike the fullextractionswrite — it does not drop the layer back to unverified. The DM's review stays intact; only the opaqueattributesbucket changes. - DM-scope tokens only, same rule as
extractions. Declaringextractions:attributesin 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
nullvalue deletes that key — so your app can clean up after itself. - Caps: 10KB per patch, 20KB per item's
attributesafter 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.
attributeshas 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
eventsin your manifest (underreadsorwrites) forces a DM to be the installer, same asextractions. event_type— exactly one ofencounter,scene,spell_cast,xp_award. This is a closed set: Scryboard's own internal event types (rules_lookupand friends) are pipeline records, not app-writable.payload— a JSON object, up to 20KB. No fixed schema per type beyond thexp_awardrule 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_idmust be a member of this campaign — ids come from themembersread resource),extraction_item(a canon entity: pass itsitem_id), orname(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_awardevents must cite their source: the payload must carry asource_event_idreferencing a confirmedencounterorsceneevent 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, aper_charactermap, and per-recipientparticipantseach carryingdetail: { "xp": ... }.- Scryboard itself writes events through the same record: ending an
encounter snapshots the combatants into an unconfirmed
encounterevent awaiting the same review, and DM-approved items from the post-session review land as already-confirmedsceneevents. Read those back via theeventsresource 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 })andscryboard.getEvents(params)— both available tobrowser_sandboxedapps 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:
- Everything your app needs is already in Scryboard (no outside service, no API key, no package).
- It can work out everything from scratch each tick — or it is installed by a player and fits in that player's character data.
- It's fine that it only runs while someone has the campaign page open.
- 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)