Reference
The whole truth about one thing — every field, every capability, every ctx read.
The full detail behind the build-alongs — not the path through, the place you come back to when you want everything about one part of it. If you haven't built a tool yet, start with Build a shared sheet; every section below links back to the build that introduced the idea.
defineTool, in full
Four fields are required. Everything else is a hint you add when you have a reason.
export default defineTool({
id: "dice-tray", // stable forever — analytics, tours, secrets and the Workshop key off it
name: "Dice tray", // what a human sees: Workshop listing, "new document" menu
documentTypes: ["dice-tray"], // the only registration a tool needs
render: DiceTray,
})| Field | What it does |
|---|---|
needs | Host capabilities you call by name — "nav", "world", "projects", "search"… Declaring one asks the installer for consent and makes useHostCapability fail loud and by name instead of dying on undefined. Never list an inherited capability here (see Host capabilities). |
Skeleton | The connecting-state component. Capital S on the definition; lowercase skeleton on <DocumentGate skeleton={…}> — mixing the case is silent, it just falls back to the default. |
surface | "panel" (default, the host's frosted document surface) or "plain" (bare — a canvas or map that paints to its own edges). A hint: the app owns the surface, not the tool. |
panelTitle | Default true — the host draws the document's name above your tool, click-to-rename. Set false when your own UI already shows a title. |
embed | "preview" (default, read-only picture) or "interactive" (the real tool, live). See Blocks. |
tours | A guided walkthrough the host plays. See Guided tours. |
index | What's queryable about your documents. See Search and the index. |
codec / codecModule | Your document codec — how the platform derives an agent surface. See Making it agentable. |
Your render gets exactly two props. document is identity only (id, worldId, type,
title, scope) — turn it into live data with useDocument. context answers what the
platform can't otherwise tell you:
context.* | What it tells you |
|---|---|
canEdit | May this viewer write? A render decision, never the enforcement — a write from someone without permission doesn't land regardless of what you drew. |
view | "editor", "embed", or "fullscreen" — always concrete. |
scope | The world (and optionally project) this mount is scoped to. |
Your codec is the only place Yjs appears
A tool imports @vvd/sdk and React — nothing else from the platform. That's what makes it
run unchanged under the editor, a published read-only page, and a test harness with no
network. defineStateCodec covers scalars, lists, maps and rich text; defineDocumentCodec
is the escape hatch, and it's still the only module that touches Yjs.
Blocks
The host mounts your render in one of three views, and tells you which via context.view:
context.view | Where |
|---|---|
"embed" | Block-sized, inline in a card, a project page, a wiki page |
"editor" | The side panel — the default, everyday working view |
"fullscreen" | A dedicated space; the shell hides its own chrome |
The one real decision is whether your embed is a picture or the tool itself:
export default defineTool({
id: "dice-tray",
documentTypes: ["dice-tray"],
embed: "interactive", // default is "preview" — a read-only, pointer-inert picture
render: DiceTray,
})"preview" is right for a map: a mini-map that swallows your scroll wheel is worse than a
clean thumbnail you click. "interactive" is right for a control surface — a dice tray, a
soundboard, an initiative counter — where an inert picture is a broken version of the tool,
not a smaller one. Design the embed first; the full view is the same surface with more room.
A block is a separate, smaller thing your tool can ship: UI insertable into someone else's document, carrying its own bag of data.
import { type BlockRenderProps, defineBlock } from "@vvd/sdk"
interface RollData {
label: string
result: number
}
function RollBlock({ block, canEdit, onUpdate }: BlockRenderProps<RollData>) {
return (
<aside>
<strong>{block.data.label}</strong>: {block.data.result}
{canEdit ? (
<button onClick={() => onUpdate?.({ result: 1 + Math.floor(Math.random() * 20) })}>
Reroll
</button>
) : null}
</aside>
)
}
export const rollBlock = defineBlock<RollData>({
type: "dice-roll",
name: "Die roll",
needs: ["nav"],
render: RollBlock,
})block.datalives in the host document's section — not a document of your own. A block doesn't need a document at all.onUpdatepatchesblock.datathrough the owning document's codec; a block never opens its own connection.needsis declared per block, separately from your tool's. A missing one renders a named "missing capability" boundary in the block's place, not anundefinedderef.addable: falseremoves it from the "insert a block" menu — for a block that only means something pointing at a document (a document embed, dropped rather than inserted).
When it goes wrong: an embed that renders blank but works in the panel almost always
means a branch returning null that reads fine tall and looks broken at 60px. An embed
that doesn't respond to clicks is embed: "preview" doing its job — set "interactive" if
your tool is a control surface.
World blocks
Some things you build aren't editors and own no data at all — "what changed this week", "a
roster of every character". scope: "world" is the whole declaration:
defineBlock({
type: "world-roster",
scope: "world", // reads the world; block.data is unused
needs: ["world"],
render: WorldRoster,
})The body reads through useWorldQuery, exactly like a document-owning tool — it just has no
document to open. Mount it anywhere with one line:
<BlockSlot type="world-roster" /><BlockSlot> resolves the type from the block registry the surrounding shell installed, so
your app can mount a platform block without importing another feature's registry.
Registries chain — a card body inherits every platform block for free.
Two failure modes, deliberately different:
| Case | Behavior |
|---|---|
An unregistered type through <BlockSlot> | Renders fallback (null by default) — quiet, because a slot is an invitation, not a requirement. |
| A document block mounted through a slot | Fails loudly — that one is a mistake, not a configuration. |
readsWorld not granted | useWorldQuery fails loudly at the boundary and names the capability. |
The World Graph is a real Workshop tool whose overview is a world block:
<BlockSlot type="graph-overview" /> puts the whole link plane of the world on your surface
— nothing to import, no document to supply. It's world-gated (only appears where the world
installed the tool) and survives publishing (a published edition bakes its own frozen link
plane).
Collaborative text
A field.value string is last-write-wins — fine for a title, a disaster for a paragraph two
people are editing. field.prose() merges per character:
export const codec = defineStateCodec({
title: field.value("House rules"),
rules: field.prose(), // rich text
})A prose field reads back as an opaque token, not a string — no .length, no
actions.set. Hand the token, plus the document handle, to <CollaborativeText>:
const { data, handle, status, retry } = useDocument(coords, codec)
<CollaborativeText
handle={handle}
field={data.rules}
editable={context.canEdit}
placeholder="Start typing…"
/>Bold, italics, headings, lists, links, code, and live carets when two people are in the
field at once — all come with it. No editor to build, no onChange, no debounce, no save.
The mistakes that bite
Pass handle, not data. The handle connects the component to the open document;
passing the wrong thing looks like an editor that renders but never syncs. editable is
yours to set — the component doesn't read host permissions. extensions must be a
stable reference (module constant or memoised) — a fresh array every render rebuilds the
editor and drops the caret mid-word.
A prose field has no history diff and no agent operations — rich text has no useful data
description, so it's skipped by the index and by auto-generated agent CRUD (see
Making it agentable). Keep anything about it that needs to be
queryable as a field.value beside the prose. Multiple prose fields on one codec merge
independently — two people typing in rules and notes never contend.
Host capabilities
Three kinds, and the distinction that trips everybody up:
| Kind | How you get it | Which ones |
|---|---|---|
| Baseline | Every mount has it; list it in needs anyway so a host that somehow lacks it fails loud and named. | nav · media · refs · types · embeds · server |
| Grant-gated | The world consents at install (vvd.json → capabilities), and you list it in needs. | world · search (from readsWorld) · projects · documents (from writesWorld) · publish · access · sitePublish (from sharing) |
| Inherited | Free, and never listed in needs. Degrades quietly under a host that omits it. | icons · audio · menus · dnd · theme · defaults · undo · reactions · analytics · tutorial · feedback |
"Degrades quietly" is the third row's whole point: icons fall back to a neutral glyph, sound goes silent, a right-click hands the event back so the browser's own menu appears. A published read-only page genuinely has no audio engine and your tool renders correctly anyway.
Icons — <HostIcon> renders any id the icon engine resolves: canonical ("tabler:sword"),
a bare legacy name ("map-pin"), or an uploaded image. Pass fallback when the id is data
rather than a literal, so a stale value shows your semantic default:
<HostIcon icon={card.icon} fallback="tabler:user" size={16} />useIcons() searches across every registered pack for a picker of your own.
Sound — sources are refs, not URLs; the host resolves them through its own
pipelines. A published tool that tries { kind: "url" } fails loud at the boundary.
const clack = useSound({ kind: "asset", app: "dice-tray", slot: "clack" }) // sfx bus, mixes
clack.play()
const player = useAudioPlayer({ kind: "media", id: trackMediaId }) // music bus, exclusive focusBuses: music · ambience · sfx · voice · ui (the last two never ducked). Focus:
mix (coexists) · duck (dims other buses) · exclusive (one global slot — pressing play
is "pause whatever was playing"). Fades are graph ramps: play({ fadeInMs }),
stop({ fadeOutMs }). Never build an "enable sound" button — a play() inside a user
gesture unlocks the engine and sounds on that same press; an autoplay attempt on mount drops.
Right-click menus — one layer renders every menu, so your menu is pure data:
const { openContextMenu } = useContextMenu()
const items: ContextMenuEntry[] = [
{ kind: "item", id: "reroll", label: "Reroll", icon: "dice-6", onSelect: reroll },
{ kind: "separator" },
{ kind: "item", id: "clear", label: "Clear tray", variant: "destructive", onSelect: clear },
]
<div onContextMenu={(e) => openContextMenu(e, items, { context: { toolId: "dice-tray" } })} />Entry kinds: item, submenu (nests), separator, label. openContextMenu returns
false — without touching the event — under a host with no menu layer, so the browser's
own menu opens instead of nothing happening.
Remembering how a world likes your tool — one opaque JSON blob per world, tool and key:
const defaults = useToolDefaults("dice-tray", "settings")
// defaults.status: "unavailable" | "loading" | "ready"
// defaults.value: the saved blob — validate it yourself
// defaults.save(v): replace it (null clears); optimistic, live to everyoneThe platform stores bytes; you own the schema. Older tool versions wrote into the same store, so validate on read and tolerate garbage.
Wearing the user's theme:
const { mode, tokens } = useHostTheme()
const accent = tokens["--primary"] ?? "#9aa4b2" // always ship a fallbackPick your own palette as the default; offer a "wear the user's theme" look derived from
tokens. Never ship your own Customize or Publish button — useAppCustomization().customizing
is your cue that the host's panel is open.
Analytics — inherited, optional, and must never break a render:
host.analytics?.track("rolled", { sides: 20 })
host.analytics?.track("dice_rolled", {}, count) // numeric 3rd arg SUMS into a running totalScalar props only, low-cardinality (a die size, not an id or email) — the host stamps which tool, version, world and scope it came from.
Testing all of it — createFakeHost() is what runs every example on this site: a
complete host of inert fakes, no network. Swap in a recording fake to assert your tool
asked for something: createRecordingMenus, createRecordingAudio, createRecordingIcons,
createRecordingNav, createRecordingDnd, createRecordingDefaults, createRecordingTheme,
createRecordingAnalytics, and friends.
const { menus, commands } = createRecordingMenus()
// render under createFakeHost({ menus }), fire a contextmenu event, then:
expect(commands[0].request.items.map((i) => i.id)).toContain("reroll")Linking to other documents
Three moments, each with exactly one right answer.
Someone clicks a link in your tool — they mean "show me that, I'm still here", not "take me away":
const { nav, refs } = useHost()
onNodeClick={(id) =>
peekOrOpenDocument(nav, refs.coordsFor(id) ?? { worldId: document.worldId, documentId: id })
}Your tool states the intent; the host picks the placement (a floating peek panel, or a
side-by-side column where there's room). Read-only hosts degrade to openDocument — that's
why you call the helper rather than nav.peekDocument directly. Use plain
nav.openDocument only when the click really is a departure (a sidebar row, a "go to"
button). Contract-test with createRecordingNav() / createRecordingNav({ peeks: false }).
Someone drags a document onto your tool:
const drop = useDocDropTarget({
zone: `tray:${document.id}`,
preview: "chip", // closed vocabulary: "chip" | "map-pin" | "canvas-node" | "tile" | "event" | "person"
accepts: (p) => p.documentType === "card",
onDrop: (p, point) => actions.addCombatant({ documentId: p.documentId, at: point }),
})
return <div ref={drop.ref} {...drop.props}>…</div>Attach both ref (pointer-driven drags, the only input on an iPad) and props (native
HTML5 dragenter/over/leave/drop) to the same element. The payload is deliberately thin —
documentId, documentType, name — resolve avatars/types at drop time with
host.refs.meta([id]). Never mix drag channels: native HTML5 is for cross-surface document
drops, pointer libraries (dnd-kit) are for gestures inside your own tool. Contract-test with
createRecordingDnd() and createDocDataTransfer(payload).
Entity suggestions — turn names in your prose into world documents, for one line of JSX and no AI budget of your own:
const { suggestions, dismiss, dismissAll } = useEntitySuggestions(handle)Detection runs server-side on save and writes results into the document itself — shared, persisted, offline-safe, free to read. Dismissals write to the document too.
A fixed-name prose field is invisible to the detector by default
Detection finds prose through a lookup table it cannot import your codec to build. A
dynamically-named fragment (a card's text blocks) is found automatically; a fixed-name one
(field.prose() called rules) is not, until it's added to that table. The failure is
silent and total — no suggestions, no mention linking. Ask before you ship if your prose
needs this.
Under a host with no detection (published pages, tests, createFakeHost()), the hook
returns nothing — same degrade-to-nothing contract as everything inherited.
Server endpoints
An api/ folder turns on a second build; each file is one endpoint, and the file path is
the route.
dice-tray/
├── src/tool.tsx
└── api/
├── roll-remote.ts → roll-remote
└── webhooks/stripe.ts → webhooks/stripeimport { defineHandler } from "@vvd/sdk/server"
export const config = {
secrets: ["DICE_API_KEY"], // UPPER_SNAKE_CASE
fetch: ["api.random.org"], // outbound allowlist — exact hostnames, no wildcards
}
export default defineHandler(async (ctx, { sides }: { sides: number }) => {
const key = await ctx.secrets.get("DICE_API_KEY")
const res = await ctx.fetch(`https://api.random.org/roll?d=${sides}`, {
headers: { authorization: `Bearer ${key}` },
})
const { value } = await res.json()
return { value }
})config is the enforcement surface, not documentation: ctx.secrets.get resolves only
declared names, ctx.fetch reaches only declared hostnames. Undeclared means unreachable.
Call your own endpoints as typed functions — no fetch, no JSON plumbing, no tool id (the
host binds the mounted tool's id, so you physically cannot call another tool's endpoints):
const api = useApi<DiceApi>()
const { value } = await api.rollRemote({ sides: 20 })rollRemote → api/roll-remote.ts (camelCase to kebab-case). Derive the interface from the
handler with a type-only import (erased at compile time, no server code in the bundle):
import type rollRemote from "./api/roll-remote"
type ApiCall<H> = H extends (ctx: never, args: infer A) => Promise<infer R> ? (args: A) => Promise<R> : never
export interface DiceApi { rollRemote: ApiCall<typeof rollRemote> }Failures reject as WorkshopApiError (code, status, details) — a throw inside your
handler is sanitised, the real error goes to the server log, never the browser. A host
without server (published page, anonymous reader, a test) fails loud at the boundary;
gate the affordance on host.server rather than declaring it in needs.
Secrets are write-only after vvd secret set NAME value --scope dev|published — only
ctx.secrets.get inside your own declared handler ever reads one back. A declared-but-unset
name fails loudly and tells you to set it.
Raw handlers opt into the exact Next.js shape — signature verification, streaming:
export const config = { raw: true, secrets: ["STRIPE_WEBHOOK_SECRET"] }
export async function POST(req: Request, ctx: ServerContext) {
const body = await req.text() // signature verification needs the raw bytes
return Response.json({ received: true })
}Raw handlers get the untouched Request, may export POST/GET, and are HTTP-only —
useApi has no typed caller for them.
ctx gives you:
ctx.* | What it is |
|---|---|
secrets.get(name) | The decrypted value of a declared secret — this tool's only |
fetch(url, init) | Outbound HTTP(S), allowlisted to your declared hosts |
identity | { userId, kind } — the resolved principal, never a raw token |
scope | The world (and optionally project) the call is scoped to |
log(…) | Structured logs, fire-and-forget, tagged with your tool |
index.generate(req) | The World Index — structure out of content, on the platform's budget |
ctx.index pulls structure from content with no provider key and no bill: declare a
purpose and a schema, get back parsed and validated data.
export const config = { index: ["draft"] }
export default defineHandler(async (ctx, args: { notes: string }) => {
const { data } = await ctx.index.generate<{ sections: { title: string; body: string }[] }>({
purpose: "draft", // NOT a model name — the platform picks
schema: { type: "object", fields: { sections: { type: "array", maxItems: 5, of: {
type: "object", fields: {
title: { type: "string", maxLength: 100, describe: "One line, under 60 characters." },
body: { type: "string", maxLength: 2000 },
},
} } } },
input: { notes: args.notes }, // the MATERIAL
context: { subject: "Mira" }, // FRAMING — never becomes a section
guidance: "This is a TTRPG bestiary.", // a domain hint, no instructional force
})
return data
})describe steers the model. guidance does not.
guidance reaches the model as fenced data, capped at 500 characters, with no
instructional force by design. A field's describe (240 chars/field) is part of the
declared shape and does carry force. "This is a bestiary" is guidance; "one line, under
60 characters" is title's describe. Swapping them is the most common way one of these
calls comes back subtly wrong.
There is deliberately no ctx.index.complete(prompt) — free-text generation on a shared key
is an open proxy. An empty answer is a real answer: model an optional result as an array
that can come back empty rather than adding a "none" value. Failures are WorldIndexErrors
with a branchable code (quota_exceeded, disabled, invalid_response).
What server code can import — bundle your own pure-JS dependencies, externalise the
platform's. The runtime is isolate-shaped: Web-standard APIs (fetch, Request/Response,
crypto.subtle, URL) plus ctx. Node built-ins (node:fs, node:crypto) are a build
error, not a production stack trace.
Testing:
import { createFakeServerContext } from "@vvd/sdk/server"
const ctx = createFakeServerContext({ secretValues: { DICE_API_KEY: "k-123" } })
const { value } = await handler(ctx, { sides: 20 })
expect(ctx.fetch.calls[0].url).toContain("api.random.org")createFakeServerContext covers ctx.index too via indexResponse/indexRequests, so a
fixture that doesn't fit your schema fails in your test rather than in production.
The trust boundary, honestly: your own vvd run session runs your server code
in-process — your code, your account, nothing to isolate from. A published third-party
bundle runs sandboxed, revealing only that tool's own secrets, reaching only the hosts it
declared. Either way the same defineHandler runs unchanged.
Making it agentable
Reads need nothing from you — one generic endpoint decodes any tool's document through its codec, and free-text search harvests plain text from the snapshot. Writes are a declared allowlist, not an open door.
If your tool declares state as a defineStateCodec shape, operations are derived —
declare a field, get operations, no per-operation code:
export default defineStateCodec({
title: field.value("Untitled quest", z.string()),
objectives: field.list<Objective>([], z.object({
text: z.string().min(1).describe("What must be done."),
done: z.boolean(),
})),
rewards: field.map<number>(z.number()),
briefing: field.prose(),
})| Field kind | Operations generated |
|---|---|
field.value | setTitle |
field.list | addToObjectives · updateInObjectives · removeFromObjectives |
field.map | setInRewards · removeFromRewards |
field.prose | none — rich text has no useful data description |
The runtime schema (Zod, second argument to each field) is optional but makes a generated
operation's input precise in the API reference and the description an agent reads. Write
.describe() strings — they travel all the way to the agent's decision of whether to call
your operation; say what the field is for, not its type.
Point your manifest at the codec, and there's nothing else to do:
{ "id": "quest-log", "kind": "tool", "entryModule": "@/tool", "codecModule": "@/codec" }vvd save imports it, serialises the shape onto the published manifest, and prints what it
derived (derived from @/codec: 4 field(s) → agent · 2 → index). Add a field, run vvd save: it has operations — no second place to update.
codecModule must resolve to a pure module
No views, no React, no "use client" — the build imports it directly. If it can't,
vvd save warns and falls back silently to whatever vvd.json declares by hand; it
never fails your save, which means this is a warning you have to actually read.
Why is the manifest derived instead of written by hand?Deep dive
The published manifest has to be pure data — the Workshop lists your tool without loading its bundle, the API reference generates OpenAPI from it, an install prompt computes a consent surface from it, none of them can run your code. So there are necessarily two statements of your data model, and the only question is whether you keep them in agreement or the build does.
Done by hand, the failure is silent: you add a field, ship, it works perfectly in your UI —
and an agent is simply never told it exists. Nothing errors, nobody files a bug, because
from the outside your tool just doesn't do that. vvd save executing codecModule against
a data-only stub removes that failure mode entirely, and prints what it found so you can see
it happen.
Semantic operations — for meaning a shape can't express ("advance the initiative order"), declared on the codec:
import { AgentActionError, defineDocumentCodec } from "@vvd/sdk/document/codec"
export const trayCodec = defineDocumentCodec({
agentSummary: "A dice tray: a die size and the log of rolls made at this table.",
agentActions: {
reroll: {
describe: "Reroll the most recent die in the tray, keeping the same die size.",
input: z.object({ reason: z.string().optional().describe("Why — recorded in the log.") }),
apply: (doc, input) => {
const before = trayCodec.read(doc)
if (before.rolls.length === 0) {
throw new AgentActionError("NOT_FOUND", "The tray is empty — roll something first.")
}
trayCodec.actions!(doc).reroll()
return { changed: [{ id: "rolls", label: "last roll" }] }
},
},
},
})Four fields make an AgentAction: describe (the one-line description an agent reads),
input (a runtime schema — its properties become arguments), apply(doc, input)
(mutate and return { changed }, delegating to your own codec actions — never
reimplemented), and optional title. agentSummary is the one-line "what is this
document type" an agent sees in discovery — worth a minute of thought.
Throw AgentActionError with "NOT_FOUND" / "INVALID_BODY" even when a helper would
happily no-op — a silent no-op reports false success, and an agent confidently moves on.
Name what does exist: `No clip "${clipId}" (available: ${clips.map(c => c.id).join(", ")})`.
Server-safety is the one real constraint — apply runs in a server route, so import the
pure entry, never the barrel:
import { defineDocumentCodec } from "@vvd/sdk/document/codec" // not "@vvd/sdk"Rule of thumb: your codec is server-safe if a plain Node import pulls in no React and no DOM. A codec that reaches for a rich-text editor isn't — its operations belong in a small server-safe sidecar module.
What an agent calls:
GET /api/v1/document-types?worldId=… # discover
GET /api/v1/document-types/quest-log
POST /api/v1/worlds/:worldId/documents/:documentId/content # act — one endpoint, every tool
{ "ops": [
{ "op": "quest-log.setTitle", "value": "The Salt Road" },
{ "op": "quest-log.addToObjectives", "item": { "text": "Find the caravan", "done": false } }
] }Operations apply in order over one document, persist once, and reconcile any open collaborative session. The whole batch validates before any operation runs. Every operation must belong to that document's type. Resolution is per world, through the install's version pin — publishing a new version can't change a world's API surface underneath it.
Import comes free from the same declaration — no importer to write:
POST /api/v1/worlds/:worldId/documents/:documentId/import
{ "text": "The Sundering split the realm in 412…" }One honest difference from a normal write: a model's output is proposed, not trusted —
each operation validates on its own, and invalid ones are dropped and reported rather than
failing the whole batch. If your operations link to another document, the platform resolves
entity names for you on the import path — an agent offers linkedEntity: "Aria of the North" and the platform turns it into the real id before your operation runs.
Testing — derived operations delegate to your codec's own actions, so testing the actions tests the operations:
const doc = new Y.Doc()
questCodec.actions!(doc).list("objectives").push({ text: "Find the caravan", done: false })
expect(questCodec.read(doc).objectives[0].text).toBe("Find the caravan")Declared operations you call directly, asserting the real codec reads the result, plus
the invariant: expect(() => trayCodec.agentActions!.reroll.apply(new Y.Doc(), {})).toThrow(AgentActionError).
The end-to-end check: GET /api/v1/document-types/<your-type> should list your operations
with the schemas you expect, and a content call should round-trip.
Search and the index
Any document type your tool declares is searchable the moment it exists — grouped under
your tool's name and icon, titles matched instantly client-side, aliases and body content in
a second tier, no work at all. Name the group in vvd.json if you don't want your tool's
own name on it:
{ "search": { "documentTypes": ["recipe"], "group": { "name": "Recipes", "icon": "chef-hat" } } }Search finds documents by text. The index finds them by structure — "every event between these two dates" — declared as pure data:
defineTool({
id: "timeline",
documentTypes: ["timeline"],
index: {
version: 1,
fields: { eventDates: { path: "$.events[*].dateMs", type: "number[]", mode: "all" } },
refs: [{ kind: "linked-card", path: "$.events[*].linkedCardId" }],
},
render: TimelineView,
})The platform extracts those selectors on every save, whoever wrote the document —
nothing to keep in sync. (vvd save also derives an index from a defineStateCodec
shape; a hand-written index always wins, since it's the more specific statement.)
fields become projection columns you query live — mode: "all" matches when some
element of an array satisfies the condition (mode: "first", the default, stores only the
first match):
const rows = useWorldQuery("index", { type: "timeline", where: { eventDates: { gte: 0, lte: 1000 } } })The same grammar drives POST /api/v1/worlds/:worldId/documents/query, so an agent filters
your documents exactly as your UI does. refs says "the ids at this path are references" —
results are scanned for UUIDs and become (kind, targetId) edges, answering "where does
this character appear?" for free via GET /api/v1/worlds/:worldId/documents/:documentId/links.
Paths are Postgres jsonpath in lax mode, restricted: $ · .name (arrays auto-unwrap) ·
[*] · .** (self + descendants) · ? (@.kind == "battle") · ? (@ != null). Anything
else is rejected at validation — the platform extracts in SQL, the SDK evaluates the same
paths in JS, and the small subset is what guarantees they can't quietly disagree. Caps: 32
fields, 16 refs, 2 .** paths, 512 characters per path.
Document metadata is invisible to the index
A document's name, and whether it's viewable, never reach the projected content — a selector pointing at them silently extracts nothing. Keep the title as a field in your own codec instead, which most tools do anyway.
Test with evaluateToolIndex(spec, fixtureJson) (assert exactly what selectors extract), and
indexRowsFromDocs(spec, docs) to seed a fake host's index with rows the real extraction
produced, so a useWorldQuery("index", …) component is tested against honest data.
Guided tours
A tour is pure data — no components, no handlers, no observers — which is what lets a tool someone else installed ship one safely.
<button data-tutorial="dice-roll" onClick={roll}>Roll</button>export default defineTool({
id: "dice-tray",
documentTypes: ["dice-tray"],
render: DiceTray,
tours: [{
id: "dice:intro", // prefix with your tool id — the registry is global, first-wins
once: true, // auto-offered at most once; dismissal is remembered
steps: [
{ id: "dice-meet", target: "dice-tray", title: "A tool you installed", body: "…" },
{
id: "dice-roll", target: "dice-roll", title: "Roll the d20", body: "…",
advanceOn: { type: "event", name: "dice:rolled" }, // no Next button — advance on outcome
},
],
}],
})advanceOn mode | Advances when | Use it for |
|---|---|---|
manual (default) | User presses Next | Orientation beats — "this is the tray" |
{ type: "element-appears", target } | That element exists and is visible | "Roll a die" — the result appearing is the proof |
{ type: "event", name } | Your code dispatches that window CustomEvent | Outcomes with no stable DOM footprint |
The platform makes the first offer — you don't trigger it
Any editor panel whose tool ships a tour gets a "Tutorial" chip during a user's first few
uses. Call host.tutorial.show("dice-meet") only for an explicit ask (a Help button)
— never from a first-open effect. That's a second offer channel with different memory
rules, and a user who quits halfway gets nagged twice.
You inherit spotlight/dim overlay/coachmark panel in the platform's design system, wait-for-
target (the step shows when your data-tutorial anchor appears in the DOM; if it never does,
the step is skipped, not stuck), one-tour-at-a-time, and per-user progress with no table
of your own.
host.tutorial.isCompleted("dice:first-roll") // sync — true means completed OR dismissed
host.tutorial.complete("dice:first-roll")Namespace your own ids ("dice:…"); hint: is reserved for the platform's own panel hint.
Write two or three steps, not eight — a tour is an introduction, not a manual. Aim the first
step at something already on screen.