Skip to content
Reference— browse docs
On this page

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,
})
FieldWhat it does
needsHost 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).
SkeletonThe 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.
panelTitleDefault 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.
toursA guided walkthrough the host plays. See Guided tours.
indexWhat's queryable about your documents. See Search and the index.
codec / codecModuleYour 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
canEditMay 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.
scopeThe world (and optionally project) this mount is scoped to.
Danger:

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.viewWhere
"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.

src/blocks.tsx
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.data lives in the host document's section — not a document of your own. A block doesn't need a document at all.
  • onUpdate patches block.data through the owning document's codec; a block never opens its own connection.
  • needs is declared per block, separately from your tool's. A missing one renders a named "missing capability" boundary in the block's place, not an undefined deref.
  • addable: false removes 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:

CaseBehavior
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 slotFails loudly — that one is a mistake, not a configuration.
readsWorld not granteduseWorldQuery 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.

Warning:

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:

KindHow you get itWhich ones
BaselineEvery 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-gatedThe 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)
InheritedFree, 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 focus

Buses: 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 everyone

The 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 fallback

Pick 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 total

Scalar 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.

Danger:

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/stripe
api/roll-remote.ts
import { 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:

api/webhooks/stripe.ts
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
scopeThe 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.

api/draft.ts
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
})
Warning:

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:

src/codec.ts
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 kindOperations generated
field.valuesetTitle
field.listaddToObjectives · updateInObjectives · removeFromObjectives
field.mapsetInRewards · removeFromRewards
field.prosenone — 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:

vvd.json
{ "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.

Warning:

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:

vvd.json
{ "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.

Warning:

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 modeAdvances whenUse it for
manual (default)User presses NextOrientation 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 CustomEventOutcomes with no stable DOM footprint
Danger:

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.

Next steps