This workflow follows the Emailsend → Form Trigger recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "Research Agent (ep10)",
"nodes": [
{
"parameters": {
"formTitle": "Research intake \u2014 Ships Itself",
"formDescription": "Ask a question. You get an answer where every claim is checked against the source it cites.",
"formFields": {
"values": [
{
"fieldLabel": "Research question",
"placeholder": "What does HTTP status 418 mean?",
"requiredField": true
},
{
"fieldLabel": "Email the report to",
"fieldType": "email",
"requiredField": true
}
]
},
"options": {}
},
"id": "e1000000-0000-4000-8000-000000000001",
"name": "research-intake",
"type": "n8n-nodes-base.formTrigger",
"typeVersion": 2.2,
"position": [
200,
300
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// One run folder per question. Everything about this run lands here.\nconst fs=require('fs'),path=require('path');\nconst BASE=$env.EP10_DIR||require('path').resolve('.');\nconst id=new Date().toISOString().replace(/[:.]/g,'-')+'-'+Math.random().toString(36).slice(2,6);\nconst dir=path.join(BASE,'runs',id);\nfs.mkdirSync(path.join(dir,'cache'),{recursive:true});\nconst q=$json['Research question'],to=$json['Email the report to'];\nfs.writeFileSync(path.join(dir,'meta.json'),JSON.stringify({id,q,to}));\nreturn {json:{id,dir,q,to}};"
},
"id": "e1000000-0000-4000-8000-000000000001",
"name": "run-init",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
420,
300
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Call 1: ask the model which sources it WANTS. It does not get to fetch them.\nconst fs=require('fs'),https=require('https');\nconst {id,dir,q,to}=$json;\nconst prompt='Propose up to 5 source URLs that answer: '+q+'\\nONLY from these domains: en.wikipedia.org, developer.mozilla.org, docs.python.org, datatracker.ietf.org.\\nReply with a JSON array of URLs only.';\nconst res=await new Promise((ok,er)=>{const r=https.request('https://fal.run/fal-ai/any-llm',{method:'POST',headers:{Authorization:'Key '+$env.FAL_KEY,'Content-Type':'application/json'}},s=>{let d='';s.on('data',c=>d+=c);s.on('end',()=>ok({h:s.headers,d}))});r.on('error',er);r.end(JSON.stringify({model:'meta-llama/llama-4-scout',prompt}))});\nfs.writeFileSync(dir+'/plan.json',res.d);\nfs.appendFileSync(dir+'/receipts.jsonl',JSON.stringify({call:'plan',units:res.h['x-fal-billable-units']||null,at:Date.now()})+'\\n');\nlet out=res.d;try{out=JSON.parse(res.d).output||res.d}catch(e){}\nreturn {json:{id,dir,q,to,plan:String(out)}};"
},
"id": "e1000000-0000-4000-8000-000000000002",
"name": "plan-call",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
300
]
},
{
"parameters": {
"jsCode": "// The security line: the allowlist is enforced HERE, in code. The model never picks a fetch target.\nconst ALLOW=['en.wikipedia.org','developer.mozilla.org','docs.python.org','datatracker.ietf.org'];\nconst out=[];\nfor(const {json:j} of $input.all()){\n const found=(j.plan.match(/https?:\\/\\/[^\\s\"')\\]]+/g)||[]).map(u=>u.replace(/[.,)\\]]+$/,''));\n const host=u=>u.replace(/^https?:\\/\\//i,'').split(/[/?#]/)[0].toLowerCase();\n const urls=[...new Set(found)].filter(u=>ALLOW.includes(host(u))).slice(0,5);\n for(const url of urls) out.push({json:{id:j.id,dir:j.dir,q:j.q,to:j.to,url}});\n if(!urls.length) out.push({json:{id:j.id,dir:j.dir,q:j.q,to:j.to,url:'(none)',key:'none',status:0,dead:true}});\n}\nreturn out;"
},
"id": "e1000000-0000-4000-8000-000000000003",
"name": "split-urls",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
860,
300
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// One polite GET. http->https (https.get throws on http), 15s timeout -> DEAD_URL, one redirect followed.\nconst fs=require('fs'),https=require('https');\nconst {dir,url}=$json;\nif($json.dead) return {json:$json};\nconst key=[...url].reduce((h,c)=>(h*33+c.charCodeAt(0))>>>0,5381).toString(16)+'-'+url.split('/').pop().replace(/[^\\w.-]/g,'').slice(0,40);\nconst get=u=>new Promise(ok=>{const rq=https.get(u.replace(/^http:\\/\\//i,'https://'),{headers:{'user-agent':'ships-itself-ep10-research'}},s=>{\n if([301,302,307,308].includes(s.statusCode)&&s.headers.location){s.resume();return ok({redir:s.headers.location})}\n let d='';s.on('data',c=>d+=c);s.on('end',()=>ok({code:s.statusCode,d}))});\n rq.on('error',()=>{rq.destroy();ok({code:0})});rq.setTimeout(15000,()=>{rq.destroy();ok({code:0})})});\nlet r=await get(url),redirectedTo=null;\nif(r.redir){redirectedTo=r.redir.startsWith('http')?r.redir:url.replace(/^(https?:\\/\\/[^/]+).*$/,'$1')+r.redir;r=await get(redirectedTo)}\nif(r.redir||r.code!==200) return {json:{...$json,key,status:r.code||0,redirectedTo,dead:true}};\nfs.writeFileSync(dir+'/cache/'+key+'.html',r.d);\nreturn {json:{...$json,key,status:200,redirectedTo,dead:false}};"
},
"id": "e1000000-0000-4000-8000-000000000004",
"name": "fetch-page",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1080,
300
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Same-bytes rule: this exact slice is what the model reads AND what the gate checks.\nconst fs=require('fs');\nconst {dir,key,dead}=$json;\nif(dead) return {json:$json};\nlet t=fs.readFileSync(dir+'/cache/'+key+'.html','utf8');\nt=t.replace(/<script[\\s\\S]*?<\\/script>/gi,' ').replace(/<style[\\s\\S]*?<\\/style>/gi,' ');\nt=t.replace(/<sup[^>]*class=\"[^\"]*reference[^\"]*\"[^>]*>[\\s\\S]*?<\\/sup>/gi,'');\nt=t.replace(/=\\s*\"[^\"]*\"/g,'').replace(/=\\s*'[^']*'/g,'');\nt=t.replace(/\\{\\\\displaystyle(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})*\\}/g,'');\nt=t.replace(/<\\/?(a|code|em|i|b|strong|span|sup|sub|abbr|cite|kbd|var|small|s|u|mark|q|time|data)\\b[^>]*>/gi,'');\nt=t.replace(/<[^>]+>/g,' ');\nt=t.replace(/&/g,'&').replace(/ /g,' ').replace(/"/g,'\"').replace(/'/g,\"'\").replace(/</g,'<').replace(/>/g,'>');\nt=t.replace(/\\s+/g,' ').trim().slice(0,8000);\nfs.writeFileSync(dir+'/cache/'+key+'.txt',t);\nreturn {json:{...$json,chars:t.length}};"
},
"id": "e1000000-0000-4000-8000-000000000005",
"name": "page-text",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1300,
300
]
},
{
"parameters": {
"jsCode": "// Call 2: answer from the fetched text only, with a verbatim quote per claim.\nconst fs=require('fs'),https=require('https');\nconst call=body=>new Promise((ok,er)=>{const r=https.request('https://fal.run/fal-ai/any-llm',{method:'POST',headers:{Authorization:'Key '+$env.FAL_KEY,'Content-Type':'application/json'}},s=>{let d='';s.on('data',c=>d+=c);s.on('end',()=>ok({h:s.headers,d}))});r.on('error',er);r.end(body)});\nconst groups={};for(const {json:s} of $input.all())(groups[s.id]=groups[s.id]||[]).push(s);\nconst out=[];\nfor(const id in groups){const g=groups[id],{dir,q,to}=g[0],live=g.filter(s=>!s.dead);\n if(!live.length){out.push({json:{id,dir,q,to,answer:'',sources:g,failed:'NO_LIVE_SOURCES'}});continue}\n const src=live.map((s,i)=>'['+(i+1)+'] '+s.url+'\\n'+fs.readFileSync(dir+'/cache/'+s.key+'.txt','utf8')).join('\\n\\n');\n const prompt='Question: '+q+'\\nSources:\\n'+src+'\\nAnswer using ONLY these sources. Every claim needs a quote of <=40 words copied EXACTLY from a source, plus that source URL. Reply JSON only: {\"claims\":[{\"claim\":\"...\",\"quote\":\"...\",\"url\":\"...\"}]}';\n const res=await call(JSON.stringify({model:'meta-llama/llama-4-scout',prompt}));\n fs.writeFileSync(dir+'/answer.json',res.d);\n fs.appendFileSync(dir+'/receipts.jsonl',JSON.stringify({call:'answer',units:res.h['x-fal-billable-units']||null,at:Date.now()})+'\\n');\n out.push({json:{id,dir,q,to,answer:res.d,sources:g.map(({url,key,status,dead})=>({url,key,status,dead}))}});}\nreturn out;"
},
"id": "e1000000-0000-4000-8000-000000000006",
"name": "answer-call",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1520,
300
]
},
{
"parameters": {
"jsCode": "// Tolerant parse: a broken reply becomes one visible rejected row, never a crash.\nconst out=[];\nfor(const {json:j} of $input.all()){\n let raw=j.answer;try{raw=JSON.parse(raw).output||raw}catch(e){}\n let claims=[];const m=String(raw).match(/\\{[\\s\\S]*\\}/);\n try{claims=JSON.parse(m[0]).claims||[]}catch(e){}\n if(!claims.length){out.push({json:{...j,n:0,claim:'(no parseable claims returned)',quote:'',url:'',verdict:'REJECTED',reason:j.failed||'PARSE_FAIL'}});continue}\n claims.slice(0,12).forEach((c,n)=>out.push({json:{id:j.id,dir:j.dir,q:j.q,to:j.to,n:n+1,claim:String(c.claim||''),quote:String(c.quote||''),url:String(c.url||''),sources:j.sources}}));\n}\nreturn out;"
},
"id": "e1000000-0000-4000-8000-000000000007",
"name": "split-claims",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1740,
300
]
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// The centerpiece. Zero AI: the URL must be one we fetched, the quote must exist in those bytes.\nconst fs=require('fs');\nconst t0=Date.now(),{dir,quote,url,sources}=$json;\nif($json.verdict){fs.appendFileSync(dir+'/gate.jsonl',JSON.stringify({n:0,verdict:'REJECTED',reason:$json.reason,ms:0})+'\\n');return {json:$json};}\nconst norm=s=>s.replace(/\\[\\s*(?:\\d{1,3}|note\\s*\\d+|edit|citation needed)\\s*\\]/gi,' ').toLowerCase()\n .replace(/[\\u2018\\u2019\\u02BC]/g,\"'\").replace(/[\\u201C\\u201D]/g,'\"').replace(/[\\u2013\\u2014]/g,'-')\n .replace(/[\\u00AD\\u200B]/g,'').replace(/\\u00A0/g,' ').replace(/\\s+/g,' ')\n .replace(/\\s+([,.;:!?%)\\]}])/g,'$1').replace(/([(\\[{])\\s+/g,'$1')\n .replace(/\\s+('s\\b|'\\b)/g,'$1').replace(/\\s*-\\s*/g,'-').replace(/\\b([a-z])\\s+(\\d)\\b/g,'$1$2').trim();\nconst src=(sources||[]).find(s=>s.url===url||s.url===url.replace(/\\/$/,'')||s.url+'/'===url);\nlet verdict='VERIFIED',reason='';\nif(!src){verdict='REJECTED';reason='UNKNOWN_URL'}\nelse if(src.dead){verdict='REJECTED';reason='DEAD_URL:'+src.status}\nelse if(!quote||!norm(fs.readFileSync(dir+'/cache/'+src.key+'.txt','utf8')).includes(norm(quote))){verdict='REJECTED';reason='QUOTE_NOT_FOUND'}\nconst ms=Date.now()-t0;\nfs.appendFileSync(dir+'/gate.jsonl',JSON.stringify({n:$json.n,verdict,reason,ms,url})+'\\n');\nreturn {json:{...$json,verdict,reason,ms}};"
},
"id": "e1000000-0000-4000-8000-000000000008",
"name": "cite-gate",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1960,
300
]
},
{
"parameters": {
"jsCode": "// Report + stats. Price comes from the env var set on camera from the pricing page that day.\nconst fs=require('fs');\nconst price=Number($env.EP10_PRICE_PER_REQ);\nconst groups={};for(const {json:c} of $input.all())(groups[c.id]=groups[c.id]||[]).push(c);\nconst out=[];\nfor(const id in groups){const g=groups[id],{dir,q,to}=g[0];\n const rec=fs.readFileSync(dir+'/receipts.jsonl','utf8').trim().split('\\n').map(JSON.parse);\n const ver=g.filter(c=>c.verdict==='VERIFIED'),rej=g.filter(c=>c.verdict!=='VERIFIED');\n const byReason=rej.reduce((a,c)=>(a[c.reason||'?']=(a[c.reason||'?']||0)+1,a),{});\n const srcs=g[0].sources||[],dead=srcs.filter(s=>s.dead&&s.status>=400&&s.status<500);\n const row=c=>c.verdict==='VERIFIED'?'<li class=\"ok\">✔ '+c.claim+'<blockquote>\"'+c.quote+'\" — <a href=\"'+c.url+'\">'+c.url+'</a></blockquote></li>':'<li class=\"no\"><s>'+c.claim+'</s> <b>['+c.reason+']</b></li>';\n const html='<div class=\"ribbon\">SAMPLE · DEMO DATA · BUILT ON CAMERA</div><h1>'+q+'</h1><ol>'+g.map(row).join('')+'</ol><p>'+ver.length+' verified · '+rej.length+' rejected · '+dead.length+' source(s) the model invented · '+rec.length+' API calls · $'+(rec.length*price).toFixed(3)+'</p>'+(dead.length?'<p class=\"no\">Proposed but never existed: '+dead.map(s=>s.url).join(', ')+'</p>':'');\n fs.writeFileSync(dir+'/report.html','<style>body{font-family:sans-serif;max-width:720px;margin:2em auto}.ok{color:#060}.no{color:#b00}.ribbon{background:#c00;color:#fff;padding:6px;text-align:center;font-weight:bold}</style>'+html);\n fs.writeFileSync(dir+'/stats.json',JSON.stringify({id,q,claims:g.length,verified:ver.length,rejected:rej.length,byReason,sources:srcs.length,deadSources:dead.length,deadUrls:dead.map(s=>s.url+' ['+s.status+']'),calls:rec.length,cost:rec.length*price},null,1));\n out.push({json:{id,q,to,subject:('Verified research: '+q).slice(0,78),html,verified:ver.length,rejected:rej.length}});}\nreturn out;"
},
"id": "e1000000-0000-4000-8000-000000000009",
"name": "report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2180,
300
]
},
{
"parameters": {
"fromEmail": "Ships Itself Research <you@example.com>",
"toEmail": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"emailFormat": "html",
"html": "={{ $json.html }}",
"options": {}
},
"id": "e1000000-0000-4000-8000-000000000010",
"name": "Send report",
"type": "n8n-nodes-base.emailSend",
"typeVersion": 2.1,
"position": [
2400,
300
],
"credentials": {
"smtp": {
"name": "<your credential>"
}
}
}
],
"connections": {
"research-intake": {
"main": [
[
{
"node": "run-init",
"type": "main",
"index": 0
}
]
]
},
"run-init": {
"main": [
[
{
"node": "plan-call",
"type": "main",
"index": 0
}
]
]
},
"plan-call": {
"main": [
[
{
"node": "split-urls",
"type": "main",
"index": 0
}
]
]
},
"split-urls": {
"main": [
[
{
"node": "fetch-page",
"type": "main",
"index": 0
}
]
]
},
"fetch-page": {
"main": [
[
{
"node": "page-text",
"type": "main",
"index": 0
}
]
]
},
"page-text": {
"main": [
[
{
"node": "answer-call",
"type": "main",
"index": 0
}
]
]
},
"answer-call": {
"main": [
[
{
"node": "split-claims",
"type": "main",
"index": 0
}
]
]
},
"split-claims": {
"main": [
[
{
"node": "cite-gate",
"type": "main",
"index": 0
}
]
]
},
"cite-gate": {
"main": [
[
{
"node": "report",
"type": "main",
"index": 0
}
]
]
},
"report": {
"main": [
[
{
"node": "Send report",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
smtp
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Research Agent (ep10). Uses formTrigger, emailSend. Event-driven trigger; 11 nodes.
Source: https://github.com/Ships-Itself/builds/blob/main/ep10-research-agent/workflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Memorybufferwindow Workflow. Uses emailSend, httpRequest, executeWorkflowTrigger, formTrigger. Event-driven trigger; 45 nodes.
How it Works
Template Nodes Example. Uses CUSTOM, formTrigger, executeWorkflowTrigger, chatTrigger. Event-driven trigger; 70 nodes.
Generate research-backed article with n8n
This end-to-end AI-powered recruitment automation workflow helps HR and talent acquisition teams automate the complete hiring pipeline—from resume intake and parsing to GPT-4-based evaluation, TA appr