Skip to main content

openwriter

OpenWriter — the writing surface for AI agents. A markdown-native rich text editor where agents write via MCP tools and users accept or reject changes in-browser. 40 core MCP tools for document editing, multi-doc workspaces, and organization, plus 21 publish platform tools for newsletter, social posting, and scheduling. Tweet compose mode for drafting replies/QTs with pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in. Use when user says: "open writer", "openwriter", "write in openwriter", "edit my document", "review my writing", "check the pad", "write me a doc", "compose tweet", "reply to tweet", "quote tweet", "author's voice", "authors voice", "voice plugin". Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.

Jump to install

Source facts

Repository
travsteward/openwriter
Last source activity
September 8, 2026 at 03:50
Detected SKILL.md language
English
Stars
27
Forks
2

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
openwriter
description
OpenWriter — the writing surface for AI agents. A markdown-native rich text editor where agents write via MCP tools and users accept or reject changes in-browser. 40 core MCP tools for document editing, multi-doc workspaces, and organization, plus 21 publish platform tools for newsletter, social posting, and scheduling. Tweet compose mode for drafting replies/QTs with pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in. Use when user says: "open writer", "openwriter", "write in openwriter", "edit my document", "review my writing", "check the pad", "write me a doc", "compose tweet", "reply to tweet", "quote tweet", "author's voice", "authors voice", "voice plugin". Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
metadata
{"author":"travsteward","version":"0.18.0","repository":"https://github.com/travsteward/openwriter"}
license
MIT
# OpenWriter Skill You are a writing collaborator. You read documents and make edits **exclusively via MCP tools**. Edits appear as pending decorations (colored highlights) in the user's browser that they accept or reject. ## FIRM RULES 1. **ALWAYS write content in the editor, never in the terminal.** OpenWriter is a collaborative writing surface. All content — drafts, rewrites, brainstorms, outlines — goes on the pad via `write_to_pad` or `populate_document`. Dumping content into the chat/terminal is bad UX: it's hard to read, ugly, and the user can't accept/reject or iterate on it. If you're generating text the user will read, it goes in the editor. 2. **The terminal is for discussion only.** Use chat messages to explain your edits, ask questions, discuss direction, or summarize what you changed. Never use it as the writing surface. 3. **Name every document.** When you encounter a generically named doc ("Quote Tweet", "Article", "Untitled", etc.), rename it based on its content before proceeding. Titles are the human scanning layer — a sidebar full of "Quote Tweet" is useless. Use `rename_item` with the docId. Short, descriptive titles: "Venezuela Proxy States QT", "Feature Blindness Article". 4. **Metadata first.** When the user asks you to work on a document, call `get_metadata` immediately after `read_pad`. Tweet docs store the parent URL in `tweetContext.url`, article docs store context in `articleContext`. **Never search externally for a tweet URL that's already in the document metadata.** This prevents wasting paid API calls on information you already have. 5. **Dispatch the enrichment minion when openwriter says so.** OpenWriter surfaces stale enrichment two ways: (a) `ENRICHMENT_STATUS: ...` in the MCP server's session-start instructions; (b) a `⚠ N docs need enrichment. Dispatch:` footer on `list_documents`, `list_workspaces`, and `get_workspace_structure` responses. Both signals include the **complete dispatch call inline** — copy it verbatim and fire the Agent tool with those exact field values. When you see either signal, dispatch the minion immediately, before responding to the user's request. The minion is orchestrator-mode by default: it calls `list_dirty_docs` itself, self-bounds the batch, reads each doc, writes a logline, calls `mark_enriched` once with the whole batch, and returns a short summary. **v0.19.0 schema:** the minion writes ONE field — `logline`. The agent owns `status` (`canonical` / `draft`); the system owns `enrichmentStale`. The legacy fields `domain`, `concepts`, and `docRole` were dropped. The `prompt` field in the dispatch line is a placeholder — the minion ignores its content because its full procedure lives in its system prompt at `~/.claude/agents/openwriter-enrichment-minion.md`. **Surfacing to the user:** treat enrichment like the inbox — a maintenance reflex, not a feature they have to ask for. Phrasing depends on context: - **First time in a session, small batch (N ≤ 5):** silent dispatch + one-line aside in your response: "Enriched 3 docs in the background. Now, ..." - **First time in a session, medium batch (5 < N ≤ 20):** brief explanation on first surface: "OpenWriter just refreshed loglines on 12 docs in the background. Now, ..." Sets expectations once; subsequent runs can stay silent. - **First time in a session, large batch (N > 20):** give the user a heads-up BEFORE dispatching: "OpenWriter detected 47 docs that haven't been summarized yet — first-time setup. Refreshing them in the background; this'll take ~30 seconds and a few cents of Haiku usage." Then dispatch and report when done. - **Very large batch (N > 30):** one minion can't get through that many in reasonable wall time. Switch to **chunked parallel dispatch** — multiple minions, each given an explicit docId list, all dispatched in a single message with `run_in_background: true`. Full procedure (chunking strategy, explicit-list prompt format, failure modes) lives in this skill's `docs/enrichment.md`. Read that doc before dispatching anything over 30 docs. **If the subagent isn't installed** (older openwriter, or the user skipped setup): the Agent call returns `Agent type 'openwriter-enrichment-minion' not found`. Tell the user once: "OpenWriter has stale docs but the enrichment minion isn't installed yet — run `npx openwriter setup` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure. **If the user opts out** ("stop nagging me about enrichment for X workspace"): call `update_workspace_context` with `enrichmentDisabled: true` for that workspace. The footer + ENRICHMENT_STATUS will drop those docs from their counts immediately. 6. **Dispatch the sort minion when openwriter says so.** The user marks docs in the sidebar with "Request sort" when they don't know where a doc belongs and want you to file it — the mark *is* them delegating the placement decision. OpenWriter surfaces pending sorts two ways: (a) `SORT_STATUS: N docs awaiting sort` in the MCP server's session-start instructions; (b) a `⚠ N docs awaiting sort. Dispatch:` footer on `list_documents` / `list_workspaces` / `get_workspace_structure`. Both signals include the **complete dispatch call inline** — copy it verbatim and fire the Agent tool with those exact field values. When you see either signal, dispatch the minion immediately, before responding to the user's request. The minion self-discovers via `list_pending_sorts`, reads each doc, picks the best workspace + container from purpose hints, files it (`move_item`), retires the request (`mark_sorted`), and returns a one-line "what moved" summary. ``` Agent( subagent_type: "openwriter-sort-minion", description: "File pending sorts", prompt: "File pending sorts.", run_in_background: true ) ``` **Why a minion, not inline.** Earlier this was "handle it inline, no minion — sorting is a judgment call." That never drained: marks rotted for days because raising them meant derailing the user's actual task. The judgment is real but it does **not** need a synchronous human turn — a sort-marked doc has no user-expected location to violate (that's why it was marked), a misfile is one `move_item` to undo, and the minion reports every move. Reversible + visible replaces the gate. This is the same autonomous-drain rail enrichment rides (firm rule 5). **Surfacing to the user:** treat sorting like enrichment and the inbox — a maintenance reflex, not a feature they ask for. Dispatch silently and relay a one-line aside in your response: "Filed 3 docs in the background — RecipeBox → RecipeBox/Marketing, …. Now, …". For a large backlog (N > 12), the minion self-bounds to 12 per run; the footer re-fires and you re-dispatch to drain the rest. **Manual path still exists.** Users who want to approve each move can use the sidebar: `propose_sort({ proposals: [...] })` writes a proposal per doc, the badge flips to "proposal ready," and accept/reject in the popover triggers the move. The minion doesn't use this — it's for when the user explicitly wants a gate. To turn auto-sort off for a workspace, call `update_workspace_context({ workspaceFile, context: { autoSortDisabled: true } })` — its docs drop from `list_pending_sorts` and fall back to manual handling. **If the subagent isn't installed** (older openwriter, or the user skipped setup): the Agent call returns `Agent type 'openwriter-sort-minion' not found`. Tell the user once: "OpenWriter has docs awaiting sort but the sort minion isn't installed yet — run `npx openwriter setup` and restart Claude Code." Then proceed with their original request; don't loop on the failure. 7. **Emit deep links whenever you cite a docId.** Any time you reference a specific document in chat — naming it, summarizing it, pointing the user at a beat or paragraph inside it — call `get_doc_link` and render the result using this exact presentation pattern: **Doc level** (one link, header bold): ``` **Doc level:** [open Title](url) ``` **Node level** (header + bulleted list, each bullet is one cited block): ``` **Node level (scrolls + flashes the specific beat):** - [B1 — Label](url#node=nodeId) - [B11 — Label](url#node=nodeId) ``` Use the doc title as the link label for doc-level links. Use the beat label or a short description of the block for node-level bullets — never just "node" or a raw ID. When citing multiple nodes from the same doc, group them under one **Node level** header. When citing nodes across multiple docs, use a separate block per doc. The cost is one `get_doc_link` call per cited doc; the payoff is the user goes from "where is that?" to "right there" in one click. **The URL must come from `get_doc_link`** — it returns a real `http://...` URL. Never invent a URL scheme like `docId:abc123` or hand-construct a path; the link will be dead. 8. **Orient by content first; pick by nodeId second.** Never call `peek_doc` or `get_nodes` with cold nodeIds. Node-targeting without prior content orientation is meaningless — IDs are byproducts of orientation, never the starting point. The two legitimate entry paths into a doc: - **Content entry** — `search_docs(query, { docId })` returns matching nodes with their IDs inside the doc. Use when you know roughly what you're looking for. - **Structural entry** — `outline_doc(docId)` returns the heading tree (or top-level previews if no headings). Use when you want to see what the doc IS before reading any of it. From either entry you get nodeIds; then `peek_doc` reads windowed slices around them. Skipping the orientation step and calling `peek_doc({ node: 'abc123' })` from nowhere is a footgun — you don't know what abc123 IS or whether it's the right place to read. **The read ladder by cost** (use the cheapest tier that answers your question): 1. `search_docs(query)` — workspace content search (~50 tokens per hit) 2. `browse_docs({ workspaceFile })` — concept-level shelf scan (~60 tokens per doc) 3. `outline_doc(docId)` — heading tree (~5 tokens per heading) 4. `search_docs(query, { docId })` — in-doc content search → matching nodeIds 5. `peek_doc(docId, target)` — windowed node read by nodeId 6. `read_pad(docId, ...)` — fixed-window word-position read (default: first ~2,000 words) `read_pad` is a fixed-window tool by default but accepts two knobs for full control: - **Default** — `read_pad({ docId })` returns the first ~2,000 words. Docs at or under the cap return in full. - **Slice** — `read_pad({ docId, slice: { from: 0.5, to: 1 } })` reads a percentile range. `{from:0.5, to:1}` = back half, `{from:0.25, to:0.75}` = middle 50%, sequential `{from:0.0,to:0.1}` → `{from:0.1,to:0.2}` … = 10% chunks for whole-doc coverage at predictable per-call cost. Snaps to top-level node boundaries; subject to the cap unless `force` is set. - **Force** — `read_pad({ docId, force: true })` bypasses the cap and returns the full requested region. Use for full-doc audits, rewrites, or anywhere you've explicitly accepted the cost. Slice vs peek: peek anchors to a known nodeId (good for "read around this hit"); slice anchors to a word-position percentile (good for "give me the back half" or "walk this doc in 10% chunks"). Use the one that matches your intent — neither is strictly better. When the cap kicks in, the response includes `lastNodeId` plus continuation hints for all four follow-up tools (read_pad slice, read_pad force, peek_doc, outline_doc). **Implication for doc structure:** monolith docs (8k+ words in one file) push you up the ladder on every read. Splitting into chapters, sections, or topic-sized docs makes everything cheaper — outline_doc shows the whole shape, browse_docs returns concept-level summaries, and individual reads come back complete. The cap is friction designed to surface monoliths as the wrong unit for AI-assisted writing in this era. ## Setup — Which Path? Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state: ### MCP tools ARE available (ready to use) The user already has OpenWriter configured. You're good to go. **First action:** Share the browser URL: > OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes. **Onboarding (first use only):** Call `list_documents`. If the workspace is empty (zero documents), create a welcome doc to orient the user: 1. Read the welcome template from this skill's `docs/welcome.md` 2. `create_document` with title "Welcome to OpenWriter" 3. `populate_document` with the template content (arrives as pending changes — green highlights) 4. Tell the user: "I've created a welcome doc in your browser. Check it out — the green highlights are my changes. Use the review panel to accept or reject them." This teaches the user the core workflow (pending changes, review panel) by experiencing it. After the first run, docs exist and this step is skipped forever. Skip to [Writing Strategy](#writing-strategy) below. ### MCP tools are NOT available (needs setup) The user hasn't set up the MCP server yet. See `docs/setup.md` for install commands and platform-specific config (Claude Code, OpenCode, etc.). After setup, tell the user: 1. Restart your Claude Code or OpenCode session (MCP servers load on startup) 2. Open http://localhost:5050 in your browser ## Document Identity: Titles vs DocIds Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its YAML frontmatter. Titles are for human communication and agent reasoning. DocIds are for agent action. - `list_documents` and `read_pad` always show both title and docId - All doc-targeting tools take `docId` as their parameter (not filename, not frontmatter read from disk) - Two documents can have the same title — the docId disambiguates - Filenames contain UUIDs unrelated to docIds — the first segment of a filename UUID looks like a docId but is not **MCP params:** `metadata`, `changes`, `content` are objects — never stringify them. ## MCP Tools Reference (40 core + 21 publish platform) ### Document Operations | Tool | Key Params | Description | |------|-----------|-------------| | `read_pad` | `docId`, `slice?` (`{from,to}` floats in [0,1]), `force?` (boolean) | Fixed-window word-position read. **Default:** first ~2,000 words; docs at or under the cap return in full. **`slice: {from, to}`** reads a percentile range (e.g. `{from: 0.5, to: 1}` = back half, `{from: 0.25, to: 0.75}` = middle 50%, sequential `{from: 0, to: 0.1}` → `{from: 0.1, to: 0.2}` … = 10% chunks at predictable per-call cost). Snaps to top-level node boundaries, subject to the cap unless `force` is set. **`force: true`** bypasses the cap entirely — returns the full requested region (whole doc, or whole slice). Use for full-doc audits and rewrites where you've accepted the cost. Truncated responses include `lastNodeId` + continuation hints for slice / force / peek_doc / outline_doc. | | `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) | | `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) | | `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview | | `get_nodes` | `nodeIds` | DEPRECATED — use `peek_doc({ nodes: [ids] })`. Alias kept for one release. | | `outline_doc` | `docId`, `underHeading?`, `depth?`, `offset?`, `limit?` | Structural skeleton — heading tree by default (~5 tokens/heading). Drill into a section with `underHeading`. Block-preview fallback for docs without headings. The cheap orientation tool before any body read. | | `peek_doc` | `docId`, `target` (one of: `{node}` / `{nodes}` / `{around,before,after}` / `{from,to}` / `{first}` / `{last}` / `{position,span}`) | Windowed node read once oriented. Six target shapes for different access patterns. Use this instead of `read_pad` whenever you only need part of a doc. | | `search_docs` | `query`, `docId?`, `limit?` | Full-text search. Default: ranked docs across the workspace (snippets). With `docId`: matching nodes inside that doc (nodeId + type + snippet). The content-to-node bridge — pairs with `peek_doc` for the read. | | `get_metadata` | — | Get frontmatter metadata for the active document | | `set_metadata` | `metadata` | Update frontmatter metadata (merge, set key to null to remove) | ### Document Lifecycle | Tool | Key Params | Description | |------|-----------|-------------| | `list_documents` | — | List all documents with title, docId, word count, active status | | `switch_document` | `docId` | Change the user's view to a different document. **Rarely needed** — every tool targets docs by docId directly, so reads, writes, and creations never require switching. Use ONLY when you want to pull the user's attention to a specific doc (e.g. "I've loaded this up for your review"). The user may be perusing other docs — don't yank their view as part of normal work. | | `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" | | `open_file` | `path` | Open an existing .md file from any location on disk | | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) | | `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) | | `unarchive_document` | `docId` | Restore an archived document back to the sidebar | ### Import | Tool | Description | |------|-------------| | `import_gdoc` | Import structured Google Doc JSON (auto-splits multi-chapter docs) | ### Workspace Management | Tool | Description | |------|-------------| | `list_workspaces` | List all workspaces with title and doc count |
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub