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
{
"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"
}| Field | What it is |
|---|---|
id | Fixed 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.sharing | Grants sitePublish — the capability that lets the wiki publish its own public address. |
capabilities.readsWorld | Grants world + search. Without it: Host capability "world" is not provided by this host. — loud and named, never a silently empty page. |
writesWorld: false | Stays 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.
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.
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:
| Field | Type | What it is |
|---|---|---|
id | string | The document id — what you pass to HostEmbed and nav. |
name | string | Already defaulted — never empty, never null. |
slug | string | null | The URL-safe name. Falls back to id when absent. |
documentType | string | "card", "map", "note", or a third-party tool's own type. |
entityTypeId | string | null | Which entity type a card is (Character, Location…). |
avatarMediaId | string | null | A media id — never a URL. See Images. |
aliases | readonly string[] | Other names this document goes by — search on them too. |
isViewable | boolean | The 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.
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
| Customization | Page state | |
|---|---|---|
| What it is | Theme and font picks | Title, subtitle, banner, featured entries, section order |
| Who defines the options | You, as data in vvd.json | You, as a collab shape in code |
| Who renders the UI | The platform's Customize panel | Your page, inline, in edit mode |
| How it publishes | Frozen into the edition's settings | Frozen into the edition's extra.page |
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:
values | The 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. |
customizing | Is 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:
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> }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:
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.
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
const DEFAULT_SECTIONS = ["featured", "entries", "extras"]
const order = page.sectionOrder.length > 0 ? page.sectionOrder : DEFAULT_SECTIONSAn 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:
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:
| URL | What 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 deeper | Nothing — 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.xmlThere'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
| Read | What 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.eraId | Which 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:
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
| Available | Gone |
|---|---|
world · media · refs · types · nav · embeds | collab · search · presence |
identity (always canEdit() === false, me === null) | projects · publish · sitePublish · install · server |
audio · menus · icons · scope | theme · 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 | |
|---|---|
snapshot | Your bake result, reassembled. unknown at the seam — narrow it yourself. |
settings | The frozen customization values (theme, fonts). |
entryPath | The 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. |
basePrefix | This 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:
| Audience | Who gets in |
|---|---|
public | Anyone with the address. The default. |
unlisted | Anyone with the capability URL — <address>/u/<token>. The plain address doesn't serve it. |
password | Anyone with the password. Must be set before publishing at this audience. |
entitled | People the creator granted access to, by email. |
The four verbs
| What it does | |
|---|---|
vvd save | Uploads an immutable version — a private draft. In a solo world it also installs itself there. |
vvd share | Points your world/team at your latest save. Needed once the world has other members. |
vvd publish | Submits 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 see | What it means |
|---|---|
custom_bake_required | vvd.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 yours | Your 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 missing | The hidden flag or the entity-type gate — see What actually ships. |
| Images broken on the site, fine in the app | A hand-built media URL — resolve through media instead. |
| The design didn't change | The edition is frozen. Publish again. |
| Publish is slow on a big world | Contents are fetched in batches and persisted in chunks; a world with hundreds of documents genuinely takes a while. |
Custom domains
| Error | Meaning |
|---|---|
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. |
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.