{
  "nodes": [
    {
      "id": "sticky-how-it-works",
      "name": "How this flow works",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        500,
        -140
      ],
      "parameters": {
        "content": "## How this flow works\n- New mail lands, Claude sorts it into one of the agreed categories and writes a draft when a reply is genuinely needed.\n- Urgent mail and anything Claude could not read confidently get a Slack ping so a person sees it in minutes instead of hours.",
        "height": 260,
        "width": 640
      }
    },
    {
      "id": "trigger-new-email",
      "name": "When a new email arrives",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.4,
      "position": [
        -120,
        340
      ],
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": true,
        "filters": {}
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "code-prepare-email",
      "name": "Get the email ready",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        100,
        340
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Gmail's simplified output puts headers at the top level, raw output nests them.\n// Both shapes are handled so the flow keeps working if the Simplify toggle changes.\nconst m = $json;\nconst headers = m.headers || {};\nconst rawHeaders = m.payload && Array.isArray(m.payload.headers) ? m.payload.headers : [];\nconst headerValue = (name) => {\n  const h = rawHeaders.find((x) => x.name && x.name.toLowerCase() === name);\n  return h ? h.value : undefined;\n};\n\nconst from = m.From || m.from || headers.from || headerValue('from') || '';\nconst subject = m.Subject || m.subject || headers.subject || headerValue('subject') || '(no subject)';\nconst body = m.text || m.textPlain || m.snippet || m.textHtml || '';\n\n// Pull the bare address out of \"Dana Whitfield <dana@example.com>\" so the draft goes back to the right place\nconst match = String(from).match(/<([^>]+)>/);\nconst fromAddress = match ? match[1] : String(from).trim();\n\nconst cleanBody = String(body)\n  .replace(/\\r/g, '')\n  .replace(/\\n{3,}/g, '\\n\\n')\n  .trim()\n  .slice(0, 6000);\n\nreturn {\n  json: {\n    messageId: m.id || m.messageId || '',\n    threadId: m.threadId || '',\n    from: String(from).trim(),\n    fromAddress,\n    subject,\n    body: cleanBody,\n    bodyIsThin: cleanBody.length < 40,\n    receivedAt: m.internalDate\n      ? new Date(Number(m.internalDate)).toISOString()\n      : new Date().toISOString(),\n  },\n};"
      }
    },
    {
      "id": "claude-triage-email",
      "name": "Claude reads and sorts the email",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        340,
        340
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-sonnet-5"
        },
        "messages": {
          "values": [
            {
              "role": "user",
              "content": "=You are triaging the shared inbox at Harborline Group. Read the email below and reply with JSON only, no other text.\n\nKeys:\n- summary: one plain sentence on what this email is actually asking for\n- category: exactly one of Urgent, Customer question, New lead, Billing, Vendor or admin, Newsletter or promo\n- urgent: true only when someone is blocked, angry, or a deadline lands inside 24 hours. Otherwise false.\n- confidence: a number from 0 to 1 for how sure you are about the category\n- needs_reply: true when a person at Harborline owes this sender an answer. False for newsletters, receipts, delivery notices, and anything automated.\n- reply_draft: the draft reply when needs_reply is true, otherwise an empty string\n- reason: one short sentence on why you sorted it that way\n\nReply guidelines for reply_draft:\nWrite like a real person answering a colleague. Short sentences, plain words, first person. Answer only what was asked and stop. No corporate phrases, no emojis, no exclamation marks, no em dashes, no \"reaching out\", no \"hope this finds you well\". Sign off with just [Your name].\nNever invent a price, a date, a policy, or an availability window. If a good reply needs a fact you were not given, leave reply_draft empty and drop confidence below 0.5 so a person picks it up.\n\nWhen the email is empty, truncated, in a language you cannot read, or you simply cannot tell what it wants, set confidence below 0.5 and leave reply_draft empty. Guessing is worse than handing it to a person.\n\nEmail:\nFrom: {{ $json.from }}\nSubject: {{ $json.subject }}\nBody:\n{{ $json.body }}"
            }
          ]
        },
        "options": {}
      },
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "code-read-verdict",
      "name": "Read what Claude decided",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        580,
        340
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// ---- Editable settings ----------------------------------------------------\n// Below this score nothing gets drafted and a person is asked to look.\nconst CONFIDENCE_FLOOR = 0.7;\n\n// Gmail label IDs. Find yours once with the Gmail node's Label > Get Many operation.\nconst LABELS = {\n  'Urgent': 'Label_4471029385',\n  'Customer question': 'Label_4471029386',\n  'New lead': 'Label_4471029387',\n  'Billing': 'Label_4471029388',\n  'Vendor or admin': 'Label_4471029389',\n  'Newsletter or promo': 'Label_4471029390',\n};\nconst LABEL_NEEDS_HUMAN = 'Label_4471029391';\nconst LABEL_DRAFTED = 'Label_4471029392';\n// ---------------------------------------------------------------------------\n\nconst email = $('Get the email ready').item.json;\nconst raw = $json;\n\n// The Anthropic node hands back its answer in a few different shapes depending on version\nlet text =\n  (Array.isArray(raw.content) && raw.content[0] && raw.content[0].text) ??\n  raw.output ??\n  raw.text ??\n  raw.message ??\n  '';\n\nlet verdict = {};\nif (text && typeof text === 'object') {\n  verdict = text;\n} else {\n  try {\n    verdict = JSON.parse(String(text).replace(/```json|```/g, '').trim());\n  } catch (e) {\n    verdict = {};\n  }\n}\n\nconst readable = Object.keys(verdict).length > 0;\nlet category = verdict.category;\nif (!Object.prototype.hasOwnProperty.call(LABELS, category)) category = 'Vendor or admin';\n\nlet confidence = Number(verdict.confidence);\nif (!Number.isFinite(confidence)) confidence = 0;\n\nconst draftText = String(verdict.reply_draft || '').trim();\n\n// A person gets it when Claude could not be read, scored itself low, the email\n// arrived nearly empty, or it promised a reply and then wrote nothing.\nconst needsHuman =\n  !readable ||\n  confidence < CONFIDENCE_FLOOR ||\n  email.bodyIsThin ||\n  (verdict.needs_reply === true && !draftText);\n\nconst willDraft = !needsHuman && verdict.needs_reply === true && draftText.length > 0;\n\nconst labelIds = needsHuman\n  ? [LABEL_NEEDS_HUMAN]\n  : willDraft\n    ? [LABELS[category], LABEL_DRAFTED]\n    : [LABELS[category]];\n\nlet reason = String(verdict.reason || '').trim();\nif (!readable) reason = 'Claude answered in a shape this step could not read, so a person gets it.';\nelse if (email.bodyIsThin) reason = 'The email came in nearly empty, so there was nothing solid to sort.';\nelse if (confidence < CONFIDENCE_FLOOR) reason = reason || 'Claude was not sure enough to call it.';\n\nreturn {\n  json: {\n    messageId: email.messageId,\n    threadId: email.threadId,\n    from: email.from,\n    fromAddress: email.fromAddress,\n    subject: email.subject,\n    receivedAt: email.receivedAt,\n    summary: String(verdict.summary || '').trim() || 'No summary, this one needs a read.',\n    category: needsHuman ? 'Needs a human' : category,\n    confidence,\n    urgent: verdict.urgent === true,\n    needsHuman,\n    willDraft,\n    replyDraft: draftText,\n    reason,\n    labelIds,\n  },\n};"
      }
    },
    {
      "id": "if-should-draft",
      "name": "Should Claude draft a reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        820,
        340
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "check-will-draft",
              "leftValue": "={{ $json.willDraft }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "gmail-save-draft",
      "name": "Save the reply as a draft in Gmail",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1060,
        220
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "parameters": {
        "resource": "draft",
        "operation": "create",
        "subject": "={{ 'Re: ' + $json.subject.replace(/^Re:\\s*/i, '') }}",
        "emailType": "text",
        "message": "={{ $json.replyDraft }}",
        "options": {
          "sendTo": "={{ $json.fromAddress }}",
          "threadId": "={{ $json.threadId }}",
          "appendAttribution": false
        }
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "gmail-add-labels",
      "name": "Put the category label on the email",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.2,
      "position": [
        1320,
        340
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "parameters": {
        "resource": "message",
        "operation": "addLabels",
        "messageId": "={{ $('Read what Claude decided').item.json.messageId }}",
        "labelIds": "={{ $('Read what Claude decided').item.json.labelIds }}"
      },
      "credentials": {
        "gmailOAuth2": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "if-needs-a-person",
      "name": "Does a person need to see this now?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1560,
        340
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "check-urgent",
              "leftValue": "={{ $('Read what Claude decided').item.json.urgent }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            },
            {
              "id": "check-needs-human",
              "leftValue": "={{ $('Read what Claude decided').item.json.needsHuman }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "or"
        },
        "options": {}
      }
    },
    {
      "id": "slack-notify-team",
      "name": "Ping the team in Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1800,
        240
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#inbox-urgent"
        },
        "text": "={{ ($('Read what Claude decided').item.json.needsHuman ? 'Claude could not call this one' : 'Urgent email just landed') + '\\n*' + $('Read what Claude decided').item.json.subject + '*\\nFrom: ' + $('Read what Claude decided').item.json.from + '\\nWhat it wants: ' + $('Read what Claude decided').item.json.summary + '\\nSorted as: ' + $('Read what Claude decided').item.json.category + ' (' + Math.round($('Read what Claude decided').item.json.confidence * 100) + '% sure)\\nWhy: ' + $('Read what Claude decided').item.json.reason + '\\n' + ($('Read what Claude decided').item.json.willDraft ? 'A draft is waiting in Gmail.' : 'No draft was written, this one is yours from scratch.') }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "slack-notify-failure",
      "name": "Tell Slack a step failed",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1060,
        640
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#inbox-errors"
        },
        "text": "={{ 'A step in the email triage flow failed after 3 tries, so one email was skipped.\\nSubject: ' + ($('Get the email ready').item.json.subject || 'unknown') + '\\nFrom: ' + ($('Get the email ready').item.json.from || 'unknown') + '\\nWhat broke: ' + ($json.error && $json.error.message ? $json.error.message : 'no error text came back') + '\\nThe email is untouched in the inbox. Nothing was sent, labeled, or drafted for it.' }}",
        "otherOptions": {
          "includeLinkToWorkflow": true
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    }
  ],
  "connections": {
    "When a new email arrives": {
      "main": [
        [
          {
            "node": "Get the email ready",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get the email ready": {
      "main": [
        [
          {
            "node": "Claude reads and sorts the email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude reads and sorts the email": {
      "main": [
        [
          {
            "node": "Read what Claude decided",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Tell Slack a step failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read what Claude decided": {
      "main": [
        [
          {
            "node": "Should Claude draft a reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Should Claude draft a reply?": {
      "main": [
        [
          {
            "node": "Save the reply as a draft in Gmail",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Put the category label on the email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save the reply as a draft in Gmail": {
      "main": [
        [
          {
            "node": "Put the category label on the email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Tell Slack a step failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Put the category label on the email": {
      "main": [
        [
          {
            "node": "Does a person need to see this now?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Tell Slack a step failed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Does a person need to see this now?": {
      "main": [
        [
          {
            "node": "Ping the team in Slack",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  }
}