Skip to content
Get started— browse docs
On this page

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:

src/codec.ts
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):

src/tool.tsx
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:

src/tool.tsx
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.

Link a note to the world
src/codec.ts + src/tool.tsx

Edit and the example re-runs. Tab indents; press Escape to leave the editor.

Running · your edits, live
Starting the example…

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.

Note:

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.

Next steps