Skip to content

Latest commit

 

History

271 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

logo_black@2x

Rich text editor for human/AI Agent
collaboration. Headless. Extendable.

npm version GitHub stars license

Pen is a block-native rich text editor SDK for applications where people and AI write in the same document. The runtime is headless: it owns the document, selection, and history, and renders nothing you did not ask for. The document is a Yjs CRDT from the first keystroke. Pen is published as public npm packages.

Why Pen

You own the UI. Pen ships no required stylesheet and no built-in chrome. Toolbars, slash menus, and AI panels are your markup over Pen's state. Start with one component, or compose the Pen.* primitives and keep every pixel.

One write path. A keystroke, a paste, an AI rewrite, and a remote peer all become DocumentOp[] and go through editor.apply(ops, { origin }). There is no second way to change a document, so undo, review, and history read one stream instead of guessing.

AI is a writer, not a plugin. Origins record who wrote each change, so a model can stream into the document, land as tracked suggestions, and be accepted or rejected per change, with the same undo stack a human gets.

Collaborative by construction. The document is a Yjs CRDT from the first keystroke. Multiplayer adds presence and a transport; it does not change the document model.

Runs without a DOM. The same runtime works in Node, so agents, servers, and pipelines edit documents through the API the editor uses.

Quick Start

Every host follows the same two steps: build an editor, then mount it. createEditor() from @input/pen comes with the batteries preset applied; the default schema, undo, formatting shortcuts, document tools, and the streaming writer. The React and Vue hooks own the editor's lifetime themselves, so they take the preset as an option instead.

React

pnpm add @input/pen @input/pen-react react react-dom yjs y-protocols
"use client";

import { defaultPreset } from "@input/pen";
import { PenEditor, useEditor } from "@input/pen-react";

export function App() {
  const editor = useEditor({ preset: defaultPreset() });

  return <PenEditor editor={editor} />;
}

useEditor owns the editor's lifetime: one editor per component instance, destroyed on unmount, rebuilt across a StrictMode remount. Reach for createEditor directly when something outside React owns the editor (a store, a route loader, or a collaboration session) and pass the instance in as useEditor(editor), which borrows it without destroying it.

@input/pen-react is a client module and its entry points carry "use client". In Next.js App Router, call useEditor from a Client Component; @input/pen-core stays importable from server code.

Vue

pnpm add @input/pen @input/pen-vue vue yjs y-protocols
<script setup lang="ts">
import { defaultPreset } from "@input/pen";
import { PenEditor, useEditor } from "@input/pen-vue";

const editor = useEditor({ preset: defaultPreset() });
</script>

<template>
  <PenEditor :editor="editor" />
</template>

The composable mirrors the React hook: it destroys the editor it created when the component scope is disposed, and returns an editor you pass in untouched.

Vue has no "use client" directive. Mount PenEditor in the browser, not during SSR.

Vanilla DOM

pnpm add @input/pen @input/pen-dom yjs y-protocols
import { createEditor } from "@input/pen";
import { mountEditor } from "@input/pen-dom";

const editor = createEditor();

const root = document.querySelector("#app");
if (!(root instanceof HTMLElement)) {
  throw new Error("Missing #app");
}

mountEditor(editor, root);

mountEditor assembles the same field editor, root shell, and inline surfaces that the React and Vue bindings use. Construct it in the browser, not during SSR.

createEditor from @input/pen applies defaultPreset() whenever you pass no preset of your own; explicit preset, schema, and extensions options pass through unchanged. The bare constructor — no schema, no extensions, editor.undoManager an inert stub, and Mod-Z doing nothing, silently — is @input/pen-core's createEditor. Reach for that one when you compose every extension yourself.

Peer dependencies. react and react-dom, or vue, are peers of the binding you install. yjs and y-protocols are peers of @input/pen-yjs, which @input/pen-core depends on, so every Pen install needs both, including non-collaborative ones, since the document model is a Yjs document and the adapter imports awareness. yjs is a peer rather than a dependency so that exactly one copy is resolved; the adapter asserts that at document creation and fails loudly if a second copy is present. Package managers that auto-install peers will add them for you, but naming them explicitly is what pins the versions you get.

Direct imports. Sections below import @input/pen-types and @input/pen-shortcuts on top of a feature package each. Both arrive transitively with the starter, so the code resolves without them in your manifest, but list whatever you import directly, because a phantom dependency breaks as soon as the tree shifts underneath it.

Styling. The editor is functional unstyled, including on an empty document: clicks land and the first keystroke works with no CSS at all. Design tokens live in the STYLING.md that ships inside @input/pen-react.

The Document Model

Three ideas cover most of Pen.

Blocks are the document unit, and addressing is block-scoped: { blockId, offset }. Ops are the mutation currency: ten variants, including splice-text, format-text, insert-block, and move-block. Origins label the author of a change, which is how undo, suggestions, and attribution stay correct.

import { createEditor } from "@input/pen";
import { generateId } from "@input/pen-types";

const editor = createEditor();

const blockId = generateId();

editor.apply(
  [
    {
      type: "insert-block",
      blockId,
      blockType: "paragraph",
      props: {},
      position: "last",
    },
    {
      type: "splice-text",
      blockId,
      from: 0,
      to: 0,
      insert: "Every change is an op.",
    },
  ],
  { origin: "user" },
);

editor.on("commit", (event) => {
  console.log(event.origin.type, event.summary.affectedBlockIds);
});

One apply call is one commit: validated, normalized, and reported as a single commit event with a change summary. Read the result back through editor.documentState, editor.getBlock(id), or an exporter.

Build Your Own UI

Pen keeps state in the editor and hands you the pieces to render it. useToolbar reports what the current selection can do, and @input/pen-shortcuts provides the formatting commands behind the keyboard shortcuts, so your toolbar and Mod-B stay in agreement for free.

import { type Editor, useToolbar } from "@input/pen-react";
import { toggleInlineMark } from "@input/pen-shortcuts";

export function Toolbar({ editor }: { editor: Editor }) {
  const toolbar = useToolbar(editor);

  return (
    <div role="toolbar">
      <button
        type="button"
        disabled={!toolbar.canBold}
        aria-pressed={Boolean(toolbar.activeMarks.bold)}
        onClick={() => toggleInlineMark(editor, "bold")}
      >
        Bold
      </button>
      <button
        type="button"
        disabled={!toolbar.canItalic}
        aria-pressed={Boolean(toolbar.activeMarks.italic)}
        onClick={() => toggleInlineMark(editor, "italic")}
      >
        Italic
      </button>
      <span>{toolbar.blockType ?? "paragraph"}</span>
    </div>
  );
}

When you want structure without styling, @input/pen-react also ships unstyled compound primitives (Pen.Editor.*, Pen.Toolbar.*, Pen.SlashMenu.*, Pen.Search.*, Pen.AI.*, Pen.Multiplayer.*) plus hooks such as useSearch, useSelection, useSlashMenu, and useSnapshots. Use PenEditor to ship today and drop down to primitives when the design demands it.

AI Co-Authoring

aiExtension needs one thing from you: a ModelAdapter that streams events. Pen bundles no provider SDK and holds no API keys, so the model call stays in your infrastructure.

import { aiExtension } from "@input/pen-ai";
import { createEditor } from "@input/pen";
import type { ModelAdapter } from "@input/pen-types";

const model: ModelAdapter = {
  async *stream({ messages, signal }) {
    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ messages }),
      signal,
    });

    if (!response.body) {
      throw new Error("No response body");
    }

    const reader = response.body
      .pipeThrough(new TextDecoderStream())
      .getReader();

    let chunk = await reader.read();
    while (!chunk.done) {
      yield { type: "text-delta", delta: chunk.value };
      chunk = await reader.read();
    }

    yield { type: "done" };
  },
};

const editor = createEditor({
  extensions: [aiExtension({ model })],
});

By default AI edits land as tracked suggestions for a review UI; mutationPreference: "direct" applies them immediately. Because AI writes carry an ai origin through the same pipeline as human edits, a rewrite is reviewable, undoable, and attributable without a parallel code path. Subpaths cover the rest: @input/pen-ai/suggestions for proactive suggestions, @input/pen-ai/autocomplete for inline completion, @input/pen-ai/tools for document tool calls, and @input/pen-ai/stream for streaming protocol handling.

Collaboration

The document is already a CRDT, so collaboration is presence plus a network provider. multiplayerExtension owns peers, remote cursors, and selections.

import { createEditor } from "@input/pen";
import { multiplayerExtension } from "@input/pen-multiplayer";

const editor = createEditor({
  extensions: [
    multiplayerExtension({
      user: { id: "ada", name: "Ada Lovelace", color: "#8D30FF" },
    }),
  ],
});

Pen ships no server. To go over the wire, pass a sessionFactory that wraps your provider. createYjsProviderSession from @input/pen-yjs adapts anything with connect, disconnect, and status callbacks. playground/src/collaboration/session.ts is a complete y-websocket implementation.

Import And Export

@input/pen-interop moves documents in and out as HTML, Markdown, JSON, or XML. JSON is the canonical machine-readable format; XML exists for interoperability.

import { createEditor } from "@input/pen";
import {
  markdownExporter,
  markdownImporter,
} from "@input/pen-interop/markdown";

const editor = createEditor();

markdownImporter.import("# Title\n\nHello **world**.", editor, {
  replace: true,
});

const markdown = markdownExporter.export(editor);

Importers report what they dropped rather than failing silently, and HTML input is sanitized on the way in.

Without A DOM

createHeadlessEditor gives you the full runtime with no browser globals: the same ops, schema, and normalization your UI runs, in Node. The starter's carries the same batteries as its rendered constructor; @input/pen-core's is the bare one.

import { createHeadlessEditor } from "@input/pen";
import { exportPlainText } from "@input/pen-interop/json";

export async function summarize(): Promise<string> {
  const editor = createHeadlessEditor();

  await editor.whenReady();
  const text = exportPlainText(editor);
  await editor.destroy();

  return text;
}

This is the path for server-side generation, agent workflows, migrations, and tests. Most of Pen's own suite exercises the runtime with no DOM at all.

Packages

Install @input/pen and a renderer (createEditor() from the starter applies the batteries preset, and @input/pen-core plus the default schema arrive with it), then add the rest when you need them. Everything below ships as public npm packages released from this repository with provenance, so none of it needs a credential to install, and every published package commits an api-report.md next to its source as the signatures of record.

This table is the whole published set. The last three rows are companions: they arrive as dependencies of the packages above them, and installed on their own they give you helpers with no editor. Name a companion in your manifest when you import from it directly, alongside the preset or core rather than instead of it; each companion's README says the same in its first sentence.

Package What it does
@input/pen Batteries-included starter: createEditor() with the default preset built in
@input/pen-core Editor runtime: apply pipeline, selection, normalization, extension manager
@input/pen-schema Default block and inline definitions
@input/pen-yjs Yjs document adapter
@input/pen-react React primitives, hooks, and renderers: the documented renderer surface
@input/pen-vue Vue bindings over the shared DOM engine
@input/pen-dom Framework-free DOM field-editor engine
@input/pen-ai AI sessions, suggestions, autocomplete, tools, and streaming
@input/pen-multiplayer Presence, remote cursors, and remote selections
@input/pen-interop HTML, Markdown, JSON, and XML import and export
@input/pen-search Search and replace primitives
@input/pen-autoformat Markdown shortcuts while typing
@input/pen-shortcuts Keyboard shortcuts and formatting commands
@input/pen-undo Undo and redo with origin tagging
@input/pen-snapshots Snapshot history and per-character attribution
@input/pen-tools The built-in document tools: block CRUD, generation zones, tool runtime
@input/pen-transport Transports for AI streams: in-process (./direct) and SSE (./sse)
@input/pen-test Headless test utilities and deterministic fixtures
@input/pen-bench Benchmarks with recorded budgets
@input/pen-assets In-memory asset provider for development, tests, and benchmarks
@input/pen-types Companion: shared contracts, constants, and generateId
@input/pen-ingest Companion: Markdown parsing and write-op construction
@input/pen-markdown Companion: Markdown export for blocks and ranges

Architecture

Pen is layered, and dependencies point strictly downward: contracts, then the CRDT adapter, then the core runtime, then schema, rendering, and extensions. editor.apply(...) is the runtime authority boundary for document writes, extensions compose behavior without replacing it, and renderer packages stay separate from the core.

The current-state specs in spec/README.md are the contract: per-package descriptions in spec/packages/, normative rules with stable IDs in spec/rules/, and architectural invariants in spec/charter/.

Browser And Node Support

Runtime Minimum Input backend
Node >=22 n/a (headless)
Chromium 93 contenteditable on 93–120; EditContext when EditContext is a function (Chromium 121+)
Firefox 92 contenteditable
Safari / WebKit 15.4 contenteditable

Expanded field-editor mode and table-cell editing always use contenteditable, even when EditContext is present. APIs newer than this floor (EditContext, structuredClone, ResizeObserver, color-mix(), crypto.randomUUID) are feature-detected with a documented fallback and do not raise the minimum. Published packages declare engines.node: ">=22", and CI verifies both declared endpoints (Node 22 and current Node 26) plus one non-Linux runner in .github/workflows/node-matrix.yml. Raising the floor is a minor-version change; lowering it is never silent. The reasoning is in spec/rules/host.md (HOST3, HOST4).

Docs, Examples, And Playground

  • Documentation: getting started per host, core concepts, selection, extensions, commands, collaboration, AI, import and export, security, and accessibility.
  • Examples: minimal Vite apps at examples/react, examples/vue, and examples/vanilla. Each is a workspace member consuming the built packages, so pnpm build once and then pnpm --filter @input/pen-example-react dev. CI mounts each one and types into it. A drifted quickstart fails the build.
  • Playground: the reference app (editor, AI agent, document inspector, and live collaboration). Hosted on Cloudflare. Locally, pnpm --dir playground run dev after pnpm build. It is the host for pnpm test:e2e, not a starter template.

Development

pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm lint

Prefer scoped runs while iterating: pnpm --filter @input/pen-core test. Browser coverage is pnpm test:e2e. Any change to a published package needs a changeset (pnpm changeset). CONTRIBUTING.md has the full loop, including which gates run in CI.

Community

Authors

Pen is created and maintained by Krijn Rijshouwer and Input.

License

The Pen SDK is provided under the MIT License.

Copyright (c) 2026-present Input B.V.

Releases

Packages

Used by

Contributors

Languages