Step 2: The shape of an app
The one sentence that separates an app from a tool, and the three things defineApp needs from you.
Every tool you've written calls useHost() — that's how it asks for the world, media,
navigation. The tool never knows who's answering. It asks.
An app is the thing that answers:
// A tool CONSUMES the host — it asks.
function LobbyScreen() {
const nav = useHostCapability("nav")
nav.openDocument(coords)
}
// An app PROVIDES one — it answers.
function LobbyHost({ children }) {
return <HostProvider services={buildServices()}>{children}</HostProvider>
}Same contract, opposite roles. Hold on to that — it's why the tool you host inside your app never has to know it's inside your app rather than someone else's.
You will learn
defineApp's three required parts:Host,Surface, and atoolsregistryAppHost— the SDK helper that composes them into one mount- Why a bad manifest throws while your file is still loading, not while it's rendering
Three things, and nothing else is required
export default defineApp({
id: "lobby",
name: "Lobby",
route: "lobby",
Host: LobbyHost, // 1. answers useHost() for everything inside
Surface: LobbySurface, // 2. the chrome a document renders inside
tools: new ToolRegistry().register(lobbyScreen), // 3. what you compose
})Host answers useHost() for the tools you mount. Surface is the window frame around a
document — a tool declares surface: "plain" when it draws its own, "panel" when it wants
yours. tools is the registry AppHost dispatches into by document type.
The playground below can't mount an app directly — an app owns a route and needs the real
shell, so this page does what the shell does for real: wrap your app in <AppHost> and show
you what it draws. That's the whole three-part composition, running.
No edit to make yet — try something else first (keep reading below the code).
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
You should see: "Welcome to the lobby." — your Host answered useHost(), your
Surface drew the frame, and AppHost dispatched to lobbyScreen by document type.
Our LobbyHost is a passthrough — it hands the platform's real host straight through,
which is the normal case; wrap with HostProvider only when you want to override specific
answers. See Hosting tools.
Now break it on purpose. Change line 24's route: "lobby" to route: "My Lobby" — a space,
like you'd type without thinking. The whole example vanishes, and the strip above the
editor reads:
Invalid app manifest for "lobby": route "My Lobby" must be a single lowercase URL segment
(letters, digits, hyphens; no slashes or spaces)That's not a bug in this page. defineTool is a pure identity function — it hands your
object back unchanged, and a typo in it only surfaces whenever the typo happens to matter.
defineApp is different: it derives your manifest and validates it the moment the function
is called — which is while your module is still loading, before a single component mounts.
A route with a space in it fails right here instead of as a 404 nobody can explain three
clicks later. So when you get a blank screen and a message like this in the console during
vvd run, don't go looking in your render function — nothing rendered.
Put the space back to a hyphen-free "lobby" and the app returns.
defineApp reports every problem at once, joined with ; — a bad route and a bad
version show up together, not one fix at a time. The full checklist (id, route,
version, engines, capabilities…) is on defineApp.