{
  "name": "Support email triage (Gmail + OpenAI + Pinecone)",
  "nodes": [
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "labelIds": [
            "INBOX"
          ],
          "readStatus": "unread"
        },
        "options": {}
      },
      "id": "96625b78-455c-595e-8310-1bcf84f5714f",
      "name": "Gmail Trigger",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_GMAIL",
          "name": "Gmail support inbox (client account)"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Capture the fields every later node needs.\nreturn $input.all().map(item => {\n  const m = item.json;\n  const header = (name) => (m.payload?.headers || []).find(h => h.name.toLowerCase() === name)?.value;\n  const from = m.from?.value?.[0]?.address || m.from || header('from') || '';\n  const body = (m.text || m.textPlain || m.snippet || '').replace(/\\r/g, '').trim();\n  return { json: {\n    messageId: m.id,\n    threadId: m.threadId,\n    sender: String(from).toLowerCase(),\n    subject: m.subject || header('subject') || '(no subject)',\n    body: body.slice(0, 6000),\n    receivedAt: m.date || new Date().toISOString(),\n  } };\n});"
      },
      "id": "e23b3b22-5fe9-5255-9896-249df9c330ff",
      "name": "Normalize email",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"gpt-4o-mini\", temperature: 0, response_format: { type: \"json_object\" }, messages: [ { role: \"system\", content: \"You triage emails for an agency support inbox. Return JSON only: {\\\"is_support\\\": boolean, \\\"category\\\": \\\"outage|billing|change_request|access|how_to|other_support|sales|newsletter|auto_reply|spam\\\", \\\"confidence\\\": number 0-1, \\\"search_query\\\": \\\"short question to search the knowledge base\\\"}. Support means a client asks for help with a service, invoice, access, change or problem.\" }, { role: \"user\", content: \"Subject: \" + $json.subject + \"\\n\\n\" + $json.body } ] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "4989c21b-5645-5a09-96ec-aea3b01f4634",
      "name": "AI classify",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        300
      ],
      "credentials": {
        "openAiApi": {
          "id": "REPLACE_OPENAI",
          "name": "OpenAI (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "jsCode": "// Parse the classifier's JSON answer; anything unparsable is treated as \"needs a human\".\nconst emails = $('Normalize email').all();\nreturn $input.all().map((item, i) => {\n  const email = emails[i].json;\n  let c = {};\n  try { c = JSON.parse(item.json.choices[0].message.content); } catch (e) { c = { category: 'unknown', is_support: false, confidence: 0, parse_error: true }; }\n  const isSupport = c.is_support === true && Number(c.confidence) >= 0.6;\n  return { json: { ...email, category: c.category || 'unknown', isSupport, classifierConfidence: Number(c.confidence) || 0,\n                   searchQuery: c.search_query || `${email.subject}\\n${email.body}`.slice(0, 800), parseError: !!c.parse_error } };\n});"
      },
      "id": "1122d6b7-edf3-5d49-bb43-9786a9cb5c8c",
      "name": "Parse classification",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        280
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.isSupport }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "61466acd-f416-512a-abf1-759b1043f736",
      "name": "Is support?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        880,
        280
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/embeddings",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"text-embedding-3-small\", input: $json.searchQuery }) }}",
        "options": {}
      },
      "id": "d113e8bb-a04e-5ec1-aa28-e3f5c36faefb",
      "name": "Embed question",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        180
      ],
      "credentials": {
        "openAiApi": {
          "id": "REPLACE_OPENAI",
          "name": "OpenAI (client account)"
        }
      },
      "onError": "continueErrorOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.PINECONE_INDEX_HOST }}/query",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "pineconeApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ vector: $json.data[0].embedding, topK: 4, includeMetadata: true, namespace: $env.PINECONE_NAMESPACE || \"\" }) }}",
        "options": {}
      },
      "id": "a8cdad6c-7776-5785-8545-699c24c41aab",
      "name": "Pinecone query",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        160
      ],
      "credentials": {
        "pineconeApi": {
          "id": "REPLACE_PINECONE",
          "name": "Pinecone (client account)"
        }
      },
      "onError": "continueErrorOutput",
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "jsCode": "// Decide if the knowledge base is good enough to answer on its own.\nconst MIN_SCORE = 0.78;      // top match must be at least this similar\nconst MIN_MATCHES = 1;       // number of chunks above MIN_SCORE\nconst emails = $('Parse classification').all();\nreturn $input.all().map((item, i) => {\n  const email = emails[i].json;\n  const matches = (item.json.matches || []).filter(m => m.score >= MIN_SCORE);\n  const context = matches.slice(0, 4).map((m, k) => `[${k + 1}] (${m.metadata?.source || m.id}) ${m.metadata?.text || ''}`).join('\\n\\n');\n  return { json: { ...email, topScore: item.json.matches?.[0]?.score || 0, sources: matches.slice(0, 4).map(m => m.metadata?.source || m.id),\n                   context, sufficient: matches.length >= MIN_MATCHES } };\n});"
      },
      "id": "3a8cfb05-2831-5f88-98b9-a3aec146627d",
      "name": "Knowledge gate",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c2",
              "leftValue": "={{ $json.sufficient }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "86262fdb-61b6-5626-bbc7-ff48ebafd59e",
      "name": "Enough knowledge?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        160
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"gpt-4o-mini\", temperature: 0.3, messages: [ { role: \"system\", content: \"You write replies for the agency support team. Use ONLY the numbered knowledge snippets. If they do not answer the question, say a team member will follow up. Friendly, concise, no promises beyond the snippets, sign as \\\"The Support Team\\\". Plain text.\" }, { role: \"user\", content: \"Customer email:\\n\" + $json.body + \"\\n\\nKnowledge snippets:\\n\" + $json.context } ] }) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "554bda73-2b55-57fc-a136-e0b1d7356fb5",
      "name": "AI draft reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1980,
        60
      ],
      "credentials": {
        "openAiApi": {
          "id": "REPLACE_OPENAI",
          "name": "OpenAI (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "operation": "reply",
        "messageId": "={{ $('Knowledge gate').item.json.messageId }}",
        "emailType": "text",
        "message": "={{ $json.choices[0].message.content }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "7e0b7833-3690-5c46-81fe-e6468b24c91e",
      "name": "Reply in thread",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2200,
        60
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_GMAIL",
          "name": "Gmail support inbox (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "jsCode": "const e = $('Knowledge gate').all();\nreturn $input.all().map((item, i) => ({ json: { timestamp: new Date().toISOString(), messageId: e[i].json.messageId, threadId: e[i].json.threadId,\n  sender: e[i].json.sender, subject: e[i].json.subject, category: e[i].json.category, route: 'auto_reply',\n  topScore: e[i].json.topScore, sources: e[i].json.sources.join(', '), note: 'Replied in original thread' } }));"
      },
      "id": "7a6e911c-f1e8-5235-9632-49e1c2ff5856",
      "name": "Log reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2420,
        60
      ]
    },
    {
      "parameters": {
        "jsCode": "// Every path that cannot answer safely lands here. Restore the email context and keep the error text.\nreturn $input.all().map((item, i) => {\n  let email = item.json;\n  if (!email.messageId) { try { email = $('Normalize email').itemMatching(i).json; } catch (e) { email = {}; } }\n  const err = item.json.error ? (item.json.error.message || String(item.json.error)) : '';\n  return { json: { ...email, error: err, reason: err ? 'integration_error' : (email.isSupport === false ? 'unclear' : 'low_knowledge') } };\n});"
      },
      "id": "712c70a2-7a5f-5f52-8a44-5fc93169d611",
      "name": "Prepare escalation",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        320
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $env.SUPPORT_LEAD_EMAIL }}",
        "subject": "=Needs a human: {{ $json.subject }}",
        "emailType": "text",
        "message": "=From: {{ $json.sender }}\nThread: {{ $json.threadId }}\nTop KB score: {{ $json.topScore }}\n\n{{ $json.body }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "a0e2285b-097c-5f79-9c87-1c86e43410b0",
      "name": "Escalate to human",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        2200,
        320
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_GMAIL",
          "name": "Gmail support inbox (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "jsCode": "const prep = $('Prepare escalation').all();\nreturn $input.all().map((item, i) => { const j = prep[i].json;\n  return { json: { timestamp: new Date().toISOString(), messageId: j.messageId, threadId: j.threadId, sender: j.sender, subject: j.subject,\n  category: j.category || 'unknown', route: 'escalated', topScore: j.topScore ?? '', sources: (j.sources || []).join(', '),\n  note: j.error ? `Integration error: ${j.error}` : 'Knowledge base not sufficient, forwarded to support lead' } }; });"
      },
      "id": "c8c38c79-8e7e-58a5-8d0a-a357a9a2ac45",
      "name": "Log escalation",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2420,
        320
      ]
    },
    {
      "parameters": {
        "operation": "addLabels",
        "messageId": "={{ $json.messageId }}",
        "labelIds": [
          "={{ $env.GMAIL_LABEL_NOT_SUPPORT }}"
        ]
      },
      "id": "ee7a3d0b-3332-5296-9bb3-7bc9e56b503c",
      "name": "Label not-support",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1100,
        460
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_GMAIL",
          "name": "Gmail support inbox (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {
        "jsCode": "return $input.all().map(item => ({ json: { timestamp: new Date().toISOString(), messageId: item.json.messageId, threadId: item.json.threadId,\n  sender: item.json.sender, subject: item.json.subject, category: item.json.category, route: 'not_support', topScore: '', sources: '',\n  note: 'Labelled and left in inbox, no reply generated' } }));"
      },
      "id": "cc0f3f2a-97e5-5a07-9035-a86c10f23d69",
      "name": "Log skip",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        460
      ]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $env.LOG_SHEET_ID }}"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Support log"
        },
        "columns": {
          "mappingMode": "autoMapInputData",
          "value": {},
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "f745ff82-2bd2-5b96-a7df-624dd5828956",
      "name": "Write log row",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [
        2660,
        300
      ],
      "credentials": {
        "googleSheetsOAuth2Api": {
          "id": "REPLACE_SHEETS",
          "name": "Google Sheets log (client account)"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 2000
    },
    {
      "parameters": {},
      "id": "385d8052-a076-5655-8ed0-0237444f7c74",
      "name": "Error Trigger",
      "type": "n8n-nodes-base.errorTrigger",
      "typeVersion": 1,
      "position": [
        0,
        680
      ]
    },
    {
      "parameters": {
        "jsCode": "// Error workflow entry: turn n8n's error payload into one readable alert.\nconst e = $input.first().json;\nreturn [{ json: { workflow: e.workflow?.name, node: e.execution?.lastNodeExecuted, message: e.execution?.error?.message,\n                  executionUrl: e.execution?.url, at: new Date().toISOString() } }];"
      },
      "id": "3a5b887b-b68e-5966-a38e-8ce9e85892e8",
      "name": "Format alert",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        680
      ]
    },
    {
      "parameters": {
        "sendTo": "={{ $env.SUPPORT_LEAD_EMAIL }}",
        "subject": "=Support workflow failed at {{ $json.node }}",
        "emailType": "text",
        "message": "={{ $json.message }}\n{{ $json.executionUrl }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "690d8d48-8f8e-5adf-a7d4-cc108a5c6ab9",
      "name": "Alert support lead",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        440,
        680
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "REPLACE_GMAIL",
          "name": "Gmail support inbox (client account)"
        }
      }
    },
    {
      "parameters": {
        "width": 760,
        "height": 300,
        "color": 5,
        "content": "## Support email triage\n1. Create credentials: Gmail OAuth2, OpenAI, Pinecone, Google Sheets, then select them on the nodes.\n2. Set env vars: `PINECONE_INDEX_HOST`, `PINECONE_NAMESPACE`, `SUPPORT_LEAD_EMAIL`, `GMAIL_LABEL_NOT_SUPPORT`, `LOG_SHEET_ID`.\n3. Workflow settings: set *Error workflow* to this workflow so the Error Trigger fires.\n4. Tune `MIN_SCORE` in **Knowledge gate** (default 0.78) after the first test run.\n5. Activate. Every email ends in exactly one log row: auto_reply, escalated or not_support."
      },
      "id": "a824e68e-dc01-582b-9e7c-a44bdf59f499",
      "name": "Sticky: setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -40,
        -120
      ]
    }
  ],
  "connections": {
    "Gmail Trigger": {
      "main": [
        [
          {
            "node": "Normalize email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize email": {
      "main": [
        [
          {
            "node": "AI classify",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI classify": {
      "main": [
        [
          {
            "node": "Parse classification",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse classification": {
      "main": [
        [
          {
            "node": "Is support?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Is support?": {
      "main": [
        [
          {
            "node": "Embed question",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Label not-support",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embed question": {
      "main": [
        [
          {
            "node": "Pinecone query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pinecone query": {
      "main": [
        [
          {
            "node": "Knowledge gate",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Knowledge gate": {
      "main": [
        [
          {
            "node": "Enough knowledge?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Enough knowledge?": {
      "main": [
        [
          {
            "node": "AI draft reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI draft reply": {
      "main": [
        [
          {
            "node": "Reply in thread",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply in thread": {
      "main": [
        [
          {
            "node": "Log reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare escalation": {
      "main": [
        [
          {
            "node": "Escalate to human",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escalate to human": {
      "main": [
        [
          {
            "node": "Log escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Label not-support": {
      "main": [
        [
          {
            "node": "Log skip",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log reply": {
      "main": [
        [
          {
            "node": "Write log row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log escalation": {
      "main": [
        [
          {
            "node": "Write log row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log skip": {
      "main": [
        [
          {
            "node": "Write log row",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Error Trigger": {
      "main": [
        [
          {
            "node": "Format alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format alert": {
      "main": [
        [
          {
            "node": "Alert support lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "saveManualExecutions": true,
    "timezone": "America/Chicago"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}