Step 8: Reach into the world
A note that points at a real document — search for it, drag it, or right-click to let go of it.
This is the biggest jump in the tutorial — the moment bestiary stops being an island and
starts talking to the rest of your world. Three small capabilities, all in service of one
idea: a note can point at a real document, by id, never a copy.
Why an id and not a copy? A copy starts drifting the moment it's made — rename the card and every copy is stale. An id has nothing to drift: the name gets looked up at render time, so there is exactly one of it, and it lives on the card where it belongs.
1. Give a note somewhere to point. In src/codec.ts, add linkedDocumentId to Note:
export type Note = {
text: string
linkedDocumentId: string | null
at: number
}2. Search the world for one, through the host — never fetch. In src/tool.tsx, the
search capability is one hook away. It goes through the host because the host already
knows who's asking and what they're allowed to see — every hit is permission-checked for
free, which no fetch to an endpoint of your own could promise. query is async, so it
runs inside an effect (the answer adds a debounce and a liveness guard around this same
call):
const search = useHostCapability("search")
useEffect(() => {
search.query(q, { types: ["card"], limit: 12 }).then(setHits)
}, [q, search])3. Or skip the search: accept a drag from the sidebar. Also in src/tool.tsx — the
platform's drag-and-drop contract hands your drop target the dragged document's id, and the
write is the same one the picker makes:
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() }),
})Permissions ride along here too: context.canEdit is the host telling your tool whether
this reader may write — the same tool can be opened from a read-only share link, so every
mutating control (the add button, the textarea, the picker, the drop target) gates on it.
To thread it down, the answer changes BestiaryView's signature from { document } to
{ document, context }.
One more change rides along: actions move off a visible button and onto the platform's own
right-click menu, through useContextMenu — the same menu every tool and every card in vvd
uses, so a note behaves like the rest of the app instead of inventing its own.
That's a lot of surface for one step. Press Show me without guilt — the point here is
seeing it work, not retyping 150 lines by hand. The answer is both files glued together, and
as ever it says so inline: the // src/codec.ts and // src/tool.tsx comment lines mark
where each file begins. On disk they stay separate — an error about Note or the codec
means compare against the src/codec.ts section; anything about JSX, a hook, or a
capability means the src/tool.tsx section.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
You should see: click the dashed Link a document chip on a note and type into the search box — the picker itself is real, but this page has no world behind it, so the search always comes back empty here. Run it for real (next step) against your own world — say, a bestiary card for Aria of the North or The Sunken Vale — and the same picker finds it and writes a real chip. Right-click a note for Delete and, on a linked one, Unlink document.
Search and drag both end at the same write — linkedDocumentId: hit.id either way. A link
is always an id, resolved to a name at render time through refs.meta([id]). Rename the card
in your world and the chip follows, because there's nothing to keep in sync — there's only
one copy of the name, and it isn't on the note.