This workflow follows the Airtable → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
airtableTokenApianthropicApislackApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Ai-Ticket-Triage. Uses anthropic, httpRequest, slack, airtable. Webhook trigger; 14 nodes.
Source: https://github.com/mcruz1799/automation-examples/blob/main/claude-code/02-automation-file-factory/skills/n8n/section-templates/ai-ticket-triage.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Lead-Scoring-Routing. Uses anthropic, httpRequest, slack, twilio. Webhook trigger; 15 nodes.
Recruitment Ops Automation Suite (EU Placements). Uses gmailTrigger, anthropic, airtable, gmail. Webhook trigger; 71 nodes.
AI-Powered Fake Review Detection Workflow Using n8n & Airtable. Uses httpRequest, airtable, openAi, slack. Webhook trigger; 27 nodes.
This workflow automatically monitors Facebook Group posts, analyzes them using AI, detects policy violations, logs incidents, notifies moderators and automatically hides high-severity posts to keep th
Board LinkedIn Monitor - Weekly. Uses httpRequest, airtable, anthropic, slack. Scheduled trigger; 15 nodes.