{
  "nodes": [
    {
      "parameters": {
        "content": "## Guardrails + data\n- Auto-handle only when confidence is 0.75+, priority is normal or low, and the type is on the allow list\n- Classifier error or bad JSON = escalate. The failure mode is a human, never a wrong action\n- Urgent after hours routes to on-call instead of the team channel\n- Before the model call: identifiers never enter the prompt, and PII inside the text (emails, phones, card and ID numbers) is scrubbed to [tokens] by Redact Customer Data\n- Strict residency: n8n runs on the client's own infrastructure and the model call is one swappable node - point it at Bedrock, Vertex, or a self-hosted model in-region and nothing else changes",
        "height": 240,
        "width": 520
      },
      "id": "sticky-guardrails",
      "name": "Guardrails",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -640,
        40
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "ticket-in",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "webhook-ticket-in",
      "name": "Ticket In",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "const body = $json.body ?? $json;\nconst subject = String(body.subject ?? body.title ?? '').trim();\nconst message = String(body.body ?? body.message ?? body.description ?? '').trim().slice(0, 4000);\nconst customerEmail = body.customer_email ?? body.email ?? '';\nconst ticketId = String(body.ticket_id ?? body.id ?? 'tkt-' + Date.now());\nconst channel = body.channel ?? body.source ?? 'webhook';\nreturn [{ json: { ticketId, subject, message, customerEmail, channel } }];"
      },
      "id": "code-normalize",
      "name": "Normalize Ticket",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        240,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ ($json.subject + $json.message).trim() }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-has-content",
      "name": "Has Content?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        480,
        0
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: false, error: 'The ticket had no subject or body' }) }}",
        "options": {
          "responseCode": 422
        }
      },
      "id": "respond-empty",
      "name": "Respond Empty Ticket",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        480,
        240
      ]
    },
    {
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5"
        },
        "messages": {
          "values": [
            {
              "content": "=Subject: {{ $json.redactedSubject }}\n\n{{ $json.redactedMessage }}",
              "role": "user"
            }
          ]
        },
        "options": {
          "system": "You triage support tickets. Reply with JSON only: {\"type\": one of \"billing\", \"technical\", \"account\", \"general\", \"priority\": one of \"urgent\", \"high\", \"normal\", \"low\", \"confidence\": a number from 0 to 1, \"reason\": one short sentence, \"suggested_reply\": a short plain reply to the customer when the ticket is routine and you are sure, otherwise an empty string}. If you are unsure about anything, lower the confidence. Never invent account details, amounts, or promises."
        }
      },
      "id": "http-classify",
      "name": "Classify With Claude",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        1000,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput",
      "credentials": {
        "anthropicApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const AUTO_TYPES = ['general', 'account'];\nconst MIN_CONFIDENCE = 0.75;\nconst TZ = 'America/New_York';\nconst OPEN_HOUR = 9;\nconst CLOSE_HOUR = 17;\n\nconst ticket = $('Normalize Ticket').item.json;\nlet c = {};\ntry {\n  const text = $json.content?.[0]?.text ?? $json.message?.content?.[0]?.text ?? $json.text ?? '{}';\n  c = JSON.parse(String(text).replace(/^```json\\s*|```\\s*$/g, ''));\n} catch (e) {\n  c = {};\n}\n\nconst confidence = Number(c.confidence ?? 0);\nconst type = c.type ?? 'unknown';\nconst priority = c.priority ?? 'unknown';\n\nconst now = new Date();\nconst hour = Number(new Intl.DateTimeFormat('en-US', { hour: 'numeric', hour12: false, timeZone: TZ }).format(now));\nconst day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: TZ }).format(now);\nconst inBusinessHours = !['Sat', 'Sun'].includes(day) && hour >= OPEN_HOUR && hour < CLOSE_HOUR;\n\n// Auto-handle needs ALL of: solid confidence, low stakes, and a type on the allow list.\n// Anything unusual falls through to a person. The default answer is always 'human'.\nlet route = 'human';\nlet reason = c.reason ?? '';\nif (type === 'unknown' || !confidence) {\n  reason = 'Classifier returned an unusable answer, so this goes to a person';\n} else if (priority === 'urgent' || priority === 'high') {\n  reason = 'Priority is ' + priority + '. ' + reason;\n} else if (confidence < MIN_CONFIDENCE) {\n  reason = 'Confidence ' + confidence + ' is below ' + MIN_CONFIDENCE + '. ' + reason;\n} else if (!AUTO_TYPES.includes(type)) {\n  reason = 'Type ' + type + ' is not on the auto-handle list. ' + reason;\n} else {\n  route = 'auto';\n}\n\nconst escalationTarget = priority === 'urgent' && !inBusinessHours ? 'on-call' : 'team';\nreturn [{ json: { ...ticket, type, priority, confidence, route, reason, inBusinessHours, escalationTarget, suggestedReply: c.suggested_reply ?? '' } }];"
      },
      "id": "code-guardrails",
      "name": "Apply Guardrails",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1260,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// The classifier failed after retries. The safe move is a person, not a guess.\nconst ticket = $('Normalize Ticket').item.json;\nreturn [{ json: { ...ticket, type: 'unknown', priority: 'unknown', confidence: 0, route: 'human', reason: 'Classifier was unavailable, so the ticket escalated by default', inBusinessHours: null, escalationTarget: 'team', suggestedReply: '' } }];"
      },
      "id": "code-classifier-down",
      "name": "Escalate On Classifier Failure",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1260,
        -220
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ $json.route }}",
              "rightValue": "auto",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "if-auto",
      "name": "Handle Automatically?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1520,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.helpdesk-demo.example.com/v1/tickets/reply",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ ticket_id: $json.ticketId, reply: $json.suggestedReply || 'Thanks for writing in. We got your message and will follow up soon.' }) }}",
        "options": {}
      },
      "id": "http-auto-reply",
      "name": "Send Auto Reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1780,
        -160
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "support-escalations"
        },
        "text": "=Ticket needs a human ({{ $json.escalationTarget }})\nSubject: {{ $json.subject }}\nType: {{ $json.type }} | Priority: {{ $json.priority }} | Confidence: {{ $json.confidence }}\nWhy: {{ $json.reason }}\nTicket: {{ $json.ticketId }}",
        "otherOptions": {}
      },
      "id": "http-slack-notify",
      "name": "Notify Team In Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.5,
      "position": [
        1780,
        160
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput",
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "resource": "record",
        "operation": "create",
        "base": {
          "__rl": true,
          "mode": "id",
          "value": "appSupportDesk001"
        },
        "table": {
          "__rl": true,
          "mode": "id",
          "value": "tblTickets"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "Ticket ID": "={{ $('Handle Automatically?').item.json.ticketId }}",
            "Customer Email": "={{ $('Handle Automatically?').item.json.customerEmail }}",
            "Channel": "={{ $('Handle Automatically?').item.json.channel }}",
            "Type": "={{ $('Handle Automatically?').item.json.type }}",
            "Priority": "={{ $('Handle Automatically?').item.json.priority }}",
            "Confidence": "={{ $('Handle Automatically?').item.json.confidence }}",
            "Route": "={{ $('Handle Automatically?').item.json.route }}",
            "Reason": "={{ $('Handle Automatically?').item.json.reason }}"
          },
          "matchingColumns": [],
          "schema": []
        },
        "options": {}
      },
      "id": "http-log-crm",
      "name": "Log To Airtable",
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        2040,
        0
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueRegularOutput",
      "credentials": {
        "airtableTokenApi": {
          "name": "<your credential>"
        }
      }
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ ok: true, ticketId: $('Handle Automatically?').item.json.ticketId, type: $('Handle Automatically?').item.json.type, priority: $('Handle Automatically?').item.json.priority, route: $('Handle Automatically?').item.json.route, confidence: $('Handle Automatically?').item.json.confidence, reason: $('Handle Automatically?').item.json.reason }) }}",
        "options": {}
      },
      "id": "respond-triage",
      "name": "Respond With Triage Result",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2300,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "// Customers paste their own PII into ticket bodies. Scrub the obvious kinds\n// before anything leaves this box for the model. Order matters: cards before\n// phones, so the phone pattern cannot eat part of a card number.\nconst redact = (text) => String(text)\n  .replace(/[\\w.+-]+@[\\w-]+\\.[\\w.-]+/g, '[email]')\n  .replace(/\\b(?:\\d[ -]?){12,15}\\d\\b/g, '[card]')\n  .replace(/\\b\\d{3}-\\d{2}-\\d{4}\\b/g, '[ssn]')\n  .replace(/(\\+?\\d[\\d ().-]{7,}\\d)/g, '[phone]')\n  .replace(/\\b\\d{6,}\\b/g, '[number]');\n\nreturn [{ json: { ...$json, redactedSubject: redact($json.subject), redactedMessage: redact($json.message) } }];"
      },
      "id": "code-redact-pii",
      "name": "Redact Customer Data",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        0
      ]
    }
  ],
  "connections": {
    "Ticket In": {
      "main": [
        [
          {
            "node": "Normalize Ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize Ticket": {
      "main": [
        [
          {
            "node": "Has Content?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Content?": {
      "main": [
        [
          {
            "node": "Redact Customer Data",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Empty Ticket",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Classify With Claude": {
      "main": [
        [
          {
            "node": "Apply Guardrails",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Escalate On Classifier Failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Apply Guardrails": {
      "main": [
        [
          {
            "node": "Handle Automatically?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Escalate On Classifier Failure": {
      "main": [
        [
          {
            "node": "Handle Automatically?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Automatically?": {
      "main": [
        [
          {
            "node": "Send Auto Reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify Team In Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send Auto Reply": {
      "main": [
        [
          {
            "node": "Log To Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Notify Team In Slack": {
      "main": [
        [
          {
            "node": "Log To Airtable",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log To Airtable": {
      "main": [
        [
          {
            "node": "Respond With Triage Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Redact Customer Data": {
      "main": [
        [
          {
            "node": "Classify With Claude",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}