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
- src/server.ts:207-245 (handler)The handler function for the 'update_draft' tool. Accepts draft_id (required), title, subtitle, body, audience (optional). Converts markdown body to ProseMirror format via markdownToProseMirror, then calls client.updateDraft to send a PUT request to Substack API.
server.tool( "update_draft", "Update an existing draft post. Only works on unpublished drafts. Accepts markdown body.", { draft_id: z.number().describe("The draft ID to update"), title: z.string().optional().describe("New title"), subtitle: z.string().optional().describe("New subtitle"), body: z.string().optional().describe("New body in markdown format"), audience: z .enum(["everyone", "only_paid", "founding", "only_free"]) .optional() .describe("Who can see this post"), }, async ({ draft_id, title, subtitle, body, audience }) => { const updates: Record<string, unknown> = {}; if (title !== undefined) updates.draft_title = title; if (subtitle !== undefined) updates.draft_subtitle = subtitle; if (body !== undefined) updates.draft_body = markdownToProseMirror(body); if (audience !== undefined) updates.audience = audience; const draft = await client.updateDraft(draft_id, updates); return { content: [ { type: "text", text: JSON.stringify( { id: draft.id, title: draft.draft_title, message: "Draft updated successfully.", }, null, 2, ), }, ], }; }, ); - src/server.ts:207-245 (registration)Tool registration via server.tool() in the createServer function. Registers 'update_draft' with Zod schema for input validation.
server.tool( "update_draft", "Update an existing draft post. Only works on unpublished drafts. Accepts markdown body.", { draft_id: z.number().describe("The draft ID to update"), title: z.string().optional().describe("New title"), subtitle: z.string().optional().describe("New subtitle"), body: z.string().optional().describe("New body in markdown format"), audience: z .enum(["everyone", "only_paid", "founding", "only_free"]) .optional() .describe("Who can see this post"), }, async ({ draft_id, title, subtitle, body, audience }) => { const updates: Record<string, unknown> = {}; if (title !== undefined) updates.draft_title = title; if (subtitle !== undefined) updates.draft_subtitle = subtitle; if (body !== undefined) updates.draft_body = markdownToProseMirror(body); if (audience !== undefined) updates.audience = audience; const draft = await client.updateDraft(draft_id, updates); return { content: [ { type: "text", text: JSON.stringify( { id: draft.id, title: draft.draft_title, message: "Draft updated successfully.", }, null, 2, ), }, ], }; }, ); - src/api/types.ts:63-70 (schema)TypeScript type definition for the payload sent to update a draft.
export interface DraftUpdatePayload { draft_title?: string; draft_subtitle?: string; draft_body?: string; audience?: "everyone" | "only_paid" | "founding" | "only_free"; section_id?: number | null; cover_image?: string | null; } - src/api/client.ts:148-159 (helper)API client method that sends a PUT request to /api/v1/drafts/{id} to update a draft on Substack.
async updateDraft( id: number, updates: DraftUpdatePayload, ): Promise<SubstackDraft> { return this.request<SubstackDraft>( `${this.publicationUrl}/api/v1/drafts/${id}`, { method: "PUT", body: JSON.stringify(updates), }, ); } - 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[] {