This workflow corresponds to n8n.io template #16798 — we link there as the canonical source.
This workflow follows the Form Trigger → Google Sheets 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 →
{
"id": "jOekaAQCWlsGwTag",
"name": "Tender Document Analyser (Template)",
"tags": [],
"nodes": [
{
"id": "534003b7-b874-49ab-af3d-3478ab7aa61f",
"name": "On form submission",
"type": "n8n-nodes-base.formTrigger",
"position": [
496,
2464
],
"parameters": {
"options": {},
"formTitle": "Upload Tender Document",
"formFields": {
"values": [
{
"fieldType": "file",
"fieldLabel": "Upload Invoice Here"
}
]
},
"formDescription": "Upload a tender / RFT PDF to extract structured data and make it chat-searchable."
},
"typeVersion": 2.5
},
{
"id": "c577b840-187f-4bea-bbd1-7718bd612226",
"name": "Generate Doc ID",
"type": "n8n-nodes-base.code",
"position": [
720,
2464
],
"parameters": {
"jsCode": "const item = $input.first();\n\n// Filename is in json, not binary\n// Key: \"Upload Invoice Here\" \u2192 array \u2192 first item \u2192 filename\nconst formData = item.json?.['Upload Invoice Here'];\nconst fileInfo = Array.isArray(formData) \n ? formData[0] \n : formData;\n\nconst fileName = \n fileInfo?.filename ||\n fileInfo?.fileName ||\n \"unknown.pdf\";\n\nconst docId = fileName\n .replace(/\\.pdf$/i, \"\")\n .toUpperCase()\n .replace(/\\s+/g, \"-\");\n\n// Binary is stored under \"Upload_Invoice_Here\" key\nconst binaryData = item.binary?.['Upload_Invoice_Here'];\n\nreturn [{\n json: {\n doc_id: docId,\n file_name: fileName,\n uploaded_at: new Date().toISOString()\n },\n binary: {\n data: binaryData\n }\n}];"
},
"typeVersion": 2
},
{
"id": "cc8c3bdc-0254-4fa7-a4f6-e89206f0e51f",
"name": "Parse a document",
"type": "@llamaindex/n8n-nodes-llamacloud.llamaParsePlatform",
"position": [
944,
2368
],
"parameters": {
"tier": "agentic"
},
"credentials": {
"llamaParseApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "ab72a189-88f5-4651-b50f-e544e48739b0",
"name": "Split a file",
"type": "@llamaindex/n8n-nodes-llamacloud.llamaParsePlatform",
"position": [
944,
2176
],
"parameters": {
"resource": "splitting",
"categoriesUi": {
"categories": [
{
"name": "project_summary",
"description": "Cover page content including RFT number, contract number, project title, issuing authority name, contact person name, phone, email and document date."
},
{
"name": "scope_and_timeline",
"description": "Purpose of procurement, scope of work, deliverables, specifications overview and all key dates including issue date, questions deadline, submission deadline, award date and delivery date."
},
{
"name": "submission_rules",
"description": "Instructions for bidders, how to submit the tender, mandatory submission requirements, addenda rules, alteration and withdrawal rules and contact for questions."
},
{
"name": "compliance_terms",
"description": "Terms and conditions including insurance requirements, WSIB, health and safety, AODA accessibility, conflict of interest, indemnification and contract termination clauses."
},
{
"name": "specification_checklist",
"description": "Itemized specification form with list of required features or items. Each item has a description and Yes or No compliance indicator with technical dimensions and material standards."
},
{
"name": "bid_form",
"description": "Form of tender, supplier declaration, pricing table, HST registration number, WSIB account number, signature block and irrevocability statement."
}
]
}
},
"credentials": {
"llamaParseApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "4631cda6-869c-4c8d-a567-f6fd48e79f05",
"name": "Extract structured data from a document",
"type": "@llamaindex/n8n-nodes-llamacloud.llamaParsePlatform",
"position": [
1136,
2768
],
"parameters": {
"resource": "extracting",
"dataSchema": "{\n \"type\": \"object\",\n \"properties\": {\n \"rft_number\": {\n \"type\": \"string\",\n \"description\": \"The RFT or contract number e.g. CTY-AH-2024-8\"\n },\n \"project_title\": {\n \"type\": \"string\",\n \"description\": \"Full title of the project or procurement\"\n },\n \"issuing_authority\": {\n \"type\": \"string\",\n \"description\": \"Name of the municipality or organization issuing the tender\"\n },\n \"contact_name\": {\n \"type\": \"string\",\n \"description\": \"Name of the contact person for questions\"\n },\n \"contact_email\": {\n \"type\": \"string\",\n \"description\": \"Email address of the RFT contact person\"\n },\n \"contact_phone\": {\n \"type\": \"string\",\n \"description\": \"Phone number of the RFT contact person\"\n },\n \"submission_deadline\": {\n \"type\": \"string\",\n \"description\": \"Date and time by which tenders must be submitted\"\n },\n \"questions_deadline\": {\n \"type\": \"string\",\n \"description\": \"Deadline for submitting questions or clarifications\"\n },\n \"award_date\": {\n \"type\": \"string\",\n \"description\": \"Expected date of contract award\"\n },\n \"delivery_date\": {\n \"type\": \"string\",\n \"description\": \"Expected delivery or project completion date\"\n },\n \"insurance_gl_amount\": {\n \"type\": \"number\",\n \"description\": \"Required commercial general liability insurance amount in dollars\"\n },\n \"wsib_required\": {\n \"type\": \"boolean\",\n \"description\": \"Whether WSIB certificate is required\"\n },\n \"bond_required\": {\n \"type\": \"boolean\",\n \"description\": \"Whether a bid bond is required\"\n },\n \"bond_percentage\": {\n \"type\": \"number\",\n \"description\": \"Bid bond percentage of total tender price if required\"\n },\n \"award_basis\": {\n \"type\": \"string\",\n \"description\": \"Basis for awarding the contract e.g. lowest compliant bid\"\n },\n \"irrevocability_days\": {\n \"type\": \"number\",\n \"description\": \"Number of days the tender must remain irrevocable after submission\"\n },\n \"payment_terms\": {\n \"type\": \"string\",\n \"description\": \"Payment terms and conditions\"\n }\n }\n}"
},
"credentials": {
"llamaParseApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "87fd676a-fcd0-4fb6-93a1-7ec3e14fed84",
"name": "Normalize + Bid Score",
"type": "n8n-nodes-base.code",
"position": [
1344,
2768
],
"parameters": {
"jsCode": "const item = $input.first();\n\n// Extract result comes as stringified JSON\nlet extracted = {};\ntry {\n const raw = item.json.result || '{}';\n extracted = JSON.parse(raw);\n} catch(e) {\n extracted = {};\n}\n\n// Calculate days until deadline\nlet daysLeft = 999;\ntry {\n const deadlineStr = extracted.submission_deadline || '';\n const deadline = new Date(deadlineStr);\n const today = new Date();\n if (!isNaN(deadline)) {\n daysLeft = Math.floor((deadline - today) / (1000 * 60 * 60 * 24));\n }\n} catch(e) {}\n\n// Bid scoring\nlet score = 5;\nlet flags = [];\n\nif (daysLeft > 7) { score += 2; }\nif (daysLeft >= 0 && daysLeft < 3) { score -= 3; flags.push(\"\u26a0\ufe0f Deadline very tight\"); }\nif (daysLeft < 0) { score = 0; flags.push(\"\u274c Deadline passed\"); }\n\nconst insurance = Number(extracted.insurance_gl_amount) || 0;\nif (insurance > 0 && insurance <= 5000000) { score += 1; }\nif (insurance > 10000000) { score -= 1; flags.push(\"\u26a0\ufe0f High insurance\"); }\nif (!extracted.bond_required) { score += 1; }\n\nscore = Math.min(10, Math.max(0, score));\n\nlet recommendation = '';\nif (score >= 7) { recommendation = '\u2705 BID'; }\nelse if (score >= 4) { recommendation = '\u26a0\ufe0f REVIEW'; }\nelse { recommendation = '\u274c SKIP'; }\n\nreturn [{\n json: {\n rft_number: extracted.rft_number || '',\n project_title: extracted.project_title || '',\n issuing_authority: extracted.issuing_authority || '',\n contact_name: extracted.contact_name || '',\n contact_email: extracted.contact_email || '',\n contact_phone: extracted.contact_phone || '',\n submission_deadline: extracted.submission_deadline || '',\n questions_deadline: extracted.questions_deadline || '',\n award_date: extracted.award_date || '',\n delivery_date: extracted.delivery_date || '',\n insurance_gl: extracted.insurance_gl_amount || '',\n wsib_required: extracted.wsib_required || false,\n bond_required: extracted.bond_required || false,\n bond_percentage: extracted.bond_percentage || 0,\n award_basis: extracted.award_basis || '',\n irrevocability_days: extracted.irrevocability_days || 90,\n days_until_deadline: daysLeft,\n bid_score: score,\n recommendation: recommendation,\n flags: flags.join(', '),\n status: 'New',\n rag_status: 'Pending'\n }\n}];"
},
"typeVersion": 2
},
{
"id": "13643274-757f-40bf-b0a1-9f58f7e87f07",
"name": "Prepare RAG Payload",
"type": "n8n-nodes-base.code",
"position": [
1392,
2272
],
"parameters": {
"jsCode": "const items = $input.all();\n\n// Separate Parse item (has \"text\") from Split items (have \"category\")\nconst parseItem = items.find(\n item => item.json.text !== undefined\n);\nconst splitItems = items.filter(\n item => item.json.category !== undefined\n);\n\nconst fullText = parseItem?.json?.text || '';\nconst docId = $('Generate Doc ID').first().json.doc_id;\nconst fileName = $('Generate Doc ID').first().json.file_name;\n\nconst segments = splitItems.map(item => ({\n category: item.json.category || 'uncategorized',\n pages: item.json.pages || []\n}));\n\nreturn [{\n json: {\n doc_id: docId,\n file_name: fileName,\n full_text: fullText,\n segments: segments\n }\n}];"
},
"typeVersion": 2
},
{
"id": "97496994-5b8b-44c8-956d-e1c1971d6064",
"name": "Merge",
"type": "n8n-nodes-base.merge",
"position": [
1168,
2272
],
"parameters": {},
"typeVersion": 3.2
},
{
"id": "cad31211-c8a4-4865-a3a2-1a5b0e2fad30",
"name": "Carve Text by Section",
"type": "n8n-nodes-base.code",
"position": [
1616,
2272
],
"parameters": {
"jsCode": "const item = $input.first();\n\nconst fullText = item.json.full_text || '';\nconst segments = item.json.segments || [];\nconst doc_id = item.json.doc_id || '';\nconst fileName = item.json.file_name || '';\n\n// Split full text by page number markers\n// LlamaParse embeds <page_number>N</page_number> tags\nconst pagePattern = /<page_number>(\\d+)<\\/page_number>/gi;\nconst pageTexts = {};\n\nlet lastPage = 1;\nlet lastIndex = 0;\nlet match;\n\n// Find all page markers and extract text per page\nconst allMatches = [...fullText.matchAll(/<page_number>(\\d+)<\\/page_number>/gi)];\n\nallMatches.forEach((m, idx) => {\n const pageNum = parseInt(m[1]);\n const startIdx = lastIndex;\n const endIdx = m.index;\n\n if (!pageTexts[lastPage]) pageTexts[lastPage] = '';\n pageTexts[lastPage] += fullText.slice(startIdx, endIdx);\n\n lastPage = pageNum;\n lastIndex = m.index + m[0].length;\n});\n\n// Add remaining text after last marker\nif (!pageTexts[lastPage]) pageTexts[lastPage] = '';\npageTexts[lastPage] += fullText.slice(lastIndex);\n\n// Now carve text per segment using EXACT page content\nconst results = [];\n\nsegments.forEach((seg) => {\n const pages = seg.pages || [];\n if (pages.length === 0) return;\n\n // Get exact text for these pages\n let sectionText = pages\n .map(p => pageTexts[p] || '')\n .join('\\n\\n')\n .trim();\n\n if (sectionText.length < 30) return;\n\n // Semantic chunking by paragraph\n const paragraphs = sectionText\n .split(/\\n\\n+/)\n .map(p => p.trim())\n .filter(p => p.length > 30);\n\n const maxChunkSize = 600;\n let currentChunk = '';\n let chunkIndex = 0;\n\n paragraphs.forEach((para) => {\n if (currentChunk.length > 0 &&\n (currentChunk + '\\n\\n' + para).length > maxChunkSize) {\n results.push({\n json: {\n doc_id,\n file_name: fileName,\n section: seg.category,\n chunk_index: chunkIndex,\n chunk_text: currentChunk,\n page_ref: pages.join(', '),\n uploaded_at: new Date().toISOString()\n }\n });\n chunkIndex++;\n currentChunk = para;\n } else {\n currentChunk = currentChunk\n ? currentChunk + '\\n\\n' + para\n : para;\n }\n });\n\n if (currentChunk.length > 30) {\n results.push({\n json: {\n doc_id,\n file_name: fileName,\n section: seg.category,\n chunk_index: chunkIndex,\n chunk_text: currentChunk,\n page_ref: pages.join(', '),\n uploaded_at: new Date().toISOString()\n }\n });\n }\n});\n\nreturn results;"
},
"typeVersion": 2
},
{
"id": "82468d1e-93d1-4b1b-82ee-dcf3de92c9ae",
"name": "Loop Over Items",
"type": "n8n-nodes-base.splitInBatches",
"position": [
1808,
2272
],
"parameters": {
"options": {}
},
"typeVersion": 3
},
{
"id": "27872a53-f8f8-412e-8a2c-23c007e93ddc",
"name": "Gemini \u2014 Embed Chunk",
"type": "n8n-nodes-base.httpRequest",
"position": [
2064,
2176
],
"parameters": {
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=YOUR_TOKEN_HERE",
"method": "POST",
"options": {},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "model",
"value": "models/gemini-embedding-001"
},
{
"name": "content",
"value": "={{ { \"parts\": [{ \"text\": $json.chunk_text }] } }}"
},
{
"name": "outputDimensionality",
"value": "768"
},
{
"name": "taskType",
"value": "RETRIEVAL_DOCUMENT"
}
]
}
},
"typeVersion": 4.4
},
{
"id": "09d1d0be-ea4c-4b97-b292-aee531df3c00",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
896,
2016
],
"parameters": {
"width": 1568,
"height": 508,
"content": "## \ud83e\uddec Pipeline 2 \u2014 RAG Ingestion\nSplits the document into semantic sections, chunks each section along paragraph boundaries (never mid-clause), embeds every chunk, and stores it in Supabase tagged with a doc_id.\n\n**Nodes:** Split a file + Parse a document \u2192 Merge \u2192 Prepare RAG Payload \u2192 Carve Text by Section \u2192 Loop Over Items \u2192 Gemini Embed \u2192 Insert Embed Vectors\n\n"
},
"typeVersion": 1
},
{
"id": "5d8074b3-fb9c-406f-b5b3-0b2e096bbe76",
"name": "Tender Tracker",
"type": "n8n-nodes-base.googleSheets",
"position": [
1568,
2768
],
"parameters": {
"operation": "appendOrUpdate",
"sheetName": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultUrl": "",
"cachedResultName": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": "",
"cachedResultUrl": "",
"cachedResultName": ""
}
},
"credentials": {
"googleSheetsOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 4.7
},
{
"id": "56126f53-3bb5-42d8-b0fa-6748857b7275",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
1008,
2576
],
"parameters": {
"width": 816,
"height": 356,
"content": "## \ud83d\udcca Pipeline 1 \u2014 Document Intelligence & Tracker\nParses the uploaded PDF, extracts key structured fields, and logs one row per document to a Google Sheets tracker.\n\n**Nodes:** Parse a document \u2192 Extract structured data \u2192 Normalize + Bid Score \u2192 Tender Tracker\n\n"
},
"typeVersion": 1
},
{
"id": "c782b45b-76c3-42c4-a429-2e0c1149782f",
"name": "Extract Question",
"type": "n8n-nodes-base.code",
"position": [
768,
3200
],
"parameters": {
"jsCode": "const item = $input.first();\n\n// Chat trigger sends message in chatInput field\nconst question = item.json?.chatInput ||\n item.json?.message?.text ||\n item.json?.input || '';\n\n// \u26a0\ufe0f CONFIGURE ME\n// This demo answers questions against ONE fixed document.\n// Replace the string below with the doc_id you want this chat\n// to search against (must match a doc_id already stored in\n// Supabase by the RAG Ingestion Pipeline).\n//\n// To support multiple documents / let the user pick one, replace\n// this hardcoded value with a lookup (e.g. a Supabase query for\n// available doc_ids, or ask the user to specify it in their message).\nconst docId = 'YOUR_DOC_ID_HERE';\n\nreturn [{\n json: {\n question: question,\n doc_id: docId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "94cbeac8-d54d-4430-aba0-e26ebbf6fa32",
"name": "Gemini \u2014 Embed Question",
"type": "n8n-nodes-base.httpRequest",
"position": [
992,
3200
],
"parameters": {
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent?key=YOUR_TOKEN_HERE",
"method": "POST",
"options": {},
"jsonBody": "={\n \"model\": \"models/gemini-embedding-001\",\n \"content\": {\n \"parts\": [\n {\n \"text\": \"={{ $json.question }}\"\n }\n ]\n },\n \"outputDimensionality\": 768,\n\"taskType\": \"RETRIEVAL_QUERY\"\n}",
"sendBody": true,
"specifyBody": "json"
},
"typeVersion": 4.4
},
{
"id": "1a2f5522-1f49-4ee9-8309-2a12cfb9325a",
"name": "Vector Search",
"type": "n8n-nodes-base.httpRequest",
"position": [
1200,
3200
],
"parameters": {
"url": "https://YOUR_SUPABASE_PROJECT.supabase.co/rest/v1/rpc/match_tender_chunks",
"method": "POST",
"options": {},
"jsonBody": "={\n \"query_embedding\": \"{{ '[' + $('Gemini \u2014 Embed Question').item.json.embedding.values.join(',') + ']' }}\",\n \"match_doc_id\": \"{{ $('Extract Question').item.json.doc_id }}\",\n \"match_count\": 8\n}",
"sendBody": true,
"sendHeaders": true,
"specifyBody": "json",
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "YOUR_SUPABASE_ANON_KEY"
},
{
"name": "Authorization",
"value": "Bearer YOUR_TOKEN_HERE"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
}
},
"typeVersion": 4.4
},
{
"id": "b9626bbd-4f0a-4fc8-a566-c93aeb8fd7c9",
"name": "Build Context",
"type": "n8n-nodes-base.code",
"position": [
1408,
3200
],
"parameters": {
"jsCode": "const items = $input.all();\n\n// Build context from top 3 chunks\nconst contextParts = items.map((item, idx) => {\n const section = item.json.section || 'unknown';\n const chunkText = item.json.chunk_text || '';\n const similarity = item.json.similarity || 0;\n return `[Source ${idx + 1} \u2014 Section: ${section} | Similarity: ${Math.round(similarity * 100)}%]:\\n${chunkText}`;\n});\n\nconst context = contextParts.join('\\n\\n---\\n\\n');\nconst sources = [...new Set(items.map(i => i.json.section))].join(', ');\n\n// Get original question\nconst question = $('Extract Question').first().json.question;\nconst docId = $('Extract Question').first().json.doc_id;\n\nreturn [{\n json: {\n context,\n sources,\n question,\n doc_id: docId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "c16ff20d-3ead-4ed4-b0df-94ba803c0595",
"name": "DeepSeek AI Answer",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"position": [
1584,
3200
],
"parameters": {
"modelId": {
"__rl": true,
"mode": "list",
"value": "deepseek-v4-flash",
"cachedResultName": "deepseek-v4-flash"
},
"options": {
"system": "You are a tender document assistant for [YOUR COMPANY NAME]. Your job is to extract and present information from tender documents accurately.\n\nRules:\n- Answer ONLY from the provided context\n- ALWAYS structure your answer as bullet points\n- Include ALL relevant details from the context\n- Never summarize or omit details\n- If information appears in multiple sources, \n combine them into one complete answer\n- Always cite which section the answer came from\n- If information is not in context, say exactly:\n \"This information is not in the selected tender document\"\n- Never add information not present in the context",
"maxTokens": 2000,
"temperature": 0
},
"messages": {
"values": [
{
"content": "={{ 'Context:\\n' + $json.context + '\\n\\nQuestion: ' + $json.question }}"
}
]
}
},
"credentials": {
"anthropicApi": {
"name": "<your credential>"
}
},
"retryOnFail": true,
"typeVersion": 1
},
{
"id": "ed3d5e82-0f7a-4322-b77e-da77c11390ef",
"name": "Code in JavaScript",
"type": "n8n-nodes-base.code",
"position": [
1872,
3200
],
"parameters": {
"jsCode": "const item = $input.first();\n\nlet answer = '';\n\n// DeepSeek is returning content directly at top level\n// not nested inside choices[0].message.content\nconst content = item.json?.content || \n item.json?.choices?.[0]?.message?.content;\n\nif (typeof content === 'string') {\n answer = content;\n} else if (Array.isArray(content)) {\n // Find text block \u2014 skip thinking blocks\n const textBlock = content.find(b => b.type === 'text');\n answer = textBlock?.text || '';\n} else {\n answer = 'Sorry, could not generate an answer.';\n}\n\nconst sources = $('Build Context').first().json.sources;\n\nreturn [{\n json: {\n output: `${answer}\\n\\n\ud83d\udcc4 *Sources: ${sources}*`\n }\n}];"
},
"typeVersion": 2
},
{
"id": "ca638d82-5a2f-4248-8ab3-892c14af12be",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
544,
3008
],
"parameters": {
"width": 1728,
"height": 440,
"content": "## \ud83d\udcac Pipeline 3 \u2014 Conversational Chat (WhatsApp)\nIndependently triggered by an incoming message\n\n**Nodes:** WhatsApp Trigger \u2192 Extract Question \u2192 Gemini Embed Question \u2192 Vector Search (Supabase RPC) \u2192 Build Context \u2192 DeepSeek/LLM Answer \u2192 Format Reply \u2192 Send a text message\n\n"
},
"typeVersion": 1
},
{
"id": "165e4685-420e-433a-8c57-c6b1e7d77edc",
"name": "Insert Embed Vectors",
"type": "n8n-nodes-base.supabase",
"position": [
2288,
2272
],
"parameters": {
"tableId": "tender_chunks",
"fieldsUi": {
"fieldValues": [
{
"fieldId": "doc_id",
"fieldValue": "={{ $('Loop Over Items').item.json.doc_id }}"
},
{
"fieldId": "file_name",
"fieldValue": "={{ $('Loop Over Items').item.json.file_name }}"
},
{
"fieldId": "section",
"fieldValue": "={{ $('Loop Over Items').item.json.section }}"
},
{
"fieldId": "chunk_index",
"fieldValue": "={{ $('Loop Over Items').item.json.chunk_index }}"
},
{
"fieldId": "chunk_text",
"fieldValue": "={{ $('Loop Over Items').item.json.chunk_text }}"
},
{
"fieldId": "page_ref",
"fieldValue": "={{ $('Loop Over Items').item.json.page_ref }}"
},
{
"fieldId": "uploaded_at",
"fieldValue": "={{ $('Loop Over Items').item.json.uploaded_at }}"
},
{
"fieldId": "embedding",
"fieldValue": "={{ $json.embedding.values }}"
}
]
}
},
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "2578e9c2-92f5-4e7b-8c57-6700b15cf58a",
"name": "Overview",
"type": "n8n-nodes-base.stickyNote",
"position": [
-256,
1952
],
"parameters": {
"color": 4,
"width": 688,
"height": 1394,
"content": "## \ud83d\uddc2\ufe0f AI Tender Document Analyser (Powered by LlamaParse + RAG)\nThis workflow turns a long tender/RFT PDF into structured tracker data AND a searchable chat interface \u2014 in one pipeline. Upload a document once; extract it automatically and ask it questions forever after.\n\n### \ud83e\udde9 Why Semantic Chunking Is Important Here\nMost RAG workflows split documents by raw character count, which cuts legal clauses in half mid-sentence (a dollar amount ends up in one chunk, its conditions in the next). This workflow instead:\n\u2022 Uses LlamaParse's embedded page markers to extract EXACT section text (not an approximated line count)\n\u2022 Splits along paragraph/clause boundaries so every chunk stays one complete, self-contained requirement\n\u2022 This single change took retrieval accuracy from \"context does not contain this information\" to 100% on validated test questions\n\n### \ud83d\udd0d What It Does\nThis workflow:\n\u2022 Accepts a tender PDF via form upload\n\u2022 Classifies it, then extracts structured fields (deadlines, insurance, compliance, contacts) into a Google Sheet\n\u2022 Splits the document into semantic sections and stores embedded chunks in Supabase, isolated per document\n\u2022 Powers a Telegram chat bot that answers questions about the document, citing the exact section every time\n\n### \u2699\ufe0f How It Works (High-level)\n\u2022 User uploads a PDF via the form\n\u2022 LlamaParse Platform parses, classifies, and extracts structured fields\n\u2022 Extracted fields are logged to a Google Sheets tracker\n\u2022 In parallel, the document is semantically chunked and embedded \n\u2022 Chunks are stored in Supabase (pgvector) tagged with a doc_id\n\u2022 A Whatsapp message triggers the chat pipeline: question \u2192 embedded \u2192 vector search scoped to doc_id \u2192 context assembled \u2192 LLM answers strictly from that context \u2192 reply sent back to Whatsapp\n\n### \ud83e\udde0 Key Capabilities\n\u2022 Structure-aware parsing (not basic OCR) via LlamaParse Platform\n\u2022 Semantic, paragraph-aligned chunking \u2014 no clause ever split across chunks\n\u2022 Document-isolated vector search \u2014 multiple tenders never bleed into one answer\n\u2022 Deterministic answers \u2014 same question returns the same complete answer every run\n\u2022 Explicit \"not in document\" response instead of hallucinating when information is missing\n\u2022 Every answer cites the section it came from\n\n### \ud83d\udce6 Requirements\n\u2022 LlamaParse API key (cloud.llamaindex.ai)\n\u2022 Google Gemini API key (embeddings)\n\u2022 Supabase project with pgvector enabled \n\u2022 Google Sheets OAuth2 credential\n\u2022 Anthropic-compatible or DeepSeek chat completion credential (used for answer generation)\n\n### \ud83d\udca1 Example Use Cases\n\u2022 Government/municipal RFT and tender review for contractors\n\u2022 Any procurement team that reviews multiple RFPs/RFQs weekly\n\u2022 Turning any dense legal/compliance PDF into a queryable knowledge base\n\u2022 Cross-referencing scope, insurance, and deadline requirements without re-reading the source document\n\n### \ud83c\udfaf Output\n\u2022 Structured Google Sheets row per document (for tracking/comparison)\n\u2022 Conversational Whatsapp interface, permanently searchable per document\n\u2022 Source-cited, deterministic answers \u2014 never a guess"
},
"typeVersion": 1
},
{
"id": "e5271406-eaf5-4f4b-ab77-f28b3cc2ab3e",
"name": "WhatsApp Trigger",
"type": "n8n-nodes-base.whatsAppTrigger",
"position": [
576,
3200
],
"parameters": {
"options": {},
"updates": [
"messages"
]
},
"typeVersion": 1
},
{
"id": "1fb84e5b-cce1-44af-aecd-2cc56c9fba66",
"name": "Send message",
"type": "n8n-nodes-base.whatsApp",
"position": [
2080,
3200
],
"parameters": {
"operation": "send",
"additionalFields": {}
},
"typeVersion": 1.1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "d978e42d-5274-4203-94db-a9e22f65fe05",
"connections": {
"Merge": {
"main": [
[
{
"node": "Prepare RAG Payload",
"type": "main",
"index": 0
}
]
]
},
"Split a file": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Build Context": {
"main": [
[
{
"node": "DeepSeek AI Answer",
"type": "main",
"index": 0
}
]
]
},
"Vector Search": {
"main": [
[
{
"node": "Build Context",
"type": "main",
"index": 0
}
]
]
},
"Generate Doc ID": {
"main": [
[
{
"node": "Parse a document",
"type": "main",
"index": 0
},
{
"node": "Split a file",
"type": "main",
"index": 0
},
{
"node": "Extract structured data from a document",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Items": {
"main": [
[],
[
{
"node": "Gemini \u2014 Embed Chunk",
"type": "main",
"index": 0
}
]
]
},
"Extract Question": {
"main": [
[
{
"node": "Gemini \u2014 Embed Question",
"type": "main",
"index": 0
}
]
]
},
"Parse a document": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"WhatsApp Trigger": {
"main": [
[
{
"node": "Extract Question",
"type": "main",
"index": 0
}
]
]
},
"Code in JavaScript": {
"main": [
[
{
"node": "Send message",
"type": "main",
"index": 0
}
]
]
},
"DeepSeek AI Answer": {
"main": [
[
{
"node": "Code in JavaScript",
"type": "main",
"index": 0
}
]
]
},
"On form submission": {
"main": [
[
{
"node": "Generate Doc ID",
"type": "main",
"index": 0
}
]
]
},
"Prepare RAG Payload": {
"main": [
[
{
"node": "Carve Text by Section",
"type": "main",
"index": 0
}
]
]
},
"Insert Embed Vectors": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Carve Text by Section": {
"main": [
[
{
"node": "Loop Over Items",
"type": "main",
"index": 0
}
]
]
},
"Normalize + Bid Score": {
"main": [
[
{
"node": "Tender Tracker",
"type": "main",
"index": 0
}
]
]
},
"Gemini \u2014 Embed Chunk": {
"main": [
[
{
"node": "Insert Embed Vectors",
"type": "main",
"index": 0
}
]
]
},
"Gemini \u2014 Embed Question": {
"main": [
[
{
"node": "Vector Search",
"type": "main",
"index": 0
}
]
]
},
"Extract structured data from a document": {
"main": [
[
{
"node": "Normalize + Bid Score",
"type": "main",
"index": 0
}
]
]
}
}
}
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.
anthropicApigoogleSheetsOAuth2ApillamaParseApisupabaseApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow lets you upload a tender/RFT PDF via an n8n form, extracts key fields into Google Sheets using LlamaParse, and makes the document chat-searchable by embedding section chunks with Google Gemini and storing them in Supabase for WhatsApp-based Q&A with…
Source: https://n8n.io/workflows/16798/ — 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.
The competitive edge, delivered. This Customer Intelligence Engine simultaneously analyzes the web, Reddit, and X/Twitter to generate a professional, actionable executive briefing.
Note: Now includes an Apify alternative for Rapid API (Some users can't create new accounts on Rapid API, so I have added an alternative for you. But immediately you are able to get access to Rapid AP
This system automates LinkedIn lead generation and enrichment in six clear stages: Lead Collection (via Apollo.io) Automatically pulls leads based on keywords, roles, or industries using Apollo’s API.
Automated Lead Generation. Uses formTrigger, openAi, googleSheetsTrigger, googleSheets. Event-driven trigger; 55 nodes.
This workflow enriches leads from Google Sheets by finding and validating LinkedIn profile URLs with SerpAPI and Anthropic, then scraping matched profiles with Apify to extract a relevant company and