Reference
The whole truth about one thing — every manifest field, every capability grant, 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 an app yet, start with Build a world lobby; every section below links back to the build that introduced the idea.
defineApp, in full
defineApp splits your app into a manifest (pure, serializable data) and the code it
points at:
import { ToolRegistry, defineApp, deriveCapabilities } from "@vvd/sdk"
const tools = new ToolRegistry().register(lobbyView)
export default defineApp({
// --- identity: this half serializes ---
id: "lobby",
name: "Lobby",
route: "lobby",
version: "1.0.0",
description: "A front door for your world.",
icon: "house",
engines: { host: 1, app: 1 },
capabilities: deriveCapabilities(tools, ["world"]),
// --- code: this half never leaves your bundle ---
Host: LobbyHost,
Surface: LobbySurface,
tools,
})Only id, name, route, Host, Surface and tools are required. Defaults: version
→ "0.0.0", engines → { host: 1, app: 1 }, capabilities → [], subRoutes → false.
It validates at import time — not defineTool's contract
defineTool is an identity function: it hands your object back and does nothing else.
defineApp derives the manifest and validates it right there, in the function call —
which runs the moment your module is imported. A bad manifest throws before a single
component mounts:
Invalid app manifest for "lobby": route "My Lobby" must be a single lowercase URL
segment (letters, digits, hyphens; no slashes or spaces)So a blank screen plus that message during vvd run means the module failed to load —
don't go looking in your render function, nothing rendered. Validation collects every
issue at once (joined with ; ) instead of stopping at the first.
| Field | Rule | Violation looks like |
|---|---|---|
id | non-empty | id is required |
name | non-empty | name is required |
route | one lowercase URL segment: letters, digits, hyphens, no slashes/spaces/leading-trailing hyphen | route "My Lobby" must be a single lowercase URL segment … |
version | semver, three parts | version "1.0" must be semver (e.g. 1.2.0) |
engines.host / .app | positive integers | engines.host must be a positive integer (got 0) |
capabilities | real capability names, no duplicates | unknown capability "worlds" · duplicate capability "world" |
stateClass | lowercase slug, like an id | stateClass "Wiki!" must be a lowercase slug … |
route — where you mount: /worlds/<world-slug>/<route>, one segment, and two apps
can't claim the same route in one world. Want everything below it too?
subRoutes: true (see State, routes and tabs).
capabilities — the install consent prompt, derived rather than hand-written so it
can't go stale:
capabilities: deriveCapabilities(tools, ["world", "media"]) // unions tools' needs + your own chrome'sThe result is deduped and sorted. This is the SDK-declared half; what the platform actually
hands you at runtime comes from vvd.json's grants (below) and what the installing user
approved — a capability you declare but weren't granted is still missing at runtime, and
asking for it throws by name.
engines — { host: 1, app: 1 } is the contract floor: a runtime older than it refuses
to load you with a reason, rather than mounting and crashing halfway down. Leave it alone
until you have a reason not to. version is yours, for listing and updates — nothing to
do with engines.
vvd.json mirrors the same identity as a file the CLI and platform read without executing
your bundle:
{
"id": "lobby", "name": "Lobby", "kind": "app", "version": "0.1.0", "icon": "house",
"route": "lobby", "needs": [],
"capabilities": { "readsWorld": true, "writesWorld": false, "sharing": false },
"entryModule": "@/app"
}vvd create writes it; vvd save validates it with the same parser the server runs, so
anything the server would reject is rejected on your machine first, with the same words.
Keep the two honest with each other — a route mismatch is a tab that leads nowhere.
Registering an app is discovery only. appRegistry.register(app) lists apps so a shell
can draw tabs; it does not route requests — the router does that, off route. A standalone
CLI app doesn't touch a registry at all: your bundle's default export is the app, and
installing it is what makes it discoverable.
Owning a space
An instance of your app is a project whose kind is your app's route id. The documents
inside it — your sub-documents — are your space. projects is the whole API, five methods:
const projects = useHostCapability("projects")
await projects.list("lobby") // my instances in this world
await projects.create("lobby", "The Salt Road") // a new one
await projects.rename?.(spaceId, "The Salt Road, revised")
await projects.listDocuments(spaceId) // what's inside one
await projects.createDocument(spaceId, { type: "chapter", name: "Chapter 1" })list/create take your own route id, so an app only ever sees its own instances — there's
no call that returns another app's projects.
You need the write grant first. vvd create --app writes an app that can read the world but
not write to it — most apps start as lenses, and a lens shouldn't create documents nobody
asked for. Without it:
Host capability "projects" is not provided by this host. Either this surface should
not render a consumer that needs "projects", or <HostProvider services> must include it.{ "capabilities": { "readsWorld": true, "writesWorld": true, "sharing": false } }vvd.json grant | You get | Which means |
|---|---|---|
readsWorld | world, search | Query the world's documents, eras, media, index |
writesWorld | projects, documents | Create projects and documents, change typed fields |
sharing | publish, access, sitePublish | Mint public links, read entitlements, publish a site |
The failure is loud on purpose: declaring a permission you don't use is easy to notice; silently reading data you weren't granted is not.
Declare the document types you create, or the platform rejects every create_document
against manifests it doesn't know about — usually at the worst moment, the first time
someone opens your app in an empty project:
{ "route": "lobby", "documentTypes": ["chapter"] }route is how your app opens; documentTypes is what it may write — different questions,
answer both. A sub-document created this way is an ordinary document with an ordinary type:
a tool can render it, the agent API can query it, it has its own collaborative state.
Sub-documents hold content. "Which chapter is open", "what this instance is called", the front-page pinboard — that's app state, shared between everyone in the space, and it has its own door: State, routes and tabs.
Hosting tools
Mounting a document inside your app is always three layers, composed for you by AppHost:
<Host> {/* your useHost() provider */}
<Surface variant="panel"> {/* your window chrome */}
<ToolHost registry={tools} document={doc} context={ctx} /> {/* the SDK's dispatch */}
</Surface>
</Host>import { AppHost } from "@vvd/sdk"
<AppHost app={campaignApp} document={chapter} context={{ scope, canEdit: true }} />1. Host — you answer useHost(). Most standalone apps just pass along the real host
the platform already built around you:
function CampaignHost({ children }: AppHostProps) {
return <>{children}</>
}That's the scaffold's default and it's not a placeholder. To change one answer, wrap with an
inner HostProvider — it overrides only what you name, innermost wins, everything else
flows down:
function ReadOnlySubtree({ children }: { children: React.ReactNode }) {
const host = useHost()
return (
<HostProvider services={{ ...host, identity: { ...host.identity, canEdit: () => false } }}>
{children}
</HostProvider>
)
}That one pattern gives you "this document is editable, the things it embeds are not" with
no tool knowing it happened. Building HostServices from scratch is what the platform's own
hosts do (and what createFakeHost() does for tests) — a standalone app almost never needs
to.
2. Surface — the window a document sits in. Receives variant, which comes from the
tool's own surface hint, not your choice:
function CampaignSurface({ variant, children }: AppSurfaceProps) {
return <section data-variant={variant}>{children}</section>
}variant | Means | When you get it |
|---|---|---|
"panel" | Give this document your chrome | Default — a tool that says nothing |
"plain" | It owns its own frame, stay out of the way | Tool declared surface: "plain" |
One override: a fullscreen view is always "plain", whatever the tool declared — a
fullscreen space owns the screen, and a second frame around it is only a smaller screen.
3. tools — a ToolRegistry maps document types to tools; ToolHost calls
registry.getForDocumentType(document.type):
const tools = new ToolRegistry().register(chapterTool).register(sceneTool)Dispatch is by document type, never a switch you write. The registry is per app — your
bundle composes what it ships with, another app can compose a different set, and the same
tool behaves identically in both because the only thing that changed is who answered
useHost(). It's also what your consent surface derives from
(deriveCapabilities(tools)).
Composing a tool is not installing one
The tools in your registry are the tools your bundle ships with — you can't reach into a world and mount a tool someone else installed. That boundary is deliberate: it's what makes "what can this app do" a question with an answer at install time.
Projects (multiple instances)
A world is not one of anything — a group running two campaigns reads the same characters, same map, same history; they aren't the same campaign. So an app runs once per project, not once per world.
- World scope — the canon instance, one per app, always there.
- Project scope — a named instance, as many as the world wants.
A project does not fork world data; both instances read the same shared pool of cards. What's per-project is your app's own space and its own state.
render: function Campaign({ context }) {
const projectId = context.scope.type === "project" ? context.scope.projectId : null // null = world-canon
}type Scope = { type: "world"; worldId: string } | { type: "project"; worldId: string; projectId: string }Outside a render, the same value is useHost().scope. Your shared state document and your
sub-documents are already keyed by world, app and project — you read the scope, you
never route it anywhere.
const projects = useHostCapability("projects")
const mine = await projects.list("campaigns")
const fresh = await projects.create("campaigns", "The Salt Road")
await projects.rename?.(fresh.id, "The Salt Road (2nd run)")A ProjectRef is { id, name, slug, kind }; kind is always yours. rename is optional —
called with ?. — because a read-only host (a published site, a share link) provides the
reads and omits the write.
The tab capability — when mounted as a tab, the host tells you which project it's
bound to and lets you re-bind:
const tab = useHost().tab
tab?.project // ProjectRef | null — null means world scope
tab?.setProject(chosen) // re-bind THIS tab; host persists it and re-scopes you
tab?.setProject(null) // back to world canonRead tab off the host, never through useHostCapability
tab exists only where there are tabs. An embed, a published page, a test host, all
legitimately have none — and useHostCapability("tab") throws by design when a namespace
is missing. Reading useHost().tab and checking it's there is the difference between an
app that degrades and one that white-screens on a share link. Same rule covers
nav.route, projects.rename, and every other optional member: if the type says it might
not be there, it won't be, somewhere.
Eras are a different axis. An era is a version of the world — the same characters, a
hundred years later. A project is an instance of your app. A project doesn't fork world
data; an era does, and the platform resolves it for you before you see a row —
useWorldQuery("documents") already gives you the era-correct view. You never resolve an
era, and can't accidentally read the wrong one.
State, routes and tabs
App state — a headline, a pinboard, which layout the team picked — belongs to the instance, not one person. It lives in a shared document, with one hook:
import { field, useCollabState } from "@vvd/sdk"
const { data, actions, peers } = useCollabState({
headline: field.value("Welcome — make this place yours."),
pinnedIds: field.list<string>(),
})No coordinates, no document id, no provider — the host already knows where this mount's
shared state lives (one durable document per world, per app, per project) and hands it
over. Same field vocabulary as a tool's codec (field.value, .list, .map, .prose),
same actions, same merge semantics. peers is who's in that room right now.
collab is granted by default, to every creation
Real-time collaboration is the platform promise, not an upgrade — no vvd.json switch,
nothing for a user to approve.
What still belongs in useState — the test is one question: if the person next to me
opened this, would they expect to see it? If not, useState is correct. Shared: headline,
pinboard, chosen layout, notes. Yours alone: which panel is expanded, a half-typed form,
scroll position, an open menu.
Owning your URLs — one route by default (/worlds/salt-road/campaigns; anything deeper
404s). Claim the subtree:
export default defineApp({ id: "campaigns", route: "campaigns", subRoutes: true /* … */ })Then read and drive position through the host — never Next's router, never usePathname:
import { useAppRoute } from "@vvd/sdk"
function CampaignSurface() {
const route = useAppRoute() // null = not mounted on a subtree you own (embed, keep-alive pane, test)
if (!route) return <Browse />
const [slug] = route.segments // [] at your root
return slug ? <Entry slug={slug} onBack={() => route.push([])} /> : <Browse onOpen={(s) => route.push([s])} />
}route.push([...]) navigates client-side without remounting your app, and the shell mirrors
your position into the address bar — deep links seed you back, browser back/forward walks
your pages. route.hrefFor([...]) gives a real URL for a real <a> (middle-click,
open-in-new-tab, search engines).
Reach for the router and you lose the app
usePathname reads the page the browser is on — not "where am I inside my own app". They
agree exactly until your app is embedded, previewed, published, or opened in a second
pane, and then a route-driven UI shows the wrong thing with no error to trace.
useAppRoute() returns null when nobody can answer the question, rather than lying.
Theming
The viewer's chosen look is a host capability:
import { useHostTheme } from "@vvd/sdk"
const { mode, tokens } = useHostTheme()
// mode: "light" | "dark"
// tokens: the resolved platform-then-world cascade as CSS custom-property pairsAlways present: --background · --foreground · --primary · --primary-foreground ·
--muted · --muted-foreground · --border · --destructive ·
--destructive-foreground · --text-color · --text-color-muted. A world theme may add
more. It never throws and needs no grant — under no cascade at all it returns the platform
default (dark mode, empty tokens), so your app renders identically headless.
The rule that decides whether an app feels native or like a skin: when the host offers a token, wear it; when it doesn't, wear your own — undiluted, not a grey compromise.
const accent = tokens["--primary"] ?? "#c2410c" // my colour, when nobody has an opinionmode is for decisions a token can't express — shadow weight, which of two illustrations to
show. Never use it to pick between two hardcoded palettes; that's how an app ends up
ignoring a world theme in both modes.
Letting the user choose is a separate, declarative contract — parameters as data on your definition, the host renders the panel, you read the picks. Never ship your own Customize button:
const { choice, customizing } = useAppCustomization()
const themeId = choice(myThemeParam) // "default" | "midnight" | …"default" always means your design, untouched. customizing is panel state, not
permission — it's true while the host's panel is open, so you can reveal inline editing
affordances; what a user may actually change is still canEdit, always was. The full
picture — shared theme/font catalogs, self-loading fonts, a stored choice surviving a
template switch — belongs to site apps: Wiki.
Publishing and sharing
save, share, publish work identically for an app, a tool and a wiki — covered once,
with real terminal output, in Ship it. The one-line
version: save mints a private version, share points your world at one, publish
submits to the Workshop, and saving never changes who can see anything.
An app spends most of its life in share. The rhythm: vvd run while building, vvd save whenever a change is worth keeping, vvd share the moment a teammate can usefully
open it — then months of real use before vvd publish. vvd status shows all three
pointers at once: what you have, what your world has, what the Workshop has.
vvd share
✓ Shared Lobby v3 with your world.--version points at an older save; --undo stops sharing. Versions accumulate — backing
out is picking an earlier one, not rebuilding it.
What installing consents to — an app is the higher-privilege install (it provides a
host), so the prompt is built from your manifest's capabilities (derived, above). A user
can grant less than you asked for, never more: a namespace you declared but weren't
given is still missing, and asking for it throws by name. Asking for less gets you installed
more — writesWorld: true on an app that only reads is a permission prompt you're paying
for and not spending. Write your app so a declined capability degrades: read optional
members off the host and check for null (same rule as tab, above).
Publishing the app (your bundle goes to the Workshop) is a different thing from
publishing a space (one instance becomes a public web page at a real address). The
second needs the sharing grant and an app that says how its space freezes into a page — a
whole discipline, and it's Wiki: a wiki is an app with category: "site",
not a fourth kind of thing.
Check the host before you write the URL down
beta.vvd.world is vvd. The bare vvd.world still serves the previous generation — the
CLI, the Workshop, and your login all live on beta. VVD_API_URL defaults to
https://beta.vvd.world.