{
  "id": "OtXp4PWNEIru8oPS",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Extract action items and decisions from meeting transcripts into Asana and Google Sheets",
  "tags": [],
  "nodes": [
    {
      "id": "3150c233-acf4-4823-8332-1f6c1cd8f47c",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        -272
      ],
      "parameters": {
        "width": 980,
        "height": 1020,
        "content": "## Extract action items and decisions from meeting transcripts\n\nThis workflow converts meeting recordings or transcripts into structured intelligence: full transcripts, key decisions, action items, and automatic task syncing to your project management tool.\n\n### Who's it for\n\u2022 Teams running 5+ meetings per week\n\u2022 Project managers needing automatic task capture\n\u2022 Executives wanting searchable meeting records\n\u2022 Remote teams requiring async meeting summaries\n\n### How it works / What it does\n1. Accepts meeting input via webhook (upload or calendar event)\n2. Polls for scheduled meetings every 30 minutes\n3. Validates and normalises the incoming meeting data\n4. Transcribes audio/video or ingests existing transcript\n5. AI extracts decisions, action items, and a structured summary\n6. Formats the intelligence into a clean output object\n7. Pushes action items to your project management tool (e.g. Asana / Linear)\n8. Logs every meeting and its outputs to Google Sheets\n9. Sends a summary email/Slack notification to participants\n\n### How to set up\n1. Import this workflow into n8n\n2. Configure credentials: Webhook secret, OpenAI API, Google Sheets OAuth, PM tool API, SendGrid\n3. Replace placeholder IDs (YOUR_SHEET_ID, YOUR_PROJECT_ID) with real values\n4. Update the resume/preferences fields in the AI node with your team context\n5. Activate the workflow\n\n### Requirements\n\u2022 n8n instance (cloud or self-hosted)\n\u2022 OpenAI API key (Whisper + GPT-4.1-mini)\n\u2022 Google Sheets with OAuth2\n\u2022 Asana / Linear / Jira API key (or swap the HTTP node)\n\u2022 SendGrid API key (or swap for Gmail node)\n\n### How to customise\n\u2022 Swap GPT-4.1-mini for Claude Sonnet in the LLM node\n\u2022 Change extraction prompt to match your meeting taxonomy\n\u2022 Add a Slack node after the email send step\n\u2022 Extend the Google Sheet columns for custom metadata\n\u2022 Replace Asana with any HTTP-compatible task tool"
      },
      "typeVersion": 1
    },
    {
      "id": "2a6bee86-b7a4-4ddd-a887-3f40fbbcab87",
      "name": "Sticky Note - Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1200,
        -32
      ],
      "parameters": {
        "color": 3,
        "width": 780,
        "height": 520,
        "content": "## 1. Trigger & Intake\nAccepts meeting data from webhook POST or\nscheduled poll. Normalises all fields into\na consistent context object."
      },
      "typeVersion": 1
    },
    {
      "id": "ae20767d-b2bb-4d9b-86a8-66a433860592",
      "name": "Sticky Note - Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2032,
        -128
      ],
      "parameters": {
        "color": 3,
        "width": 780,
        "height": 520,
        "content": "## 2. Transcription & Validation\nValidates that audio/transcript is present,\ncalls Whisper if audio URL provided,\nthen filters out non-meeting payloads."
      },
      "typeVersion": 1
    },
    {
      "id": "8dc99b22-6c53-4635-9f40-59f6e3d08f63",
      "name": "Sticky Note - Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2880,
        -128
      ],
      "parameters": {
        "color": 3,
        "width": 828,
        "height": 760,
        "content": "## 3. AI Analysis & Extraction\nSends transcript to GPT-4.1-mini with a\nstructured prompt. Extracts decisions,\naction items, summary, and owners."
      },
      "typeVersion": 1
    },
    {
      "id": "8462169a-2c71-4f40-8a05-337eeb30f93a",
      "name": "Sticky Note - Section 4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3760,
        -96
      ],
      "parameters": {
        "color": 3,
        "width": 1364,
        "height": 792,
        "content": "## 4. Output, Sync & Notify\nFormats the structured output, pushes action\nitems to the PM tool, logs to Sheets, and\nsends a summary notification."
      },
      "typeVersion": 1
    },
    {
      "id": "9c8dd843-ad8a-4628-93a0-25320a65b5b4",
      "name": "Webhook - New Meeting Upload",
      "type": "n8n-nodes-base.webhook",
      "position": [
        1360,
        112
      ],
      "parameters": {
        "path": "meeting-intelligence-inbound",
        "options": {},
        "httpMethod": "POST",
        "responseMode": "responseNode"
      },
      "typeVersion": 1.1
    },
    {
      "id": "1102cce2-2700-40a4-9250-3f30da30bec2",
      "name": "Poll Scheduled Meetings",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        1360,
        320
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/30 * * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "3391a76f-141c-443f-a8a9-327388876d35",
      "name": "Normalise Meeting Context",
      "type": "n8n-nodes-base.set",
      "position": [
        1600,
        208
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "name": "meetingId",
              "type": "string",
              "value": "={{ $json.meetingId || $json.body?.meetingId || 'mtg-' + Date.now().toString() }}"
            },
            {
              "name": "meetingTitle",
              "type": "string",
              "value": "={{ $json.title || $json.body?.title || 'Untitled Meeting' }}"
            },
            {
              "name": "meetingDate",
              "type": "string",
              "value": "={{ $json.date || $json.body?.date || new Date().toISOString().split('T')[0] }}"
            },
            {
              "name": "participants",
              "type": "string",
              "value": "={{ $json.participants || $json.body?.participants || '' }}"
            },
            {
              "name": "audioUrl",
              "type": "string",
              "value": "={{ $json.audioUrl || $json.body?.audioUrl || '' }}"
            },
            {
              "name": "rawTranscript",
              "type": "string",
              "value": "={{ $json.transcript || $json.body?.transcript || '' }}"
            },
            {
              "name": "platform",
              "type": "string",
              "value": "={{ $json.platform || $json.body?.platform || 'unknown' }}"
            },
            {
              "name": "durationMinutes",
              "type": "number",
              "value": "={{ $json.durationMinutes || $json.body?.durationMinutes || 0 }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "5a57b2a5-3fa5-4e61-ae2f-2f4abded01f1",
      "name": "Python - Validate & Route",
      "type": "n8n-nodes-base.code",
      "notes": "Checks that the payload contains either an audioUrl or a rawTranscript. Sets hasAudio and hasTranscript flags, and marks invalid payloads so the filter can drop them.\n\n# --- paste this into the Code node body ---\nitem = _input.item.json\naudio_url = item.get('audioUrl', '').strip()\nraw_tx = item.get('rawTranscript', '').strip()\nhas_audio = len(audio_url) > 10\nhas_transcript = len(raw_tx) > 50\nitem['hasAudio'] = has_audio\nitem['hasTranscript'] = has_transcript\nitem['isValidMeeting'] = has_audio or has_transcript\nitem['needsTranscription'] = has_audio and not has_transcript\nreturn {'json': item}",
      "position": [
        1840,
        208
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "pythonNative",
        "pythonCode": "item = _input.item.json\n\naudio_url    = (item.get('audioUrl', '')    or '').strip()\nraw_tx       = (item.get('rawTranscript', '') or '').strip()\n\nhas_audio      = len(audio_url) > 10\nhas_transcript = len(raw_tx) > 50\n\nitem['hasAudio']          = has_audio\nitem['hasTranscript']     = has_transcript\nitem['isValidMeeting']    = has_audio or has_transcript\nitem['needsTranscription'] = has_audio and not has_transcript\n\n# Basic metadata validation\nmeeting_title = (item.get('meetingTitle', '') or '').strip()\nmeeting_date  = (item.get('meetingDate',  '') or '').strip()\n\nitem['hasMeetingTitle'] = len(meeting_title) > 0\nitem['hasMeetingDate']  = len(meeting_date)  > 0\n\n# Participant count (comma-separated string or list)\nparticipants = item.get('participants', '')\nif isinstance(participants, list):\n    item['participantCount'] = len(participants)\nelif isinstance(participants, str) and len(participants) > 0:\n    item['participantCount'] = len([p.strip() for p in participants.split(',') if p.strip()])\nelse:\n    item['participantCount'] = 0\n\n# Transcript word count estimate (useful for AI token planning)\nif has_transcript:\n    item['transcriptWordCount'] = len(raw_tx.split())\nelse:\n    item['transcriptWordCount'] = 0\n\n# Validation reason (helpful for debugging dropped items)\nif not item['isValidMeeting']:\n    if not has_audio and not has_transcript:\n        item['invalidReason'] = 'No audioUrl or rawTranscript provided'\n    elif has_audio and len(audio_url) <= 10:\n        item['invalidReason'] = 'audioUrl too short to be valid'\n    else:\n        item['invalidReason'] = 'rawTranscript too short (minimum 50 characters)'\nelse:\n    item['invalidReason'] = None\n\nreturn {'json': item}"
      },
      "typeVersion": 2
    },
    {
      "id": "08f94761-7e9a-40ce-8687-cd10eed9dddf",
      "name": "Filter Valid Meetings",
      "type": "n8n-nodes-base.filter",
      "position": [
        2080,
        208
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.isValidMeeting }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "df6fbe52-476c-4431-bb8c-add40f215321",
      "name": "Route - Audio or Text",
      "type": "n8n-nodes-base.filter",
      "position": [
        2320,
        208
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "true"
              },
              "leftValue": "={{ $json.needsTranscription }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "06b8d626-1565-4b96-a99d-446ee509704d",
      "name": "Whisper - Transcribe Audio",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        2576,
        96
      ],
      "parameters": {
        "url": "https://api.openai.com/v1/audio/transcriptions",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "multipart-form-data",
        "sendHeaders": true,
        "bodyParameters": {
          "parameters": [
            {
              "name": "model",
              "value": "whisper-1"
            },
            {
              "name": "url",
              "value": "={{ $json.audioUrl }}"
            },
            {
              "name": "response_format",
              "value": "text"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $credentials.openAiApi.apiKey }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "7f5b7020-e576-4247-bb19-008d9a64cd02",
      "name": "Merge Transcript into Context",
      "type": "n8n-nodes-base.set",
      "position": [
        2928,
        208
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "name": "rawTranscript",
              "type": "string",
              "value": "={{ $json.text || $json.rawTranscript }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "66436a69-1c1f-48c6-8416-9bf7d7907a34",
      "name": "AI - Extract Meeting Intelligence",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "position": [
        3184,
        208
      ],
      "parameters": {
        "text": "=You are an expert meeting analyst. Analyse the following meeting transcript and return a structured JSON object.\n\nMeeting Metadata:\n- Title: {{ $json.meetingTitle }}\n- Date: {{ $json.meetingDate }}\n- Participants: {{ $json.participants }}\n- Duration: {{ $json.durationMinutes }} minutes\n- Platform: {{ $json.platform }}\n\nTranscript:\n{{ $json.rawTranscript }}\n\nReturn ONLY valid JSON with this exact schema (no markdown, no preamble):\n{\n  \"summary\": \"<2-4 sentence executive summary>\",\n  \"keyTopics\": [\"<topic 1>\", \"<topic 2>\"],\n  \"decisions\": [\n    {\n      \"decision\": \"<what was decided>\",\n      \"rationale\": \"<brief reason>\",\n      \"decidedBy\": \"<name or team>\"\n    }\n  ],\n  \"actionItems\": [\n    {\n      \"task\": \"<task description>\",\n      \"owner\": \"<person responsible>\",\n      \"dueDate\": \"<YYYY-MM-DD or null>\",\n      \"priority\": \"<high|medium|low>\"\n    }\n  ],\n  \"risks\": [\"<risk 1>\", \"<risk 2>\"],\n  \"followUpDate\": \"<YYYY-MM-DD or null>\"\n}",
        "options": {},
        "promptType": "define"
      },
      "typeVersion": 1.6
    },
    {
      "id": "a0b8a96d-ea88-4fee-932b-39dd00b46493",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        3200,
        416
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4.1-mini"
        },
        "options": {},
        "builtInTools": {}
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "890065fc-ef9e-4360-87e1-7ea54c8aa334",
      "name": "JS - Parse & Structure Output",
      "type": "n8n-nodes-base.code",
      "position": [
        3568,
        208
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const item = $input.item.json;\n\n// Safely parse the AI response\nlet intelligence = {};\ntry {\n  const raw = item.response || item.output || item.text || '{}';\n  const clean = raw.replace(/```json|```/g, '').trim();\n  intelligence = JSON.parse(clean);\n} catch (e) {\n  intelligence = {\n    summary: item.response || 'Parsing failed \u2014 see raw output.',\n    keyTopics: [],\n    decisions: [],\n    actionItems: [],\n    risks: [],\n    followUpDate: null\n  };\n}\n\nreturn {\n  json: {\n    // Pass through original meeting metadata\n    meetingId: item.meetingId,\n    meetingTitle: item.meetingTitle,\n    meetingDate: item.meetingDate,\n    participants: item.participants,\n    platform: item.platform,\n    durationMinutes: item.durationMinutes,\n    rawTranscript: item.rawTranscript,\n\n    // Structured intelligence\n    summary: intelligence.summary || '',\n    keyTopics: intelligence.keyTopics || [],\n    decisions: intelligence.decisions || [],\n    actionItems: intelligence.actionItems || [],\n    risks: intelligence.risks || [],\n    followUpDate: intelligence.followUpDate || null,\n\n    // Convenience fields for downstream nodes\n    actionItemCount: (intelligence.actionItems || []).length,\n    decisionCount: (intelligence.decisions || []).length,\n    processedAt: new Date().toISOString()\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "d850bfa1-47e3-4d38-b516-3e91c9347fe4",
      "name": "JS - Split Action Items",
      "type": "n8n-nodes-base.code",
      "position": [
        4048,
        112
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Expand action items into individual items for the PM sync loop\nconst item = $input.item.json;\nconst actionItems = item.actionItems || [];\n\nif (actionItems.length === 0) {\n  return [{ json: { ...item, _noActionItems: true } }];\n}\n\nreturn actionItems.map(ai => ({\n  json: {\n    ...item,\n    currentActionItem: ai,\n    taskTitle: ai.task || 'Untitled task',\n    taskOwner: ai.owner || 'Unassigned',\n    taskDueDate: ai.dueDate || null,\n    taskPriority: ai.priority || 'medium',\n    taskNotes: `From meeting: ${item.meetingTitle} (${item.meetingDate})`\n  }\n}));"
      },
      "typeVersion": 2
    },
    {
      "id": "7f4fbae8-5128-4276-9a44-bcb42b7433d3",
      "name": "Filter Has Action Items",
      "type": "n8n-nodes-base.filter",
      "position": [
        4288,
        112
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "conditions": [
            {
              "operator": {
                "type": "boolean",
                "operation": "false"
              },
              "leftValue": "={{ $json._noActionItems }}"
            }
          ]
        }
      },
      "typeVersion": 2.2
    },
    {
      "id": "0ffb0f9e-3506-4729-95fe-8ad5f2465cd6",
      "name": "Asana - Create Task",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4528,
        112
      ],
      "parameters": {
        "url": "https://app.asana.com/api/1.0/tasks",
        "method": "POST",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          }
        },
        "jsonBody": "={\n  \"data\": {\n    \"name\": \"{{ $json.taskTitle }}\",\n    \"notes\": \"{{ $json.taskNotes }}\",\n    \"assignee\": \"{{ $json.taskOwner }}\",\n    \"due_on\": \"{{ $json.taskDueDate }}\",\n    \"projects\": [\"YOUR_PROJECT_ID\"]\n  }\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer YOUR_ASANA_PAT"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "8d1fc55f-0492-4344-95b3-2c76f1fad1e5",
      "name": "Google Sheets - Log Meeting",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4528,
        320
      ],
      "parameters": {
        "url": "https://sheets.googleapis.com/v4/spreadsheets/YOUR_SHEET_ID/values/MeetingLog!A1:append?valueInputOption=USER_ENTERED",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"values\": [[\n    \"{{ $json.meetingDate }}\",\n    \"{{ $json.meetingTitle }}\",\n    \"{{ $json.participants }}\",\n    \"{{ $json.platform }}\",\n    \"{{ $json.durationMinutes }}\",\n    \"{{ $json.summary }}\",\n    \"{{ $json.decisionCount }}\",\n    \"{{ $json.actionItemCount }}\",\n    \"{{ $json.followUpDate || '' }}\",\n    \"{{ $json.processedAt }}\"\n  ]]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $credentials.googleSheetsOAuth2Api.accessToken }}"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "cd5c7591-b11e-4d27-a440-c7e2bd2a5a0d",
      "name": "Send Summary Notification",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4528,
        512
      ],
      "parameters": {
        "url": "https://api.sendgrid.com/v3/mail/send",
        "method": "POST",
        "options": {},
        "jsonBody": "={\n  \"personalizations\": [{\n    \"to\": [{ \"email\": \"your-team@company.com\" }]\n  }],\n  \"from\": { \"email\": \"meetings@company.com\", \"name\": \"Meeting Intelligence Hub\" },\n  \"subject\": \"Meeting Summary: {{ $json.meetingTitle }} ({{ $json.meetingDate }})\",\n  \"content\": [{\n    \"type\": \"text/plain\",\n    \"value\": \"MEETING SUMMARY\\n\\nTitle: {{ $json.meetingTitle }}\\nDate: {{ $json.meetingDate }}\\nParticipants: {{ $json.participants }}\\nDuration: {{ $json.durationMinutes }} minutes\\n\\nSUMMARY\\n{{ $json.summary }}\\n\\nKEY DECISIONS ({{ $json.decisionCount }})\\n{{ JSON.stringify($json.decisions, null, 2) }}\\n\\nACTION ITEMS ({{ $json.actionItemCount }})\\n{{ JSON.stringify($json.actionItems, null, 2) }}\\n\\nRISKS\\n{{ JSON.stringify($json.risks, null, 2) }}\\n\\nFollow-up Date: {{ $json.followUpDate || 'Not scheduled' }}\\n\\n-- Meeting Intelligence Hub\"\n  }]\n}",
        "sendBody": true,
        "sendHeaders": true,
        "specifyBody": "json",
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer YOUR_SENDGRID_API_KEY"
            }
          ]
        }
      },
      "typeVersion": 4.2
    },
    {
      "id": "bfbbaf79-e5d3-4b60-960a-37fa8fb1997f",
      "name": "Respond to Webhook",
      "type": "n8n-nodes-base.respondToWebhook",
      "position": [
        4800,
        320
      ],
      "parameters": {
        "options": {},
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: true, meetingId: $json.meetingId, actionItems: $json.actionItemCount, decisions: $json.decisionCount, processedAt: $json.processedAt }) }}"
      },
      "typeVersion": 1.1
    },
    {
      "id": "ec6e3670-0450-4216-81df-1f1c772b04cf",
      "name": "Wait - Review Buffer",
      "type": "n8n-nodes-base.wait",
      "position": [
        3808,
        208
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "versionId": "eaeb0a96-4f1b-44ba-9daa-a4023ab7fab6",
  "connections": {
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "AI - Extract Meeting Intelligence",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Asana - Create Task": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait - Review Buffer": {
      "main": [
        [
          {
            "node": "JS - Split Action Items",
            "type": "main",
            "index": 0
          },
          {
            "node": "Google Sheets - Log Meeting",
            "type": "main",
            "index": 0
          },
          {
            "node": "Send Summary Notification",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Valid Meetings": {
      "main": [
        [
          {
            "node": "Route - Audio or Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route - Audio or Text": {
      "main": [
        [
          {
            "node": "Whisper - Transcribe Audio",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter Has Action Items": {
      "main": [
        [
          {
            "node": "Asana - Create Task",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Split Action Items": {
      "main": [
        [
          {
            "node": "Filter Has Action Items",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Scheduled Meetings": {
      "main": [
        [
          {
            "node": "Normalise Meeting Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalise Meeting Context": {
      "main": [
        [
          {
            "node": "Python - Validate & Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Python - Validate & Route": {
      "main": [
        [
          {
            "node": "Filter Valid Meetings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Summary Notification": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Whisper - Transcribe Audio": {
      "main": [
        [
          {
            "node": "Merge Transcript into Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Google Sheets - Log Meeting": {
      "main": [
        [
          {
            "node": "Respond to Webhook",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Webhook - New Meeting Upload": {
      "main": [
        [
          {
            "node": "Normalise Meeting Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JS - Parse & Structure Output": {
      "main": [
        [
          {
            "node": "Wait - Review Buffer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Transcript into Context": {
      "main": [
        [
          {
            "node": "AI - Extract Meeting Intelligence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI - Extract Meeting Intelligence": {
      "main": [
        [
          {
            "node": "JS - Parse & Structure Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}