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. MCP tools for document editing, multi
SKILL.mdskills/openwriter/SKILL.md
GitHubOpenWriter Skill
Active harness
This skill is shared by every agent harness. Harness-specific execution (how to run a background worker, which browser tool to use) lives in one short document per harness; read only the one for the harness you are running in:
- Claude Code: docs/harness-claude.md
- Codex: docs/harness-codex.md
- Setup for Claude Code, Codex and OpenCode: docs/setup.md
Tool names below are logical names: discover the current MCP namespace and input
schema. Never copy a Claude Agent(...) call from a server footer into another
harness.
Revision variants
Use Create variant → Revision on the source document. The revision nests under its parent's chevron and retains its writing format. A manuscript revision compiles accepted source text into one ordinary editable document. Unresolved references stop creation. The source and its pending suggestions stay intact. Later source changes do not flow into the revision. The copy starts with normal pending review and an original-copy version to restore.
For agents, create_editing_draft({ docId, title? }) is the compatibility name
for this same operation. It returns identity and chapter headings, never the
book body. Do not follow it with populate_document. Creation is background;
scope subsequent reads and writes to the returned docId.
Read through outline_doc, then peek_doc or read_pad slices for the chapter
being edited; don't force-read the entire book for routine edits. Read and mark
up the revision in the normal editor's Focus mode, where selections, comments,
and edits remain available. No separate reading copy or reading surface is needed.
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
NEVER OPEN THE OPENWRITER UI (localhost:5050) IN A BROWSER — Chrome, the browser pane, or any other. The browser is the user's view; every tab shares the server's one open document, so an agent tab opening a doc moves the user's screen mid-review. Read and verify through MCP tools only (
read_pad,peek_doc,get_pad_status). Need the user to look at something? Tell them the docId, orswitch_documenton their explicit request. Checking a published page (the live blog URL) in a browser is fine.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_padorpopulate_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.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.
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_itemwith the docId. Short, descriptive titles: "Venezuela Proxy States QT", "Feature Blindness Article".Metadata first. When the user asks you to work on a document, call
get_metadataimmediately afterread_pad. Tweet docs store the parent URL intweetContext.url, article docs store context inarticleContext. 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.Refresh stale loglines through server-owned claims. During authorized document work, enrichment notices signal a bounded maintenance batch. Read
docs/enrichment.md. Useclaim_enrichmentto acquire at most 12 full canonical snapshots, summarize those exact snapshots, then callmark_enrichedonce with{ docId, claimToken, logline }per document. Use a helper supported by the active harness, or work inline when helpers cannot access MCP. Empty claims mean stop, even if the dirty count is nonzero. Do not poll or repeatedly spawn workers from that count. Claims expire after five minutes; edited documents settle for five seconds. Respect workspaceenrichmentDisabledopt-outs. Report only actual completions and failures, without fixed time or cost promises.File pending sorts 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 sortin the MCP server's session-start instructions; (b) a⚠ N docs awaiting sort. Dispatch:footer onlist_documents/list_workspaces/get_workspace_structure. When you see either signal, start a sort worker immediately, before responding to the user's request, the way your harness document says (Claude Code dispatches theopenwriter-sort-minionsubagent with the call the signal carries). The worker discovers work vialist_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. At most 12 docs per run.Why a minion, not inline. Handled inline, marks rot because raising them derails 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_itemto 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, callupdate_workspace_context({ workspaceFile, context: { autoSortDisabled: true } })— its docs drop fromlist_pending_sortsand fall back to manual handling.If no worker can run (the subagent isn't installed, or the harness has no helper with MCP access): file one bounded batch inline, or tell the user once and proceed with their original request; don't loop on the failure. Your harness document has the exact fallback.
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_linkand 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_linkcall 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 realhttp://...URL. Never invent a URL scheme likedocId:abc123or hand-construct a path; the link will be dead.Orient by content first; pick by nodeId second. Never call
peek_docorget_nodeswith 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_docreads windowed slices around them. Skipping the orientation step and callingpeek_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):
search_docs(query)— workspace content search (~50 tokens per hit)browse_docs({ workspaceFile })— concept-level shelf scan (~60 tokens per doc)outline_doc(docId)— heading tree (~5 tokens per heading)search_docs(query, { docId })— in-doc content search → matching nodeIdspeek_doc(docId, target)— windowed node read by nodeIdread_pad(docId, ...)— fixed-window word-position read (default: first ~2,000 words)
read_padis 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 unlessforceis 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
lastNodeIdplus 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.
- Content entry —
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:
- Read the welcome template from this skill's
docs/welcome.md create_documentwith title "Welcome to OpenWriter"populate_documentwith the template content (arrives as pending changes — green highlights)- 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 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:
- Restart your Claude Code or OpenCode session (MCP servers load on startup)
- 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_documentsandread_padalways show both title and docId- All doc-targeting tools take
docIdas 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
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 |
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 |
create_workspace |
Create a new workspace |
delete_workspace |
Delete a workspace and all its document files (moves to OS trash) |
get_workspace_structure |
Get the workspace tree shape: containers + their IDs, docs + their filenames, workspace-level structural fields (vocab, schema, enrichment flag), plus context (characters, settings, rules). Tree shape only — per-doc loglines, status, tags, and stale flag are NOT here. Use this when you need a destination container (sort, move) or to understand nesting. For "what is each doc about" call browse_docs. |
get_item_context |
Get progressive disclosure context for a doc — workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
update_workspace_context |
Update workspace context (characters, settings, rules) |
Workspace Organization
| Tool | Description |
|---|---|
create_container |
Create a folder inside a workspace (max depth: 3) |
delete_container |
Delete a container from a workspace (doc files stay on disk) |
tag_doc |
Add a tag to a document by docId (stored in doc frontmatter) |
untag_doc |
Remove a tag from a document by docId |
move_item |
Move or reorder a doc, container, or workspace (type: doc/container/workspace). To nest a doc into a container: move_item({ type: 'doc', workspaceFile, itemId: <docId>, targetContainerId: <containerId>, afterId? }). The target param is targetContainerId — passing containerId/container instead is silently ignored and the doc lands at workspace root. |
rename_item |
Rename a workspace, container, or document (type: workspace/container/document) |
Enrichment (three-field schema)
OpenWriter detects when a doc has drifted past enrichment thresholds (sentence-hash Jaccard drift, character-count volume ratio) on every save and stamps enrichmentStale: true. The agent's job is to dispatch the enrichment minion (see firm rule 5 + docs/enrichment.md in this skill) to refresh the logline.
The three-field schema — each field has exactly one owner:
| Field | Owner | Set how |
|---|---|---|
logline |
LLM (minion) | mark_enriched({ docs: [{ docId, claimToken, logline }] }) |
status (canonical / draft) |
Agent | create_document({ status }) on create; set_metadata({ status }) on lifecycle change |
enrichmentStale |
System | OpenWriter sets on save; minion clears on mark_enriched |
Lifecycle convention for status:
- Default to
drafton new docs (omitstatusfromcreate_documentand it lands asdraft). - Flip to
canonicalwhen the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth). - Flip back to
draftwhen superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 todraft). - The common browse pattern is
browse_docs({ status: "canonical" })— that's the trusted-shelf query.
| Tool | Key Params | Description |
|---|---|---|
list_dirty_docs |
workspaceFile? |
List docs that need enrichment (never enriched OR explicitly flagged stale). Returns identity + reason only — no bodies. Optionally scoped to one workspace. Docs in opted-out workspaces (enrichmentDisabled: true) are excluded. |
claim_enrichment |
workspaceFile?, docIds?, limit? |
Acquire up to 12 exclusive full canonical snapshots with claimToken, revision and expiry. Empty docs means stop. |
mark_enriched |
docs: [{docId, claimToken, logline}] |
Stamp one or more docs as freshly enriched. Strict schema — passing domain / concepts / docRole / status fails validation. OpenWriter auto-computes baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences), clears enrichmentStale, and retires legacy fields from frontmatter. The minion calls this once at the end of its run with the full batch. |
browse_docs |
workspaceFile?, tags?, status? (canonical/draft), hasLogline? |
Bulk-read concept-level frontmatter per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies, no tree shape. Pairs with get_workspace_structure (tree shape), outline_doc (skeleton), peek_doc (windowed read), and read_pad (full body) as the read ladder. |
Sort Requests
User-triggered file-this-for-me marker. See firm rule 6 for the full procedure. The agent picks up pending sorts via the surfacing footer / SORT_STATUS notice and handles them inline.
| Tool | Key Params | Description |
|---|---|---|
list_pending_sorts |
workspaceFile? |
List docs the user has marked for sorting. Returns identity + current location + optional proposal (already written by a prior pass). |
propose_sort |
proposals: [{docId, wsFilename, containerId, reasoning}] |
Write a proposal back to one or more docs (batch flow). The sidebar flips each doc's badge to "proposal ready"; the user accepts or rejects via the in-menu popover (server applies the move on accept). |
mark_sorted |
docs: [{docId}] |
Clear the sortRequest marker after a chat-flow move (move_item first) or after deciding the doc should stay where it is. Bulk-friendly. |
Comments
| Tool | Key Params | Description |
|---|---|---|
get_comments |
docId?, scope? |
Get comments left by the user. Default scope is workspace when a docId is given (returns comments for every doc in the same project); pass scope: "document" to narrow, or scope: "all" for every doc on disk |
resolve_comments |
comment_ids |
Remove comments after addressing feedback (pass comment IDs) |
Aliases: get_nodes → peek_doc, crawl → browse_docs, get_agent_marks / resolve_agent_marks → get_comments / resolve_comments. Use the canonical names.
Task Management
| Tool | Key Params | Description |
|---|---|---|
list_tasks |
— | List all tasks for the current profile |
add_task |
text |
Add a new task to the checklist |
update_task |
id, text?, completed? |
Update a task (text or completion status) |
remove_task |
id |
Remove a task from the checklist |
Call list_tasks at session start to check for pending work from previous sessions.
Text Operations
| Tool | Key Params | Description |
|---|---|---|
edit_text |
docId, nodeId, edits |
Fine-grained text edits within a node (find/replace, add/remove marks). edits must be a JSON array, not a string. Example: edits: [{ find: "old text", replace: "new text" }] |
Image Generation
| Tool | Description |
|---|---|
insert_image |
Generate image via Gemini. Three modes: (1) docId + afterNodeId → inline insert with pending decoration. (2) set_cover: true → set as article cover. (3) Neither → generate to disk only. Requires GEMINI_API_KEY. |
Version Management
| Tool | Description |
|---|---|
list_versions |
List version history for the active document (timestamps, word counts, sizes) |
create_checkpoint |
Force a version snapshot right now — use before risky operations |
restore_version |
Restore to a previous version by timestamp (auto-creates safety checkpoint first) |
reload_from_disk |
Re-read the active document from its file on disk (for external modifications) |
Writing Strategy
OpenWriter has two distinct modes: editing existing documents and creating new content. Use the right approach for each.
Editing (write_to_pad)
For making changes to existing documents — rewrites, insertions, deletions:
- Use
write_to_padfor all edits —docIdis required (8-char hex fromlist_documentsorread_pad) - Send 3-8 changes per call for a responsive, streaming feel
- Get fresh node IDs before editing. Three patterns by edit scope:
- Short doc broad edit (≤ ~2,000 words):
read_pad({ docId })returns the full body with all node IDs in one call. - Long doc broad edit: either
read_pad({ docId, force: true })for the whole body in one shot (cost acknowledged), orread_pad({ docId, slice: {from, to} })walking 10% chunks if the edit spans the whole doc but you want predictable per-call cost. - Surgical edit (you already know the anchor from
outline_doc/search_docs/ deep-link click):peek_doc({ around: anchor })returns just the relevant region's current IDs — much cheaper than any read_pad form for targeted work.
- Short doc broad edit (≤ ~2,000 words):
- Editing a node that is already pending is safe: it stays ONE pending change reviewed against the true original. Don't make the user accept first just to revise a pending edit.
- Content accepts markdown strings (preferred) or TipTap JSON
rewritepreserves the target node's type. Sending plain prose to rewrite a heading keeps it a heading; the same for list items and blockquotes. To intentionally change a node's type, usedelete+insert. For surgical text-only edits inside a node (no risk of restructuring),edit_textis the smaller hammer.- Decoration colors: blue = rewrite, green = insert, red = delete
- Never re-populate a document to fix it.
populate_documentre-sends the entire document body — extremely token-expensive. To remove nodes, usewrite_to_padwith{ operation: "delete", nodeId: "..." }. To fix content, userewrite. Only usepopulate_documentonce during initial creation, or as a last resort if the document is severely broken.
Auto-accept mode (no pending review)
The user can turn on auto-accept on a per-doc basis (right-click the doc in the sidebar). When on, your edits commit directly — no pending decorations, no review panel for that doc. Used during fast drafting where the user isn't reviewing as you go.
get_pad_statusreturnsautoAccept: truewhen the active doc has it on. Use this to decide your cadence.- When autoAccept is true: keep writing without polling for review. Don't wait between batches. Send the next 3-8 changes the moment you're ready.
- When autoAccept is false (default): respect
pendingChanges > 0— wait for the user to accept/reject before sending more. - You don't toggle this flag yourself — only the user does, from the sidebar. If you think the user wants it, ask first.
- The flag is persisted in the doc's frontmatter as
autoAccept: true. Visible inget_metadata.
Creating New Documents (two-step flow)
Always use the two-step flow when creating new content:
1. create_document({ title: "My Doc", content_type: "document" }) ← fires instantly, shows spinner
2. populate_document({ content: "..." }) ← delivers content, clears spinner
Why two steps? MCP tool calls are atomic — the server doesn't receive the call until ALL parameters are fully generated. For a document with hundreds or thousands of words, the user would wait 30+ seconds with zero feedback while you generate content tokens. The two-step flow shows a sidebar spinner immediately (step 1 has no content to generate), then the spinner persists while you generate and deliver the content (step 2).
Rules:
create_documentdoes NOT accept acontentparameter — it always creates an empty doc- Step 1 (
create_document) — shows spinner, creates empty doc, does NOT switch the editor - Step 2 (
populate_document) — pass thedocIdfrom step 1 to write content directly to that doc, marks as pending decorations, clears the spinner. Does NOT switch the user's view — they keep working wherever they are. - Never use
write_to_padfor the initial population — usepopulate_documentexclusively
Workspace-Integrated Creation
create_document takes placement in either convention:
create_document({
title: "Opening Chapter",
content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
workspace: "My Book", ← name-based: creates workspace if it doesn't exist
container: "Chapters" ← name-based: creates container if it doesn't exist
})
Name-based (auto-create):
workspace(string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).container(string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requiresworkspace.
Id-based (target existing items — the same ids move_item/get_workspace_structure use):
workspaceFile(string) — existing workspace manifest filename (*.json). Must already exist. Alternative toworkspace.containerId(string) — existing container id (8-char hex). Must already exist in the resolved workspace. Alternative tocontainer. Requires a workspace param.
Both conventions:
afterId(string, optional) — docId (8-char hex) or containerId to place the new doc immediately after. Omit and the doc lands at the bottom of its parent (the ascending-order convention: oldest at top, newest at bottom).afterIdalone does NOT set a workspace — pass a workspace param too.- Omit all placement params for a standalone doc — the result then says UNFILED (a doc created in no workspace announces it, rather than reading as a bland success).
- A placement that can't be honored (unknown
workspaceFile/containerId, or a container with no workspace) is a hard error — the doc is never silently created unplaced. The result always states where it landed.
This eliminates the need for separate create_workspace, create_container, and move_item calls when building up a workspace. The default-bottom landing also eliminates the need for a follow-up move_item pass to fix sidebar order after every create — the doc lands in convention position the first time.
create_container accepts the same afterId parameter with identical semantics — new containers default to the bottom of their parent and can be precisely placed via afterId. The Drafts sub-container that goes under every chapter container, for example, can be created with afterId set to the chapter's Research Notes docId so it lands at the very bottom in one call.
Batched Creation (multiple docs at once)
Variants — when repurposing a doc into another format (a thread off a blog post, a LinkedIn cut of a newsletter), pass create_document({ masterDocId, variantType }) so the new doc nests under its master in the sidebar instead of floating off as a disconnected doc. Users do the same via right-click → "Create variant".
When creating two or more documents together — a tweet thread saved as separate docs, a series of blog drafts, newsletter variants, a workspace populated with several files — use declare_writes instead of looping create_document. It's one tool call, registers all sidebar spinners atomically, and survives app refreshes.
1. declare_writes({
writes: [
{ title: "Post 1", content_type: "tweet" },
{ title: "Post 2", content_type: "tweet" },
{ title: "Post 3", content_type: "tweet" },
]
})
→ returns [{ docId, filename, title }, ...]
2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
Rules:
- Each write in the batch gets its own sidebar spinner keyed to its filename — a spinner only clears when you
populate_documentthat specificdocId - Spinners persist across app refreshes (server-side registry)
- Same per-write fields as
create_document:title,content_type, optionalworkspace/container/url/path/afterId reply/quotetypes still requireurl- For a single document, use
create_document— don't reach fordeclare_writesjust to wrap one entry
Citations & footnotes
Long-form writing (especially academic-adjacent nonfiction) uses CommonMark / Pandoc footnote syntax:
- Reference (inline in prose):
text[^1]— renders as a superscript chip - Definition (anywhere in the markdown body):
[^1]: footnote text— automatically corralled into a "Footnotes" section at end-of-doc on save - Mnemonic labels allowed:
[^sapolsky2017]survives round-trip on disk; the editor shows auto-sequential display numbers regardless
Just include the syntax in populate_document content or write_to_pad content — no special tool needed. The parser handles the tokenization, the editor handles the rendering, the serializer enforces the constrained end-of-doc shape.
Scope is per-doc. Each chapter has its own [^1] … [^N] numbering; cross-doc references aren't supported at the editor level. Full guide → docs/footnotes.md.
Companion Skills (optional)
All companion skills install from the same openwriter GitHub repo unless noted:
# X/Twitter content — writing format, image gen, full pipeline
npx skills add https://github.com/travsteward/openwriter --skill x-writer
# Book-scale long-form — chapter architecture, beats, workspace management
npx skills add https://github.com/travsteward/openwriter --skill book-writer
# Channel-agnostic drafting — beats-first uncommitted drafts
npx skills add https://github.com/travsteward/openwriter --skill beat-writer
# Long-form blog posts — beats, titling, voice anchor, publish via github plugin
npx skills add https://github.com/travsteward/openwriter --skill blog-writer
# Weekly email newsletter pipeline
npx skills add https://github.com/travsteward/openwriter --skill newsletter-writer
# Copy polish to 90/100 + AI-fingerprint scrub
npx skills add https://github.com/travsteward/openwriter --skill polish
npx skills add https://github.com/travsteward/openwriter --skill anti-ai
The Author's Voice skill (voice matching, minion dispatch — used by the writers above) installs on its own: claude install github:travsteward/authors-voice. For an AI-detection pass without it, the bundled anti-ai skill stands alone.
Workflow
Research (read-only, no edits coming)
When the user asks "find X in this doc", "what does Y argue", "show me the beat about Z" — read-only intent. Use the ladder, not a default read_pad.
1. search_docs({ query: "X" }) → ranked docs across workspace
OR
browse_docs({ status: "canonical" }) → shelf-level scan of one workspace
2. outline_doc({ docId }) → heading skeleton (~5 tokens/heading)
Use underHeading to drill into one section.
3. search_docs({ query: "X", docId }) → in-doc node hits with nodeIds
OR pick a heading nodeId from step 2.
4. Read the region — pick by how wide:
- peek_doc({ docId, target: { around: nodeId, before, after } })
→ node-anchored window (small, surgical)
- read_pad({ docId, slice: { from: 0.4, to: 0.6 } })
→ percentile-anchored region (wider, contiguous)
- read_pad({ docId, force: true }) → whole body (you've accepted the cost)
Pattern: search_docs returns hits with node IDs and approximate doc positions. When the user wants the matched paragraph + a sentence either side, peek_doc around the node is right. When they want "the section that hit lives in" or "the back half of the doc that contains the hit" or "a contiguous region wider than peek's 100-node window," read_pad with a slice is the better call.
Cost on an 8,000-word chapter doc: ~1.5k tokens via the ladder (search + peek), ~3k tokens via search + read_pad slice for a 25% region, ~10k tokens for the full body via read_pad force. Match the read to the question.
Single document (editing)
1. get_pad_status → check pendingChanges and userSignaledReview
2. Orient on the doc — pick by edit shape:
- Short doc (≤ ~2,000 words): read_pad({ docId }) returns the full body
- Long doc, surgical edit (you know roughly what you're touching):
search_docs({ query, docId }) → peek_doc({ around: hitNodeId, before, after })
— fresh IDs for just the region you'll edit, ~500 tokens
- Long doc, broad edit on one section:
outline_doc({ docId }) → read_pad({ docId, slice: { from, to } })
where {from, to} bounds the section's percentile range
- Long doc, whole-body rewrite:
read_pad({ docId, force: true }) — explicit, cost acknowledged
3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
5. Wait → user accepts/rejects in browser
read_pad always returns the doc opening up to ~2,000 words unless you pass slice or force. Never assume you got the whole body from a default read_pad — the truncation response tells you what's missing and gives you the exact slice/force/peek/outline calls to continue.
For tweet/article docs: step 3 gives you the parent tweet URL (in tweetContext.url) and mode (reply/quote/tweet). Use this URL with fxtwitter to read the parent tweet for free — never search externally for it.
Multi-document
1. list_documents → see all docs with title + [docId] + wordCount
2. For each target doc, orient by wordCount:
- ≤ ~2,000 words: read_pad({ docId }) — full body in one call
- Long doc, targeted: search_docs({ query, docId }) → peek_doc({ around: hit })
- Long doc, sectional: outline_doc({ docId }) → read_pad({ docId, slice })
- Long doc, full pass: read_pad({ docId, force: true })
3. write_to_pad({ docId, changes: [...] }) → edits go to the identified doc
The wordCount on list_documents tells you up-front which docs return in full from a default read_pad and which will truncate — use it to plan the read shape per doc. A 500-word doc is one round trip; an 8,000-word doc is search + peek for surgical work, outline + slice for sectional work, or force for the rare whole-body case.
Reading patterns at a glance
| Intent | Best tool |
|---|---|
| "What's in this doc?" | outline_doc({ docId }) |
| "Find X in this doc" | search_docs({ query, docId }) → peek_doc({ around: hit }) |
| "Read around this node I already know" | peek_doc({ around: anchor }) |
| "Read this specific region of the doc" | read_pad({ docId, slice: { from, to } }) |
| "Walk the whole doc in predictable chunks" | read_pad({ docId, slice }) × N sequential calls |
| "Give me everything" | read_pad({ docId, force: true }) |
| "What's in this doc and what's it about" | outline_doc + frontmatter via get_metadata |
| "Which docs in the workspace talk about X" | search_docs({ query }) (no docId) |
| "Scan the shelf — concept-level only" | browse_docs({ workspaceFile }) |
Creating new content (two-step)
1. create_document({ title: "My Doc", content_type: "document", workspace: "Project", container: "Chapters" })
→ returns docId "a1b2c3d4", spinner appears
2. populate_document({ docId: "a1b2c3d4", content: "# ..." })
→ content delivered, spinner clears
3. read_pad → get node IDs + docId if further edits needed
4. write_to_pad({ docId: "a1b2c3d4", ... }) → refine with edits
Building a workspace (multiple docs)
1. create_document({ title: "Ch 1", content_type: "document", workspace: "My Book", container: "Chapters" })
→ returns docId "ch1docid"
2. populate_document({ docId: "ch1docid", content: "..." })
3. create_document({ title: "Ch 2", content_type: "document", workspace: "My Book", container: "Chapters" })
→ returns docId "ch2docid"
4. populate_document({ docId: "ch2docid", content: "..." })
5. create_document({ title: "Character Bible", content_type: "document", workspace: "My Book", container: "References" })
6. populate_document({ docId: "<from step 5>", content: "..." })
7. tag_doc + update_workspace_context → organize and add context
The workspace and containers are auto-created on the first create_document call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
Comments (inline feedback)
Users can select text in the browser, right-click, and leave a comment — a note attached to a specific text range. Comments appear as dotted underlines in the editor. This is the user's way of marking up a document with feedback for you to address.
1. User says "check my comments" (or you see the hint in read_pad output)
2. get_comments({ docId }) → comments for the current workspace by default
3. Address each comment → rewrite, insert, delete via write_to_pad (use docId)
4. resolve_comments([ids]) → clears decorations in browser
read_padautomatically shows comment counts: this doc + other docs- Default scope is
workspacewhen a docId is provided — you see comments across every doc in the user's current project, not just the one they're viewing - Pass
scope: "document"to narrow to one doc,scope: "all"to span everything on disk - Always resolve comments after addressing them —
resolve_commentsis a state change ("addressed, archive it"), not a destructive delete. The record stays in storage; only the decoration disappears.get_commentsskips resolved ones by default - A comment with an empty note means "fix this" — use your judgment
- A comment with a note is specific feedback — follow the instruction
Book workspace guidelines
When importing or organizing book-length projects, read the source material first and follow the grain — break content into the categories the author is already thinking in, don't impose a template.
- One concept per doc. Don't create one giant reference doc. If the material covers characters, setting, plot, and themes, those are separate documents.
- Preserve originals. Keep raw drafts separate from revised versions (e.g. Drafts vs. Chapters containers). The author needs both.
- Synthesize, don't just copy. Reorganize messy notes into clean, scannable docs (headers, bullets, sections) while keeping the author's voice and prose verbatim.
- Surface open threads. Unanswered questions, brainstorm lists, and loose ideas get their own doc — don't bury them inside reference material.
X, Blog and Newsletter Documents
X documents (tweet, reply, quote, article, threads), blog posts and newsletter issues carry their format rules: OpenWriter sends them with the document. Voice and strategy: /x-writer, /blog-writer, /newsletter-writer.
Review Etiquette
- Share the URL. Always tell the user: http://localhost:5050
- Read before writing. Always fetch the document before suggesting changes
- Don't overwhelm. 1-3 changes at a time for reviews, 3-8 for drafting
- Explain your edits. Tell the user what you changed and why
- Respect pending changes. If
pendingChanges > 0, wait for the user - Watch for the review signal. When
userSignaledReviewis true, the user is asking for your input — reading status clears it (one-shot)
Publish Platform
Requires authentication via request_login_code + verify_login. All publish tools are provided by the @openwriter/plugin-publish plugin.
Authentication
| Tool | Description |
|---|---|
request_login_code |
Send a 6-digit login code to an email address (signup or key recovery) |
verify_login |
Verify the code → API key issued + auto-saved to plugin config. With two-factor sign-in on it answers two_factor_required: ask for the authenticator (or recovery) code and call again with totp_code |
1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
2. User reads code from inbox (or agent reads via gmail skill)
3. verify_login({ email: "user@example.com", code: "123456" })
→ API key issued + auto-saved to plugin config
- Agents with email access (e.g. gmail skill) can fully automate this — zero user involvement
- Key recovery: Same flow. Old keys are automatically revoked when a new one is issued
- Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
Sending Domain
The domain site emails come from (newsletter@example.com). The website address is separate (set_site_domain). Reply-to is an update_site_settings field; get_site shows all sender settings together.
| Tool | Description |
|---|---|
set_sending_domain |
Send emails from the writer's own domain + from_email |
check_sending_domain |
Check DNS and sender verification status |
resend_sending_domain_verification |
Re-send the SendGrid sender verification email |
The old names setup_custom_domain, check_domain_status and resend_domain_verification still work and are deprecated.
Setup flow:
- Call
set_sending_domainwith domain + from_email - Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
- User checks email for SendGrid sender verification
- Wait ~30-60s, call
check_sending_domainto confirm - Both
dns_verified+sender_verified= domain ready
Social Posting & Connections
| Tool | Description |
|---|---|
list_connections |
List connected social accounts (X, LinkedIn, etc.) |
post_to_x |
Post current document to X/Twitter |
post_to_linkedin |
Post current document to LinkedIn |
Scheduling
| Tool | Description |
|---|---|
schedule_post |
Schedule a post for a specific time |
list_schedule |
List all scheduled posts |
manage_schedule |
Update or cancel a scheduled post |
list_slots |
List recurring time slots |
create_slot |
Create a recurring posting slot |
edit_slot |
Modify an existing slot |
delete_slot |
Remove a recurring slot |
Timezones: scheduled_at is UTC. Convert local times using IANA names (e.g. America/Los_Angeles), never fixed offsets — DST shifts automatically.
Newsletter
| Tool | Key Params | Description |
|---|---|---|
list_subscribers |
limit?, offset?, status?, tier?, email_off?, search?, section?, tag?, joined_after?, joined_before?, opened_within_days?, not_opened_days?, segment? |
List subscribers with IDs, emails, names, tags and email_off (turned emails off, still subscribed); section lists who gets that section's emails; segment uses a saved segment instead of the filters |
add_subscriber |
email, name?, send_welcome? |
Add a single subscriber; send_welcome also sends the welcome email (once per person) |
get_subscriber |
member_id? or email? |
One subscriber: plan, payments, activity, tags, billing country, whether emails are off, and the support actions taken on them |
set_subscriber_emails |
member_id? or email?, email_off |
Turn a subscriber's emails off (they stay subscribed with all their access; paid access and billing unchanged) or back on |
refund_subscriber |
member_id? or email?, payment_id?, amount_cents? |
Refund a paid subscriber's payment (default: what is left of the latest); their plan is unchanged |
cancel_subscription |
member_id? or email?, when (now or period_end) |
Cancel a paid subscriber's plan now or at the end of the period |
set_auto_renew |
member_id? or email?, auto_renew |
Turn a paid subscriber's renewal off (it ends at the period end) or back on |
set_founding |
member_id? or email?, founding |
Mark or unmark a paying subscriber as a founding member |
import_subscribers |
file?, csv_text? |
Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
list_newsletter_issues |
limit? |
List past sends with open/click stats — returns issue IDs |
get_newsletter_analytics |
issue_id |
Detailed drill-down: delivery stats, per-subscriber events, recipient list |
get_subscribe_embed |
(none) | Get public subscribe URL + HTML/JS embed snippets for signup forms on external sites |
Sending: to email your subscribers, publish the document to your site with publish_to_site({ send_email: true }) (add email_only: true to keep it off the site). Site posts are emailed to the site's members; see Publishing to your site below.
Analytics workflow:
1. list_newsletter_issues() → see past sends with open/click counts
2. get_newsletter_analytics({ issue_id }) → drill into a specific send
→ returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
Publishing to your site
Each profile gets a free site at <name>.openwriter.io with posts, subscribers and a paywall.
| Tool | Key Params | Description |
|---|---|---|
claim_site_name |
name |
Claim <name>.openwriter.io, or rename it |
get_site |
(none) | Site details, sending limit (sending: status, daily cap, when review was asked for), publication settings, design and tracking, payments (Stripe connected, plans), and sender: from name and address, reply-to, sending domain status, mailing address |
update_site |
name?, tagline?, about?, logo_url?, cover_url?, terms?, privacy? |
Change site details. terms and privacy (HTML) are the site's own pages at /terms and /privacy, linked in the footer; an empty string removes one |
update_site_settings |
default_audience?, auto_wall_after_days?, double_opt_in?, welcome_page?, welcome_endorsements?, recommendation_digest?, recommendation_digest_days?, welcome_email?, welcome_email_paid?, welcome_email_founding?, welcome_email_imported?, comments_enabled?, mailing_address?, private?, notify_subscribe_requests?, author_name?, email_header?, email_footer?, email_banner? (each also _free? / _paid?), reply_to?, notify_free_subscribers?, notify_paid_subscribers?, notify_cancellations?, accent_color?, heading_font?, body_font?, wordmark_url?, wordmark_dark_url?, home_layout?, nav_links?, home_links?, google_analytics_id?, plausible_domain?, block_ai_training?, show_likes?, show_bylines?, comment_images?, allow_cross_posts?, language? |
Change publication settings, the site's design (colour, typefaces, wordmark, homepage layout, links) and tracking. A mailing address is needed before the first email. Header, footer and banner appear only in post emails, for everyone or per audience. Subscriber notices: off, each or digest. welcome_endorsements (on by default) shows other writers' recommendation blurbs on the welcome page; recommendation_digest (off by default) emails each new subscriber the sites you recommend recommendation_digest_days (default 3) after joining, once, never to imported or hand-added subscribers. Likes and bylines can be hidden; readers may attach a picture to comments (off by default); other sites may cross-post public posts (on by default); language (like fr) sets the pages' language, so browsers offer readers a translation, and how post dates read |
publish_to_site |
title?, subtitle?, slug?, cover_url?, social_image_url?, seo_description?, audience?, web_only?, email_only?, comments_audience?, publish_at?, send_email?, send_free_preview?, subject_b?, ab_test_percent?, ab_test_wait_minutes?, section?, tags?, authors? |
Publish the active doc as a post, or update the post it was published as before |
unpublish_site_post |
post_id? |
Take the active doc's post off the site |
import_posts |
source (wordpress, ghost, medium, rss), file?, content?, content_base64?, url?, as? (published or draft, default draft), dry_run? |
Bring a back catalogue over from a WordPress export or site address, a Ghost export, a Medium export or any feed: title, subtitle, text, cover, original date, slug when free, tags; pictures copied while storage has room. Never emails; re-runs skip what came over. Run dry_run first. Owners and admins |
get_site_stats |
post_id? |
Subscriber counts, site totals, revenue, and one post's stats (any post by id or slug, else the active doc's): views, opens, clicks, unsubscribes and their rates |
get_subscriber_growth |
interval?, from?, to? |
Subscribers (free, paid, comp, total) at the end of each day or week |
get_paid_retention |
months? |
New paid, cancelled, churn rate and MRR per month, with today's MRR |
get_traffic_sources |
post_id?, from?, to? |
Where post views came from: direct, your site, search, social, webmail, other websites, utm_source |
request_site_review |
note? |
New sites email a limited number of people a day; ask OpenWriter to lift the limit (once a day, owners and admins) |
get_poll_results |
post_id? |
Each poll in a post: question, total votes, each choice's votes and percent (the active doc's post by default) |
attach_file_to_post |
path (or content_base64 + name), name? |
Upload a PDF, EPUB, ZIP, CSV, TXT, DOCX, XLSX or PPTX (up to 20 MB) and get a markdown link to put on its own line in the post; counts against storage |
list_surveys |
The site's surveys: questions, who can answer, closed or not, response count, page link and the marker line for a post (contributors too) | |
save_survey |
survey_id?, title?, intro?, questions? ({type, text, required?, choices?}; short_answer, long_answer, multiple_choice, checkboxes), who_can_answer? (subscribers or anyone) |
Make a survey (title and 1 to 20 questions) or change one; it gets its own page; with responses, questions can only be reworded |
close_survey |
survey_id, reopen? |
Stop new responses (the responses stay), or take them again |
delete_survey |
survey_id |
Delete a survey and all its responses (only when asked) |
get_survey_results |
survey_id |
Responses count; per question counts and percents, or the text answers |
list_survey_responses |
survey_id, limit?, offset? |
Each response with the reader's email when they were signed in, answers in words |
export_survey_responses |
survey_id, file? |
Every response as CSV, saved to file or returned |
email_subscribers |
subject, body, email?, member_id?, tier?, section?, search?, segment?, dry_run? |
Email one subscriber, a group or a saved segment a message that is not a post (never on the site), skipping subscribers who turned emails off; dry_run counts who would get it |
list_sent_messages |
limit?, offset? |
Past messages: subject, who it went to, who sent it, when, delivered and opens |
resend_post_to_unopened |
post_id, subject?, dry_run? |
Resend an emailed post once to who did not open it, from 24 hours after it was sent; dry_run counts first |
- Audience:
everyone,subscribers(free or paid),paid, orfounding. - Paywall: put the line
<!-- paywall -->between blocks. Everything above it is the free preview. One per doc, not inside a list or quote. With no marker, a walled post shows readers without access nothing past the title. The editor shows it as a "Paywall" divider (lock button in the toolbar), andread_padshows it as[hr:<id>] <!-- paywall -->. - Blocks: type each marker as a line of its own (a paragraph by itself, not in a list, quote or bold), with blank lines around it in markdown:
[paid-only]…[/paid-only]: only paid subscribers see the lines between (on the site, in their email; never in RSS or to free readers).[free-only]…[/free-only]: everyone except paid subscribers, e.g. an upgrade note. Not one inside the other.[callout]…[/callout]: the lines between in a highlighted box.[drop-cap]: the next paragraph starts with a large first letter.[subscribe]: a subscribe button (a form on the site, a button in email).[poll] Which topic next?followed directly by a bulleted list of 2 to 6 choices: readers vote once, on the page or by tapping a choice in the email; results show after voting;get_poll_resultsfor the writer. Each poll in a post needs its own question; rewording a question starts a new poll.- A file:
attach_file_to_postreturns[name](url); put that link alone on its line. [survey] reader-survey: a card linking to that survey's page (a link in email and RSS). Make the survey first withsave_survey; it returns the exact line asmarker.- Broken markers (unclosed, nested, a poll without choices) make
publish_to_sitefail with what to fix.
- The post id is saved in the doc's metadata (
sitePost), so publishing the same doc again updates that post. Only accepted text is published, never pending changes. send_emailemails the post once; later updates change the web copy only.send_free_preview(withsend_email, on a paid or founding post) also emails free subscribers the preview above the paywall.- Pictures stored in this OpenWriter (
/_images/...), in the body or ascover_url, are uploaded with the post; https images are used as they are.
Paid plans
The writer connects their own Stripe; readers pay through Stripe Checkout to the writer, less a 5% OpenWriter fee. Amounts are in cents (500 is 5.00).
| Tool | Key Params | Description |
|---|---|---|
connect_stripe |
(none) | Returns a link the writer opens to approve OpenWriter in Stripe |
disconnect_stripe |
confirm |
Stops new paid signups; existing subscriptions stay on the writer's Stripe. Only when the writer asks |
get_site_plans |
(none) | Stripe connected, 7-day trial, plans |
set_site_plans |
monthly, annual, founding?, currency?, trial_7_day? |
Replace the plan set. Each plan is { amount, benefits? }; founding may add flexible |
list_comps / grant_comp / end_comp |
email or membership_id, duration, founding? |
Free paid access for 7d, 30d, 90d, 6mo, 1yr or forever |
list_offers / create_offer / end_offer |
kind, trial_days or percent_off/amount_off + duration, plans?, code?, expires_at?, max_redemptions? |
Trial and discount links; each result has the link to share |
email_offer |
offer_id, subject, body, tier? (default free), section?, search?, dry_run? |
Email a live offer's link and terms, below the writer's message, to a group of subscribers |
pause_billing / resume_billing |
resume_at? |
Pause all paid billing for a break: no reader is charged (invoices voided), access is kept, new paid signups close; resumes on the date or by hand. Only when the writer asks |
pause_subscriber_billing |
member_id or email, paused? (default true), resume_at? |
Pause or resume one paying subscriber's billing; access is kept |
set_pledges / list_pledges |
enabled?, currency?, monthly?, annual?, founding? |
Before paid plans open, readers pledge on the subscribe page (and subscribe free); see the count and would-be monthly revenue |
email_pledgers |
subject?, body?, dry_run? |
Once plans are open, email each pledger the link to subscribe, once |
- Minimums: 5.00 a month, 30.00 a year; founding must cost more than annual.
set_site_plansreplaces the whole set, so leavingfoundingout removes it. - An offer link is
https://<name>.openwriter.io/subscribe?offer=<CODE>, for monthly and/or annual only.
Domain, sections, growth and team
Every site tool takes an optional site (a site's name or id from list_my_sites) to act on a site you are on the team of; leave it out for the active profile's own site.
| Tool | Key Params | Description |
|---|---|---|
get_site_domain / set_site_domain / check_site_domain |
hostname |
Website address on the writer's own domain (not the email sending domain): returns the CNAME (to sites.openwriter.io) and _cf-custom-hostname TXT records to add, and the status |
remove_site_domain |
confirm |
Back to <name>.openwriter.io at once. Owner only, only when the writer asks |
list_site_sections / save_site_section |
section_id?, name, slug?, description?, hide_from_home?, add_new_by_default?, show_in_nav?, position? |
Sections: separate streams of posts readers can switch on or off. Without section_id, makes one |
delete_site_section |
section_id, confirm |
Its posts move to the main publication |
add_subscribers_to_section |
section_id |
Signs every current subscriber up, silently |
list_site_tags / save_site_tag / delete_site_tag |
tag_id?, name, slug?, show_in_nav?; delete needs confirm |
Tags; tagging a post with a new name also makes one |
get_site_gifts / set_site_gifts |
enabled |
Gift subscriptions sold at /gift |
get_site_groups / set_site_group_discount |
discount_percent |
Group subscriptions (2+ seats of the annual plan) and the discount new groups get |
get_site_referrals / update_site_referrals |
enabled?, show_leaderboard?, email_line?, tiers? |
Referral program; tiers is all three, lowest first: { threshold, comp?, reward_text? } |
list_site_recommendations / recommend_site / remove_site_recommendation |
slug, blurb?, show_on_home? |
Recommend other OpenWriter sites; see who recommends yours |
get_qr_code |
target (subscribe, post, offer), id? |
A QR code for the subscribe page, a published post (its id) or an offer (its id or code): the link, the SVG, and download links for the SVG and a PNG |
list_perks / save_perk / delete_perk |
perk_id?, text, link?, code? |
What paid subscribers get besides the posts (a partner discount, a call); shown on paid readers' account page and in the paid welcome email, never to free readers. Without perk_id, adds one |
list_my_sites |
(none) | Sites you own or joined, with your role |
list_site_team / invite_to_team / update_team_member |
email, role, name?, public?; member_id to change; notify? (an admin's own subscriber emails) |
Team: admin, contributor (posts only) or byline (named on posts, no access) |
remove_team_member |
member_id, confirm |
Takes away access; their posts with no other author become the owner's |
list_guest_authors / save_guest_author / delete_guest_author |
guest_author_id?, name, bio?, photo_url?, link_url? |
Guest authors: credited on posts (publish_to_site authors) without joining the team, each with a page on the site. Without guest_author_id, adds one |
cross_post |
url or from_site + slug, note?, send_email? |
Show another OpenWriter site's public post here, credited, with a canonical link to the original. Emailed only with send_email: the note and a link, never the post's text |
list_cross_posts / remove_cross_post |
cross_post_id |
Cross-posts on the site; live: false while the original is no longer public |
accept_team_invite |
code |
Join a team with the code from the invite email |
list_subscribe_requests / decide_subscribe_request |
status?; request_id, decision |
Private site: approve or deny readers who ask to subscribe |
list_site_comments |
post_id?, reported?, status?, limit?, offset? |
Readers' comments with post, reader and report count; reported: true is the moderation queue |
approve_site_comment / delete_site_comment / pin_site_comment |
comment_id; pinned to pin |
Approve clears a reported comment's reports and keeps it up; delete takes it down for good |
ban_readers / unban_reader / list_banned_readers |
emails, reason?, delete_comments?; email |
Ban emails from commenting and subscribing; free subscriptions end, paid access is kept and listed in paid_access_kept to refund or cancel in Stripe |
tag_subscribers / list_subscriber_tags |
emails? or member_ids?, add?, remove? |
Put tags on subscribers or take them off (many at once; also from an import's tags column); list tags with how many have each |
save_segment / list_segments / delete_segment |
name, tier?, status?, email_off?, source?, search?, section?, tag?, joined_after?, joined_before?, opened_within_days?, not_opened_days? |
Named saved filters with live counts; list_subscribers, export_subscribers, get_subscriber_counts and email_subscribers take segment |
get_paid_by_country |
(none) | Paid subscribers per billing country (from Stripe), plus how many have none yet |
find_inactive_subscribers / ask_inactive_subscribers |
days? (180), min_sent? (5); ask: subject?, body?, dry_run? |
Free subscribers who opened nothing in days though emailed min_sent times, and why pruning helps delivery; ask emails them "still want this?" first |
prune_inactive_subscribers / list_pruned_subscribers |
days?, min_sent?, dry_run |
Unsubscribe them (never paid, comp or founding); dry_run: true first. Waits 7 days after an ask; the list shows who went and why |
export_site |
save_to? |
Everything as one zip: every post as Markdown (settings at the top) and HTML, subscribers CSV, comments, settings, sections, tags, plans. Without save_to, a download link good for 15 minutes |
delete_site |
confirm_name |
Owner only, cannot be undone: deletes the site and frees its address. Refused while readers still pay; cancel_subscription them first |
transfer_site_ownership |
member_id or email, confirm |
Owner only: an accepted admin becomes the owner and you stay as an admin; payments keep going to the connected Stripe until they connect their own |
publish_to_site:sectionis a section's name, slug or id (empty string for the main publication);tagsare tag names;authorsare ids fromlist_site_teamorlist_guest_authors,"owner"for the owner. A doc published to a team site keeps going to that site.publish_to_sitewithsend_emailandsubject_bruns a subject line test against the title:ab_test_percent(default 20) of recipients get one or the other, and afterab_test_wait_minutes(default 60) the subject opened more goes to the rest. It needs at least 200 recipients. Results per subject are in the post's stats (subject_test).- Referral rewards: on a site selling paid plans each tier's
comp(7d,30d,90d,6mo,1yr); otherwise itsreward_textis emailed. Referrals are off while the site is private. - A bare domain (
example.com,apex: true) needs a DNS host that can put a CNAME at the root; otherwise usewww.
Writing in the user's voice
The editor's right-click actions (Enhance, Modify, Shrink, Expand, Insert, Fill paragraph, Fill sentence) and the sidebar's transforms (Vary, Shrinkify, Expandify, Threadify, Storify, Emailify, Postify) are the user's, run from the browser on their own Claude key or their OpenWriter balance (Settings > Claude). The user teaches it their voice by right-clicking documents > "Learn my voice from this". There are no agent tools for these; point the user to them when they ask.
When you draft in the user's voice yourself, use the Author's Voice skill (see the writer skills above). Afterwards the user can right-click any paragraph > Enhance to bring it closer to their voice.
Updating
npm install -g openwriter@latest
npx openwriter setup
Then restart your Claude Code session (/mcp to reconnect).
Troubleshooting
MCP tools not available — The OpenWriter MCP server isn't configured yet. Follow the setup instructions above. After adding the MCP config, the user must restart their Claude Code session.
Browser dies mid-session — The MCP stdio pipe can break during context compaction or session resets. The HTTP server survives (crash guards), but MCP tools stop working. Reconnect by restarting the MCP server (see below). The new process enters client mode and proxies MCP calls to the surviving HTTP server. The browser will auto-reconnect.
Restarting the MCP server
Both Claude Code and Claude Desktop work the same way: there's no explicit restart button. Call list_documents (zero params, read-only, fast). If the previous process is dead, Claude auto-spawns a fresh one to satisfy the call. After code changes, kill the old process first (taskkill /F /PID <pid> on Windows, kill <pid> on macOS/Linux) so the spawn picks up the new build. Only fall back to /mcp (Claude Code) if tool calls keep returning Connection error: fetch failed.
Port 5050 busy — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
Edits don't appear — Stale node IDs. Always read_pad before write_to_pad to get fresh IDs.
"pendingChanges" never clears — User needs to accept/reject changes in the browser at http://localhost:5050.
Server not starting — Ensure openwriter works from your terminal (npm install -g openwriter first). If on Windows and the global command isn't found, the MCP config may need "command": "cmd" with "args": ["/c", "openwriter", "--no-open"].
After code changes — Run npm run build in packages/openwriter, kill the running openwriter process, then restart the MCP server. /mcp alone only reconnects to the existing process; it won't pick up new code unless the old process dies first.
Slow to load / loads last — MCP servers load sequentially in config order. Move openwriter to the first position in mcpServers in ~/.claude.json. See setup instructions above.