openwriter 0.2.0 → 0.2.2

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.
@@ -1,30 +1,72 @@
1
1
  /**
2
2
  * Workspace manifest CRUD for OpenWriter v2.
3
- * Unified container model: containers (ordered/unordered) hold docs, tags are cross-cutting.
3
+ * Unified container model: containers hold docs in an ordered tree.
4
4
  * Manifests live in ~/.openwriter/_workspaces/*.json.
5
5
  */
6
- import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from 'fs';
6
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { randomUUID } from 'crypto';
9
9
  import matter from 'gray-matter';
10
- import { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath } from './helpers.js';
10
+ import trash from 'trash';
11
+ import { WORKSPACES_DIR, ensureWorkspacesDir, sanitizeFilename, resolveDocPath, isExternalDoc } from './helpers.js';
12
+ import { markdownToTiptap, tiptapToMarkdown } from './markdown.js';
11
13
  const ORDER_FILE = join(WORKSPACES_DIR, '_order.json');
12
14
  import { isV1, migrateV1toV2 } from './workspace-types.js';
13
15
  import { addDocToContainer, addContainer as addContainerToTree, removeNode, moveNode, reorderNode, findContainer, collectAllFiles, countDocs, findDocNode } from './workspace-tree.js';
14
- import { addTag, removeTag, removeFileFromAllTags, listTagsForFile } from './workspace-tags.js';
15
16
  // ============================================================================
16
17
  // INTERNAL HELPERS
17
18
  // ============================================================================
18
19
  function workspacePath(filename) {
19
20
  return join(WORKSPACES_DIR, filename);
20
21
  }
22
+ /**
23
+ * Migrate workspace-level tags into document frontmatter.
24
+ * Old format: workspace.tags = { "tag1": ["file1.md", "file2.md"], ... }
25
+ * New format: each doc file has `tags: ["tag1", ...]` in its frontmatter.
26
+ * Returns true if migration occurred and the workspace was modified.
27
+ */
28
+ function migrateWorkspaceTags(ws) {
29
+ if (!ws.tags || typeof ws.tags !== 'object')
30
+ return false;
31
+ const tagMap = ws.tags;
32
+ const entries = Object.entries(tagMap);
33
+ if (entries.length === 0) {
34
+ delete ws.tags;
35
+ return true;
36
+ }
37
+ for (const [tagName, files] of entries) {
38
+ for (const file of files) {
39
+ try {
40
+ const targetPath = resolveDocPath(file);
41
+ if (!existsSync(targetPath))
42
+ continue;
43
+ const raw = readFileSync(targetPath, 'utf-8');
44
+ const parsed = markdownToTiptap(raw);
45
+ const tags = Array.isArray(parsed.metadata.tags) ? [...parsed.metadata.tags] : [];
46
+ if (!tags.includes(tagName)) {
47
+ tags.push(tagName);
48
+ parsed.metadata.tags = tags;
49
+ const markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
50
+ writeFileSync(targetPath, markdown, 'utf-8');
51
+ }
52
+ }
53
+ catch { /* best-effort */ }
54
+ }
55
+ }
56
+ delete ws.tags;
57
+ return true;
58
+ }
21
59
  function readWorkspace(filename) {
22
60
  const raw = readFileSync(workspacePath(filename), 'utf-8');
23
- const parsed = JSON.parse(raw);
61
+ let parsed = JSON.parse(raw);
24
62
  if (isV1(parsed)) {
25
- const migrated = migrateV1toV2(parsed);
26
- writeWorkspace(filename, migrated);
27
- return migrated;
63
+ parsed = migrateV1toV2(parsed);
64
+ writeWorkspace(filename, parsed);
65
+ return parsed;
66
+ }
67
+ // Migrate workspace-level tags to doc frontmatter
68
+ if (migrateWorkspaceTags(parsed)) {
69
+ writeWorkspace(filename, parsed);
28
70
  }
29
71
  return parsed;
30
72
  }
@@ -96,7 +138,7 @@ export function createWorkspace(options) {
96
138
  const { title, voiceProfileId = null } = options;
97
139
  const slug = sanitizeFilename(title).toLowerCase().replace(/\s+/g, '-');
98
140
  const filename = `${slug}-${randomUUID().slice(0, 8)}.json`;
99
- const workspace = { version: 2, title, voiceProfileId, root: [], tags: {} };
141
+ const workspace = { version: 2, title, voiceProfileId, root: [] };
100
142
  writeWorkspace(filename, workspace);
101
143
  // Append to order
102
144
  const order = readOrder();
@@ -104,12 +146,27 @@ export function createWorkspace(options) {
104
146
  writeOrder(order);
105
147
  return { filename, title, docCount: 0 };
106
148
  }
107
- export function deleteWorkspace(filename) {
149
+ export async function deleteWorkspace(filename) {
108
150
  ensureWorkspacesDir();
109
151
  const p = workspacePath(filename);
110
152
  if (!existsSync(p))
111
153
  throw new Error(`Workspace not found: ${filename}`);
112
- unlinkSync(p);
154
+ const ws = readWorkspace(filename);
155
+ const files = collectAllFiles(ws.root);
156
+ const deletedFiles = [];
157
+ const skippedExternal = [];
158
+ for (const file of files) {
159
+ if (isExternalDoc(file)) {
160
+ skippedExternal.push(file);
161
+ continue;
162
+ }
163
+ const filePath = resolveDocPath(file);
164
+ if (existsSync(filePath)) {
165
+ await trash(filePath);
166
+ deletedFiles.push(file);
167
+ }
168
+ }
169
+ await trash(p);
113
170
  // Remove from order
114
171
  const order = readOrder();
115
172
  const idx = order.indexOf(filename);
@@ -117,6 +174,7 @@ export function deleteWorkspace(filename) {
117
174
  order.splice(idx, 1);
118
175
  writeOrder(order);
119
176
  }
177
+ return { deletedFiles, skippedExternal };
120
178
  }
121
179
  export function reorderWorkspaces(orderedFilenames) {
122
180
  ensureWorkspacesDir();
@@ -134,7 +192,6 @@ export function addDoc(wsFile, containerId, file, title, afterFile) {
134
192
  export function removeDoc(wsFile, file) {
135
193
  const ws = getWorkspace(wsFile);
136
194
  removeNode(ws.root, file);
137
- removeFileFromAllTags(ws.tags, file);
138
195
  writeWorkspace(wsFile, ws);
139
196
  return ws;
140
197
  }
@@ -164,11 +221,7 @@ export function removeContainer(wsFile, containerId) {
164
221
  const found = findContainer(ws.root, containerId);
165
222
  if (!found)
166
223
  throw new Error(`Container "${containerId}" not found`);
167
- // Collect files in container to clean up tags
168
- const files = collectAllFiles(found.node.items || []);
169
224
  removeNode(ws.root, containerId);
170
- for (const file of files)
171
- removeFileFromAllTags(ws.tags, file);
172
225
  writeWorkspace(wsFile, ws);
173
226
  return ws;
174
227
  }
@@ -188,27 +241,6 @@ export function reorderContainer(wsFile, containerId, afterIdentifier) {
188
241
  return ws;
189
242
  }
190
243
  // ============================================================================
191
- // TAG OPERATIONS
192
- // ============================================================================
193
- export function tagDoc(wsFile, file, tag) {
194
- const ws = getWorkspace(wsFile);
195
- if (!findDocNode(ws.root, file))
196
- throw new Error(`Document "${file}" not in workspace`);
197
- addTag(ws.tags, tag, file);
198
- writeWorkspace(wsFile, ws);
199
- return ws;
200
- }
201
- export function untagDoc(wsFile, file, tag) {
202
- const ws = getWorkspace(wsFile);
203
- removeTag(ws.tags, tag, file);
204
- writeWorkspace(wsFile, ws);
205
- return ws;
206
- }
207
- export function getDocTags(wsFile, file) {
208
- const ws = getWorkspace(wsFile);
209
- return listTagsForFile(ws.tags, file);
210
- }
211
- // ============================================================================
212
244
  // CONTEXT
213
245
  // ============================================================================
214
246
  export function updateWorkspaceContext(wsFile, context) {
@@ -222,7 +254,9 @@ export function getItemContext(wsFile, docFile) {
222
254
  const found = findDocNode(ws.root, docFile);
223
255
  if (!found)
224
256
  throw new Error(`Document "${docFile}" not found in workspace`);
225
- const tags = listTagsForFile(ws.tags, docFile);
257
+ // Read tags from document frontmatter (not workspace manifest)
258
+ const fm = readDocFrontmatter(docFile);
259
+ const tags = Array.isArray(fm?.tags) ? fm.tags : [];
226
260
  return {
227
261
  workspaceTitle: ws.title,
228
262
  workspaceContext: ws.context || {},
@@ -230,8 +264,64 @@ export function getItemContext(wsFile, docFile) {
230
264
  };
231
265
  }
232
266
  // ============================================================================
267
+ // FIND-OR-CREATE HELPERS
268
+ // ============================================================================
269
+ /** Find an existing workspace by title (case-insensitive). Returns null if not found. */
270
+ export function findWorkspaceByTitle(title) {
271
+ const all = listWorkspaces();
272
+ const lower = title.toLowerCase();
273
+ return all.find((w) => w.title.toLowerCase() === lower) || null;
274
+ }
275
+ /** Find a container by name in a workspace. Returns its ID, or null if not found. */
276
+ export function findContainerByName(wsFile, name) {
277
+ const ws = getWorkspace(wsFile);
278
+ const lower = name.toLowerCase();
279
+ function scan(nodes) {
280
+ for (const n of nodes) {
281
+ if (n.type === 'container') {
282
+ if (n.name.toLowerCase() === lower)
283
+ return n.id;
284
+ const found = scan(n.items);
285
+ if (found)
286
+ return found;
287
+ }
288
+ }
289
+ return null;
290
+ }
291
+ return scan(ws.root);
292
+ }
293
+ /** Find workspace by title or create it. Returns workspace filename. */
294
+ export function findOrCreateWorkspace(title) {
295
+ const existing = findWorkspaceByTitle(title);
296
+ if (existing)
297
+ return { filename: existing.filename, created: false };
298
+ const info = createWorkspace({ title });
299
+ return { filename: info.filename, created: true };
300
+ }
301
+ /** Find container by name in workspace, or create it. Returns container ID. */
302
+ export function findOrCreateContainer(wsFile, name) {
303
+ const existing = findContainerByName(wsFile, name);
304
+ if (existing)
305
+ return { containerId: existing, created: false };
306
+ const result = addContainerToWorkspace(wsFile, null, name);
307
+ return { containerId: result.containerId, created: true };
308
+ }
309
+ // ============================================================================
233
310
  // CROSS-WORKSPACE QUERIES
234
311
  // ============================================================================
312
+ /** Remove a document from every workspace that references it. */
313
+ export function removeDocFromAllWorkspaces(file) {
314
+ const workspaces = listWorkspaces();
315
+ for (const info of workspaces) {
316
+ try {
317
+ const ws = readWorkspace(info.filename);
318
+ if (collectAllFiles(ws.root).includes(file)) {
319
+ removeDoc(info.filename, file);
320
+ }
321
+ }
322
+ catch { /* skip corrupt manifests */ }
323
+ }
324
+ }
235
325
  export function getWorkspaceAssignedFiles() {
236
326
  const assigned = new Set();
237
327
  const workspaces = listWorkspaces();
package/dist/server/ws.js CHANGED
@@ -2,8 +2,9 @@
2
2
  * WebSocket handler: pushes NodeChanges to browser, receives doc updates + signals.
3
3
  */
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
- import { updateDocument, getDocument, getTitle, getFilePath, getDocId, setMetadata, save, onChanges, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, stripPendingAttrs, } from './state.js';
6
- import { switchDocument, createDocument, deleteDocument } from './documents.js';
5
+ import { updateDocument, getDocument, getTitle, getFilePath, getDocId, getMetadata, setMetadata, save, onChanges, isAgentLocked, getPendingDocFilenames, getPendingDocCounts, stripPendingAttrs, saveDocToFile, stripPendingAttrsFromFile, } from './state.js';
6
+ import { switchDocument, createDocument, deleteDocument, getActiveFilename } from './documents.js';
7
+ import { removeDocFromAllWorkspaces } from './workspaces.js';
7
8
  const clients = new Set();
8
9
  let currentAgentConnected = false;
9
10
  // Debounced auto-save: persist to disk 2s after last doc-update
@@ -57,13 +58,18 @@ export function setupWebSocket(server) {
57
58
  counts: getPendingDocCounts(),
58
59
  },
59
60
  }));
60
- ws.on('message', (data) => {
61
+ ws.on('message', async (data) => {
61
62
  try {
62
63
  const msg = JSON.parse(data.toString());
63
64
  if (msg.type === 'doc-update' && msg.document) {
64
65
  if (isAgentLocked()) {
65
66
  // Agent write in progress — ignore browser doc-updates
66
67
  }
68
+ else if (msg.filename && msg.filename !== getActiveFilename()) {
69
+ // Browser sent a doc-update for a different document (race: server switched away).
70
+ // Save directly to that file on disk instead of corrupting the active doc.
71
+ saveDocToFile(msg.filename, msg.document);
72
+ }
67
73
  else {
68
74
  updateDocument(msg.document);
69
75
  debouncedSave();
@@ -109,19 +115,46 @@ export function setupWebSocket(server) {
109
115
  if (msg.type === 'pending-resolved' && msg.filename) {
110
116
  const action = msg.action; // 'accept' or 'reject'
111
117
  const resolvedFilename = msg.filename;
112
- if (action === 'reject' && msg.wasAgentCreated) {
118
+ const isActiveDoc = resolvedFilename === getActiveFilename();
119
+ // Get metadata from the correct source (active state or disk file)
120
+ const metadata = isActiveDoc ? getMetadata() : null;
121
+ if (action === 'reject' && metadata?.agentCreated) {
113
122
  // Agent-created doc with all content rejected → delete the file
123
+ // Cancel debounced save (doc-update may have queued one for the now-empty doc)
124
+ if (saveTimer) {
125
+ clearTimeout(saveTimer);
126
+ saveTimer = null;
127
+ }
114
128
  try {
115
- deleteDocument(resolvedFilename);
129
+ // Remove from any workspace manifests before deleting the file
130
+ removeDocFromAllWorkspaces(resolvedFilename);
131
+ const result = await deleteDocument(resolvedFilename);
132
+ if (result.switched && result.newDoc) {
133
+ broadcastDocumentSwitched(result.newDoc.document, result.newDoc.title, result.newDoc.filename);
134
+ }
135
+ broadcastDocumentsChanged();
136
+ broadcastWorkspacesChanged();
137
+ broadcastPendingDocsChanged();
138
+ return; // File deleted — no strip/save needed
116
139
  }
117
140
  catch (err) {
118
141
  console.error('[WS] Failed to delete rejected agent doc:', err.message);
142
+ // Fall through to normal strip+save (e.g. only doc remaining)
119
143
  }
120
144
  }
121
- // Strip pending attrs that transferPendingAttrs() re-added from stale server state,
122
- // then save clean markdown to disk.
123
- stripPendingAttrs();
124
- save();
145
+ if (isActiveDoc) {
146
+ // Normal path: resolved doc is the active one
147
+ if (action === 'accept' && metadata?.agentCreated) {
148
+ delete metadata.agentCreated;
149
+ }
150
+ stripPendingAttrs();
151
+ save();
152
+ }
153
+ else {
154
+ // Race path: resolved doc is NOT the active one (server switched away).
155
+ // Strip pending attrs directly from the file on disk.
156
+ stripPendingAttrsFromFile(resolvedFilename, action === 'accept');
157
+ }
125
158
  broadcastPendingDocsChanged();
126
159
  }
127
160
  }
@@ -187,6 +220,13 @@ export function broadcastPendingDocsChanged() {
187
220
  }
188
221
  }, PENDING_DOCS_DEBOUNCE_MS);
189
222
  }
223
+ export function broadcastPluginsChanged() {
224
+ const msg = JSON.stringify({ type: 'plugins-changed' });
225
+ for (const ws of clients) {
226
+ if (ws.readyState === WebSocket.OPEN)
227
+ ws.send(msg);
228
+ }
229
+ }
190
230
  export function broadcastAgentStatus(connected) {
191
231
  currentAgentConnected = connected;
192
232
  const msg = JSON.stringify({ type: 'agent-status', agentConnected: connected });
@@ -197,6 +237,20 @@ export function broadcastAgentStatus(connected) {
197
237
  }
198
238
  }
199
239
  let lastSyncStatus = null;
240
+ export function broadcastWritingStarted(title, target) {
241
+ const msg = JSON.stringify({ type: 'writing-started', title, target: target || null });
242
+ for (const ws of clients) {
243
+ if (ws.readyState === WebSocket.OPEN)
244
+ ws.send(msg);
245
+ }
246
+ }
247
+ export function broadcastWritingFinished() {
248
+ const msg = JSON.stringify({ type: 'writing-finished' });
249
+ for (const ws of clients) {
250
+ if (ws.readyState === WebSocket.OPEN)
251
+ ws.send(msg);
252
+ }
253
+ }
200
254
  export function broadcastSyncStatus(status) {
201
255
  lastSyncStatus = status;
202
256
  const msg = JSON.stringify({ type: 'sync-status', ...status });
@@ -206,6 +260,3 @@ export function broadcastSyncStatus(status) {
206
260
  }
207
261
  }
208
262
  }
209
- export function getLastSyncStatus() {
210
- return lastSyncStatus;
211
- }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.2.0",
4
- "description": "Local TipTap editor for human-agent collaboration via MCP",
3
+ "version": "0.2.2",
4
+ "description": "The open-source writing surface for AI agents. Markdown-native editor with pending change review — your agent writes, you accept or reject.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Travis Steward",
@@ -66,6 +66,7 @@
66
66
  "open": "^10.1.0",
67
67
  "react": "^18.3.1",
68
68
  "react-dom": "^18.3.1",
69
+ "trash": "^10.1.0",
69
70
  "ws": "^8.18.0",
70
71
  "zod": "^3.25.76"
71
72
  },
package/skill/SKILL.md CHANGED
@@ -1,39 +1,53 @@
1
1
  ---
2
2
  name: openwriter
3
3
  description: |
4
- OpenWriter — local TipTap editor for human-agent collaboration.
5
- Agent reads/edits documents via MCP tools (read_pad, write_to_pad, etc.).
6
- Changes appear as pending decorations the user accepts or rejects.
7
- Multi-document workspace with sidebar navigation.
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. 26 MCP tools for document editing, multi-doc workspaces, and
7
+ organization. Plain .md files on disk — no database, no lock-in.
8
8
 
9
9
  Use when user says: "open writer", "openwriter", "write in openwriter",
10
- "edit my document", "review my writing", "check the pad".
10
+ "edit my document", "review my writing", "check the pad", "write me a doc".
11
11
 
12
12
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
13
+ metadata:
14
+ author: travsteward
15
+ version: "0.2.0"
16
+ repository: https://github.com/travsteward/openwriter
17
+ license: MIT
13
18
  ---
14
19
 
15
- # OpenWriter — Public Companion Skill
20
+ # OpenWriter Skill
16
21
 
17
- You are a writing collaborator. The user has a document open in OpenWriter (http://localhost:5050). You read their document and make edits **exclusively via MCP tools**. Edits appear as pending decorations (colored highlights) that the user can accept or reject.
22
+ 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.
18
23
 
19
- **First action when activated:** Always share the browser URL:
20
- > OpenWriter is at **http://localhost:5050**
24
+ ## Setup Which Path?
21
25
 
22
- ## Quick Setup
26
+ Check whether the `open-writer` MCP tools are available (e.g. `read_pad`, `write_to_pad`). This determines setup state:
23
27
 
24
- OpenWriter must be configured as an MCP server before use. Two paths:
28
+ ### MCP tools ARE available (ready to use)
25
29
 
26
- ### Option A: User runs it from their terminal (outside Claude Code)
30
+ The user already has OpenWriter configured either they ran `npx openwriter install-skill` (which installed this skill) and added the MCP server, or they set it up manually. You're good to go.
31
+
32
+ **First action:** Share the browser URL:
33
+ > OpenWriter is at **http://localhost:5050** — open it in your browser to see and review changes.
34
+
35
+ Skip to [Writing Strategy](#writing-strategy) below.
36
+
37
+ ### MCP tools are NOT available (skill-first install)
38
+
39
+ The user installed this skill from a directory but hasn't set up the MCP server yet. OpenWriter needs an MCP server to provide the 24 editing tools.
40
+
41
+ **Step 1:** Tell the user to install the npm package and MCP server:
27
42
 
28
43
  ```bash
44
+ # Add the OpenWriter MCP server to Claude Code
29
45
  claude mcp add -s user open-writer -- npx openwriter --no-open
30
46
  ```
31
47
 
32
- Then restart the Claude Code session. The MCP tools become available automatically.
33
-
34
- ### Option B: Agent configures it (when user asks you to set it up)
48
+ Then restart the Claude Code session. The MCP tools become available on next launch.
35
49
 
36
- Edit `~/.claude.json` and add to the `mcpServers` object:
50
+ **Step 2 (if the user can't run the command above):** Edit `~/.claude.json` directly. Add to the `mcpServers` object:
37
51
 
38
52
  ```json
39
53
  "open-writer": {
@@ -42,7 +56,7 @@ Edit `~/.claude.json` and add to the `mcpServers` object:
42
56
  }
43
57
  ```
44
58
 
45
- The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exist, create it. Example:
59
+ The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exist, create it:
46
60
 
47
61
  ```json
48
62
  {
@@ -56,12 +70,12 @@ The `mcpServers` key is at the top level of `~/.claude.json`. If it doesn't exis
56
70
  ```
57
71
 
58
72
  After editing, tell the user:
59
- 1. Restart your Claude Code session (the MCP server loads on startup)
73
+ 1. Restart your Claude Code session (MCP servers load on startup)
60
74
  2. Open http://localhost:5050 in your browser
61
75
 
62
- **Note:** You cannot run `claude mcp add` from inside a session (nested session error). That's why we edit the JSON directly.
76
+ **Note:** You cannot run `claude mcp add` from inside a session (nested session error). That's why we edit the JSON directly when configuring from within Claude Code.
63
77
 
64
- ## MCP Tools Reference (24 tools)
78
+ ## MCP Tools Reference (26 tools)
65
79
 
66
80
  ### Document Operations
67
81
 
@@ -69,6 +83,7 @@ After editing, tell the user:
69
83
  |------|-------------|
70
84
  | `read_pad` | Read the current document (compact tagged-line format) |
71
85
  | `write_to_pad` | Apply edits as pending decorations (rewrite, insert, delete) |
86
+ | `populate_document` | Populate an empty doc with content (two-step creation flow) |
72
87
  | `get_pad_status` | Lightweight poll: word count, pending changes, userSignaledReview |
73
88
  | `get_nodes` | Fetch specific nodes by ID |
74
89
  | `get_metadata` | Get frontmatter metadata for the active document |
@@ -80,14 +95,14 @@ After editing, tell the user:
80
95
  |------|-------------|
81
96
  | `list_documents` | List all documents with filename, word count, active status |
82
97
  | `switch_document` | Switch to a different document by filename |
83
- | `create_document` | Create a new document (optional title, content, path) |
98
+ | `create_document` | Create a new empty document (optional workspace + container placement) |
84
99
  | `open_file` | Open an existing .md file from any location on disk |
100
+ | `delete_document` | Delete a document file (moves to OS trash, recoverable) |
85
101
 
86
102
  ### Import
87
103
 
88
104
  | Tool | Description |
89
105
  |------|-------------|
90
- | `replace_document` | Import external content into a new/blank document |
91
106
  | `import_gdoc` | Import a Google Doc (auto-splits multi-chapter docs) |
92
107
 
93
108
  ### Workspace Management
@@ -96,6 +111,7 @@ After editing, tell the user:
96
111
  |------|-------------|
97
112
  | `list_workspaces` | List all workspaces with title and doc count |
98
113
  | `create_workspace` | Create a new workspace |
114
+ | `delete_workspace` | Delete a workspace and all its document files (moves to OS trash) |
99
115
  | `get_workspace_structure` | Get full workspace tree: containers, docs, tags, context |
100
116
  | `get_item_context` | Get progressive disclosure context for a doc in a workspace |
101
117
  | `update_workspace_context` | Update workspace context (characters, settings, rules) |
@@ -106,8 +122,8 @@ After editing, tell the user:
106
122
  |------|-------------|
107
123
  | `add_doc` | Add a document to a workspace (optional container placement) |
108
124
  | `create_container` | Create a folder inside a workspace (max depth: 3) |
109
- | `tag_doc` | Add a tag to a document in a workspace |
110
- | `untag_doc` | Remove a tag from a document |
125
+ | `tag_doc` | Add a tag to a document (stored in doc frontmatter) |
126
+ | `untag_doc` | Remove a tag from a document (stored in doc frontmatter) |
111
127
  | `move_doc` | Move a document to a different container or root level |
112
128
 
113
129
  ### Text Operations
@@ -118,15 +134,54 @@ After editing, tell the user:
118
134
 
119
135
  ## Writing Strategy
120
136
 
121
- **Incremental edits, not monolithic replacement.**
137
+ OpenWriter has two distinct modes: **editing** existing documents and **creating** new content. Use the right approach for each.
138
+
139
+ ### Editing (write_to_pad)
140
+
141
+ For making changes to existing documents — rewrites, insertions, deletions:
122
142
 
123
- - Use `write_to_pad` for all edits — never `replace_document` (unless importing into a blank doc)
143
+ - Use `write_to_pad` for all edits
124
144
  - Send **3-8 changes per call** for a responsive, streaming feel
125
- - Always `read_pad` before writing you need fresh node IDs
145
+ - Always `read_pad` before editing to get fresh node IDs
126
146
  - Respect `pendingChanges > 0` — wait for the user to accept/reject before sending more
127
147
  - Content accepts markdown strings (preferred) or TipTap JSON
128
148
  - Decoration colors: **blue** = rewrite, **green** = insert, **red** = delete
129
149
 
150
+ ### Creating New Documents (two-step flow)
151
+
152
+ **Always use the two-step flow** when creating new content:
153
+
154
+ ```
155
+ 1. create_document({ title: "My Doc" }) ← no content, fires instantly, shows spinner
156
+ 2. populate_document({ content: "..." }) ← delivers content, clears spinner
157
+ ```
158
+
159
+ **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).
160
+
161
+ **Rules:**
162
+ - `create_document` does NOT accept a `content` parameter — it always creates an empty doc
163
+ - Step 1 (`create_document`) — shows spinner, creates empty doc, does NOT switch the editor
164
+ - Step 2 (`populate_document`) — writes content to the active doc, marks as pending decorations, switches the editor, clears the spinner
165
+ - Never use `write_to_pad` for the initial population — use `populate_document` exclusively
166
+
167
+ ### Workspace-Integrated Creation
168
+
169
+ `create_document` accepts optional `workspace` and `container` parameters for direct workspace placement:
170
+
171
+ ```
172
+ create_document({
173
+ title: "Opening Chapter",
174
+ workspace: "The Immortal", ← creates workspace if it doesn't exist
175
+ container: "Chapters" ← creates container if it doesn't exist
176
+ })
177
+ ```
178
+
179
+ - **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
180
+ - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
181
+ - Both are optional — omit for standalone docs outside any workspace.
182
+
183
+ This eliminates the need for separate `create_workspace`, `create_container`, and `add_doc` calls when building up a workspace.
184
+
130
185
  ## Workflow
131
186
 
132
187
  ### Single document
@@ -147,14 +202,39 @@ After editing, tell the user:
147
202
  4. write_to_pad → apply edits
148
203
  ```
149
204
 
150
- ### Creating new content
205
+ ### Creating new content (two-step)
151
206
 
152
207
  ```
153
- 1. create_document({ title: "My Doc", content: "# Heading\n\nContent..." })
154
- 2. read_pad get node IDs from created content
155
- 3. write_to_pad refine with edits
208
+ 1. create_document({ title: "My Doc", workspace: "Project", container: "Chapters" })
209
+ spinner appears, doc placed in workspace
210
+ 2. populate_document({ content: "# ..." }) content delivered, spinner clears
211
+ 3. read_pad → get node IDs if further edits needed
212
+ 4. write_to_pad → refine with edits
156
213
  ```
157
214
 
215
+ ### Building a workspace (multiple docs)
216
+
217
+ ```
218
+ 1. create_document({ title: "Ch 1", workspace: "My Book", container: "Chapters" })
219
+ 2. populate_document({ content: "..." })
220
+ 3. create_document({ title: "Ch 2", workspace: "My Book", container: "Chapters" })
221
+ 4. populate_document({ content: "..." })
222
+ 5. create_document({ title: "Character Bible", workspace: "My Book", container: "References" })
223
+ 6. populate_document({ content: "..." })
224
+ 7. tag_doc + update_workspace_context → organize and add context
225
+ ```
226
+
227
+ The workspace and containers are auto-created on the first `create_document` call. Subsequent calls reuse the existing workspace/containers (matched case-insensitively).
228
+
229
+ ### Book workspace guidelines
230
+
231
+ 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.
232
+
233
+ - **One concept per doc.** Don't create one giant reference doc. If the material covers characters, setting, plot, and themes, those are separate documents.
234
+ - **Preserve originals.** Keep raw drafts separate from revised versions (e.g. Drafts vs. Chapters containers). The author needs both.
235
+ - **Synthesize, don't just copy.** Reorganize messy notes into clean, scannable docs (headers, bullets, sections) while keeping the author's voice and prose verbatim.
236
+ - **Surface open threads.** Unanswered questions, brainstorm lists, and loose ideas get their own doc — don't bury them inside reference material.
237
+
158
238
  ## Review Etiquette
159
239
 
160
240
  1. **Share the URL.** Always tell the user: http://localhost:5050
@@ -166,7 +246,7 @@ After editing, tell the user:
166
246
 
167
247
  ## Troubleshooting
168
248
 
169
- **MCP tools not available** — Run `/mcp` in Claude Code to check connection status. Click reconnect if needed.
249
+ **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.
170
250
 
171
251
  **Port 5050 busy** — Another OpenWriter instance owns the port. New sessions auto-enter client mode (proxying via HTTP) — tools still work. No action needed.
172
252