AutomationFlowsWeb Scraping › Eek-go V2 (batch-then-review)

Eek-go V2 (batch-then-review)

eek-Go v2 (Batch-Then-Review). Uses httpRequest. Webhook trigger; 75 nodes.

Webhook trigger★★★★★ complexity75 nodesHTTP Request
Web Scraping Trigger: Webhook Nodes: 75 Complexity: ★★★★★ Added:

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
{
  "updatedAt": "2026-03-19T05:34:06.197Z",
  "createdAt": "2026-03-17T03:35:25.543Z",
  "id": "PNOMGkCjzFGxf52E",
  "name": "eek-Go v2 (Batch-Then-Review)",
  "description": null,
  "active": true,
  "isArchived": false,
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "coding-agent",
        "responseMode": "lastNode",
        "options": {}
      },
      "id": "wh-trigger",
      "name": "Webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        250,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const query = $json.query || {};\nconst body = $json.body || $json;\nconst message = query.message || body.message || body.goal || '';\nconst projectId = query.project_id || body.project_id || ('proj-' + Date.now());\nconst reference_url = query.reference_url || body.reference_url || null;\nconst image_data = body.image_data || null;\nreturn [{ json: { message, project_id: projectId, operation: 'init', goal: message, reference_url, image_data } }];"
      },
      "id": "extract-input",
      "name": "Extract Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        450,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const operation = $json.operation || 'get';\nconst projectId = $json.project_id;\nif (!projectId) return [{ json: { error: 'project_id required' } }];\nconst store = $getWorkflowStaticData('global');\nif (operation === 'init') {\n  if (store[projectId]) return [{ json: { ...store[projectId], already_exists: true } }];\n  store[projectId] = {\n    project_id: projectId,\n    goal: $json.goal || '',\n    status: 'active',\n    completed_tasks: [],\n    pending_tasks: [],\n    files: [],\n    created_at: new Date().toISOString(),\n    last_updated: new Date().toISOString()\n  };\n  return [{ json: store[projectId] }];\n}\nif (operation === 'get') {\n  return [{ json: store[projectId] || { project_id: projectId, exists: false } }];\n}\nif (operation === 'set') {\n  const existing = store[projectId] || {};\n  store[projectId] = { ...existing, ...$json.state, project_id: projectId, last_updated: new Date().toISOString() };\n  return [{ json: store[projectId] }];\n}\nreturn [{ json: { error: `Unknown operation: ${operation}` } }];"
      },
      "id": "init-memory",
      "name": "Init Memory",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        650,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-content' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            }
          ]
        },
        "options": {}
      },
      "id": "fetch-project-files",
      "name": "Fetch Project Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        850,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// MODIFIED: Absorbs Research: Build Project Context\n// Caches _allFileContents, _researchDocs, _projectFileList in static data\n// so Phase 2 chunks don't need to re-fetch or re-compute.\nconst memory = $('Init Memory').first().json;\nconst message = $('Extract Input').first().json.message;\nconst allFiles = $json.files || [];\nconst staticData = $getWorkflowStaticData('global');\n\n// Cache all file contents for Phase 2\nstaticData._allFileContents = allFiles;\n\nconst fileList = allFiles.map(f => f.path);\n\n// Build API surface (exports from each file)\nconst apiSurface = [];\nfor (const f of allFiles) {\n  if (!f.path.match(/\\.(ts|tsx|js|jsx)$/) || f.path.includes('node_modules')) continue;\n  const lines = (f.content || '').split('\\n');\n  const exports = [];\n  for (const line of lines) {\n    const trimmed = line.trim();\n    if (/^export\\s+default\\s/.test(trimmed)) {\n      exports.push(trimmed.replace(/\\{[\\s\\S]*$/, '{...}').substring(0, 120));\n    } else if (/^export\\s+(const|let|var|function|class|interface|type|enum)\\s/.test(trimmed)) {\n      exports.push(trimmed.replace(/\\{[\\s\\S]*$/, '{...}').replace(/=>[\\s\\S]*$/, '=> ...').substring(0, 150));\n    } else if (/^export\\s+\\{/.test(trimmed)) {\n      exports.push(trimmed.substring(0, 150));\n    }\n  }\n  if (exports.length > 0) {\n    apiSurface.push(`## ${f.path}\\n${exports.join('\\n')}`);\n  }\n}\n\n// Extract dependencies from package.json\nlet dependencies = {};\nlet devDependencies = {};\nlet depNames = [];\nfor (const f of allFiles) {\n  if (f.path === 'package.json' || f.path.endsWith('/package.json')) {\n    try {\n      const pkg = JSON.parse(f.content);\n      dependencies = { ...dependencies, ...(pkg.dependencies || {}) };\n      devDependencies = { ...devDependencies, ...(pkg.devDependencies || {}) };\n      depNames = [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];\n    } catch(e) {}\n  }\n}\n\n// Build research docs string\nconst depList = Object.entries(dependencies).map(([k,v]) => `  ${k}: ${v}`).join('\\n');\nconst devDepList = Object.entries(devDependencies).map(([k,v]) => `  ${k}: ${v}`).join('\\n');\nlet researchDocs = '';\nif (depList || devDepList) {\n  researchDocs += '## Dependency Manifest\\nOnly use packages listed here.\\n';\n  if (depList) researchDocs += `dependencies:\\n${depList}\\n`;\n  if (devDepList) researchDocs += `devDependencies:\\n${devDepList}\\n`;\n}\nif (apiSurface.length > 0) {\n  researchDocs += '\\n## Project API Surface\\nMatch these import/export signatures exactly when importing from project files.\\n';\n  researchDocs += apiSurface.join('\\n\\n');\n}\n\n// Cache for all downstream phases\nstaticData._researchDocs = researchDocs;\nstaticData._projectFileList = fileList;\nstaticData._projectApiSummary = apiSurface.join('\\n');\nstaticData._projectDeps = depNames;\n\nreturn [{ json: {\n  message,\n  project_goal: memory.goal || message,\n  project_id: memory.project_id,\n  existing_files: fileList,\n  api_summary: apiSurface.join('\\n'),\n  installed_packages: depNames\n} }];"
      },
      "id": "prepare-planner",
      "name": "Prepare Planner Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1050,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const message = $json.message || '';\nconst existingFiles = $json.existing_files || [];\nconst apiSummary = $json.api_summary || '';\nconst installedPkgs = $json.installed_packages || [];\nconst scrapeData = $json._scrapeData;\n\nlet projectContext = '';\nif (existingFiles.length > 0) {\n  projectContext += `\\n\\nEXISTING PROJECT FILES (only reference files from this list):\\n${existingFiles.join('\\n')}\\n`;\n}\nif (apiSummary) {\n  projectContext += `\\nAPI SURFACE (exports from each file):\\n${apiSummary}\\n`;\n}\nif (installedPkgs.length > 0) {\n  projectContext += `\\nINSTALLED PACKAGES (only reference packages from this list):\\n${installedPkgs.join(', ')}\\n`;\n}\n\nconst plannerPrompt = `You are a Senior Software Architect and UI/UX Design Expert. Your job is to break a coding request into MULTIPLE structured tasks with detailed visual specifications.\n\nDESIGN PRINCIPLES (apply to ALL task descriptions):\n- Main interaction element must be centered and dominant (40-60% of viewport height)\n- Use vertical single-column layouts for apps/games \u2014 never side-by-side grids unless explicitly requested\n- Content hierarchy: hero/main action at top \u2192 stats/feedback \u2192 secondary actions (shop, settings) at bottom\n- Mobile-first: everything fits in 100vh, no page-level scrollbars, internal scrolling only for lists\n- Dark backgrounds with bright accent colors. Use gradients for depth (purple-900 to black, not flat colors)\n- Every click/tap must produce visible feedback (scale animation, color flash, particle effect)\n- Numbers should animate when they change (bounce, pulse, scale). Use tabular-nums for counters\n- Cards: rounded-xl (12-16px), subtle shadows, semi-transparent backgrounds with backdrop-blur\n- Buttons: press animation (scale 0.95), hover glow, disabled state with reduced opacity\n- Color-code affordability: gold/amber = available, red/gray = locked, green = owned/success\n- Typography: big bold numbers (text-4xl+), clear hierarchy via weight not just size\n- Spacing: generous padding, never cramped. Mobile touch targets minimum 44px\n\nWHEN A REFERENCE IMAGE IS PROVIDED:\n- Describe the EXACT layout structure you see: column vs row, spacing ratios, component sizes\n- Specify exact colors, gradients, border styles, and shadows visible in the image\n- Note the visual hierarchy: what's biggest, what's brightest, what draws the eye first\n- Describe animations or interactive states implied by the design (hover effects, active states)\n- Include specific dimensions: \"toilet button should be 150px diameter\" not just \"large button\"\n- The coder CANNOT see the image \u2014 your description is their ONLY reference Each task will be handled by a separate coder with 128K context who can write up to 6 files at once.\\n\\nOutput a JSON object with ONE field:\\n\\n\"tasks\": array of task objects. IMPORTANT: You MUST create multiple tasks. Group files by concern (e.g. styles in one task, frontend components in another, config in another). Each task has:\\n- task_id: string like \"TASK-001\"\\n- description: detailed actionable description of what to implement, including specific requirements, color values, component names, API endpoints, and behavior. The coder cannot see the original request \u2014 your description is all they get.\\n- files: array of exact file paths to create or modify. List EVERY file this task needs \u2014 the coder can ONLY write files listed here. For existing projects, prefer modifying files from the EXISTING PROJECT FILES list. For new projects or missing functionality, CREATE new file paths as needed.\\n- dependencies: array of task_id strings this task depends on (empty if none)\\n- complexity: \"low\", \"medium\", or \"high\"\n- needs_concept: boolean \u2014 set to true if this task involves UI/visual work that has NO reference image from the user. Set to false if the user provided a reference image that covers this task's visual design, or if the task is purely logic/config with no visual component.\\n\\nRULES:\\n- Always split work across multiple tasks by concern: styles, components, routes, config, etc.\\n- Each task can touch up to 12-15 files. The coder has 128K context and currently uses less than 2% of it \u2014 give it MORE work per task. Aim for 2-3 LARGE tasks instead of many small ones. A single task can contain an entire feature (components + hooks + styles + config). Fewer tasks = faster pipeline\\n- The description must be SELF-CONTAINED \u2014 include ALL details the coder needs\\n- For existing projects, reference files from the EXISTING PROJECT FILES list when modifying. You MAY create new files that do not exist yet.\n- CRITICAL: For new projects (empty file list), you MUST include a setup task that creates: package.json (with ALL dependencies), index.html, vite.config.js (or next.config.js), tsconfig.json (if TypeScript), tailwind.config.js + postcss.config.js (if using Tailwind), and the main entry point (src/main.jsx or app/layout.tsx). The project MUST be buildable.\\n- Detect the project type from the files and packages. If the project is a React/Vite frontend (no Express or backend framework in INSTALLED PACKAGES), focus all tasks on frontend files. Only create server/middleware/API handler tasks if Express or a similar backend framework is in INSTALLED PACKAGES.\\n- In each task description, specify the EXACT import/export style each file should use (named vs default) based on the API SURFACE above.\\n- NEVER create a task whose only purpose is deleting files. The coder cannot delete files \u2014 it can only create or modify. If dead files exist, ignore them. They do not affect the build.\n- The FIRST task should always include project configuration files (package.json, config files, index.html) if they do not already exist. The project MUST compile and run after all tasks complete.\n- Output ONLY the raw JSON object: {\"tasks\": [...]}\\n- No markdown, no explanation \u2014 ONLY the JSON with tasks`;\n\nlet messageContent;\nif (scrapeData && scrapeData.screenshot_b64) {\n  const cssTokensStr = JSON.stringify(scrapeData.css_tokens, null, 2);\n  const domStr = scrapeData.dom_summary || '';\n  messageContent = [\n    { type: 'image_url', image_url: { url: `data:image/png;base64,${scrapeData.screenshot_b64}` } },\n    { type: 'text', text: `${plannerPrompt}\\n\\nREFERENCE DESIGN (screenshot above):\\nCSS Tokens:\\n${cssTokensStr}\\n\\nDOM Structure:\\n${domStr}\\n\\nRequest: ${message}${projectContext}\\n\\n(Match the visual design, color palette, typography, and layout from the screenshot)` }\n  ];\n} else if ($('Extract Input').first().json.image_data) {\n  const userImage = $('Extract Input').first().json.image_data;\n  messageContent = [\n    { type: 'image_url', image_url: { url: 'data:image/png;base64,' + userImage } },\n    { type: 'text', text: plannerPrompt + '\\n\\nREFERENCE IMAGE (uploaded by user \u2014 replicate this design):\\n\\nRequest: ' + message + projectContext }\n  ];\n} else {\n  messageContent = `${plannerPrompt}\\n\\nRequest: ${message}${projectContext}`;\n}\n\nreturn [{\n  json: {\n    model: $env.PLANNER_MODEL || 'qwen3.5-27b@q4_k_m',\n    messages: [{ role: 'user', content: messageContent }],\n    temperature: 1.0,\n    top_p: 0.95,\n    top_k: 20,\n    min_p: 0.0,\n    presence_penalty: 0.0,\n    max_tokens: 8192,\n    chat_template_kwargs: { enable_thinking: true, max_thinking_tokens: 4096 }\n  }\n}];"
      },
      "id": "planner-build",
      "name": "Planner: Build Request",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1250,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.LLM_API_KEY }}"
            }
          ]
        }
      },
      "id": "planner-llm",
      "name": "Planner: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1450,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const raw = $json.choices[0].message.content || $json.choices[0].message.reasoning_content || '';\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\ncontent = content.replace(/<\\/?task>/g, '').trim();\ncontent = content.replace(/\\n+(?:Reasoning|Note|Explanation):[\\s\\S]*/i, '').trim();\ncontent = content.replace(/^```(?:json)?\\n?/, '').replace(/\\n?```$/, '').trim();\n\nlet tasks, planDocument = '';\ntry {\n  const parsed = JSON.parse(content);\n  if (Array.isArray(parsed)) {\n    tasks = parsed;\n  } else {\n    tasks = parsed.tasks || [parsed];\n    planDocument = parsed.plan_document || '';\n  }\n} catch (e) {\n  // Truncated JSON recovery: extract complete task objects\n  const tasksMatch = content.match(/\"tasks\"\\s*:\\s*\\[/);\n  if (tasksMatch) {\n    const arrStart = content.indexOf('[', tasksMatch.index);\n    let bracketDepth = 0;\n    let lastCompleteObj = -1;\n    for (let i = arrStart; i < content.length; i++) {\n      if (content[i] === '{') bracketDepth++;\n      if (content[i] === '}') {\n        bracketDepth--;\n        if (bracketDepth === 0) lastCompleteObj = i;\n      }\n    }\n    if (lastCompleteObj > arrStart) {\n      const recoveredArr = content.substring(arrStart, lastCompleteObj + 1) + ']';\n      try {\n        tasks = JSON.parse(recoveredArr);\n      } catch (e2) {\n        tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n      }\n    } else {\n      tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n    }\n  } else {\n    tasks = [{ task_id: 'TASK-FALLBACK-' + Date.now(), description: content, files: [], dependencies: [], complexity: 'high' }];\n  }\n}\n\ntasks = tasks.map(t => ({\n  ...t,\n  files: t.files || [],\n  dependencies: t.dependencies || [],\n  complexity: t.complexity || 'medium'\n}));\n\nreturn [{ json: { tasks, plan_document: planDocument } }];"
      },
      "id": "planner-parse",
      "name": "Planner: Parse Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1650,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// MODIFIED: Absorbs Split Into Chunks. Builds flat (task, chunk) queue.\n// Queue is passed through data pipeline (not static data) to avoid stale reads after HTTP nodes.\nconst tasks = $json.tasks || [];\nconst planDocument = $json.plan_document || '';\nconst plannerInput = $('Prepare Planner Input').first().json;\nconst projectId = plannerInput.project_id;\nconst projectGoal = plannerInput.project_goal;\n\n// Topological sort\nconst taskMap = new Map();\ntasks.forEach(t => taskMap.set(t.task_id, t));\nconst inDegree = new Map();\nconst dependents = new Map();\ntasks.forEach(t => {\n  inDegree.set(t.task_id, 0);\n  dependents.set(t.task_id, []);\n});\ntasks.forEach(t => {\n  const deps = (t.dependencies || []).filter(d => taskMap.has(d));\n  inDegree.set(t.task_id, deps.length);\n  deps.forEach(d => dependents.get(d).push(t.task_id));\n});\nconst topoQueue = [];\ntasks.forEach(t => {\n  if (inDegree.get(t.task_id) === 0) topoQueue.push(t.task_id);\n});\nconst sorted = [];\nwhile (topoQueue.length > 0) {\n  const id = topoQueue.shift();\n  sorted.push(taskMap.get(id));\n  for (const dep of (dependents.get(id) || [])) {\n    inDegree.set(dep, inDegree.get(dep) - 1);\n    if (inDegree.get(dep) === 0) topoQueue.push(dep);\n  }\n}\nif (sorted.length < tasks.length) {\n  const sortedIds = new Set(sorted.map(t => t.task_id));\n  tasks.forEach(t => { if (!sortedIds.has(t.task_id)) sorted.push(t); });\n}\n\n// Flatten into (task, chunk) queue \u2014 2 files per chunk\nconst chunkSize = 15;\nconst queue = [];\nfor (const task of sorted) {\n  const taskFiles = task.files || [];\n  const chunks = [];\n  for (let i = 0; i < taskFiles.length; i += chunkSize) {\n    chunks.push(taskFiles.slice(i, i + chunkSize));\n  }\n  if (taskFiles.length === 0) continue;  // skip tasks with no files assigned\n  for (const chunkFiles of chunks) {\n    queue.push({\n      task,\n      chunk_files: chunkFiles,\n      project_id: projectId,\n      project_goal: projectGoal,\n      plan_document: planDocument\n    });\n  }\n}\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData.p2Results = [];\nstaticData.allTasks = sorted;\nstaticData._queueTotal = queue.length;\nstaticData._queueDone = 0;\n\nif (queue.length === 0) {\n  return [{ json: { task: { task_id: 'NONE', description: 'No tasks', files: [] }, chunk_files: [], project_id: projectId, project_goal: projectGoal, plan_document: planDocument, _p2Queue: [] } }];\n}\n// Pass remaining queue items through data pipeline instead of static data\nconst first = queue[0];\nfirst._p2Queue = queue.slice(1);\nreturn [{ json: first }];"
      },
      "id": "spread-tasks",
      "name": "Spread Tasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1850,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// P2: Stash Context \u2014 passes through all fields including _p2Queue for loop control\nconst staticData = $getWorkflowStaticData('global');\nstaticData._currentProjectId = $json.project_id;\nstaticData._currentTaskId = ($json.task || {}).task_id || '';\nstaticData._currentTask = $json.task || null;\nstaticData._currentProjectGoal = $json.project_goal || '';\nstaticData._currentQueue = $json._p2Queue || [];  // snapshot queue for this iteration\nreturn [{ json: {\n  task: $json.task,\n  chunk_files: $json.chunk_files,\n  project_id: $json.project_id,\n  project_goal: $json.project_goal,\n  plan_document: $json.plan_document,\n  _p2Queue: $json._p2Queue || []\n} }];"
      },
      "id": "p2-stash",
      "name": "P2: Stash Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2050,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Builds coder input \u2014 task files in full, other files as compact summaries\nconst staticData = $getWorkflowStaticData('global');\nconst stashed = $json;\nconst taskFiles = (stashed.task || {}).files || stashed.chunk_files || [];\nconst allFiles = staticData._allFileContents || [];\n\n// Full content for files in THIS task + closely related files (imports)\nconst taskFileSet = new Set(taskFiles.map(f => typeof f === 'string' ? f : f.path));\n\n// Also include files that task files import from\nfor (const file of allFiles) {\n  if (taskFileSet.has(file.path)) {\n    // Find imports in this file\n    const importMatches = (file.content || '').matchAll(/from\\s+['\"]([^'\"]+)['\"]/g);\n    for (const m of importMatches) {\n      const importPath = m[1];\n      if (importPath.startsWith('.')) {\n        // Resolve relative import to find the actual file\n        const dir = file.path.replace(/\\/[^\\/]+$/, '');\n        const resolved = importPath.replace(/^\\.\\//,'').replace(/^\\.\\.\\//, '');\n        for (const f of allFiles) {\n          if (f.path.includes(resolved) || f.path.replace(/\\.(ts|tsx|js|jsx)$/, '').endsWith(resolved)) {\n            taskFileSet.add(f.path);\n          }\n        }\n      }\n    }\n  }\n}\n\n// Always include package.json and main config files (small, critical for context)\nconst alwaysInclude = ['package.json', 'tsconfig.json', 'vite.config.js', 'tailwind.config.js', 'postcss.config.js', 'index.html'];\nfor (const f of alwaysInclude) {\n  const match = allFiles.find(a => a.path === f || a.path.endsWith('/' + f));\n  if (match) taskFileSet.add(match.path);\n}\n\n// Split: full content for task files, compact for everything else\nconst fullFiles = [];\nconst summaryFiles = [];\nfor (const file of allFiles) {\n  if (file.path.includes('node_modules')) continue;\n  if (taskFileSet.has(file.path)) {\n    fullFiles.push(file);\n  } else if (file.path.match(/\\.(ts|tsx|js|jsx|css|json|html)$/)) {\n    // Compact summary: path + first line (exports) + size\n    const firstLines = (file.content || '').split('\\n').slice(0, 3).join('\\n');\n    const exports = (file.content || '').match(/export\\s+(default\\s+)?(?:function|const|class)\\s+(\\w+)/g) || [];\n    summaryFiles.push({\n      path: file.path,\n      summary: exports.join(', ') || firstLines.substring(0, 100),\n      size: (file.content || '').length\n    });\n  }\n}\n\nconst existingFiles = fullFiles;\n\nconst task = { ...(stashed.task || {}) };\nif (taskFiles.length > 0) task.files = taskFiles;\n\n// Extract design tokens from reference HTML files (e.g., Stitch concepts)\nconst http = require('http');\nconst projectId = stashed.project_id;\nlet designSystem = staticData._stitchDesignSystem || '';\nlet referenceStyles = staticData._stitchStyles || '';\n\n// Only fetch if not already cached this run AND task involves UI/visual work\nconst taskDesc = ((stashed.task || {}).description || '').toLowerCase();\nconst isVisualTask = taskDesc.includes('design') || taskDesc.includes('style') || taskDesc.includes('layout') || taskDesc.includes('ui') || taskDesc.includes('color') || taskDesc.includes('component') || taskDesc.includes('css') || taskDesc.includes('tailwind') || taskDesc.includes('visual') || taskDesc.includes('mockup') || taskDesc.includes('reference');\n\nif (!designSystem && isVisualTask && projectId) {\n  try {\n    // Fetch reference files list\n    const refFiles = await new Promise((resolve) => {\n      const req = http.request({\n        hostname: 'file-api', port: 3456, method: 'GET',\n        path: '/projects/' + projectId + '/files',\n        headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n        timeout: 10000\n      }, res => {\n        let d = ''; res.on('data', c => d += c);\n        res.on('end', () => { try { resolve(JSON.parse(d).files || []); } catch { resolve([]); } });\n      });\n      req.on('error', () => resolve([]));\n      req.end();\n    });\n\n    // Find HTML files in references/\n    const htmlRefs = refFiles.filter(f => f.path.startsWith('references/') && f.path.endsWith('.html'));\n    \n    for (const ref of htmlRefs.slice(0, 2)) {\n      const content = ref.content || '';\n      if (content.length < 100) continue;\n      \n      // Extract Tailwind config\n      const configMatch = content.match(/tailwind\\.config\\s*=\\s*(\\{[\\s\\S]*?\\})\\s*<\\/script>/);\n      if (configMatch && !designSystem) {\n        designSystem = configMatch[1].substring(0, 4000);\n        staticData._stitchDesignSystem = designSystem;\n          \n          // Extract common component patterns from HTML and generate @apply classes\n          const classPatterns = [];\n          \n          // Find repeated class combinations in the HTML (buttons, cards, badges, etc.)\n          const classMatches = content.match(/class=\"([^\"]{30,})\"/g) || [];\n          const classCounts = {};\n          for (const match of classMatches) {\n            const classes = match.replace('class=\"', '').replace('\"', '').trim();\n            // Normalize whitespace\n            const normalized = classes.replace(/\\s+/g, ' ').trim();\n            classCounts[normalized] = (classCounts[normalized] || 0) + 1;\n          }\n          \n          // Find patterns used 2+ times \u2014 these are component-worthy\n          const componentClasses = [];\n          for (const [classes, count] of Object.entries(classCounts)) {\n            if (count >= 2 && classes.length > 20 && classes.length < 200) {\n              // Generate a semantic name from the classes\n              let name = 'component';\n              if (classes.includes('rounded-full') && classes.includes('px-')) name = 'pill';\n              else if (classes.includes('rounded-') && classes.includes('border')) name = 'card';\n              else if (classes.includes('font-bold') && classes.includes('text-')) name = 'heading';\n              else if (classes.includes('flex') && classes.includes('items-center')) name = 'row';\n              else if (classes.includes('grid')) name = 'grid';\n              else if (classes.includes('btn') || (classes.includes('cursor-pointer') && classes.includes('px-'))) name = 'btn';\n              else if (classes.includes('backdrop-blur')) name = 'glass';\n              \n              componentClasses.push({ name: name + '-' + componentClasses.length, classes, count });\n            }\n          }\n          \n          if (componentClasses.length > 0) {\n            // Generate @apply CSS\n            let applyCSS = '@layer components {\\n';\n            for (const comp of componentClasses.slice(0, 15)) {\n              applyCSS += '  .' + comp.name + ' {\\n    @apply ' + comp.classes.substring(0, 150) + ';\\n  }\\n';\n            }\n            applyCSS += '}';\n            staticData._stitchComponentClasses = applyCSS;\n          }\n      }\n      \n      // Extract style blocks\n      const styleMatches = content.match(/<style>([\\s\\S]*?)<\\/style>/g);\n      if (styleMatches && !referenceStyles) {\n        referenceStyles = styleMatches.join('\\n').substring(0, 2000);\n        staticData._stitchStyles = referenceStyles;\n      }\n    }\n  } catch(e) {}\n}\n\nreturn [{ json: {\n  task,\n  existing_files: existingFiles,\n  other_files_summary: summaryFiles,\n  plan_document: stashed.plan_document || '',\n  project_goal: stashed.project_goal || '',\n  project_id: stashed.project_id,\n  research_docs: staticData._researchDocs || '',\n  design_system: designSystem,\n  reference_styles: referenceStyles\n} }];"
      },
      "id": "p2-build-input",
      "name": "P2: Build Code Input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2250,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// CW: Prepare Message \u2014 restructured for instruction priority\n// Order: Task FIRST \u2192 Assets \u2192 Design System \u2192 Images \u2192 Files \u2192 Research (last)\nconst input = $json;\nconst planDocument = input.plan_document || '';\n\nlet taskDescription = input.task?.description || input.description || '';\nif (!taskDescription && input.task?.raw_output) {\n  const raw = input.task.raw_output;\n  const cleaned = raw.replace(/<\\/?task>/g, '').replace(/\\n+(?:Reasoning|Note):[\\s\\S]*/i, '').trim();\n  try {\n    const parsed = JSON.parse(cleaned);\n    const list = Array.isArray(parsed) ? parsed : (parsed.tasks || []);\n    taskDescription = list.map(t => `${t.task_id}: ${t.description}`).join('\\n\\n');\n  } catch(e) { taskDescription = raw; }\n}\n\nconst taskFiles = (input.task || {}).files || [];\nconst filesConstraint = taskFiles.length > 0\n  ? '\\n\\nFILES TO MODIFY (only output these files):\\n' + taskFiles.join('\\n')\n  : '';\n\nconst existingFiles = input.existing_files || [];\nconst otherFilesSummary = input.other_files_summary || [];\nconst existingFilesSection = existingFiles.length > 0\n  ? '\\n\\nEXISTING PROJECT FILES:\\n' +\n    (() => { let total = 0; const MAX = 40000; return existingFiles.filter(f => { const size = f.path.length + (f.content||'').length + 20; if (total + size > MAX) return false; total += size; return true; }).map(f => `### ${f.path}\\n\\`\\`\\`\\n${f.content}\\n\\`\\`\\``).join('\\n\\n'); })() + (otherFilesSummary.length > 0 ? '\\n\\nOTHER PROJECT FILES (not shown in full):\\n' + otherFilesSummary.map(f => `- ${f.path} (${f.size} chars): ${f.summary}`).join('\\n') : '')\n  : '';\n\nconst researchDocs = input.research_docs || '';\n\n// Detect image assets in public/assets/\nconst assetFiles = existingFiles\n  .filter(f => f.path.match(/^public\\/assets\\/.*\\.(png|jpg|jpeg|svg|gif|webp)$/i))\n  .map(f => f.path);\nconst assetWarning = assetFiles.length > 0\n  ? '\\n\\nAVAILABLE ASSETS (use <img src> for these, do NOT recreate them):\\n' +\n    assetFiles.map(a => `- ${a} \u2192 <img src=\"/${a.replace('public/', '')}\" />`).join('\\n')\n  : '';\n\n// Design system tokens from reference HTML\nconst designSystem = input.design_system || $getWorkflowStaticData('global')._stitchDesignSystem || '';\nconst stitchStyles = input.reference_styles || $getWorkflowStaticData('global')._stitchStyles || '';\n\n// Load reference images\nconst http = require('http');\nconst projectId = input.project_id || $('Extract Input').first().json.project_id || '';\nconst FILE_API = ($env.FILE_API_URL || 'http://file-api:3456').replace('http://', '');\nconst [apiHost, apiPort] = FILE_API.split(':');\nconst refImages = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: apiHost, port: parseInt(apiPort) || 3456,\n    path: `/projects/${projectId}/references`,\n    method: 'GET',\n    headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n    timeout: 10000\n  }, (res) => {\n    let data = '';\n    res.on('data', d => data += d);\n    res.on('end', () => { try { resolve(JSON.parse(data).references || []); } catch { resolve([]); } });\n  });\n  req.on('error', () => resolve([]));\n  req.on('timeout', () => { req.destroy(); resolve([]); });\n  req.end();\n});\n\nconst webhookImage = $('Extract Input').first().json.image_data || null;\nconst taskId = (input.task || {}).task_id || '';\nconst taskConcepts = $getWorkflowStaticData('global')._taskConcepts || {};\nconst taskConcept = taskConcepts[taskId] || null;\n\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n// BUILD THE PROMPT \u2014 instruction-first order\n// \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n// SYSTEM: Short, focused rules only\nconst systemMessage = `You are a Senior Full-Stack Developer. Output ONLY code file blocks.\n\nFORMAT \u2014 for each file output EXACTLY:\n### path/to/file.ext\n\\`\\`\\`ext\n[complete file content]\n\\`\\`\\`\n\nRULES:\n- Output complete file content, not diffs\n- Only output files listed in FILES TO MODIFY\n- No explanations, no prose \u2014 ONLY file blocks\n- Files with JSX MUST use .tsx extension\n- NEVER remove @tailwind directives from CSS files\n- NEVER remove existing imports that are still used\n- When modifying a file, preserve everything that works \u2014 only change what the task asks for`;\n\n// USER: Task first, then supporting context\nconst content = [];\n\n// \u2500\u2500 1. TASK DESCRIPTION (highest priority \u2014 read this first) \u2500\u2500\ncontent.push({ type: 'text', text: `YOUR TASK:\\n${taskDescription}${assetWarning}${filesConstraint}` });\n\n// \u2500\u2500 2. DESIGN SYSTEM (if available \u2014 use these tokens) \u2500\u2500\nif (designSystem) {\n  content.push({ type: 'text', text: 'DESIGN SYSTEM (follow these design principles, color palette, font choices, and spacing patterns):\\n' + designSystem.substring(0, 3000) });\n}\nif (stitchStyles) {\n  content.push({ type: 'text', text: 'REFERENCE CSS PATTERNS:\\n' + stitchStyles.substring(0, 1500) });\n}\nconst componentClasses = $getWorkflowStaticData('global')._stitchComponentClasses || '';\nif (componentClasses) {\n  content.push({ type: 'text', text: 'COMPONENT CLASSES (add these to your CSS file and use the semantic class names instead of repeating utility classes):\\n' + componentClasses });\n}\n\n// \u2500\u2500 3. REFERENCE IMAGES (visual target) \u2500\u2500\nif (refImages.length > 0) {\n  for (const ref of refImages) {\n    content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + ref.base64 } });\n    const label = ref.filename.replace(/\\.(png|jpg|jpeg|gif|webp)$/i, '').replace(/[_-]/g, ' ');\n    content.push({ type: 'text', text: 'REFERENCE (' + label + '):' });\n  }\n} else if (webhookImage) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + webhookImage } });\n  content.push({ type: 'text', text: 'REFERENCE DESIGN:' });\n}\nif (taskConcept) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + taskConcept } });\n  content.push({ type: 'text', text: 'TASK CONCEPT (visual guide for this task):' });\n}\n\n// \u2500\u2500 4. EXISTING FILES (context for the coder) \u2500\u2500\nif (existingFilesSection) {\n  content.push({ type: 'text', text: existingFilesSection });\n}\n\n// \u2500\u2500 5. ARCHITECTURE + RESEARCH (lowest priority \u2014 background context) \u2500\u2500\nif (planDocument) {\n  content.push({ type: 'text', text: 'ARCHITECTURE:\\n' + planDocument.substring(0, 3000) });\n}\nif (researchDocs) {\n  content.push({ type: 'text', text: 'LIBRARY DOCS:\\n' + researchDocs.substring(0, 6000) });\n}\n\nconst messageContent = content.length > 1 ? content : content[0].text;\n\nreturn [{ json: {\n  model: $env.CODER_MODEL || 'qwen3.5-27b@q4_k_m',\n  messages: [\n    { role: 'system', content: systemMessage },\n    { role: 'user', content: messageContent }\n  ],\n  temperature: 0.6,\n  chat_template_kwargs: { enable_thinking: false },\n  top_p: 0.95,\n    top_k: 20,\n    min_p: 0.0,\n    presence_penalty: 0.0,\n  max_tokens: 16384\n} }];"
      },
      "id": "cw-prepare",
      "name": "CW: Prepare Message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2450,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.LLM_API_KEY }}"
            }
          ]
        }
      },
      "id": "cw-llm",
      "name": "CW: Call LM Studio",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2650,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "const msg = $json.choices[0].message || {};\nlet raw = msg.content || '';\nconst reasoning = msg.reasoning_content || '';\n\n// Qwen3.5-27B reasoning model may put ALL output in reasoning_content or <think> tags\n// Try content first, then reasoning_content\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) {\n  content = content.split('</think>').pop().trim();\n}\n\n// If content is empty or too short (binary garbage from images), use reasoning_content\nif (content.length < 50 && reasoning.length > 50) {\n  content = reasoning.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\n  if (content.includes('</think>')) {\n    content = content.split('</think>').pop().trim();\n  }\n}\n\n// If still empty, try the full raw content (maybe think tags are unclosed)\nif (content.length < 50 && raw.length > 50) {\n  // Strip just the opening <think> and try to find file blocks\n  content = raw.replace(/<think>/g, '').replace(/<\\/think>/g, '').trim();\n}\n\nconst files = [];\nconst seen = new Set();\nconst re = /###\\s+((?:[\\w.-]+\\/)*(?:\\.[\\w][\\w.-]*|[\\w.-]+\\.(?:ts|tsx|js|jsx|json|md|yml|yaml|env|prisma|css|html|sh|txt|lock|toml|cfg|ini)|Dockerfile|Makefile|LICENSE|CHANGELOG))\\s*\\n```[\\w]*\\n([\\s\\S]*?)```/g;\nlet m;\nwhile ((m = re.exec(content)) !== null) {\n  const path = m[1].trim();\n  const fileContent = m[2];\n  if (path && !seen.has(path)) {\n    seen.add(path);\n    files.push({ path, content: fileContent });\n  }\n}\nconst staticData = $getWorkflowStaticData('global');\nconst projectId = staticData._currentProjectId || $('Extract Input').first().json.project_id || 'unknown';\nconst task = staticData._currentTask || {};\nconst projectGoal = staticData._currentProjectGoal || $('Extract Input').first().json.message || '';\nreturn [{ json: { files, task, project_id: projectId, project_goal: projectGoal } }];"
      },
      "id": "cw-parse",
      "name": "CW: Parse Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2850,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// NEW: Replaces Chunk: Prepare Write\n// Simplified write prep + updates _allFileContents cache so later chunks see fresh content\nconst stashed = $('P2: Stash Context').first().json;\nconst chunkFiles = stashed.chunk_files || [];\nconst newFiles = $json.files || [];\nconst staticData2 = $getWorkflowStaticData('global');\nconst projectId = $json.project_id || staticData2._currentProjectId || stashed.project_id;\n\n// Include all files the Code Writer produced\nlet filteredFiles = newFiles;\n\n// Handle package.json merge: preserve existing deps, add new ones\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nfor (const newFile of filteredFiles) {\n  if (newFile.path === 'package.json' || newFile.path.endsWith('/package.json')) {\n    const existing = allFiles.find(f => f.path === newFile.path);\n    if (existing) {\n      try {\n        const existingPkg = JSON.parse(existing.content);\n        const newPkg = JSON.parse(newFile.content);\n        newPkg.dependencies = { ...(existingPkg.dependencies || {}), ...(newPkg.dependencies || {}) };\n        newPkg.devDependencies = { ...(existingPkg.devDependencies || {}), ...(newPkg.devDependencies || {}) };\n        newFile.content = JSON.stringify(newPkg, null, 2);\n      } catch(e) {}\n    }\n  }\n}\n\n// Update the in-memory file cache so later chunks see fresh content\nfor (const newFile of filteredFiles) {\n  const idx = allFiles.findIndex(f => f.path === newFile.path);\n  if (idx >= 0) {\n    allFiles[idx] = { path: newFile.path, content: newFile.content };\n  } else {\n    allFiles.push({ path: newFile.path, content: newFile.content });\n  }\n}\nstaticData._allFileContents = allFiles;\n\nif (filteredFiles.length === 0) {\n  return [{ json: { project_id: projectId, files: [], _skipWrite: true } }];\n}\n\nreturn [{ json: { project_id: projectId, files: filteredFiles } }];"
      },
      "id": "p2-prepare-write",
      "name": "P2: Prepare Write",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3050,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $json.project_id + '/files-batch' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ files: $json.files }) }}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        }
      },
      "id": "p2-write-files",
      "name": "P2: Write Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3250,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// P2: Store Result \u2014 uses $json like all other Code nodes\nconst staticData = $getWorkflowStaticData('global');\nstaticData._queueDone = (staticData._queueDone || 0) + 1;\nconst total = staticData._queueTotal || 0;\nconst done = staticData._queueDone;\nconst remaining = total - done;\n\nconst taskId = staticData._currentTaskId || 'unknown';\nconst taskFiles = (staticData._currentTask || {}).files || [];\nconst writtenFiles = ($json.files_written || $json.files || []).map(f => typeof f === 'string' ? f : (f.path || JSON.stringify(f)));\nif (!staticData.p2Results) staticData.p2Results = [];\nstaticData.p2Results.push({ task_id: taskId, files_written: writtenFiles });\n\n\n// Status callback to Forge \u2014 fire and forget\ntry {\n  const http = require('http');\n  const cbBody = JSON.stringify({\n    event: 'task_written',\n    project_id: staticData._currentProjectId || 'unknown',\n    data: { task_id: taskId, files: writtenFiles.length > 0 ? writtenFiles : taskFiles }\n  });\n  const cbReq = http.request({\n    hostname: 'forge', port: 3500, path: '/api/status-callback',\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(cbBody) }\n  });\n  cbReq.on('error', () => {});\n  cbReq.write(cbBody);\n  cbReq.end();\n} catch(e) {}\n\nconst remainingQueue = staticData._currentQueue || [];\nconst isDone = remaining <= 0 || remainingQueue.length === 0;\n\nlet nextItem = null;\nif (!isDone) {\n  nextItem = JSON.parse(JSON.stringify(remainingQueue[0]));\n  nextItem._p2Queue = remainingQueue.slice(1);\n}\n\nreturn [{ json: {\n  _done: isDone,\n  _nextItem: nextItem,\n  _remaining: remaining,\n  task_id: taskId,\n  files_written: writtenFiles\n} }];"
      },
      "id": "p2-store",
      "name": "P2: Store Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3450,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ ($env.FILE_API_URL || 'http://file-api:3456') + '/projects/' + $('Prepare Planner Input').first().json.project_id + '/files-content' }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($env.FILE_API_TOKEN || '') }}"
            }
          ]
        },
        "options": {}
      },
      "id": "p3-refetch",
      "name": "P3: Re-fetch All Files",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3850,
        500
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Full project review with VL model \u2014 sees code AND screenshot\nconst allFiles = $json.files || [];\n// Store files for the code review pass\nconst __staticData = $getWorkflowStaticData('global');\n__staticData._codeReviewFiles = allFiles;\n__staticData._codeReviewBuildResult = $getWorkflowStaticData('global')._buildResult || {};\nconst staticData = $getWorkflowStaticData('global');\nconst screenshots = staticData._screenshots || {};\nconst interactionResult = staticData._interactionTest || {};\nconst playtestScreenshots = staticData._playtestScreenshots || [];\nconst playtestReport = staticData._playtestReport || '';\nconst plannerInput = $('Prepare Planner Input').first().json;\nconst projectGoal = plannerInput.project_goal || '';\nconst deps = staticData._projectDeps || [];\nconst buildResult = staticData._buildResult || { success: true };\nconst screenshot_b64 = staticData._screenshot_b64 || null;\n// Load reference images from project's references/ folder (persisted across runs)\nconst http = require('http');\nconst projectId = $('Extract Input').first().json.project_id;\nconst FILE_API = ($env.FILE_API_URL || 'http://file-api:3456').replace('http://', '');\nconst [apiHost, apiPort] = FILE_API.split(':');\nconst refImages = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: apiHost, port: parseInt(apiPort) || 3456,\n    path: `/projects/${projectId}/references`,\n    method: 'GET',\n    headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n    timeout: 15000\n  }, (res) => {\n    let data = '';\n    res.on('data', d => data += d);\n    res.on('end', () => {\n      try { resolve(JSON.parse(data).references || []); } catch { resolve([]); }\n    });\n  });\n  req.on('error', () => resolve([]));\n  req.on('timeout', () => { req.destroy(); resolve([]); });\n  req.end();\n});\n// Fallback to webhook image_data if no saved references\nconst webhookImage = $('Extract Input').first().json.image_data || null;\n\nconst allFilesFormatted = allFiles\n  .filter(f => f.path.match(/\\.(ts|tsx|js|jsx|json|css|html)$/) && !f.path.includes('node_modules'))\n  .map(f => '### ' + f.path + '\\n```\\n' + f.content + '\\n```')\n  .join('\\n\\n');\n\n// Browser inspection data from Playwright MCP\nconst consoleErrors = staticData._consoleErrors || [];\nconst layoutInfo = staticData._layoutInfo || null;\nconst networkErrors = staticData._networkErrors || [];\nconst domSnapshot = staticData._domSnapshot || '';\n\nlet browserSection = '';\nif (interactionResult && !interactionResult.error) {\n  browserSection += '\\nINTERACTION TEST: Clicked ' + (interactionResult.clicked || 'main button') + \n    ' \u2014 state ' + (interactionResult.stateChanged ? 'CHANGED (interactive element works)' : 'DID NOT CHANGE (possible bug: click handler may be broken)') + '\\n';\n}\nif (Object.keys(screenshots).length > 1) {\n  browserSection += '\\nMULTI-VIEWPORT TEST: Screenshots taken at mobile (390px), tablet (768px), desktop (1440px).\\n';\n  browserSection += 'Check: Does the layout adapt? Any overflow? Elements hidden on smaller screens?\\n';\n}\nif (consoleErrors.length > 0) {\n  browserSection += '\\nCONSOLE ERRORS (from browser \u2014 these are REAL runtime errors):\\n' + consoleErrors.join('\\n') + '\\n';\n}\nif (networkErrors.length > 0) {\n  browserSection += '\\nNETWORK ERRORS (failed requests):\\n' + networkErrors.join('\\n') + '\\n';\n}\nif (layoutInfo) {\n  browserSection += '\\nLAYOUT INSPECTION (computed CSS from running app):\\n' + JSON.stringify(layoutInfo, null, 2) + '\\n';\n  browserSection += 'Check: Are elements overlapping? Is the layout centered? Are widths/heights reasonable for mobile (390x844)?\\n';\n}\nif (domSnapshot) {\n  browserSection += '\\nDOM SNAPSHOT (accessibility tree from browser):\\n' + domSnapshot + '\\n';\n}\n\nconst buildSection = buildResult.success\n  ? '\\nBUILD STATUS: \u2705 Project builds successfully.\\n'\n  : '\\nBUILD STATUS: \u274c BUILD FAILED \u2014 THIS IS THE HIGHEST PRIORITY FIX:\\n' +\n    'Stage: ' + (buildResult.stage || 'unknown') + '\\n' +\n    'Error: ' + (buildResult.error || 'unknown') + '\\n' +\n    'Output:\\n' + (buildResult.output || '') + '\\n' +\n    'You MUST flag this as a critical severity fix.\\n';\n\nconst textPrompt = 'You are a Senior Code Reviewer with UI/UX expertise performing a FULL PROJECT REVIEW.\\n' +\n  'You are reviewing ALL files AND the visual output of the project.\\n\\n' +\n  'Your job is to find issues across code AND visuals:\\n' +\n  '1. IMPORT/EXPORT MISMATCHES: imports that reference exports that don\\'t exist, wrong import style (named vs default)\\n' +\n  '2. TYPE MISMATCHES: function signatures that don\\'t match their call sites\\n' +\n  '3. DEPENDENCY ISSUES: imports of packages not in package.json\\n' +\n  '4. MISSING FILES: components or modules imported but never created\\n' +\n  '5. CRITICAL BUGS: null reference risks, unhandled promise rejections\\n' +\n  '6. ASSET/STYLE LOADING: verify entry point imports stylesheet, Tailwind classes are compiled\\n' +\n  '7. BUILD CHAIN: configs use consistent module format, all deps listed\\n' +\n  '8. DEAD FILES: duplicate entry points, orphaned files\\n' +\n  '9. FILE SCOPE: ONLY flag issues in files that exist in the ALL PROJECT FILES section above. Do NOT suggest creating new files. If functionality is missing, suggest adding it to an EXISTING file.\\n10. UNUSED ASSETS: If public/assets/ contains PNG/SVG images but the code uses inline SVGs or emoji instead, flag this as a BLOCKER. The project has real game assets \u2014 they MUST be used via <img src> tags.\\n10. VISUAL ISSUES: compare the screenshot with the design intent \u2014 is the layout correct? Are components visible? Colors applied? Anything broken visually?\\n\\n' +\n  'Project Goal: ' + projectGoal + '\\n' +\n  buildSection + '\\n' +\n  'ALL PROJECT FILES:\\n' + allFilesFormatted + '\\n\\n' +\n  'CRITICAL: Output the JSON response IMMEDIATELY \u2014 do NOT write analysis or reasoning before the JSON. Start your response with the opening { bracket. Return ONLY valid JSON:\\n' +\n  '{\\n  \"overall_quality\": number (0-100),\\n  \"cross_file_consistent\": boolean,\\n' +\n  '  \"visual_quality\": number (0-100),\\n' +\n  '  \"fixes_needed\": [\\n    {\\n      \"file\": \"path\",\\n      \"severity\": \"critical|high|medium\",\\n' +\n  '      \"issue\": \"description\",\\n      \"problem\": \"specific fix\",\\n      \"related_files\": [\"path\"]\\n    }\\n  ],\\n' +\n  '  \"visual_issues\": [\"specific visual problem 1\", \"specific visual problem 2\"],\\n' +\n  '  \"summary\": \"overall assessment including visual review\"\\n}';\n\n// Build multimodal content array\nconst content = [];\n// Include all reference images from the project\nif (refImages.length > 0) {\n  for (const ref of refImages) { // All refs \u2014 96K context can handle it\n    content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + ref.base64 } });\n    const label = ref.filename.replace(/\\.(png|jpg|jpeg|gif|webp)$/i, '').replace(/[_-]/g, ' ');\n    content.push({ type: 'text', text: 'REFERENCE: ' + label + ' (what the user wants):' });\n  }\n} else if (webhookImage) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + webhookImage } });\n  content.push({ type: 'text', text: 'REFERENCE IMAGE (what the user wants it to look like):' });\n}\nif (screenshot_b64) {\n  content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + screenshot_b64 } });\n  content.push({ type: 'text', text: 'ACTUAL SCREENSHOT of the built application:' });\n}\n// Tablet/desktop screenshots removed to save context\n// Add playtest screenshots \u2014 reviewer sees the game being played\nif (playtestScreenshots.length > 0) {\n  for (const ps of playtestScreenshots.slice(0, 2)) { // Limit to 2 playtest screenshots\n    content.push({ type: 'image_url', image_url: { url: 'data:image/png;base64,' + ps.b64 } });\n    content.push({ type: 'text', text: 'PLAYTEST: ' + ps.label.replace(/_/g, ' ') });\n  }\n}\nif (playtestReport) {\n  browserSection += '\\nPLAYTEST OBSERVATIONS:\\n' + playtestReport + '\\n';\n}\ncontent.push({ type: 'text', text: textPrompt + browserSection });\n\n// Use multimodal array if we have images, plain text if not\nconst messageContent = content.length > 1 ? content : textPrompt;\n\nreturn [{\n  json: {\n    model: $env.REVIEWER_MODEL || 'qwen3.5-27b@q4_k_m',\n    messages: [{ role: 'user', content: messageContent }],\n    temperature: 0.7,\n    top_p: 0.8,\n    top_k: 20,\n    min_p: 0.0,\n    presence_penalty: 1.5,\n    max_tokens: 8192\n  }\n}];"
      },
      "id": "p3-review-build",
      "name": "P3: Full Review Build",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4050,
        500
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.LM_STUDIO_URL || 'http://10.0.0.100:1234/v1/chat/completions' }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json) }}",
        "options": {
          "timeout": 600000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + $env.LLM_API_KEY }}"
            }
          ]
        }
      },
      "id": "p3-review-llm",
      "name": "P3: Review LLM",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4250,
        500
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "// Parses cross-file review format with fixes_needed[]\n// Merge visual review + code review\nconst staticData2 = $getWorkflowStaticData('global');\nconst visualReview = staticData2._visualReview || '';\n\n// Code review from current LLM call\nconst codeMsg = ($json.choices && $json.choices[0] && $json.choices[0].message) || {};\nconst codeContent = codeMsg.content || '';\nconst codeReasoning = codeMsg.reasoning_content || '';\n// Try content first, then reasoning for code review JSON\nconst codeReview = (codeContent && codeContent.includes('code_quality')) ? codeContent : codeReasoning;\n\n// Use visual review as primary (has overall quality), code review adds issues\nlet raw = visualReview;\n\n// Try to extract code issues and merge them\nlet codeIssues = [];\ntry {\n  const codeJson = JSON.parse(codeReview.match(/\\{[\\s\\S]*\\}/)?.[0] || '{}');\n  codeIssues = codeJson.issues || [];\n} catch {}\nlet content = raw.replace(/<think>[\\s\\S]*?<\\/think>/g, '').trim();\nif (content.includes('</think>')) content = content.split('</think>').pop().trim();\ncontent = content.replace(/^```(?:json)?\\n?/, '').replace(/\\n?```$/, '').trim();\nconst jsonMatch = content.match(/\\{[\\s\\S]*\\}/);\nlet review;\ntry {\n  review = JSON.parse(jsonMatch ? jsonMatch[0] : content);\n} catch (e) {\n  review = { overall_quality: 0, cross_file_consistent: true, fixes_needed: [], summary: 'Review parse failed: ' + content.substring(0, 200) };\n}\n\nconst fixes = [...(review.fixes_needed || []), ...codeIssues.map(i => ({ file: i.file, severity: i.severity, issue: i.issue, problem: i.problem || i.issue }))];\n// If visual review failed but code review produced issues, use code review quality\nlet codeQuality = 0;\ntry {\n  const codeJson = JSON.parse((codeReview.match(/\\{[\\s\\S]*\\}/) || ['{}'])[0]);\n  codeQuality = codeJson.code_quality || 0;\n} catch {}\n\n// Use whichever quality is higher (non-zero)\nif (review.overall_quality === 0 && codeQuality > 0) {\n  review.overall_quality = codeQuality;\n}\n\nconst criticalFixCount = fixes.filter(f => f.severity === 'critical' || f.severity === 'high').length;\nconst visualQuality = review.visual_quality || 0;\nconst visualIssues = review.visual_issues || [];\nconst projectId = $('Prepare Planner Input').first().json.project_id;\n\nconst staticData = $getWorkflowStaticData('global');\nstaticData._reviewResult = review;\n\nreturn [{ json: {\n  review,\n  critical_fix_count: criticalFixCount,\n  fixes_needed: fixes,\n  project_id: projectId\n} }];"
      },
      "id": "p3-review-parse",
      "name": "P3: Review Parse",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4450,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Determine if fixes are needed \u2014 adds _needsFix flag\nconst fixes = $json.fixes_needed || [];\nconst criticalCount = $json.critical_fix_count || 0;\nconst quality = ($json.review || {}).overall_quality || 0;\n\n// Only fix if: real fixes exist AND review actually produced valid output\nconst needsFix = fixes.length > 0 && criticalCount > 0 && quality > 0;\n\nreturn [{ json: { ...$json, _needsFix: needsFix } }];"
      },
      "id": "p3-needs-fix",
      "name": "P3: Route Decision",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4650,
        500
      ]
    },
    {
      "parameters": {
        "jsCode": "// Visual-aware fix: sees screenshot + reference images, not just text\nconst http = require('http');\nconst fixes = $json.fixes_needed || [];\nconst visualIssues = $json.visual_issues || [];\nconst projectId = $json.project_id;\nconst staticData = $getWorkflowStaticData('global');\nconst allFiles = staticData._allFileContents || [];\nconst screenshot_b64 = staticData._screenshot_b64 || null;\n\n// Load reference images from project references/ folder\nconst FILE_API = ($env.FILE_API_URL || 'http://file-api:3456').replace('http://', '');\nconst [apiHost, apiPort] = FILE_API.split(':');\nconst refImages = await new Promise((resolve) => {\n  const req = http.request({\n    hostname: apiHost, port: parseInt(apiPort) || 3456,\n    path: `/projects/${projectId}/references`,\n    method: 'GET',\n    headers: { 'Authorization': 'Bearer ' + ($env.FILE_API_TOKEN || '') },\n    timeout: 15000\n  }, (res) => {\n    let data = '';\n    res.on('data', d => data += d);\n    res.on('end', () => {\n      try { resolve(JSON.parse(data).references || []); } catch { resolve([]); }\n    });\n  });\n  req.on('error', () => resolve([]));\
Pro

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

About this workflow

eek-Go v2 (Batch-Then-Review). Uses httpRequest. Webhook trigger; 75 nodes.

Source: https://github.com/eekanti/eek.GO/blob/16654b8428e6d4ada43b42faf3aef2c5e46521a8/workflows/eek-go.json — original creator credit. Request a take-down →

More Web Scraping workflows → · Browse all categories →

Related workflows

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

Web Scraping

This n8n template provides enterprise-level version control for your workflows using GitHub integration. Stop losing hours to broken workflows and manual exports – get proper commit history, visual di

n8n, Execute Workflow Trigger, HTTP Request +1
Web Scraping

This flow creates dummy files for every item added in your *Arrs (Radarr/Sonarr) with the tag .

HTTP Request, Ssh
Web Scraping

This workflow receives webhook requests from a content calendar and uses the X API v2 to publish text posts, threads, image/video posts, and polls, as well as delete existing posts and run a credentia

HTTP Request
Web Scraping

This workflow acts as a central API gateway for all technical indicator agents in the Binance Spot Market Quant AI system. It listens for incoming webhook requests and dynamically routes them to the c

HTTP Request
Web Scraping

comentarios automaticos. Uses httpRequest, stopAndError. Webhook trigger; 69 nodes.

HTTP Request, Stop And Error