Skip to content
Reference— browse docs
On this page

Reference

The whole truth about one thing — host capability tables, ctx reads, every error message.

The full detail behind Build a themed wiki — not the path through, the place you come back to when you want everything about one part of it. If you haven't built one yet, start there; every section below assumes the build-along's shape (src/app.tsx, src/publish-hooks.ts) and links back to the step that introduced it.

The manifest, in full

vvd.json
{
  "id": "chronicle",
  "name": "Chronicle",
  "kind": "app",
  "version": "0.1.0",
  "icon": "book-open",
  "route": "chronicle",
  "category": "site",
  "publish": "custom",
  "customization": [ /* serialized from defineApp's customization: [THEME_PARAM, WIKI_HEADING_FONT_PARAM, WIKI_BODY_FONT_PARAM] — see Themes and fonts */ ],
  "needs": [],
  "capabilities": { "readsWorld": true, "writesWorld": false, "sharing": true },
  "entryModule": "@/app"
}
FieldWhat it is
idFixed forever — the platform keys routes and installs on it.
kind: "app"An app provides a host and owns a space, where a tool consumes one and edits a document. A wiki is a lens over the whole world, so it's an app.
category: "site"Classes the app as a wiki template. Once installed it appears in that world's template switcher, and the platform mounts it in the wiki chrome.
publish: "custom"Declares that your bundle exports its own bake + renderBaked. Without it the platform substitutes its own surface for yours — your colours ship, your layout doesn't.
capabilities.sharingGrants sitePublish — the capability that lets the wiki publish its own public address.
capabilities.readsWorldGrants world + search. Without it: Host capability "world" is not provided by this host. — loud and named, never a silently empty page.
writesWorld: falseStays false for a wiki. A site reads the world; it has no business creating documents in it.

Edit these with the CLI rather than by hand: vvd set name "The Chronicle", and vvd info reads the whole manifest back.

Danger:

Keep vvd.json and defineApp in sync

A manifest that claims "publish": "custom" while the bundle exports no bake/renderBaked fails publishing outright with custom_bake_required. Deleting the hooks and leaving the manifest alone is the usual way people hit this — see Publish errors.

Two lives, one rule

A wiki renders in two places, and the same React component does both: live, inside the app, reading the world in real time and taking edits when context.canEdit; published, at a public address, reading one frozen edition with no sockets, no auth, no database.

Danger:

The published half must not touch live state

Anything both lives render must never call useCollabState or useAppCustomization directly — a published page has no collab document and no Customize panel. Pass those values in as props from the live mount, and read them from the frozen edition on the published side. The scaffold is already shaped this way; keep it that way and the rule costs you nothing.

Where your wiki shows up

When installed, a wiki does not get its own tile in the launcher — every site-category app collapses into the one Wiki tile, because they're all templates for the same thing. Find yours by opening the wiki and switching the template in the design rail.

All site-category apps in a world also share one shared-state room, app-state:<world>:wiki — not one named after your app. Switch from the built-in Default template to yours and the page keeps its title and its featured entries; your field names live in that shared namespace on purpose.

Reading the world

useWorldQuery("documents", { type?: string }) is live — the component re-renders when the world's catalog changes on any client. Each row:

FieldTypeWhat it is
idstringThe document id — what you pass to HostEmbed and nav.
namestringAlready defaulted — never empty, never null.
slugstring | nullThe URL-safe name. Falls back to id when absent.
documentTypestring"card", "map", "note", or a third-party tool's own type.
entityTypeIdstring | nullWhich entity type a card is (Character, Location…).
avatarMediaIdstring | nullA media id — never a URL. See Images.
aliasesreadonly string[]Other names this document goes by — search on them too.
isViewablebooleanThe author's "readers may see this" flag. !== false, always — see What actually ships.

useWorldMeta() gives you the world header — the natural default for your site's title:

const meta = useWorldMeta()
const title = page.title.trim() || (meta ? meta.name : "Chronicle")

It carries id, name, slug, description, avatarMediaId, and optionally creator (a display-ready { name, avatarUrl }) and genres. Returns null under a host with no world identity — guard it.

Images

A media id is not a URL, and you must never build one. The bytes might be public, private, world-owned, another world's, or platform stock — and on a published page they've been rewritten entirely. Only the host knows.

const media = useHostCapability("media")

const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
  if (!mediaId) { setUrl(null); return }
  let on = true
  setUrl(media.placeholder(mediaId))
  media.resolve(mediaId).then((u) => { if (on && u) setUrl(u) })
  return () => { on = false }
}, [mediaId, media])
  • placeholder(id) is synchronous and render-safe — paint it immediately.
  • resolve(id) is the truth, async — overwrite with it when it lands.

On a published page placeholder already returns the final URL, so nothing flashes a fallback — call both in that order regardless.

Danger:

The classic bug

Building images/<worldId>/<mediaId>.png by hand works in the one world you developed against and produces broken images (and a wrong extension for stock art) in every other one. This has shipped for real, more than once. Ask the host.

Customization vs. page state

CustomizationPage state
What it isTheme and font picksTitle, subtitle, banner, featured entries, section order
Who defines the optionsYou, as data in vvd.jsonYou, as a collab shape in code
Who renders the UIThe platform's Customize panelYour page, inline, in edit mode
How it publishesFrozen into the edition's settingsFrozen into the edition's extra.page
Warning:

Your app ships no Customize button

The platform mounts a site-category app in the wiki chrome: design controls in a rail beside the page, Publish top-right. Building your own customize or publish button gives the owner two controls that disagree. Read customizing; don't render a pill.

Themes and fonts

useAppCustomization() — live only:

valuesThe raw saved map. Hand this straight to your published render — it's what an edition freezes.
choice(param)The effective choice id — the saved one when valid, the param's default otherwise.
custom(param)The active hand-rolled palette for a colors param, or null when a preset is selected.
setChoice(param, id)Save a pick. Merges — never clobbers a sibling param.
customizingIs the host's Customize panel open right now?

resolveCustomizationChoice(param, values) is a pure function you can also call on the published side, where there's no hook to call.

A theme is a palette plus two hints — background / primary / secondary are always present, foreground / line / font aren't, so a template-side reader gives every one of them its own fallback:

src/app.tsx
function themeFromPreset(p: { id: string; name: string; values: Record<string, string> }): ThemeSpec {
  if (p.id === WIKI_TEMPLATE_DEFAULT_ID) return TEMPLATE_DEFAULT
  const v = p.values
  return {
    name: p.name,
    bg: v.background || "#f4eedd",
    ink: v.foreground || "#1c1b18",
    card: v.secondary || v.background || "#fffdf4",
    line: v.line || "rgba(127,127,127,0.3)",
    accent: v.primary || "#8a5a2b",
    serif: v.font === "serif",
  }
}

"world" — the reader's own look, derived live:

import { useHostTheme, WIKI_WORLD_THEME_ID } from "@vvd/sdk"

const hostTheme = useHostTheme()   // { mode: "light" | "dark", tokens: Record<string, string> }
Warning:

useHostTheme goes quiet on a published page

A published third-party wiki runs sandboxed behind a brokered host, and theme isn't among the namespaces that host serves — useHostTheme() returns the platform default there, so a "Your World" wiki that looks right in the app publishes dark and untinted. The portable source is the world header instead, which carries the same tokens on both sides:

const meta = useWorldMeta()
const tokens = meta?.theme ?? {}   // "--background", "--foreground", …

WIKI_THEME_PARAM sets allowCustom: true with five editable tokens (Background, Accent, Secondary, Text, Border) — the platform's picker lets an owner roll their own and save it to a reusable library. If your themeFromPreset reads a key the custom editor doesn't offer, that key comes back undefined — which is exactly why every read above has a fallback.

Fonts — WIKI_HEADING_FONT_PARAM and WIKI_BODY_FONT_PARAM share one list of thirteen options; each is { id, name, family, source: "google" | "system" }. Loading a Google font is your job, deduped by id:

src/app.tsx
function ensureWikiFont(name: string, source?: string) {
  if (source !== "google" || typeof document === "undefined") return
  const id = "wiki-font-" + name.replace(/ /g, "-").toLowerCase()
  if (document.getElementById(id)) return
  const link = document.createElement("link")
  link.id = id
  link.rel = "stylesheet"
  link.href = "https://fonts.googleapis.com/css2?family=" + name.replace(/ /g, "+") + ":wght@400;500;600;700&display=swap"
  document.head.appendChild(link)
}

The typeof document === "undefined" guard matters: a published page renders in a sandboxed frame, and this should be a no-op there, never a crash.

Warning:

You can't build on the built-in templates' internals

Default and Overworld — the two templates vvd ships next to yours in the switcher — are first-party apps on packages (@vvd/site-sdk, @vvd/site-theme-default, @vvd/site-theme-overworld) that are not part of the developer surface. They aren't injected into your bundle and importing one fails the build. Your toolkit is @vvd/sdk — everything on this page comes from it.

Section order

src/app.tsx
const DEFAULT_SECTIONS = ["featured", "entries", "extras"]
const order = page.sectionOrder.length > 0 ? page.sectionOrder : DEFAULT_SECTIONS

An empty stored list means "the default order" — so a fourth section added later lands in the right place for everyone who never touched the order. The first reorder has to seed the list before it can move within it:

src/app.tsx
moveSection: (key, dir) => {
  const cur = page.sectionOrder.length > 0 ? [...page.sectionOrder] : [...DEFAULT_SECTIONS]
  const from = cur.indexOf(key)
  const to = from + dir
  if (from < 0 || to < 0 || to >= cur.length) return
  if (page.sectionOrder.length > 0) {
    actions.list("sectionOrder").move(from, to)
  } else {
    cur.splice(to, 0, cur.splice(from, 1)[0])
    actions.list("sectionOrder").push(...cur)
  }
}

The URL model

A world has one address. Published sites hang off it:

URLWhat it resolves to
/The root instance — the site pointed at the domain root
/<a>An instance mounted at a, if one exists; otherwise the root instance's entry a
/<a>/<b>Instance a, entry b
anything deeperNothing — a 404

The middle row's ambiguity is resolved by looking: the platform asks whether a mount named a exists and falls back to treating it as an entry slug — an entry called atlas and a mount called atlas collide, and the mount wins. Path mounts exist in the data model; the in-app control for choosing one isn't exposed yet, so today a wiki is either the root instance or it isn't.

These first segments belong to the platform and never reach a published site:

api  _next  c  t  auth  login  worlds  site  sandbox  onboarding  cli
favicon.ico  robots.txt  sitemap.xml

There's also /u/<token>, used by the unlisted audience.

basePrefix — handed to renderBaked — is the link prefix for this mount ("" at a subdomain root, /site/<slug> under a path). You need it only for a non-document href; for documents, nav.hrefFor has already applied it.

A search box costs you nothing

The rows you already have carry name and aliases, so filtering in memory gives you a real search with no capability, no index and no request:

const entries = useMemo(() => {
  const q = query.trim().toLowerCase()
  if (!q) return cards
  return cards.filter(
    (d) => (d.name || "").toLowerCase().includes(q) || (d.aliases || []).some((a) => a.toLowerCase().includes(q)),
  )
}, [cards, query])

Search the aliases too — a character called "Sera Vale" who everyone calls "the Grey" should be findable as either.

Publishing — the full ctx

ReadWhat you get
ctx.world()The world header + its theme config, as of now
ctx.documents()Every shippable row — the same slim shape useWorldQuery serves
ctx.documentContent(id)One document's projected content, or null for anything documents() didn't list
ctx.entityTypes()The full type taxonomy, including gate-disabled types
ctx.mediaUrls()Every referenced media id → its final public URL
ctx.appState?.()The instance's live page state, as the publishing surface handed it over
ctx.projectDocuments?.()The instance's own project-scoped documents
ctx.worldGraph?.()The world's link plane, if you want to ship it
ctx.eraIdWhich era this edition views the world through — documents()/documentContent() already serve that era's effective view

The last three are optional and additive — call them defensively (ctx.appState ? ctx.appState() : Promise.resolve(undefined)) so an older platform doesn't break your publish.

What you return — every key optional, bake only what your editions need:

return {
  index: docs,        // the frozen catalog — serve-time entry resolution reads it
  refs,                // documentId → { id, name, slug, type, avatarMediaId, entityTypeId }
  routes,              // documentId → "/<slug>" — the platform prefixes the mount
  mediaUrls,           // mediaId → final public URL
  entityTypes,
  documents,           // documentId → projected content
  extra: { worldName: world.name, page: appState ?? null },  // yours; nothing else reads it
}

Fetch contents in batches — both ways of getting this wrong are painful: one at a time turns a 200-card world into a multi-minute publish, and firing them all at once floods the host:

src/publish-hooks.ts
const BATCH = 24
for (let i = 0; i < docs.length; i += BATCH) {
  const slice = docs.slice(i, i + BATCH)
  const contents = await Promise.all(slice.map((d) => ctx.documentContent(d.id).catch(() => null)))
  slice.forEach((d, j) => { if (contents[j] != null) documents[d.id] = contents[j] })
}

Note the .catch(() => null) — one unreadable document should cost you that document, not the whole publish.

The published host

AvailableGone
world · media · refs · types · nav · embedscollab · search · presence
identity (always canEdit() === false, me === null)projects · publish · sitePublish · install · server
audio · menus · icons · scopetheme · reactions · media.pick and every other write

Anything not on the left resolves to nothing rather than throwing: useHostTheme() returns the platform default, HostEmbed still renders (embeds is there), media.pick is undefined. Props your renderBaked gets:

Prop
snapshotYour bake result, reassembled. unknown at the seam — narrow it yourself.
settingsThe frozen customization values (theme, fonts).
entryPathThe document id the request resolved to, or null for the home page. Always resolved: a segment the platform can't match (slug, "name-slug" form, or raw id) 404s before your renderer runs, so you never see a raw URL segment.
basePrefixThis mount's link prefix.

Versions and audiences

Every publish appends an immutable version; nothing is overwritten or deleted. You can point serving back at an earlier one. Four audiences, enforced at serve time:

AudienceWho gets in
publicAnyone with the address. The default.
unlistedAnyone with the capability URL — <address>/u/<token>. The plain address doesn't serve it.
passwordAnyone with the password. Must be set before publishing at this audience.
entitledPeople the creator granted access to, by email.

The four verbs

What it does
vvd saveUploads an immutable version — a private draft. In a solo world it also installs itself there.
vvd sharePoints your world/team at your latest save. Needed once the world has other members.
vvd publishSubmits your creation to the Workshop, so other people can install it. Reviewed by a person.
Publish (in the app)Puts this world's wiki on the public internet.

vvd publish ships the wiki template. The Publish button ships the site. You can do either without the other. Ship it covers the first three; they behave identically for a tool, an app and a wiki.

Publish errors

What you seeWhat it means
custom_bake_requiredvvd.json says "publish": "custom" but the bundle exports no bake/renderBaked. Usually a manifest edited ahead of the code.
The site renders in a look that isn't yoursYour app reached the platform's stand-in surface. Check "publish": "custom" and defineApp({ publish: { bake, renderBaked } }) both hold. In development this logs a warning rather than passing silently.
Entries missingThe hidden flag or the entity-type gate — see What actually ships.
Images broken on the site, fine in the appA hand-built media URL — resolve through media instead.
The design didn't changeThe edition is frozen. Publish again.
Publish is slow on a big worldContents are fetched in batches and persisted in chunks; a world with hundreds of documents genuinely takes a while.

Custom domains

ErrorMeaning
That address is already taken.Someone has it — uniqueness spans the older vvd's namespace too.
That address is reserved.Platform names (www, app, beta…) are never claimable.
Enter a valid domain (e.g. wiki.example.com).The hostname didn't parse, or it's a vvd-owned host.
This domain is already in use.Another world has it.
One custom domain per world — remove the existing one first.Exactly that.
Only world editors can manage domains.You're a member, not an editor.
DNS hasn't propagated yet — this can take up to 24 hours.The record isn't visible yet. Verify again later; nothing is lost meanwhile.
Verification failed. Check your DNS settings.Usually the record's Name is wrong (must be the full hostname), or a conflicting A record on the same name.
Domain verification isn't available on this deployment yet.No domain integration configured here. The row is still saved.
Note:

On beta, the address has an extra label

beta.vvd.world is where vvd runs today, and the published sites it serves live at <slug>.beta.vvd.world. The panel writes the suffix as .vvd.world because that's the production address; the beta deploy resolves the longer form the same way. vvd.world itself still serves the previous version of vvd.

Where to next