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": "Meeting-Bot Cross-Meeting Continuity (Multi-Provider)",
"nodes": [
{
"parameters": {
"content": "## AI Meeting-Bot Cross-Meeting Continuity Bot\n\n**Stack:** Meeting Webhook (or WhatsApp / web chat) \u2192 StudioMeyer Memory entity lookup \u2192 Claude or OpenAI with meeting set dossier \u2192 reply \u2192 persist outcome.\n\n**Why this beats a stateless bot:** Returning meeting sets don't have to re-explain who they are. The bot greets them by name, references past meetings, and the agent that takes over has full history one click away.\n\n**To swap Meeting Webhook for WhatsApp:** replace the trigger node with a WhatsApp Trigger and the reply node with WhatsApp Send Message. The middle stays identical.\n\n**Production patterns ship in this workflow.json** as opt-in Code nodes. See the orange Sticky Notes below for the four env vars that toggle them on.",
"height": 380,
"width": 480,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-260,
-60
]
},
{
"parameters": {
"content": ">> SET ME <<\n\n**Meeting Webhook Bot credential** required.\n\n1. Talk to your meeting platform's webhook configuration, create a bot, get the token.\n2. n8n Credentials \u2192 New \u2192 Meeting Webhook \u2192 paste token.\n3. After activation, message your bot once and check this node's incoming-data view.\n\n**Webhook security:** to harden the trigger, expand `additionalFields` and set `secretToken` to a strong random string. Then re-register the webhook so Meeting Webhook sends `X-Meeting Webhook-Bot-Api-Secret-Token` on every request. The trigger validates it automatically.\n\nIMPORTANT (self-hosted n8n): set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` env var, otherwise the Verify Webhook + Extract Meeting Key code nodes throw at runtime when calling require('crypto').",
"height": 320,
"width": 320,
"color": 5
},
"id": "note-telegram",
"name": "Sticky Note - Telegram",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
240,
-60
]
},
{
"parameters": {
"content": "## Production patterns (opt-in)\n\nThree Code nodes below are off by default. Toggle each with an n8n env var:\n\n- `IDEMPOTENCY_ENABLED=1` deduplicates Meeting Webhook retries on the same `meeting_id` (5-min window).\n- `RATE_LIMIT_ENABLED=1` caps each chat at 60 requests / 5 min.\n- `WEBHOOK_INTEGRITY_CHECK_ENABLED=1` rejects malformed payloads (no `message`, no `from`).\n\nEach node returns pass-through when its env var is unset, so the default import boots clean. Production deployments enable all three plus the Meeting Webhook Trigger `secretToken`.\n\nFor clustered n8n deployments, swap the in-memory `$getWorkflowStaticData` blocks for Redis (`SET NX EX 300` for idempotency, `INCR + EXPIRE` for rate limit). Single-instance n8n is fine with the default.\n\nIMPORTANT (self-hosted n8n): set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` env var, otherwise the Verify Webhook + Extract Meeting Key code nodes throw at runtime when calling require('crypto').",
"height": 360,
"width": 540,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
700,
-120
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "meeting-end",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "mtg-1-trigger",
"name": "Meeting Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
320
]
},
{
"parameters": {
"jsCode": "// Webhook integrity check (opt-in via WEBHOOK_INTEGRITY_CHECK_ENABLED=1).\n// Telegram Trigger handles HMAC via its secretToken option (set on the\n// trigger node itself). This Code node is the second defense layer:\n// reject malformed payloads that lack the fields downstream nodes expect.\n//\n// To enable: set the n8n env var WEBHOOK_INTEGRITY_CHECK_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n\nconst enabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\n\nif (!message || typeof message !== 'object') {\n throw new Error('Webhook integrity check failed: no message object');\n}\nif (typeof message.from?.id !== 'number' && typeof message.from?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.from.id');\n}\nif (typeof message.chat?.id !== 'number' && typeof message.chat?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.chat.id');\n}\nif (typeof message.text !== 'string' && typeof message.caption !== 'string') {\n throw new Error('Webhook integrity check failed: no text or caption');\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-1-verify",
"name": "Verify Webhook (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
320
]
},
{
"parameters": {
"jsCode": "// Rate limit (opt-in via RATE_LIMIT_ENABLED=1).\n// Per-chat-id 60 requests in a 5-minute window. Tracked in workflow\n// static data (per-instance). For clustered n8n deployments or higher\n// throughput, use Nginx limit_req_zone or Cloudflare WAF instead.\n//\n// To enable: set the n8n env var RATE_LIMIT_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic. Under heavy\n// burst load the count can over-shoot the limit by a few percent. For\n// hard limits use a reverse proxy or Redis INCR + EXPIRE.\n\nconst enabled = $env.RATE_LIMIT_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst chatId = message?.chat?.id ?? message?.from?.id ?? 'unknown';\nconst bucketKey = `chat:${chatId}`;\n\nconst data = $getWorkflowStaticData('global');\nconst buckets = data.rateBuckets ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\nconst LIMIT = 60;\nconst MAX_BUCKETS = 5000;\n\nconst bucket = buckets[bucketKey] ?? { count: 0, windowStart: now };\nif (now - bucket.windowStart > WINDOW_MS) {\n bucket.count = 0;\n bucket.windowStart = now;\n}\nbucket.count++;\nbuckets[bucketKey] = bucket;\n\n// Bound the map: evict expired entries when full\nif (Object.keys(buckets).length > MAX_BUCKETS) {\n const cutoff = now - WINDOW_MS;\n for (const k of Object.keys(buckets)) {\n if (buckets[k].windowStart < cutoff) delete buckets[k];\n }\n}\ndata.rateBuckets = buckets;\n\nif (bucket.count > LIMIT) {\n throw new Error(`Rate limit exceeded for ${bucketKey}: ${bucket.count} requests in 5 min window`);\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
320
]
},
{
"parameters": {
"jsCode": "// Idempotency check (opt-in via IDEMPOTENCY_ENABLED=1).\n// Telegram retries on 5xx. Without dedup, the workflow fires twice and\n// writes Memory twice. This node holds a 5-minute in-memory window of\n// seen update_ids and short-circuits duplicates.\n//\n// To enable: set the n8n env var IDEMPOTENCY_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic and not cluster-\n// aware. Two simultaneous fires of the same update_id may both pass.\n// For production scale, swap the staticData block for Redis SET NX EX 300.\n\nconst enabled = $env.IDEMPOTENCY_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst updateId = item?.update_id ?? message?.update_id;\nconst messageId = message?.message_id;\nconst idempotencyKey = updateId ? `tg-update:${updateId}` : (messageId ? `tg-msg:${messageId}` : null);\n\nif (!idempotencyKey) {\n // No dedup key available, pass through\n return [{ json: item }];\n}\n\nconst data = $getWorkflowStaticData('global');\nconst seen = data.seenKeys ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\n\n// Purge expired entries\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\n\nif (seen[idempotencyKey]) {\n // Duplicate detected. Emit a sentinel item that the\n // 'Skip If Duplicate' IF node routes to 'Respond Duplicate'\n // (200 OK + { deduped: true }). Without that 200 the source\n // provider would hold the HTTP connection until n8n's webhook\n // timeout (default 30s) and mark delivery failed.\n return [{ json: { skipped: true, reason: 'duplicate', dedupKey: String(idempotencyKey) } }];\n}\nseen[idempotencyKey] = now;\ndata.seenKeys = seen;\n\nreturn [{ json: item }];"
},
"id": "support-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
320
]
},
{
"parameters": {
"jsCode": "// Extract a stable meeting key from the Fathom or Otter or Granola webhook payload.\n// Strategy: combine sorted-participant-emails into a hash (the \"participant set\"),\n// plus persist the meeting_id from the source for idempotency. Fathom and Otter both\n// expose a unique meeting_id and a participants array.\n//\n// Why participant-set: cross-meeting continuity is ONLY meaningful for the SAME\n// people. A 1-on-1 with Alex and a 1-on-1 with Sam should not share context.\n\nconst body = $input.first().json;\n\n// Source-detection (Fathom vs Otter vs Granola vs Fireflies)\nlet source = 'unknown';\nlet meetingId, title, transcript, summary, participants, startedAt;\n\nif (body?.fathom_id || body?.fathom_meeting_id) {\n source = 'fathom';\n meetingId = body.fathom_id ?? body.fathom_meeting_id;\n title = body?.title ?? 'Fathom Meeting';\n transcript = body?.transcript_text ?? body?.transcript ?? '';\n summary = body?.summary?.text ?? body?.summary ?? '';\n participants = (body?.invitees ?? body?.participants ?? []).map(p => p.email ?? p);\n startedAt = body?.scheduled_start_time ?? body?.started_at;\n} else if (body?.otter_meeting_id || body?.speech_id) {\n source = 'otter';\n meetingId = body.otter_meeting_id ?? body.speech_id;\n title = body?.title ?? 'Otter Meeting';\n transcript = body?.transcript ?? '';\n summary = body?.summary ?? '';\n participants = (body?.speakers ?? []).map(s => s.email ?? s.name ?? '');\n startedAt = body?.started_at;\n} else if (body?.granola_id) {\n source = 'granola';\n meetingId = body.granola_id;\n title = body?.title ?? 'Granola Meeting';\n transcript = body?.transcript ?? '';\n summary = body?.notes ?? body?.summary ?? '';\n participants = (body?.attendees ?? []).map(a => a.email ?? a);\n startedAt = body?.start_time;\n} else {\n // Generic fallback shape\n meetingId = body?.id ?? body?.meeting_id ?? `unknown-${Date.now()}`;\n title = body?.title ?? body?.subject ?? 'Meeting';\n transcript = body?.transcript ?? body?.text ?? '';\n summary = body?.summary ?? '';\n participants = body?.participants ?? body?.attendees ?? body?.invitees ?? [];\n if (Array.isArray(participants) && participants[0] && typeof participants[0] === 'object') {\n participants = participants.map(p => p.email ?? p.name ?? String(p));\n }\n startedAt = body?.started_at ?? body?.start_time;\n source = body?.source ?? 'generic';\n}\n\nparticipants = (participants ?? [])\n .filter(Boolean)\n .map(p => String(p).trim().toLowerCase())\n .filter(p => p.length > 0);\n\nif (participants.length === 0) {\n throw new Error('Cannot extract meeting key: no participants in payload.');\n}\n\n// Sorted participant set hash for stable cross-meeting recognition\nconst sortedParticipants = [...new Set(participants)].sort();\nconst crypto = require('crypto');\nconst participantKey = 'meeting-set:' + crypto\n .createHash('sha256')\n .update(sortedParticipants.join('|'))\n .digest('hex')\n .slice(0, 16);\n\nreturn [{\n json: {\n meetingId,\n title,\n source,\n transcript: transcript.slice(0, 8000), // cap to avoid LLM context blow\n summary,\n customerKey: participantKey, // reuse customerKey naming for entity-lookup parity\n customerLabel: `Meeting set: ${sortedParticipants.join(', ').slice(0, 80)}`,\n participants: sortedParticipants,\n participantList: sortedParticipants.join(', '),\n participantCount: sortedParticipants.length,\n startedAt,\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "mtg-2-extract",
"name": "Extract Meeting Key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "search",
"query": "={{ $json.customerKey }}",
"entityType": "customer",
"limit": 1
},
"id": "mtg-3-lookup",
"name": "Memory: Lookup Meeting Set",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1340,
320
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-known",
"leftValue": "={{ ($json.entities ?? $json.results ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "mtg-3b-known",
"name": "Recurring Meeting Set?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1560,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "open",
"entityRef": "={{ $('Extract Meeting Key').item.json.customerKey }}"
},
"id": "mtg-3c-dossier",
"name": "Memory: Meeting History",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
200
]
},
{
"parameters": {
"resource": "entity",
"operation": "create",
"name": "={{ $('Extract Meeting Key').item.json.customerKey }}",
"entityType": "customer",
"project": "meeting-bot",
"observations": "=Meeting set created via webhook on {{ $('Extract Meeting Key').item.json.receivedAt }}. Message: {{ $('Extract Meeting Key').item.json.messageText.slice(0, 200) }}"
},
"id": "mtg-3d-create",
"name": "Memory: Create Meeting Set",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
460
]
},
{
"parameters": {
"content": "## Why entity.open here\n\nFor returning meeting sets we use `entity.open` (not `memory.search`) because we want the **complete dossier**: entity-type, first-seen date, every observation, every relation. That's exactly what an agent needs to feel like a colleague who reviewed the meeting set's file before saying hello.\n\nFor recency-weighted search across **all** memory (e.g. when the meeting set asks about a product topic, not their own history), use the Memory: Search operation instead.",
"height": 240,
"width": 380,
"color": 7
},
"id": "note-entity-open",
"name": "Sticky Note - entity.open",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1800,
-120
]
},
{
"parameters": {
"jsCode": "// Build a system + user prompt that asks the LLM to synthesize a cross-meeting\n// summary, knowing what was discussed in prior meetings with the SAME participant set.\n//\n// The dossier comes from Memory: Meeting History (entity.open) for known sets,\n// or Memory: Create Meeting Set for first-time. The LLM gets the new meeting's\n// summary plus 5 most-recent prior meeting observations as context.\n\nconst customerKey = $('Extract Meeting Key').item.json.customerKey;\nconst meetingId = $('Extract Meeting Key').item.json.meetingId;\nconst title = $('Extract Meeting Key').item.json.title;\nconst summary = $('Extract Meeting Key').item.json.summary;\nconst transcript = $('Extract Meeting Key').item.json.transcript;\nconst participantList = $('Extract Meeting Key').item.json.participantList;\n\nconst dossierRaw = $input.first().json;\nconst dossier = dossierRaw?.data ?? dossierRaw;\nconst observations = dossier?.observations ?? [];\nconst priorMeetingCount = observations.length;\nconst priorContext = observations\n .slice(-5)\n .map(o => `- ${o?.created_at?.slice(0, 10) ?? '?'} ${(o?.content ?? '').slice(0, 300)}`)\n .join('\\n');\n\nconst meetingInput = summary || transcript.slice(0, 4000);\n\nconst systemPrompt = `You are a meeting-context analyst. The same group of participants has met before. Your job is to summarize the LATEST meeting as a 4-sentence brief that highlights what is NEW versus what was already discussed in prior meetings, and what action items emerge that did not exist before.\n\nOutput format (markdown, NO preamble, NO em-dashes):\n\n**${title}** (meeting ${meetingId.slice(0, 12)})\n\n* Key takeaway from this meeting (1 sentence)\n* New decisions or shifts since last meeting (1-2 sentences)\n* Action items, who-owns-what (1-2 lines)\n* Open questions for next time (1 line)\n\nParticipants: ${participantList}\nPrior meetings on file with this exact set: ${priorMeetingCount}\n\nPrior meeting observations (most-recent last):\n${priorContext || '(no prior meetings on file with this participant set)'}\n\nLatest meeting input:\n${meetingInput}`;\n\nreturn [{\n json: {\n systemPrompt,\n messageText: meetingInput,\n customerKey,\n customerLabel: $('Extract Meeting Key').item.json.customerLabel,\n meetingTitle: title,\n priorMeetingCount,\n },\n}];"
},
"id": "mtg-4-prompt",
"name": "Build LLM Prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
320
]
},
{
"parameters": {
"mode": "manual",
"duplicateItem": false,
"assignments": {
"assignments": [
{
"id": "set-provider",
"name": "provider",
"value": "openai",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "mtg-5-set-provider",
"name": "Set Provider",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2260,
320
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-openai",
"leftValue": "={{ $json.provider }}",
"rightValue": "openai",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "openai"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-anthropic",
"leftValue": "={{ $json.provider }}",
"rightValue": "anthropic",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "anthropic"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "fallback"
}
},
"id": "mtg-5b-route",
"name": "Route by Provider",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
2480,
320
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "gpt-5-mini",
"mode": "list",
"cachedResultName": "gpt-5-mini"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText ?? $json.transcript ?? $json.payload }}",
"role": "user"
}
]
},
"jsonOutput": false,
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "mtg-6-openai",
"name": "OpenAI Reply",
"type": "n8n-nodes-base.openAi",
"typeVersion": 1.7,
"onError": "continueErrorOutput",
"position": [
2700,
200
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "claude-haiku-4-5",
"mode": "list",
"cachedResultName": "claude-haiku-4-5"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText }}",
"role": "user"
}
]
},
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "mtg-6-anthropic",
"name": "Anthropic Reply",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"onError": "continueErrorOutput",
"position": [
2700,
440
]
},
{
"parameters": {
"content": "## Error branch (always on)\n\nThe two LLM Reply nodes have `On Error: Continue (Error Output)` enabled. The red error pin lands at **LLM Fallback Reply**, which builds a graceful meeting set message and feeds two destinations:\n\n1. **Meeting Webhook Reply** so the meeting set gets an answer instead of silence.\n2. **Memory: Learn Error** with `category: mistake, tags: [llm-error, <provider>]` so you spot patterns in your knowledge graph.\n\nNo env var, this branch is always wired. Without it, an OpenAI rate-limit or Anthropic 5xx leaves the meeting set hanging.\n\nThe error syntax is `{{ $json.error.message }}`, not `$error.message` (which does not exist in n8n) and not `$json.execution.error.message` (which is for separate Error Trigger Workflows, not inline error pins).",
"height": 360,
"width": 460,
"color": 7
},
"id": "note-error-branch",
"name": "Sticky Note - Error Branch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2960,
-100
]
},
{
"parameters": {
"jsCode": "// LLM Fallback Reply for meeting summarization.\n// Two arrival paths:\n// 1. LLM error - input has $json.error\n// 2. Router fallback - input has the original prompt object including\n// systemPrompt with prior meeting observations. We must NOT JSON.stringify.\n\nconst errorRaw = $input.first().json;\nconst provider = $('Set Provider').item.json.provider ?? 'unknown';\n\nconst isLlmError = !!(errorRaw?.error || errorRaw?.message);\nlet errorMessage;\nif (isLlmError) {\n errorMessage = errorRaw?.error?.message\n ?? errorRaw?.error?.name\n ?? errorRaw?.message\n ?? 'Unknown LLM error';\n} else {\n errorMessage = `Unknown provider value: ${provider}.`;\n}\n\nconst customerKey = $('Extract Meeting Key').item.json.customerKey;\nconst meetingId = $('Extract Meeting Key').item.json.meetingId;\nconst title = $('Extract Meeting Key').item.json.title;\nconst messageText = $('Extract Meeting Key').item.json.summary || '(transcript unavailable)';\n\nconst fallbackText = `**${title}** (meeting ${String(meetingId).slice(0, 12)})\\n\\n* Summarizer is briefly down, please review the transcript manually.\\n* No cross-meeting comparison available for this run.\\n* Action: human review required.\\n* Next: re-run when the LLM provider is back.`;\n\nreturn [{\n json: {\n replyText: fallbackText,\n provider,\n isFallback: true,\n isRouterFallback: !isLlmError,\n errorMessage: String(errorMessage),\n customerKey,\n customerLabel: $('Extract Meeting Key').item.json.customerLabel,\n messageText,\n },\n}];"
},
"id": "mtg-7-fallback",
"name": "LLM Fallback Reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
580
]
},
{
"parameters": {
"jsCode": "// Normalize LLM output across providers into a single field `replyText`.\n// OpenAI: $json.choices[0].message.content\n// Anthropic: $json.content[0].text\n\nconst raw = $input.first().json;\nlet replyText = '';\n\nif (raw?.choices?.[0]?.message?.content) {\n replyText = raw.choices[0].message.content;\n} else if (Array.isArray(raw?.content) && raw.content[0]?.text) {\n replyText = raw.content[0].text;\n} else if (raw?.message?.content) {\n replyText = raw.message.content;\n} else if (raw?.text) {\n replyText = raw.text;\n} else if (raw?.reply) {\n replyText = raw.reply;\n} else {\n replyText = '(LLM returned no text, check provider response shape)';\n}\n\nreturn [{\n json: {\n replyText: String(replyText).trim(),\n provider: $('Set Provider').item.json.provider,\n isFallback: false,\n },\n}];"
},
"id": "mtg-8-normalize",
"name": "Normalize LLM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
320
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SLACK_WEBHOOK_URL }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ text: $('Build LLM Prompt').item.json.meetingTitle + ' - cross-meeting summary', blocks: [{type:'section', text:{type:'mrkdwn', text:$json.replyText}}, {type:'context', elements:[{type:'mrkdwn', text:'Participants: ' + $('Extract Meeting Key').item.json.participantList + ' | Prior meetings on file: ' + $('Build LLM Prompt').item.json.priorMeetingCount}]}] }) }}",
"options": {}
},
"id": "mtg-9-slack",
"name": "Slack: Post Summary",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3200,
320
],
"credentials": {}
},
{
"parameters": {
"resource": "entity",
"operation": "observe",
"entityRef": "={{ $('Extract Meeting Key').item.json.customerKey }}",
"observations": "=Ticket on {{ $('Extract Meeting Key').item.json.receivedAt }}, Customer: {{ $('Extract Meeting Key').item.json.messageText.slice(0, 200) }} | Bot reply: {{ ($json.replyText ?? '').slice(0, 200) }}"
},
"id": "mtg-9b-observe",
"name": "Memory: Observe Meeting",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
220
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=Support interaction with {{ $('Extract Meeting Key').item.json.customerLabel }} ({{ $('Extract Meeting Key').item.json.customerKey }}): \"{{ $('Extract Meeting Key').item.json.messageText.slice(0, 150) }}\" \u2192 Bot resolved with: \"{{ ($json.replyText ?? '').slice(0, 150) }}\"",
"category": "insight",
"project": "meeting-bot",
"tags": "=support, customer-{{ $('Extract Meeting Key').item.json.customerKey }}",
"confidence": 0.7
},
"id": "mtg-9c-learn",
"name": "Memory: Learn Cross-Meeting-Insight",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
380
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=LLM error in support bot ({{ $json.provider }}): {{ $json.errorMessage }} | Customer: {{ $json.customerLabel }} ({{ $json.customerKey }}) | Question: \"{{ ($json.messageText ?? '').slice(0, 150) }}\"",
"category": "mistake",
"project": "meeting-bot",
"tags": "=llm-error, {{ $json.provider }}, support-bot",
"confidence": 0.6
},
"id": "mtg-9d-error-learn",
"name": "Memory: Learn Error",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
580
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ status: 'ack', meetingId: $('Extract Meeting Key').item.json.meetingId, participantCount: $('Extract Meeting Key').item.json.participantCount }) }}",
"options": {}
},
"id": "mtg-1b-ack",
"name": "Webhook Acknowledge",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
600,
100
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-07-meeting-bot-cross-meeting-continuity-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "07-mee-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "07-mee-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1340,
140
]
}
],
"connections": {
"Meeting Webhook": {
"main": [
[
{
"node": "Verify Webhook (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Verify Webhook (opt-in)": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Skip If Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Extract Meeting Key": {
"main": [
[
{
"node": "Memory: Lookup Meeting Set",
"type": "main",
"index": 0
}
]
]
},
"Memory: Lookup Meeting Set": {
"main": [
[
{
"node": "Recurring Meeting Set?",
"type": "main",
"index": 0
}
]
]
},
"Recurring Meeting Set?": {
"main": [
[
{
"node": "Memory: Meeting History",
"type": "main",
"index": 0
}
],
[
{
"node": "Memory: Create Meeting Set",
"type": "main",
"index": 0
}
]
]
},
"Memory: Meeting History": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Memory: Create Meeting Set": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build LLM Prompt": {
"main": [
[
{
"node": "Set Provider",
"type": "main",
"index": 0
}
]
]
},
"Set Provider": {
"main": [
[
{
"node": "Route by Provider",
"type": "main",
"index": 0
}
]
]
},
"Route by Provider": {
"main": [
[
{
"node": "OpenAI Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "Anthropic Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Anthropic Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Normalize LLM Output": {
"main": [
[
{
"node": "Slack: Post Summary",
"type": "main",
"index": 0
},
{
"node": "Memory: Observe Meeting",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Cross-Meeting-Insight",
"type": "main",
"index": 0
}
]
]
},
"LLM Fallback Reply": {
"main": [
[
{
"node": "Slack: Post Summary",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Error",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract Meeting Key",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
About this workflow
Meeting-Bot Cross-Meeting Continuity (Multi-Provider). Uses stickyNote, n8n-nodes-studiomeyer-memory, openAi, anthropic. Webhook trigger; 28 nodes.
Source: https://github.com/studiomeyer-io/n8n-templates/blob/main/templates/07-meeting-bot-cross-meeting-continuity/workflow.json — original creator credit. Request a take-down →