Skip to content
Reference— browse docs
On this page

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.

Danger:

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.

FieldRuleViolation looks like
idnon-emptyid is required
namenon-emptyname is required
routeone lowercase URL segment: letters, digits, hyphens, no slashes/spaces/leading-trailing hyphenroute "My Lobby" must be a single lowercase URL segment …
versionsemver, three partsversion "1.0" must be semver (e.g. 1.2.0)
engines.host / .apppositive integersengines.host must be a positive integer (got 0)
capabilitiesreal capability names, no duplicatesunknown capability "worlds" · duplicate capability "world"
stateClasslowercase slug, like an idstateClass "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's

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

vvd.json
{
  "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.
vvd.json
{ "capabilities": { "readsWorld": true, "writesWorld": true, "sharing": false } }
vvd.json grantYou getWhich means
readsWorldworld, searchQuery the world's documents, eras, media, index
writesWorldprojects, documentsCreate projects and documents, change typed fields
sharingpublish, access, sitePublishMint 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>
}
variantMeansWhen you get it
"panel"Give this document your chromeDefault — a tool that says nothing
"plain"It owns its own frame, stay out of the wayTool 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)).

Warning:

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 canon
Warning:

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

Tip:

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

Warning:

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 pairs

Always 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 opinion

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

Warning:

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.

Next steps