Skip to content
Starter Kits— browse docs
On this page

Document (tool)

Collaborative rich text — character-level merge and live carets, from one field and one component.

Two people typing in the same paragraph is the hardest thing on this list to build and the easiest thing here to use. It's one field in the codec and one component in the render, and there is no synchronisation code in the file at all.

That's the whole kit: a title, a body, and about seventy lines around them.

You will learn

  • How to add collaborative rich text to anything, in two lines
  • Why the body is a different kind of field from the title
  • What <CollaborativeText> gives you that a <textarea> can't
  • Where the caret colours and names come from

Try it

Document, running
Starting the example…

This is the real collaborative text field, character-level merge and all. Remote carets need a second person, which a single frame can't give you — the collaborative text page runs the two-pane version.

Type a title, then write in the body. Bold and italics work; so does undo. This is the same editor the product's own prose surfaces use — you're not looking at a simplified version.

For the part a single frame can't show — a second person's caret moving through your paragraph — Collaborative text runs the two-pane version of exactly this.

Create it

vvd create field-notes --tool --template=document
Expected output:
→ Creating tool field-notes in /Users/you/dev/field-notes — from the Document template

✦  a tool called field-notes. love it.

Next:
cd field-notes
vvd run    # render it live in your world — hot-reloads as you edit
vvd save   # save a new version (a private draft)
✓ Created field-notes (tool) → /Users/you/dev/field-notes

What you'd build with it

Anything whose primary content is written rather than structured:

  • Session or campaign notes — the classic, and the reason for the example name.
  • A lore document or an in-world text — a treaty, a letter, a prophecy, written by several people at once.
  • A design brief or a spec — where the comment-and-revise loop matters more than the schema.
  • Character backstories — one document per character, linked from their card.
  • A shared changelog — everyone appending to the same page without stepping on each other.
  • A starting point for something bigger — add fields to the codec and you've got a structured document with a prose body, which is most documents.

What's in it

src/codec.ts10 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.

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

export const codec = defineStateCodec({
  // Last-write-wins title; the body is collaborative rich text — character-level
  // merge with live carets via <CollaborativeText>.
  title: field.value<string>(""),
  body: field.prose(),
})

export default codec
src/tool.tsx71 lines

The tool itself: a `defineTool` with a `render` function. This is the file you edit first.

src/tool.tsx
import { useMemo } from "react"

import { CollaborativeText, DocumentGate, defineTool, useDocument, useDocumentPresence } from "@vvd/sdk"

import { codec } from "@/codec"

const NAME = "field-notes"
const FILE = "src/tool.tsx"

// field-notes is a collaborative document: a last-write-wins title and a rich-text
// body with character-level merge — two people can type in the same paragraph and
// both edits land, each peer's caret live in their color. All of it comes from one
// field.prose() + <CollaborativeText>; there is no sync code in this file.

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>
  )
}

function Doc({ data, actions, handle, canEdit }: {
  data: { title: string; body: string }
  actions: { set(name: "title", value: string): void }
  handle: Parameters<typeof useDocumentPresence>[0]
  canEdit: boolean
}) {
  return (
    <div style={{ height: "100%", overflowY: "auto" }}>
      <div style={{ maxWidth: 720, margin: "0 auto", padding: "48px 24px 96px" }}>
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <input value={data.title} readOnly={!canEdit} placeholder="Untitled"
            onChange={(e) => actions.set("title", e.target.value)}
            style={{ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", color: "inherit", font: "inherit", fontSize: 34, fontWeight: 700, letterSpacing: -0.3 }} />
          <PresenceStrip handle={handle} />
        </div>
        <div style={{ marginTop: 18, fontSize: 16, lineHeight: 1.7 }}>
          <CollaborativeText handle={handle} field={data.body} editable={canEdit}
            placeholder="Start writing — everyone in this world sees it live…" />
        </div>
      </div>
    </div>
  )
}

export default defineTool({
  id: "field-notes",
  name: "field-notes",
  documentTypes: ["field-notes"],
  needs: [],
  render: function FieldNotesView({ document, context }) {
    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 && <Doc data={data} actions={actions} handle={handle} canEdit={context.canEdit} />}
      </DocumentGate>
    )
  },
})

Ten lines of codec, seventy of component, and the two lines doing the work are body: field.prose() and <CollaborativeText handle={handle} field={data.body} />.

Why isn't the body just a string?Deep dive

Because a string merges by replacement and prose needs to merge by character.

field.value<string>("") is last-write-wins: whoever saves second wins the whole field. If you're editing paragraph one while someone edits paragraph four, one of you loses everything they typed. That's fine for a title — titles are short, edited rarely, and a clobber is obvious and recoverable. It is not fine for a page of prose.

field.prose() stores a sequence CRDT instead. Every character has an identity, so two insertions at different points are independent and two insertions at the same point get a stable, deterministic order. Nobody loses a paragraph, ever — and the same structure is what lets a caret be a position in the text rather than an offset that goes stale.

The title in this kit is deliberately a plain field.value, so you can see both in one ten-line file and choose correctly next time.

How it works

One field type buys the whole editor. field.prose() in the codec, <CollaborativeText> in the render. Collaborative text is the full page, including toolbars and the two-pane demo.

Carets are awareness, not document state. Names and colours are stamped by the host, so a peer can't spoof either. See Presence.

The gate is not optional. <DocumentGate status={status} onRetry={retry}> is what shows "connecting" and "reconnecting" instead of an empty page. Every kit here uses it — see Define a tool.

Next steps

  • Collaborative text — the two-pane demo, with real remote carets.
  • 3D Model — the kit that stores a reference instead of content.
  • Storing data — field.value vs field.list vs field.map vs field.prose.