openwriter 0.22.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/skill/SKILL.md CHANGED
@@ -1,712 +1,574 @@
1
- ---
2
- name: openwriter
3
- description: |
4
- OpenWriter — the writing surface for AI agents. A markdown-native rich text
5
- editor where agents write via MCP tools and users accept or reject changes
6
- in-browser. 40 core MCP tools for document editing, multi-doc workspaces,
7
- and organization, plus 21 publish platform tools for newsletter, social
8
- posting, and scheduling. Tweet compose mode for drafting replies/QTs with
9
- pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
10
-
11
- Use when user says: "open writer", "openwriter", "write in openwriter",
12
- "edit my document", "review my writing", "check the pad", "write me a doc",
13
- "compose tweet", "reply to tweet", "quote tweet", "author's voice",
14
- "authors voice", "voice plugin".
15
-
16
- Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
- metadata:
18
- author: travsteward
19
- version: "0.10.0"
20
- repository: https://github.com/travsteward/openwriter
21
- license: MIT
22
- ---
23
-
24
- # OpenWriter Skill
25
-
26
- 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.
27
-
28
- ## FIRM RULES
29
-
30
- 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.
31
- 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.
32
- 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".
33
- 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.
34
- 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`.
35
-
36
- **Surfacing to the user:** treat enrichment like the inbox — a maintenance reflex, not a feature they have to ask for. Phrasing depends on context:
37
-
38
- - **First time in a session, small batch (N ≤ 5):** silent dispatch + one-line aside in your response: "Enriched 3 docs in the background. Now, ..."
39
- - **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.
40
- - **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.
41
- - **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.
42
-
43
- **If the subagent isn't installed** (older openwriter, or the user skipped install-skill): 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 install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
44
-
45
- **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.
46
-
47
- ## Setup — Which Path?
48
-
49
- Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
50
-
51
- ### MCP tools ARE available (ready to use)
52
-
53
- The user already has OpenWriter configured. You're good to go.
54
-
55
- **First action:** Share the browser URL:
56
- > OpenWriter is at **http://localhost:5050** open it in your browser to see and review changes.
57
-
58
- **Onboarding (first use only):** Call `list_documents`. If the workspace is empty (zero documents), create a welcome doc to orient the user:
59
-
60
- 1. Read the welcome template from this skill's `docs/welcome.md`
61
- 2. `create_document` with title "Welcome to OpenWriter"
62
- 3. `populate_document` with the template content (arrives as pending changes — green highlights)
63
- 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."
64
-
65
- 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.
66
-
67
- Skip to [Writing Strategy](#writing-strategy) below.
68
-
69
- ### MCP tools are NOT available (needs setup)
70
-
71
- The user has this skill but hasn't set up the MCP server yet. One command does everything:
72
-
73
- ```bash
74
- npx openwriter install-skill
75
- ```
76
-
77
- This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
78
-
79
- **Fallback (if the command above fails):** Do it manually:
80
-
81
- ```bash
82
- npm install -g openwriter
83
- claude mcp add -s user openwriter -- openwriter --no-open
84
- ```
85
-
86
- If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
87
-
88
- ```json
89
- {
90
- "mcpServers": {
91
- "openwriter": {
92
- "command": "openwriter",
93
- "args": ["--no-open"]
94
- }
95
- }
96
- }
97
- ```
98
-
99
- After setup, tell the user:
100
- 1. Restart your Claude Code session (MCP servers load on startup)
101
- 2. Open http://localhost:5050 in your browser
102
-
103
- ## Document Identity: Titles vs DocIds
104
-
105
- 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.
106
-
107
- - `list_documents` and `read_pad` always show both title and docId
108
- - All doc-targeting tools take `docId` as their parameter (not filename, not frontmatter read from disk)
109
- - Two documents can have the same title — the docId disambiguates
110
- - Filenames contain UUIDs unrelated to docIds — the first segment of a filename UUID looks like a docId but is not
111
-
112
- **MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
113
-
114
- ## MCP Tools Reference (40 core + 21 publish platform)
115
-
116
- ### Document Operations
117
-
118
- | Tool | Key Params | Description |
119
- |------|-----------|-------------|
120
- | `read_pad` | — | Read the current document (compact tagged-line format with `id:` in header) |
121
- | `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
122
- | `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
123
- | `get_pad_status` | | Lightweight poll: word count, pending changes, userSignaledReview |
124
- | `get_nodes` | `nodeIds` | Fetch specific nodes by ID |
125
- | `get_metadata` | | Get frontmatter metadata for the active document |
126
- | `set_metadata` | `metadata` | Update frontmatter metadata (merge, set key to null to remove) |
127
-
128
- ### Document Lifecycle
129
-
130
- | Tool | Key Params | Description |
131
- |------|-----------|-------------|
132
- | `list_documents` | — | List all documents with title, docId, word count, active status |
133
- | `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. |
134
- | `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" |
135
- | `open_file` | `path` | Open an existing .md file from any location on disk |
136
- | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
137
- | `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
138
- | `unarchive_document` | `docId` | Restore an archived document back to the sidebar |
139
-
140
- ### Import
141
-
142
- | Tool | Description |
143
- |------|-------------|
144
- | `import_gdoc` | Import structured Google Doc JSON (auto-splits multi-chapter docs) |
145
-
146
- ### Workspace Management
147
-
148
- | Tool | Description |
149
- |------|-------------|
150
- | `list_workspaces` | List all workspaces with title and doc count |
151
- | `create_workspace` | Create a new workspace |
152
- | `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
153
- | `get_workspace_structure` | Get full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), workspace-level vocab/schema, plus context (characters, settings, rules) |
154
- | `get_item_context` | Get progressive disclosure context for a doc workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
155
- | `update_workspace_context` | Update workspace context (characters, settings, rules) |
156
-
157
- ### Workspace Organization
158
-
159
- | Tool | Description |
160
- |------|-------------|
161
- | `create_container` | Create a folder inside a workspace (max depth: 3) |
162
- | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
163
- | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
164
- | `untag_doc` | Remove a tag from a document by docId |
165
- | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
166
- | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
167
-
168
- ### Enrichment (three-field schema v0.19.0)
169
-
170
- 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.
171
-
172
- **The three-field schema** — each field has exactly one owner:
173
-
174
- | Field | Owner | Set how |
175
- |-------|-------|---------|
176
- | `logline` | LLM (minion) | `mark_enriched({ docs: [{ docId, logline }] })` |
177
- | `status` (`canonical` / `draft`) | Agent | `create_document({ status })` on create; `set_metadata({ status })` on lifecycle change |
178
- | `enrichmentStale` | System | OpenWriter sets on save; minion clears on `mark_enriched` |
179
-
180
- **Lifecycle convention for `status`:**
181
- - Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
182
- - Flip to `canonical` when the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth).
183
- - Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
184
- - The common crawl pattern is `crawl({ status: "canonical" })` that's the trusted-shelf query.
185
-
186
- | Tool | Key Params | Description |
187
- |------|-----------|-------------|
188
- | `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. |
189
- | `mark_enriched` | `docs: [{docId, 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. |
190
- | `crawl` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies. v0.19.0 dropped `domain` / `concepts` / `docRole` filters (their fields had no authority discipline); `status` is the replacement axis for the common load-bearing-vs-working query. |
191
-
192
- ### Comments
193
-
194
- | Tool | Key Params | Description |
195
- |------|-----------|-------------|
196
- | `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 |
197
- | `resolve_comments` | `comment_ids` | Remove comments after addressing feedback (pass comment IDs) |
198
-
199
- The older names `get_agent_marks` and `resolve_agent_marks` remain as deprecated aliases.
200
-
201
- ### Task Management
202
-
203
- | Tool | Key Params | Description |
204
- |------|-----------|-------------|
205
- | `list_tasks` | | List all tasks for the current profile |
206
- | `add_task` | `text` | Add a new task to the checklist |
207
- | `update_task` | `id`, `text?`, `completed?` | Update a task (text or completion status) |
208
- | `remove_task` | `id` | Remove a task from the checklist |
209
-
210
- Call `list_tasks` at session start to check for pending work from previous sessions.
211
-
212
- ### Text Operations
213
-
214
- | Tool | Key Params | Description |
215
- |------|-----------|-------------|
216
- | `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" }]` |
217
-
218
- ### Image Generation
219
-
220
- | Tool | Description |
221
- |------|-------------|
222
- | `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. |
223
-
224
- ### Version Management
225
-
226
- | Tool | Description |
227
- |------|-------------|
228
- | `list_versions` | List version history for the active document (timestamps, word counts, sizes) |
229
- | `create_checkpoint` | Force a version snapshot right now — use before risky operations |
230
- | `restore_version` | Restore to a previous version by timestamp (auto-creates safety checkpoint first) |
231
- | `reload_from_disk` | Re-read the active document from its file on disk (for external modifications) |
232
-
233
- ## Writing Strategy
234
-
235
- OpenWriter has two distinct modes: **editing** existing documents and **creating** new content. Use the right approach for each.
236
-
237
- ### Editing (write_to_pad)
238
-
239
- For making changes to existing documents — rewrites, insertions, deletions:
240
-
241
- - Use `write_to_pad` for all edits — **`docId` is required** (8-char hex from `list_documents` or `read_pad`)
242
- - Send **3-8 changes per call** for a responsive, streaming feel
243
- - Always `read_pad` before editing to get fresh node IDs
244
- - Respect `pendingChanges > 0` wait for the user to accept/reject before sending more
245
- - Content accepts markdown strings (preferred) or TipTap JSON
246
- - **`rewrite` preserves 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, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
247
- - Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
248
- - **Never re-populate a document to fix it.** `populate_document` re-sends the entire document body — extremely token-expensive. To remove nodes, use `write_to_pad` with `{ operation: "delete", nodeId: "..." }`. To fix content, use `rewrite`. Only use `populate_document` once during initial creation, or as a last resort if the document is severely broken.
249
-
250
- ### Auto-accept mode (no pending review)
251
-
252
- 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.
253
-
254
- - `get_pad_status` returns `autoAccept: true` when the active doc has it on. Use this to decide your cadence.
255
- - **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.
256
- - **When autoAccept is false (default):** respect `pendingChanges > 0` — wait for the user to accept/reject before sending more.
257
- - You don't toggle this flag yourself only the user does, from the sidebar. If you think the user wants it, ask first.
258
- - The flag is persisted in the doc's frontmatter as `autoAccept: true`. Visible in `get_metadata`.
259
-
260
- ### Creating New Documents (two-step flow)
261
-
262
- **Always use the two-step flow** when creating new content:
263
-
264
- ```
265
- 1. create_document({ title: "My Doc", content_type: "document" }) ← fires instantly, shows spinner
266
- 2. populate_document({ content: "..." }) ← delivers content, clears spinner
267
- ```
268
-
269
- **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).
270
-
271
- **Rules:**
272
- - `create_document` does NOT accept a `content` parameter it always creates an empty doc
273
- - Step 1 (`create_document`)shows spinner, creates empty doc, does NOT switch the editor
274
- - Step 2 (`populate_document`) pass the `docId` from 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.
275
- - Never use `write_to_pad` for the initial population — use `populate_document` exclusively
276
-
277
- ### Workspace-Integrated Creation
278
-
279
- `create_document` accepts optional `workspace` and `container` parameters for direct workspace placement:
280
-
281
- ```
282
- create_document({
283
- title: "Opening Chapter",
284
- content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
285
- workspace: "The Immortal", ← creates workspace if it doesn't exist
286
- container: "Chapters" ← creates container if it doesn't exist
287
- })
288
- ```
289
-
290
- - **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
291
- - **`container`** (string) container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
292
- - **`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 default since 0.18.0, matching the ascending-order convention: oldest at top, newest at bottom). Use `afterId` when you need surgical placement — e.g. inserting a new chapter doc immediately after the chapter's Beats doc.
293
- - All three are optional — omit `workspace` for standalone docs outside any workspace.
294
-
295
- 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.
296
-
297
- `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.
298
-
299
- ### Batched Creation (multiple docs at once)
300
-
301
- 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.
302
-
303
- ```
304
- 1. declare_writes({
305
- writes: [
306
- { title: "Post 1", content_type: "tweet" },
307
- { title: "Post 2", content_type: "tweet" },
308
- { title: "Post 3", content_type: "tweet" },
309
- ]
310
- })
311
- returns [{ docId, filename, title }, ...]
312
-
313
- 2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
314
- ```
315
-
316
- **Rules:**
317
- - Each write in the batch gets its own sidebar spinner keyed to its filename a spinner only clears when you `populate_document` that specific `docId`
318
- - Spinners persist across app refreshes (server-side registry)
319
- - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`/`afterId`
320
- - `reply` / `quote` types still require `url`
321
- - For a **single** document, use `create_document` — don't reach for `declare_writes` just to wrap one entry
322
-
323
- ### Citations & footnotes
324
-
325
- Long-form writing (especially academic-adjacent nonfiction) uses CommonMark / Pandoc footnote syntax:
326
-
327
- - **Reference** (inline in prose): `text[^1]` renders as a superscript chip
328
- - **Definition** (anywhere in the markdown body): `[^1]: footnote text` — automatically corralled into a "Footnotes" section at end-of-doc on save
329
- - **Mnemonic labels** allowed: `[^sapolsky2017]` survives round-trip on disk; the editor shows auto-sequential display numbers regardless
330
-
331
- 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.
332
-
333
- **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`.
334
-
335
- ## Companion Skills (optional)
336
-
337
- For voice-matched drafting without a custom Author's Voice profile, install the **voice-presets** skill — 5 frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass on output, install **anti-ai**. Both are optional and ship separately from this skill.
338
-
339
- ## Workflow
340
-
341
- ### Single document
342
-
343
- ```
344
- 1. get_pad_status → check pendingChanges and userSignaledReview
345
- 2. read_pad → get full document with node IDs + docId
346
- 3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
347
- 4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
348
- 5. Wait → user accepts/rejects in browser
349
- ```
350
-
351
- **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.
352
-
353
- ### Multi-document
354
-
355
- ```
356
- 1. list_documents → see all docs with title + [docId]
357
- 2. read_pad({ docId: "e5f6a7b8" }) → reads that doc directly, no switch needed
358
- 3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
359
- edits go to the identified doc, no view switch needed
360
- ```
361
-
362
- ### Creating new content (two-step)
363
-
364
- ```
365
- 1. create_document({ title: "My Doc", content_type: "document", workspace: "Project", container: "Chapters" })
366
- returns docId "a1b2c3d4", spinner appears
367
- 2. populate_document({ docId: "a1b2c3d4", content: "# ..." })
368
- content delivered, spinner clears
369
- 3. read_pad → get node IDs + docId if further edits needed
370
- 4. write_to_pad({ docId: "a1b2c3d4", ... }) → refine with edits
371
- ```
372
-
373
- ### Building a workspace (multiple docs)
374
-
375
- ```
376
- 1. create_document({ title: "Ch 1", content_type: "document", workspace: "My Book", container: "Chapters" })
377
- returns docId "ch1docid"
378
- 2. populate_document({ docId: "ch1docid", content: "..." })
379
- 3. create_document({ title: "Ch 2", content_type: "document", workspace: "My Book", container: "Chapters" })
380
- returns docId "ch2docid"
381
- 4. populate_document({ docId: "ch2docid", content: "..." })
382
- 5. create_document({ title: "Character Bible", content_type: "document", workspace: "My Book", container: "References" })
383
- 6. populate_document({ docId: "<from step 5>", content: "..." })
384
- 7. tag_doc + update_workspace_context → organize and add context
385
- ```
386
-
387
- The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
388
-
389
- ### Comments (inline feedback)
390
-
391
- 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.
392
-
393
- ```
394
- 1. User says "check my comments" (or you see the hint in read_pad output)
395
- 2. get_comments({ docId }) → comments for the current workspace by default
396
- 3. Address each comment → rewrite, insert, delete via write_to_pad (use docId)
397
- 4. resolve_comments([ids]) clears decorations in browser
398
- ```
399
-
400
- - `read_pad` automatically shows comment counts: this doc + other docs
401
- - Default scope is `workspace` when a docId is provided — you see comments across every doc in the user's current project, not just the one they're viewing
402
- - Pass `scope: "document"` to narrow to one doc, `scope: "all"` to span everything on disk
403
- - Always resolve comments after addressing them — `resolve_comments` is a state change ("addressed, archive it"), not a destructive delete. The record stays in storage; only the decoration disappears. `get_comments` skips resolved ones by default
404
- - A comment with an empty note means "fix this" — use your judgment
405
- - A comment with a note is specific feedback follow the instruction
406
-
407
- ### Book workspace guidelines
408
-
409
- 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.
410
-
411
- - **One concept per doc.** Don't create one giant reference doc. If the material covers characters, setting, plot, and themes, those are separate documents.
412
- - **Preserve originals.** Keep raw drafts separate from revised versions (e.g. Drafts vs. Chapters containers). The author needs both.
413
- - **Synthesize, don't just copy.** Reorganize messy notes into clean, scannable docs (headers, bullets, sections) while keeping the author's voice and prose verbatim.
414
- - **Surface open threads.** Unanswered questions, brainstorm lists, and loose ideas get their own doc — don't bury them inside reference material.
415
-
416
- ## Tweet Compose Mode
417
-
418
- OpenWriter doubles as a tweet compose surface. When `tweetContext` is set in a document's metadata, the editor switches to a pixel-accurate X/Twitter compose view — reply thread or quote tweet layout with embedded parent tweet, character counter, and action bar.
419
-
420
- ### Setting up a tweet document
421
-
422
- ```
423
- 1. create_document({ title: "Reply to @username", content_type: "reply", url: "https://x.com/user/status/123", empty: true })
424
- ```
425
-
426
- - **`url`** the tweet URL to reply to or quote
427
- - **`mode`** — `"reply"` (thread layout with parent above) or `"quote"` (compose above, quoted card below)
428
-
429
- The view activates automatically when `tweetContext` is present no manual toggle needed. Documents are auto-tagged `"x"` in the sidebar for discoverability.
430
-
431
- ### Working on an existing tweet document
432
-
433
- When the user asks you to work on a tweet doc, follow this exact sequence:
434
-
435
- ```
436
- 1. read_pad → get content + node IDs + docId
437
- 2. get_metadata → get tweetContext (url, mode), tags
438
- 3. Extract tweet URL → parse username + tweet ID from tweetContext.url
439
- 4. WebFetch fxtwitter → read the parent tweet for FREE
440
- 5. Check workspaces → find relevant reference docs for context
441
- 6. Write → now you have everything, edit the pad
442
- ```
443
-
444
- **Step 3-4 in detail:** Parse the URL from `tweetContext.url` (e.g. `https://x.com/HustleBitch_/status/2033641235739496554`) → extract username and ID → fetch via fxtwitter:
445
-
446
- ```
447
- WebFetch: https://api.fxtwitter.com/{username}/status/{tweet_id}
448
- ```
449
-
450
- This returns full text, metrics, media, quoted tweets — all for FREE. **Never use paid X API search to find a tweet that's already in the document metadata.**
451
-
452
- **Step 5:** If the tweet references concepts the user has written about (dimorphism, territory, frame, etc.), check their workspaces via `list_workspaces` → `get_workspace_structure` → `read_pad` on relevant reference docs. This gives you the user's framework to write from, not generic knowledge.
453
-
454
- ### Reading the parent tweet (when creating new tweet docs)
455
-
456
- Use the x-reader skill or fxtwitter API to fetch tweet data before setting up:
457
-
458
- ```
459
- WebFetch: https://api.fxtwitter.com/{username}/status/{tweet_id}
460
- ```
461
-
462
- The compose view fetches and renders the parent tweet (text, author, avatar, media, metrics) automatically from the URL.
463
-
464
- ### Template Documents
465
-
466
- Users can also create tweet and article templates directly from the browser UI using the **Templates** dropdown in the titlebar. For agent-initiated creation, `content_type` handles all metadata automatically:
467
-
468
- **Tweet:** `create_document({ title: "Tweet", content_type: "tweet", empty: true })`
469
-
470
- **Reply:** `create_document({ title: "Reply", content_type: "reply", url: "https://x.com/user/status/123", empty: true })`
471
-
472
- **Quote tweet:** `create_document({ title: "Quote Tweet", content_type: "quote", url: "https://x.com/user/status/123", empty: true })`
473
-
474
- **Article:** `create_document({ title: "Article", content_type: "article", empty: true })`
475
-
476
- ### Removing tweet mode
477
-
478
- ```
479
- set_metadata({ tweetContext: null })
480
- ```
481
-
482
- This restores the normal editor view and removes the "x" tag.
483
-
484
- ### Placeholder text
485
-
486
- - Quote mode: "Add a comment"
487
- - Reply mode: "What is happening?!"
488
-
489
- ### Compose avatar
490
-
491
- Users set their X handle by clicking the avatar circle in the compose area. The handle is saved to localStorage and the pfp loads from `unavatar.io/twitter/{handle}`.
492
-
493
- ### Creating Tweet Threads
494
-
495
- Threads are single documents with `horizontalRule` nodes separating each tweet. The compose view splits at HRs into separate tweet editors.
496
-
497
- **Do NOT use `populate_document` for threads.** Use `create_document` with `content_type: "tweet"` + `empty: true`, then `write_to_pad` with `horizontalRule` JSON nodes between tweets. The `content_type` flag sets `tweetContext` metadata automatically.
498
-
499
- **THREE RULES for thread HRs:**
500
-
501
- 1. **`horizontalRule` separators MUST use TipTap JSON `{ type: "horizontalRule" }`.** Markdown `---` does NOT create proper HR nodes.
502
- 2. **Each HR must be its own change.** Do NOT use content arrays `[{type: "horizontalRule"}, {type: "paragraph", ...}]` — this silently drops the HR.
503
- 3. **Send the ENTIRE thread in ONE `write_to_pad` call.** Do NOT split across multiple calls. Multiple calls create race conditions — if the user accepts changes between calls, pending HRs can be dropped. One call = atomic = no race conditions.
504
-
505
- ```
506
- 1. create_document({ title: "Thread title", content_type: "tweet", empty: true })
507
- 2. write_to_pad({ docId: "<docId>", changes: [
508
- { operation: "insert", afterNodeId: "end", content: "Tweet 1 paragraph 1" },
509
- { operation: "insert", afterNodeId: "end", content: "Tweet 1 paragraph 2" },
510
- { operation: "insert", afterNodeId: "end", content: { type: "horizontalRule" } },
511
- { operation: "insert", afterNodeId: "end", content: "Tweet 2 paragraph 1" },
512
- { operation: "insert", afterNodeId: "end", content: "Tweet 2 paragraph 2" },
513
- { operation: "insert", afterNodeId: "end", content: { type: "horizontalRule" } },
514
- { operation: "insert", afterNodeId: "end", content: "Tweet 3 paragraph 1" }
515
- ]})
516
- ```
517
-
518
- **For long threads (many tweets):** still send in ONE call. The changes array can hold dozens of items. Atomicity matters more than streaming feel for threads — a half-built thread with missing HRs is worse than waiting for the full thread to arrive.
519
-
520
- ### Inserting New Tweets into Existing Threads
521
-
522
- **Mid-thread insertion is unreliable.** `afterNodeId: "end"` always means document end, not after your last insert. Inserting after specific node IDs mid-document has edge cases with pending changes and image nodes.
523
-
524
- **Preferred approach: rebuild the full thread.** Delete the document and recreate with all tweets in one atomic `write_to_pad` call. This is the only pattern that reliably produces correct thread structure.
525
-
526
- **If you must insert mid-thread:** use a single `write_to_pad` call with the HR and all content targeting the same `afterNodeId` (the last node of the preceding tweet). Content inserts in reverse order when sharing an afterNodeId, so list changes in reverse. This is fragile — prefer full rebuild.
527
-
528
- **Do NOT delete empty paragraphs after images.** Images create empty `<p>` nodes after them. These look like junk but HRs (thread separators) are dependent on them. Deleting the empty paragraph kills the HR too, merging two tweets into one. Leave them alone.
529
-
530
- **NEVER bulk-delete text nodes in a thread that contains images.** Image nodes survive text deletion and become orphans stranded in the wrong position with no surrounding content. The user must then manually delete every orphan image from the browser. This is catastrophic. If you need to reorder tweets, move text around the existing images, or delete the entire document and start fresh (which properly removes everything including images).
531
-
532
- ### Paragraph Spacing in Tweets
533
-
534
- Tweet compose uses `<br>` (hardBreak) for line breaks within a paragraph. Double Enter in the browser creates a new `<p>` node (paragraph split) with visual spacing.
535
-
536
- **For agents writing via `write_to_pad`:** use separate paragraph nodes for paragraph spacing. Each paragraph gets its own node ID, enabling independent editing.
537
-
538
- ```
539
- // Correct: separate paragraph nodes for paragraph spacing
540
- write_to_pad({ docId: "...", changes: [
541
- { operation: "insert", afterNodeId: "end", content: "First paragraph of tweet." },
542
- { operation: "insert", afterNodeId: "end", content: "Second paragraph — separate node, visual gap." }
543
- ]})
544
- ```
545
-
546
- For line breaks WITHIN a single paragraph (no gap), use TipTap JSON with hardBreak:
547
-
548
- ```
549
- {
550
- type: "paragraph",
551
- content: [
552
- { type: "text", text: "Line one" },
553
- { type: "hardBreak" },
554
- { type: "text", text: "Line two (same node, no gap)" }
555
- ]
556
- }
557
- ```
558
-
559
- This applies to all tweet modes — single tweets, replies, quotes, and individual tweets within threads.
560
-
561
- ### Inserting Images into Thread Tweets
562
-
563
- After creating a thread, use `read_pad` to get node IDs, then `insert_image` to add images after specific tweets:
564
-
565
- ```
566
- 1. read_pad() → shows [p:abc123] for each tweet paragraph
567
- 2. insert_image({
568
- docId: "...",
569
- afterNodeId: "abc123", ← paragraph node ID from read_pad
570
- prompt: "...",
571
- aspect_ratio: "16:9"
572
- })
573
- ```
574
-
575
- All `insert_image` calls can run **in parallel** — no dependencies between them. Images appear with green pending decorations for user review.
576
-
577
- ### Inserting Existing Images (from disk)
578
-
579
- Copy to `~/.openwriter/profiles/Default/_images/`, then use TipTap JSON in `write_to_pad`:
580
-
581
- ```
582
- content: { "type": "image", "attrs": { "src": "/_images/my-image.png", "alt": "..." } }
583
- ```
584
-
585
- **Markdown `![alt](path)` does NOT work** — creates an empty paragraph. Always use TipTap JSON.
586
-
587
- ## Review Etiquette
588
-
589
- 1. **Share the URL.** Always tell the user: http://localhost:5050
590
- 2. **Read before writing.** Always fetch the document before suggesting changes
591
- 3. **Don't overwhelm.** 1-3 changes at a time for reviews, 3-8 for drafting
592
- 4. **Explain your edits.** Tell the user what you changed and why
593
- 5. **Respect pending changes.** If `pendingChanges > 0`, wait for the user
594
- 6. **Watch for the review signal.** When `userSignaledReview` is true, the user is asking for your input — reading status clears it (one-shot)
595
-
596
- ## Publish Platform (21 tools)
597
-
598
- Requires authentication via `request_login_code` + `verify_login`. All publish tools are provided by the `@openwriter/plugin-publish` plugin.
599
-
600
- ### Authentication
601
-
602
- | Tool | Description |
603
- |------|-------------|
604
- | `request_login_code` | Send a 6-digit login code to an email address (signup or key recovery) |
605
- | `verify_login` | Verify the code → API key issued + auto-saved to plugin config |
606
-
607
- ```
608
- 1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
609
- 2. User reads code from inbox (or agent reads via gmail skill)
610
- 3. verify_login({ email: "user@example.com", code: "123456" })
611
- → API key issued + auto-saved to plugin config
612
- ```
613
-
614
- - **Agents with email access** (e.g. gmail skill) can fully automate this — zero user involvement
615
- - **Key recovery:** Same flow. Old keys are automatically revoked when a new one is issued
616
- - Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
617
-
618
- ### Custom Domains
619
-
620
- | Tool | Description |
621
- |------|-------------|
622
- | `setup_custom_domain` | Configure a custom domain + from_email for newsletter sending |
623
- | `check_domain_status` | Check DNS and sender verification status |
624
- | `resend_domain_verification` | Re-send the SendGrid sender verification email |
625
-
626
- **Setup flow:**
627
- 1. Call `setup_custom_domain` with domain + from_email
628
- 2. Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
629
- 3. User checks email for SendGrid sender verification
630
- 4. Wait ~30-60s, call `check_domain_status` to confirm
631
- 5. Both `dns_verified` + `sender_verified` = domain ready
632
-
633
- ### Social Posting & Connections
634
-
635
- | Tool | Description |
636
- |------|-------------|
637
- | `list_connections` | List connected social accounts (X, LinkedIn, etc.) |
638
- | `post_to_x` | Post current document to X/Twitter |
639
- | `post_to_linkedin` | Post current document to LinkedIn |
640
-
641
- ### Scheduling
642
-
643
- | Tool | Description |
644
- |------|-------------|
645
- | `schedule_post` | Schedule a post for a specific time |
646
- | `list_schedule` | List all scheduled posts |
647
- | `manage_schedule` | Update or cancel a scheduled post |
648
- | `list_slots` | List recurring time slots |
649
- | `create_slot` | Create a recurring posting slot |
650
- | `edit_slot` | Modify an existing slot |
651
- | `delete_slot` | Remove a recurring slot |
652
-
653
- **Timezones:** `scheduled_at` is UTC. Convert local times using IANA names (e.g. `America/Los_Angeles`), never fixed offsets — DST shifts automatically.
654
-
655
- ### Newsletter
656
-
657
- | Tool | Key Params | Description |
658
- |------|-----------|-------------|
659
- | `send_newsletter` | `subject?`, `format?`, `test_email?`, `subscriber_ids?`, `exclude_issue_id?` | Send current document as newsletter to all subscribers, a subset, or a test address |
660
- | `list_subscribers` | `limit?`, `offset?` | List newsletter subscribers with IDs, emails, names |
661
- | `add_subscriber` | `email`, `name?` | Add a single subscriber |
662
- | `import_subscribers` | `file?`, `csv_text?` | Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
663
- | `list_newsletter_issues` | `limit?` | List past sends with open/click stats — returns issue IDs |
664
- | `get_newsletter_analytics` | `issue_id` | Detailed drill-down: delivery stats, per-subscriber events, recipient list |
665
- | `get_subscribe_embed` | *(none)* | Get public subscribe URL + HTML/JS embed snippets for signup forms on external sites |
666
-
667
- **Subscriber selection** — `send_newsletter` supports targeting:
668
- - **All subscribers** (default) — omit both params
669
- - **Specific subscribers** — pass `subscriber_ids: ["id1", "id2"]` (use `list_subscribers` for IDs)
670
- - **Send to remaining** — pass `exclude_issue_id: "..."` to send to everyone who did NOT receive that issue (use `list_newsletter_issues` for issue IDs)
671
-
672
- **Analytics workflow:**
673
- ```
674
- 1. list_newsletter_issues() → see past sends with open/click counts
675
- 2. get_newsletter_analytics({ issue_id }) → drill into a specific send
676
- → returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
677
- ```
678
-
679
- ## Author's Voice Plugin
680
-
681
- When the user enables the Author's Voice plugin in Settings, install the skill — see [authors-voice.com](https://www.authors-voice.com) for install methods. The skill handles API key setup and everything else.
682
-
683
- ## Updating
684
-
685
- ```bash
686
- npm install -g openwriter@latest
687
- npx openwriter install-skill
688
- ```
689
-
690
- Then restart your Claude Code session (`/mcp` to reconnect).
691
-
692
- ## Troubleshooting
693
-
694
- **MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
695
-
696
- **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](#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.
697
-
698
- ### Restarting the MCP server
699
-
700
- 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`.
701
-
702
- **Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
703
-
704
- **Edits don't appear** — Stale node IDs. Always `read_pad` before `write_to_pad` to get fresh IDs.
705
-
706
- **"pendingChanges" never clears** — User needs to accept/reject changes in the browser at http://localhost:5050.
707
-
708
- **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"]`.
709
-
710
- **After code changes** — Run `npm run build` in `packages/openwriter`, kill the running openwriter process, then [restart the MCP server](#restarting-the-mcp-server). `/mcp` alone only reconnects to the existing process; it won't pick up new code unless the old process dies first.
711
-
712
- **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.
1
+ ---
2
+ name: openwriter
3
+ description: |
4
+ OpenWriter — the writing surface for AI agents. A markdown-native rich text
5
+ editor where agents write via MCP tools and users accept or reject changes
6
+ in-browser. 40 core MCP tools for document editing, multi-doc workspaces,
7
+ and organization, plus 21 publish platform tools for newsletter, social
8
+ posting, and scheduling. Tweet compose mode for drafting replies/QTs with
9
+ pixel-accurate X/Twitter UI. Plain .md files on disk — no database, no lock-in.
10
+
11
+ Use when user says: "open writer", "openwriter", "write in openwriter",
12
+ "edit my document", "review my writing", "check the pad", "write me a doc",
13
+ "compose tweet", "reply to tweet", "quote tweet", "author's voice",
14
+ "authors voice", "voice plugin".
15
+
16
+ Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
+ metadata:
18
+ author: travsteward
19
+ version: "0.11.2"
20
+ repository: https://github.com/travsteward/openwriter
21
+ license: MIT
22
+ ---
23
+
24
+ # OpenWriter Skill
25
+
26
+ 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.
27
+
28
+ ## FIRM RULES
29
+
30
+ 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.
31
+ 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.
32
+ 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".
33
+ 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.
34
+ 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`.
35
+
36
+ **Surfacing to the user:** treat enrichment like the inbox — a maintenance reflex, not a feature they have to ask for. Phrasing depends on context:
37
+
38
+ - **First time in a session, small batch (N ≤ 5):** silent dispatch + one-line aside in your response: "Enriched 3 docs in the background. Now, ..."
39
+ - **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.
40
+ - **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.
41
+ - **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.
42
+
43
+ **If the subagent isn't installed** (older openwriter, or the user skipped install-skill): 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 install-skill` and restart Claude Code." Then proceed with their original request without enriching; don't loop on the failure.
44
+
45
+ **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.
46
+ 6. **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:
47
+
48
+ **Doc level** (one link, header bold):
49
+ ```
50
+ **Doc level:**
51
+ [open Title](url)
52
+ ```
53
+
54
+ **Node level** (header + bulleted list, each bullet is one cited block):
55
+ ```
56
+ **Node level (scrolls + flashes the specific beat):**
57
+ - [B1 — Label](url#node=nodeId)
58
+ - [B11 Label](url#node=nodeId)
59
+ ```
60
+
61
+ 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.
62
+
63
+ ## SetupWhich Path?
64
+
65
+ Check whether the `openwriter` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
66
+
67
+ ### MCP tools ARE available (ready to use)
68
+
69
+ The user already has OpenWriter configured. You're good to go.
70
+
71
+ **First action:** Share the browser URL:
72
+ > OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
73
+
74
+ **Onboarding (first use only):** Call `list_documents`. If the workspace is empty (zero documents), create a welcome doc to orient the user:
75
+
76
+ 1. Read the welcome template from this skill's `docs/welcome.md`
77
+ 2. `create_document` with title "Welcome to OpenWriter"
78
+ 3. `populate_document` with the template content (arrives as pending changes — green highlights)
79
+ 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."
80
+
81
+ 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.
82
+
83
+ Skip to [Writing Strategy](#writing-strategy) below.
84
+
85
+ ### MCP tools are NOT available (needs setup)
86
+
87
+ The user has this skill but hasn't set up the MCP server yet. One command does everything:
88
+
89
+ ```bash
90
+ npx openwriter install-skill
91
+ ```
92
+
93
+ This installs openwriter globally, configures the MCP server for Claude Code, and copies this skill — all in one step. After it finishes, the user just needs to restart their Claude Code session.
94
+
95
+ **Fallback (if the command above fails):** Do it manually:
96
+
97
+ ```bash
98
+ npm install -g openwriter
99
+ claude mcp add -s user openwriter -- openwriter --no-open
100
+ ```
101
+
102
+ If `claude mcp add` can't run (e.g. nested session error), edit `~/.claude.json` directly. Add `openwriter` as the **first entry** in `mcpServers`:
103
+
104
+ ```json
105
+ {
106
+ "mcpServers": {
107
+ "openwriter": {
108
+ "command": "openwriter",
109
+ "args": ["--no-open"]
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ After setup, tell the user:
116
+ 1. Restart your Claude Code session (MCP servers load on startup)
117
+ 2. Open http://localhost:5050 in your browser
118
+
119
+ ## Document Identity: Titles vs DocIds
120
+
121
+ 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.
122
+
123
+ - `list_documents` and `read_pad` always show both title and docId
124
+ - All doc-targeting tools take `docId` as their parameter (not filename, not frontmatter read from disk)
125
+ - Two documents can have the same title the docId disambiguates
126
+ - Filenames contain UUIDs unrelated to docIds the first segment of a filename UUID looks like a docId but is not
127
+
128
+ **MCP params:** `metadata`, `changes`, `content` are objects — never stringify them.
129
+
130
+ ## MCP Tools Reference (40 core + 21 publish platform)
131
+
132
+ ### Document Operations
133
+
134
+ | Tool | Key Params | Description |
135
+ |------|-----------|-------------|
136
+ | `read_pad` | | Read the current document (compact tagged-line format with `id:` in header) |
137
+ | `write_to_pad` | `docId`, `changes` | Apply edits as pending decorations (rewrite, insert, delete) |
138
+ | `populate_document` | `docId?`, `content` | Populate an empty doc with content (two-step creation flow) |
139
+ | `get_pad_status` | — | Lightweight poll: word count, pending changes, userSignaledReview |
140
+ | `get_nodes` | `nodeIds` | Fetch specific nodes by ID |
141
+ | `get_metadata` | — | Get frontmatter metadata for the active document |
142
+ | `set_metadata` | `metadata` | Update frontmatter metadata (merge, set key to null to remove) |
143
+
144
+ ### Document Lifecycle
145
+
146
+ | Tool | Key Params | Description |
147
+ |------|-----------|-------------|
148
+ | `list_documents` | | List all documents with title, docId, word count, active status |
149
+ | `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. |
150
+ | `create_document` | `content_type`, `title?`, ... | Create a new document. `content_type` is required: "document", "tweet", "reply", "quote", "article", "linkedin", "newsletter", or "blog" |
151
+ | `open_file` | `path` | Open an existing .md file from any location on disk |
152
+ | `delete_document` | `docId` | Delete a document file (moves to OS trash, recoverable) |
153
+ | `archive_document` | `docId` | Archive a document (hides from sidebar, keeps on disk) |
154
+ | `unarchive_document` | `docId` | Restore an archived document back to the sidebar |
155
+
156
+ ### Import
157
+
158
+ | Tool | Description |
159
+ |------|-------------|
160
+ | `import_gdoc` | Import structured Google Doc JSON (auto-splits multi-chapter docs) |
161
+
162
+ ### Workspace Management
163
+
164
+ | Tool | Description |
165
+ |------|-------------|
166
+ | `list_workspaces` | List all workspaces with title and doc count |
167
+ | `create_workspace` | Create a new workspace |
168
+ | `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
169
+ | `get_workspace_structure` | Get full workspace tree: containers, docs, per-doc enrichment (logline, status, STALE marker), workspace-level vocab/schema, plus context (characters, settings, rules) |
170
+ | `get_item_context` | Get progressive disclosure context for a doc workspace context + the doc's own enrichment (logline, status, enrichmentStale) |
171
+ | `update_workspace_context` | Update workspace context (characters, settings, rules) |
172
+
173
+ ### Workspace Organization
174
+
175
+ | Tool | Description |
176
+ |------|-------------|
177
+ | `create_container` | Create a folder inside a workspace (max depth: 3) |
178
+ | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
179
+ | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
180
+ | `untag_doc` | Remove a tag from a document by docId |
181
+ | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
182
+ | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
183
+
184
+ ### Enrichment (three-field schemav0.19.0)
185
+
186
+ 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.
187
+
188
+ **The three-field schema**each field has exactly one owner:
189
+
190
+ | Field | Owner | Set how |
191
+ |-------|-------|---------|
192
+ | `logline` | LLM (minion) | `mark_enriched({ docs: [{ docId, logline }] })` |
193
+ | `status` (`canonical` / `draft`) | Agent | `create_document({ status })` on create; `set_metadata({ status })` on lifecycle change |
194
+ | `enrichmentStale` | System | OpenWriter sets on save; minion clears on `mark_enriched` |
195
+
196
+ **Lifecycle convention for `status`:**
197
+ - Default to `draft` on new docs (omit `status` from `create_document` and it lands as `draft`).
198
+ - Flip to `canonical` when the doc commits to the workspace spine (Beats locked, Research Note is now load-bearing, Master Reference is the source of truth).
199
+ - Flip back to `draft` when superseded (e.g. Ch 7 Beats v3 ships → demote v1/v2 to `draft`).
200
+ - The common crawl pattern is `crawl({ status: "canonical" })` — that's the trusted-shelf query.
201
+
202
+ | Tool | Key Params | Description |
203
+ |------|-----------|-------------|
204
+ | `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. |
205
+ | `mark_enriched` | `docs: [{docId, 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. |
206
+ | `crawl` | `workspaceFile?`, `tags?`, `status?` (`canonical`/`draft`), `hasLogline?` | Bulk-read enrichment fields per doc with AND-composed filters. The agent's "scan the shelf" primitive — ~60 tokens per doc, no bodies. v0.19.0 dropped `domain` / `concepts` / `docRole` filters (their fields had no authority discipline); `status` is the replacement axis for the common load-bearing-vs-working query. |
207
+
208
+ ### Comments
209
+
210
+ | Tool | Key Params | Description |
211
+ |------|-----------|-------------|
212
+ | `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 |
213
+ | `resolve_comments` | `comment_ids` | Remove comments after addressing feedback (pass comment IDs) |
214
+
215
+ The older names `get_agent_marks` and `resolve_agent_marks` remain as deprecated aliases.
216
+
217
+ ### Task Management
218
+
219
+ | Tool | Key Params | Description |
220
+ |------|-----------|-------------|
221
+ | `list_tasks` | — | List all tasks for the current profile |
222
+ | `add_task` | `text` | Add a new task to the checklist |
223
+ | `update_task` | `id`, `text?`, `completed?` | Update a task (text or completion status) |
224
+ | `remove_task` | `id` | Remove a task from the checklist |
225
+
226
+ Call `list_tasks` at session start to check for pending work from previous sessions.
227
+
228
+ ### Text Operations
229
+
230
+ | Tool | Key Params | Description |
231
+ |------|-----------|-------------|
232
+ | `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" }]` |
233
+
234
+ ### Image Generation
235
+
236
+ | Tool | Description |
237
+ |------|-------------|
238
+ | `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. |
239
+
240
+ ### Version Management
241
+
242
+ | Tool | Description |
243
+ |------|-------------|
244
+ | `list_versions` | List version history for the active document (timestamps, word counts, sizes) |
245
+ | `create_checkpoint` | Force a version snapshot right now — use before risky operations |
246
+ | `restore_version` | Restore to a previous version by timestamp (auto-creates safety checkpoint first) |
247
+ | `reload_from_disk` | Re-read the active document from its file on disk (for external modifications) |
248
+
249
+ ## Writing Strategy
250
+
251
+ OpenWriter has two distinct modes: **editing** existing documents and **creating** new content. Use the right approach for each.
252
+
253
+ ### Editing (write_to_pad)
254
+
255
+ For making changes to existing documents rewrites, insertions, deletions:
256
+
257
+ - Use `write_to_pad` for all edits**`docId` is required** (8-char hex from `list_documents` or `read_pad`)
258
+ - Send **3-8 changes per call** for a responsive, streaming feel
259
+ - Always `read_pad` before editing to get fresh node IDs
260
+ - Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
261
+ - Content accepts markdown strings (preferred) or TipTap JSON
262
+ - **`rewrite` preserves 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, use `delete` + `insert`. For surgical text-only edits inside a node (no risk of restructuring), `edit_text` is the smaller hammer.
263
+ - Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
264
+ - **Never re-populate a document to fix it.** `populate_document` re-sends the entire document body — extremely token-expensive. To remove nodes, use `write_to_pad` with `{ operation: "delete", nodeId: "..." }`. To fix content, use `rewrite`. Only use `populate_document` once during initial creation, or as a last resort if the document is severely broken.
265
+
266
+ ### Auto-accept mode (no pending review)
267
+
268
+ 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.
269
+
270
+ - `get_pad_status` returns `autoAccept: true` when the active doc has it on. Use this to decide your cadence.
271
+ - **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.
272
+ - **When autoAccept is false (default):** respect `pendingChanges > 0` — wait for the user to accept/reject before sending more.
273
+ - You don't toggle this flag yourself only the user does, from the sidebar. If you think the user wants it, ask first.
274
+ - The flag is persisted in the doc's frontmatter as `autoAccept: true`. Visible in `get_metadata`.
275
+
276
+ ### Creating New Documents (two-step flow)
277
+
278
+ **Always use the two-step flow** when creating new content:
279
+
280
+ ```
281
+ 1. create_document({ title: "My Doc", content_type: "document" }) ← fires instantly, shows spinner
282
+ 2. populate_document({ content: "..." }) ← delivers content, clears spinner
283
+ ```
284
+
285
+ **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).
286
+
287
+ **Rules:**
288
+ - `create_document` does NOT accept a `content` parameter — it always creates an empty doc
289
+ - Step 1 (`create_document`) — shows spinner, creates empty doc, does NOT switch the editor
290
+ - Step 2 (`populate_document`) — pass the `docId` from 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.
291
+ - Never use `write_to_pad` for the initial population use `populate_document` exclusively
292
+
293
+ ### Workspace-Integrated Creation
294
+
295
+ `create_document` accepts optional `workspace` and `container` parameters for direct workspace placement:
296
+
297
+ ```
298
+ create_document({
299
+ title: "Opening Chapter",
300
+ content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
301
+ workspace: "The Immortal", creates workspace if it doesn't exist
302
+ container: "Chapters" ← creates container if it doesn't exist
303
+ })
304
+ ```
305
+
306
+ - **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
307
+ - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
308
+ - **`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 default since 0.18.0, matching the ascending-order convention: oldest at top, newest at bottom). Use `afterId` when you need surgical placement — e.g. inserting a new chapter doc immediately after the chapter's Beats doc.
309
+ - All three are optional — omit `workspace` for standalone docs outside any workspace.
310
+
311
+ 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.
312
+
313
+ `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.
314
+
315
+ ### Batched Creation (multiple docs at once)
316
+
317
+ 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.
318
+
319
+ ```
320
+ 1. declare_writes({
321
+ writes: [
322
+ { title: "Post 1", content_type: "tweet" },
323
+ { title: "Post 2", content_type: "tweet" },
324
+ { title: "Post 3", content_type: "tweet" },
325
+ ]
326
+ })
327
+ returns [{ docId, filename, title }, ...]
328
+
329
+ 2. populate_document({ docId: "...", content: "..." }) ← one call per doc, parallel is fine
330
+ ```
331
+
332
+ **Rules:**
333
+ - Each write in the batch gets its own sidebar spinner keyed to its filename a spinner only clears when you `populate_document` that specific `docId`
334
+ - Spinners persist across app refreshes (server-side registry)
335
+ - Same per-write fields as `create_document`: `title`, `content_type`, optional `workspace`/`container`/`url`/`path`/`afterId`
336
+ - `reply` / `quote` types still require `url`
337
+ - For a **single** document, use `create_document` don't reach for `declare_writes` just to wrap one entry
338
+
339
+ ### Citations & footnotes
340
+
341
+ Long-form writing (especially academic-adjacent nonfiction) uses CommonMark / Pandoc footnote syntax:
342
+
343
+ - **Reference** (inline in prose): `text[^1]` — renders as a superscript chip
344
+ - **Definition** (anywhere in the markdown body): `[^1]: footnote text` automatically corralled into a "Footnotes" section at end-of-doc on save
345
+ - **Mnemonic labels** allowed: `[^sapolsky2017]` survives round-trip on disk; the editor shows auto-sequential display numbers regardless
346
+
347
+ 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.
348
+
349
+ **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`.
350
+
351
+ ## Companion Skills (optional)
352
+
353
+ All companion skills install from the same openwriter GitHub repo unless noted:
354
+
355
+ ```bash
356
+ # X/Twitter content writing format, image gen, full pipeline
357
+ npx skills add https://github.com/travsteward/openwriter --skill x-writer
358
+
359
+ # Book-scale long-form chapter architecture, beats, workspace management
360
+ npx skills add https://github.com/travsteward/openwriter --skill book-writer
361
+
362
+ # Author's Voice voice matching, minion dispatch, anti-AI (required by both above)
363
+ claude install github:travsteward/authors-voice
364
+ ```
365
+
366
+ For voice-matched drafting without a custom voice profile, install **voice-presets** — 5 pre-built frames (authority, provocateur, logical, storyteller, business). For an AI-detection pass without full authors-voice setup, install **anti-ai**. Both are optional.
367
+
368
+ ## Workflow
369
+
370
+ ### Single document
371
+
372
+ ```
373
+ 1. get_pad_status → check pendingChanges and userSignaledReview
374
+ 2. read_pad → get full document with node IDs + docId
375
+ 3. get_metadata → check tweetContext/articleContext for URLs, mode, tags
376
+ 4. write_to_pad({ docId: "a1b2c3d4", changes: [...] })
377
+ 5. Wait user accepts/rejects in browser
378
+ ```
379
+
380
+ **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.
381
+
382
+ ### Multi-document
383
+
384
+ ```
385
+ 1. list_documents → see all docs with title + [docId]
386
+ 2. read_pad({ docId: "e5f6a7b8" }) → reads that doc directly, no switch needed
387
+ 3. write_to_pad({ docId: "e5f6a7b8", changes: [...] })
388
+ → edits go to the identified doc, no view switch needed
389
+ ```
390
+
391
+ ### Creating new content (two-step)
392
+
393
+ ```
394
+ 1. create_document({ title: "My Doc", content_type: "document", workspace: "Project", container: "Chapters" })
395
+ returns docId "a1b2c3d4", spinner appears
396
+ 2. populate_document({ docId: "a1b2c3d4", content: "# ..." })
397
+ content delivered, spinner clears
398
+ 3. read_pad → get node IDs + docId if further edits needed
399
+ 4. write_to_pad({ docId: "a1b2c3d4", ... }) → refine with edits
400
+ ```
401
+
402
+ ### Building a workspace (multiple docs)
403
+
404
+ ```
405
+ 1. create_document({ title: "Ch 1", content_type: "document", workspace: "My Book", container: "Chapters" })
406
+ → returns docId "ch1docid"
407
+ 2. populate_document({ docId: "ch1docid", content: "..." })
408
+ 3. create_document({ title: "Ch 2", content_type: "document", workspace: "My Book", container: "Chapters" })
409
+ returns docId "ch2docid"
410
+ 4. populate_document({ docId: "ch2docid", content: "..." })
411
+ 5. create_document({ title: "Character Bible", content_type: "document", workspace: "My Book", container: "References" })
412
+ 6. populate_document({ docId: "<from step 5>", content: "..." })
413
+ 7. tag_doc + update_workspace_context → organize and add context
414
+ ```
415
+
416
+ The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
417
+
418
+ ### Comments (inline feedback)
419
+
420
+ 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.
421
+
422
+ ```
423
+ 1. User says "check my comments" (or you see the hint in read_pad output)
424
+ 2. get_comments({ docId }) → comments for the current workspace by default
425
+ 3. Address each comment → rewrite, insert, delete via write_to_pad (use docId)
426
+ 4. resolve_comments([ids]) → clears decorations in browser
427
+ ```
428
+
429
+ - `read_pad` automatically shows comment counts: this doc + other docs
430
+ - Default scope is `workspace` when a docId is provided — you see comments across every doc in the user's current project, not just the one they're viewing
431
+ - Pass `scope: "document"` to narrow to one doc, `scope: "all"` to span everything on disk
432
+ - Always resolve comments after addressing them — `resolve_comments` is a state change ("addressed, archive it"), not a destructive delete. The record stays in storage; only the decoration disappears. `get_comments` skips resolved ones by default
433
+ - A comment with an empty note means "fix this" use your judgment
434
+ - A comment with a note is specific feedback — follow the instruction
435
+
436
+ ### Book workspace guidelines
437
+
438
+ 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.
439
+
440
+ - **One concept per doc.** Don't create one giant reference doc. If the material covers characters, setting, plot, and themes, those are separate documents.
441
+ - **Preserve originals.** Keep raw drafts separate from revised versions (e.g. Drafts vs. Chapters containers). The author needs both.
442
+ - **Synthesize, don't just copy.** Reorganize messy notes into clean, scannable docs (headers, bullets, sections) while keeping the author's voice and prose verbatim.
443
+ - **Surface open threads.** Unanswered questions, brainstorm lists, and loose ideas get their own doc — don't bury them inside reference material.
444
+
445
+ ## X Content (Tweets, Threads, Articles)
446
+
447
+ For composing X content in OpenWriter — `tweetContext` and `articleContext` metadata, `content_type` (`tweet` / `reply` / `quote` / `article`), thread HR rules, image handling, paragraph spacing, parent-tweet workflow — see the `/x-writer` skill.
448
+
449
+ ## Review Etiquette
450
+
451
+ 1. **Share the URL.** Always tell the user: http://localhost:5050
452
+ 2. **Read before writing.** Always fetch the document before suggesting changes
453
+ 3. **Don't overwhelm.** 1-3 changes at a time for reviews, 3-8 for drafting
454
+ 4. **Explain your edits.** Tell the user what you changed and why
455
+ 5. **Respect pending changes.** If `pendingChanges > 0`, wait for the user
456
+ 6. **Watch for the review signal.** When `userSignaledReview` is true, the user is asking for your input — reading status clears it (one-shot)
457
+
458
+ ## Publish Platform (21 tools)
459
+
460
+ Requires authentication via `request_login_code` + `verify_login`. All publish tools are provided by the `@openwriter/plugin-publish` plugin.
461
+
462
+ ### Authentication
463
+
464
+ | Tool | Description |
465
+ |------|-------------|
466
+ | `request_login_code` | Send a 6-digit login code to an email address (signup or key recovery) |
467
+ | `verify_login` | Verify the code → API key issued + auto-saved to plugin config |
468
+
469
+ ```
470
+ 1. request_login_code({ email: "user@example.com" }) → 6-digit code sent to email
471
+ 2. User reads code from inbox (or agent reads via gmail skill)
472
+ 3. verify_login({ email: "user@example.com", code: "123456" })
473
+ → API key issued + auto-saved to plugin config
474
+ ```
475
+
476
+ - **Agents with email access** (e.g. gmail skill) can fully automate this — zero user involvement
477
+ - **Key recovery:** Same flow. Old keys are automatically revoked when a new one is issued
478
+ - Codes expire in 10 minutes, max 3 attempts per code, rate-limited to 1 request per 60 seconds
479
+
480
+ ### Custom Domains
481
+
482
+ | Tool | Description |
483
+ |------|-------------|
484
+ | `setup_custom_domain` | Configure a custom domain + from_email for newsletter sending |
485
+ | `check_domain_status` | Check DNS and sender verification status |
486
+ | `resend_domain_verification` | Re-send the SendGrid sender verification email |
487
+
488
+ **Setup flow:**
489
+ 1. Call `setup_custom_domain` with domain + from_email
490
+ 2. Cloudflare domains: DNS auto-added. Non-CF: show DNS records for manual setup
491
+ 3. User checks email for SendGrid sender verification
492
+ 4. Wait ~30-60s, call `check_domain_status` to confirm
493
+ 5. Both `dns_verified` + `sender_verified` = domain ready
494
+
495
+ ### Social Posting & Connections
496
+
497
+ | Tool | Description |
498
+ |------|-------------|
499
+ | `list_connections` | List connected social accounts (X, LinkedIn, etc.) |
500
+ | `post_to_x` | Post current document to X/Twitter |
501
+ | `post_to_linkedin` | Post current document to LinkedIn |
502
+
503
+ ### Scheduling
504
+
505
+ | Tool | Description |
506
+ |------|-------------|
507
+ | `schedule_post` | Schedule a post for a specific time |
508
+ | `list_schedule` | List all scheduled posts |
509
+ | `manage_schedule` | Update or cancel a scheduled post |
510
+ | `list_slots` | List recurring time slots |
511
+ | `create_slot` | Create a recurring posting slot |
512
+ | `edit_slot` | Modify an existing slot |
513
+ | `delete_slot` | Remove a recurring slot |
514
+
515
+ **Timezones:** `scheduled_at` is UTC. Convert local times using IANA names (e.g. `America/Los_Angeles`), never fixed offsets — DST shifts automatically.
516
+
517
+ ### Newsletter
518
+
519
+ | Tool | Key Params | Description |
520
+ |------|-----------|-------------|
521
+ | `send_newsletter` | `subject?`, `format?`, `test_email?`, `subscriber_ids?`, `exclude_issue_id?` | Send current document as newsletter to all subscribers, a subset, or a test address |
522
+ | `list_subscribers` | `limit?`, `offset?` | List newsletter subscribers with IDs, emails, names |
523
+ | `add_subscriber` | `email`, `name?` | Add a single subscriber |
524
+ | `import_subscribers` | `file?`, `csv_text?` | Bulk import from CSV (auto-detects ConvertKit, Mailchimp, Substack, Beehiiv formats) |
525
+ | `list_newsletter_issues` | `limit?` | List past sends with open/click stats — returns issue IDs |
526
+ | `get_newsletter_analytics` | `issue_id` | Detailed drill-down: delivery stats, per-subscriber events, recipient list |
527
+ | `get_subscribe_embed` | *(none)* | Get public subscribe URL + HTML/JS embed snippets for signup forms on external sites |
528
+
529
+ **Subscriber selection** — `send_newsletter` supports targeting:
530
+ - **All subscribers** (default)omit both params
531
+ - **Specific subscribers** — pass `subscriber_ids: ["id1", "id2"]` (use `list_subscribers` for IDs)
532
+ - **Send to remaining** — pass `exclude_issue_id: "..."` to send to everyone who did NOT receive that issue (use `list_newsletter_issues` for issue IDs)
533
+
534
+ **Analytics workflow:**
535
+ ```
536
+ 1. list_newsletter_issues() → see past sends with open/click counts
537
+ 2. get_newsletter_analytics({ issue_id }) → drill into a specific send
538
+ → returns: stats (delivered, opens, clicks, bounces), per-subscriber events, recipient list
539
+ ```
540
+
541
+ ## Author's Voice Plugin
542
+
543
+ When the user enables the Author's Voice plugin in Settings, install the skill — see [authors-voice.com](https://www.authors-voice.com) for install methods. The skill handles API key setup and everything else.
544
+
545
+ ## Updating
546
+
547
+ ```bash
548
+ npm install -g openwriter@latest
549
+ npx openwriter install-skill
550
+ ```
551
+
552
+ Then restart your Claude Code session (`/mcp` to reconnect).
553
+
554
+ ## Troubleshooting
555
+
556
+ **MCP tools not available** — The OpenWriter MCP server isn't configured yet. Follow the [setup instructions](#mcp-tools-are-not-available-skill-first-install) above. After adding the MCP config, the user must restart their Claude Code session.
557
+
558
+ **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](#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.
559
+
560
+ ### Restarting the MCP server
561
+
562
+ 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`.
563
+
564
+ **Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
565
+
566
+ **Edits don't appear** — Stale node IDs. Always `read_pad` before `write_to_pad` to get fresh IDs.
567
+
568
+ **"pendingChanges" never clears** — User needs to accept/reject changes in the browser at http://localhost:5050.
569
+
570
+ **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"]`.
571
+
572
+ **After code changes** — Run `npm run build` in `packages/openwriter`, kill the running openwriter process, then [restart the MCP server](#restarting-the-mcp-server). `/mcp` alone only reconnects to the existing process; it won't pick up new code unless the old process dies first.
573
+
574
+ **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.