Skip to content
Starter Kits— browse docs
On this page

Hello World (app)

A world lobby — a shared headline everyone can rewrite, a pinboard of world documents, and a live directory.

A tool edits one document. An app owns a place: its own route in the world, its own surface filling the screen, and as much or as little of the world as it wants to show.

This kit is the smallest complete app — a lobby. A headline anyone can rewrite, a pinboard of things worth finding, and a live directory of everything the world holds, grouped by type. It's the first page you'd want if you walked into someone else's world and had no idea where anything was.

You will learn

  • What an app has that a tool doesn't, in about 160 lines
  • How useCollabState gives an app durable shared state with no document to open
  • How to read the whole world live with useWorldQuery
  • When "this should be an app" is the right answer

Try it

Hello World, running (the lobby, without the app shell)
Starting the example…

An app owns a route and provides a host, and a docs page has neither — so this is the lobby's own surface, mounted directly. The headline and the pinboard are real shared state; the directory below is reading a small fixture world instead of yours.

Rewrite the headline — it's real shared state, so a second person would see it change as you type. Pin something from the directory below and it appears in Pinned for everyone.

Create it

vvd create guild-hall --app --template=hello
Expected output:
→ Creating app guild-hall in /Users/you/dev/guild-hall — from the Hello World template

✦  nice. guild-hall the app is born.

Next:
cd guild-hall
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created guild-hall (app) → /Users/you/dev/guild-hall

hello is the default app kit, so vvd create guild-hall --app gives you the same thing. Running it in a real world is where this kit earns its keep — the directory fills with your actual documents.

What you'd build with it

Anything that's a place rather than a document:

  • A world home page — the thing collaborators land on, with the current state of play at the top.
  • A campaign dashboard — session date, current arc, the five documents that matter this week.
  • A writers' room — open questions, who's working on what, what changed since Tuesday.
  • An onboarding lobby for a shared world — "start here", curated by whoever runs it.
  • A review queue — everything tagged for approval, pinned by the person who has to approve it.
  • A launcher for your own tools — an app can host tools, so the lobby becomes the front door to a whole suite.

What's in it

src/locales/en.json18 lines

Every string this creation says, in English. Adding a language is `vvd locales add fr` plus one import — never “first go and find every string”.

src/locales/en.json
{
  "common": {
    "untitled": "Untitled"
  },
  "lobby": {
    "documents": {
      "0": "no documents yet",
      "one": "{count} document",
      "other": "{count} documents"
    },
    "pinned": "Pinned",
    "pinnedEmpty": "Nothing pinned yet — use the pin button on any entry below. Pins are shared app state: everyone sees the same board.",
    "pin": "Pin",
    "unpin": "Unpin",
    "more": "+{count} more",
    "empty": "Nothing here yet — create cards, maps, and notes in the editor and they appear here live."
  }
}
src/app.tsx184 lines

The app itself: a `defineApp` with the route it owns, the surface it draws, and the tools it hosts inside it.

src/app.tsx
import { type CSSProperties, useMemo } from "react"

import { HostIcon, ToolRegistry, createTranslator, defineApp, defineTool, field, resolveHostLocale, useCollabState, useHostCapability, useHostLocale, useWorldMeta, useWorldQuery } from "@vvd/sdk"

import en from "@/locales/en.json"

const NAME = "guild-hall"
const FILE = "src/app.tsx"

// ── Words ────────────────────────────────────────────────────────────────────
// The HOST decides WHICH language the reader is in (and re-publishes live when they
// switch — the subtree re-renders in place, nothing remounts); these JSON files decide
// what guild-hall says in it. `vvd locales add fr` writes src/locales/fr.json with every
// key blank; import it into CATALOGS and you speak French. `vvd locales check` reports
// what's still untranslated — it warns, it never blocks.
//
// Only UI text lives here. The shared headline below is DURABLE STATE someone typed —
// never localize the words your users wrote.
const CATALOGS = { en }

function useT() {
  const { locale } = useHostLocale()
  return useMemo(
    () => createTranslator(resolveHostLocale({ locale, catalogs: CATALOGS, defaultLocale: "en" })),
    [locale],
  )
}

// guild-hall is an APP — a full-screen lens over the whole world (a tool edits one
// document; an app owns the room). This starter is a world lobby: a shared headline
// everyone can rewrite live, a pinboard of world documents (references by id —
// never copies), and a live directory of everything the world holds.

const SECTION: CSSProperties = { margin: "26px 0 0" }
const H2: CSSProperties = { margin: "0 0 10px", fontSize: 12, textTransform: "uppercase", letterSpacing: 1.2, opacity: 0.55 }
const ROW: CSSProperties = { display: "flex", alignItems: "center", gap: 8, width: "100%", textAlign: "left", padding: "7px 10px", borderRadius: 9, border: "none", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit", fontSize: 13.5 }
const CHIP: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, padding: "5px 12px", borderRadius: 999, border: "1px solid rgba(127,127,127,0.4)", background: "rgba(127,127,127,0.08)", color: "inherit", cursor: "pointer", font: "inherit", fontSize: 13 }

function GuildHallLobby({ canEdit }: { canEdit: boolean }) {
  const meta = useWorldMeta()
  const docs = useWorldQuery("documents")
  const refs = useHostCapability("refs")
  const nav = useHostCapability("nav")
  const t = useT()

  // Shared APP state — one durable doc for the whole instance, plus who's here now.
  // The headline's default is SEED CONTENT, not UI text: it is written into the shared
  // document the first time someone edits, so it must not change with the reader's
  // language (two readers would otherwise disagree about what the world says).
  const { data, actions, peers } = useCollabState({
    headline: field.value<string>("Welcome — make this place yours."),
    pinnedIds: field.list<string>(),
  })
  const others = peers.filter((p) => !p.isSelf)

  const byType = useMemo(() => {
    const groups = new Map<string, (typeof docs)[number][]>()
    for (const d of docs) {
      const list = groups.get(d.documentType) || []
      list.push(d)
      groups.set(d.documentType, list)
    }
    return [...groups.entries()].sort((a, b) => b[1].length - a[1].length)
  }, [docs])
  const rowById = useMemo(() => new Map(docs.map((d) => [d.id, d])), [docs])

  const pinned = data?.pinnedIds ?? []
  const togglePin = (id: string) => {
    if (!actions || !canEdit) return
    const i = pinned.indexOf(id)
    if (i >= 0) actions.list("pinnedIds").remove(i)
    else actions.list("pinnedIds").push(id)
  }
  const open = (id: string) => {
    const coords = refs.coordsFor(id)
    if (coords) nav.openDocument(coords)
  }

  return (
    <div style={{ height: "100%", overflowY: "auto" }}>
      <div style={{ maxWidth: 860, margin: "0 auto", padding: "44px 24px 80px" }}>
        <header style={{ display: "flex", alignItems: "baseline", gap: 12, flexWrap: "wrap" }}>
          <h1 style={{ margin: 0, fontSize: 30, letterSpacing: -0.3 }}>{meta ? meta.name : NAME}</h1>
          {/* Never `n + " document" + (n === 1 ? "" : "s")` — plurals are not a suffix in
              most languages. t.plural runs Intl.PluralRules over the catalog's forms. */}
          <span style={{ opacity: 0.5, fontSize: 13 }}>{t.plural("lobby.documents", docs.length)}</span>
          <span style={{ flex: 1 }} />
          {others.length > 0 && (
            <span style={{ display: "flex", alignItems: "center" }} title={others.map((p) => p.name).join(", ")}>
              {others.slice(0, 5).map((p) => (
                <span key={p.userId} style={{ width: 26, height: 26, borderRadius: "50%", marginLeft: -8, display: "inline-flex", alignItems: "center", justifyContent: "center", background: p.color, color: "#fff", fontSize: 11, fontWeight: 700, border: "2px solid rgba(255,255,255,0.35)" }}>
                  {p.name ? p.name.slice(0, 1).toUpperCase() : ""}
                </span>
              ))}
            </span>
          )}
        </header>

        {/* The shared headline — one line of durable app state; edit it and everyone sees it change live. */}
        {canEdit ? (
          <input value={data?.headline ?? ""} onChange={(e) => actions?.set("headline", e.target.value)}
            style={{ marginTop: 10, width: "100%", border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", fontSize: 17, opacity: 0.85, fontStyle: "italic" }} />
        ) : (
          <p style={{ margin: "10px 0 0", fontSize: 17, opacity: 0.85, fontStyle: "italic" }}>{data?.headline ?? ""}</p>
        )}

        <section style={SECTION}>
          <h2 style={H2}>{t("lobby.pinned")}</h2>
          {pinned.length === 0 ? (
            <p style={{ margin: 0, opacity: 0.5, fontSize: 13.5 }}>
              {t("lobby.pinnedEmpty")}
            </p>
          ) : (
            <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
              {pinned.map((id) => (
                <span key={id} style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
                  <button type="button" onClick={() => open(id)} style={CHIP}>
                    {rowById.get(id)?.name || t("common.untitled")}
                  </button>
                  {canEdit && (
                    <button type="button" onClick={() => togglePin(id)} title={t("lobby.unpin")}
                      style={{ width: 20, height: 20, display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: 6, border: "none", background: "rgba(127,127,127,0.2)", color: "inherit", cursor: "pointer" }}>
                      <HostIcon icon="x" size={11} />
                    </button>
                  )}
                </span>
              ))}
            </div>
          )}
        </section>

        {byType.map(([type, list]) => (
          <section key={type} style={SECTION}>
            <h2 style={H2}>{type} · {list.length}</h2>
            <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 2 }}>
              {list.slice(0, 12).map((d) => (
                <span key={d.id} style={{ display: "flex", alignItems: "center", minWidth: 0 }}>
                  <button type="button" onClick={() => open(d.id)} style={{ ...ROW, flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis", display: "block" }}>
                    {d.name || t("common.untitled")}
                  </button>
                  <button type="button" onClick={() => togglePin(d.id)} title={pinned.includes(d.id) ? t("lobby.unpin") : t("lobby.pin")}
                    style={{ display: "inline-flex", alignItems: "center", border: "none", background: "transparent", color: "inherit", cursor: "pointer", opacity: pinned.includes(d.id) ? 0.9 : 0.3 }}>
                    <HostIcon icon="star" size={13} />
                  </button>
                </span>
              ))}
            </div>
            {list.length > 12 && <p style={{ margin: "6px 0 0", fontSize: 12, opacity: 0.5 }}>{t("lobby.more", { count: list.length - 12 })}</p>}
          </section>
        ))}
        {docs.length === 0 && (
          <p style={{ marginTop: 26, opacity: 0.6, fontSize: 14 }}>
            {t("lobby.empty")}
          </p>
        )}

      </div>
    </div>
  )
}

const view = defineTool({
  id: "guild-hall",
  name: "guild-hall",
  documentTypes: ["guild-hall"],
  needs: ["world"],
  surface: "plain",
  render: function GuildHallView({ context }) {
    return <GuildHallLobby canEdit={context.canEdit} />
  },
})

export default defineApp({
  id: "guild-hall",
  name: "guild-hall",
  route: "guild-hall",
  Host: function GuildHallHost({ children }) {
    return <>{children}</>
  },
  Surface: function GuildHallSurface({ children }) {
    return <div style={{ position: "relative", height: "100%", width: "100%" }}>{children}</div>
  },
  tools: new ToolRegistry().register(view),
})

One file. The defineApp at the bottom is the whole difference from a tool: a route, a Host, a Surface, and a ToolRegistry holding the view that actually draws the lobby.

Where does an app's state live, if there's no document?Deep dive

A tool is handed a document — that's its whole job, and useDocument(coords, codec) opens it. An app isn't handed anything, so where does a headline everyone shares actually go?

useCollabState(shape) answers it by asking the host. The host owns a durable state document per (world, app, project) and hands back its coordinates; the hook opens it with a codec built from the shape you passed. You get data, actions and peers with no coordinates in your code at all — and because it's the same document runtime underneath, it is the same CRDT, the same undo timeline and the same presence as anything else.

The practical consequence: an app's state follows the place, not a file. Install the app in two worlds and each gets its own; scope it to a project and each project gets its own. You never wire that up.

How it works

An app provides a host; a tool consumes one. Same contract, opposite roles — which is why the lobby's own view is a defineTool registered in the app's ToolRegistry. See Define an app and Hosting tools.

useWorldQuery reads the world live. useWorldQuery("documents") is a live list, not a fetch — create a card in the editor and the directory updates without a refresh. See World data.

Opening a document is the host's job. The lobby resolves an id to coordinates with the refs capability and hands them to nav — it never builds a URL. See Host capabilities.

Next steps

  • Blocks — the other app kit, and much less serious.
  • Define an app — route, Host, Surface, and what each is for.
  • Owning a space — parent and sub-documents, and what an app is allowed to own.