{
  "name": "Cold email reply triage",
  "nodes": [
    {
      "parameters": {
        "pollTimes": {
          "item": [
            {
              "mode": "everyMinute"
            }
          ]
        },
        "simple": false,
        "filters": {
          "q": "in:inbox is:unread"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [
        -460,
        300
      ],
      "name": "New reply in inbox"
    },
    {
      "parameters": {
        "jsCode": "// Drop the quoted original, the signature block and the bounce headers before\n// the classifier sees the body. Measured on the sample corpus: no change in\n// accuracy, 17.6% fewer input tokens. This step is a cost decision, not an\n// accuracy one, which is worth knowing before anyone spends a day on it.\nconst CUTS = [\n  /^>/,\n  /^on .+wrote:$/i,\n  /^-{2,}\\s*$/,\n  /^_{2,}\\s*$/,\n  /^-+\\s*original message\\s*-+$/i,\n  /^reporting-mta:/i,\n  /^sent from my (phone|iphone|mobile)/i,\n];\n\nreturn items.map(item => {\n  const j = item.json;\n  const raw = String(j.body || j.text || j.textPlain || '');\n  const lines = raw.split('\\n');\n  const kept = [];\n  for (const line of lines) {\n    const t = line.trim();\n    if (CUTS.some(re => re.test(t))) break;\n    kept.push(line);\n  }\n  const stripped = kept.join('\\n').trim() || raw.trim();\n  return {\n    json: {\n      ...j,\n      message_id: j.id || j.messageId || null,\n      subject: j.subject || '',\n      body_raw: raw,\n      body: stripped,\n      chars_dropped: raw.length - stripped.length,\n    },\n  };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -240,
        300
      ],
      "name": "Strip quoted thread"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "x-api-key",
              "value": "={{ $env.ANTHROPIC_API_KEY }}"
            },
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\"model\": \"claude-haiku-4-5\", \"max_tokens\": 1024, \"system\": \"You triage replies to cold outbound email. Assign exactly one label from the allowed set, a calibrated confidence between 0 and 1, and the shortest verbatim span from the reply that decides it. Precedence: an explicit removal or deletion request is unsubscribe however politely phrased; a named alternative person is referral even alongside a soft decline; a stated future date is not_now rather than not_interested.\", \"messages\": [{\"role\": \"user\", \"content\": \"=Subject: {{ $json.subject }}\\n---\\n{{ $json.body }}\"}], \"output_config\": {\"format\": {\"type\": \"json_schema\", \"schema\": {\"type\": \"object\", \"properties\": {\"label\": {\"type\": \"string\", \"enum\": [\"interested\", \"not_now\", \"not_interested\", \"referral\", \"out_of_office\", \"unsubscribe\", \"auto_bounce\"]}, \"confidence\": {\"type\": \"number\"}, \"evidence\": {\"type\": \"string\"}}, \"required\": [\"label\", \"confidence\", \"evidence\"], \"additionalProperties\": false}}}, \"temperature\": 0}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          },
          "timeout": 30000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -20,
        300
      ],
      "name": "Classify (cheap pass)"
    },
    {
      "parameters": {
        "jsCode": "// Read the classifier reply. The request constrains output with a JSON schema\n// and an enum, so a label outside the enum is not something that can happen\n// quietly. If parsing fails at all, this fails closed to the review lane\n// rather than defaulting to a class: a reply silently filed as not_interested\n// is a reply nobody ever reads again.\nconst ALLOWED = [\"interested\", \"not_now\", \"not_interested\", \"referral\", \"out_of_office\", \"unsubscribe\", \"auto_bounce\"];\n\nreturn items.map(item => {\n  const j = item.json;\n  let parsed = null;\n  try {\n    const block = (j.content || []).find(b => b.type === 'text');\n    parsed = JSON.parse(block.text);\n  } catch (e) {\n    return { json: { ...j, label: 'review', confidence: 0, evidence: '',\n                     fail_reason: 'classifier reply could not be parsed: ' + e.message } };\n  }\n  if (!ALLOWED.includes(parsed.label)) {\n    return { json: { ...j, label: 'review', confidence: 0, evidence: '',\n                     fail_reason: 'label outside the allowed set: ' + parsed.label } };\n  }\n  return {\n    json: {\n      ...j,\n      label: parsed.label,\n      confidence: Number(parsed.confidence),\n      evidence: String(parsed.evidence || ''),\n      usage_in: j.usage ? j.usage.input_tokens : null,\n      usage_out: j.usage ? j.usage.output_tokens : null,\n    },\n  };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        200,
        300
      ],
      "name": "Read label, fail closed"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.confidence }}",
              "rightValue": 0.9,
              "operator": {
                "type": "number",
                "operation": "lt"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        420,
        300
      ],
      "name": "Confident enough?"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "x-api-key",
              "value": "={{ $env.ANTHROPIC_API_KEY }}"
            },
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            },
            {
              "name": "content-type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\"model\": \"claude-opus-5\", \"max_tokens\": 1024, \"system\": \"You triage replies to cold outbound email. Assign exactly one label from the allowed set, a calibrated confidence between 0 and 1, and the shortest verbatim span from the reply that decides it. Precedence: an explicit removal or deletion request is unsubscribe however politely phrased; a named alternative person is referral even alongside a soft decline; a stated future date is not_now rather than not_interested.\", \"messages\": [{\"role\": \"user\", \"content\": \"=Subject: {{ $json.subject }}\\n---\\n{{ $json.body }}\"}], \"output_config\": {\"format\": {\"type\": \"json_schema\", \"schema\": {\"type\": \"object\", \"properties\": {\"label\": {\"type\": \"string\", \"enum\": [\"interested\", \"not_now\", \"not_interested\", \"referral\", \"out_of_office\", \"unsubscribe\", \"auto_bounce\"]}, \"confidence\": {\"type\": \"number\"}, \"evidence\": {\"type\": \"string\"}}, \"required\": [\"label\", \"confidence\", \"evidence\"], \"additionalProperties\": false}}}}",
        "options": {
          "response": {
            "response": {
              "neverError": true
            }
          },
          "timeout": 30000
        }
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        640,
        180
      ],
      "name": "Second opinion (stronger model)"
    },
    {
      "parameters": {
        "jsCode": "// Two readings of the same reply. Where they agree, the label stands. Where\n// they disagree, nothing is routed and a human decides.\n//\n// On the sample corpus this gate fired on 2 of 54 replies and those 2 were\n// the only replies either model got wrong. Both were items where the correct\n// label is genuinely arguable, which is the honest reason a review lane\n// exists at all.\nreturn items.map(item => {\n  const j = item.json;\n  const first = j.label;\n  const second = j.second_label;\n  if (!second || first === second) {\n    return { json: { ...j, label: first, adjudicated: false } };\n  }\n  return {\n    json: {\n      ...j,\n      label: 'review',\n      adjudicated: true,\n      disagreement: first + ' vs ' + second,\n    },\n  };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        860,
        300
      ],
      "name": "Adjudicate"
    },
    {
      "parameters": {
        "jsCode": "// The destination for each class, and what a wrong call costs there. This is\n// data, not wiring: changing where a class lands never means touching a node.\n//\n// Nothing here sends anything to a prospect. Positives are flagged and\n// notified so they can be eyeballed first.\nconst ROUTES = {\n  interested:     { action: 'notify_and_flag',  channel: 'slack',   auto_send: false },\n  referral:       { action: 'notify_and_flag',  channel: 'slack',   auto_send: false,\n                    extract: 'named_person' },\n  not_now:        { action: 'crm_followup',     channel: 'crm',     auto_send: false,\n                    needs: 'follow_up_date' },\n  not_interested: { action: 'crm_close',        channel: 'crm',     auto_send: false,\n                    suppress: true },\n  out_of_office:  { action: 'pause_sequence',   channel: 'sequencer', auto_send: false,\n                    needs: 'return_date' },\n  unsubscribe:    { action: 'suppress_now',     channel: 'compliance', auto_send: false,\n                    suppress: true, log: true },\n  auto_bounce:    { action: 'suppress_and_count', channel: 'domain_health', auto_send: false,\n                    suppress: true },\n  review:         { action: 'human_review',     channel: 'review_queue', auto_send: false },\n};\n\nreturn items.map(item => {\n  const j = item.json;\n  const route = ROUTES[j.label] || ROUTES.review;\n  return { json: { ...j, route_action: route.action, route_channel: route.channel,\n                   auto_send: false, route: route } };\n});"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1080,
        300
      ],
      "name": "Attach routing decision"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "interested",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "interested"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "not_now",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "not_now"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "not_interested",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "not_interested"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "referral",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "referral"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "out_of_office",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "out_of_office"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "unsubscribe",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "unsubscribe"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "auto_bounce",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "auto_bounce"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.label }}",
                    "rightValue": "review",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "outputKey": "review"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra"
        }
      },
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1300,
        300
      ],
      "name": "Route by label"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1560,
        -80
      ],
      "name": "Slack: flag for your eyes"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1860,
        50
      ],
      "name": "CRM: follow up on the stated date"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1560,
        180
      ],
      "name": "CRM: close and suppress"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1860,
        310
      ],
      "name": "Slack: referral, pull the named person"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1560,
        440
      ],
      "name": "Sequencer: pause until back"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1860,
        570
      ],
      "name": "Suppression list and compliance log"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1560,
        700
      ],
      "name": "Suppress and count against domain health"
    },
    {
      "parameters": {},
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1860,
        830
      ],
      "name": "Review queue"
    }
  ],
  "connections": {
    "New reply in inbox": {
      "main": [
        [
          {
            "node": "Strip quoted thread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Strip quoted thread": {
      "main": [
        [
          {
            "node": "Classify (cheap pass)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify (cheap pass)": {
      "main": [
        [
          {
            "node": "Read label, fail closed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read label, fail closed": {
      "main": [
        [
          {
            "node": "Confident enough?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Confident enough?": {
      "main": [
        [
          {
            "node": "Second opinion (stronger model)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Adjudicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Second opinion (stronger model)": {
      "main": [
        [
          {
            "node": "Adjudicate",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Adjudicate": {
      "main": [
        [
          {
            "node": "Attach routing decision",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Attach routing decision": {
      "main": [
        [
          {
            "node": "Route by label",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by label": {
      "main": [
        [
          {
            "node": "Slack: flag for your eyes",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "CRM: follow up on the stated date",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "CRM: close and suppress",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: referral, pull the named person",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Sequencer: pause until back",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Suppression list and compliance log",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Suppress and count against domain health",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Review queue",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}
