This workflow corresponds to n8n.io template #18202 — we link there as the canonical source.
This workflow follows the Chainllm → Gmail 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": "e4198188-a655-5c",
"name": "Turn meeting transcripts into action items, minutes and follow-up drafts with AI",
"nodes": [
{
"id": "8f1a020a-d158-5dfe-b566-0992f221db9d",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1180,
-260
],
"parameters": {
"width": 520,
"height": 1520,
"content": "## Turn meeting transcripts into action items, minutes and follow-up drafts with AI\n\n### How it works\n\nDrop a meeting transcript into a Google Drive folder \u2014 a Google Meet transcript doc, a .txt export, or a .vtt/.srt subtitle file \u2014 and a minute later you have: structured minutes, every decision that was made, an **action-item list with owners, due dates and priorities**, and a follow-up email **waiting as a draft in your Gmail** \u2014 you review and hit send, nothing goes out on its own.\n\nThe transcript is cleaned deterministically in code first (timestamps, subtitle indexes and tags stripped, speakers detected, word count checked \u2014 files that aren't transcripts are skipped, not hallucinated about). AI then extracts a strict JSON schema \u2014 title, summary bullets, decisions, action items, open questions \u2014 under hard rules: only what was actually said, owners exactly as named or null, a due date only when one was spoken. A validation node enforces the schema before anything is written.\n\nEvery action item is also appended to a **Google Sheets tracker** (task, owner, due, priority, status), so actions from every meeting land in one running list instead of dying in the minutes. A Telegram ping tells you the draft is ready.\n\n### Setup steps\n\nTakes \u224810 minutes.\n\n- Connect Google Drive in **When Transcript Arrives in Drive** and **Download Transcript from Drive**, and pick the folder your transcripts land in (Google Meet's \"Meet Recordings\" folder works as-is).\n- Connect an OpenAI (or compatible) credential in **Use OpenAI GPT-4 Model**.\n- Connect Gmail in **Draft Follow-Up Email** \u2014 it only ever creates drafts.\n- Create a spreadsheet with columns `Date, Meeting, Task, Owner, Due, Priority, Status` and select it in **Append Actions to Sheet Tracker**.\n- For the Telegram ping: create a bot with @BotFather, add its credential in **Post Action List to Telegram** and set your chat id (message @userinfobot to get it). Delete the node if you don't want pings.\n- Drop a transcript file into the folder and watch the draft appear in Gmail.\n\n### Customization\n\n- **Your minutes format:** the email/minutes layout is one Code node (**Compile Minutes and Drafts**) \u2014 reorder sections, add your logo, change the grouping.\n- **Extraction rules:** the JSON schema and its \"never invent\" rules live in **AI Extract Decisions & Actions** \u2014 add fields like `blockers`, `sentiment` or `next_meeting` and matching tracker columns.\n- **Auto-send instead of draft:** switch the Gmail node's operation from draft to send \u2014 only recommended once you trust the output.\n- **Other sources:** swap the Drive trigger for Dropbox, or feed transcripts in via webhook from Fireflies/Fathom/tl;dv \u2014 everything after the cleaning node is unchanged.\n- **Task tools:** replace the Sheets append with Todoist, ClickUp, Notion or Jira nodes \u2014 the action items are clean JSON by that point.\n\n*Works in any language your AI model reads.*"
},
"typeVersion": 1
},
{
"id": "cbe126d5-fa0a-5c3b-ab0d-9c3209435295",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-580,
-260
],
"parameters": {
"color": 7,
"width": 520,
"height": 360,
"content": "## Watch for new transcripts\n\nFires when a file lands in your transcripts folder and downloads it. Google Docs (Meet transcripts) are converted to plain text on the way down."
},
"typeVersion": 1
},
{
"id": "2a7ac97f-44b7-5ca4-a1f7-82dd4c3e428b",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-4,
-260
],
"parameters": {
"color": 7,
"width": 1000,
"height": 560,
"content": "## Read and clean the transcript\n\nExtracts the text, strips subtitle timestamps/indexes/tags in code, detects speakers and counts words. Files that are too short to be a real transcript are skipped \u2014 no AI cost, no hallucinated minutes."
},
"typeVersion": 1
},
{
"id": "955cc94c-5413-5e58-9c68-6f6dc792c5e1",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1052,
-260
],
"parameters": {
"color": 7,
"width": 760,
"height": 540,
"content": "## Extract decisions and actions with AI\n\nThe AI returns a strict JSON schema \u2014 title, summary, decisions, action items with owner/due/priority, open questions \u2014 under hard rules: only what was said, owners exactly as named or null, due dates only when spoken. The validation node enforces the schema."
},
"typeVersion": 1
},
{
"id": "4a0959cd-ee71-5a57-a353-ec9d312c5157",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1868,
-260
],
"parameters": {
"color": 7,
"width": 520,
"height": 360,
"content": "## Draft the follow-up \u2014 you hit send\n\nBuilds polished HTML minutes (summary, decisions, actions grouped by owner, open questions) and creates a Gmail DRAFT. Nothing is ever sent automatically \u2014 you review, address it and hit send."
},
"typeVersion": 1
},
{
"id": "df620652-ccb2-5692-a75f-b7ceb7cebad6",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
2444,
-260
],
"parameters": {
"color": 7,
"width": 760,
"height": 360,
"content": "## Ping the team and track every action\n\nA compact Telegram message with the action list, then every action item is appended to a running Google Sheets tracker \u2014 tasks from every meeting in one place instead of dying in the minutes."
},
"typeVersion": 1
},
{
"id": "10f76a60-6939-548f-a680-4681080f2b65",
"name": "When Transcript Arrives in Drive",
"type": "n8n-nodes-base.googleDriveTrigger",
"position": [
-498,
-104.9
],
"parameters": {
"event": "fileCreated",
"options": {},
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"triggerOn": "specificFolder",
"folderToWatch": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 1
},
{
"id": "a0efa6e3-e7f6-5011-bbce-2118196cbf5d",
"name": "Download Transcript from Drive",
"type": "n8n-nodes-base.googleDrive",
"position": [
-258,
-104.9
],
"parameters": {
"fileId": {
"__rl": true,
"mode": "id",
"value": "={{ $json.id }}"
},
"options": {
"fileName": "={{ $json.name }}",
"googleFileConversion": {
"conversion": {
"docsToFormat": "text/plain"
}
}
},
"operation": "download"
},
"typeVersion": 3
},
{
"id": "304a674f-7388-5d3f-b853-0ab5d616c4eb",
"name": "Extract Text from Transcript",
"type": "n8n-nodes-base.extractFromFile",
"position": [
78,
-104.9
],
"parameters": {
"options": {},
"operation": "text"
},
"typeVersion": 1
},
{
"id": "7b32ce4f-3995-59cf-a45d-bf9040b21f22",
"name": "Sanitize Transcript Text",
"type": "n8n-nodes-base.code",
"position": [
318,
-104.9
],
"parameters": {
"jsCode": "const raw = String($json.data ?? '');\n\n// Deterministic cleanup: works for .vtt, .srt, Meet doc exports and plain text.\nlet t = raw.replace(/^WEBVTT.*$/m, '');\nt = t.replace(/^\\d+\\s*$/gm, ''); // srt cue indexes\nt = t.replace(/^.*-->.*$/gm, ''); // timestamp lines\nt = t.replace(/<[^>]+>/g, ''); // vtt/html tags\nt = t.replace(/\\r/g, '').replace(/\\n{3,}/g, '\\n\\n').trim();\n\nconst words = t.split(/\\s+/).filter(Boolean).length;\nconst speakers = [...new Set(\n [...t.matchAll(/^([A-Z][\\w .'-]{1,30}):/gm)].map(m => m[1].trim())\n)].slice(0, 20);\n\nconst meta = $('When Transcript Arrives in Drive').first().json;\nconst fileName = String(meta.name || 'meeting').replace(/\\.[a-z0-9]+$/i, '');\n\nreturn [{ json: {\n fileName,\n transcript: t.slice(0, 60000),\n words,\n speakers,\n tooShort: words < 100,\n} }];"
},
"typeVersion": 2
},
{
"id": "1f832c0c-a1ab-50a3-a440-86ce7729a2c3",
"name": "Validate Transcript Content",
"type": "n8n-nodes-base.if",
"position": [
558,
-104.9
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "cond-is-transcript",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
},
"leftValue": "={{ !$json.tooShort }}",
"rightValue": ""
}
]
}
},
"typeVersion": 2.2
},
{
"id": "7f8eadd3-6a93-54fb-a029-09fd03b9ff70",
"name": "Ignore Non-Transcripts",
"type": "n8n-nodes-base.noOp",
"position": [
798,
95.1
],
"parameters": {},
"typeVersion": 1
},
{
"id": "36ad5ee0-e9dc-51c9-b0f5-25443ce9a9af",
"name": "AI Extract Decisions & Actions",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"position": [
1134,
-104.9
],
"parameters": {
"text": "=Meeting file: {{ $json.fileName }}\nSpeakers detected: {{ $json.speakers.join(', ') || 'unknown' }} ({{ $json.words }} words)\n\nTranscript:\n{{ $json.transcript }}",
"messages": {
"messageValues": [
{
"message": "You extract structured minutes from a meeting transcript. Return ONLY one JSON object, no markdown fences, with exactly these keys:\n{\"meeting_title\": string, \"summary\": string[], \"decisions\": string[], \"action_items\": [{\"task\": string, \"owner\": string|null, \"due\": \"YYYY-MM-DD\"|null, \"priority\": \"high\"|\"normal\"}], \"open_questions\": string[]}\n\nHard rules:\n- Use ONLY what is actually in the transcript. Never invent tasks, owners or dates.\n- owner: the speaker's name exactly as it appears, or null if no one was clearly assigned.\n- due: an ISO date ONLY if a real date or deadline was spoken; otherwise null.\n- priority \"high\" only when urgency was explicit (\"today\", \"by Friday\", \"blocker\", \"urgent\").\n- summary: 3-5 short bullets. decisions: only things actually decided, not discussed.\n- Empty arrays are correct when nothing qualifies.\n- Write in the transcript's language."
}
]
},
"promptType": "define"
},
"typeVersion": 1.5
},
{
"id": "2a2d11bd-6c37-59c0-b8e1-121f49953b8c",
"name": "Use OpenAI GPT-4 Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"position": [
1134,
95.1
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "gpt-4o-mini",
"cachedResultName": "gpt-4o-mini"
},
"options": {}
},
"typeVersion": 1.2
},
{
"id": "dd88f97a-87bd-5cfa-8ed5-ca81c1b72335",
"name": "Confirm AI Extraction Validity",
"type": "n8n-nodes-base.code",
"position": [
1614,
-104.9
],
"parameters": {
"jsCode": "const src = $('Sanitize Transcript Text').first().json;\n\nlet s = String($input.first().json.text ?? '').trim()\n .replace(/^```(?:json)?/i, '').replace(/```\\s*$/, '').trim();\nconst a = s.indexOf('{'), b = s.lastIndexOf('}');\nif (a === -1 || b === -1) {\n throw new Error('The AI did not return a JSON object.');\n}\nconst p = JSON.parse(s.slice(a, b + 1));\n\nconst arr = (x) => (Array.isArray(x) ? x : []);\nconst actions = arr(p.action_items)\n .filter(i => i && typeof i.task === 'string' && i.task.trim())\n .map(i => ({\n task: i.task.trim(),\n owner: i.owner && String(i.owner).trim() ? String(i.owner).trim() : null,\n due: i.due && /^\\d{4}-\\d{2}-\\d{2}$/.test(String(i.due)) ? String(i.due) : null,\n priority: i.priority === 'high' ? 'high' : 'normal',\n }));\n\nreturn [{ json: {\n fileName: src.fileName,\n words: src.words,\n speakers: src.speakers,\n title: String(p.meeting_title || src.fileName).trim(),\n summary: arr(p.summary).map(String).slice(0, 6),\n decisions: arr(p.decisions).map(String),\n actions,\n openQuestions: arr(p.open_questions).map(String),\n nothingFound: !actions.length && !arr(p.decisions).length,\n} }];"
},
"typeVersion": 2
},
{
"id": "e1ef5e23-9319-51a8-b0e0-80ba146d9185",
"name": "Compile Minutes and Drafts",
"type": "n8n-nodes-base.code",
"position": [
1950,
-104.9
],
"parameters": {
"jsCode": "const m = $input.first().json;\nconst today = new Date().toISOString().slice(0, 10);\nconst esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');\n\nconst byOwner = {};\nfor (const a of m.actions) {\n const k = a.owner || 'Unassigned';\n (byOwner[k] = byOwner[k] || []).push(a);\n}\n\nconst actionRows = Object.entries(byOwner).map(([owner, items]) => items.map((a, i) => `\n <tr style=\"border-bottom:1px solid #d8dee4\">\n ${i === 0 ? `<td style=\"padding:6px 10px;font-weight:600;vertical-align:top\" rowspan=\"${items.length}\">${esc(owner)}</td>` : ''}\n <td style=\"padding:6px 10px\">${a.priority === 'high' ? '\ud83d\udd34 ' : ''}${esc(a.task)}</td>\n <td style=\"padding:6px 10px;white-space:nowrap;color:#57606a\">${a.due || '\u2014'}</td>\n </tr>`).join('')).join('');\n\nconst section = (title, items) => items.length\n ? `<h2 style=\"font-size:14px;margin:16px 0 4px;text-transform:uppercase;color:#57606a\">${title}</h2>\n <ul style=\"margin:4px 0;padding-left:20px\">${items.map(i => `<li style=\"margin:3px 0\">${esc(i)}</li>`).join('')}</ul>`\n : '';\n\nconst html = `<!doctype html><html><body style=\"margin:0;padding:0;background:#f6f8fa\">\n<div style=\"max-width:640px;margin:0 auto;padding:20px;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#24292f\">\n <div style=\"background:#fff;border:1px solid #d8dee4;border-radius:10px;padding:20px\">\n <h1 style=\"font-size:18px;margin:0 0 2px\">\ud83d\udccb ${esc(m.title)}</h1>\n <div style=\"font-size:12.5px;color:#57606a;margin-bottom:10px\">${today} \u00b7 ${m.speakers.length ? esc(m.speakers.join(', ')) + ' \u00b7 ' : ''}${m.words.toLocaleString('en-US')} words</div>\n ${section('Summary', m.summary)}\n ${section('Decisions', m.decisions)}\n ${m.actions.length ? `<h2 style=\"font-size:14px;margin:16px 0 4px;text-transform:uppercase;color:#57606a\">Action items</h2>\n <table style=\"width:100%;border-collapse:collapse;font-size:13.5px\">\n <tr style=\"text-align:left;color:#57606a;font-size:11.5px;text-transform:uppercase\">\n <th style=\"padding:4px 10px\">Owner</th><th style=\"padding:4px 10px\">Task</th><th style=\"padding:4px 10px\">Due</th>\n </tr>${actionRows}</table>` : ''}\n ${section('Open questions', m.openQuestions)}\n ${m.nothingFound ? '<div style=\"background:#fff8c5;border:1px solid #d4a72c;border-radius:8px;padding:10px 14px;margin-top:12px\">\u26a0 No clear decisions or action items were found \u2014 worth a manual look at the transcript.</div>' : ''}\n <div style=\"font-size:11.5px;color:#8b949e;margin-top:16px\">Generated from \"${esc(m.fileName)}\" \u2014 review before sending.</div>\n </div>\n</div></body></html>`;\n\nconst high = m.actions.filter(a => a.priority === 'high').length;\nconst tgList = m.actions.slice(0, 6)\n .map(a => `\u2022 ${a.task}${a.owner ? ' \u2014 ' + a.owner : ''}${a.due ? ' (' + a.due + ')' : ''}`)\n .join('\\n');\nconst tg = `\ud83d\udccb ${m.title}\\n${m.actions.length} action item${m.actions.length === 1 ? '' : 's'}${high ? ` (${high} high)` : ''}, ${m.decisions.length} decision${m.decisions.length === 1 ? '' : 's'}\\n\\n${tgList}${m.actions.length > 6 ? `\\n\u2026and ${m.actions.length - 6} more` : ''}${m.nothingFound ? '\u26a0 Nothing clearly actionable found \u2014 check the transcript.' : ''}\\n\\nFollow-up draft is in your Gmail \u2014 review and send.`;\n\nreturn [{ json: {\n ...m, date: today, html, tg,\n subject: `Minutes & actions: ${m.title} (${today})`,\n} }];"
},
"typeVersion": 2
},
{
"id": "3c9468db-5ff2-53fc-aff6-625468efef76",
"name": "Draft Follow-Up Email",
"type": "n8n-nodes-base.gmail",
"position": [
2190,
-104.9
],
"parameters": {
"message": "={{ $json.html }}",
"options": {},
"subject": "={{ $json.subject }}",
"resource": "draft"
},
"typeVersion": 2.1
},
{
"id": "eeda7a65-336d-5cd3-9670-239fdf1634c7",
"name": "Post Action List to Telegram",
"type": "n8n-nodes-base.telegram",
"position": [
2526,
-104.9
],
"parameters": {
"text": "={{ $('Compile Minutes and Drafts').item.json.tg }}",
"chatId": "YOUR_CHAT_ID",
"additionalFields": {
"appendAttribution": false
}
},
"typeVersion": 1.2
},
{
"id": "92f6ddd1-265d-5694-a68a-2242909abcc2",
"name": "Divide Actions for Tracker",
"type": "n8n-nodes-base.code",
"position": [
2766,
-104.9
],
"parameters": {
"jsCode": "const m = $('Compile Minutes and Drafts').first().json;\nreturn m.actions.map(a => ({ json: {\n Date: m.date,\n Meeting: m.title,\n Task: a.task,\n Owner: a.owner || '',\n Due: a.due || '',\n Priority: a.priority,\n Status: 'Open',\n} }));"
},
"typeVersion": 2
},
{
"id": "0f0a86cd-e6d9-5e8a-b176-2b43a83f34a8",
"name": "Append Actions to Sheet Tracker",
"type": "n8n-nodes-base.googleSheets",
"position": [
3006,
-104.9
],
"parameters": {
"columns": {
"value": {
"Due": "={{ $json.Due }}",
"Date": "={{ $json.Date }}",
"Task": "={{ $json.Task }}",
"Owner": "={{ $json.Owner }}",
"Status": "={{ $json.Status }}",
"Meeting": "={{ $json.Meeting }}",
"Priority": "={{ $json.Priority }}"
},
"schema": [],
"mappingMode": "defineBelow",
"matchingColumns": []
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 4.5
}
],
"settings": {
"executionOrder": "v1"
},
"connections": {
"Draft Follow-Up Email": {
"main": [
[
{
"node": "Post Action List to Telegram",
"type": "main",
"index": 0
}
]
]
},
"Use OpenAI GPT-4 Model": {
"ai_languageModel": [
[
{
"node": "AI Extract Decisions & Actions",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Sanitize Transcript Text": {
"main": [
[
{
"node": "Validate Transcript Content",
"type": "main",
"index": 0
}
]
]
},
"Compile Minutes and Drafts": {
"main": [
[
{
"node": "Draft Follow-Up Email",
"type": "main",
"index": 0
}
]
]
},
"Divide Actions for Tracker": {
"main": [
[
{
"node": "Append Actions to Sheet Tracker",
"type": "main",
"index": 0
}
]
]
},
"Validate Transcript Content": {
"main": [
[
{
"node": "AI Extract Decisions & Actions",
"type": "main",
"index": 0
}
],
[
{
"node": "Ignore Non-Transcripts",
"type": "main",
"index": 0
}
]
]
},
"Extract Text from Transcript": {
"main": [
[
{
"node": "Sanitize Transcript Text",
"type": "main",
"index": 0
}
]
]
},
"Post Action List to Telegram": {
"main": [
[
{
"node": "Divide Actions for Tracker",
"type": "main",
"index": 0
}
]
]
},
"AI Extract Decisions & Actions": {
"main": [
[
{
"node": "Confirm AI Extraction Validity",
"type": "main",
"index": 0
}
]
]
},
"Confirm AI Extraction Validity": {
"main": [
[
{
"node": "Compile Minutes and Drafts",
"type": "main",
"index": 0
}
]
]
},
"Download Transcript from Drive": {
"main": [
[
{
"node": "Extract Text from Transcript",
"type": "main",
"index": 0
}
]
]
},
"When Transcript Arrives in Drive": {
"main": [
[
{
"node": "Download Transcript from Drive",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Drop a meeting transcript in a Drive folder (Meet doc, .txt, .vtt/.srt) and get structured minutes, decisions, and action items with owners, due dates and priorities — as a Gmail draft you review before sending, a Telegram ping, and a running Google Sheets action tracker.…
Source: https://n8n.io/workflows/18202/ — 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.
📥 Invoice Intake & Notification Workflow
Inbox Guardian. Uses gmailTrigger, lmChatOpenAi, agent, textClassifier. Event-driven trigger; 66 nodes.
This workflow automates document processing using LlamaParse to extract and analyze text from various file formats. It intelligently processes documents, extracts structured data, and delivers actiona
🤖🧑💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.
🤖🧑💻 AI Agent for Top n8n Creators Leaderboard Reporting. Uses httpRequest, lmChatOpenAi, executeWorkflowTrigger, toolWorkflow. Event-driven trigger; 49 nodes.