{ "name": "Research Agent Pipeline", "nodes": [ { "typeVersion": 2, "position": [ -1400, 300 ], "parameters": { "httpMethod": "POST", "path": "research-agent", "responseMode": "onReceived", "responseData": "={{ { status: \"accepted\", message: \"Research started for: \" + $json.body.topic } }}", "options": {} }, "id": "n1", "name": "New Research Request", "type": "n8n-nodes-base.webhook", "webhookId": "b2f3a9c1-6d4e-4a1b-9c2f-8e7d5a3b1f00" }, { "typeVersion": 4.2, "position": [ -1140, 300 ], "parameters": { "method": "GET", "url": "https://api.search.brave.com/res/v1/web/search", "sendQuery": true, "queryParameters": { "parameters": [ { "name": "q", "value": "={{ $json.next_query || $json.body.topic }}" }, { "name": "count", "value": "5" } ] }, "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "Accept", "value": "application/json" } ] }, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": {} }, "id": "n2", "name": "Web Search", "type": "n8n-nodes-base.httpRequest", "credentials": { "httpHeaderAuth": { "id": null, "name": "Brave Search API" } }, "notes": "Query = next_query on loop-back rounds, or the raw topic on round 1. Response items land in $json.web.results — adjust the 'Accumulate Results' code below if your search provider's response shape differs." }, { "typeVersion": 2, "position": [ -880, 300 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "// Brave's actual results usually live under $json.web.results on each item.\n// This node normalizes that into a flat array we can carry through the loop.\nconst searchResults = $input.all().flatMap(item => {\n const results = item.json.web?.results || item.json.results || [];\n return results.map(r => ({\n title: r.title || '',\n url: r.url || r.link || '',\n description: r.description || r.snippet || ''\n }));\n});\n\n// Recover prior loop state. On round 1, \"Parse Decision\" hasn't run yet,\n// so this throws — we catch that and bootstrap fresh state from the webhook.\nlet state;\ntry {\n state = $('Parse Decision').first().json;\n} catch (e) {\n state = null;\n}\n\nif (!state) {\n const webhookItem = $('New Research Request').first().json;\n state = {\n topic: webhookItem.body.topic,\n round: 1,\n max_rounds: 3,\n accumulated_results: []\n };\n}\n\nconst tagged = searchResults.map(r => ({ ...r, round: state.round }));\nconst accumulated_results = [...(state.accumulated_results || []), ...tagged];\n\nreturn [{\n json: {\n topic: state.topic,\n round: state.round,\n max_rounds: state.max_rounds,\n accumulated_results\n }\n}];" }, "id": "n3", "name": "Accumulate Results", "type": "n8n-nodes-base.code", "notes": "Bootstraps state on round 1 (from the webhook) or continues it from 'Parse Decision' on loop-back rounds." }, { "typeVersion": 4.2, "position": [ -620, 300 ], "parameters": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "anthropic-version", "value": "2023-06-01" }, { "name": "content-type", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({\n model: \"claude-sonnet-4-6\",\n max_tokens: 500,\n system: \"You are researching a topic and deciding whether you have enough material to write a well-supported report. Respond with ONLY valid JSON, no other text: {\\\"decision\\\": \\\"SUFFICIENT\\\" or \\\"SEARCH_AGAIN\\\", \\\"next_query\\\": \\\"a revised search query if SEARCH_AGAIN, else empty string\\\", \\\"reason\\\": \\\"one sentence explaining the decision, naming the specific gap if SEARCH_AGAIN\\\"}\",\n messages: [{\n role: \"user\",\n content: \"Topic: \" + $json.topic + \"\\n\\nRound \" + $json.round + \" of \" + $json.max_rounds + \".\\n\\nSources found so far:\\n\" + JSON.stringify($json.accumulated_results.map(r => ({ title: r.title, url: r.url, description: r.description })), null, 2)\n }]\n}) }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": {} }, "id": "n4", "name": "Enough? Decision", "type": "n8n-nodes-base.httpRequest", "credentials": { "httpHeaderAuth": { "id": null, "name": "Anthropic API" } }, "notes": "Header auth credential should send your key as 'x-api-key'. Create it once in n8n Credentials (Header Auth: name=x-api-key, value=your Claude key), name it 'Anthropic API', and every Claude node in this workflow will offer to use it." }, { "typeVersion": 2, "position": [ -360, 300 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const claudeResponse = $input.first().json;\nconst state = $('Accumulate Results').first().json;\n\nlet parsed;\ntry {\n const text = claudeResponse.content[0].text;\n parsed = JSON.parse(text);\n if (!parsed.decision) throw new Error('missing decision field');\n} catch (e) {\n // Model didn't return clean JSON — fail safe rather than loop forever.\n parsed = {\n decision: 'SUFFICIENT',\n next_query: '',\n reason: 'Could not parse model output as JSON — proceeding with what we have. Check the Claude prompt if this happens often.'\n };\n}\n\nreturn [{\n json: {\n topic: state.topic,\n round: state.round + 1,\n max_rounds: state.max_rounds,\n accumulated_results: state.accumulated_results,\n decision: parsed.decision,\n next_query: parsed.next_query,\n reason: parsed.reason\n }\n}];" }, "id": "n5", "name": "Parse Decision", "type": "n8n-nodes-base.code", "notes": "Round is incremented here, after the decision is made, so 'Loop Check' compares the NEXT round against max_rounds." }, { "typeVersion": 2.2, "position": [ -100, 300 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "loose" }, "combinator": "or", "conditions": [ { "leftValue": "={{ $json.decision }}", "rightValue": "SUFFICIENT", "operator": { "type": "string", "operation": "equals" } }, { "leftValue": "={{ $json.round }}", "rightValue": "={{ $json.max_rounds }}", "operator": { "type": "number", "operation": "gt" } } ] }, "options": {} }, "id": "n6", "name": "Loop Check", "type": "n8n-nodes-base.if", "notes": "TRUE (sufficient, or round cap hit) continues to filtering. FALSE loops back to Web Search with the revised query." }, { "typeVersion": 4.2, "position": [ 160, 180 ], "parameters": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "anthropic-version", "value": "2023-06-01" }, { "name": "content-type", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({\n model: \"claude-sonnet-4-6\",\n max_tokens: 800,\n system: \"Score how relevant each source is to the research topic, from 0 (irrelevant) to 1 (highly relevant). Respond with ONLY a JSON array, no other text: [{\\\"url\\\": \\\"...\\\", \\\"score\\\": 0.0}]\",\n messages: [{\n role: \"user\",\n content: \"Topic: \" + $json.topic + \"\\n\\nSources:\\n\" + JSON.stringify($json.accumulated_results.map(r => ({ url: r.url, title: r.title, description: r.description })), null, 2)\n }]\n}) }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": {} }, "id": "n7", "name": "Relevance Scoring", "type": "n8n-nodes-base.httpRequest", "credentials": { "httpHeaderAuth": { "id": null, "name": "Anthropic API" } }, "notes": "One batched call scores every accumulated source at once — this is the 'lightweight' relevance check, not a per-source API call, to keep cost down." }, { "typeVersion": 2, "position": [ 420, 180 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const claudeResponse = $input.first().json;\nconst state = $('Loop Check').first().json;\nconst accumulated = state.accumulated_results;\n\nlet scores = [];\ntry {\n scores = JSON.parse(claudeResponse.content[0].text);\n} catch (e) {\n // If scoring fails, fall back to keeping everything — better an\n // over-inclusive report than a broken run.\n scores = accumulated.map(r => ({ url: r.url, score: 1 }));\n}\nconst scoreMap = new Map(scores.map(s => [s.url, s.score]));\n\n// Relevance score is the only quality gate by default — broad research\n// topics (news, current events) tend to have trustworthy sources spread\n// across far more domains than any fixed allowlist could cover, and an\n// allowlist that's too narrow just filters out everything.\n//\n// If you want to exclude specific low-quality sites for a narrower or\n// more sensitive topic (medical, legal, academic), uncomment the two\n// lines below and add domains to BLOCKED_DOMAINS.\n//\n// const BLOCKED_DOMAINS = ['pinterest.com', 'quora.com', 'reddit.com'];\n// function domainOf(url) { try { return new URL(url).hostname.replace(/^www\\./, ''); } catch (e) { return ''; } }\n\nconst RELEVANCE_THRESHOLD = 0.5;\n\nconst seen = new Set();\nconst kept = [];\nfor (const r of accumulated) {\n if (!r.url || seen.has(r.url)) continue;\n const score = scoreMap.get(r.url) ?? 0;\n if (score >= RELEVANCE_THRESHOLD) {\n seen.add(r.url);\n kept.push(r);\n }\n}\n\nreturn kept.map(r => ({ json: { ...r, topic: state.topic } }));" }, "id": "n8", "name": "Filter: Domain + Relevance", "type": "n8n-nodes-base.code", "notes": "Relevance score is the only filter by default. See the commented-out BLOCKED_DOMAINS lines in the code if you want to add domain exclusions for a narrower topic." }, { "typeVersion": 4.2, "position": [ 680, 180 ], "parameters": { "method": "GET", "url": "={{ $json.url }}", "options": { "response": { "response": { "responseFormat": "text", "outputPropertyName": "html" } }, "timeout": 10000 }, "onError": "continueRegularOutput" }, "id": "n9", "name": "Fetch Full Page", "type": "n8n-nodes-base.httpRequest", "notes": "Runs once per surviving source (n8n processes multiple input items automatically). 'Continue on Fail' is on, so one dead link doesn't kill the whole run — it just won't have article text to extract." }, { "typeVersion": 1.2, "position": [ 940, 180 ], "parameters": { "operation": "extractHtmlContent", "sourceData": "json", "dataPropertyName": "html", "extractionValues": { "values": [ { "key": "articleText", "cssSelector": "article, main, body", "returnValue": "text", "returnArray": false } ] }, "options": { "trimValues": true, "cleanUpText": true } }, "id": "n10", "name": "Extract Article Text", "type": "n8n-nodes-base.html", "notes": "Falls back through article -> main -> body selectors, whichever matches first. Keeps url/title from the input item alongside the extracted text. Uses the modern 'HTML' node (operation: Extract HTML Content) — the older standalone 'HTML Extract' node is deprecated/hidden in current n8n versions and will show as an unrecognized node if used." }, { "typeVersion": 2, "position": [ 1200, 180 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "// \"Fetch Full Page\" and \"Extract Article Text\" each replace the item's\n// JSON with a fresh object containing only their own output field\n// (html, then articleText) — this is standard n8n behavior for those\n// node types, not a bug. So url/title/topic have to be recovered from\n// \"Filter: Domain + Relevance\", the last node that still had them,\n// using itemMatching() — n8n's official way to follow an item's\n// lineage across nodes that don't preserve fields.\nconst items = $input.all();\nconst seen = new Set();\nconst deduped = [];\n\nfor (let i = 0; i < items.length; i++) {\n const item = items[i];\n\n let source;\n try {\n source = $('Filter: Domain + Relevance').itemMatching(i).json;\n } catch (e) {\n // Fallback if pairedItem lineage broke somewhere upstream — better to\n // keep the item with what we have than silently drop it.\n source = {};\n }\n\n const url = source.url;\n if (!url || seen.has(url)) continue;\n seen.add(url);\n\n deduped.push({\n json: {\n ...source,\n articleText: item.json.articleText || ''\n }\n });\n}\n\nreturn deduped;" }, "id": "n11", "name": "Dedupe by URL", "type": "n8n-nodes-base.code" }, { "typeVersion": 4.2, "position": [ 1460, 180 ], "parameters": { "method": "POST", "url": "https://api.anthropic.com/v1/messages", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "anthropic-version", "value": "2023-06-01" }, { "name": "content-type", "value": "application/json" } ] }, "sendBody": true, "specifyBody": "json", "jsonBody": "={{ JSON.stringify({\n model: \"claude-sonnet-4-6\",\n max_tokens: 2000,\n system: \"Write a research report using exactly this markdown structure:\\n\\n## Research Question\\n[restate the topic as a specific question]\\n\\n## Key Findings\\n[3-6 findings, each with the source it came from]\\n\\n## Sources\\n[list every source actually cited above, as a markdown list]\\n\\n## Conclusion\\n[2-3 sentences]\\n\\nDo not add sections. Do not omit the Sources section, even if short.\",\n messages: [{\n role: \"user\",\n content: \"Research topic: \" + $('Loop Check').first().json.topic + \"\\n\\nSource material:\\n\" + $input.all().map(item => \"URL: \" + item.json.url + \"\\nTitle: \" + item.json.title + \"\\nContent: \" + (item.json.articleText || item.json.description || '').slice(0, 2000)).join(\"\\n\\n---\\n\\n\")\n }]\n}) }}", "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "options": {} }, "id": "n12", "name": "Generate Report", "type": "n8n-nodes-base.httpRequest", "credentials": { "httpHeaderAuth": { "id": null, "name": "Anthropic API" } } }, { "typeVersion": 2, "position": [ 1720, 180 ], "parameters": { "mode": "runOnceForAllItems", "jsCode": "const response = $input.first().json;\nconst reportText = response.content?.[0]?.text || 'Report generation failed — check the Generate Report node output.';\nreturn [{ json: { report: reportText } }];" }, "id": "n13", "name": "Extract Report Text", "type": "n8n-nodes-base.code" }, { "typeVersion": 1.2, "position": [ 1980, 180 ], "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "={{ $json.chatId || 'YOUR_CHAT_ID' }}", "text": "={{ $json.report }}", "additionalFields": { "parse_mode": "Markdown" } }, "id": "n14", "name": "Send to Telegram", "type": "n8n-nodes-base.telegram", "credentials": { "telegramApi": { "id": null, "name": "Telegram Bot" } }, "notes": "Swap this node for Notion or Google Docs if you'd rather route the report there — see Step 6 in the article." } ], "connections": { "New Research Request": { "main": [ [ { "node": "Web Search", "type": "main", "index": 0 } ] ] }, "Web Search": { "main": [ [ { "node": "Accumulate Results", "type": "main", "index": 0 } ] ] }, "Accumulate Results": { "main": [ [ { "node": "Enough? Decision", "type": "main", "index": 0 } ] ] }, "Enough? Decision": { "main": [ [ { "node": "Parse Decision", "type": "main", "index": 0 } ] ] }, "Parse Decision": { "main": [ [ { "node": "Loop Check", "type": "main", "index": 0 } ] ] }, "Loop Check": { "main": [ [ { "node": "Relevance Scoring", "type": "main", "index": 0 } ], [ { "node": "Web Search", "type": "main", "index": 0 } ] ] }, "Relevance Scoring": { "main": [ [ { "node": "Filter: Domain + Relevance", "type": "main", "index": 0 } ] ] }, "Filter: Domain + Relevance": { "main": [ [ { "node": "Fetch Full Page", "type": "main", "index": 0 } ] ] }, "Fetch Full Page": { "main": [ [ { "node": "Extract Article Text", "type": "main", "index": 0 } ] ] }, "Extract Article Text": { "main": [ [ { "node": "Dedupe by URL", "type": "main", "index": 0 } ] ] }, "Dedupe by URL": { "main": [ [ { "node": "Generate Report", "type": "main", "index": 0 } ] ] }, "Generate Report": { "main": [ [ { "node": "Extract Report Text", "type": "main", "index": 0 } ] ] }, "Extract Report Text": { "main": [ [ { "node": "Send to Telegram", "type": "main", "index": 0 } ] ] } }, "active": false, "settings": { "executionOrder": "v1" }, "pinData": {}, "meta": { "templateCredsSetupCompleted": false } }