{
  "name": "Draft Gmail replies with an AI agent, a Google Sheets knowledge base and guardrails",
  "nodes": [
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "in:inbox -label:ai-drafted -label:ai-needs-review -label:ai-skipped -category:promotions -category:social -from:me"
        },
        "options": {}
      },
      "id": "f2ad09f3-982c-5def-9984-f080f855a61a",
      "name": "New Inbox Email",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.3,
      "position": [
        -1700,
        300
      ]
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "73db846c-2a3e-5de2-b233-825e86c9442e",
              "name": "my_name",
              "value": "Your Name",
              "type": "string"
            },
            {
              "id": "8b7b9066-6e41-56d6-b425-0b591ab02183",
              "name": "my_email",
              "value": "YOUR_EMAIL@example.com",
              "type": "string"
            },
            {
              "id": "87aa54eb-1d54-50fe-9177-6c3b80172ead",
              "name": "business_name",
              "value": "Your Business",
              "type": "string"
            },
            {
              "id": "53e970d9-f9c3-5289-9999-da3d08eebea0",
              "name": "business_context",
              "value": "One or two sentences about what you do and who usually emails you.",
              "type": "string"
            },
            {
              "id": "09eb6372-1245-5bed-8f58-590357096bc3",
              "name": "approved_offers",
              "value": "Free 15-minute intro call\nStandard rate card attached on request",
              "type": "string"
            },
            {
              "id": "6bd18bfe-8e90-519a-b7da-37302c49b61b",
              "name": "auto_draft_categories",
              "value": "lead,customer_support,scheduling,billing",
              "type": "string"
            },
            {
              "id": "637f5a1d-bd67-5933-9298-6a5ed696d0e2",
              "name": "min_confidence",
              "value": "0.7",
              "type": "string"
            },
            {
              "id": "07ba9a5f-b0ab-5abd-b8b7-d00b0ba8933c",
              "name": "blocked_domains",
              "value": "example-newsletter.com",
              "type": "string"
            },
            {
              "id": "1c835236-3bc5-5698-9225-83664330491b",
              "name": "max_body_chars",
              "value": "6000",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "aff8acd6-6020-50ab-8f9d-eda3fa753f58",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -1480,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Normalizes the Gmail message (works with Simplify on or off) and decides, without AI, whether it is worth a reply.\nconst j = $json;\nconst cfg = {\n  my_name: j.my_name, my_email: String(j.my_email || '').toLowerCase(), business_name: j.business_name, business_context: j.business_context,\n  approved_offers: String(j.approved_offers || '').split('\\n').map(s => s.trim()).filter(Boolean),\n  auto_draft_categories: String(j.auto_draft_categories || '').split(',').map(s => s.trim()).filter(Boolean),\n  min_confidence: Number(j.min_confidence || 0.7),\n  blocked_domains: String(j.blocked_domains || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean),\n  max_body_chars: Number(j.max_body_chars || 6000),\n};\nconst h = {};\nfor (const [k, v] of Object.entries(j.headers || {})) h[k.toLowerCase()] = String(v).replace(/^[^:]+:\\s*/, '');\nconst addr = (s) => { const m = String(s || '').match(/[^\\s<>\"]+@[^\\s<>\"]+/); return m ? m[0].toLowerCase() : ''; };\nconst fromText = (j.from && j.from.text) || j.From || h.from || '';\nconst fromEmail = (j.from && j.from.value && j.from.value[0] && j.from.value[0].address) ? j.from.value[0].address.toLowerCase() : addr(fromText);\nconst fromName = (j.from && j.from.value && j.from.value[0] && j.from.value[0].name) || String(fromText).replace(/<.*>/, '').replace(/\"/g, '').trim();\nconst replyTo = (j.replyTo && j.replyTo.value && j.replyTo.value[0] && j.replyTo.value[0].address) || addr(h['reply-to']) || fromEmail;\nconst subject = j.subject || j.Subject || h.subject || '(no subject)';\nconst body = String(j.text || j.snippet || '').replace(/\\r/g, '').slice(0, cfg.max_body_chars);\nconst labels = (j.labelIds || (j.labels || []).map(l => l.id) || []);\n\nconst reasons = [];\nconst domain = fromEmail.split('@')[1] || '';\nif (!fromEmail) reasons.push('no sender address');\nif (cfg.my_email && fromEmail === cfg.my_email) reasons.push('sent by you');\nif (/(no-?reply|do-?not-?reply|mailer-daemon|postmaster|notifications?|bounce)[^@]*@/i.test(fromEmail)) reasons.push('no-reply sender');\nif (h['list-unsubscribe'] || h['list-id']) reasons.push('newsletter / mailing list');\nif (/bulk|list|junk/i.test(h.precedence || '')) reasons.push('bulk precedence header');\nif (h['auto-submitted'] && !/^no$/i.test(h['auto-submitted'])) reasons.push('auto-reply');\nif (labels.some(l => ['CATEGORY_PROMOTIONS', 'CATEGORY_SOCIAL', 'CATEGORY_FORUMS', 'SPAM', 'DRAFT'].includes(l))) reasons.push('Gmail category ' + labels.filter(l => l.startsWith('CATEGORY_') || l === 'SPAM').join('/'));\nif (cfg.blocked_domains.some(d => domain === d || domain.endsWith('.' + d))) reasons.push('blocked domain ' + domain);\nif (!body.trim()) reasons.push('empty body');\n\nreturn [{ json: {\n  message_id: j.id, thread_id: j.threadId, from: fromText, from_email: fromEmail, from_name: fromName, reply_to: replyTo,\n  subject, body, date: j.date || h.date || '', labels, cfg,\n  should_process: reasons.length === 0, decision: reasons.length ? 'skipped' : 'pending', reasons,\n} }];\n"
      },
      "id": "fc8f91da-f20f-5322-aab9-264faa56ef72",
      "name": "Normalize Email & Pre-Filter",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1260,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "742760ae-53a9-5cfd-954a-56595ce67a58",
              "leftValue": "={{ $json.should_process }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "9fbaee85-38b4-5548-94e4-d91693e234c6",
      "name": "Worth A Reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -1040,
        300
      ]
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "=Draft a reply to this email.\n\n{{ JSON.stringify({ thread_id: $json.thread_id, from: $json.from, reply_to: $json.reply_to, subject: $json.subject, date: $json.date, body: $json.body }, null, 2) }}",
        "hasOutputParser": true,
        "options": {
          "systemMessage": "=You are the email assistant for {{ $json.cfg.my_name }} at {{ $json.cfg.business_name }}. {{ $json.cfg.business_context }}\nYou write reply DRAFTS that a human will review before sending. You never send anything.\nRules:\n- First call Read Thread History with the thread_id to see the whole conversation. Call Look Up Approved Answers before stating any price, policy, availability or offer.\n- Only state facts that appear in the thread or in the approved answers. If you don't know something, say you will check and get back to them. Never invent prices, dates, links or policies.\n- Never offer a discount, refund, credit, fee waiver, free extra, guarantee or deadline unless it is word for word in this approved list:\n{{ $json.cfg.approved_offers.join('\\n') || '(none)' }}\n- List every promise or commitment the draft makes in `commitments` (empty list if none). Paraphrased promises count too.\n- Plain text, short paragraphs, match the sender's language and tone. No subject line. Sign off with exactly: {{ $json.cfg.my_name }}\n- Never use placeholders such as [Name] or curly-brace template tags. If a detail is missing, write around it.\n- Set needs_reply=false for receipts, FYIs, cold sales pitches you would not answer, and anything that only needs reading.\n- confidence is 0-1: how sure you are the draft is correct and complete using only known facts.",
          "maxIterations": 6
        }
      },
      "id": "efa3eb09-8cd2-5697-9c05-12a29304def4",
      "name": "Email Reply Agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 3.1,
      "position": [
        -760,
        160
      ],
      "onError": "continueErrorOutput",
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 3000
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-4o-mini",
          "cachedResultName": "gpt-4o-mini"
        },
        "options": {
          "temperature": 0.2
        }
      },
      "id": "ecebb41d-b483-50ee-99b9-6360b094f999",
      "name": "OpenAI Chat Model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.3,
      "position": [
        -900,
        420
      ]
    },
    {
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ $('Normalize Email & Pre-Filter').item.json.thread_id }}",
        "contextWindowLength": 6
      },
      "id": "b9f0310a-ff53-5d2a-8da7-514caa7f3b74",
      "name": "Thread Memory",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "typeVersion": 1.3,
      "position": [
        -760,
        420
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Returns the owner's approved answers: FAQs, prices, policies and offers (columns topic, approved_answer, notes). Use it before stating any fact about the business.",
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Approved Answers"
        },
        "options": {}
      },
      "id": "2b588156-a985-5ba0-b954-fbbf263c2638",
      "name": "Look Up Approved Answers",
      "type": "n8n-nodes-base.googleSheetsTool",
      "typeVersion": 4.7,
      "position": [
        -620,
        420
      ]
    },
    {
      "parameters": {
        "descriptionType": "manual",
        "toolDescription": "Returns every message in a Gmail thread. Input: the thread_id from the prompt.",
        "resource": "thread",
        "operation": "get",
        "threadId": "={{ $fromAI('thread_id', 'The Gmail thread_id given in the prompt', 'string') }}",
        "simple": true,
        "options": {
          "returnOnlyMessages": true
        }
      },
      "id": "0af12fa7-2138-5204-ac5e-bd5965654ce4",
      "name": "Read Thread History",
      "type": "n8n-nodes-base.gmailTool",
      "typeVersion": 2.1,
      "position": [
        -480,
        420
      ]
    },
    {
      "parameters": {
        "schemaType": "manual",
        "inputSchema": "{\n  \"type\": \"object\",\n  \"required\": [\n    \"category\",\n    \"needs_reply\",\n    \"urgency\",\n    \"summary\",\n    \"reply_body\",\n    \"commitments\",\n    \"confidence\"\n  ],\n  \"properties\": {\n    \"category\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"lead\",\n        \"customer_support\",\n        \"scheduling\",\n        \"billing\",\n        \"vendor_or_sales_pitch\",\n        \"personal\",\n        \"other\"\n      ]\n    },\n    \"needs_reply\": {\n      \"type\": \"boolean\"\n    },\n    \"urgency\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"low\",\n        \"normal\",\n        \"high\"\n      ]\n    },\n    \"summary\": {\n      \"type\": \"string\",\n      \"description\": \"One sentence: who wants what\"\n    },\n    \"reply_body\": {\n      \"type\": \"string\",\n      \"description\": \"Plain-text reply draft, signed off with the owner's name. Empty if needs_reply is false\"\n    },\n    \"facts_used\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"description\": \"Each fact in the draft and where it came from (thread or approved answers)\"\n    },\n    \"commitments\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"description\": \"Every promise, price, discount, refund, waiver, deadline or guarantee in the draft\"\n    },\n    \"missing_info\": {\n      \"type\": \"array\",\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"description\": \"What you would need to answer fully\"\n    },\n    \"confidence\": {\n      \"type\": \"number\",\n      \"minimum\": 0,\n      \"maximum\": 1\n    }\n  }\n}"
      },
      "id": "6b773577-6a22-5ec7-9d0e-1670e9d0cfc7",
      "name": "Reply Output Parser",
      "type": "@n8n/n8n-nodes-langchain.outputParserStructured",
      "typeVersion": 1.3,
      "position": [
        -340,
        420
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// The AI step failed (missing credential, rate limit, invalid JSON...). Don't drop the email: send it to human review.\nconst e = $('Normalize Email & Pre-Filter').item.json;\nconst err = $json.error ? (typeof $json.error === 'string' ? $json.error : ($json.error.message || JSON.stringify($json.error))) : 'unknown error';\nreturn { json: { ...e, decision: 'review', ai_status: 'failed', reasons: ['AI step failed: ' + String(err).slice(0, 200)], proposed_reply: '' } };\n"
      },
      "id": "47420780-dc10-5e95-a379-90df02b485f7",
      "name": "AI Failed: Send To Review",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -340,
        640
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Mechanical checks in plain code: cheap, predictable, and they don't trust the model's own opinion of its draft.\nconst e = $('Normalize Email & Pre-Filter').item.json;\nconst cfg = e.cfg;\nconst ai = $json.output || {};\n// Words that usually signal a promise. Edit freely. Paraphrases like \"waive part of the fee\" are covered by 'waiv'.\nconst RISKY = /\\b(discount|\\d+\\s?%|percent off|refund|reimburs|credit|waiv|free of charge|for free|complimentary|on the house|guarantee|promise|by (today|tomorrow|monday|tuesday|wednesday|thursday|friday|end of (day|week))|within \\d+ (hours?|days?)|\\$\\s?\\d)/i;\nconst reasons = [];\nlet reply = String(ai.reply_body || '').trim();\nconst to = e.reply_to || e.from_email;\nconst approved = cfg.approved_offers.map(a => a.toLowerCase());\nconst isApproved = (c) => approved.some(a => a && (c.toLowerCase().includes(a) || a.includes(c.toLowerCase())));\n\nif (ai.needs_reply === false) {\n  return { json: { ...e, decision: 'skipped', ai_status: 'ok', reasons: ['AI: no reply needed'], category: ai.category, urgency: ai.urgency, summary: ai.summary, confidence: ai.confidence, proposed_reply: '' } };\n}\nif (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(to || '')) reasons.push('missing or invalid recipient');\nif (reply.length < 20) reasons.push('draft is empty or too short');\nif (/\\[[^\\]]{2,40}\\]|\\{\\{|\\}\\}|YOUR_|<insert|lorem ipsum/i.test(reply)) reasons.push('placeholder left in draft');\nif (cfg.my_name && !reply.toLowerCase().includes(String(cfg.my_name).toLowerCase())) reply = reply + '\\n\\n' + cfg.my_name;  // auto-fix sign-off\nconst commitments = Array.isArray(ai.commitments) ? ai.commitments.filter(Boolean) : [];\nconst unapproved = commitments.filter(c => !isApproved(c));\nif (unapproved.length) reasons.push('unapproved commitment: ' + unapproved.join('; ').slice(0, 200));\n// Remove your approved offer wording, then look for risky wording in what is left. Anything left over goes to review,\n// even if the model declared some other (approved) commitment.\nconst leftover = approved.reduce((t, a) => (a ? t.split(a).join(' ') : t), reply.toLowerCase());\nconst m = leftover.match(RISKY);\nif (m) reasons.push(`risky wording \"${m[0]}\" outside your approved offers`);\nif (typeof ai.confidence !== 'number' || ai.confidence < cfg.min_confidence) reasons.push(`confidence ${ai.confidence} below ${cfg.min_confidence}`);\nif (!cfg.auto_draft_categories.includes(ai.category)) reasons.push(`category \"${ai.category}\" is not auto-drafted`);\n\nreturn { json: { ...e, ai_status: 'ok', decision: reasons.length ? 'review' : 'draft', reasons,\n  category: ai.category, urgency: ai.urgency, summary: ai.summary, confidence: ai.confidence, commitments,\n  missing_info: ai.missing_info || [], facts_used: ai.facts_used || [], proposed_reply: reply, send_to: to,\n  draft_subject: /^re:/i.test(e.subject) ? e.subject : 'Re: ' + e.subject } };\n"
      },
      "id": "7c9109cd-4147-5290-802f-e6a760840b5a",
      "name": "Guardrail Checks",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -340,
        160
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "eefae70a-c173-551b-8d5f-4c11b327dbbd",
              "leftValue": "={{ $json.decision }}",
              "rightValue": "draft",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "3543e02c-35c4-5fab-a92f-608a8de18d21",
      "name": "Safe To Draft?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        -100,
        160
      ]
    },
    {
      "parameters": {
        "resource": "draft",
        "subject": "={{ $json.draft_subject }}",
        "emailType": "text",
        "message": "={{ $json.proposed_reply }}",
        "options": {
          "threadId": "={{ $json.thread_id }}",
          "sendTo": "={{ $json.send_to }}"
        }
      },
      "id": "9db86f92-e8cd-5a7d-b7cb-8d761662ac38",
      "name": "Create Reply Draft In Thread",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        140,
        40
      ],
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Gmail returns the draft; pick the email record back up and mark it drafted.\nconst r = $('Guardrail Checks').item.json;\nreturn { json: { ...r, decision: 'drafted', draft_id: $json.id || '' } };\n"
      },
      "id": "0a4667cd-809b-5ef3-8f93-cb1dceb25458",
      "name": "Attach Draft ID",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        380,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Creating the draft failed (permissions, bad address...). Keep the proposed reply and flag the thread for you.\nconst r = $('Guardrail Checks').item.json;\nconst err = $json.error ? ($json.error.message || String($json.error)) : 'draft create failed';\nreturn { json: { ...r, decision: 'review', reasons: [...(r.reasons || []), 'draft create failed: ' + String(err).slice(0, 200)] } };\n"
      },
      "id": "95712b4c-9d0e-5a3c-92f2-f933a68e5557",
      "name": "Draft Failed: Send To Review",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        380,
        200
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Every path ends here with one uniform record: drafted, review or skipped.\nconst r = $json;\nreturn { json: {\n  logged_at: new Date().toISOString(), from: r.from, from_email: r.from_email, subject: r.subject, category: r.category || '',\n  decision: r.decision === 'pending' ? 'review' : r.decision, reasons: (r.reasons || []).join(' | '), urgency: r.urgency || '',\n  confidence: r.confidence ?? '', summary: r.summary || '', proposed_reply: r.decision === 'drafted' ? '' : (r.proposed_reply || ''),\n  draft_id: r.draft_id || '', thread_id: r.thread_id, message_id: r.message_id, ai_status: r.ai_status || 'not_run',\n} };\n"
      },
      "id": "8c20293e-e8d9-57f1-afc5-16a05b5055cb",
      "name": "Collect Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        640,
        300
      ]
    },
    {
      "parameters": {
        "resource": "thread",
        "operation": "removeLabels",
        "threadId": "={{ $json.thread_id }}",
        "labelIds": [
          "PICK_LABEL_AI_DRAFTED",
          "PICK_LABEL_AI_NEEDS_REVIEW",
          "PICK_LABEL_AI_SKIPPED"
        ]
      },
      "id": "1cc4a8f3-28cf-5792-acb3-6266f4e4f06b",
      "name": "Remove Old AI Labels",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        860,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "860339c2-4df1-52bd-bd51-67017c4fe691",
                    "leftValue": "={{ $('Collect Result').item.json.decision }}",
                    "rightValue": "drafted",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Drafted"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "8a0873b0-778f-59a3-8838-8e38299c93bf",
                    "leftValue": "={{ $('Collect Result').item.json.decision }}",
                    "rightValue": "review",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Needs Review"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "b70f602a-326b-55c6-b326-b9f11422f14d",
                    "leftValue": "={{ $('Collect Result').item.json.decision }}",
                    "rightValue": "skipped",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Skipped"
            }
          ]
        },
        "options": {
          "fallbackOutput": 1
        }
      },
      "id": "058d8370-147b-55b5-ac1f-1aa273a0a392",
      "name": "Route By Decision",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1080,
        300
      ]
    },
    {
      "parameters": {
        "resource": "thread",
        "operation": "addLabels",
        "threadId": "={{ $('Collect Result').item.json.thread_id }}",
        "labelIds": [
          "PICK_LABEL_AI_DRAFTED"
        ]
      },
      "id": "2f01b73f-6850-5109-b5f7-7980d4f01da0",
      "name": "Add AI Drafted Label",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1320,
        100
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "thread",
        "operation": "addLabels",
        "threadId": "={{ $('Collect Result').item.json.thread_id }}",
        "labelIds": [
          "PICK_LABEL_AI_NEEDS_REVIEW"
        ]
      },
      "id": "06f0149b-6712-5f84-9c2d-48518b37df05",
      "name": "Add Needs Review Label",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1320,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "thread",
        "operation": "addLabels",
        "threadId": "={{ $('Collect Result').item.json.thread_id }}",
        "labelIds": [
          "PICK_LABEL_AI_SKIPPED"
        ]
      },
      "id": "1a548512-e04a-5dda-9e22-01c4e5e979ed",
      "name": "Add Skipped Label",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        1320,
        500
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Log"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "logged_at": "={{ $('Collect Result').item.json.logged_at }}",
            "from": "={{ $('Collect Result').item.json.from }}",
            "subject": "={{ $('Collect Result').item.json.subject }}",
            "category": "={{ $('Collect Result').item.json.category }}",
            "decision": "={{ $('Collect Result').item.json.decision }}",
            "reasons": "={{ $('Collect Result').item.json.reasons }}",
            "urgency": "={{ $('Collect Result').item.json.urgency }}",
            "confidence": "={{ $('Collect Result').item.json.confidence }}",
            "summary": "={{ $('Collect Result').item.json.summary }}",
            "proposed_reply": "={{ $('Collect Result').item.json.proposed_reply }}",
            "draft_id": "={{ $('Collect Result').item.json.draft_id }}",
            "thread_id": "={{ $('Collect Result').item.json.thread_id }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "logged_at",
              "displayName": "logged_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "from",
              "displayName": "from",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "subject",
              "displayName": "subject",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "category",
              "displayName": "category",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "decision",
              "displayName": "decision",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reasons",
              "displayName": "reasons",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "urgency",
              "displayName": "urgency",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "confidence",
              "displayName": "confidence",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "summary",
              "displayName": "summary",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "proposed_reply",
              "displayName": "proposed_reply",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "draft_id",
              "displayName": "draft_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "thread_id",
              "displayName": "thread_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      },
      "id": "f996fd12-6f80-5527-b7a2-2223f4da9471",
      "name": "Log Decision To Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        1560,
        300
      ],
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "triggerAtHour": 17
            }
          ]
        }
      },
      "id": "d429e308-05c2-50f8-93c9-8e3c631432f6",
      "name": "Every Day 5pm",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        -1700,
        980
      ]
    },
    {
      "parameters": {
        "operation": "read",
        "documentId": {
          "__rl": true,
          "mode": "url",
          "value": "PASTE_YOUR_GOOGLE_SHEET_URL_HERE"
        },
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "Log"
        },
        "options": {}
      },
      "id": "dd1b6588-cd33-5496-9845-82cf2a090b98",
      "name": "Read Decision Log",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        -1480,
        980
      ],
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// Last 24h of decisions -> one email listing what needs you. Nothing to review = no email.\nconst since = Date.now() - 24 * 60 * 60 * 1000;\nconst rows = $input.all().map(i => i.json).filter(r => r.logged_at && Date.parse(r.logged_at) >= since);\nconst review = rows.filter(r => r.decision === 'review');\nif (!review.length) return [];\nconst count = (d) => rows.filter(r => r.decision === d).length;\nconst esc = (s) => String(s ?? '').replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));\nconst items = review.map(r => `<li><b>${esc(r.subject)}</b> from ${esc(r.from)}<br>${esc(r.summary)}<br><i>Why:</i> ${esc(r.reasons)}</li>`).join('');\nreturn [{ json: { subject: `Inbox assistant: ${review.length} ${review.length === 1 ? 'email needs' : 'emails need'} you`,\n  html: `<p>Last 24h: ${count('drafted')} drafted, ${review.length} need review, ${count('skipped')} skipped.</p><h3>Needs review (label AI/Needs Review)</h3><ol>${items}</ol><p>Proposed replies are in the Log tab.</p>` } }];\n"
      },
      "id": "4bcaf645-601c-5f56-9e85-a2f68de0c7c1",
      "name": "Build Review Digest",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -1260,
        980
      ]
    },
    {
      "parameters": {
        "sendTo": "YOUR_EMAIL@example.com",
        "subject": "={{ $json.subject }}",
        "emailType": "html",
        "message": "={{ $json.html }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "3920ba20-c0a2-5760-b96e-a85dd68882b0",
      "name": "Email Review Digest",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        -1040,
        980
      ]
    },
    {
      "parameters": {
        "content": "## Draft Gmail replies with an AI agent (draft-only, with guardrails)\n**Who it's for:** freelancers, small businesses, agents, investors and job seekers who answer the same kinds of emails every day and want reply drafts waiting in Gmail, without anything being sent automatically.\n\n### How it works\n1. **Gmail Trigger** checks the inbox every minute for messages that don't have an AI label yet.\n2. **Pre-filter (no AI cost):** skips your own messages, no-reply senders, newsletters (List-Unsubscribe / bulk headers), auto-replies, Promotions/Social and any blocked domains. Skipped threads get the *AI/Skipped* label.\n3. **AI Agent** (OpenAI chat model + Simple Memory per thread) has two tools: **Look Up Approved Answers** (a Google Sheets tab with your FAQs, prices and policies) and **Read Thread History** (the full Gmail thread). A Structured Output Parser returns: category, needs_reply, urgency, summary, reply body, facts used, commitments made and a confidence score.\n4. **Guardrail checks (plain code, no second AI call):** empty or invalid recipient, leftover placeholders like `[Your Name]` or `{{ }}`, low confidence, categories you don't auto-draft, and any **commitment** (discount, refund, \"we can waive the fee\", a deadline, a guarantee) that isn't on your approved-offers list (risky wording is checked after your approved offer text is removed, so \"15% off\" next to an approved \"free intro call\" is still caught). A missing sign-off is added automatically. Any other failure sends the thread to **AI/Needs Review** instead of drafting.\n5. **Draft in the same thread.** Passing emails become a Gmail **draft** created with the thread ID and the correct To address, so the reply stays in the conversation. **Nothing is ever sent.**\n6. **Labels are replaced, not stacked:** old AI labels are removed from the thread before the new one is added.\n7. **Fail-safe:** if the AI call or the draft call fails, the error output routes the thread to *AI/Needs Review*, so no email is silently dropped.\n8. **Log + daily digest:** every decision is appended to a *Log* tab (with the proposed reply for review items). At 5pm you get one email listing the threads that need you.\n\n### Setup steps (about 15 minutes)\n1. In Gmail, create three labels: **AI/Drafted**, **AI/Needs Review**, **AI/Skipped**.\n2. Create a Google Sheet with two tabs:\n   - **Approved Answers:** `topic, approved_answer, notes` (one row per FAQ, price, policy or offer you are happy for drafts to state).\n   - **Log:** `logged_at, from, subject, category, decision, reasons, urgency, confidence, summary, proposed_reply, draft_id, thread_id`\n3. Open **Settings (edit me)** and set your name, email, business name, approved offers, auto-draft categories, minimum confidence and blocked domains.\n4. Attach a **Gmail OAuth2** credential to the trigger, the tool and the Gmail nodes. In *Remove Old AI Labels* and the three *Add … Label* nodes, pick your labels from the dropdown (replace the `PICK_…` placeholders).\n5. Replace `PASTE_YOUR_GOOGLE_SHEET_URL_HERE` in the Sheets tool and the two Sheets nodes, then attach a **Google Sheets OAuth2** credential.\n6. Attach an **OpenAI** credential to *OpenAI Chat Model*.\n7. Test with one email to yourself from another account, check the draft, then activate.\n\n### Customization\n- **Categories:** edit the enum in *Reply Output Parser* and `auto_draft_categories` in Settings.\n- **Risk words:** edit `RISKY` at the top of *Guardrail Checks*.\n- **Model:** swap the chat model sub-node for any LangChain model (Claude, Gemini, local).\n- **Memory:** Simple Memory lives in the n8n process. For long-running production use, swap in Postgres or Redis chat memory.\n- **Error workflow:** set an Error Workflow in the workflow settings to be told about trigger or credential failures.\n\n### Requirements\nn8n with the AI Agent node (cloud or self-hosted), Gmail, Google Sheets and an OpenAI API key (you pay OpenAI per use; gpt-4o-mini by default).\n\n*The AI drafts; you review and send. Always read a draft before sending it.*",
        "width": 620,
        "height": 1480,
        "color": 1
      },
      "id": "257685c4-4ed2-5304-bdef-dd5bbc672d2c",
      "name": "Main Description",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -2400,
        -200
      ]
    },
    {
      "parameters": {
        "content": "## 1. Trigger, settings, pre-filter\nThe Gmail search skips threads that already carry an AI label, so nothing is drafted twice.\nEdit **Settings (edit me)** first. The pre-filter drops no-reply senders, newsletters, auto-replies and blocked domains **before** any AI cost.",
        "width": 860,
        "height": 560,
        "color": 7
      },
      "id": "0f81616c-1726-502a-b210-ceca91768e87",
      "name": "Section 1",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1760,
        -60
      ]
    },
    {
      "parameters": {
        "content": "## 2. AI Agent with tools + memory\n**Read Thread History** gives it the whole conversation; **Look Up Approved Answers** is your Sheets FAQ / price list. Memory is keyed by thread ID.\nThe parser forces JSON: category, needs_reply, reply, facts used, **commitments**, confidence.\nIf the AI fails, the error output sends the email to review.",
        "width": 780,
        "height": 900,
        "color": 7
      },
      "id": "e298ba22-3f42-50be-9515-8d653e96125d",
      "name": "Section 2",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -960,
        -160
      ]
    },
    {
      "parameters": {
        "content": "## 3. Guardrails + draft\nPlain-code checks: recipient, placeholders, sign-off, confidence, category and **unapproved commitments** (discounts, refunds, waivers, deadlines).\nPass: a Gmail **draft** in the same thread (thread ID + To). Fail: AI/Needs Review. Nothing is sent.",
        "width": 760,
        "height": 560,
        "color": 7
      },
      "id": "db42eeb7-e694-5ac8-af58-75b8622d76bb",
      "name": "Section 3",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -160,
        -160
      ]
    },
    {
      "parameters": {
        "content": "## 4. Relabel + log\nOld AI labels are removed from the thread first, so labels never stack. Then one label is added and the decision is logged to the **Log** tab. Pick your labels in the four label nodes.",
        "width": 1160,
        "height": 560,
        "color": 7
      },
      "id": "14dad5bf-4680-54c5-87d9-137abfa7ce9f",
      "name": "Section 4",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        600,
        -60
      ]
    },
    {
      "parameters": {
        "content": "## 5. Daily review digest\nAt 5pm: one email listing the threads that need you, with reasons. No review items, no email.",
        "width": 860,
        "height": 320,
        "color": 7
      },
      "id": "15e08ef1-6b6f-54d0-84ad-d46705f2b3c8",
      "name": "Section 5",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1760,
        840
      ]
    }
  ],
  "connections": {
    "New Inbox Email": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Normalize Email & Pre-Filter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Email & Pre-Filter": {
      "main": [
        [
          {
            "node": "Worth A Reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Worth A Reply?": {
      "main": [
        [
          {
            "node": "Email Reply Agent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Collect Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI Chat Model": {
      "ai_languageModel": [
        [
          {
            "node": "Email Reply Agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Thread Memory": {
      "ai_memory": [
        [
          {
            "node": "Email Reply Agent",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "Look Up Approved Answers": {
      "ai_tool": [
        [
          {
            "node": "Email Reply Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Read Thread History": {
      "ai_tool": [
        [
          {
            "node": "Email Reply Agent",
            "type": "ai_tool",
            "index": 0
          }
        ]
      ]
    },
    "Reply Output Parser": {
      "ai_outputParser": [
        [
          {
            "node": "Email Reply Agent",
            "type": "ai_outputParser",
            "index": 0
          }
        ]
      ]
    },
    "Email Reply Agent": {
      "main": [
        [
          {
            "node": "Guardrail Checks",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI Failed: Send To Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Guardrail Checks": {
      "main": [
        [
          {
            "node": "Safe To Draft?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Safe To Draft?": {
      "main": [
        [
          {
            "node": "Create Reply Draft In Thread",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Collect Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create Reply Draft In Thread": {
      "main": [
        [
          {
            "node": "Attach Draft ID",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Draft Failed: Send To Review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach Draft ID": {
      "main": [
        [
          {
            "node": "Collect Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Draft Failed: Send To Review": {
      "main": [
        [
          {
            "node": "Collect Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI Failed: Send To Review": {
      "main": [
        [
          {
            "node": "Collect Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Result": {
      "main": [
        [
          {
            "node": "Remove Old AI Labels",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remove Old AI Labels": {
      "main": [
        [
          {
            "node": "Route By Decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route By Decision": {
      "main": [
        [
          {
            "node": "Add AI Drafted Label",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Add Needs Review Label",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Add Skipped Label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add AI Drafted Label": {
      "main": [
        [
          {
            "node": "Log Decision To Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Needs Review Label": {
      "main": [
        [
          {
            "node": "Log Decision To Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Add Skipped Label": {
      "main": [
        [
          {
            "node": "Log Decision To Sheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Every Day 5pm": {
      "main": [
        [
          {
            "node": "Read Decision Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Decision Log": {
      "main": [
        [
          {
            "node": "Build Review Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Review Digest": {
      "main": [
        [
          {
            "node": "Email Review Digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "tags": []
}