AI Support Inbox Triage — classify, prioritize, draft the reply, human still sends

Sharing a workflow I built for support inboxes that were drowning in cold-start tickets: every email needed a human to read it, figure out category and urgency, then write a reply from scratch before anything could happen.

Here is the chain: new email trigger, AI classifies it (billing, technical, complaint, etc.), rates urgency, summarizes in one line, and drafts a reply.

Everything logs to a sheet for reporting, then the classification and draft land in Slack for a person to review before sending.

Deliberately kept a human in the loop on sending. The AI draft is good, but a fully automated reply to a real customer is a different bar than a good first draft. 5 nodes, about 20 minutes to wire up with an OpenAI compatible key, Gmail, Sheets, and Slack.

Workflow JSON:

json
{
  "name": "AI Support Inbox Triage",
  "nodes": [
    {
      "parameters": {
        "pollTimes": { "item": [{ "mode": "everyMinute" }] },
        "simple": false,
        "filters": {}
      },
      "id": "l1b2c3d4-0001-4a11-8b11-000000000001",
      "name": "New Support Email",
      "type": "n8n-nodes-base.gmailTrigger",
      "typeVersion": 1.2,
      "position": [-400, 0],
      "credentials": { "gmailOAuth2": { "id": "GMAIL_CRED_ID", "name": "Gmail account" } }
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ model: \"gpt-4o-mini\", response_format: { type: \"json_object\" }, messages: [ { role: \"system\", content: \"You triage customer support emails. Given the subject and body of an email, return ONLY a JSON object with keys: category (one of: billing, technical, general, complaint, spam), urgency (one of: low, medium, high), summary (one sentence), and draftReply (a polite, helpful draft reply in plain text, 3-5 sentences, ready for a human to review and send).\" }, { role: \"user\", content: \"Subject: \" + $json.subject + \"\\n\\nBody: \" + ($json.text || $json.snippet || '').slice(0, 3000) } ] }) }}",
        "options": {}
      },
      "id": "l1b2c3d4-0002-4a11-8b11-000000000002",
      "name": "Classify & Draft Reply (AI)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [-180, 0],
      "credentials": { "httpHeaderAuth": { "id": "AI_API_CRED_ID", "name": "AI API Key" } }
    },
    {
      "parameters": {
        "jsCode": "const raw = $input.first().json.choices[0].message.content;\nconst parsed = JSON.parse(raw);\nreturn [{\n  json: {\n    from: $('New Support Email').first().json.from,\n    subject: $('New Support Email').first().json.subject,\n    category: parsed.category,\n    urgency: parsed.urgency,\n    summary: parsed.summary,\n    draftReply: parsed.draftReply\n  }\n}];"
      },
      "id": "l1b2c3d4-0003-4a11-8b11-000000000003",
      "name": "Parse AI Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [40, 0]
    },
    {
      "parameters": {
        "operation": "append",
        "documentId": { "__rl": true, "value": "YOUR_GOOGLE_SHEET_ID", "mode": "id" },
        "sheetName": { "__rl": true, "value": "Support Log", "mode": "name" },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "From": "={{ $json.from }}",
            "Subject": "={{ $json.subject }}",
            "Category": "={{ $json.category }}",
            "Urgency": "={{ $json.urgency }}",
            "Summary": "={{ $json.summary }}",
            "Date": "={{ $now.toISO() }}"
          }
        },
        "options": {}
      },
      "id": "l1b2c3d4-0004-4a11-8b11-000000000004",
      "name": "Log to Sheet",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.5,
      "position": [260, -80],
      "credentials": { "googleSheetsOAuth2Api": { "id": "GOOGLE_SHEETS_CRED_ID", "name": "Google Sheets account" } }
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": { "__rl": true, "value": "YOUR_SLACK_CHANNEL_ID", "mode": "id" },
        "text": "=📬 [{{ $json.urgency }}/{{ $json.category }}] {{ $json.subject }}\nFrom: {{ $json.from }}\nSummary: {{ $json.summary }}\n\nSuggested reply:\n{{ $json.draftReply }}",
        "otherOptions": {}
      },
      "id": "l1b2c3d4-0005-4a11-8b11-000000000005",
      "name": "Send to Slack for Review",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [260, 80],
      "credentials": { "slackApi": { "id": "SLACK_CRED_ID", "name": "Slack account" } }
    }
  ],
  "connections": {
    "New Support Email": { "main": [[{ "node": "Classify & Draft Reply (AI)", "type": "main", "index": 0 }]] },
    "Classify & Draft Reply (AI)": { "main": [[{ "node": "Parse AI Response", "type": "main", "index": 0 }]] },
    "Parse AI Response": {
      "main": [
        [
          { "node": "Log to Sheet", "type": "main", "index": 0 },
          { "node": "Send to Slack for Review", "type": "main", "index": 0 }
        ]
      ]
    }
  },
  "active": false,
  "settings": { "executionOrder": "v1" },
  "meta": { "templateCredsSetupCompleted": false },
  "id": "flowmanic-ai-support-inbox-triage"
}


Nice approach. I’d keep the AI classification and the actual sending step completely separate, especially since a human is still approving the reply.

I’d structure it roughly like:

Email Trigger → Normalize → AI Classification → Validate JSON → Priority/Category Routing → Draft Reply → Human Approval → Send

For the AI output, I’d use a strict schema such as:


{
  "category": "billing",
  "priority": "high",
  "needs_human": true,
  "summary": "...",
  "draft_reply": "..."
}

Then validate the output before using category or priority for routing. This prevents an unexpected AI response from breaking the workflow.

I’d also store the original message ID/thread ID with the draft. That makes it easier to reply in the correct email thread and prevents duplicate processing if the trigger is retried.

For the human approval step, I’d probably make the approval action update a status such as approved, rejected, or needs_edit rather than relying only on a boolean.

That gives you a clean separation between AI interpretation → deterministic routing → human approval → sending.

One thing I’d be interested in testing is whether priority should be entirely AI-generated or whether some hard rules should override the AI, e.g. billing/security keywords always forcing high priority.

Nice one, and good call keeping a human on the send.

Two things I’d fix before running this in production:

  1. The Code node uses .first(), so if two emails arrive in the same poll, only one gets processed. Map over $input.all() instead.
  2. With simple: false, Gmail’s from is an object. Use from.text, or you’ll see [object Object] in Slack.

Also worth adding a try/catch around JSON.parse and an IF node to drop spam before it reaches Slack.

Next step I’d suggest: swap the Slack message for Send and Wait with an Approve button that triggers a Gmail reply in the thread. Same human check, but no copy-pasting.