Step 2: The two files
Every vvd tool is a data shape and a component — src/codec.ts and src/tool.tsx.
vvd create bestiary --tool writes two files that matter. Everything else is boilerplate you
never open.
Why two files? Because in vvd a document is shared — several people can hold it open and
edit it at the same time, so its data shape has to be something the platform can merge
across all of them. That shape is the codec, and it lives alone in src/codec.ts. The view
in src/tool.tsx never owns the data; it's just a reader (and writer) of whatever the codec
declares. Keeping them apart keeps the roles honest: merge questions live in the codec,
rendering questions live in the view.
import { defineStateCodec, field } from "@vvd/sdk"
export const codec = defineStateCodec({
// your fields go here
})
export default codecimport { useMemo } from "react"
import { DocumentGate, type ToolRenderProps, defineTool, useDocument } from "@vvd/sdk"
import { codec } from "@/codec"
function Board() {
return <p>Nothing here yet.</p>
}
export function BestiaryView({ document }: ToolRenderProps) {
const coords = useMemo(
() => ({ worldId: document.worldId, documentId: document.id }),
[document.worldId, document.id],
)
const { data, status, actions, retry } = useDocument(coords, codec)
return (
<DocumentGate status={status} onRetry={retry}>
<Board />
</DocumentGate>
)
}
export default defineTool({
id: "bestiary",
name: "bestiary",
documentTypes: ["bestiary"],
needs: [],
render: BestiaryView,
})codec.ts is the shape. defineStateCodec describes what a document holds — right now,
nothing. tool.tsx is the view. useDocument opens the document against that shape,
DocumentGate handles the "still loading" and "offline" states for you, and defineTool
is what makes this whole file an installable thing.
Two details in the view are worth knowing why they're there, because every step keeps them:
- The
useMemoaroundcoords.useDocumenttreatscoordsas the document's identity. A fresh{ worldId, documentId }object on every render would look like a different document each time — the memo keeps the identity stable. DocumentGate. A shared document has to load, and can be offline. Those are normal states for local-first software, not errors — the gate renders them so your view only ever runs against a document that's actually there.
That's the whole mental model. Everything from here is filling in the blanks.
There's no title field, no useState, no fetch — a fresh tool is deliberately almost
empty. You'll add exactly one field next.
From here, one pane
This page has no bundler, so from Step 3 on, the two files above are glued into one editable
pane — exactly like the Starter Kit pages do. Nothing is rewritten;
it's the same code, in one file instead of two. The seam is marked for you: a
// src/codec.ts comment sits above the codec half, and a // src/tool.tsx comment above
everything from function Board down. In your real project on disk these stay two separate files —
so when an error mentions the codec or a field, look in (and compare your answer against)
src/codec.ts; when it mentions the view, JSX, or a hook, look in src/tool.tsx. (One
cosmetic difference: the pane's Board adds padding/opacity styles so the empty state
reads inside the frame — your file doesn't need them.) Here's where the glued pane starts:
You should see: the pane renders "Nothing here yet." — an empty codec, a view with nothing to read, and a gate that let them both through. That's the correct starting state; every step from here edits this exact code.
Recap
src/codec.tsdeclares what a document holds — the collaborative shape.src/tool.tsxreads that shape withuseDocumentand renders it.defineToolis the one call that turns a component into an installable tool.