{
  "name": "Lumeor | Meeting summaries and action items",
  "nodes": [
    {
      "parameters": {
        "content": "## Meeting summaries and action items (Zoom, Claude, Slack, Asana)\n\n**What it does**\nWhen Zoom finishes a cloud recording transcript, this workflow downloads it, asks Claude for a summary, decisions, open questions and action items (each with a quoted line and timestamp), drops any item whose quote is not in the transcript, and sends the draft to a Slack review channel. Tasks are created in Asana only after someone clicks Approve. Anything that fails goes to an exceptions channel.\n\n**Setup**\n1. Credentials: Anthropic (API key), Slack, Asana, and a Crypto credential holding your Zoom app's Secret Token (HMAC secret).\n2. In your Zoom app, set the event notification URL to this workflow's production webhook URL and subscribe to *Recording transcript files completed*. Turn on the option to include a download token. Zoom validates the URL using the Sign and Return nodes.\n3. Replace placeholders: REPLACE_WITH_SLACK_REVIEW_CHANNEL_ID, REPLACE_WITH_SLACK_EXCEPTIONS_CHANNEL_ID, REPLACE_WITH_ASANA_WORKSPACE_ID, REPLACE_WITH_ASANA_PROJECT_ID, and the email-to-Asana-user map in *Fan out approved tasks*.\n4. Tell attendees that meetings are recorded and summarized, and let them opt out. Several US states require consent from all parties to record.\n\n**Notes**\n- Model: claude-opus-5-5 with structured outputs. claude-haiku-5-5 is a cheaper option for high-volume, simple classification, but long transcripts benefit from the larger model.\n- Set an error workflow in Workflow settings so failures outside the handled paths still alert someone.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-meeting-summaries-and-action-items/",
        "height": 820,
        "width": 560,
        "color": 5
      },
      "id": "b9d4e6c9-bcea-4656-8f1b-7cc0e807f071",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -760,
        40
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "zoom-transcript-ready",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "72833fc1-0714-4525-bbfd-544a9eab306a",
      "name": "Zoom webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "f30f2be5-9bd6-4189-8d2e-dbc90c749727"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "99ac7d40-e6a9-40ba-bb71-16d8b1e087d4",
              "leftValue": "={{ $json.body.event }}",
              "rightValue": "endpoint.url_validation",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "e14e925a-036e-47c6-8296-f9bf574366c1",
      "name": "URL validation?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.body.payload.plainToken }}",
        "dataPropertyName": "encryptedToken",
        "encoding": "hex"
      },
      "id": "6b8edaff-5093-4972-abe4-b8064936ad8b",
      "name": "Sign plainToken",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        440,
        100
      ],
      "credentials": {
        "crypto": {
          "id": "",
          "name": "Zoom webhook secret token"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ plainToken: $json.body.payload.plainToken, encryptedToken: $json.encryptedToken }) }}",
        "options": {}
      },
      "id": "856a087d-890e-443c-ba9e-3f15938faf50",
      "name": "Return validation",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        660,
        100
      ]
    },
    {
      "parameters": {
        "respondWith": "noData",
        "options": {
          "responseCode": 200
        }
      },
      "id": "34d127c6-4d6a-4200-b7f1-91987865e48c",
      "name": "Acknowledge event",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ (($json.body.payload.object.recording_files || []).find(f => f.file_type === 'TRANSCRIPT') || {}).download_url }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.body.download_token }}"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "id": "85023323-43d5-4090-8fed-d34e04e56c7e",
      "name": "Download transcript",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Turns the Zoom WebVTT transcript into compact \"[hh:mm:ss] Speaker: text\" lines\n// and collects meeting context from the original webhook payload.\n// Zoom sends one transcript per webhook call, so each execution has one item.\nconst MAX_CHARS = 400000; // roughly a long meeting; longer transcripts go to the exceptions path\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const raw = String(items[i].json.data || '');\n  const hook = $('Zoom webhook').itemMatching(i).json.body || {};\n  const meeting = (hook.payload && hook.payload.object) || {};\n\n  const lines = [];\n  const blocks = raw.replace(/\\r/g, '').split(/\\n\\n+/);\n  for (const block of blocks) {\n    const rows = block.split('\\n').filter(Boolean);\n    const timing = rows.find((r) => r.includes('-->'));\n    if (!timing) continue;\n    const start = timing.split('-->')[0].trim().split('.')[0];\n    const text = rows.slice(rows.indexOf(timing) + 1).join(' ').trim();\n    if (text) lines.push(`[${start}] ${text}`);\n  }\n  const transcript = lines.join('\\n');\n\n  const context = {\n    meeting_id: meeting.id || '',\n    meeting_uuid: meeting.uuid || '',\n    topic: meeting.topic || 'Untitled meeting',\n    host_email: meeting.host_email || '',\n    start_time: meeting.start_time || '',\n    timezone: meeting.timezone || 'America/New_York',\n  };\n\n  // Throwing sends the item to this node's error output (the exceptions path).\n  if (transcript.length < 200) {\n    throw new Error(`Transcript for \"${context.topic}\" is empty or too short to summarize`);\n  }\n  if (transcript.length > MAX_CHARS) {\n    throw new Error(`Transcript for \"${context.topic}\" is ${transcript.length} characters, above the ${MAX_CHARS} limit`);\n  }\n  out.push({ json: { ...context, transcript } });\n}\nreturn out;\n"
      },
      "id": "b25c5077-9383-492f-98cd-1ea43cdd6e44",
      "name": "Clean transcript",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        300
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={\n  \"model\": \"claude-opus-5-5\",\n  \"max_tokens\": 4000,\n  \"system\": \"You turn internal meeting transcripts into a factual summary and a list of action items. Only list an action item when a specific person clearly agreed to do something; general suggestions such as \\\"we should\\\" are not action items. Every action item and decision must include a quote copied exactly from the transcript with its timestamp. Resolve relative dates such as \\\"by Friday\\\" against the meeting start date and time zone you are given. Do not invent owners, dates or commitments. Leave out personal or sensitive remarks that are not about the work.\",\n  \"messages\": [\n    {\n      \"role\": \"user\",\n      \"content\": {{ JSON.stringify('Meeting: ' + $json.topic + '\\nStart: ' + $json.start_time + ' (' + $json.timezone + ')\\nHost: ' + $json.host_email + '\\n\\nTranscript:\\n' + $json.transcript) }}\n    }\n  ],\n  \"output_config\": {\n    \"effort\": \"medium\",\n    \"format\": {\n      \"type\": \"json_schema\",\n      \"schema\": {\n        \"type\": \"object\",\n        \"additionalProperties\": false,\n        \"required\": [\n          \"summary\",\n          \"decisions\",\n          \"open_questions\",\n          \"action_items\"\n        ],\n        \"properties\": {\n          \"summary\": {\n            \"type\": \"string\",\n            \"description\": \"Three to six sentences on what the meeting covered and concluded.\"\n          },\n          \"decisions\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\",\n              \"additionalProperties\": false,\n              \"required\": [\n                \"decision\",\n                \"quote\"\n              ],\n              \"properties\": {\n                \"decision\": {\n                  \"type\": \"string\"\n                },\n                \"quote\": {\n                  \"type\": \"string\",\n                  \"description\": \"Exact words from the transcript.\"\n                }\n              }\n            }\n          },\n          \"open_questions\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"string\"\n            }\n          },\n          \"action_items\": {\n            \"type\": \"array\",\n            \"items\": {\n              \"type\": \"object\",\n              \"additionalProperties\": false,\n              \"required\": [\n                \"description\",\n                \"owner_name\",\n                \"owner_email\",\n                \"due_date\",\n                \"quote\",\n                \"timestamp\"\n              ],\n              \"properties\": {\n                \"description\": {\n                  \"type\": \"string\",\n                  \"description\": \"Verb-first task, one sentence.\"\n                },\n                \"owner_name\": {\n                  \"type\": \"string\",\n                  \"description\": \"Person who committed to the task, or empty string if nobody did.\"\n                },\n                \"owner_email\": {\n                  \"type\": \"string\",\n                  \"description\": \"Owner email address only if it appears in the transcript or meeting details, otherwise empty string.\"\n                },\n                \"due_date\": {\n                  \"type\": \"string\",\n                  \"description\": \"YYYY-MM-DD resolved against the meeting date, or empty string if no date was stated.\"\n                },\n                \"quote\": {\n                  \"type\": \"string\",\n                  \"description\": \"Exact words from the transcript where the commitment was made.\"\n                },\n                \"timestamp\": {\n                  \"type\": \"string\",\n                  \"description\": \"The [hh:mm:ss] timestamp of the quoted line.\"\n                }\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n}",
        "options": {
          "timeout": 180000
        }
      },
      "id": "01432add-56e3-460b-8b5f-9472eae927af",
      "name": "Extract with Claude",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        280
      ],
      "credentials": {
        "anthropicApi": {
          "id": "",
          "name": "Anthropic account"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Parses Claude's structured output, handles refusal and max_tokens,\n// and drops any action item whose supporting quote is not in the transcript.\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\\s+/g, ' ').trim();\nconst isDate = (s) => /^\\d{4}-\\d{2}-\\d{2}$/.test(s);\n\nconst out = [];\nconst items = $input.all();\nfor (let i = 0; i < items.length; i++) {\n  const res = items[i].json;\n  const ctx = $('Clean transcript').itemMatching(i).json;\n  const base = {\n    topic: ctx.topic,\n    host_email: ctx.host_email,\n    start_time: ctx.start_time,\n    context_line: `Meeting: ${ctx.topic} (${ctx.start_time}), host ${ctx.host_email}`,\n  };\n\n  if (res.stop_reason === 'refusal') { out.push({ json: { ...base, ok: false, reason: 'Claude declined to process this transcript (stop_reason refusal)' } }); continue; }\n  if (res.stop_reason === 'max_tokens') { out.push({ json: { ...base, ok: false, reason: 'Claude output was cut off (stop_reason max_tokens). Raise max_tokens or shorten the transcript.' } }); continue; }\n\n  // Opus 5.5 always thinks, so a thinking block can come first: find the text block.\n  const textBlock = (res.content || []).find((b) => b.type === 'text');\n  if (!textBlock) { out.push({ json: { ...base, ok: false, reason: 'Claude response has no text block' } }); continue; }\n  let data;\n  try {\n    data = JSON.parse(textBlock.text);\n  } catch (e) {\n    out.push({ json: { ...base, ok: false, reason: 'Could not parse Claude output as JSON: ' + e.message } });\n    continue;\n  }\n\n  const transcript = norm(ctx.transcript);\n  const kept = [];\n  const dropped = [];\n  for (const a of data.action_items || []) {\n    const quoteOk = a.quote && transcript.includes(norm(a.quote));\n    if (!quoteOk) { dropped.push(a.description); continue; }\n    kept.push({\n      description: a.description,\n      owner_name: a.owner_name || '',\n      owner_email: (a.owner_email || '').toLowerCase(),\n      due_date: isDate(a.due_date) ? a.due_date : '',\n      quote: a.quote,\n      timestamp: a.timestamp || '',\n      unassigned: !a.owner_email,\n    });\n  }\n\n  const fmt = (a, n) => `${n + 1}. ${a.description}\\n    Owner: ${a.owner_email || a.owner_name || 'UNASSIGNED'} | Due: ${a.due_date || 'none stated'} | [${a.timestamp}] \"${a.quote}\"`;\n  const review_text = [\n    `*Draft summary: ${ctx.topic}* (${ctx.start_time})`,\n    data.summary,\n    '',\n    '*Decisions*',\n    ...(data.decisions || []).map((d) => `- ${d.decision}`),\n    '',\n    '*Open questions*',\n    ...(data.open_questions || []).map((q) => `- ${q}`),\n    '',\n    `*Action items to create (${kept.length})*`,\n    ...kept.map(fmt),\n    dropped.length ? `\\n_${dropped.length} item(s) removed because their quote was not found in the transcript._` : '',\n    '',\n    'Approve to create these tasks in Asana. Decline to discard.',\n  ].join('\\n');\n\n  out.push({ json: { ...base, ok: true, summary: data.summary, decisions: data.decisions || [], open_questions: data.open_questions || [], action_items: kept, dropped_count: dropped.length, review_text } });\n}\nreturn out;\n"
      },
      "id": "6ab583c2-0e35-4c49-9f35-58bc30ba3b8a",
      "name": "Parse and check evidence",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        280
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f81f44ef-0ef5-4a0b-9934-1587f8d06be9",
              "leftValue": "={{ $json.ok }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "looseTypeValidation": true,
        "options": {}
      },
      "id": "4b705273-b24a-4fec-86b3-4fc3af6a33c7",
      "name": "Draft OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        280
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "sendAndWait",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_REVIEW_CHANNEL_ID",
          "mode": "id"
        },
        "message": "={{ $json.review_text }}",
        "responseType": "approval",
        "approvalOptions": {
          "values": {
            "approvalType": "double",
            "approveLabel": "Approve and create tasks",
            "disapproveLabel": "Discard"
          }
        },
        "options": {
          "limitWaitTime": {
            "values": {
              "limitType": "afterTimeInterval",
              "resumeAmount": 2,
              "resumeUnit": "days"
            }
          }
        }
      },
      "id": "444ad6e6-f7ab-4185-9853-cc9021c969a8",
      "name": "Organizer review in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        260
      ],
      "webhookId": "6b3d9152-9459-45ab-8ef2-1baae88b20bf",
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack account"
        }
      }
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Runs after the organizer responds. Creates one item per approved action item.\n// Map attendee emails or lowercase full names to Asana user IDs (gid).\n// Unmapped owners are left unassigned and named in the task notes.\nconst ASANA_USERS = {\n  'replace_with_attendee_email@example.com': 'REPLACE_WITH_ASANA_USER_GID',\n  'replace with attendee full name': 'REPLACE_WITH_ASANA_USER_GID',\n};\nconst lookup = (a) => ASANA_USERS[a.owner_email] || ASANA_USERS[String(a.owner_name || '').toLowerCase().trim()] || null;\n\nconst response = $input.first().json;\nconst approved = response.data && response.data.approved === true;\nif (!approved) return []; // declined, or no answer before the time limit\n\nconst draft = $('Parse and check evidence').first().json;\nreturn draft.action_items.map((a) => ({\n  json: {\n    name: a.description,\n    assignee: lookup(a),\n    due_on: a.due_date || null,\n    notes: [\n      `From meeting: ${draft.topic} (${draft.start_time})`,\n      `Owner named in meeting: ${a.owner_email || a.owner_name || 'unassigned'}`,\n      `Evidence [${a.timestamp}]: \"${a.quote}\"`,\n      '',\n      'Meeting summary:',\n      draft.summary,\n    ].join('\\n'),\n  },\n}));\n"
      },
      "id": "50637551-8912-4d2a-90fc-e86b220a69ca",
      "name": "Fan out approved tasks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        260
      ]
    },
    {
      "parameters": {
        "resource": "task",
        "operation": "create",
        "workspace": "REPLACE_WITH_ASANA_WORKSPACE_ID",
        "name": "={{ $json.name }}",
        "otherProperties": {
          "assignee": "={{ $json.assignee }}",
          "due_on": "={{ $json.due_on }}",
          "notes": "={{ $json.notes }}",
          "projects": [
            "REPLACE_WITH_ASANA_PROJECT_ID"
          ]
        }
      },
      "id": "9ccedc44-bc82-42cf-b60d-f69481fe2158",
      "name": "Create Asana task",
      "type": "n8n-nodes-base.asana",
      "typeVersion": 1,
      "position": [
        2200,
        260
      ],
      "credentials": {
        "asanaApi": {
          "id": "",
          "name": "Asana account"
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_SLACK_EXCEPTIONS_CHANNEL_ID",
          "mode": "id"
        },
        "messageType": "text",
        "text": "=:warning: *Meeting summary workflow* needs attention\n{{ $json.reason || ($json.error ? ($json.error.message || JSON.stringify($json.error)) : 'Unknown error') }}\n{{ $json.context_line || '' }}\nExecution: {{ $execution.id }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "45d3ac17-6511-4e87-9172-5d30e95deb9f",
      "name": "Post exception to Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1540,
        560
      ],
      "credentials": {
        "slackApi": {
          "id": "",
          "name": "Slack account"
        }
      }
    }
  ],
  "connections": {
    "Zoom webhook": {
      "main": [
        [
          {
            "node": "URL validation?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "URL validation?": {
      "main": [
        [
          {
            "node": "Sign plainToken",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Acknowledge event",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sign plainToken": {
      "main": [
        [
          {
            "node": "Return validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Acknowledge event": {
      "main": [
        [
          {
            "node": "Download transcript",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Download transcript": {
      "main": [
        [
          {
            "node": "Clean transcript",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Clean transcript": {
      "main": [
        [
          {
            "node": "Extract with Claude",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract with Claude": {
      "main": [
        [
          {
            "node": "Parse and check evidence",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse and check evidence": {
      "main": [
        [
          {
            "node": "Draft OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft OK?": {
      "main": [
        [
          {
            "node": "Organizer review in Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Organizer review in Slack": {
      "main": [
        [
          {
            "node": "Fan out approved tasks",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fan out approved tasks": {
      "main": [
        [
          {
            "node": "Create Asana task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Asana task": {
      "main": [
        [],
        [
          {
            "node": "Post exception to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
