update_draft
Modify an unpublished Substack draft by updating its title, subtitle, body in markdown, or audience.
Instructions
Update an existing draft post. Only works on unpublished drafts. Accepts markdown body.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| draft_id | Yes | The draft ID to update | |
| title | No | New title | |
| subtitle | No | New subtitle | |
| body | No | New body in markdown format | |
| audience | No | Who can see this post |
Implementation Reference
- Utility function that converts markdown to Substack's ProseMirror JSON format, used by the update_draft handler to transform body content.
export function markdownToProseMirror(markdown: string): string { const lines = markdown.split("\n"); const nodes: PMNode[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; // Blank line — skip if (line.trim() === "") { i++; continue; } // Fenced code block if (line.trimStart().startsWith("```")) { const lang = line.trim().slice(3).trim(); const codeLines: string[] = []; i++; while (i < lines.length && !lines[i].trimStart().startsWith("```")) { codeLines.push(lines[i]); i++; } i++; // skip closing ``` nodes.push({ type: "code_block", ...(lang ? { attrs: { lang } } : {}), content: [{ type: "text", text: codeLines.join("\n") }], }); continue; } // Horizontal rule if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) { nodes.push({ type: "horizontal_rule" }); i++; continue; } // Heading const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); if (headingMatch) { const level = headingMatch[1].length; nodes.push({ type: "heading", attrs: { level }, content: parseInline(headingMatch[2]), }); i++; continue; } // Blockquote if (line.trimStart().startsWith("> ")) { const quoteLines: string[] = []; while (i < lines.length && lines[i].trimStart().startsWith("> ")) { quoteLines.push(lines[i].replace(/^>\s?/, "")); i++; } nodes.push({ type: "blockquote", content: [ { type: "paragraph", content: parseInline(quoteLines.join(" ")), }, ], }); continue; } // Unordered list if (/^[\s]*[-*+]\s/.test(line)) { const items: PMNode[] = []; while (i < lines.length && /^[\s]*[-*+]\s/.test(lines[i])) { const itemText = lines[i].replace(/^[\s]*[-*+]\s/, ""); items.push({ type: "list_item", content: [ { type: "paragraph", content: parseInline(itemText) }, ], }); i++; } nodes.push({ type: "bullet_list", content: items, }); continue; } // Ordered list if (/^[\s]*\d+\.\s/.test(line)) { const items: PMNode[] = []; while (i < lines.length && /^[\s]*\d+\.\s/.test(lines[i])) { const itemText = lines[i].replace(/^[\s]*\d+\.\s/, ""); items.push({ type: "list_item", content: [ { type: "paragraph", content: parseInline(itemText) }, ], }); i++; } nodes.push({ type: "ordered_list", content: items, }); continue; } // Image (standalone line) const imgMatch = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/); if (imgMatch) { nodes.push({ type: "captionedImage", attrs: { src: imgMatch[2], alt: imgMatch[1], title: null, caption: imgMatch[1] || null, }, }); i++; continue; } // Default: paragraph — collect consecutive non-blank, non-special lines const paraLines: string[] = []; while ( i < lines.length && lines[i].trim() !== "" && !lines[i].trimStart().startsWith("```") && !lines[i].trimStart().startsWith("# ") && !lines[i].trimStart().startsWith("## ") && !lines[i].trimStart().startsWith("### ") && !lines[i].trimStart().startsWith("> ") && !/^(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i].trim()) && !/^[\s]*[-*+]\s/.test(lines[i]) && !/^[\s]*\d+\.\s/.test(lines[i]) && !/^!\[/.test(lines[i]) ) { paraLines.push(lines[i]); i++; } if (paraLines.length > 0) { const text = paraLines.join(" "); const inlineContent = parseInline(text); if (inlineContent.length > 0) { nodes.push({ type: "paragraph", content: inlineContent, }); } } } const doc: PMNode = { type: "doc", content: nodes.length > 0 ? nodes : [{ type: "paragraph" }], }; return JSON.stringify(doc); } /** * Returns the raw ProseMirror content array (for Notes, which wrap it in their own doc envelope). */ export function markdownToProseMirrorContent(markdown: string): PMNode[] {