This workflow corresponds to n8n.io template #15051 — we link there as the canonical source.
This workflow follows the Agent → Google Gemini Embeddings 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 →
{
"id": "u2l5SVO0arDmCLkb",
"meta": {
"templateCredsSetupCompleted": true
},
"name": "ChatAssistant",
"tags": [],
"nodes": [
{
"id": "08238c79-03df-4c80-b1ec-dae3a3500e4a",
"name": "GetQuestions",
"type": "n8n-nodes-base.webhook",
"position": [
-144,
384
],
"parameters": {
"path": "de0c2362-8dde-4a0b-ac00-6b21c4fc5844",
"options": {},
"httpMethod": "POST"
},
"typeVersion": 2.1
},
{
"id": "7a90d5ce-056f-418b-b37f-5a4e1415ab26",
"name": "ValidateAndExtractMessage",
"type": "n8n-nodes-base.code",
"position": [
176,
384
],
"parameters": {
"jsCode": "const EXPECTED_TOKEN = $input.first().json.CHAT_TOKEN;\n\nconst body = $input.first().json.body;\n\n// Validate token\nif (body.token !== EXPECTED_TOKEN) {\n throw new Error('Invalid webhook token');\n}\n\n// Strip trigger word and clean whitespace\nconst rawText = body.text || '';\nconst cleanText = rawText.replace(/@your-oncall-bot/gi, '').trim();\n\n// Reject empty messages WITHOUT attachments.\n// If file_ids is present we still want to process the message even if text is empty.\nconst rawFileIds = body.file_ids;\nconst hasAttachments = rawFileIds !== undefined\n && rawFileIds !== null\n && (Array.isArray(rawFileIds) ? rawFileIds.length > 0 : String(rawFileIds).trim() !== '');\n\nif (!cleanText && !hasAttachments) {\n throw new Error('Empty message after removing trigger word');\n}\n\n// Normalize file_ids to an array of strings.\n// Possible incoming shapes:\n// - undefined / null \u2192 []\n// - \"id1\" \u2192 [\"id1\"]\n// - \"id1,id2\" \u2192 [\"id1\", \"id2\"] (defensive, just in case)\n// - [\"id1\", \"id2\"] \u2192 [\"id1\", \"id2\"]\nfunction normalizeFileIds(value) {\n if (value === undefined || value === null) return [];\n if (Array.isArray(value)) {\n return value.map(v => String(v).trim()).filter(Boolean);\n }\n return String(value)\n .split(',')\n .map(v => v.trim())\n .filter(Boolean);\n}\n\nconst fileIds = normalizeFileIds(rawFileIds);\n\n// NOTE: we intentionally do NOT compute thread membership here.\n// Mattermost outgoing webhook payload is not reliable for this \u2014\n// the AI Agent will resolve it via Mattermost MCP (get_thread).\nreturn [{\n json: {\n message: cleanText,\n post_id: body.post_id,\n channel_id: body.channel_id,\n channel_name: body.channel_name,\n user_name: body.user_name,\n user_id: body.user_id,\n team_domain: body.team_domain,\n timestamp: body.timestamp,\n file_ids: fileIds,\n has_attachments: fileIds.length > 0\n }\n}];"
},
"typeVersion": 2
},
{
"id": "8d5ce5dd-fbe1-43bf-8468-aa43fdaf9abf",
"name": "OpenRouter Chat Model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"position": [
400,
528
],
"parameters": {
"model": "anthropic/claude-sonnet-4.6",
"options": {}
},
"credentials": {
"openRouterApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "5bc08445-d9aa-4616-ab27-0cbdca05a481",
"name": "Parse AI Response",
"type": "n8n-nodes-base.code",
"position": [
816,
384
],
"parameters": {
"jsCode": "// Parse LLM classification response, validate category, fallback to \"other\"\n\nconst VALID_CATEGORIES = [\n 'modify_infrastructure',\n 'incident',\n 'question',\n 'ci_cd_error',\n 'new_system',\n 'announcement',\n 'other'\n];\n\nconst aiOutput = $input.first().json.text || $input.first().json.output || '';\n\n// Robust JSON extraction \u2014 handles:\n// 1) pure JSON\n// 2) JSON wrapped in ```json ... ``` fences\n// 3) JSON with prose before/after (agent \"thinking out loud\")\nfunction extractJson(text) {\n if (!text) return null;\n\n // Prefer a fenced ```json ... ``` block if present\n const fenced = text.match(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/i);\n if (fenced && fenced[1]) {\n try { return JSON.parse(fenced[1].trim()); } catch (_) { /* fall through */ }\n }\n\n // Otherwise grab the widest {...} span and try to parse it\n const firstBrace = text.indexOf('{');\n const lastBrace = text.lastIndexOf('}');\n if (firstBrace !== -1 && lastBrace > firstBrace) {\n const candidate = text.slice(firstBrace, lastBrace + 1);\n try { return JSON.parse(candidate); } catch (_) { /* ignore */ }\n }\n\n // Last resort: raw parse\n try { return JSON.parse(text.trim()); } catch (_) { return null; }\n}\n\nlet parsed = extractJson(aiOutput);\nlet parseFailed = false;\n\nif (!parsed || typeof parsed !== 'object') {\n parseFailed = true;\n parsed = {\n category: 'other',\n confidence: 0,\n summary: 'Failed to parse AI response',\n acknowledge: '',\n is_thread: false,\n parent_post: ''\n };\n}\n\nif (!VALID_CATEGORIES.includes(parsed.category)) {\n parsed.category = 'other';\n parsed.confidence = 0;\n}\n\n// Pull upstream data (raw webhook extract)\nconst upstream = $('ValidateAndExtractMessage').first().json;\n\n// Safety fallback: if the agent did not return parent_post, reply as a new\n// thread rooted at the current post_id.\nconst parentPost = (parsed.parent_post && String(parsed.parent_post).trim())\n || upstream.post_id;\n\nconst isThread = parsed.is_thread === true;\n\nreturn [{\n json: {\n ...upstream,\n category: parsed.category,\n confidence: parsed.confidence,\n summary: parsed.summary || '',\n acknowledge: parsed.acknowledge || '',\n is_thread: isThread,\n parent_post: parentPost,\n // Keep the old field name so downstream sub-workflows and reply nodes\n // that still reference `thread_root_id` continue to work unchanged.\n thread_root_id: parentPost,\n // Debug flag \u2014 useful when checking executions\n _parse_failed: parseFailed\n }\n}];"
},
"typeVersion": 2
},
{
"id": "cfded253-d20c-41e8-8f1f-d614134f207f",
"name": "Switch",
"type": "n8n-nodes-base.switch",
"position": [
1344,
304
],
"parameters": {
"rules": {
"values": [
{
"outputKey": "modify_infrastructure",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "43563850-a0dc-48e7-bc8f-4feba2ebed70",
"operator": {
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "modify_infrastructure"
}
]
},
"renameOutput": true
},
{
"outputKey": "incident",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "d29bcd68-7dea-4882-ad95-d1a216ff659f",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "incident"
}
]
},
"renameOutput": true
},
{
"outputKey": "question",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "13abbfd6-4dc9-4fcb-8128-a3d3332a99ed",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "question"
}
]
},
"renameOutput": true
},
{
"outputKey": "ci_cd_error",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "d26d3d6b-0d2e-4ba5-be7a-3cebc0e0ef16",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "ci_cd_error"
}
]
},
"renameOutput": true
},
{
"outputKey": "new_system",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "18b50cea-3cb8-4517-9193-1f04ffdcd5d2",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "new_system"
}
]
},
"renameOutput": true
},
{
"outputKey": "announcement",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "f243a2ef-a148-4ec5-8aef-8bf3e13efd6e",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "announcement"
}
]
},
"renameOutput": true
},
{
"outputKey": "other",
"conditions": {
"options": {
"version": 3,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "08b1fef7-6642-4b4c-bfb5-0748312bdd2b",
"operator": {
"name": "filter.operator.equals",
"type": "string",
"operation": "equals"
},
"leftValue": "={{ $json.category }}",
"rightValue": "other"
}
]
},
"renameOutput": true
}
]
},
"options": {}
},
"typeVersion": 3.4
},
{
"id": "23954c02-f559-4bcd-bc7b-789b827b5d8a",
"name": "GetDutyEvent",
"type": "n8n-nodes-base.googleCalendar",
"position": [
976,
384
],
"parameters": {
"limit": 5,
"options": {
"orderBy": "startTime"
},
"timeMax": "={{ $now.endOf('day') }}",
"timeMin": "={{ $now.startOf('day') }}",
"calendar": {
"__rl": true,
"mode": "list",
"value": "<your-google-calendar-id>@group.calendar.google.com",
"cachedResultName": "DevOps on-call"
},
"operation": "getAll"
},
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
},
{
"id": "9692fed2-21bf-4b10-a1b4-924b0d6b371f",
"name": "Inject duty name",
"type": "n8n-nodes-base.code",
"position": [
1120,
384
],
"parameters": {
"jsCode": "const calendarEvents = $input.all();\nconst classification = $('Parse AI Response').first().json;\n\nlet onCallUser = 'unknown';\n\nfor (const event of calendarEvents) {\n const summary = event.json.summary || '';\n const match = summary.match(/@(\\S+)/);\n if (match) {\n onCallUser = match[1]; // Mattermost @handle from calendar event title\n break;\n }\n}\n\nreturn [{\n json: {\n ...classification,\n on_call_user: onCallUser\n }\n}];"
},
"typeVersion": 2
},
{
"id": "905ee612-45a6-48ec-bbbf-590971c5f603",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-208,
288
],
"parameters": {
"width": 544,
"height": 384,
"content": "## Input chain\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nReceiving a message and validating that it came from a trusted source"
},
"typeVersion": 1
},
{
"id": "6f7dcecb-2a0f-4828-b7fb-747ca888f7ab",
"name": "AI Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"position": [
496,
384
],
"parameters": {
"text": "=CONTEXT\n- channel_id: {{ $json.channel_id }}\n- channel_name: {{ $json.channel_name }}\n- post_id (the message that triggered this workflow): {{ $json.post_id }}\n- user_name: {{ $json.user_name }}\n\nUSER MESSAGE:\n{{ $json.message }}",
"options": {
"systemMessage": "=You are a request classifier for a DevOps/SRE team. You receive messages from a Mattermost channel where team members @-mention your configured on-call bot (match the handle used in ValidateAndExtractMessage and in Mattermost outgoing webhook keywords).\n\n## Step 1 \u2014 Always resolve thread context via Mattermost MCP\n\nYou CANNOT assume anything about whether the incoming message is part of a thread. You MUST determine it by calling the `get_thread` tool:\n\n1. Call `get_thread` with `post_id` = the `post_id` from CONTEXT.\n2. The tool returns the root post and all replies in chronological order.\n3. Find the post whose `id` equals the `post_id` from CONTEXT (this is the message that triggered you).\n4. Determine thread membership:\n - If the tool returns ONLY one post and its `root_id` is empty \u2192 this is a brand-new message in the channel.\n - `is_thread` = false\n - `parent_post` = the `post_id` from CONTEXT (so a NEW thread is started when we reply)\n - If the tool returns multiple posts, OR the triggering post has a non-empty `root_id` \u2192 this message is inside an existing thread.\n - `is_thread` = true\n - `parent_post` = the `id` of the ROOT post of the thread (the post whose `root_id` is empty, or equivalently the first post returned). This is what we must use as `root_id` when replying so our answer stays in the SAME thread.\n5. If the tool call fails for any reason, fall back to:\n - `is_thread` = false\n - `parent_post` = the `post_id` from CONTEXT\n - Reduce `confidence` accordingly.\n\nIMPORTANT: Do NOT narrate what you found in the tool result. Do NOT write any text before the JSON. The tool result is for YOUR reasoning only, not for the user.\n\n## Step 2 \u2014 Use thread context for classification (when is_thread = true)\n\nWhen `is_thread` = true, use the same `get_thread` result (do not call the tool again):\n- Read the ROOT post + all replies in chronological order.\n- The ROOT post usually contains the actual problem (link to failing build, error text, service name). Later replies contain investigation notes.\n- The latest user message is often a short follow-up like \"@your-oncall-bot any ideas what to try next?\" \u2014 it has no meaning without the thread.\n- Classify based on the WHOLE thread (root + replies + current message). Example: if the thread root contains a link to GitHub Actions / GitLab CI with a failed build and the user asks for ideas \u2014 category is `ci_cd_error`.\n\nWhen `is_thread` = false \u2014 classify based on the current user message alone.\n\nNever use `get_channel_messages` or `search_messages` for routine classification. `get_thread` is the only Mattermost tool you need.\n\n## Step 3 \u2014 Classify\n\nPick exactly ONE category.\n\n## Response format\n\n{\n \"category\": \"<category_key>\",\n \"confidence\": <0.0-1.0>,\n \"summary\": \"<one sentence summary in the same language as the user message>\",\n \"acknowledge\": \"<a short response that you accepted the request and started working on it>\",\n \"is_thread\": <true|false>,\n \"parent_post\": \"<post_id to use as root_id when replying \u2014 ALWAYS set>\"\n}\n\n## Categories\n\n### modify_infrastructure\nA group of requests related to infrastructure modifications. Typically, this involves changes to the architectural characteristics of the system:\nhigh availability, scalability, security, cost management, perfromance, operations, etc\nMain types of requests\n1. Changing characteristics of existing resources, changing integrations between resources, scaling, icnresing limits and deleting resources.\nExamples:\n- Increase max memory in stateful Valkey\n- Add the X-Region header to the ingress of our main API\n- Scale the Redpanda cluster to 5 instances in production\n- Remove the Meilisearch instance, we no longer use it\n- Increase the memory limit for bottle-api\n\n2. A request to create, allocate, or configure new standard infrastructure resources, i.e., those that we have already organized in our infrastructure. Creation databases (PostgreSQL, Redpanda, Valkey, ScyllaDB), secrets in Vault, ntopics in Kafka/Redpanda, DNS records, create new git repository, write new github actions workflow, add new alert into Prometheus etc.\nExamples:\n- Can you create a Postgres for the new service?\n- Please add a Vault secret\n- Can we add battlepass to the stateless Valkey?\n- We need Redis and Postgres for the new achievement service\n- Please set up a small Postgres for the roulette service\n- Create an alert for low disk space on the server\n\n3. This could be an explicit request to create a task\nExamples:\n - Create a task to expand load balancer monitoring\n\n### incident\nSomething is broken, down, not working, returning errors. Active production or staging issues. Keywords: broken, down, 500, 502, errors flooding, not working, crashed, fell over, investigate alert.\nExamples:\n- Two hours ago we broke the Kafka connection in Vault\n- Are you changing something? The admin panel is returning 500s\n- Postgres is down on staging\n- fetch had inner topic errors from broker\n- The network on staging is acting up\n- 502 errors are flooding in\n- Sentry is down\n- Sockets have been broken for about an hour\n- All jobs are stuck on staging\n- We got a disk space alert\n- Need to investigate the 500 error issue\n\n### question\nAsking for information, clarification, checking status. No urgent breakage, just wants to understand or verify something. This must be related to our infrastructure. Keywords: tell me, how does it work, is there docs, can you check, it seems like, I have a feeling, could it be.\nExamples:\n- How do pod crash alerts work?\n- Is there any documentation on domains?\n- There was an OOM on user-api but no alert came through\n- Please check the memory on stateful Valkey\n- It seems like battlepass-api metrics are not being collected\n- Could it be that the CDN is blocking MP3 files?\n- Are the ingress settings different between staging and production?\n\n### ci_cd_error\nCI/CD pipeline failures: builds broken, deployments failing, GitHub Actions issues, builds stuck in queue, timeouts.\nExamples:\n- Builds seem to be broken (link to GitHub Actions)\n- The build is taking very long\n- Builds are queuing but not running\n- Staging deployment is failing\n- CI/CD in Flutter is broken\n- Builds in pull requests are failing en masse\n- GitHub CI/CD is not working\n\n### new_system\n1. Implementing a fundamentally new system or integration. Use the knowledge base to determine whether such a system already exists in our infrastructure. This could be a request to create a vector database that we don't yet have. This could be a request to implement tracing using Grafana Tempo.\nExamples:\n- Set up a Qdrant vector database for us\n- Deploy OpenClaw on a DigitalOcean droplet\n\n2. This could be replacing the current solution with some new one that we haven't had yet.\nExamples:\n- We need to replace Nginx Ingress Controller with Envoy API Gateway\n- Let's migrate from Redis to Valkey\n\n### announcement\nNotifications about changes related to our infrastructure. For example, work in our cloud provider's Kubernetes cluster on a specific date. Changes in the cost of resources consumed by our infrastructure.\n\n### other\nAnything that does not clearly fit the above categories.\n---\n## Output contract (STRICT)\n\nYour final answer MUST be a single JSON object and NOTHING else:\n- No prose before the JSON.\n- No prose after the JSON.\n- No markdown, no ```json fences, no explanations.\n- No comments inside the JSON.\n\nIf you violate this contract, the downstream parser fails and the user gets no reply.\n\n## Important Rules\n1. If a message fits multiple categories, pick the PRIMARY intent.\n2. \"incident\" takes priority over \"question\" when something is clearly broken.\n3. \"change_request\" vs \"create_resource\": if the resource already exists and needs modification \u2192 change_request. If it's new \u2192 create_resource.\n4. Respond with JSON only.\n5. Don't ask anything.\n6. `parent_post` MUST ALWAYS be a valid post_id \u2014 never empty, never null."
},
"promptType": "define"
},
"typeVersion": 3.1
},
{
"id": "27a484cd-3aed-46b8-a50a-b15c467da89a",
"name": "Mattermost",
"type": "@n8n/n8n-nodes-langchain.mcpClientTool",
"position": [
640,
752
],
"parameters": {
"include": "selected",
"options": {},
"endpointUrl": "https://<your-mcp-host>/mattermost/mcp",
"includeTools": [
"get_channel",
"get_channel_by_name",
"get_channel_members",
"get_file_info",
"get_file_link",
"get_channel_messages",
"get_user",
"get_user_by_username",
"get_user_status",
"search_users",
"get_thread"
]
},
"typeVersion": 1.2
},
{
"id": "3c04da1e-eff1-46b4-a735-f60bfe4cd3b8",
"name": "Qdrant Vector Store",
"type": "@n8n/n8n-nodes-langchain.vectorStoreQdrant",
"position": [
768,
752
],
"parameters": {
"mode": "retrieve-as-tool",
"options": {},
"toolDescription": "Use this tool to fetch knowledge our IT infrastructure. ",
"qdrantCollection": {
"__rl": true,
"mode": "list",
"value": "<your-infrastructure-knowledge-collection>",
"cachedResultName": "<your-infrastructure-knowledge-collection>"
}
},
"credentials": {
"qdrantApi": {
"name": "<your credential>"
}
},
"typeVersion": 1.3
},
{
"id": "338a5d4f-1247-40ef-a253-f6b913e11197",
"name": "Embeddings Google Gemini",
"type": "@n8n/n8n-nodes-langchain.embeddingsGoogleGemini",
"position": [
768,
912
],
"parameters": {
"modelName": "models/gemini-embedding-2-preview"
},
"credentials": {
"googlePalmApi": {
"name": "<your credential>"
}
},
"typeVersion": 1
},
{
"id": "7e6a9624-446e-4669-a374-5b5d8f66fdd7",
"name": "SetVars",
"type": "n8n-nodes-base.set",
"position": [
16,
384
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "9175c8c7-4681-482b-a76a-e3815dea1fbe",
"name": "CHAT_TOKEN",
"type": "string",
"value": "<your-chat-webhook-token>"
}
]
},
"includeOtherFields": true
},
"typeVersion": 3.4
},
{
"id": "6547ee8a-432d-48d0-a577-46e2dcbe5db9",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
368,
288
],
"parameters": {
"color": 6,
"width": 400,
"height": 384,
"content": "## Message analysis"
},
"typeVersion": 1
},
{
"id": "ed8db8c2-ddab-4b23-ad59-4ff7bd24a607",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
368,
704
],
"parameters": {
"color": 4,
"width": 688,
"height": 352,
"content": "\n\n\n\n\n\n\n\n\n\n\n\n## MCP servers\nAdd correct URLs for remote MCPs\nUse following mcp:\n* [mattermost-mcp](https://github.com/cloud-ru-tech/mcp-server-mattermost)"
},
"typeVersion": 1
},
{
"id": "e76a6d42-0d5b-4805-90ba-d5a769c7d757",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
1264,
288
],
"parameters": {
"width": 352,
"height": 768,
"content": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n## Output chain\n```\n[\n {\n \"message\": ,\n \"post_id\": ,\n \"channel_id\": ,\n \"channel_name\": ,\n \"user_name\": ,\n \"user_id\": ,\n \"file_ids\":,\n \"category\": ,\n \"confidence\": ,\n \"summary\": ,\n \"acknowledge\": ,\n \"is_thread\":,\n \"parent_post\": ,\n \"thread_root_id\": ,\n \"on_call_user\": \n }\n]\n```"
},
"typeVersion": 1
},
{
"id": "380f2679-4073-4e78-aa6a-34f8bf35520d",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
-208,
704
],
"parameters": {
"color": 5,
"width": 544,
"height": 992,
"content": "## Overview\nReceives messages to technical support and classifies them.\n\n## Requirements\n1. OpenRouter/OpenAI/Anthropic API key\n1. Google Gemini API key \u2014 for embeddings (models/gemini-embedding-2-preview) used with Qdrant\n1. Mattermost outgoing webhook \u2014 configured on the Mattermost side, sends POST to the GetQuestions webhook node; the token from the webhook is compared against CHAT_TOKEN\n1. Mattermost MCP \n1. Qdrant \n1. Google Calendar OAuth credentials \n1. Webhook token \u2014 a static CHAT_TOKEN value in the SetVars node, must match the Token configured in the Mattermost outgoing webhook\n\n## How it works\n1. Message reception. Mattermost sends a POST with fields token, text, post_id, channel_id, user_name, file_ids, etc. to the GetQuestions webhook when @your-oncall-bot is mentioned in a subscribed channel. The message is validated using the token and the required fields are extracted.\n1. Classification via AI Agent. The agent analyzes the request and determines which category to assign it to.\n1. Parsing the agent's response. P\n1. Resolving the on-call engineer. GetDutyEvent fetches events from the DevOps Duty Google Calendar \n1. Routing by category. Switch (v3.4) dispatches the payload across 7 branches (modify_infrastructure, incident, question, ci_cd_error, new_system, announcement, other) \u2014 all branches are intentionally empty in the template, so users can plug in their own sub-workflows.\n\n## How to use\n1. Import the workflow into n8n, verify compatibility with v2.18.2.\n1. Connect credentials:\n * OpenRouter (Chat Model)\n * Google Gemini (Embeddings)\n * Google Calendar OAuth (GetDutyEvent)\n * Qdrant API (Vector Store) \u2014 set your own URL and API key\n3. Replace the calendar ID in GetDutyEvent or disable it\n4. Replace the Mattermost MCP URL in the Mattermost node with your endpoint\n5. Create a Qdrant collection\n6. Generate a webhook token\n7. Configure the Mattermost outgoing webhook\n8. Optionally configure an Error Workflow"
},
"typeVersion": 1
}
],
"active": false,
"settings": {
"binaryMode": "separate",
"callerPolicy": "workflowsFromSameOwner",
"errorWorkflow": "",
"timeSavedMode": "fixed",
"availableInMCP": true,
"executionOrder": "v1"
},
"versionId": "e20eb71e-1f06-4cda-86cc-13b04fd29065",
"connections": {
"Switch": {
"main": [
[],
[],
[],
[],
[],
[],
[]
]
},
"SetVars": {
"main": [
[
{
"node": "ValidateAndExtractMessage",
"type": "main",
"index": 0
}
]
]
},
"AI Agent": {
"main": [
[
{
"node": "Parse AI Response",
"type": "main",
"index": 0
}
]
]
},
"Mattermost": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"GetDutyEvent": {
"main": [
[
{
"node": "Inject duty name",
"type": "main",
"index": 0
}
]
]
},
"GetQuestions": {
"main": [
[
{
"node": "SetVars",
"type": "main",
"index": 0
}
]
]
},
"Inject duty name": {
"main": [
[
{
"node": "Switch",
"type": "main",
"index": 0
}
]
]
},
"Parse AI Response": {
"main": [
[
{
"node": "GetDutyEvent",
"type": "main",
"index": 0
}
]
]
},
"Qdrant Vector Store": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"OpenRouter Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Embeddings Google Gemini": {
"ai_embedding": [
[
{
"node": "Qdrant Vector Store",
"type": "ai_embedding",
"index": 0
}
]
]
},
"ValidateAndExtractMessage": {
"main": [
[
{
"node": "AI Agent",
"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.
googleCalendarOAuth2ApigooglePalmApiopenRouterApiqdrantApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Automatically classify and route DevOps requests from your team chat using LLM + on-call calendar lookup.
Source: https://n8n.io/workflows/15051/ — 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.
This workflow helps automatically analyze alerts occurring in the infrastructure and suggest solutions even before the on-duty engineer sees the alert. Workflow receives alert from Alertmanager via We
AI-powered sub-workflow that answers questions about a your infrastructure configuration directly in a Mattermost channel or thread OpenRouter/OpenAI/Anthropic API key Google Gemini API key — for embe
AI-powered SRE sub-workflow that investigates user-reported incidents coming from a Mattermost channel and posts a structured diagnostic report back into the same thread. The result is a four-section
This is a sub-workflow that converts a free-form DevOps request posted in Mattermost into a properly formatted Jira task OpenRouter/OpenAI/Anthropic API key Google Gemini API key — for embeddings Jira
This workflow helps automatically analyze alerts occurring in the infrastructure and suggest solutions even before the on-duty engineer sees the alert.