This workflow corresponds to n8n.io template #16322 — 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": "hz1Q5PNlSbLRrmsW",
"name": "Meeting Action Tracker with Claude, Linear and Slack",
"tags": [],
"nodes": [
{
"id": "f0a42837-ce34-4fc1-a814-ed002e0ea761",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-2208,
80
],
"parameters": {
"width": 624,
"height": 832,
"content": "# Meeting Action Tracker with Claude, Linear and Slack\n\n\n## How it works\n\n1. Receives meeting data via webhook trigger and extracts the transcript (supports Fireflies, Otter, and raw payloads)\n2. Validates the event is a real meeting and checks for duplicate webhook deliveries\n3. Sends the transcript plus attendee list to Claude for analysis, owners are matched only against real attendees\n4. Parses the AI output, filters action items by confidence score (>= 0.7), and builds Slack + email content\n5. Distributes results: Slack digest, email recap, and one Linear issue per high-confidence action item\n\n\n## Setup steps\n\n- [ ] Configure your transcript provider (Fireflies/Otter) to POST to this webhook's URL\n- [ ] Set up Anthropic API credentials on the LLM node\n- [ ] Connect Gmail account for sending meeting recaps\n- [ ] Connect Slack workspace and set the target channel\n- [ ] Connect Linear and set your team ID in 'Create Linear Issues'\n- [ ] Adjust the CONFIDENCE_THRESHOLD constant in 'Parse Meeting Summary Output' if needed (default 0.7)\n\n\n## Customization\n\nOwners are matched only against the attendee list passed to Claude if no match, the item is Unassigned rather than guessed. Duplicate meetings (same meeting ID or same transcript fingerprint) are detected and skipped using workflow static data, so retried webhooks don't create duplicate Linear issues. Action items below the confidence threshold are shown in Slack for visibility but are not turned into Linear issues; raise or lower the threshold to control how aggressive this filter is."
},
"typeVersion": 1
},
{
"id": "ff263338-54d1-47d5-bb06-7d778d44411b",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1504,
80
],
"parameters": {
"color": 7,
"width": 608,
"height": 528,
"content": "## Webhook input and response\n\nReceives meeting event data via webhook and sends acknowledgement response immediately so the provider doesn't time out or retry."
},
"typeVersion": 1
},
{
"id": "eb875d36-d564-467e-a3cf-e945ea36e870",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-864,
80
],
"parameters": {
"color": 7,
"width": 880,
"height": 336,
"content": "## Extract, validate, and dedup\n\nExtracts transcript from the webhook payload, validates it's a real meeting, and checks a fingerprint against previously-processed meetings to avoid duplicate processing on webhook retries."
},
"typeVersion": 1
},
{
"id": "1323a704-a047-4b5f-92d9-ed9cf99ed8ab",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
48,
80
],
"parameters": {
"color": 7,
"width": 720,
"height": 544,
"content": "## Analyze with AI and parse\n\nSends the transcript and attendee list to Claude. Owners are matched only against real attendees. Output is filtered by a confidence score before action items become Linear issues."
},
"typeVersion": 1
},
{
"id": "2b352b58-bff2-4e88-86cc-e28f54019f20",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
1296,
80
],
"parameters": {
"color": 7,
"width": 480,
"height": 544,
"content": "## Distribute to communication channels\n\nPosts meeting summary to Slack and sends an HTML email recap to the meeting organizer."
},
"typeVersion": 1
},
{
"id": "580a18b3-440d-4170-b043-b8dac35ca3b4",
"name": "Sticky Note5",
"type": "n8n-nodes-base.stickyNote",
"position": [
800,
80
],
"parameters": {
"color": 7,
"width": 464,
"height": 544,
"content": "## Process and track action items\n\nSplits high-confidence action items and creates one Linear issue per item, with owner, priority, due-date hint, and meeting context in the description."
},
"typeVersion": 1
},
{
"id": "660e0d0c-2bd1-4b93-82de-b1473e5f0e40",
"name": "When Meeting Notes Received",
"type": "n8n-nodes-base.webhook",
"position": [
-1456,
240
],
"parameters": {
"path": "meeting-notes",
"options": {},
"httpMethod": "POST",
"responseMode": "responseNode"
},
"typeVersion": 2
},
{
"id": "8aa06675-fa90-446e-9365-c61510f6b26f",
"name": "Send Webhook Response",
"type": "n8n-nodes-base.respondToWebhook",
"position": [
-1056,
416
],
"parameters": {
"options": {},
"respondWith": "json",
"responseBody": "{\"status\": \"ok\", \"message\": \"Meeting received and processing\"}"
},
"typeVersion": 1.1
},
{
"id": "9f207253-09fd-4eb3-896b-5269067c43b6",
"name": "Extract Meeting Transcript",
"type": "n8n-nodes-base.code",
"position": [
-816,
240
],
"parameters": {
"jsCode": "const body = $input.item.json.body || $input.item.json;\n\n// Fireflies test ping \u2014 stop silently\nif (body.event === 'test') {\n return [{ json: { skip: true } }];\n}\n\nlet transcript = '';\nlet meetingTitle = 'Meeting';\nlet meetingDate = new Date().toISOString().split('T')[0];\nlet organizerEmail = '';\nlet attendees = [];\nlet meetingId = '';\n\n// Fireflies format\nif (body.transcript?.sentences) {\n transcript = body.transcript.sentences\n .map(s => `${s.speaker_name}: ${s.text}`)\n .join('\\n');\n meetingTitle = body.meeting?.title || 'Meeting';\n meetingDate = body.meeting?.date?.split('T')[0] || meetingDate;\n organizerEmail = body.meeting?.organizer_email || '';\n attendees = body.meeting?.attendees?.map(a => a.name || a.email) || [];\n meetingId = body.meeting?.id || body.meeting?.transcript_id || '';\n}\n// Otter format\nelse if (body.data?.transcript) {\n transcript = body.data.transcript;\n meetingTitle = body.data.title || 'Meeting';\n meetingDate = body.data.created_at?.split('T')[0] || meetingDate;\n meetingId = body.data.id || '';\n}\n// Raw / test payload\nelse if (typeof body.transcript === 'string') {\n transcript = body.transcript;\n meetingTitle = body.title || 'Meeting';\n organizerEmail = body.organizer_email || '';\n attendees = body.attendees || [];\n meetingId = body.meeting_id || '';\n}\n\nif (!transcript) {\n throw new Error('No transcript found in webhook payload.');\n}\n\n// Build a stable dedup key: prefer explicit meetingId, fall back to a hash of title+date+transcript length\nlet dedupKey = meetingId;\nif (!dedupKey) {\n // Simple deterministic hash (FNV-1a style) \u2014 no external deps\n const str = `${meetingTitle}|${meetingDate}|${transcript.length}|${transcript.slice(0,200)}`;\n let hash = 0x811c9dc5;\n for (let i = 0; i < str.length; i++) {\n hash ^= str.charCodeAt(i);\n hash = Math.imul(hash, 0x01000193);\n }\n dedupKey = (hash >>> 0).toString(16);\n}\n\nreturn [{ json: { skip: false, transcript, meetingTitle, meetingDate, organizerEmail, attendees, dedupKey } }];"
},
"typeVersion": 2
},
{
"id": "dace0439-7ebc-4f53-9441-475565c44ffa",
"name": "If Valid Meeting",
"type": "n8n-nodes-base.if",
"position": [
-576,
240
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "skip-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.skip }}",
"rightValue": false
}
]
}
},
"typeVersion": 2
},
{
"id": "9bcec2a1-ae7a-4b49-81b8-4a85c7343a78",
"name": "Check Duplicate Meeting",
"type": "n8n-nodes-base.code",
"position": [
-352,
224
],
"parameters": {
"jsCode": "// Checks the workflow's static data for a previously-seen dedupKey.\n// If seen, marks duplicate=true so the next IF node can stop the run.\n// If not seen, records it (capped list to avoid unbounded growth).\n\nconst data = $input.item.json;\nconst dedupKey = data.dedupKey;\n\nconst staticData = $getWorkflowStaticData('global');\nif (!staticData.seenMeetings) staticData.seenMeetings = [];\n\nconst seen = staticData.seenMeetings;\nconst isDuplicate = seen.includes(dedupKey);\n\nif (!isDuplicate) {\n seen.push(dedupKey);\n // Keep only the most recent 500 keys to avoid unbounded growth\n if (seen.length > 500) {\n staticData.seenMeetings = seen.slice(seen.length - 500);\n }\n}\n\nreturn [{ json: { ...data, duplicate: isDuplicate } }];"
},
"typeVersion": 2
},
{
"id": "77f19743-7a33-4335-a776-43be8c75e9a9",
"name": "If Not Duplicate",
"type": "n8n-nodes-base.if",
"position": [
-192,
224
],
"parameters": {
"options": {},
"conditions": {
"options": {
"version": 1,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "dup-check",
"operator": {
"type": "boolean",
"operation": "equals"
},
"leftValue": "={{ $json.duplicate }}",
"rightValue": false
}
]
}
},
"typeVersion": 2
},
{
"id": "0b39f683-903b-4e05-9f87-09e7c271e471",
"name": "Claude Meeting Analyzer",
"type": "@n8n/n8n-nodes-langchain.chainLlm",
"position": [
304,
208
],
"parameters": {
"text": "=You are an expert meeting analyst. Analyze this transcript and extract structured data.\n\nMeeting: {{ $json.meetingTitle }}\nDate: {{ $json.meetingDate }}\nAttendees: {{ ($json.attendees || []).join(', ') || 'Not specified' }}\n\nTranscript:\n{{ $json.transcript }}\n\nReturn ONLY a valid JSON object, no markdown, no explanation:\n{\n \"summary\": \"2-3 sentence executive summary of what was discussed and decided\",\n \"action_items\": [\n {\n \"title\": \"short action item title\",\n \"description\": \"clear description of what needs to be done\",\n \"owner\": \"exact name from the attendees list above who is responsible, or Unassigned if no attendee matches or none was mentioned\",\n \"priority\": \"urgent|high|medium|low\",\n \"due_hint\": \"due date or deadline exactly as mentioned in the transcript (e.g. 'next Friday', 'end of month'), or null if not mentioned\",\n \"confidence\": 0.0\n }\n ],\n \"decisions\": [\"key decisions made in the meeting\"],\n \"next_meeting\": \"next meeting date and time if mentioned or null\"\n}\n\nRules for action_items:\n- Only extract items that were explicitly assigned or agreed as next steps \u2014 not speculative ideas (\"maybe we should think about...\")\n- owner MUST be one of the names in the Attendees list above, matched exactly, or \"Unassigned\"\n- confidence is a number between 0 and 1 representing how certain you are this is a real, actionable commitment (not a vague suggestion). Use 0.9+ for explicit commitments (\"I'll do X by Friday\"), 0.5-0.7 for implied tasks, below 0.5 for speculative mentions.\n- due_hint should be the raw phrase from the transcript (e.g. \"by Friday\", \"next week\", \"end of Q2\") \u2014 do not convert it yourself",
"batching": {},
"promptType": "define"
},
"typeVersion": 1.9
},
{
"id": "9f7aec7d-34f9-44bd-8dea-b32b334b7fda",
"name": "LLM",
"type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
"position": [
96,
432
],
"parameters": {
"model": {
"__rl": true,
"mode": "list",
"value": "claude-sonnet-4-5-20250929",
"cachedResultName": "Claude Sonnet 4.5"
},
"options": {}
},
"credentials": {
"anthropicApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
},
{
"id": "48e2a422-0b89-45e6-9fa1-c0581492e03b",
"name": "Parse Meeting Summary Output",
"type": "n8n-nodes-base.code",
"position": [
864,
208
],
"parameters": {
"jsCode": "const claudeResponse = $input.item.json;\nconst content = claudeResponse.text || claudeResponse.output || claudeResponse.content?.[0]?.text || '';\nconst meetingData = $('Extract Meeting Transcript').item.json;\n\nlet parsed;\ntry {\n const clean = content.replace(/```json|```/g, '').trim();\n parsed = JSON.parse(clean);\n} catch (e) {\n throw new Error('Claude returned invalid JSON: ' + content.substring(0, 300));\n}\n\nconst CONFIDENCE_THRESHOLD = 0.7;\n\nconst allActionItems = parsed.action_items || [];\nconst actionItems = allActionItems.filter(item => (item.confidence ?? 1) >= CONFIDENCE_THRESHOLD);\nconst lowConfidenceItems = allActionItems.filter(item => (item.confidence ?? 1) < CONFIDENCE_THRESHOLD);\n\nconst summary = parsed.summary || 'No summary generated.';\nconst decisions = parsed.decisions || [];\nconst nextMeeting = parsed.next_meeting || null;\nconst runId = new Date().toISOString();\n\n// Slack digest\nconst slackLines = [\n `*Meeting: ${meetingData.meetingTitle} \u00b7 ${meetingData.meetingDate}*`,\n '',\n summary,\n ''\n];\n\nif (decisions.length) {\n slackLines.push('*Decisions*');\n decisions.forEach(d => slackLines.push(`\u2022 ${d}`));\n slackLines.push('');\n}\n\nif (actionItems.length) {\n slackLines.push(`*${actionItems.length} action item${actionItems.length > 1 ? 's' : ''} created in Linear*`);\n actionItems.forEach((item, i) => {\n const due = item.due_hint ? ` \u00b7 due ${item.due_hint}` : '';\n const owner = item.owner && item.owner !== 'Unassigned' ? `@${item.owner}` : 'Unassigned';\n slackLines.push(`${i + 1}. *${item.title}* \u00b7 ${owner}${due}`);\n });\n slackLines.push('');\n} else {\n slackLines.push('_No action items identified._');\n slackLines.push('');\n}\n\nif (lowConfidenceItems.length) {\n slackLines.push(`_${lowConfidenceItems.length} possible action${lowConfidenceItems.length > 1 ? 's' : ''} mentioned but below confidence threshold \u2014 not added to Linear:_`);\n lowConfidenceItems.forEach(item => slackLines.push(` \u00b7 ${item.title} (${Math.round((item.confidence ?? 0) * 100)}%)`));\n slackLines.push('');\n}\n\nif (nextMeeting) {\n slackLines.push(`*Next Meeting:* ${nextMeeting}`);\n}\n\n// HTML email\nconst emailBody = `\n<div style=\"font-family:sans-serif;max-width:600px;margin:0 auto;color:#1a1a1a;\">\n <h2 style=\"margin-bottom:4px;\">${meetingData.meetingTitle}</h2>\n <p style=\"color:#888;margin-top:0;\">${meetingData.meetingDate}</p>\n <hr style=\"border:none;border-top:1px solid #eee;\">\n\n <h3>Summary</h3>\n <p>${summary}</p>\n\n ${decisions.length ? `\n <h3>Decisions</h3>\n <ul>${decisions.map(d => `<li>${d}</li>`).join('')}</ul>\n ` : ''}\n\n ${actionItems.length ? `\n <h3>Action Items</h3>\n <ul style=\"padding-left:0;list-style:none;\">\n ${actionItems.map(item => `\n <li style=\"padding:10px 0;border-bottom:1px solid #f0f0f0;\">\n <strong>${item.title}</strong><br>\n <span style=\"color:#555;font-size:13px;\">\n Owner: ${item.owner || 'Unassigned'} \u00b7 Priority: ${item.priority}\n ${item.due_hint ? ` \u00b7 Due: <strong>${item.due_hint}</strong>` : ''}\n </span><br>\n <span style=\"color:#777;font-size:13px;\">${item.description}</span>\n </li>`).join('')}\n </ul>\n ` : ''}\n\n ${nextMeeting ? `<h3>Next Meeting</h3><p>${nextMeeting}</p>` : ''}\n</div>\n`.trim();\n\nreturn [{\n json: {\n meetingTitle: meetingData.meetingTitle,\n meetingDate: meetingData.meetingDate,\n organizerEmail: meetingData.organizerEmail,\n summary,\n decisions,\n nextMeeting,\n actionItems,\n lowConfidenceItems,\n slackMessage: slackLines.join('\\n'),\n emailBody,\n runId\n }\n}];"
},
"typeVersion": 2
},
{
"id": "ef3a5cf0-6418-4669-bdf6-b6ebc5706f45",
"name": "Post Summary to Slack",
"type": "n8n-nodes-base.slack",
"position": [
1488,
208
],
"parameters": {
"text": "={{ $json.slackMessage }}",
"select": "channel",
"channelId": {
"__rl": true,
"mode": "list",
"value": "C08ULTYPMA4",
"cachedResultName": "all-lets-automate"
},
"otherOptions": {
"includeLinkToWorkflow": false
},
"authentication": "oAuth2"
},
"executeOnce": true,
"typeVersion": 2.4
},
{
"id": "88c7a490-b33d-4e52-a620-c7fac3e65e06",
"name": "Send Meeting Recap Email",
"type": "n8n-nodes-base.gmail",
"position": [
1488,
416
],
"parameters": {
"sendTo": "={{ $json.organizerEmail }}",
"message": "={{ $json.emailBody }}",
"options": {
"appendAttribution": false
},
"subject": "=Meeting recap: {{ $json.meetingTitle }} ({{ $json.meetingDate }})"
},
"executeOnce": true,
"typeVersion": 2.1
},
{
"id": "dc59799e-0ca2-4cfb-b02a-f87851fd954c",
"name": "Extract Action Items",
"type": "n8n-nodes-base.code",
"position": [
864,
448
],
"parameters": {
"jsCode": "const data = $input.item.json;\nconst actionItems = data.actionItems || [];\n\nif (!actionItems.length) return [];\n\nreturn actionItems.map(item => ({\n json: {\n meetingTitle: data.meetingTitle,\n meetingDate: data.meetingDate,\n actionItem: item\n }\n}));"
},
"typeVersion": 2
},
{
"id": "2553dedb-82c1-4177-a2f3-86a9f3868559",
"name": "Create Linear Issues",
"type": "n8n-nodes-base.linear",
"position": [
1104,
448
],
"parameters": {
"title": "={{ $json.actionItem.title }}",
"teamId": "acbf102c-b6aa-4466-aca0-01a25dc76cc3",
"authentication": "oAuth2",
"additionalFields": {
"description": "={{ $json.actionItem.description }}\n\n**Priority:** {{ $json.actionItem.priority }}\n**Owner:** {{ $json.actionItem.owner }}\n**Due:** {{ $json.actionItem.due_hint || 'Not specified' }}\n**Confidence:** {{ Math.round(($json.actionItem.confidence ?? 1) * 100) }}%\n\n---\n**From meeting:** {{ $json.meetingTitle }} ({{ $json.meetingDate }})"
}
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"executionOrder": "v1"
},
"versionId": "989ceab8-2726-48cd-bc7f-6a7a4a914824",
"connections": {
"LLM": {
"ai_languageModel": [
[
{
"node": "Claude Meeting Analyzer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"If Not Duplicate": {
"main": [
[
{
"node": "Claude Meeting Analyzer",
"type": "main",
"index": 0
}
]
]
},
"If Valid Meeting": {
"main": [
[
{
"node": "Check Duplicate Meeting",
"type": "main",
"index": 0
}
]
]
},
"Extract Action Items": {
"main": [
[
{
"node": "Create Linear Issues",
"type": "main",
"index": 0
}
]
]
},
"Check Duplicate Meeting": {
"main": [
[
{
"node": "If Not Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Claude Meeting Analyzer": {
"main": [
[
{
"node": "Parse Meeting Summary Output",
"type": "main",
"index": 0
}
]
]
},
"Extract Meeting Transcript": {
"main": [
[
{
"node": "If Valid Meeting",
"type": "main",
"index": 0
}
]
]
},
"When Meeting Notes Received": {
"main": [
[
{
"node": "Extract Meeting Transcript",
"type": "main",
"index": 0
},
{
"node": "Send Webhook Response",
"type": "main",
"index": 0
}
]
]
},
"Parse Meeting Summary Output": {
"main": [
[
{
"node": "Post Summary to Slack",
"type": "main",
"index": 0
},
{
"node": "Send Meeting Recap Email",
"type": "main",
"index": 0
},
{
"node": "Extract Action Items",
"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.
anthropicApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow receives meeting transcripts via webhook, uses Anthropic Claude to extract summaries, decisions, and action items, then posts a digest to Slack, emails the organizer via Gmail, and creates high-confidence action items as Linear issues while skipping duplicate…
Source: https://n8n.io/workflows/16322/ — 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.
Automatically reads every reply to your cold email campaigns in Instantly.ai, uses Claude AI to understand the intent, and takes the right action . No need ofmanual inbox checking needed. A lead repli
This workflow starts when a GoHighLevel opportunity is marked Won, uses Anthropic Claude to generate a tailored welcome email and 6-task onboarding plan,creates tasks in GoHighLevel,then creates a Goo
The original LLM Council concept was introduced by Andrej Karpathy and published as an open-source repository demonstrating multi-model consensus and ranking. This workflow is my adaptation of that or
Stop treating document review as a manual task. Let AI extract, classify, and route every contract, invoice, and NDA automatically.
This workflow automates end-to-end contract analysis using AI. It extracts clauses, evaluates risks, tracks obligations, and generates an executive summary from uploaded contracts.