roam_create_outline
Add structured outlines with customizable nesting levels to existing Roam Research pages or blocks for organizing thoughts, research, and supplementary content.
Instructions
Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the roam_create_page tool instead. The outline parameter defines new blocks to be created. To nest content under an existing block, provide its UID or exact text in block_text_uid, and ensure the outline array contains only the child blocks with levels relative to that parent. Including the parent block's text in the outline array will create a duplicate block. Best for:
Adding supplementary structured content to existing pages
Creating temporary or working outlines (meeting notes, brainstorms)
Organizing thoughts or research under a specific topic
Breaking down subtopics or components of a larger concept Best for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider
roam_process_batch_actionsinstead. IMPORTANT: Before using this tool, ensure that you have loaded into context the 'Roam Markdown Cheatsheet' resource.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| page_title_uid | No | Title or UID of the page (UID is preferred for accuracy). Leave blank to use the default daily page. | |
| block_text_uid | No | The text content or UID of the block to nest the outline under (UID is preferred for accuracy). If blank, content is nested directly under the page (or the default daily page if page_title_uid is also blank). | |
| outline | Yes | Array of outline items with block text and explicit nesting level. Must be a valid hierarchy: the first item must be level 1, and subsequent levels cannot increase by more than 1 at a time (e.g., a level 3 cannot follow a level 1). |
Implementation Reference
- src/tools/operations/outline.ts:251-495 (handler)Core implementation of the roam_create_outline tool. Parses the outline array, resolves target page and parent block (creating if necessary), converts to Roam markdown and batch actions, executes via Roam API, and returns created block UIDs with hierarchy.async createOutline( outline: Array<OutlineItem>, page_title_uid?: string, block_text_uid?: string ): Promise<{ success: boolean; page_uid: string; parent_uid: string; created_uids: NestedBlock[] }> { // Validate input if (!Array.isArray(outline) || outline.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline must be a non-empty array' ); } // Filter out items with undefined text const validOutline = outline.filter(item => item.text !== undefined); if (validOutline.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline must contain at least one item with text' ); } // Validate outline structure const invalidItems = validOutline.filter(item => typeof item.level !== 'number' || item.level < 1 || item.level > 10 || typeof item.text !== 'string' || item.text.trim().length === 0 ); if (invalidItems.length > 0) { throw new McpError( ErrorCode.InvalidRequest, 'outline contains invalid items - each item must have a level (1-10) and non-empty text' ); } // Helper function to find or create page with retries const findOrCreatePage = async (titleOrUid: string, maxRetries = 3, delayMs = 500): Promise<string> => { // First try to find by title const titleQuery = `[:find ?uid :in $ ?title :where [?e :node/title ?title] [?e :block/uid ?uid]]`; const variations = [ titleOrUid, // Original capitalizeWords(titleOrUid), // Each word capitalized titleOrUid.toLowerCase() // All lowercase ]; for (let retry = 0; retry < maxRetries; retry++) { // Try each case variation for (const variation of variations) { const findResults = await q(this.graph, titleQuery, [variation]) as [string][]; if (findResults && findResults.length > 0) { return findResults[0][0]; } } // If not found as title, try as UID const uidQuery = `[:find ?uid :where [?e :block/uid "${titleOrUid}"] [?e :block/uid ?uid]]`; const uidResult = await q(this.graph, uidQuery, []); if (uidResult && uidResult.length > 0) { return uidResult[0][0]; } // If still not found and this is the first retry, try to create the page if (retry === 0) { const success = await createPage(this.graph, { action: 'create-page', page: { title: titleOrUid } }); // Even if createPage returns false, the page might still have been created // Wait a bit and continue to next retry await new Promise(resolve => setTimeout(resolve, delayMs)); continue; } if (retry < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, delayMs)); } } throw new McpError( ErrorCode.InvalidRequest, `Failed to find or create page "${titleOrUid}" after multiple attempts` ); }; // Get or create the target page const targetPageUid = await findOrCreatePage( page_title_uid || formatRoamDate(new Date()) ); // Get or create the parent block let targetParentUid: string; if (!block_text_uid) { targetParentUid = targetPageUid; } else { try { if (this.isValidUid(block_text_uid)) { // First try to find block by UID const uidQuery = `[:find ?uid :where [?e :block/uid "${block_text_uid}"] [?e :block/uid ?uid]]`; const uidResult = await q(this.graph, uidQuery, []) as [string][]; if (uidResult && uidResult.length > 0) { // Use existing block if found targetParentUid = uidResult[0][0]; } else { throw new McpError( ErrorCode.InvalidRequest, `Block with UID "${block_text_uid}" not found` ); } } else { // Create header block and get its UID if not a valid UID targetParentUid = await this.createAndVerifyBlock(block_text_uid, targetPageUid); } } catch (error: any) { const errorMessage = error instanceof Error ? error.message : String(error); throw new McpError( ErrorCode.InternalError, `Failed to ${this.isValidUid(block_text_uid) ? 'find' : 'create'} block "${block_text_uid}": ${errorMessage}` ); } } // Initialize result variable let result; try { // Validate level sequence if (validOutline.length > 0 && validOutline[0].level !== 1) { throw new McpError( ErrorCode.InvalidRequest, 'Invalid outline structure - the first item must be at level 1' ); } let prevLevel = 0; for (const item of validOutline) { // Level should not increase by more than 1 at a time if (item.level > prevLevel + 1) { throw new McpError( ErrorCode.InvalidRequest, `Invalid outline structure - level ${item.level} follows level ${prevLevel}` ); } prevLevel = item.level; } // Convert outline items to markdown-like structure const markdownContent = validOutline .map(item => { const indent = ' '.repeat(item.level - 1); // If the item text starts with a markdown heading (e.g., #, ##, ###), // treat it as a direct heading without adding a bullet or outline indentation. // NEW CHANGE: Handle standalone code blocks - do not prepend bullet const isCodeBlock = item.text?.startsWith('```') && item.text.endsWith('```') && item.text.includes('\n'); return isCodeBlock ? `${indent}${item.text?.trim()}` : `${indent}- ${item.text?.trim()}`; }) .join('\n'); // Convert to Roam markdown format const convertedContent = convertToRoamMarkdown(markdownContent); // Parse markdown into hierarchical structure // We pass the original OutlineItem properties (heading, children_view_type) // along with the parsed content to the nodes. const nodes = parseMarkdown(convertedContent).map((node, index) => { const outlineItem = validOutline[index]; return { ...node, ...(outlineItem?.heading && { heading_level: outlineItem.heading }), ...(outlineItem?.children_view_type && { children_view_type: outlineItem.children_view_type }) }; }); // Convert nodes to batch actions const actions = convertToRoamActions(nodes, targetParentUid, 'last'); if (actions.length === 0) { throw new McpError( ErrorCode.InvalidRequest, 'No valid actions generated from outline' ); } // Execute batch actions to create the outline result = await batchActions(this.graph, { action: 'batch-actions', actions }).catch(error => { throw new McpError( ErrorCode.InternalError, `Failed to create outline blocks: ${error.message}` ); }); if (!result) { throw new McpError( ErrorCode.InternalError, 'Failed to create outline blocks - no result returned' ); } } catch (error: any) { if (error instanceof McpError) throw error; throw new McpError( ErrorCode.InternalError, `Failed to create outline: ${error.message}` ); } // Post-creation verification to get actual UIDs for top-level blocks and their children const createdBlocks: NestedBlock[] = []; // Only query for top-level blocks (level 1) based on the original outline input const topLevelOutlineItems = validOutline.filter(item => item.level === 1); for (const item of topLevelOutlineItems) { try { // Assert item.text is a string as it's filtered earlier to be non-undefined and non-empty const foundUid = await this.findBlockWithRetry(targetParentUid, item.text!); if (foundUid) { const nestedBlock = await this.fetchBlockWithChildren(foundUid); if (nestedBlock) { createdBlocks.push(nestedBlock); } } } catch (error: any) { // This is a warning because even if one block fails to fetch, others might succeed. // The error will be logged but not re-thrown to allow partial success reporting. // console.warn(`Could not fetch nested block for "${item.text}": ${error.message}`); } } return { success: true, page_uid: targetPageUid, parent_uid: targetParentUid, created_uids: createdBlocks }; }
- src/tools/schemas.ts:120-168 (schema)Input schema definition and description for the roam_create_outline tool, including parameters for page_title_uid, block_text_uid, and outline array structure.[toolName(BASE_TOOL_NAMES.CREATE_OUTLINE)]: { name: toolName(BASE_TOOL_NAMES.CREATE_OUTLINE), description: 'Add a structured outline to an existing page or block (by title text or uid), with customizable nesting levels. To create a new page with an outline, use the `roam_create_page` tool instead. The `outline` parameter defines *new* blocks to be created. To nest content under an *existing* block, provide its UID or exact text in `block_text_uid`, and ensure the `outline` array contains only the child blocks with levels relative to that parent. Including the parent block\'s text in the `outline` array will create a duplicate block. Best for:\n- Adding supplementary structured content to existing pages\n- Creating temporary or working outlines (meeting notes, brainstorms)\n- Organizing thoughts or research under a specific topic\n- Breaking down subtopics or components of a larger concept\nBest for simpler, contiguous hierarchical content. For complex nesting (e.g., tables) or granular control over block placement, consider `roam_process_batch_actions` instead.\nIMPORTANT: Before using this tool, ensure that you have loaded into context the \'Roam Markdown Cheatsheet\' resource.', inputSchema: { type: 'object', properties: { page_title_uid: { type: 'string', description: 'Title or UID of the page (UID is preferred for accuracy). Leave blank to use the default daily page.' }, block_text_uid: { type: 'string', description: 'The text content or UID of the block to nest the outline under (UID is preferred for accuracy). If blank, content is nested directly under the page (or the default daily page if page_title_uid is also blank).' }, outline: { type: 'array', description: 'Array of outline items with block text and explicit nesting level. Must be a valid hierarchy: the first item must be level 1, and subsequent levels cannot increase by more than 1 at a time (e.g., a level 3 cannot follow a level 1).', items: { type: 'object', properties: { text: { type: 'string', description: 'Content of the block' }, level: { type: 'integer', description: 'Indentation level (1-10, where 1 is top level). Levels must be sequential and cannot be skipped (e.g., a level 3 item cannot directly follow a level 1 item).', minimum: 1, maximum: 10 }, heading: { type: 'integer', description: 'Optional: Heading formatting for this block (1-3)', minimum: 1, maximum: 3 }, children_view_type: { type: 'string', description: 'Optional: The view type for children of this block ("bullet", "document", or "numbered")', enum: ["bullet", "document", "numbered"] } }, required: ['text', 'level'] } } }, required: ['outline'] } },
- src/server/roam-server.ts:197-211 (registration)MCP server registration of the tool in the CallToolRequestSchema handler switch statement, dispatching to ToolHandlers.createOutline.case BASE_TOOL_NAMES.CREATE_OUTLINE: { const { outline, page_title_uid, block_text_uid } = request.params.arguments as { outline: Array<{ text: string | undefined; level: number }>; page_title_uid?: string; block_text_uid?: string; }; const result = await this.toolHandlers.createOutline( outline, page_title_uid, block_text_uid ); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; }
- src/tools/tool-handlers.ts:124-126 (handler)Wrapper method in ToolHandlers class that delegates to OutlineOperations.createOutline.async createOutline(outline: Array<{ text: string | undefined; level: number }>, page_title_uid?: string, block_text_uid?: string) { return this.outlineOps.createOutline(outline, page_title_uid, block_text_uid); }
- src/tools/tool-handlers.ts:33-33 (registration)Instantiation of OutlineOperations class in ToolHandlers constructor.this.outlineOps = new OutlineOperations(graph);