Skip to content
Get started— browse docs
On this page

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.

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

export const codec = defineStateCodec({
  // your fields go here
})

export default codec
src/tool.tsx
import { 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 useMemo around coords. useDocument treats coords as 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.

Note:

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:

Starting point
src/codec.ts + src/tool.tsx, running
Starting the example…

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.ts declares what a document holds — the collaborative shape.
  • src/tool.tsx reads that shape with useDocument and renders it.
  • defineTool is the one call that turns a component into an installable tool.

Next steps