Skip to content
Starter Kits— browse docs
On this page

Hello World (tool)

A tiny shared notes board — collaboration, presence, world links, and drag-and-drop, in about 240 lines.

Every other kit on this list is this one plus something. A board of notes, where the notes are real collaborative state, the people editing them are really there, and a note can point at a real document in your world. It's the smallest complete vvd tool — small enough to read in one sitting, and complete enough that nothing in it is a stub.

You will learn

  • What the default vvd create scaffold gives you, by using it
  • How a per-key map makes two people's edits both land
  • How a note points at a world document without copying it
  • Which parts of it to delete first when you make it yours

Try it

Hello World, running
Starting the example…

Everything above is real: adding a note writes to a live CRDT document in your browser, and the board is the same ToolHost the product mounts. The one thing missing is a world — “Link a document” opens the real picker, and it has nothing to find until you run this in one.

Add a note. Type in it. Add another. Nothing here is mocked: the board below is a real ToolHost over a real CRDT document, the same pair the product runs — it just has no socket, so you're the only one in it.

Create it

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

✦  ah — a bestiary tool. let's build it.

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

hello is the default tool kit, so vvd create bestiary --tool gives you exactly the same thing. Then cd bestiary && vvd run --world=<your-world-slug> puts it in a real world, where the two things this frame can't show you — a second person, and real documents to link — are both waiting.

What you'd build with it

The shape is "a list of small things people write together, each of which can point at something else in the world." That covers a lot:

  • A bestiary or a character roster — one note per creature, each linked to its card.
  • A session log — what happened, in order, with the people and places involved linked inline. The at timestamp is already there for sorting.
  • A writers' room whiteboard — loose ideas, added fast, by several people at once.
  • A continuity checklist — open questions about the world, each pinned to the document it's about, ticked off as they're answered.
  • A per-card annotation pad — drop it in as a block on any page and it becomes that page's margin notes.
  • A shared to-do list for a project — the least imaginative use, and probably the one you'll build first.

What's in it

src/codec.ts16 lines

The data model. One declaration of what the document holds and how two people's edits merge — and the file `vvd save` reads to derive what an agent can do with your creation.

src/codec.ts
import { defineStateCodec, field } from "@vvd/sdk"

/** One note on the board. linkedDocumentId points at a WORLD document — always an id, never a copy. */
export type Note = {
  text: string
  linkedDocumentId: string | null
  /** Creation time — a stable sort key every peer agrees on. */
  at: number
}

export const codec = defineStateCodec({
  // A per-KEY map: two people adding or editing different notes at once BOTH land.
  notes: field.map<Note>(),
})

export default codec
src/locales/en.json28 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"
  },
  "board": {
    "add": "Add a note",
    "count": {
      "0": "no notes yet",
      "one": "{count} note",
      "other": "{count} notes"
    },
    "empty": "No notes yet.",
    "emptyEditable": "No notes yet — add one, then open this world in a second window and watch it appear there too."
  },
  "note": {
    "placeholder": "Write something…",
    "link": "Link a document",
    "linked": "linked document",
    "open": "Open in your world",
    "unlink": "Unlink document",
    "delete": "Delete note"
  },
  "picker": {
    "search": "Search your world…",
    "create": "Create “{query}”",
    "empty": "Nothing found"
  }
}
src/tool.tsx268 lines

The tool itself: a `defineTool` with a `render` function. This is the file you edit first.

src/tool.tsx
import { type CSSProperties, useEffect, useMemo, useState } from "react"

import { DocumentGate, HostIcon, type ToolRenderProps, createTranslator, defineTool, resolveHostLocale, useContextMenu, useDocDropTarget, useDocument, useDocumentPresence, useHostCapability, useHostLocale } from "@vvd/sdk"

import { type Note, codec } from "@/codec"
import en from "@/locales/en.json"

const NAME = "bestiary"

// bestiary is a tiny shared notes board — small on purpose, but nothing here is
// faked: every note is real collaborative state (edits converge live for everyone),
// presence is real, and a note's link chip points at a REAL world document by id —
// rename the card and the chip follows. It already behaves like a vvd tool: it
// lives in the sidebar, embeds as a block on any page, shares like any document,
// accepts sidebar drags through the platform dnd engine, and puts its actions on
// the platform right-click menu.

const newId = () => crypto.randomUUID()

// ── 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 bestiary says in it. Adding a language is two steps: `vvd locales add fr`
// writes src/locales/fr.json with every key blank, then import it into CATALOGS below.
// `vvd locales check` tells you what's still untranslated — it warns, it never blocks,
// and a half-filled catalog falls back key-by-key to English, so you can ship at any point.
//
// Only UI text lives here. A note's own words are the AUTHOR's — never localize the
// content a person typed into your creation.
const CATALOGS = { en }

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

type Hit = { id: string; name: string; entityTypeName: string | null; entityTypeIcon: string | null }

const CHIP: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, maxWidth: "100%", padding: "3px 10px", borderRadius: 999, border: "1px solid rgba(127,127,127,0.4)", background: "transparent", color: "inherit", font: "inherit", fontSize: 12, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }

/** The baseline presence display — who else is in this document right now. */
function PresenceStrip({ handle }: { handle: Parameters<typeof useDocumentPresence>[0] }) {
  const others = useDocumentPresence(handle).filter((p) => !p.isSelf)
  if (others.length === 0) return null
  return (
    <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: 24, height: 24, 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>
      ))}
      {others.length > 5 && <span style={{ marginLeft: 6, fontSize: 12, opacity: 0.7 }}>+{others.length - 5}</span>}
    </span>
  )
}

/** The native reference-picker pattern (the Board/Canvas/relation-tree pickers):
 *  search the world IMMEDIATELY (an empty query shows suggestions), arrow keys +
 *  Enter to pick, a create-as-card fallback — and always hand back the document
 *  ID, never the text. Entity icons render through the platform icon engine. */
function ReferencePicker({ onPick, onClose }: { onPick: (hit: Hit) => void; onClose: () => void }) {
  const search = useHostCapability("search")
  const t = useT()
  const [q, setQ] = useState("")
  const [hits, setHits] = useState<Hit[]>([])
  const [loaded, setLoaded] = useState(false)
  const [active, setActive] = useState(0)
  useEffect(() => {
    let live = true
    const timer = setTimeout(() => {
      search.query(q, { types: ["card"], limit: 12 }).then((r) => {
        if (!live) return
        setHits(r)
        setLoaded(true)
        setActive(0)
      })
    }, q ? 150 : 0)
    return () => { live = false; clearTimeout(timer) }
  }, [q, search])
  const canCreate = !!search.create && q.trim() !== "" && !hits.some((h) => h.name.toLowerCase() === q.trim().toLowerCase())
  const total = hits.length + (canCreate ? 1 : 0)
  const choose = (i: number) => {
    if (i < hits.length) onPick(hits[i])
    else if (canCreate) void search.create?.(q.trim(), null).then((made) => { if (made) onPick(made) })
  }
  return (
    <span style={{ display: "grid", gap: 2, minWidth: 220, padding: 4, borderRadius: 10, background: "rgba(20,22,29,0.97)", color: "#fff", border: "1px solid rgba(255,255,255,0.14)", boxShadow: "0 14px 32px rgba(0,0,0,0.45)" }}>
      <input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder={t("picker.search")}
        onKeyDown={(e) => {
          if (e.key === "Escape") { e.preventDefault(); onClose() }
          else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, Math.max(0, total - 1))) }
          else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)) }
          else if (e.key === "Enter") { e.preventDefault(); if (total > 0) choose(active) }
        }}
        onBlur={() => setTimeout(onClose, 120)}
        style={{ padding: "7px 10px", borderRadius: 7, border: "none", outline: "none", background: "rgba(255,255,255,0.08)", color: "#fff", font: "inherit", fontSize: 13 }} />
      <span style={{ display: "grid", maxHeight: 240, overflowY: "auto" }}>
        {hits.map((h, i) => (
          <button key={h.id} type="button" onMouseDown={(e) => { e.preventDefault(); choose(i) }} onMouseEnter={() => setActive(i)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: i === active ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon={h.entityTypeIcon || "file-text"} size={13} />
            <span style={{ flex: 1, minWidth: 0, overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>{h.name || t("common.untitled")}</span>
            {h.entityTypeName ? <span style={{ fontSize: 10.5, opacity: 0.5 }}>{h.entityTypeName}</span> : null}
          </button>
        ))}
        {canCreate && (
          <button type="button" onMouseDown={(e) => { e.preventDefault(); choose(hits.length) }} onMouseEnter={() => setActive(hits.length)}
            style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 9px", borderRadius: 7, border: "none", textAlign: "left", cursor: "pointer", font: "inherit", fontSize: 13, color: "#fff", background: active === hits.length ? "rgba(255,255,255,0.12)" : "transparent" }}>
            <HostIcon icon="plus" size={13} />
            {/* One phrase, one key — never "Create " + q + "": word order is not universal. */}
            <span style={{ flex: 1 }}>{t("picker.create", { query: q.trim() })}</span>
          </button>
        )}
        {loaded && hits.length === 0 && !canCreate && <span style={{ padding: "10px 12px", fontSize: 12, opacity: 0.5 }}>{t("picker.empty")}</span>}
      </span>
    </span>
  )
}

function NoteCard({ note, canEdit, linkedName, onChange, onDelete, onOpenLink }: {
  note: Note
  canEdit: boolean
  linkedName: string | null
  onChange: (next: Note) => void
  onDelete: () => void
  onOpenLink: () => void
}) {
  const [linking, setLinking] = useState(false)
  const t = useT()
  const { openContextMenu } = useContextMenu()
  return (
    <div
      onContextMenu={(e) => {
        // Actions live on the platform right-click menu — no button chrome on the card.
        if (!canEdit) return
        openContextMenu(e, [
          ...(note.linkedDocumentId
            ? [{ kind: "item" as const, id: "unlink", label: t("note.unlink"), icon: "tabler:circle-x", onSelect: () => onChange({ ...note, linkedDocumentId: null }) }]
            : []),
          { kind: "item" as const, id: "delete", label: t("note.delete"), icon: "tabler:trash", variant: "destructive" as const, onSelect: onDelete },
        ])
      }}
      style={{ display: "flex", flexDirection: "column", gap: 8, padding: 14, borderRadius: 12, border: "1px solid rgba(127,127,127,0.3)", background: "rgba(127,127,127,0.07)" }}>
      <textarea value={note.text} readOnly={!canEdit} placeholder={t("note.placeholder")}
        onChange={(e) => onChange({ ...note, text: e.target.value })}
        style={{ resize: "none", minHeight: 64, border: "none", outline: "none", background: "transparent", font: "inherit", fontSize: 14, lineHeight: 1.45, color: "inherit" }} />
      {/* Dates are never hand-formatted: t.date runs Intl.DateTimeFormat in the reader's
          language, so the same timestamp reads right in every one of them. */}
      <span style={{ fontSize: 11, opacity: 0.4 }}>{t.date(note.at)}</span>
      {note.linkedDocumentId ? (
        <button type="button" onClick={onOpenLink} title={t("note.open")} style={{ ...CHIP, cursor: "pointer", alignSelf: "flex-start" }}>
          <HostIcon icon="link" size={11} /> {linkedName || t("note.linked")}
        </button>
      ) : canEdit ? (
        <span style={{ position: "relative", alignSelf: "flex-start" }}>
          <button type="button" onClick={() => setLinking(true)} style={{ ...CHIP, cursor: "pointer", opacity: 0.55, borderStyle: "dashed" }}>{t("note.link")}</button>
          {linking && (
            <span style={{ position: "absolute", zIndex: 30, top: "100%", left: 0, marginTop: 4, width: 250 }}>
              <ReferencePicker onClose={() => setLinking(false)} onPick={(h) => { onChange({ ...note, linkedDocumentId: h.id }); setLinking(false) }} />
            </span>
          )}
        </span>
      ) : null}
    </div>
  )
}

function Board({ data, actions, handle, canEdit, worldId }: {
  data: { notes: Readonly<Record<string, Note>> }
  actions: { map(name: "notes"): { set(key: string, value: Note): void; delete(key: string): void } }
  handle: Parameters<typeof useDocumentPresence>[0]
  canEdit: boolean
  worldId: string
}) {
  const refs = useHostCapability("refs")
  const nav = useHostCapability("nav")
  const t = useT()
  const notes = useMemo(() => Object.entries(data.notes).sort((a, b) => a[1].at - b[1].at), [data.notes])

  // Resolve linked ids → display names through the host (batched, host-cached; never stored).
  const idsKey = useMemo(
    () => [...new Set(notes.map(([, n]) => n.linkedDocumentId).filter((x): x is string => !!x))].join(","),
    [notes],
  )
  const [names, setNames] = useState<Record<string, string>>({})
  useEffect(() => {
    const ids = idsKey ? idsKey.split(",") : []
    if (!ids.length) return
    let live = true
    refs.meta(ids).then((m) => {
      if (!live) return
      const out: Record<string, string> = {}
      m.forEach((r, docId) => { out[docId] = r.name })
      setNames(out)
    })
    return () => { live = false }
  }, [idsKey, refs])

  const openDoc = (docId: string) => nav.openDocument(refs.coordsFor(docId) ?? { worldId, documentId: docId })
  const addNote = () => actions.map("notes").set(newId(), { text: "", linkedDocumentId: null, at: Date.now() })

  // The platform dnd contract: drag any document from the sidebar onto the board
  // and it lands as a linked note (reference by id — never a copy).
  const drop = useDocDropTarget({
    zone: "bestiary-board",
    accepts: (p) => canEdit && (!p.worldId || p.worldId === worldId),
    onDrop: (p) => actions.map("notes").set(newId(), { text: "", linkedDocumentId: p.documentId, at: Date.now() }),
  })

  return (
    <div ref={drop.ref} {...drop.props}
      style={{ height: "100%", overflowY: "auto", outline: drop.canDrop ? "2px dashed rgba(127,127,127,0.55)" : "none", outlineOffset: -8 }}>
      <div style={{ maxWidth: 960, margin: "0 auto", padding: "28px 20px 64px" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>{NAME}</h1>
          {/* Counts go through t.plural — Intl.PluralRules picks the form, and a catalog
              can add an exact-count form ("0": "no notes yet") that no rule can express. */}
          <span style={{ flex: 1, fontSize: 13, opacity: 0.5 }}>{t.plural("board.count", notes.length)}</span>
          <PresenceStrip handle={handle} />
          {canEdit && (
            <button type="button" onClick={addNote}
              style={{ padding: "7px 14px", borderRadius: 10, border: "1px solid rgba(127,127,127,0.4)", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit", fontSize: 13, fontWeight: 600 }}>
              {t("board.add")}
            </button>
          )}
        </div>
        <div style={{ marginTop: 18, display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 12 }}>
          {notes.map(([id, note]) => (
            <NoteCard key={id} note={note} canEdit={canEdit}
              linkedName={note.linkedDocumentId ? names[note.linkedDocumentId] || null : null}
              onChange={(next) => actions.map("notes").set(id, next)}
              onDelete={() => actions.map("notes").delete(id)}
              onOpenLink={() => note.linkedDocumentId && openDoc(note.linkedDocumentId)} />
          ))}
          {notes.length === 0 && (
            <p style={{ gridColumn: "1 / -1", opacity: 0.6, fontSize: 14 }}>
              {canEdit ? t("board.emptyEditable") : t("board.empty")}
            </p>
          )}
        </div>
      </div>
    </div>
  )
}

export function BestiaryView({ document, context }: ToolRenderProps) {
  const coords = useMemo(
    () => ({ worldId: document.worldId, documentId: document.id }),
    [document.worldId, document.id],
  )
  const { data, status, actions, retry, handle } = useDocument(coords, codec)
  return (
    <DocumentGate status={status} onRetry={retry}>
      {data && actions && <Board data={data} actions={actions} handle={handle} canEdit={context.canEdit} worldId={document.worldId} />}
    </DocumentGate>
  )
}

export default defineTool({
  id: "bestiary",
  name: "bestiary",
  documentTypes: ["bestiary"],
  needs: [],
  render: BestiaryView,
})

Two files carry the whole thing. src/codec.ts is eight lines of data model — notes: field.map<Note>() — and every merge guarantee the board has comes from that one word, map. src/tool.tsx is the component: useDocument(coords, codec) for the data, useDocumentPresence for the faces, useHostCapability("search") for the link picker.

Why is it a map and not a list?Deep dive

A field.list would be the obvious choice for "a list of notes", and it's the wrong one here. Lists merge by position: two people inserting at the same index at the same moment produce a defined but arbitrary order, and worse, editing item 3 while someone else deletes item 1 edits the wrong note.

A field.map keyed by a generated id has no positions to disagree about. Two people adding notes both land. Two people editing different notes both land. Two people editing the same note resolve last-write-wins per key, which is the only place a conflict can even occur. Sort order is then a plain read — the at timestamp — rather than something the document has to agree on.

Storing data works through all three field types and when each is right.

How it works

Three platform ideas do most of the work here, and each has its own page:

The codec is the data model. defineStateCodec declares the shape once, and that one declaration is what merges edits, what vvd save reads to derive your manifest, and what makes the tool agentable without you writing an API. See Storing data.

Presence comes from the document, not from you. useDocumentPresence(handle) returns who else has this document open, with host-stamped names and colours you can't spoof. See Presence.

A link is an id, never a copy. The chip on a note stores linkedDocumentId and resolves the name at render time — rename the card in your world and the chip follows. See Linking documents.

Make it yours

The order that works, whatever you're turning it into:

  1. Rename Note and its fields in src/codec.ts first. notes: field.map<Note>() is the whole data model — keep the map shape and grow the value type (a stats object, a kind, a done flag) and every merge guarantee comes with you. The at timestamp is your sort order; keep it unless you're replacing it with something better.
  2. Then delete the pieces you don't need, in pairs. No world links? The linkedDocumentId field goes together with the link picker (ReferencePicker and its search capability read). No sidebar drops? The useDocDropTarget block lifts out clean on its own.
  3. Keep DocumentGate and the presence strip. They're the loading/error states and the "someone else is here" signal — the parts that make it feel finished, for free.

Next steps

  • Sheet — the same ideas with a thousand cells instead of a dozen notes.
  • Your first creation — take this one into a real world.
  • Define a tool — what every field in that defineTool call is for.