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 createscaffold 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
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
→ 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
attimestamp 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.
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”.
{
"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.
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:
- Rename
Noteand its fields insrc/codec.tsfirst.notes: field.map<Note>()is the whole data model — keep the map shape and grow the value type (astatsobject, akind, adoneflag) and every merge guarantee comes with you. Theattimestamp is your sort order; keep it unless you're replacing it with something better. - Then delete the pieces you don't need, in pairs. No world links? The
linkedDocumentIdfield goes together with the link picker (ReferencePickerand itssearchcapability read). No sidebar drops? TheuseDocDropTargetblock lifts out clean on its own. - Keep
DocumentGateand 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
defineToolcall is for.