AutomationFlowsAI & RAG › Generate Sticky Notes and Rename Nodes

Generate Sticky Notes and Rename Nodes

Original n8n title: Auto-generate Sticky Notes and Rename Nodes

ByMiha @miha on n8n.io

This is an official n8n workflow that helps you follow our sticky note and naming guidelines - required for getting your template published on the n8n template library.

Event trigger★★★★★ complexityAI-powered32 nodesAgentOpenAI ChatOutput Parser Structured
AI & RAG Trigger: Event Nodes: 32 Complexity: ★★★★★ AI nodes: yes Added:

This workflow corresponds to n8n.io template #13868 — we link there as the canonical source.

This workflow follows the Agent → OpenAI Chat recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "nodes": [
    {
      "id": "9ac0258b-4280-4e51-92b9-73bdb68a9fda",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 272,
        "content": "## Initialize workflow\n\nStarts the workflow and sets initial variables."
      },
      "typeVersion": 1
    },
    {
      "id": "9b2ebac0-c8d3-46b4-81a1-0a9ba16797e1",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2064,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 368,
        "height": 272,
        "content": "## Prepare and parse nodes\n\nPrepares nodes and parses them for further processing."
      },
      "typeVersion": 1
    },
    {
      "id": "d406950c-0fb6-431e-ae54-733c742558a5",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2464,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 448,
        "height": 496,
        "content": "## AI logical grouping\n\nUses AI to logically group nodes."
      },
      "typeVersion": 1
    },
    {
      "id": "357b6500-6a50-4881-9e0c-c19033e0431f",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2944,
        224
      ],
      "parameters": {
        "color": 7,
        "width": 1072,
        "height": 256,
        "content": "## Collision handling and export\n\nHandles collisions and merges results for export."
      },
      "typeVersion": 1
    },
    {
      "id": "da1723ee-9f3e-468e-8c9a-a6d250ff328f",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1664,
        1008
      ],
      "parameters": {
        "color": 7,
        "width": 592,
        "height": 320,
        "content": "## Iterative adjustment\n\nControls loop for iterative collision fixes and picks the best result."
      },
      "typeVersion": 1
    },
    {
      "id": "e8557f66-051d-4fb7-8341-672215412b45",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2288,
        752
      ],
      "parameters": {
        "color": 7,
        "width": 800,
        "height": 576,
        "content": "## Conditional node renaming\n\nDecides and executes the renaming procedure with AI assistance."
      },
      "typeVersion": 1
    },
    {
      "id": "5c2f420e-733b-4fdb-83f2-c5ebbca8a21d",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3120,
        752
      ],
      "parameters": {
        "color": 7,
        "width": 832,
        "height": 576,
        "content": "## Final output preparation\n\nFormats and normalizes the final workflow for output."
      },
      "typeVersion": 1
    },
    {
      "id": "041f5632-69b8-4e1f-ae72-bc9431c49b8b",
      "name": "Start",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        1696,
        352
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "f7e1e2d2-76cc-4ab5-9c48-f3e86f0ea063",
      "name": "Parse Nodes",
      "type": "n8n-nodes-base.code",
      "position": [
        2288,
        352
      ],
      "parameters": {
        "jsCode": "const GRID = 16;\nconst DEFAULT_SIZE = [96, 96];\nconst CONFIG_SIZE = [80, 80];\nconst CONFIGURABLE_MIN_WIDTH = 256;\n\nconst data = $input.first().json;\nconst {\n  cleanedNodes, aiSubNodes, subNodeParents,\n  outgoing, incoming, maxInputIndex, maxOutputIndex, workflowName\n} = data;\n\n// --- Helpers ---\n\nconst isTrigger = (node) => {\n  const t = node.type || '';\n  return t.includes('Trigger') || t.includes('trigger') || t.includes('webhook');\n};\n\nconst getSimpleType = (fullType) => {\n  if (!fullType) return 'unknown';\n  const parts = fullType.split('.');\n  return parts[parts.length - 1] || fullType;\n};\n\nconst truncate = (str, max) =>\n  !str ? '' : str.length <= max ? str : str.substring(0, max) + '...';\n\nconst safeGet = (obj, path, fallback) => {\n  let cur = obj;\n  for (const k of path.split('.')) {\n    if (cur == null) return fallback;\n    cur = cur[k];\n  }\n  return cur !== undefined ? cur : fallback;\n};\n\n// --- Node dimension calculator ---\n// Based on n8n source: packages/frontend/editor-ui/src/app/utils/nodeViewUtils.ts\n\nfunction getNodeDimensions(node) {\n  const isSubNode = !!subNodeParents[node.id];\n  const isAIParent = !!aiSubNodes[node.id];\n\n  // Configuration nodes (sub-nodes) are circular 80x80\n  if (isSubNode) {\n    return { width: CONFIG_SIZE[0], height: CONFIG_SIZE[1] };\n  }\n\n  // Calculate height from actual input/output SLOT count (not connection count)\n  const inputSlots = maxInputIndex[node.name] || 1;\n  const outputSlots = maxOutputIndex[node.name] || 1;\n  const maxHandles = Math.max(inputSlots, outputSlots, 1);\n  const height = DEFAULT_SIZE[1] + Math.max(0, maxHandles - 2) * GRID * 2;\n\n  // Configurable nodes (AI parents) are wider\n  if (isAIParent) {\n    const subCount = aiSubNodes[node.id].length;\n    const portCount = Math.max(4, subCount);\n    const calcWidth = 80 + GRID * ((portCount - 1) * 3);\n    return { width: Math.max(CONFIGURABLE_MIN_WIDTH, calcWidth), height };\n  }\n\n  // Default nodes\n  return { width: DEFAULT_SIZE[0], height };\n}\n\n// --- Context extractor (for AI prompt) ---\n\nfunction extractContext(node) {\n  const p = node.parameters || {};\n  const type = getSimpleType(node.type);\n\n  switch (type) {\n    case 'httpRequest':\n      return { description: `${p.method || 'GET'} request to ${truncate(p.url, 40) || 'URL'}` };\n    case 'set':\n      const fields = safeGet(p, 'assignments.assignments', [])\n        .slice(0, 4).map(a => a.name).filter(Boolean);\n      return { description: `Sets: ${fields.join(', ') || 'fields'}` };\n    case 'if':\n      return { description: 'Conditional branch' };\n    case 'switch':\n      return { description: 'Multi-way branch' };\n    case 'code': {\n      const first = (p.jsCode || '').split('\\n')[0] || '';\n      const hint = first.trim().startsWith('//')\n        ? first.replace('//', '').trim()\n        : 'Custom code';\n      return { description: hint };\n    }\n    case 'filter':\n      return { description: 'Filters items' };\n    case 'merge':\n      return { description: `Merge: ${p.mode || 'append'}` };\n    case 'executeWorkflow':\n      return { description: `Sub-workflow: ${truncate(safeGet(p, 'workflowId.cachedResultName', ''), 30) || 'workflow'}` };\n    case 'postgres':\n      return { description: p.operation === 'executeQuery' ? 'SQL query' : `Postgres ${p.operation || 'op'}` };\n    case 'agent':\n      return { description: 'AI Agent' };\n    case 'chainLlm':\n      return { description: 'LLM Chain' };\n    case 'noOp':\n      return { description: 'No operation (pass-through)' };\n    case 'stopAndError':\n      return { description: 'Stop with error' };\n    case 'executionData':\n      return { description: 'Set execution data' };\n    default:\n      if (isTrigger(node)) return { description: `${type} trigger` };\n      return { description: `${type} node` };\n  }\n}\n\n// --- Build enriched node list (excluding sub-nodes) ---\n\nconst nodes = [];\n\nfor (const node of cleanedNodes) {\n  if (subNodeParents[node.id]) continue;\n\n  const dims = getNodeDimensions(node);\n  const subs = (aiSubNodes[node.id] || []).map(s => ({\n    id: s.id,\n    name: s.name,\n    type: s.type,\n    position: { x: s.position[0], y: s.position[1] },\n    dimensions: getNodeDimensions({ id: s.id, type: s.type, name: s.name })\n  }));\n\n  nodes.push({\n    id: node.id,\n    name: node.name,\n    simpleType: getSimpleType(node.type),\n    position: { x: node.position[0], y: node.position[1] },\n    dimensions: dims,\n    context: extractContext(node),\n    connectsTo: outgoing[node.name] || [],\n    connectsFrom: incoming[node.name] || [],\n    isEntryPoint: isTrigger(node) || (incoming[node.name] || []).length === 0,\n    subNodes: subs\n  });\n}\n\nreturn {\n  json: {\n    workflowName,\n    nodeCount: nodes.length,\n    nodes,\n    aiSubNodes\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "4fed7ea4-6f87-477d-9051-41cfc8c459bd",
      "name": "AI Groups Logically",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2512,
        336
      ],
      "parameters": {
        "text": "=Workflow: {{ $json.workflowName }}\nNode count: {{ $json.nodeCount }}\n\nNodes with positions, connections, and context:\n{{ JSON.stringify($json.nodes, null, 2) }}\n\nGroup these nodes considering both their purpose and their spatial position on the canvas.\nEach group should correspond to a visually distinct cluster of nodes.\nEvery node ID must appear in exactly one group.",
        "options": {
          "systemMessage": "=You are analyzing n8n workflows and grouping nodes based on BOTH their logical purpose AND their spatial position on the canvas.\n\n## Your Task\n\n1. Create logical groups of nodes that work together on the same task\n2. Generate a main overview describing the workflow\n3. Title and describe each group briefly\n\n## Critical: Spatial Awareness\n\nNodes have [x, y] positions on a 2D canvas. The creator placed them intentionally.\n\n- Group nodes that are BOTH logically related AND spatially close\n- If two nodes do similar things but are far apart (>800px on any axis), prefer separate groups\n- A group's nodes should form a visible cluster on the canvas\n- When logically different nodes sit within the same spatial cluster, group them together and expand the description to cover everything that cluster does\n\n## Grouping Guidelines\n\n- Aim for at least {{ Math.ceil((Number($json.nodeCount) || 0) / 3) }} groups depending on workflow complexity\n- Every node must belong to exactly ONE group\n- Smaller, spatially tight groups are better than large sprawling ones\n- A single isolated node can be its own group if it's far from others\n\n## Naming\n\n- Use short, descriptive titles (3-6 words)\n- Sentence case\n- Examples: \"Fetch and validate data\", \"Process AI response\", \"Save results to database\"\n\n## Main Overview\n\n- **howItWorks**: A numbered list of 2-6 items (each on a \\n new line) explaining what the workflow does. Third person. One concise sentence per item.\n- **setupSteps**: Actionable setup instructions. Focus on credentials and configuration needed. Each bullet should start with a markdown checkbox - [ ] \n- **customization**: Optional. Only include if there are obvious customization points.\n\n## Output Rules\n\n- Every node ID from the input must appear in exactly one group's nodeIds array\n- Do not invent node IDs \u2014 only use IDs provided in the input\n- Order groups by typical execution flow (trigger/input first, output/error last)"
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3
    },
    {
      "id": "f99294c8-4b0e-4be9-b573-3d03953f7163",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        2512,
        576
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o",
          "cachedResultName": "gpt-4o"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "0170da4a-2a88-422d-ae09-78021ee7f132",
      "name": "Structured Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        2656,
        576
      ],
      "parameters": {
        "autoFix": true,
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"mainOverview\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"howItWorks\": {\n          \"type\": \"string\",\n          \"description\": \"A list of 2-6 items explaining what the workflow does.\"\n        },\n        \"setupSteps\": {\n          \"type\": \"array\",\n          \"items\": { \"type\": \"string\" },\n          \"description\": \"Setup instructions\"\n        },\n        \"customization\": {\n          \"type\": \"string\",\n          \"description\": \"Optional customization tips\"\n        }\n      },\n      \"required\": [\"howItWorks\", \"setupSteps\"],\n      \"additionalProperties\": false\n    },\n    \"groups\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"groupId\": {\n            \"type\": \"integer\",\n            \"description\": \"Sequential group ID starting from 0\"\n          },\n          \"title\": {\n            \"type\": \"string\",\n            \"description\": \"Short title, 3-6 words\"\n          },\n          \"description\": {\n            \"type\": \"string\",\n            \"description\": \"Brief description or empty string\"\n          },\n          \"nodeIds\": {\n            \"type\": \"array\",\n            \"items\": { \"type\": \"string\" },\n            \"description\": \"Array of node IDs in this group\"\n          }\n        },\n        \"required\": [\"groupId\", \"title\", \"description\", \"nodeIds\"],\n        \"additionalProperties\": false\n      }\n    }\n  },\n  \"required\": [\"mainOverview\", \"groups\"],\n  \"additionalProperties\": false\n}"
      },
      "typeVersion": 1.2
    },
    {
      "id": "9de1a64a-97c2-40ce-9670-fc52644ca375",
      "name": "Generate Stickies",
      "type": "n8n-nodes-base.code",
      "position": [
        3472,
        336
      ],
      "parameters": {
        "jsCode": "const GRID = 16;\nconst MAIN_STICKY_WIDTH = 480;\nconst MAIN_STICKY_MIN_HEIGHT = 420;\nconst MAIN_STICKY_MAX_HEIGHT = 900;\nconst GAP_MAIN_TO_WORKFLOW = 80;  // 5 * GRID\nconst COLOR_WHITE = 7;\n\nconst data = $input.first().json;\nconst { groups, finalPositions, mainOverview, aiSubNodes } = data;\nconst parseData = $('Parse Nodes').first().json;\nconst workflowName = parseData.workflowName || 'Workflow overview';\n\nfunction uuid() {\n  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {\n    const r = Math.random() * 16 | 0;\n    return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);\n  });\n}\n\nfunction snapToGrid(val) {\n  return Math.round(val / GRID) * GRID;\n}\n\n// --- Build main overview content ---\n\nfunction formatMainOverview(overview, name) {\n  let content = `## ${name}\\n\\n`;\n  content += `### How it works\\n\\n${overview.howItWorks}\\n\\n`;\n  content += `### Setup steps\\n\\n`;\n  for (const step of (overview.setupSteps || [])) {\n    content += `- ${step}\\n`;\n  }\n  if (overview.customization?.trim()) {\n    content += `\\n### Customization\\n\\n${overview.customization}`;\n  }\n  return content;\n}\n\nfunction estimateContentHeight(content, width) {\n  const charsPerLine = Math.floor(width / 8);\n  let height = 60;\n  for (const line of content.split('\\n')) {\n    if (line.startsWith('## ')) height += 40;\n    else if (line.startsWith('### ')) height += 32;\n    else if (line.trim() === '') height += 16;\n    else if (line.startsWith('- ') || line.startsWith('* '))\n      height += Math.ceil(line.length / charsPerLine) * 24;\n    else height += Math.ceil(line.length / charsPerLine) * 22;\n  }\n  return height + 60;\n}\n\n// --- Find overall workflow bounds (from final positions of stickies) ---\n\nlet workflowMinX = Infinity;\nlet workflowMinY = Infinity;\nlet workflowMaxY = -Infinity;\n\nfor (const group of groups) {\n  const s = group.sticky;\n  workflowMinX = Math.min(workflowMinX, s.x);\n  workflowMinY = Math.min(workflowMinY, s.y);\n  workflowMaxY = Math.max(workflowMaxY, s.y + s.height);\n}\n\nif (workflowMinX === Infinity) {\n  workflowMinX = 176;\n  workflowMinY = 240;\n  workflowMaxY = 500;\n}\n\n// --- Main overview sticky ---\n\nconst mainContent = formatMainOverview(mainOverview, workflowName);\nconst calcHeight = estimateContentHeight(mainContent, MAIN_STICKY_WIDTH);\nconst workflowSpan = workflowMaxY - workflowMinY;\nconst mainHeight = Math.max(\n  MAIN_STICKY_MIN_HEIGHT,\n  Math.min(MAIN_STICKY_MAX_HEIGHT, Math.max(calcHeight, workflowSpan))\n);\n\nconst stickies = [];\n\nstickies.push({\n  parameters: {\n    content: mainContent,\n    width: MAIN_STICKY_WIDTH,\n    height: snapToGrid(mainHeight)\n  },\n  type: 'n8n-nodes-base.stickyNote',\n  typeVersion: 1,\n  position: [\n    snapToGrid(workflowMinX - MAIN_STICKY_WIDTH - GAP_MAIN_TO_WORKFLOW),\n    snapToGrid(workflowMinY)\n  ],\n  id: uuid(),\n  name: 'Sticky Note'\n});\n\n// --- Group stickies ---\n\nfor (let i = 0; i < groups.length; i++) {\n  const group = groups[i];\n  const s = group.sticky;\n\n  let content = `## ${group.title}`;\n  if (group.description?.trim()) {\n    content += `\\n\\n${group.description}`;\n  }\n\n  stickies.push({\n    parameters: {\n      content,\n      width: snapToGrid(s.width),\n      height: snapToGrid(s.height),\n      color: COLOR_WHITE\n    },\n    type: 'n8n-nodes-base.stickyNote',\n    typeVersion: 1,\n    position: [snapToGrid(s.x), snapToGrid(s.y)],\n    id: uuid(),\n    name: `Sticky Note${i + 1}`\n  });\n}\n\nreturn {\n  json: {\n    stickies,\n    finalPositions,\n    aiSubNodes\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "0350bd4c-4f57-428a-8221-f545a0aabfc6",
      "name": "Strip & Prepare",
      "type": "n8n-nodes-base.code",
      "position": [
        2112,
        352
      ],
      "parameters": {
        "jsCode": "const workflow = $input.first().json.workflow;\nconst allNodes = workflow.nodes || [];\nconst connections = workflow.connections || {};\n\n// 1. Remove existing sticky notes\nconst cleanedNodes = allNodes.filter(\n  n => n.type !== 'n8n-nodes-base.stickyNote'\n);\n\n// 2. Build AI sub-node relationships\n// Sub-nodes connect via ai_languageModel, ai_tool, ai_memory, ai_outputParser\nconst AI_CONNECTION_TYPES = new Set([\n  'ai_languageModel', 'ai_tool', 'ai_memory', 'ai_outputParser'\n]);\n\nconst nodesByName = {};\nfor (const node of cleanedNodes) {\n  nodesByName[node.name] = node;\n}\n\nconst aiSubNodes = {};      // parentId -> [{ id, name, type, position }]\nconst subNodeParents = {};  // subNodeId -> parentId\n\nfor (const [fromName, connDef] of Object.entries(connections)) {\n  if (!connDef) continue;\n  for (const [connType, outputs] of Object.entries(connDef)) {\n    if (!AI_CONNECTION_TYPES.has(connType)) continue;\n    for (const arr of (outputs || [])) {\n      if (!Array.isArray(arr)) continue;\n      for (const c of arr) {\n        if (!c?.node) continue;\n        const parentNode = nodesByName[c.node];\n        const subNode = nodesByName[fromName];\n        if (!parentNode || !subNode) continue;\n\n        if (!aiSubNodes[parentNode.id]) aiSubNodes[parentNode.id] = [];\n        aiSubNodes[parentNode.id].push({\n          id: subNode.id,\n          name: subNode.name,\n          type: subNode.type,\n          position: subNode.position\n        });\n        subNodeParents[subNode.id] = parentNode.id;\n      }\n    }\n  }\n}\n\n// 3. Build main connection maps + count actual input/output SLOTS\nconst outgoing = {};  // nodeName -> [targetNodeNames]\nconst incoming = {};  // nodeName -> [sourceNodeNames]\n\n// Track the highest input/output INDEX used per node (not just count of connections)\nconst maxInputIndex = {};   // nodeName -> highest input index seen\nconst maxOutputIndex = {};  // nodeName -> highest output index (= number of output arrays)\n\nfor (const [fromName, connDef] of Object.entries(connections)) {\n  if (!connDef?.main) continue;\n\n  // The number of output slots = length of the main array\n  maxOutputIndex[fromName] = Math.max(\n    maxOutputIndex[fromName] || 0,\n    connDef.main.length\n  );\n\n  for (let outputIdx = 0; outputIdx < connDef.main.length; outputIdx++) {\n    const outputArr = connDef.main[outputIdx];\n    if (!Array.isArray(outputArr)) continue;\n    for (const conn of outputArr) {\n      if (!conn?.node) continue;\n\n      if (!outgoing[fromName]) outgoing[fromName] = [];\n      outgoing[fromName].push(conn.node);\n      if (!incoming[conn.node]) incoming[conn.node] = [];\n      incoming[conn.node].push(fromName);\n\n      // Track the input index this connection targets\n      const inputIdx = (conn.index ?? 0) + 1; // index is 0-based, we want count\n      maxInputIndex[conn.node] = Math.max(\n        maxInputIndex[conn.node] || 0,\n        inputIdx\n      );\n    }\n  }\n}\n\nreturn {\n  json: {\n    cleanedNodes,\n    connections,\n    aiSubNodes,\n    subNodeParents,\n    outgoing,\n    incoming,\n    maxInputIndex,\n    maxOutputIndex,\n    workflowName: workflow.name || 'Untitled workflow'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "9004966d-ce52-424b-9593-5c598dc128c1",
      "name": "Compute Bounding Boxes",
      "type": "n8n-nodes-base.code",
      "position": [
        2992,
        336
      ],
      "parameters": {
        "jsCode": "const GRID = 16;\nconst PADDING_X = 48;          // 3 * GRID \u2014 side padding\nconst PADDING_BOTTOM = 64;     // 4 * GRID \u2014 matches n8n's STICKY_BOTTOM_PADDING\nconst MIN_PADDING_TOP = 80;    // minimum top padding even for short titles\nconst MIN_STICKY_WIDTH = 240;\nconst MIN_STICKY_HEIGHT = 180;\n\nconst aiOutput = $input.first().json.output || $input.first().json;\nconst parseData = $('Parse Nodes').first().json;\n\nconst { nodes: enrichedNodes, aiSubNodes } = parseData;\n\n// Build lookup: nodeId -> enriched node data\nconst nodeById = {};\nfor (const n of enrichedNodes) {\n  nodeById[n.id] = n;\n  for (const sub of (n.subNodes || [])) {\n    nodeById[sub.id] = sub;\n  }\n}\n\nfunction snapToGrid(val) {\n  return Math.round(val / GRID) * GRID;\n}\n\n// Calculate how much vertical space the sticky text will need\n// so nodes don't overlap with the title/description\nfunction estimateStickyTextHeight(title, description, stickyWidth) {\n  const charsPerLine = Math.max(1, Math.floor(stickyWidth / 9));\n  let height = 24; // top margin inside sticky\n\n  // Title: \"## 1. Title text\"\n  const titleText = `## ${title}`;\n  const titleLines = Math.ceil(titleText.length / charsPerLine);\n  height += titleLines * 36; // h2 line height\n\n  // Description\n  if (description && description.trim()) {\n    height += 12; // gap between title and description\n    const descLines = Math.ceil(description.length / charsPerLine);\n    height += descLines * 22;\n  }\n\n  height += 20; // bottom margin before nodes start\n  return Math.max(MIN_PADDING_TOP, height);\n}\n\n// For each group, compute the bounding box around all member nodes + sub-nodes\nconst groupBounds = [];\n\nfor (const group of aiOutput.groups) {\n  const allNodeIds = [];\n\n  for (const id of group.nodeIds) {\n    allNodeIds.push(id);\n    // Include sub-nodes of any AI parent in this group\n    if (aiSubNodes[id]) {\n      for (const sub of aiSubNodes[id]) {\n        allNodeIds.push(sub.id);\n      }\n    }\n  }\n\n  let minX = Infinity, minY = Infinity;\n  let maxX = -Infinity, maxY = -Infinity;\n\n  for (const id of allNodeIds) {\n    const node = nodeById[id];\n    if (!node) continue;\n\n    const x = node.position?.x ?? node.position?.[0] ?? 0;\n    const y = node.position?.y ?? node.position?.[1] ?? 0;\n    const w = node.dimensions?.width ?? 96;\n    const h = node.dimensions?.height ?? 96;\n\n    minX = Math.min(minX, x);\n    minY = Math.min(minY, y);\n    maxX = Math.max(maxX, x + w);\n    maxY = Math.max(maxY, y + h);\n  }\n\n  if (minX === Infinity) continue; // empty group\n\n  // First pass: estimate sticky width to calculate text height\n  const rawWidth = Math.max(MIN_STICKY_WIDTH, maxX - minX + PADDING_X * 2);\n\n  // Calculate dynamic top padding based on text content\n  const textHeight = estimateStickyTextHeight(\n    group.title || '',\n    group.description || '',\n    rawWidth\n  );\n\n  // Apply padding and snap\n  const stickyX = snapToGrid(minX - PADDING_X);\n  const stickyY = snapToGrid(minY - textHeight);\n  const stickyWidth = snapToGrid(rawWidth);\n  const stickyHeight = Math.max(\n    MIN_STICKY_HEIGHT,\n    snapToGrid(maxY - minY + textHeight + PADDING_BOTTOM)\n  );\n\n  groupBounds.push({\n    groupId: group.groupId,\n    title: group.title,\n    description: group.description,\n    nodeIds: group.nodeIds,\n    sticky: {\n      x: stickyX,\n      y: stickyY,\n      width: stickyWidth,\n      height: stickyHeight\n    },\n    textHeight, // store for reference\n    contentBounds: { minX, minY, maxX, maxY }\n  });\n}\n\nreturn {\n  json: {\n    groupBounds,\n    mainOverview: aiOutput.mainOverview,\n    aiSubNodes\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "c2080ff0-77b3-4605-9e57-1570e0aa635f",
      "name": "Collision Resolution",
      "type": "n8n-nodes-base.code",
      "position": [
        3216,
        336
      ],
      "parameters": {
        "jsCode": "const GRID = 16;\nconst GAP = 32;          // 2 * GRID \u2014 minimum gap between stickies\nconst MAX_ITERATIONS = 15;\n\nconst data = $input.first().json;\nconst parseData = $('Parse Nodes').first().json;\n\nconst { groupBounds, mainOverview, aiSubNodes } = data;\nconst { nodes: enrichedNodes } = parseData;\n\nfunction snapToGrid(val) {\n  return Math.round(val / GRID) * GRID;\n}\n\n// Build nodeId -> original position lookup\nconst nodePositions = {};\nfor (const n of enrichedNodes) {\n  nodePositions[n.id] = { x: n.position.x, y: n.position.y };\n  for (const sub of (n.subNodes || [])) {\n    nodePositions[sub.id] = { x: sub.position.x, y: sub.position.y };\n  }\n}\n\n// Working copy of group bounds and node positions\nconst groups = groupBounds.map(g => ({\n  ...g,\n  sticky: { ...g.sticky },\n  shifted: { dx: 0, dy: 0 }\n}));\n\n// Detect overlap between two rectangles\nfunction overlaps(a, b) {\n  return !(\n    a.x + a.width + GAP <= b.x ||\n    b.x + b.width + GAP <= a.x ||\n    a.y + a.height + GAP <= b.y ||\n    b.y + b.height + GAP <= a.y\n  );\n}\n\n// Calculate overlap amounts on each axis\nfunction overlapAmount(a, b) {\n  const overX = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);\n  const overY = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);\n  return { x: Math.max(0, overX), y: Math.max(0, overY) };\n}\n\n// Resolve collisions iteratively\n// Groups ordered by index (execution flow) \u2014 lower index = higher priority (stays put)\nlet iterations = 0;\nlet hasOverlap = true;\n\nwhile (hasOverlap && iterations < MAX_ITERATIONS) {\n  hasOverlap = false;\n  iterations++;\n\n  for (let i = 0; i < groups.length; i++) {\n    for (let j = i + 1; j < groups.length; j++) {\n      const a = groups[i].sticky;\n      const b = groups[j].sticky;\n\n      if (!overlaps(a, b)) continue;\n      hasOverlap = true;\n\n      const overlap = overlapAmount(a, b);\n\n      // Determine shift direction: push along axis of LEAST overlap\n      // (minimum displacement to resolve)\n      let dx = 0, dy = 0;\n\n      if (overlap.x <= overlap.y) {\n        // Push horizontally\n        dx = overlap.x + GAP;\n        // Push right if b is to the right of a, otherwise push left\n        if (b.x + b.width / 2 < a.x + a.width / 2) {\n          dx = -dx;\n        }\n      } else {\n        // Push vertically\n        dy = overlap.y + GAP;\n        if (b.y + b.height / 2 < a.y + a.height / 2) {\n          dy = -dy;\n        }\n      }\n\n      dx = snapToGrid(dx);\n      dy = snapToGrid(dy);\n\n      // Shift group j (lower priority)\n      groups[j].sticky.x += dx;\n      groups[j].sticky.y += dy;\n      groups[j].shifted.dx += dx;\n      groups[j].shifted.dy += dy;\n    }\n  }\n}\n\n// Apply accumulated shifts to node positions\nconst finalPositions = { ...nodePositions };\n\nfor (const group of groups) {\n  if (group.shifted.dx === 0 && group.shifted.dy === 0) continue;\n\n  const allNodeIds = [...group.nodeIds];\n  for (const id of group.nodeIds) {\n    if (aiSubNodes[id]) {\n      for (const sub of aiSubNodes[id]) {\n        allNodeIds.push(sub.id);\n      }\n    }\n  }\n\n  for (const id of allNodeIds) {\n    if (!finalPositions[id]) continue;\n    finalPositions[id] = {\n      x: snapToGrid(finalPositions[id].x + group.shifted.dx),\n      y: snapToGrid(finalPositions[id].y + group.shifted.dy)\n    };\n  }\n}\n\nreturn {\n  json: {\n    groups,\n    finalPositions,\n    mainOverview,\n    aiSubNodes,\n    totalShifted: groups.filter(g => g.shifted.dx !== 0 || g.shifted.dy !== 0).length,\n    iterations\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "c293a6bc-5b01-4a06-9441-40680f88b205",
      "name": "Merge & Export",
      "type": "n8n-nodes-base.code",
      "position": [
        3680,
        336
      ],
      "parameters": {
        "jsCode": "const stickyData = $input.first().json;\nconst originalWorkflow = $('Set Workflow Variables').first().json.workflow;\nconst { stickies, finalPositions, aiSubNodes } = stickyData;\n\n// Deep copy workflow\nconst workflow = JSON.parse(JSON.stringify(originalWorkflow));\n\n// Remove old stickies\nconst nodesWithoutStickies = workflow.nodes.filter(\n  n => n.type !== 'n8n-nodes-base.stickyNote'\n);\n\n// Update positions for nodes that were shifted during collision resolution\nfor (const node of nodesWithoutStickies) {\n  const pos = finalPositions[node.id];\n  if (pos) {\n    node.position = [pos.x, pos.y];\n  }\n}\n\n// Combine: stickies first (so they render behind nodes), then all nodes\nworkflow.nodes = [...stickies, ...nodesWithoutStickies];\n\nreturn {\n  json: {\n    workflow,\n    workflowJson: JSON.stringify(workflow, null, 2),\n    message: 'Stickies generated \u2014 original layout preserved, collisions resolved'\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "11105e54-015c-44f9-a991-fd9bad6a09f5",
      "name": "Pick Best Result",
      "type": "n8n-nodes-base.code",
      "position": [
        2112,
        1152
      ],
      "parameters": {
        "jsCode": "const data = $input.first().json;\nconst workflow = data.bestWorkflow;\n\nreturn {\n  json: {\n    workflow,\n    totalPasses: data.collisionIteration,\n    finalCollisions: data.bestCollisionCount,\n    message: data.bestCollisionCount === 0\n      ? `Clean \u2014 no collisions (resolved in ${data.collisionIteration} pass${data.collisionIteration > 1 ? 'es' : ''})`\n      : `Best result after ${data.collisionIteration} passes: ${data.bestCollisionCount} remaining collision${data.bestCollisionCount > 1 ? 's' : ''}`\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "4f2d5b38-a856-4cdc-a3d0-a341dbee5223",
      "name": "Collision Detector",
      "type": "n8n-nodes-base.code",
      "position": [
        3888,
        336
      ],
      "parameters": {
        "jsCode": "const GRID = 16;\nconst GAP = 32;            // minimum gap between stickies\nconst NODE_GAP = 16;       // minimum gap between sticky edge and foreign node\nconst MAX_FIX_PASSES = 10;\n\nconst input = $input.first().json;\nconst workflow = JSON.parse(JSON.stringify(input.workflow));\nconst iteration = (input.collisionIteration || 0) + 1;\n\nfunction snapToGrid(val) {\n  return Math.round(val / GRID) * GRID;\n}\n\n// Separate stickies and regular nodes\nconst stickies = [];\nconst regularNodes = [];\n\nfor (const node of workflow.nodes) {\n  if (node.type === 'n8n-nodes-base.stickyNote') {\n    stickies.push(node);\n  } else {\n    regularNodes.push(node);\n  }\n}\n\n// Get sticky bounds\nfunction stickyRect(s) {\n  return {\n    id: s.id,\n    x: s.position[0],\n    y: s.position[1],\n    w: s.parameters.width || 240,\n    h: s.parameters.height || 180,\n    right() { return this.x + this.w; },\n    bottom() { return this.y + this.h; }\n  };\n}\n\n// Get node bounds (assume 96x96 default \u2014 we don't have dimensions here,\n// but this is a safety net, not precision pass)\nfunction nodeRect(n) {\n  return {\n    id: n.id,\n    x: n.position[0],\n    y: n.position[1],\n    w: 96,\n    h: 96,\n    right() { return this.x + this.w; },\n    bottom() { return this.y + this.h; }\n  };\n}\n\n// Check if two rects overlap with a gap\nfunction rectsOverlap(a, b, gap) {\n  return !(\n    a.right() + gap <= b.x ||\n    b.right() + gap <= a.x ||\n    a.bottom() + gap <= b.y ||\n    b.bottom() + gap <= a.y\n  );\n}\n\n// Determine which nodes are \"inside\" each sticky\n// A node is inside a sticky if it's fully contained within the sticky bounds\nfunction nodeInsideSticky(nodeR, stickyR) {\n  return (\n    nodeR.x >= stickyR.x &&\n    nodeR.y >= stickyR.y &&\n    nodeR.right() <= stickyR.right() &&\n    nodeR.bottom() <= stickyR.bottom()\n  );\n}\n\n// Build ownership: which regular nodes belong to which sticky\nconst stickyOwnership = {}; // stickyId -> Set of nodeIds\n\nfor (const s of stickies) {\n  const sr = stickyRect(s);\n  stickyOwnership[s.id] = new Set();\n  for (const n of regularNodes) {\n    const nr = nodeRect(n);\n    if (nodeInsideSticky(nr, sr)) {\n      stickyOwnership[s.id].add(n.id);\n    }\n  }\n}\n\nlet collisionsFound = 0;\n\n// --- Pass 1: Sticky-to-sticky collisions ---\n// Main overview sticky (index 0, no color = yellow) gets highest priority\n// Then ordered by position (left-to-right, top-to-bottom)\n\nconst sortedStickies = [...stickies].sort((a, b) => {\n  // Main overview (no color parameter) gets priority\n  const aIsMain = !a.parameters.color;\n  const bIsMain = !b.parameters.color;\n  if (aIsMain && !bIsMain) return -1;\n  if (!aIsMain && bIsMain) return 1;\n  // Otherwise sort by x position, then y\n  if (a.position[0] !== b.position[0]) return a.position[0] - b.position[0];\n  return a.position[1] - b.position[1];\n});\n\nfor (let pass = 0; pass < MAX_FIX_PASSES; pass++) {\n  let fixedAny = false;\n\n  for (let i = 0; i < sortedStickies.length; i++) {\n    for (let j = i + 1; j < sortedStickies.length; j++) {\n      const a = stickyRect(sortedStickies[i]);\n      const b = stickyRect(sortedStickies[j]);\n\n      if (!rectsOverlap(a, b, GAP)) continue;\n\n      collisionsFound++;\n      fixedAny = true;\n\n      // Calculate overlap on each axis\n      const overX = Math.min(a.right(), b.right()) - Math.max(a.x, b.x) + GAP;\n      const overY = Math.min(a.bottom(), b.bottom()) - Math.max(a.y, b.y) + GAP;\n\n      let dx = 0, dy = 0;\n\n      // Push along axis of least overlap\n      if (overX <= overY) {\n        dx = overX;\n        if (b.x + b.w / 2 < a.x + a.w / 2) dx = -dx;\n      } else {\n        dy = overY;\n        if (b.y + b.h / 2 < a.y + a.h / 2) dy = -dy;\n      }\n\n      dx = snapToGrid(dx);\n      dy = snapToGrid(dy);\n\n      // Shift sticky j\n      sortedStickies[j].position[0] += dx;\n      sortedStickies[j].position[1] += dy;\n\n      // Shift all nodes owned by sticky j\n      const ownedIds = stickyOwnership[sortedStickies[j].id] || new Set();\n      for (const node of regularNodes) {\n        if (ownedIds.has(node.id)) {\n          node.position[0] = snapToGrid(node.position[0] + dx);\n          node.position[1] = snapToGrid(node.position[1] + dy);\n        }\n      }\n    }\n  }\n\n  if (!fixedAny) break;\n}\n\n// --- Pass 2: Check for foreign nodes overlapping stickies ---\n// A \"foreign\" node is one that overlaps a sticky it doesn't belong to\n\nfor (let pass = 0; pass < MAX_FIX_PASSES; pass++) {\n  let fixedAny = false;\n\n  for (const s of stickies) {\n    const sr = stickyRect(s);\n    const owned = stickyOwnership[s.id] || new Set();\n\n    for (const n of regularNodes) {\n      if (owned.has(n.id)) continue; // skip nodes that belong to this sticky\n      const nr = nodeRect(n);\n\n      if (!rectsOverlap(sr, nr, NODE_GAP)) continue;\n\n      // Foreign node overlaps this sticky \u2014 expand the sticky to avoid it\n      // OR shift the sticky. Expanding is safer (doesn't cascade).\n      // We'll shrink the sticky edge away from the node.\n\n      // Actually, the safest fix: grow the sticky so its edge clears the node,\n      // but only if the node is just barely clipping. If major overlap,\n      // we shift the sticky instead.\n\n      const overlapArea = (\n        Math.max(0, Math.min(sr.right(), nr.right()) - Math.max(sr.x, nr.x)) *\n        Math.max(0, Math.min(sr.bottom(), nr.bottom()) - Math.max(sr.y, nr.y))\n      );\n\n      // If overlap is small relative to sticky, just note it\n      // The main sticky-to-sticky resolution should have handled most cases\n      if (overlapArea > 0) {\n        collisionsFound++;\n        fixedAny = true;\n\n        // Determine which edge of the sticky the node is closest to\n        const distLeft = Math.abs(nr.right() - sr.x);\n        const distRight = Math.abs(nr.x - sr.right());\n        const distTop = Math.abs(nr.bottom() - sr.y);\n        const distBottom = Math.abs(nr.y - sr.bottom());\n        const minDist = Math.min(distLeft, distRight, distTop, distBottom);\n\n        // Pull the sticky edge back + shift owned nodes\n        const shift = NODE_GAP + GRID;\n        let sdx = 0, sdy = 0;\n\n        if (minDist === distRight) {\n          // Node is near right edge \u2014 shrink width\n          s.parameters.width = snapToGrid(Math.max(240, sr.x + sr.w - nr.x - shift));\n        } else if (minDist === distBottom) {\n          // Node is near bottom edge \u2014 shrink height\n          s.parameters.height = snapToGrid(Math.max(180, sr.y + sr.h - nr.y - shift));\n        } else if (minDist === distLeft) {\n          // Node is near left edge \u2014 shift sticky right\n          sdx = snapToGrid(nr.right() + shift - sr.x);\n          s.position[0] += sdx;\n          s.parameters.width = snapToGrid(Math.max(240, sr.w - sdx));\n        } else {\n          // Node is near top edge \u2014 shift sticky down\n          sdy = snapToGrid(nr.bottom() + shift - sr.y);\n          s.position[1] += sdy;\n          s.parameters.height = snapToGrid(Math.max(180, sr.h - sdy));\n        }\n      }\n    }\n  }\n\n  if (!fixedAny) break;\n}\n\n// Reassemble workflow\nworkflow.nodes = [...stickies, ...regularNodes];\n\nreturn {\n  json: {\n    workflow,\n    workflowJson: JSON.stringify(workflow, null, 2),\n    collisionsFound,\n    collisionIteration: iteration,\n    hasCollisions: collisionsFound > 0 && iteration < 5\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "2d77d13a-0661-4107-94e7-dee3a596ac29",
      "name": "If",
      "type": "n8n-nodes-base.if",
      "position": [
        1888,
        1136
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "982b9406-1e75-4f02-b747-bbfae1cb2d88",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json._loopRetry }}",
              "rightValue": 3
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "97fa4a43-26de-443f-896e-e459060ec9b9",
      "name": "Loop Controller (Automatic Fixer)",
      "type": "n8n-nodes-base.code",
      "position": [
        1712,
        1136
      ],
      "parameters": {
        "jsCode": "const max_retries = $('Set Workflow Variables').first().json.MAX_RETRIES;\nconst state = $getWorkflowStaticData('global');\nconst executionId = $execution.id;\n\nif (state._executionId !== executionId) {\n  state._executionId = executionId;\n  state.loopCount = 0;\n  state.bestCollisionCount = Infinity;\n  state.bestWorkflow = null;\n  state.bestIteration = 0;\n  state.originalWorkflow = $('Set Workflow Variables').first().json.workflow;\n}\n\nconst input = $input.first().json;\nconst currentCollisions = input.collisionsFound;\nconst currentWorkflow = input.workflow;\n\nstate.loopCount += 1;\n\nif (currentCollisions < state.bestCollisionCount) {\n  state.bestCollisionCount = currentCollisions;\n  state.bestWorkflow = JSON.parse(JSON.stringify(currentWorkflow));\n  state.bestIteration = state.loopCount;\n}\n\nconst shouldRetry = currentCollisions > 0 && state.loopCount < max_retries;\n\nif (shouldRetry) {\n  return {\n    json: {\n      workflow: state.originalWorkflow,\n      loopCount: state.loopCount,\n      lastCollisions: currentCollisions,\n      bestSoFar: state.bestCollisionCount,\n      _loopRetry: true\n    }\n  };\n} else {\n  return {\n    json: {\n      bestWorkflow: state.bestWorkflow,\n      bestCollisionCount: state.bestCollisionCount,\n      bestIteration: state.bestIteration,\n      totalPasses: state.loopCount,\n      collisionIteration: state.loopCount,\n      _loopRetry: false\n    }\n  };\n}"
      },
      "typeVersion": 2
    },
    {
      "id": "1dff0d21-f998-48d1-9894-873317b51993",
      "name": "Set Workflow Variables",
      "type": "n8n-nodes-base.set",
      "position": [
        1888,
        352
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "453a6d88-c630-4ed7-a0ab-977178b0ef24",
              "name": "workflow",
              "type": "object",
              "value": "={{ $json.workflow }}"
            },
            {
              "id": "a1b55be4-7975-4b80-bc26-0cac271ca761",
              "name": "MAX_RETRIES",
              "type": "number",
              "value": 3
            },
            {
              "id": "95aa9bc1-ebc1-433d-9d1c-6d1a64102897",
              "name": "renameNodes",
              "type": "boolean",
              "value": "={{ $json.renameNodes ?? true }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "cfaa7dac-121e-4f0d-83a1-8fa1624eab92",
      "name": "Should Rename Nodes",
      "type": "n8n-nodes-base.if",
      "position": [
        2336,
        1152
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "464093eb-5502-492a-8080-1a997f9f4f83",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $('Set Workflow Variables').first().json.renameNodes }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "4a30959a-66da-42fc-9020-c2f106d81aaf",
      "name": "Output Normalization",
      "type": "n8n-nodes-base.set",
      "position": [
        3728,
        1168
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "0087aabd-b6a5-4617-94e6-7e4ca1c90cb7",
              "name": "workflow",
              "type": "object",
              "value": "={{ $json.workflow }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "128d8236-5bd5-446b-b91f-66d6dd3d2310",
      "name": "Parse for Renaming",
      "type": "n8n-nodes-base.code",
      "position": [
        2544,
        864
      ],
      "parameters": {
        "jsCode": "// Get the workflow JSON from the first input item\nconst workflow = $input.first().json.workflow;\nconst allNodes = workflow.nodes || [];\nconst connections = workflow.connections || {};\n\n// Helper to detect sticky notes (we'll skip these)\nconst isSticky = (node) => node.type === 'n8n-nodes-base.stickyNote';\n\n// Helper to extract simplified type name\nconst getSimpleType = (fullType) => {\n  if (!fullType) return 'unknown';\n  const parts = fullType.split('.');\n  return parts[parts.length - 1] || fullType;\n};\n\n// Helper to safely get nested value\nconst safeGet = (obj, path, defaultVal = undefined) => {\n  const keys = path.split('.');\n  let current = obj;\n  for (const key of keys) {\n    if (current === null || current === undefined) return defaultVal;\n    current = current[key];\n  }\n  return current !== undefined ? current : defaultVal;\n};\n\n// Helper to truncate string\nconst truncate = (str, maxLen) => {\n  if (!str) return '';\n  if (str.length <= maxLen) return str;\n  return str.substring(0, maxLen) + '...';\n};\n\n// Build connection maps\nconst outgoingConnections = {}; // nodeName -> [targetNames]\nconst incomingConnections = {}; // nodeName -> [sourceNames]\n\nfor (const [fromName, connDef] of Object.entries(connections)) {\n  if (!connDef) continue;\n  \n  // Process main connections\n  const mains = connDef.main || [];\n  for (const outputArr of mains) {\n    if (!Array.isArray(outputArr)) continue;\n    for (const conn of outputArr) {\n      if (!conn || !conn.node) continue;\n      \n      // Outgoing from source\n      if (!outgoingConnections[fromName]) outgoingConnections[fromName] = [];\n      outgoingConnections[fromName].push(conn.node);\n      \n      // Incoming to target\n      if (!incomingConnections[conn.node]) incomingConnections[conn.node] = [];\n      incomingConnections[conn.node].push(fromName);\n    }\n  }\n  \n  // Process AI connections (ai_languageModel, ai_tool, etc.)\n  for (const [key, value] of Object.entries(connDef)) {\n    if (key === 'main') continue;\n    if (!Array.isArray(value)) continue;\n    \n    for (const outputArr of value) {\n      if (!Array.isArray(outputArr)) continue;\n      for (const conn of outputArr) {\n        if (!conn || !conn.node) continue;\n        \n        if (!outgoingConnections[fromName]) outgoingConnections[fromName] = [];\n        outgoingConnections[fromName].push(conn.node);\n        \n        if (!incomingConnections[conn.node]) incomingConnections[conn.node] = [];\n        incomingConnections[conn.node].push(fromName);\n      }\n    }\n  }\n}\n\n// Extract context based on node type\nfunction extractContext(node) {\n  const p = node.parameters || {};\n  const type = getSimpleType(node.type);\n  \n  switch (type) {\n    case 'httpRequest':\n      return {\n        method: p.method || 'GET',\n        url: truncate(p.url, 80),\n        authentication: p.authentication || 'none',\n        description: `${p.method || 'GET'} request to ${truncate(p.url, 50) || 'URL'}`\n      };\n      \n    case 'set':\n      const assignments = safeGet(p, 'assignments.assignments', []);\n      const fieldNames = assignments.slice(0, 5).map(a => a.name).filter(Boolean);\n      return {\n        fields: fieldNames,\n        fieldCount: assignments.length,\n        description: `Sets fields: ${fieldNames.join(', ') || 'various'}`\n      };\n      \n    case 'if':\n      const conditions = safeGet(p, 'conditions.conditions', []);\n      const conditionCount = conditions.length;\n      const firstCondition = conditions[0];\n      let conditionDesc = 'condition';\n      if (firstCondition) {\n        const left = String(firstCondition.leftValue || '').substring(0, 30);\n        const op = safeGet(firstCondition, 'operator.operation', 'equals');\n        conditionDesc = `${left} ${op}`;\n      }\n      return {\n        conditionCount,\n        description: `Checks ${conditionDesc}`\n      };\n      \n    case 'switch':\n      const rules = safeGet(p, 'rules.rules', []);\n      return {\n        ruleCount: rules.length,\n        description: `Routes based on ${rules.length} rules`\n      };\n      \n    case 'code':\n      const jsCode = p.jsCode || p.code || '';\n      const firstLine = jsCode.split('\\n')[0] || '';\n      const isComment = firstLine.trim().startsWith('//');\n      return {\n        language: p.language || 'javascript',\n        mode: p.mode || 'runOnceForAllItems',\n        hint: isComment ? firstLine.replace('//', '').trim() : truncate(firstLine, 50),\n        description: isComment ? firstLine.replace('//', '').trim() : 'Custom code'\n      };\n      \n    case 'filter':\n      const filterConditions = safeGet(p, 'conditions.conditions', []);\n      return {\n        conditionCount: filterConditions.length,\n        description: `Filters items based on ${filterConditions.length} condition(s)`\n      };\n      \n    case 'googleSheets':\n      return {\n        operation: p.operation || 'read',\n        sheetName: safeGet(p, 'sheetName.cachedResultName', 'Sheet'),\n        documentName: safeGet(p, 'documentId.cachedResultName', 'Document'),\n        description: `${p.operation || 'Read'} ${safeGet(p, 'sheetName.cachedResultName', 'sheet')}`\n      };\n      \n    case 'slack':\n      return {\n        resource: p.resource || 'message',\n        operation: p.operation || 'post',\n        channel: safeGet(p, 'channelId.cachedResultName', 'channel'),\n        description: `${p.operation || 'Post'} ${p.resource || 'message'} to Slack`\n      };\n      \n    case 'gmail':\n    case 'gmailTrigger':\n      return {\n        operation: p.operation || 'send',\n        description: type === 'gmailTrigger' ? 'Triggered by Gmail' : `${p.operation || 'Send'} email via Gmail`\n      };\n      \n    case 'webhook':\n      return {\n        httpMethod: p.httpMethod || 'GET',\n        path: p.path || '/',\n        description: `Webhook endpoint: ${p.httpMethod || 'GET'} ${p.path || '/'}`\n      };\n      \n    case 'scheduleTrigger':\n      const rule = safeGet(p, 'rule.interval', []);\n      return {\n        rule: rule,\n        description: 'Runs on schedule'\n      };\n      \n    case 'manualTrigger':\n      return {\n        description: 'Manual execution trigger'\n      };\n      \n    case 'wait':\n      return {\n        resumeMode: p.resume || 'timeInterval',\n        amount: p.amount,\n        unit: p.unit,\n        description: p.amount ? `Wait ${p.amount} ${p.unit || 'seconds'}` : 'Wait for event'\n      };\n      \n    case 'splitInBatches':\n      return {\n        batchSize: p.batchSize || 10,\n        description: `Process in batches of ${p.batchSize || 10}`\n      };\n      \n    case 'splitOut':\n      return {\n        fieldToSplit: p.fieldToSplitOut || 'items',\n        description: `Split out ${p.fieldToSplitOut || 'items'}`\n      };\n      \n    case 'merge':\n      return {\n        mode: p.mode || 'append',\n        description: `Merge data: ${p.mode || 'append'}`\n      };\n      \n    case 'removeDuplicates':\n      return {\n        compareField: p.fieldsToCompare || 'all',\n        description: `Remove duplicates by ${p.fieldsToCompare || 'all fields'}`\n      };\n      \n    case 'sort':\n      const sortField = safeGet(p, 'sortFieldsUi.sortField[0].fieldName', 'field');\n      return {\n        sortBy: sortField,\n        description: `Sort by ${sortField}`\n      };\n      \n    case 'limit':\n      return {\n        maxItems: p.maxItems || 10,\n        description: `Limit to ${p.maxItems || 10} items`\n      };\n      \n    case 'agent':\n      return {\n        promptType: p.promptType,\n        description: 'AI Agent node'\n      };\n      \n    case 'lmChatOpenAi':\n      return {\n        model: safeGet(p, 'model.value', 'gpt-4'),\n        description: `OpenAI ${safeGet(p, 'model.value', 'model')}`\n      };\n      \n    case 'lmChatAnthropic':\n      return {\n        model: safeGet(p, 'model.value', 'claude'),\n        description: `Anthropic ${safeGet(p, 'model.value', 'model')}`\n      };\n      \n    default:\n      return {\n        description: `${type} node`\n      };\n  }\n}\n\n// Check if node has expression references to other nodes\nfunction findExpressionReferences(node) {\n  const refs = [];\n  const jsonStr = JSON.stringify(node.parameters || {});\n  \n  // Find $('Node Name') patterns\n  const regex = /\\$\\(['\"]([^'\"]+)['\"]\\)/g;\n  let match;\n  while ((match = regex.exec(jsonStr)) !== null) {\n    refs.push(match[1]);\n  }\n  \n  return refs;\n}\n\n// Process all nodes\nconst nodesForRenaming = [];\nconst existingNames = [];\n\nfor (const node of allNodes) {\n  // Skip sticky notes\n  if (isSticky(node)) continue;\n  \n  existingNames.push(node.name);\n  \n  const context = extractContext(node);\n  const expressionRefs = findExpressionReferences(node);\n  \n  nodesForRenaming.push({\n    id: node.id,\n    currentName: node.name,\n    type: node.type,\n    simpleType: getSimpleType(node.type),\n    context,\n    incomingFrom: [...new Set(incomingConnections[node.name] || [])],\n    outgoingTo: [...new Set(outgoingConnections[node.name] || [])],\n    hasExpressionRefs: expressionRefs.length > 0,\n    expressionRefs\n  });\n}\n\nreturn {\n  json: {\n    workflowName: workflow.name || 'Untitled workflow',\n    nodeCount: nodesForRenaming.length,\n    nodes: nodesForRenaming,\n    existingNames\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "5943ae15-d6a1-43f1-ab6a-5026e0c24ed9",
      "name": "AI Rename",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        2784,
        864
      ],
      "parameters": {
        "text": "=Workflow: {{ $json.workflowName }}\nTotal nodes to rename: {{ $json.nodeCount }}\n\nNodes:\n{{ JSON.stringify($json.nodes, null, 2) }}\n\nGenerate a new descriptive name for EACH node listed above. Follow the naming conventions in your instructions.\n\nIMPORTANT: \n- Every node must get a new name\n- All names must be unique\n- Keep names under 40 characters",
        "options": {
          "systemMessage": "You are renaming nodes in an n8n workflow to make them more descriptive and meaningful.\n\n## Naming Conventions\n\nFollow these patterns based on node type:\n\n| Node Type | Pattern | Examples |\n|-----------|---------|----------|\n| Triggers | \"When [event]\" or \"[Source] Trigger\" | \"When Email Received\", \"Every Morning at 9am\" |\n| HTTP Request | \"[Verb] [what/where]\" | \"Fetch YouTube Videos\", \"Post to Slack API\" |\n| Set | \"[Set/Prepare/Build] [what]\" | \"Prepare Search Queries\", \"Build Output Data\" |\n| If | \"If [condition]\" or \"Check [what]\" | \"If Download Complete\", \"Check Status\" |\n| Switch | \"Route by [criteria]\" | \"Route by Status\", \"Route by Type\" |\n| Code | \"[Verb] [what it does]\" | \"Calculate Duration\", \"Parse API Response\" |\n| Filter | \"Filter [criteria]\" | \"Filter Valid Items\", \"Filter by Date\" |\n| Loop/SplitInBatches | \"Loop Over [items]\" | \"Loop Over Results\", \"Process Each Video\" |\n| Google Sheets | \"[Action] in Sheets\" | \"Append to Sheets\", \"Update Row in Sheets\" |\n| Wait | \"Wait [duration/reason]\" | \"Wait 15 Seconds\", \"Wait for Response\" |\n| Limit | \"Take Top [n]\" or \"Limit to [n]\" | \"Take Top 10 Results\" |\n| Sort | \"Sort by [field]\" | \"Sort by Relevance Score\" |\n| Remove Duplicates | \"Deduplicate by [field]\" | \"Deduplicate by Video ID\" |\n| Split Out | \"Split [what]\" | \"Split Search Results\" |\n| Merge | \"Merge [what]\" | \"Merge All Results\" |\n| AI Agent | \"[Purpose] Agent\" or \"AI [task]\" | \"Content Analyzer Agent\", \"AI Classifier\" |\n| OpenAI/Anthropic | \"OpenAI Model\" or \"Claude Model\" | \"OpenAI GPT-4\", \"Claude Sonnet\" |\n\n## Rules\n\n1. **Be specific**: Use the node's context (URL, fields, conditions) to create meaningful names\n2. **Title Case**: Capitalize each word (except small words like \"to\", \"by\", \"in\", \"for\")\n3. **Under 40 characters**: Keep names concise\n4. **Unique names**: Every name must be different - add specifics if needed to differentiate\n5. **No special characters**: Only letters, numbers, spaces, and hyphens\n6. **Action-oriented**: Start with a verb when possible (Fetch, Send, Check, Filter, etc.)\n\n## Understanding Context\n\n- `context.description`: Summarizes what the node does\n- `context.url`: For HTTP requests, shows the API endpoint\n- `context.fields`: For Set nodes, shows what fields are being set\n- `context.operation`: For service nodes, shows the action (read, append, update)\n- `incomingFrom` / `outgoingTo`: Shows connected nodes for understanding flow\n\n## Examples\n\n| Current Name | Type | Context | Better Name |\n|--------------|------|---------|-------------|\n| HTTP Request | httpRequest | GET youtube.com/search | Fetch YouTube Search Results |\n| Set | set | fields: [query, limit] | Set Search Parameters |\n| If | if | checks status == success | If Request Successful |\n| Code | code | hint: \"Parse duration\" | Parse Video Duration |\n| Google Sheets | googleSheets | operation: append | Append Results to Sheets |\n| Wait | wait | 15 seconds | Wait 15 Seconds |\n| Filter | filter | 2 conditions | Filter Quality Videos |\n\n## Output\n\nReturn a rename for EVERY node. Do not skip any."
        },
        "promptType": "define",
        "hasOutputParser": true
      },
      "typeVersion": 3
    },
    {
      "id": "84610bbc-2ddf-468c-8455-7e83058a00e2",
      "name": "Apply Renames",
      "type": "n8n-nodes-base.code",
      "position": [
        3168,
        864
      ],
      "parameters": {
        "jsCode": "// Get inputs\nconst aiOutput = $input.first().json.output || $input.first().json;\nconst originalWorkflow = $('Pick Best Result').first().json.workflow;\n\n// Validate AI output\nif (!aiOutput.renames || !Array.isArray(aiOutput.renames)) {\n  throw new Error('Invalid AI output: missing renames array');\n}\n\n// Deep clone the workflow to avoid modifying original\nconst workflow = JSON.parse(JSON.stringify(originalWorkflow));\n\n// Build rename maps\nconst idToNewName = {};      // id -> newName\nconst oldToNewName = {};     // oldName -> newName\nconst usedNames = new Set(); // Track used names to ensure uniqueness\n\n// First pass: collect all renames and ensure uniqueness\nfor (const rename of aiOutput.renames) {\n  let newName = rename.newName;\n  \n  // Ensure uniqueness by adding suffix if needed\n  let baseName = newName;\n  let counter = 1;\n  while (usedNames.has(newName)) {\n    newName = `${baseName} ${counter}`;\n    counter++;\n  }\n  \n  usedNames.add(newName);\n  idToNewName[rename.id] = newName;\n  oldToNewName[rename.currentName] = newName;\n}\n\n// Helper to escape special regex characters\nfunction escapeRegex(str) {\n  return str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\n// Helper to update expression references in a string\nfunction updateExpressionRefs(str, oldToNew) {\n  if (typeof str !== 'string') return str;\n  \n  let result = str;\n  \n  // Update $('Old Name') to $('New Name')\n  for (const [oldName, newName] of Object.entries(oldToNew)) {\n    // Handle both single and double quotes\n    const patterns = [\n      new RegExp(`\\\\$\\\\('${escapeRegex(oldName)}'\\\\)`, 'g'),\n      new RegExp(`\\\\$\\\\(\"${escapeRegex(oldName)}\"\\\\)`, 'g')\n    ];\n    \n    for (const pattern of patterns) {\n      result = result.replace(pattern, `$('${newName}')`);\n    }\n  }\n  \n  return result;\n}\n\n// Recursively update expression references in an object\nfunction updateExpressionsInObject(obj, oldToNew) {\n  if (obj === null || obj === undefined) return obj;\n  \n  if (typeof obj === 'string') {\n    return updateExpressionRefs(obj, oldToNew);\n  }\n  \n  if (Array.isArray(obj)) {\n    return obj.map(item => updateExpressionsInObject(item, oldToNew));\n  }\n  \n  if (typeof obj === 'object') {\n    const result = {};\n    for (const [key, value] of Object.entries(obj)) {\n      result[key] = updateExpressionsInObject(value, oldToNew);\n    }\n    return result;\n  }\n  \n  return obj;\n}\n\n// ---------- STEP 1: Rename nodes ----------\nfor (const node of workflow.nodes) {\n  // Skip sticky notes\n  if 

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This is an official n8n workflow that helps you follow our sticky note and naming guidelines - required for getting your template published on the n8n template library.

Source: https://n8n.io/workflows/13868/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

AI & RAG

🎯 Create viral TikToks, Shorts, Reels, podcasts, and ASMR videos in minutes — all on autopilot.

OpenAI, HTTP Request, Form Trigger +7
AI & RAG

Generate AI viral videos with NanoBanana & VEO3, shared on socials via Blotato 2. Uses @blotato/n8n-nodes-blotato, googleSheets, lmChatOpenAi, toolThink. Event-driven trigger; 94 nodes.

@Blotato/N8N Nodes Blotato, Google Sheets, OpenAI Chat +9
AI & RAG

RAG CHATBOT Main. Uses telegram, telegramTrigger, lmChatOpenAi, n8n-nodes-mcp. Event-driven trigger; 87 nodes.

Telegram, Telegram Trigger, OpenAI Chat +8
AI & RAG

The best content automation template in the market is now even better—with “deep research” on time-sensitive topics\! Unlike most n8n content automation templates that are mainly for “demo purposes,”

OpenAI, HTTP Request, XML +11
AI & RAG

Digital marketers, content creators, social media managers, and businesses who want to use AI marketing automation for YouTube Shorts without spending hours on production. This AI workflow helps anyon

OpenAI, HTTP Request, OpenAI Chat +7