{
  "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 (node.type === 'n8n-nodes-base.stickyNote') continue;\n  \n  const newName = idToNewName[node.id];\n  if (newName) {\n    node.name = newName;\n  }\n  \n  // Update expression references in node parameters\n  if (node.parameters) {\n    node.parameters = updateExpressionsInObject(node.parameters, oldToNewName);\n  }\n}\n\n// ---------- STEP 2: Update connections ----------\n// Connections are keyed by node name and reference target nodes by name\nconst newConnections = {};\n\nfor (const [oldSourceName, connDef] of Object.entries(workflow.connections || {})) {\n  // Get the new source name (or keep old if not renamed)\n  const newSourceName = oldToNewName[oldSourceName] || oldSourceName;\n  \n  // Deep clone the connection definition\n  const newConnDef = JSON.parse(JSON.stringify(connDef));\n  \n  // Update all target node references\n  for (const [connType, outputs] of Object.entries(newConnDef)) {\n    if (!Array.isArray(outputs)) continue;\n    \n    for (const outputArr of outputs) {\n      if (!Array.isArray(outputArr)) continue;\n      \n      for (const conn of outputArr) {\n        if (conn && conn.node) {\n          // Update target node name\n          const newTargetName = oldToNewName[conn.node] || conn.node;\n          conn.node = newTargetName;\n        }\n      }\n    }\n  }\n  \n  newConnections[newSourceName] = newConnDef;\n}\n\nworkflow.connections = newConnections;\n\n// ---------- STEP 3: Update pinData keys if present ----------\nif (workflow.pinData) {\n  const newPinData = {};\n  \n  for (const [oldName, data] of Object.entries(workflow.pinData)) {\n    const newName = oldToNewName[oldName] || oldName;\n    newPinData[newName] = data;\n  }\n  \n  workflow.pinData = newPinData;\n}\n\n// ---------- STEP 4: Build rename report ----------\nconst renameReport = aiOutput.renames.map(r => ({\n  id: r.id,\n  oldName: r.currentName,\n  newName: idToNewName[r.id]\n}));\n\nreturn {\n  json: {\n    workflow,\n    renameReport,\n    totalRenamed: renameReport.length\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "8d49a031-392a-4f22-af00-2ec94d7fdc21",
      "name": "Format for Export",
      "type": "n8n-nodes-base.code",
      "position": [
        3488,
        864
      ],
      "parameters": {
        "jsCode": "// Get the renamed workflow from previous node\nconst data = $input.first().json;\nconst workflow = data.workflow;\nconst renameReport = data.renameReport;\n\n// Return both the workflow JSON string and the rename report\nreturn {\n  json: {\n    // The workflow object for any downstream processing\n    workflow: workflow,\n    \n    // Summary of what was renamed\n    renameReport: renameReport\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "769bb4e4-6729-4344-92ba-11aeaf0d9b27",
      "name": "OpenAI Chat Model1",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        2784,
        1088
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o",
          "cachedResultName": "gpt-4o"
        },
        "options": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "5fa4ff96-4ccd-4ce6-9136-3fb91b168e2b",
      "name": "Structured Output Parser1",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "position": [
        2928,
        1056
      ],
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"renames\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"object\",\n        \"properties\": {\n          \"id\": {\n            \"type\": \"string\",\n            \"description\": \"The node ID\"\n          },\n          \"currentName\": {\n            \"type\": \"string\",\n            \"description\": \"The current node name\"\n          },\n          \"newName\": {\n            \"type\": \"string\",\n            \"description\": \"The new descriptive name (under 40 chars, unique)\"\n          }\n        },\n        \"required\": [\"id\", \"currentName\", \"newName\"],\n        \"additionalProperties\": false\n      }\n    }\n  },\n  \"required\": [\"renames\"],\n  \"additionalProperties\": false\n}"
      },
      "typeVersion": 1.2
    },
    {
      "id": "67b6e6b9-e5a0-4c26-9cca-62659ede7b2a",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        928,
        224
      ],
      "parameters": {
        "width": 624,
        "height": 704,
        "content": "## Sticky note generator and node renamer\n\n### How it works\nThis workflow creates sticky notes and renames your nodes according to [our guidelines](https://n8n.notion.site/Sticky-note-guidelines-for-templates-2aa5b6e0c94f8058b0aefddd02655887?pvs=74):\n\n@[youtube](RScKsGfrhs4)\n\n### Setup steps\n- [ ] Ensure the 'Set Workflow Variables' node is configured with correct workflow settings.\n- [ ] Set up OpenAI credentials for nodes where AI processing is required.\n\n### Customization\nYou can customize the node processing logic to suit specific workflows or modify AI behavior for different output requirements."
      },
      "typeVersion": 1
    },
    {
      "id": "0b4c610a-c3d1-4bdd-b9a2-cfdc44cb8b24",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        928,
        944
      ],
      "parameters": {
        "color": 4,
        "width": 624,
        "height": 80,
        "content": "### How can we improve this workflow?\n### [>>> Share your feedback here](https://templates.app.n8n.cloud/form/dbf8d22e-4b05-4328-b164-dcc5555623e0)"
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "If": {
      "main": [
        [
          {
            "node": "AI Groups Logically",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Pick Best Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start": {
      "main": [
        [
          {
            "node": "Set Workflow Variables",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Rename": {
      "main": [
        [
          {
            "node": "Apply Renames",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse Nodes": {
      "main": [
        [
          {
            "node": "AI Groups Logically",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Renames": {
      "main": [
        [
          {
            "node": "Format for Export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge & Export": {
      "main": [
        [
          {
            "node": "Collision Detector",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Strip & Prepare": {
      "main": [
        [
          {
            "node": "Parse Nodes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Best Result": {
      "main": [
        [
          {
            "node": "Should Rename Nodes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format for Export": {
      "main": [
        [
          {
            "node": "Output Normalization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate Stickies": {
      "main": [
        [
          {
            "node": "Merge & Export",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI Groups Logically",
            "type": "ai_languageModel",
            "index": 0
          },
          {
            "node": "Structured Output Parser",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Collision Detector": {
      "main": [
        [
          {
            "node": "Loop Controller (Automatic Fixer)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model1": {
      "ai_languageModel": [
        [
          {
            "node": "AI Rename",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Parse for Renaming": {
      "main": [
        [
          {
            "node": "AI Rename",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Groups Logically": {
      "main": [
        [
          {
            "node": "Compute Bounding Boxes",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Should Rename Nodes": {
      "main": [
        [
          {
            "node": "Parse for Renaming",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Output Normalization",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collision Resolution": {
      "main": [
        [
          {
            "node": "Generate Stickies",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Bounding Boxes": {
      "main": [
        [
          {
            "node": "Collision Resolution",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Workflow Variables": {
      "main": [
        [
          {
            "node": "Strip & Prepare",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Structured Output Parser": {
      "ai_outputParser": [
        [
          {
            "node": "AI Groups Logically",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Structured Output Parser1": {
      "ai_outputParser": [
        [
          {
            "node": "AI Rename",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Loop Controller (Automatic Fixer)": {
      "main": [
        [
          {
            "node": "If",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}