openwriter 0.40.2 → 0.40.3

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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Agent Marks: sidecar JSON storage for inline user feedback.
3
+ * Each document gets a sidecar file at DATA_DIR/_marks/{filename}.json.
4
+ */
5
+ import { join } from 'path';
6
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from 'fs';
7
+ import { randomUUID } from 'crypto';
8
+ import { getDataDir, ensureDataDir } from './helpers.js';
9
+ function getMarksDir() { return join(getDataDir(), '_marks'); }
10
+ function ensureMarksDir() {
11
+ ensureDataDir();
12
+ if (!existsSync(getMarksDir()))
13
+ mkdirSync(getMarksDir(), { recursive: true });
14
+ }
15
+ function markFilePath(filename) {
16
+ // Sanitize: replace path separators to avoid nested paths
17
+ const safe = filename.replace(/[/\\]/g, '_');
18
+ return join(getMarksDir(), `${safe}.json`);
19
+ }
20
+ function readMarkFile(filename) {
21
+ const path = markFilePath(filename);
22
+ if (!existsSync(path))
23
+ return { marks: [] };
24
+ try {
25
+ return JSON.parse(readFileSync(path, 'utf-8'));
26
+ }
27
+ catch {
28
+ return { marks: [] };
29
+ }
30
+ }
31
+ function writeMarkFile(filename, data) {
32
+ ensureMarksDir();
33
+ const path = markFilePath(filename);
34
+ if (data.marks.length === 0) {
35
+ // Clean up empty sidecar files
36
+ if (existsSync(path))
37
+ unlinkSync(path);
38
+ return;
39
+ }
40
+ writeFileSync(path, JSON.stringify(data, null, 2));
41
+ }
42
+ export function addMark(filename, text, note, nodeId, nodeIds) {
43
+ const data = readMarkFile(filename);
44
+ const mark = {
45
+ id: randomUUID().slice(0, 8),
46
+ text,
47
+ note,
48
+ nodeId,
49
+ ...(nodeIds && nodeIds.length > 1 ? { nodeIds } : {}),
50
+ createdAt: new Date().toISOString(),
51
+ };
52
+ data.marks.push(mark);
53
+ writeMarkFile(filename, data);
54
+ return mark;
55
+ }
56
+ export function getMarks(filename) {
57
+ if (filename) {
58
+ const data = readMarkFile(filename);
59
+ if (data.marks.length === 0)
60
+ return {};
61
+ return { [filename]: data.marks };
62
+ }
63
+ // All docs: scan _marks directory
64
+ ensureMarksDir();
65
+ const result = {};
66
+ try {
67
+ const files = readdirSync(getMarksDir());
68
+ for (const file of files) {
69
+ if (!file.endsWith('.json'))
70
+ continue;
71
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
72
+ // Read raw to avoid filename roundtrip issues
73
+ const path = join(getMarksDir(), file);
74
+ try {
75
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
76
+ if (data.marks.length > 0)
77
+ result[docFilename] = data.marks;
78
+ }
79
+ catch { /* skip corrupt files */ }
80
+ }
81
+ }
82
+ catch { /* dir doesn't exist yet */ }
83
+ return result;
84
+ }
85
+ export function getMarkCount(filename) {
86
+ return readMarkFile(filename).marks.length;
87
+ }
88
+ /** Count marks across all documents, optionally excluding one filename. */
89
+ export function getGlobalMarkSummary(excludeFilename) {
90
+ ensureMarksDir();
91
+ let totalMarks = 0;
92
+ let docCount = 0;
93
+ try {
94
+ const files = readdirSync(getMarksDir());
95
+ for (const file of files) {
96
+ if (!file.endsWith('.json'))
97
+ continue;
98
+ if (excludeFilename) {
99
+ const safe = excludeFilename.replace(/[/\\]/g, '_');
100
+ if (file === `${safe}.json`)
101
+ continue;
102
+ }
103
+ const path = join(getMarksDir(), file);
104
+ try {
105
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
106
+ if (data.marks.length > 0) {
107
+ totalMarks += data.marks.length;
108
+ docCount++;
109
+ }
110
+ }
111
+ catch { /* skip */ }
112
+ }
113
+ }
114
+ catch { /* dir doesn't exist */ }
115
+ return { totalMarks, docCount };
116
+ }
117
+ export function editMark(filename, id, note) {
118
+ const data = readMarkFile(filename);
119
+ const mark = data.marks.find((m) => m.id === id);
120
+ if (!mark)
121
+ return null;
122
+ mark.note = note;
123
+ writeMarkFile(filename, data);
124
+ return mark;
125
+ }
126
+ export function resolveMarks(ids) {
127
+ const idSet = new Set(ids);
128
+ const resolved = [];
129
+ ensureMarksDir();
130
+ try {
131
+ const files = readdirSync(getMarksDir());
132
+ for (const file of files) {
133
+ if (!file.endsWith('.json'))
134
+ continue;
135
+ const filePath = join(getMarksDir(), file);
136
+ try {
137
+ const data = JSON.parse(readFileSync(filePath, 'utf-8'));
138
+ const before = data.marks.length;
139
+ data.marks = data.marks.filter((m) => {
140
+ if (idSet.has(m.id)) {
141
+ resolved.push(m.id);
142
+ return false;
143
+ }
144
+ return true;
145
+ });
146
+ if (data.marks.length !== before) {
147
+ const docFilename = file.replace(/\.json$/, '').replace(/_/g, ' ');
148
+ writeMarkFile(docFilename, data);
149
+ }
150
+ }
151
+ catch { /* skip */ }
152
+ }
153
+ }
154
+ catch { /* dir doesn't exist */ }
155
+ return resolved;
156
+ }
157
+ export function pruneStaleMarks(filename, validNodeIds) {
158
+ const data = readMarkFile(filename);
159
+ if (data.marks.length === 0)
160
+ return 0;
161
+ const validSet = new Set(validNodeIds);
162
+ const before = data.marks.length;
163
+ data.marks = data.marks.filter((m) => {
164
+ // Multi-node mark: keep if ANY nodeId is still valid
165
+ if (m.nodeIds && m.nodeIds.length > 0) {
166
+ return m.nodeIds.some((id) => validSet.has(id));
167
+ }
168
+ return validSet.has(m.nodeId);
169
+ });
170
+ const pruned = before - data.marks.length;
171
+ if (pruned > 0)
172
+ writeMarkFile(filename, data);
173
+ return pruned;
174
+ }
175
+ /** Rename a mark sidecar file when a document is renamed. */
176
+ export function renameMark(oldFilename, newFilename) {
177
+ const oldPath = markFilePath(oldFilename);
178
+ if (!existsSync(oldPath))
179
+ return;
180
+ const newPath = markFilePath(newFilename);
181
+ renameSync(oldPath, newPath);
182
+ }
@@ -23,7 +23,7 @@ import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBackl
23
23
  import { logger, generateRequestId, withRequestId } from './logger.js';
24
24
  import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastPendingMetadataChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged, broadcastActivityEvent } from './ws.js';
25
25
  import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
26
- import { findDocNode } from './workspace-tree.js';
26
+ import { findDocNode, findContainer } from './workspace-tree.js';
27
27
  import { importGoogleDoc } from './gdoc-import.js';
28
28
  import { toCompactFormat, compactNodes, parseMarkdownContent } from './compact.js';
29
29
  import matter from 'gray-matter';
@@ -484,12 +484,14 @@ export const TOOL_REGISTRY = [
484
484
  },
485
485
  {
486
486
  name: 'create_document',
487
- description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. If workspace is provided, the doc is automatically added to it (workspace is created if it doesn\'t exist). If container is also provided, the doc is placed inside that container (created if it doesn\'t exist).',
487
+ description: 'Create a new document. content_type is REQUIRED — use "document" for plain docs, or "tweet"/"reply"/"quote"/"article"/"linkedin"/"newsletter"/"blog" for typed docs. Always provide a title. By default shows a sidebar spinner — call populate_document next to deliver content and clear it. This two-step flow is REQUIRED for all content documents: create_document → populate_document. Use empty=true ONLY for typed docs (tweets, articles) that start blank and get written to incrementally via write_to_pad. Placement accepts EITHER convention: name-based auto-create (workspace title + container name, created if absent) OR id-based targeting of existing items (workspaceFile + containerId, the same ids move_item/get_workspace_structure use). A placement param that cannot be honored (unknown workspaceFile/containerId, or a container with no workspace) is a hard error — the doc is never silently created unplaced. The result always states where the doc landed, or "UNFILED".',
488
488
  schema: {
489
489
  title: z.string().optional().describe('Title for the new document. Defaults to "Untitled".'),
490
490
  path: z.string().optional().describe('Absolute file path to create the document at (e.g. "C:/projects/doc.md"). If omitted, creates in ~/.openwriter/.'),
491
491
  workspace: z.string().optional().describe('Workspace title to add this doc to. Creates the workspace if it doesn\'t exist.'),
492
492
  container: z.string().optional().describe('Container name within the workspace (e.g. "Chapters", "Notes", "References"). Creates the container if it doesn\'t exist. Requires workspace.'),
493
+ workspaceFile: z.string().optional().describe('Existing workspace by manifest filename (the *.json id used by move_item / get_workspace_structure). Id-based alternative to "workspace" (title). Must already exist — errors if not found. Use when you already hold the workspaceFile from another tool.'),
494
+ containerId: z.string().optional().describe('Existing container by id (8-char hex, as used by move_item / get_workspace_structure). Id-based alternative to "container" (name). Must already exist in the resolved workspace — errors if not found. Requires a workspace (workspaceFile or workspace).'),
493
495
  empty: z.boolean().optional().describe('ONLY for content_type template docs (tweets, articles) that start blank. Skips the spinner and switches immediately. Do NOT set this for content documents — use the two-step flow (create_document → populate_document) instead.'),
494
496
  content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog', 'manuscript']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically. "manuscript" = a binding doc whose body is an ordered list of [text](doc:ID) pointers under ## chapter headings; populate it with the manifest, then it compiles to EPUB/DOCX via the manuscript routes.'),
495
497
  url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
@@ -498,7 +500,7 @@ export const TOOL_REGISTRY = [
498
500
  masterDocId: z.string().optional().describe('Make this doc a VARIANT of another doc. Pass the master doc\'s docId (8-char hex). The variant nests under its master in the sidebar (expandable tree). Use when creating a derivative — e.g. a tweet thread from a blog post. Pair with variantType. See docs/variants.md.'),
499
501
  variantType: z.string().optional().describe('Label for what kind of variant this is (e.g. "tweet", "blog", "linkedin"). Shows as a badge in the sidebar. Only meaningful alongside masterDocId.'),
500
502
  },
501
- handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
503
+ handler: async ({ title, path, workspace, container, workspaceFile, containerId, empty, content_type, url, afterId, status, masterDocId, variantType }) => {
502
504
  // Require url for reply/quote
503
505
  if ((content_type === 'reply' || content_type === 'quote') && !url) {
504
506
  return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
@@ -511,17 +513,69 @@ export const TOOL_REGISTRY = [
511
513
  };
512
514
  title = typeDefaults[content_type];
513
515
  }
514
- // Resolve workspace/container up front so spinner renders in the right place
516
+ // Resolve placement up front so the spinner renders in the right place.
517
+ // Accept BOTH addressing conventions: the id-based one used across the rest
518
+ // of the API (workspaceFile + containerId, targeting EXISTING items) and the
519
+ // name-based auto-create one (workspace title + container name). Whichever is
520
+ // given is resolved; a placement param that cannot be honored is a HARD ERROR
521
+ // rather than a silent drop that orphans the doc.
522
+ // adr: adr/create-document-placement-contract.md
515
523
  let wsTarget;
516
- if (workspace) {
517
- const ws = findOrCreateWorkspace(workspace);
518
- let containerId = null;
519
- if (container) {
520
- const c = findOrCreateContainer(ws.filename, container);
521
- containerId = c.containerId;
524
+ if (workspace || workspaceFile || container || containerId) {
525
+ // 1. Resolve the workspace. Prefer the explicit id (workspaceFile).
526
+ let wsFilenameResolved = null;
527
+ if (workspaceFile) {
528
+ try {
529
+ getWorkspace(workspaceFile);
530
+ }
531
+ catch {
532
+ return { content: [{ type: 'text', text: `Error: create_document workspaceFile "${workspaceFile}" not found. Pass an existing workspace .json filename, or use "workspace" (title) to create one by name.` }] };
533
+ }
534
+ wsFilenameResolved = workspaceFile;
535
+ }
536
+ else if (workspace) {
537
+ wsFilenameResolved = findOrCreateWorkspace(workspace).filename;
538
+ }
539
+ // 2. A container was requested but the workspace couldn't be resolved.
540
+ // Previously silently dropped — now a hard error.
541
+ if ((container || containerId) && !wsFilenameResolved) {
542
+ return { content: [{ type: 'text', text: `Error: create_document was given a container but no workspace. Pass "workspaceFile" (existing) or "workspace" (title, auto-created) alongside the container.` }] };
543
+ }
544
+ // 3. Resolve the container within that workspace.
545
+ let resolvedContainerId = null;
546
+ if (wsFilenameResolved) {
547
+ if (containerId) {
548
+ const wsObj = getWorkspace(wsFilenameResolved);
549
+ if (!findContainer(wsObj.root, containerId)) {
550
+ return { content: [{ type: 'text', text: `Error: create_document containerId "${containerId}" not found in workspace ${wsFilenameResolved}. Pass an existing containerId, or use "container" (name) to create one.` }] };
551
+ }
552
+ resolvedContainerId = containerId;
553
+ }
554
+ else if (container) {
555
+ resolvedContainerId = findOrCreateContainer(wsFilenameResolved, container).containerId;
556
+ }
557
+ wsTarget = { wsFilename: wsFilenameResolved, containerId: resolvedContainerId };
558
+ broadcastWorkspacesChanged(); // Browser sees container structure before spinner
559
+ }
560
+ }
561
+ // Always state the final placement in the result, so a doc that lands in no
562
+ // workspace says so loudly (UNFILED) — an unplaced doc can never read as a
563
+ // bland success again. adr: brief 2026-07-09-create-document-placement-contract.
564
+ let placement = ' (UNFILED — not added to any workspace; lives at ~/.openwriter)';
565
+ if (wsTarget) {
566
+ let wsLabel = wsTarget.wsFilename;
567
+ let cLabel = '';
568
+ try {
569
+ const wsObj = getWorkspace(wsTarget.wsFilename);
570
+ wsLabel = wsObj.title || wsTarget.wsFilename;
571
+ if (wsTarget.containerId) {
572
+ const found = findContainer(wsObj.root, wsTarget.containerId);
573
+ if (found)
574
+ cLabel = ` / ${found.node.name}`;
575
+ }
522
576
  }
523
- wsTarget = { wsFilename: ws.filename, containerId };
524
- broadcastWorkspacesChanged(); // Browser sees container structure before spinner
577
+ catch { /* keep filename */ }
578
+ placement = ` workspace "${wsLabel}"${cLabel}`;
525
579
  }
526
580
  // Track the spinner key so catch can clear exactly this entry
527
581
  // (not siblings from a concurrent declare_writes).
@@ -552,13 +606,11 @@ export const TOOL_REGISTRY = [
552
606
  Object.assign(initMeta, typeMeta);
553
607
  }
554
608
  setMetadata(initMeta);
555
- let wsInfo = '';
556
609
  if (wsTarget) {
557
610
  // Resolve afterId: it may be a docId (8-char hex) or containerId.
558
611
  // filenameByDocId resolves docId→filename; if null, treat as containerId.
559
612
  const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
560
613
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
561
- wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
562
614
  }
563
615
  const newDocId = getDocId();
564
616
  save('agent');
@@ -576,7 +628,7 @@ export const TOOL_REGISTRY = [
576
628
  return {
577
629
  content: [{
578
630
  type: 'text',
579
- text: `Created "${result.title}" [${newDocId}]${wsInfo}${content_type ? ` (${content_type})` : ''} — ready.`,
631
+ text: `Created "${result.title}" [${newDocId}]${placement}${content_type ? ` (${content_type})` : ''} — ready.`,
580
632
  }],
581
633
  };
582
634
  }
@@ -587,11 +639,9 @@ export const TOOL_REGISTRY = [
587
639
  const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
588
640
  const initialMeta = { ...statusMeta, ...variantMeta, ...(typeMeta || {}) };
589
641
  const result = createDocumentFile(title, path, initialMeta);
590
- let wsInfo = '';
591
642
  if (wsTarget) {
592
643
  const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
593
644
  addDoc(wsTarget.wsFilename, wsTarget.containerId, result.filename, result.title, afterRef);
594
- wsInfo = ` → workspace "${workspace}"${container ? ` / ${container}` : ''}`;
595
645
  }
596
646
  // Broadcast spinner keyed by filename so populate_document can clear exactly
597
647
  // this entry. Fires after the file exists, so documents-changed arrives with
@@ -610,7 +660,7 @@ export const TOOL_REGISTRY = [
610
660
  return {
611
661
  content: [{
612
662
  type: 'text',
613
- text: `Created "${result.title}" [${result.docId}]${wsInfo} — empty. Call populate_document with docId "${result.docId}" to add content.`,
663
+ text: `Created "${result.title}" [${result.docId}]${placement} — empty. Call populate_document with docId "${result.docId}" to add content.`,
614
664
  }],
615
665
  };
616
666
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Express routes for GitHub sync.
3
+ * Mounted in index.ts — follows version-routes.ts pattern.
4
+ */
5
+ import { Router } from 'express';
6
+ import { getSyncStatus, getCapabilities, getPendingFiles, setupWithGh, setupWithPat, connectExisting, pushSync, } from './git-sync.js';
7
+ export function createSyncRouter(broadcastSyncStatus) {
8
+ const router = Router();
9
+ router.get('/api/sync/status', async (_req, res) => {
10
+ try {
11
+ res.json(await getSyncStatus());
12
+ }
13
+ catch (err) {
14
+ res.status(500).json({ state: 'error', error: err.message });
15
+ }
16
+ });
17
+ router.get('/api/sync/capabilities', async (_req, res) => {
18
+ try {
19
+ res.json(await getCapabilities());
20
+ }
21
+ catch (err) {
22
+ res.status(500).json({ error: err.message });
23
+ }
24
+ });
25
+ router.get('/api/sync/pending', async (_req, res) => {
26
+ try {
27
+ res.json(await getPendingFiles());
28
+ }
29
+ catch (err) {
30
+ res.status(500).json({ error: err.message });
31
+ }
32
+ });
33
+ router.post('/api/sync/setup', async (req, res) => {
34
+ try {
35
+ const { method, repoName, remoteUrl, pat, isPrivate } = req.body;
36
+ if (method === 'gh') {
37
+ await setupWithGh(repoName || 'openwriter-docs', isPrivate !== false);
38
+ }
39
+ else if (method === 'pat') {
40
+ if (!pat) {
41
+ res.status(400).json({ error: 'PAT is required' });
42
+ return;
43
+ }
44
+ await setupWithPat(pat, repoName || 'openwriter-docs', isPrivate !== false);
45
+ }
46
+ else if (method === 'connect') {
47
+ if (!remoteUrl) {
48
+ res.status(400).json({ error: 'Remote URL is required' });
49
+ return;
50
+ }
51
+ await connectExisting(remoteUrl, pat);
52
+ }
53
+ else {
54
+ res.status(400).json({ error: 'Invalid method. Use: gh, pat, or connect' });
55
+ return;
56
+ }
57
+ const status = await getSyncStatus();
58
+ broadcastSyncStatus(status);
59
+ res.json({ success: true, status });
60
+ }
61
+ catch (err) {
62
+ res.status(500).json({ error: err.message });
63
+ }
64
+ });
65
+ router.post('/api/sync/push', async (_req, res) => {
66
+ try {
67
+ const result = await pushSync(broadcastSyncStatus);
68
+ res.json(result);
69
+ }
70
+ catch (err) {
71
+ res.status(500).json({ state: 'error', error: err.message });
72
+ }
73
+ });
74
+ return router;
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openwriter",
3
- "version": "0.40.2",
3
+ "version": "0.40.3",
4
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",
package/skill/SKILL.md CHANGED
@@ -16,7 +16,7 @@ description: |
16
16
  Requires: OpenWriter MCP server configured. Browser UI at localhost:5050.
17
17
  metadata:
18
18
  author: travsteward
19
- version: "0.18.0"
19
+ version: "0.19.0"
20
20
  repository: https://github.com/travsteward/openwriter
21
21
  license: MIT
22
22
  ---
@@ -201,7 +201,7 @@ Every document has an immutable **docId** (8-char hex, e.g. `a1b2c3d4`) in its Y
201
201
  | `delete_container` | Delete a container from a workspace (doc files stay on disk) |
202
202
  | `tag_doc` | Add a tag to a document by docId (stored in doc frontmatter) |
203
203
  | `untag_doc` | Remove a tag from a document by docId |
204
- | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace) |
204
+ | `move_item` | Move or reorder a doc, container, or workspace (type: doc/container/workspace). To nest a doc into a container: `move_item({ type: 'doc', workspaceFile, itemId: <docId>, targetContainerId: <containerId>, afterId? })`. The target param is **`targetContainerId`** — passing `containerId`/`container` instead is silently ignored and the doc lands at workspace root. |
205
205
  | `rename_item` | Rename a workspace, container, or document (type: workspace/container/document) |
206
206
 
207
207
  ### Enrichment (three-field schema — v0.19.0)
@@ -328,21 +328,29 @@ The user can turn on **auto-accept** on a per-doc basis (right-click the doc in
328
328
 
329
329
  ### Workspace-Integrated Creation
330
330
 
331
- `create_document` accepts optional `workspace` and `container` parameters for direct workspace placement:
331
+ `create_document` takes placement in **either** convention (unified 2026-07-09):
332
332
 
333
333
  ```
334
334
  create_document({
335
335
  title: "Opening Chapter",
336
336
  content_type: "document", ← REQUIRED: "document" for plain, or "tweet"/"article"/etc.
337
- workspace: "The Immortal", ← creates workspace if it doesn't exist
338
- container: "Chapters" ← creates container if it doesn't exist
337
+ workspace: "The Immortal", ← name-based: creates workspace if it doesn't exist
338
+ container: "Chapters" ← name-based: creates container if it doesn't exist
339
339
  })
340
340
  ```
341
341
 
342
+ **Name-based (auto-create):**
342
343
  - **`workspace`** (string) — workspace title to add the doc to. Auto-creates if not found (case-insensitive match).
343
344
  - **`container`** (string) — container name within the workspace (e.g. "Chapters", "Notes", "References"). Auto-creates if not found. Requires `workspace`.
344
- - **`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.
345
- - All three are optional omit `workspace` for standalone docs outside any workspace.
345
+
346
+ **Id-based (target existing itemsthe same ids `move_item`/`get_workspace_structure` use):**
347
+ - **`workspaceFile`** (string) — existing workspace manifest filename (`*.json`). Must already exist. Alternative to `workspace`.
348
+ - **`containerId`** (string) — existing container id (8-char hex). Must already exist in the resolved workspace. Alternative to `container`. Requires a workspace param.
349
+
350
+ **Both conventions:**
351
+ - **`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). `afterId` alone does NOT set a workspace — pass a workspace param too.
352
+ - Omit all placement params for a standalone doc — the result then says **UNFILED** (a doc created in no workspace announces it, rather than reading as a bland success).
353
+ - A placement that can't be honored (unknown `workspaceFile`/`containerId`, or a container with no workspace) is a **hard error** — the doc is never silently created unplaced. The result always states where it landed.
346
354
 
347
355
  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.
348
356