{
  "name": "Lumeor | IT helpdesk requests from Slack",
  "nodes": [
    {
      "parameters": {
        "content": "## IT helpdesk starter (Slack + Jira Service Management)\n\nEmployees post in #it-help. Claude classifies each new message against a fixed category list and pulls out the app, error, location and urgency. How-to questions get the top knowledge base articles as a thread reply. Everything else, including any message the model could not classify, becomes a Jira Service Management request, and the employee gets the ticket key in the thread. Outages also page the on-call channel. Priority comes from rules in code, not from the model.\n\n### Setup\n1. Create a Slack app with Event Subscriptions (`message.channels`) pointed at the **Slack: new message in #it-help** trigger URL, scopes `channels:history`, `chat:write`, `users:read`. Set `REPLACE_WITH_IT_HELP_CHANNEL_ID` on the trigger.\n2. In **Parse and route**, set `REPLACE_WITH_SERVICE_DESK_ID`, the request type IDs per category, and `REPLACE_WITH_KB_SPACE_KEY`.\n3. Replace `REPLACE_WITH_ATLASSIAN_SITE` in both Atlassian URLs. Both use the n8n Jira Software Cloud credential (email + API token), which also works for Confluence Cloud search.\n4. Set `REPLACE_WITH_ONCALL_CHANNEL_ID` and `REPLACE_WITH_IT_OPS_CHANNEL_ID`.\n5. Credentials: Slack, Anthropic API, Jira Software Cloud API.\n\nClassification runs at low effort on `claude-opus-5-5`. `claude-haiku-5-5` is a cheaper option for high-volume, simple classification like this.\n\nNot included: manager approval and identity provider actions (Microsoft Graph). Add them after the routing is trusted.\n\nTemplate by Lumeor Studio, https://www.lumeor.studio/guides/automate-it-helpdesk-requests/",
        "height": 860,
        "width": 540,
        "color": 7
      },
      "id": "83be79ae-5a98-4262-8732-ec6de63007f9",
      "name": "About this template",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -260
      ]
    },
    {
      "parameters": {
        "trigger": [
          "message"
        ],
        "watchWorkspace": false,
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_IT_HELP_CHANNEL_ID",
          "mode": "id"
        },
        "options": {
          "resolveIds": true
        }
      },
      "id": "aac3b5a8-e4f0-41c5-a2d8-2647e2f37b93",
      "name": "Slack: new message in #it-help",
      "type": "n8n-nodes-base.slackTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "webhookId": "7bcc87b6-a6f1-414f-a65a-d475f6cb4065"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Only new top-level messages from people. Ignore bots, edits, joins and thread replies.\nconst CATEGORIES = ['how_to', 'access_request', 'hardware', 'outage', 'account_lockout', 'purchase', 'other'];\nconst nul = (type) => ({ anyOf: [{ type }, { type: 'null' }] });\nconst schema = {\n  type: 'object',\n  additionalProperties: false,\n  required: ['category', 'summary', 'app_or_device', 'error_message', 'location', 'urgency', 'kb_query', 'confidence'],\n  properties: {\n    category: { type: 'string', enum: CATEGORIES },\n    summary: { type: 'string', description: 'Ticket title, under 80 characters.' },\n    app_or_device: nul('string'),\n    error_message: nul('string'),\n    location: nul('string'),\n    urgency: { type: 'string', enum: ['low', 'normal', 'high'], description: 'From what the employee said, e.g. blocked from working = high.' },\n    kb_query: { type: 'string', description: 'Three to six search keywords for the IT knowledge base.' },\n    confidence: { type: 'string', enum: ['high', 'medium', 'low'] },\n  },\n};\n\nconst out = [];\n$input.all().forEach((item, i) => {\n  const e = item.json;\n  if (e.bot_id || e.subtype || !e.text) return;\n  if (e.thread_ts && e.thread_ts !== e.ts) return;\n  out.push({\n    json: {\n      slack: { channel: e.channel, ts: e.ts, user: e.user, userName: e.user_resolved || e.user, text: e.text },\n      claudeRequest: {\n        model: 'claude-opus-5-5',\n        max_tokens: 1000,\n        system: 'You triage IT helpdesk messages. Choose exactly one category: ' + CATEGORIES.join(', ') + '. how_to means the person wants instructions. outage means a shared system is down for them or others. Extract only what the message states.',\n        messages: [{ role: 'user', content: '<message>\\n' + e.text + '\\n</message>' }],\n        output_config: { effort: 'low', format: { type: 'json_schema', schema } },\n      },\n    },\n    pairedItem: { item: i },\n  });\n});\nreturn out;\n"
      },
      "id": "f01f275b-541b-4a5a-bbf8-13b97532f21f",
      "name": "Filter and build prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "anthropic-version",
              "value": "2023-06-01"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.claudeRequest) }}",
        "options": {
          "timeout": 120000
        }
      },
      "id": "2f1fefef-a2a2-4380-bc2e-fc6df91d60c0",
      "name": "Claude: classify request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// Config\nconst SERVICE_DESK_ID = 'REPLACE_WITH_SERVICE_DESK_ID';\nconst REQUEST_TYPES = { how_to: 'REPLACE_WITH_REQUEST_TYPE_ID_GET_HELP', access_request: 'REPLACE_WITH_REQUEST_TYPE_ID_ACCESS', hardware: 'REPLACE_WITH_REQUEST_TYPE_ID_HARDWARE', outage: 'REPLACE_WITH_REQUEST_TYPE_ID_INCIDENT', account_lockout: 'REPLACE_WITH_REQUEST_TYPE_ID_ACCESS', purchase: 'REPLACE_WITH_REQUEST_TYPE_ID_PURCHASE', other: 'REPLACE_WITH_REQUEST_TYPE_ID_GET_HELP' };\nconst KB_SPACE_KEY = 'REPLACE_WITH_KB_SPACE_KEY';\nconst SET_PRIORITY_FIELD = false; // true only if your request types expose the Priority field\n\n// Pull the structured JSON out of a Messages API response.\n// Opus 5.5 always thinks, so a (possibly empty) thinking block can come before the text block.\nfunction parseClaude(res) {\n  if (!res || typeof res !== 'object') return { error: 'Empty response from the Messages API' };\n  if (res.stop_reason === 'refusal') {\n    return { error: 'Model declined the request (refusal' + (res.stop_details?.category ? ': ' + res.stop_details.category : '') + ')' };\n  }\n  if (res.stop_reason === 'max_tokens') return { error: 'Response hit max_tokens before the JSON was complete' };\n  const block = (res.content || []).find((b) => b.type === 'text');\n  if (!block) return { error: 'No text block in the response' };\n  try {\n    return { data: JSON.parse(block.text) };\n  } catch (e) {\n    return { error: 'Could not parse model JSON: ' + e.message };\n  }\n}\n\n// Priority by rule, not by model opinion.\nfunction priorityFor(category, urgency) {\n  if (category === 'outage') return urgency === 'low' ? 'High' : 'Highest';\n  if (category === 'account_lockout') return 'High';\n  if (urgency === 'high') return 'High';\n  return urgency === 'low' ? 'Low' : 'Medium';\n}\n\nreturn $input.all().map((item, i) => {\n  const slackMsg = $('Filter and build prompt').itemMatching(i).json.slack;\n  // The Claude node's error output is wired here too, so an API failure still becomes a ticket.\n  const { data, error } = parseClaude(item.json.error ? null : item.json);\n  const c = data || { category: 'other', summary: slackMsg.text.slice(0, 80), app_or_device: null, error_message: null, location: null, urgency: 'normal', kb_query: '', confidence: 'low' };\n  const priority = priorityFor(c.category, c.urgency);\n  const route = c.category === 'how_to' && c.confidence !== 'low' && c.kb_query ? 'kb' : 'ticket';\n  const safeQuery = c.kb_query.replace(/[\"\\\\]/g, ' ');\n\n  const description = [\n    'Reported in Slack by ' + slackMsg.userName + ' (' + slackMsg.user + ')',\n    'Category: ' + c.category + ' | Urgency: ' + c.urgency + ' | Priority by rule: ' + priority,\n    'App or device: ' + (c.app_or_device || 'not stated'),\n    'Error: ' + (c.error_message || 'not stated'),\n    'Location: ' + (c.location || 'not stated'),\n    error ? 'Note: automatic classification failed (' + (item.json.error?.message || error) + ')' : '',\n    '',\n    'Original message:',\n    slackMsg.text,\n  ].filter((l) => l !== '').join('\\n');\n\n  const requestFieldValues = { summary: c.summary, description };\n  if (SET_PRIORITY_FIELD) requestFieldValues.priority = { name: priority };\n\n  return {\n    json: {\n      slack: slackMsg,\n      classification: c,\n      classificationError: error,\n      priority,\n      route,\n      alertOnCall: c.category === 'outage',\n      cql: 'space = \"' + KB_SPACE_KEY + '\" AND type = page AND text ~ \"' + safeQuery + '\"',\n      jsmBody: { serviceDeskId: SERVICE_DESK_ID, requestTypeId: REQUEST_TYPES[c.category] || REQUEST_TYPES.other, requestFieldValues },\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "8bc273cd-13b5-4300-82a4-b26bfce10788",
      "name": "Parse and route",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "c94c8f01-6177-4a27-9482-7628830ab576",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "kb",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "knowledge base"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "00260efc-c542-4e06-8f73-b17eb685fcae",
                    "leftValue": "={{ $json.route }}",
                    "rightValue": "ticket",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "ticket"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "9d3c7549-2b52-4c8d-bb43-131bf6ea434d",
                    "leftValue": "={{ $json.alertOnCall }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "page on-call"
            }
          ]
        },
        "options": {
          "allMatchingOutputs": true
        }
      },
      "id": "864a5f50-cb9f-4401-b4e3-7338f11fb205",
      "name": "Route request",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        880,
        300
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://REPLACE_WITH_ATLASSIAN_SITE.atlassian.net/wiki/rest/api/search",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "jiraSoftwareCloudApi",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "cql",
              "value": "={{ $json.cql }}"
            },
            {
              "name": "limit",
              "value": "3"
            }
          ]
        },
        "options": {
          "timeout": 20000
        }
      },
      "id": "dbb2b435-588f-4819-adc2-c87d75f49841",
      "name": "Search knowledge base",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1100,
        100
      ],
      "retryOnFail": true,
      "maxTries": 2,
      "waitBetweenTries": 2000,
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "return $input.all().map((item, i) => {\n  const r = item.json;\n  const base = r._links?.base || 'https://REPLACE_WITH_ATLASSIAN_SITE.atlassian.net/wiki';\n  const results = Array.isArray(r.results) ? r.results : [];\n  const links = results.slice(0, 3).map((x, n) => (n + 1) + '. <' + base + (x.url || x.content?._links?.webui || '') + '|' + (x.title || x.content?.title || 'Article') + '>');\n  return {\n    json: {\n      found: links.length > 0,\n      replyText: 'These articles should help:\\n' + links.join('\\n') + '\\n\\nIf this does not fix it, reply in this thread and someone from IT will pick it up.',\n    },\n    pairedItem: { item: i },\n  };\n});\n"
      },
      "id": "fb61dc65-39d8-4f35-945c-0835ad7955f4",
      "name": "Format KB reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        100
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "bcb6ff07-c81e-4cdb-8c46-9b578c5ee76b",
              "leftValue": "={{ $json.found }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "7e56fa78-a632-4cf0-940a-6f2e64856bcb",
      "name": "Articles found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1540,
        100
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $('Parse and route').item.json.slack.channel }}",
          "mode": "id"
        },
        "text": "={{ $json.replyText }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $('Parse and route').item.json.slack.ts }}"
            }
          }
        }
      },
      "id": "9426b694-887c-40f8-891d-465c644fbeb3",
      "name": "Slack: reply with articles",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1760,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://REPLACE_WITH_ATLASSIAN_SITE.atlassian.net/rest/servicedeskapi/request",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "jiraSoftwareCloudApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($('Parse and route').item.json.jsmBody) }}",
        "options": {
          "timeout": 20000
        }
      },
      "id": "d976c0f7-46cd-4c64-96fd-ff0266e07f16",
      "name": "JSM: create request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1760,
        300
      ],
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 3000,
      "onError": "continueErrorOutput"
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "={{ $('Parse and route').item.json.slack.channel }}",
          "mode": "id"
        },
        "text": "=Thanks. I opened {{ $json.issueKey }} ({{ $('Parse and route').item.json.classification.category.replace('_', ' ') }}, priority {{ $('Parse and route').item.json.priority }}). IT will follow up here: {{ $json._links?.web ?? '' }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $('Parse and route').item.json.slack.ts }}"
            }
          }
        }
      },
      "id": "7e0bcf66-04bb-4ced-8fa5-57632acaaf82",
      "name": "Slack: confirm ticket",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        240
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_ONCALL_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=:rotating_light: Possible outage reported by {{ $json.slack.userName }}: {{ $json.classification.summary }}\nApp: {{ $json.classification.app_or_device ?? 'not stated' }} | Error: {{ $json.classification.error_message ?? 'not stated' }}\nOriginal: {{ $json.slack.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "04e04a22-2353-40c3-8be7-461effe4af8a",
      "name": "Slack: page on-call",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1100,
        480
      ]
    },
    {
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "REPLACE_WITH_IT_OPS_CHANNEL_ID",
          "mode": "id"
        },
        "text": "=:warning: Could not create a JSM request for a Slack message from {{ $('Parse and route').item.json.slack.userName }}.\nError: {{ $json.error?.message ?? JSON.stringify($json.error ?? $json) }}\nMessage: {{ $('Parse and route').item.json.slack.text }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "id": "a56b0c8b-4b18-4bd2-bbc0-478b99e71604",
      "name": "Slack: IT ops exceptions",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [
        1980,
        440
      ]
    }
  ],
  "connections": {
    "Slack: new message in #it-help": {
      "main": [
        [
          {
            "node": "Filter and build prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Filter and build prompt": {
      "main": [
        [
          {
            "node": "Claude: classify request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude: classify request": {
      "main": [
        [
          {
            "node": "Parse and route",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Parse and route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse and route": {
      "main": [
        [
          {
            "node": "Route request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route request": {
      "main": [
        [
          {
            "node": "Search knowledge base",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JSM: create request",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: page on-call",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search knowledge base": {
      "main": [
        [
          {
            "node": "Format KB reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format KB reply": {
      "main": [
        [
          {
            "node": "Articles found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Articles found?": {
      "main": [
        [
          {
            "node": "Slack: reply with articles",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "JSM: create request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "JSM: create request": {
      "main": [
        [
          {
            "node": "Slack: confirm ticket",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack: IT ops exceptions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
