{
  "name": "Lumeor | AI alert grouping and incident summaries",
  "nodes": [
    {
      "parameters": {
        "content": "## Alert grouping and AI incident summary starter\n\nAlerts from CloudWatch (through SNS), Azure Monitor, Google Cloud Monitoring or any tool that can POST JSON land on one webhook. Code normalizes them, fingerprints them by service, environment and title, and suppresses repeats inside a 10-minute window. For each new critical group, the workflow pulls recent error lines from CloudWatch Logs and recent commits from GitHub, masks secrets and personal data, and asks Claude for a short summary with hypotheses clearly labeled. The summary goes to the on-call Slack channel. Warnings are appended to a Google Sheet for a daily digest.\n\n### Setup\n1. Point your alert sources at the **Alert intake** webhook. For SNS, create an HTTPS subscription; the confirmation link is posted to the ops channel for you to open once.\n2. In **Normalize and group alerts**, edit `SERVICES` (service name to CloudWatch log group and GitHub repo) and `WINDOW_MINUTES`.\n3. Replace `REPLACE_WITH_AWS_REGION` in the CloudWatch URL and `REPLACE_WITH_GITHUB_OWNER` in the GitHub URL.\n4. Set `REPLACE_WITH_ONCALL_CHANNEL_ID`, `REPLACE_WITH_OPS_CHANNEL_ID` and `REPLACE_WITH_DIGEST_SHEET_ID` (tab \"Alert digest\").\n5. Credentials: AWS (IAM user or role with `logs:FilterLogEvents` only), GitHub API (read-only token), Anthropic API, Slack, Google Sheets OAuth2.\n\nGrouping state lives in workflow static data, which only persists for an active (production) workflow, not manual test runs.\n\nModel: `claude-opus-5-5` for critical incidents. `claude-haiku-5-5` is a cheaper option for high-volume, routine alert groups.\n\nNot included: the daily digest send, Azure Log Analytics and Cloud Logging queries. Copy the CloudWatch step pattern for those.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/ai-log-and-alert-summaries/",
        "height": 900,
        "width": 540,
        "color": 7
      },
      "id": "a39b1029-695a-4bfb-a8ef-6f910f040e10",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -260
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "alerts-intake",
        "options": {}
      },
      "id": "8a3b4d00-bf2e-4057-8193-6d6338328367",
      "name": "Alert intake",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        300
      ],
      "webhookId": "042f322d-702e-41ca-9334-36791a7d210d"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Config: map each service to its log group and repo. Unknown services still alert, just without context.\nconst SERVICES = {\n  'checkout-api': { logGroup: '/aws/lambda/checkout-api', repo: 'checkout-api' },\n  'web': { logGroup: '/ecs/web', repo: 'web' },\n};\nconst WINDOW_MINUTES = 10;\n\nconst state = $getWorkflowStaticData('global');\nstate.groups = state.groups || {};\nconst now = Date.now();\n// Forget groups that have been quiet for a day.\nfor (const [k, g] of Object.entries(state.groups)) if (now - g.lastSeen > 86400000) delete state.groups[k];\n\nconst sev = (s) => {\n  const v = String(s || '').toLowerCase();\n  if (/crit|sev ?0|sev ?1|p1|alarm|error|fatal|high/.test(v)) return 'critical';\n  if (/warn|sev ?2|p2|medium/.test(v)) return 'warning';\n  return 'info';\n};\n\nfunction normalize(raw) {\n  let b = raw;\n  if (typeof b === 'string') { try { b = JSON.parse(b); } catch { return { source: 'unknown', service: 'unknown', environment: 'unknown', severity: 'warning', title: b.slice(0, 200), link: null, at: new Date().toISOString() }; } }\n  // AWS SNS\n  if (b.Type === 'SubscriptionConfirmation') return { kind: 'sns_confirm', subscribeUrl: b.SubscribeURL, topic: b.TopicArn };\n  if (b.Type === 'Notification') {\n    let m = b.Message; try { m = JSON.parse(m); } catch {}\n    const dims = m?.Trigger?.Dimensions || [];\n    return {\n      source: 'cloudwatch',\n      service: (dims.find((d) => /function|service|cluster/i.test(d.name))?.value) || m?.AlarmName || 'unknown',\n      environment: /prod/i.test(m?.AlarmName || '') ? 'production' : 'unknown',\n      severity: m?.NewStateValue === 'ALARM' ? 'critical' : 'info',\n      title: m?.AlarmName ? m.AlarmName + ': ' + (m.NewStateReason || '') : String(b.Subject || 'SNS notification'),\n      link: null,\n      at: m?.StateChangeTime || b.Timestamp,\n    };\n  }\n  // Azure Monitor common alert schema\n  if (b.schemaId === 'azureMonitorCommonAlertSchema') {\n    const e = b.data?.essentials || {};\n    return { source: 'azure', service: (e.configurationItems || [])[0] || 'unknown', environment: 'unknown', severity: e.monitorCondition === 'Resolved' ? 'info' : ['Sev0', 'Sev1'].includes(e.severity) ? 'critical' : e.severity === 'Sev2' ? 'warning' : 'info', title: e.alertRule || 'Azure alert', link: null, at: e.firedDateTime };\n  }\n  // Google Cloud Monitoring\n  if (b.incident) {\n    const inc = b.incident;\n    return { source: 'gcp', service: inc.resource?.labels?.service_name || inc.resource_name || 'unknown', environment: 'unknown', severity: inc.state === 'closed' ? 'info' : sev(inc.severity || 'critical'), title: inc.policy_name + ': ' + (inc.summary || ''), link: inc.url, at: new Date((inc.started_at || 0) * 1000).toISOString() };\n  }\n  // Generic JSON: { service, environment, severity, title, link, timestamp }\n  return { source: b.source || 'generic', service: b.service || 'unknown', environment: b.environment || 'unknown', severity: sev(b.severity), title: b.title || b.message || 'Alert', link: b.link || null, at: b.timestamp || new Date().toISOString() };\n}\n\nconst out = [];\nfor (const item of $input.all()) {\n  const raw = item.json.body;\n  const list = Array.isArray(raw) ? raw : [raw];\n  for (const r of list) {\n    const a = normalize(r);\n    if (a.kind === 'sns_confirm') { out.push({ json: { route: 'sns_confirm', ...a } }); continue; }\n    // Fingerprint: strip numbers and IDs so \"timeout after 3012ms\" and \"timeout after 2950ms\" group together.\n    const titleKey = String(a.title).toLowerCase().replace(/[0-9a-f]{8,}|\\d+/g, '#').slice(0, 120);\n    const fp = [a.service, a.environment, titleKey].join('|');\n    const g = state.groups[fp] || { firstSeen: now, lastSeen: 0, count: 0, notifiedAt: 0 };\n    g.count += 1; g.lastSeen = now;\n    state.groups[fp] = g;\n    const recentlyNotified = now - g.notifiedAt < WINDOW_MINUTES * 60000;\n    if (a.severity === 'info' || recentlyNotified) continue; // resolved, informational or already summarized\n    if (a.severity === 'critical') g.notifiedAt = now;\n    const svc = SERVICES[a.service] || {};\n    const windowStart = Math.min(g.firstSeen, now - 30 * 60000);\n    out.push({\n      json: {\n        route: a.severity === 'critical' ? 'summarize' : 'digest',\n        alert: a,\n        fingerprint: fp,\n        groupCount: g.count,\n        firstSeen: new Date(g.firstSeen).toISOString(),\n        logGroup: svc.logGroup || null,\n        repo: svc.repo || null,\n        logQuery: JSON.stringify({ logGroupName: svc.logGroup || 'REPLACE_WITH_DEFAULT_LOG_GROUP', startTime: windowStart, endTime: now, filterPattern: '?ERROR ?Error ?Exception ?FATAL', limit: 200 }),\n        commitsSince: new Date(now - 6 * 3600000).toISOString(),\n      },\n    });\n  }\n}\nreturn out;\n"
      },
      "id": "cbf5c2be-4d3f-47bb-933c-0a3b977b93c8",
      "name": "Normalize and group alerts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "ca8ac358-2f20-429d-b628-3991857918aa",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "summarize",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "critical: summarize"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "8bd6d2b9-fa74-460b-9169-b057b4b06014",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "digest",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "warning: digest"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "2abe6174-907c-4289-9054-4b1898834989",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "sns_confirm",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "SNS confirmation"
            }
          ]
        },
        "options": {}
      },
      "id": "44c483fe-06bc-4d15-bd82-fa38e80de81c",
      "name": "Route by severity",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://logs.REPLACE_WITH_AWS_REGION.amazonaws.com/",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "aws",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "X-Amz-Target",
              "value": "Logs_20140328.FilterLogEvents"
            }
          ]
        },
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/x-amz-json-1.1",
        "body": "={{ $json.logQuery }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "7dfed16d-3c2e-4745-ac1a-b0347c51b94a",
      "name": "CloudWatch: recent error logs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        100
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.github.com/repos/REPLACE_WITH_GITHUB_OWNER/{{ $('Normalize and group alerts').item.json.repo || 'REPLACE_WITH_DEFAULT_REPO' }}/commits",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "githubApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "since",
              "value": "={{ $('Normalize and group alerts').item.json.commitsSince }}"
            },
            {
              "name": "per_page",
              "value": "10"
            }
          ]
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/vnd.github+json"
            }
          ]
        },
        "options": {
          "timeout": 20000,
          "response": {
            "response": {
              "responseFormat": "text",
              "outputPropertyName": "data"
            }
          }
        }
      },
      "id": "914c5c87-ebab-478a-b3d3-d8ba8166063a",
      "name": "GitHub: recent commits",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Mask secrets and personal data BEFORE anything is sent to a model. Test these patterns on your own logs.\nconst REDACTIONS = [\n  [/AKIA[0-9A-Z]{16}/g, '[AWS_KEY]'],\n  [/(?:bearer|token|authorization)[=: ]+[A-Za-z0-9._~+\\/-]{12,}/gi, '[TOKEN]'],\n  [/eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}/g, '[JWT]'],\n  [/(?:password|passwd|secret|api[_-]?key|access[_-]?key)[\"']?\\s*[=:]\\s*[\"']?[^\\s\"'&]+/gi, '[SECRET]'],\n  [/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g, '[EMAIL]'],\n  [/\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b/g, '[IP]'],\n  [/\\b(?:\\d[ -]?){13,16}\\b/g, '[CARD]'],\n  [/([?&](?:token|key|sig|signature|code)=)[^&\\s]+/gi, '$1[REDACTED]'],\n];\nconst redact = (s) => REDACTIONS.reduce((acc, [re, rep]) => acc.replace(re, rep), String(s));\nconst signature = (m) => m.replace(/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '<id>').replace(/\\d+(\\.\\d+)?/g, '<n>').slice(0, 160);\nconst MAX_LOG_BYTES = 6000;\n\nconst schema = {\n  type: 'object',\n  additionalProperties: false,\n  required: ['headline', 'impact', 'since', 'scale', 'recent_changes', 'hypotheses', 'unknowns'],\n  properties: {\n    headline: { type: 'string', description: 'What is failing, one line.' },\n    impact: { type: 'string', description: 'Who or what is affected, or \"unknown\".' },\n    since: { type: 'string' },\n    scale: { type: 'string', description: 'Counts and rates from the evidence.' },\n    recent_changes: { type: 'array', items: { type: 'string' } },\n    hypotheses: { type: 'array', items: { type: 'string' }, description: 'At most two, each starting with \"Hypothesis:\".' },\n    unknowns: { type: 'array', items: { type: 'string' } },\n  },\n};\n\nreturn $input.all().map((item, i) => {\n  const g = $('Normalize and group alerts').itemMatching(i).json;\n  const logs = $('CloudWatch: recent error logs').itemMatching(i).json;\n  const events = Array.isArray(logs.events) ? logs.events : [];\n  const counts = {};\n  for (const e of events) { const k = signature(redact(e.message || '')); counts[k] = (counts[k] || 0) + 1; }\n  const topSignatures = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([sig, n]) => n + 'x ' + sig);\n  let sample = ''; for (const e of events.slice(-20)) { const line = redact((e.message || '').trim()).slice(0, 400) + '\\n'; if (sample.length + line.length > MAX_LOG_BYTES) break; sample += line; }\n  let commitList = [];\n  try { commitList = JSON.parse(item.json.data || '[]'); } catch { commitList = []; }\n  const commits = (Array.isArray(commitList) ? commitList : []).slice(0, 10).map((c) => ({ sha: String(c.sha || '').slice(0, 7), date: c.commit?.author?.date, author: c.commit?.author?.name, message: redact((c.commit?.message || '').split('\\n')[0]), url: c.html_url }));\n  const logError = logs.error ? 'Log query failed: ' + (logs.error.message || 'unknown') : null;\n\n  const evidence = {\n    alert: { ...g.alert, title: redact(g.alert.title) },\n    occurrences_in_group: g.groupCount,\n    group_first_seen: g.firstSeen,\n    log_group: g.logGroup,\n    log_error_signatures: topSignatures,\n    log_sample: sample || '(no log lines returned)',\n    log_query_problem: logError,\n    recent_commits: commits,\n  };\n  return {\n    json: {\n      ...g,\n      commits,\n      logError,\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 2000,\n        system: 'You write incident summaries for on-call engineers. Use only the evidence provided. Say \"unknown\" instead of guessing. Label every hypothesis as a hypothesis and give at most two. Never recommend destructive actions such as deleting data, dropping tables, force-pushing or restarting production databases.',\n        messages: [{ role: 'user', content: '<evidence>\\n' + JSON.stringify(evidence, null, 1) + '\\n</evidence>' }],\n        output_config: { format: { type: 'json_schema', schema } },\n      },\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "adcf6204-3fd7-4850-9435-7dbc5b3b4122",
      "name": "Redact and build prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        100
      ]
    },
    {
      "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"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "f6970945-65e0-4ad4-96c6-2726ac6f8fb0",
      "name": "Claude: summarize incident",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        100
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Pull the structured JSON out of a Messages API response.\n// Opus 5.5 always thinks, so a (possibly empty) thinking block can come before the text block.\nfunction parseClaude(res) {\n  if (!res || typeof res !== 'object') return { error: 'Empty response from the Messages API' };\n  if (res.stop_reason === 'refusal') {\n    return { error: 'Model declined the request (refusal' + (res.stop_details?.category ? ': ' + res.stop_details.category : '') + ')' };\n  }\n  if (res.stop_reason === 'max_tokens') return { error: 'Response hit max_tokens before the JSON was complete' };\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) return { error: 'No text block in the response' };\n  try {\n    return { data: JSON.parse(block.text) };\n  } catch (e) {\n    return { error: 'Could not parse model JSON: ' + e.message };\n  }\n}\n\nreturn $input.all().map((item, i) => {\n  const g = $('Redact and build prompt').itemMatching(i).json;\n  const { data, error } = parseClaude(item.json.error ? null : item.json);\n  const a = g.alert;\n  const header = ':rotating_light: *' + a.service + '* (' + a.environment + ', ' + a.source + ') | ' + g.groupCount + ' alert(s) in group since ' + g.firstSeen;\n  let text;\n  if (data) {\n    text = [\n      header,\n      '*' + data.headline + '*',\n      '*Impact:* ' + data.impact + '  *Since:* ' + data.since + '  *Scale:* ' + data.scale,\n      data.recent_changes.length ? '*Recent changes:*\\n' + data.recent_changes.map((c) => '- ' + c).join('\\n') : '*Recent changes:* none found',\n      data.hypotheses.length ? data.hypotheses.map((h) => '_' + h + '_').join('\\n') : '',\n      data.unknowns.length ? '*Unknown:* ' + data.unknowns.join('; ') : '',\n      g.commits.length ? '*Commits:* ' + g.commits.slice(0, 3).map((c) => '<' + c.url + '|' + c.sha + '>').join(' ') : '',\n      a.link ? '<' + a.link + '|Open alert>' : '',\n      g.logError ? ':grey_question: ' + g.logError : '',\n    ].filter(Boolean).join('\\n');\n  } else {\n    // Exceptions path: the summary failed, so post the raw alert rather than nothing.\n    text = header + '\\n*' + a.title + '*\\n:warning: AI summary unavailable (' + (item.json.error?.message || error) + '). Raw alert above.' + (a.link ? '\\n<' + a.link + '|Open alert>' : '');\n  }\n  return { json: { text, summaryFailed: !data }, pairedItem: { item: i } };\n});\n"
      },
      "id": "45646af5-cc9d-4c91-a55e-f1f4748257cf",
      "name": "Format Slack summary",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        100
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ONCALL_CHANNEL_ID",
          "mode": "id"
        },
        "text": "={{ $json.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "803df8fb-1f04-495a-a600-cb017d3564ec",
      "name": "Slack: post to on-call",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        100
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "value": "REPLACE_WITH_DIGEST_SHEET_ID",
          "mode": "id"
        },
        "sheetName": {
          "__rl": true,
          "value": "Alert digest",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "received_at": "={{ $now.toISO() }}",
            "service": "={{ $json.alert.service }}",
            "environment": "={{ $json.alert.environment }}",
            "source": "={{ $json.alert.source }}",
            "title": "={{ $json.alert.title }}",
            "fingerprint": "={{ $json.fingerprint }}",
            "group_count": "={{ $json.groupCount }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "received_at",
              "displayName": "received_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "service",
              "displayName": "service",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "environment",
              "displayName": "environment",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "source",
              "displayName": "source",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "title",
              "displayName": "title",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "fingerprint",
              "displayName": "fingerprint",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "group_count",
              "displayName": "group_count",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "27e8389e-dfab-4650-8023-62516abd5a1b",
      "name": "Google Sheets: daily digest log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        660,
        320
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_OPS_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=SNS subscription for {{ $json.topic }} needs confirming. Open this link once: {{ $json.subscribeUrl }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "33a99f35-c890-45e0-8518-c759527a6439",
      "name": "Slack: ops exceptions",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        660,
        520
      ]
    }
  ],
  "connections": {
    "Alert intake": {
      "main": [
        [
          {
            "node": "Normalize and group alerts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize and group alerts": {
      "main": [
        [
          {
            "node": "Route by severity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by severity": {
      "main": [
        [
          {
            "node": "CloudWatch: recent error logs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Google Sheets: daily digest log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: ops exceptions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "CloudWatch: recent error logs": {
      "main": [
        [
          {
            "node": "GitHub: recent commits",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "GitHub: recent commits": {
      "main": [
        [
          {
            "node": "Redact and build prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Redact and build prompt": {
      "main": [
        [
          {
            "node": "Claude: summarize incident",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: summarize incident": {
      "main": [
        [
          {
            "node": "Format Slack summary",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Slack summary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Slack summary": {
      "main": [
        [
          {
            "node": "Slack: post to on-call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
