{
  "name": "Answer LINE inquiries with Claude and learn from staff replies in Slack (personal data masked)",
  "nodes": [
    {
      "id": "6049302c-3dfb-444f-94f1-f03e9fc1ebb8",
      "name": "Slack message",
      "type": "n8n-nodes-base.slackTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {
        "trigger": [
          "message"
        ],
        "watchWorkspace": false,
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "options": {}
      }
    },
    {
      "id": "af1076ca-e8ac-495c-b7d8-4bcbf1a53831",
      "name": "Staff thread replies only",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        440,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.thread_ts }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            },
            {
              "leftValue": "={{ $json.thread_ts }}",
              "rightValue": "={{ $json.ts }}",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            },
            {
              "leftValue": "={{ $json.bot_id ?? \"\" }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "empty",
                "singleValue": true
              }
            },
            {
              "leftValue": "={{ ($json.text ?? \"\").trim() }}",
              "rightValue": "//",
              "operator": {
                "type": "string",
                "operation": "notStartsWith"
              }
            },
            {
              "leftValue": "={{ [\"\", \"thread_broadcast\"].includes($json.subtype ?? \"\") }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "d80931bc-6516-4b8c-a620-6d5096c233b4",
      "name": "Learning settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        0
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "k1",
              "name": "keep_contacts",
              "value": "03-0000-0000, support@example.com",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "6ea76333-8354-493e-aab5-cb435307a5bd",
      "name": "Find customer",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        660,
        0
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "slack_ts",
              "condition": "eq",
              "keyValue": "={{ $json.thread_ts }}"
            }
          ]
        },
        "limit": 1
      }
    },
    {
      "id": "46b8e2a2-5e22-42ee-b7c9-b8ea470564e8",
      "name": "Prepare reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "jsCode": "// One place that turns the Slack reply into (1) the text sent to LINE and (2) the learn decision.\nconst row = $input.first().json;\nconst msg = $('Slack message').first().json;\nconst text = String(msg.text ?? '');\n// Learning is opt-in: only a reply that contains #learn is learned. The tag itself is never sent to the customer.\nconst TAG = /(^|\\s)#learn\\b/gi;\nconst learn_requested = /(^|\\s)#learn\\b/i.test(text);\nconst send_text = text.replace(TAG, '').trim();\nif (!send_text) return [];\n// The customer may have written several messages since the last staff reply. Keep them all: the AI later picks the one this reply answers.\nlet inquiries = [];\ntry { inquiries = JSON.parse(row.pending_inquiries || '[]'); } catch (e) { inquiries = []; }\nif (!inquiries.length && row.last_inquiry) inquiries = [row.last_inquiry];\nreturn [{ json: {\n  line_user_id: row.line_user_id,\n  customer_name: row.customer_name ?? '',\n  inquiries,\n  send_text,\n  learn_requested,\n  thread_ts: msg.thread_ts,\n  reply_ts: msg.ts,\n  staff: msg.user ?? ''\n} }];"
      }
    },
    {
      "id": "c3781ddc-fe1d-4f8c-bfc1-71b53e6d8678",
      "name": "Send to LINE",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.line.me/v2/bot/message/push",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ to: $json.line_user_id, messages: [{ type: \"text\", text: $json.send_text }] }) }}",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "94c1835b-5d30-4930-9781-91faf46ca9fc",
      "name": "Sent?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1320,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.error ? \"ng\" : \"ok\" }}",
              "rightValue": "ok",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "31ff7741-ddb0-4640-8a13-ef7d9f750fc2",
      "name": "Mark as sent",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1540,
        -100
      ],
      "parameters": {
        "resource": "reaction",
        "operation": "add",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "timestamp": "={{ $(\"Prepare reply\").first().json.reply_ts }}",
        "name": "white_check_mark"
      }
    },
    {
      "id": "a4088f4f-7225-48d8-a7cb-946be2c39e8a",
      "name": "Mark thread replied",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1760,
        -100
      ],
      "parameters": {
        "resource": "row",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "slack_ts",
              "condition": "eq",
              "keyValue": "={{ $(\"Prepare reply\").first().json.thread_ts }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "status": "replied",
            "pending_inquiries": "[]"
          },
          "schema": [
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "pending_inquiries",
              "displayName": "pending_inquiries",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        }
      }
    },
    {
      "id": "8c7af4d7-3085-4b63-97e0-224b40dde8b7",
      "name": "Report failure",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1540,
        120
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ \"⚠ This reply could not be sent to LINE: \" + JSON.stringify($json.error?.message ?? $json.error) }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $(\"Prepare reply\").first().json.thread_ts }}"
            }
          }
        }
      }
    },
    {
      "id": "1dac301e-e99a-4ead-996c-086e8619e47f",
      "name": "Learn this reply?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2010,
        -100
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "l1",
              "leftValue": "={{ $(\"Prepare reply\").first().json.learn_requested }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "848ee231-fc94-45df-95aa-b0c716112ce6",
      "name": "Mask with regex",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2318,
        -200
      ],
      "parameters": {
        "jsCode": "function maskPII(text, known) {\n  let s = String(text ?? '');\n  const hits = {};\n  const rep = (re, label) => { s = s.replace(re, () => { hits[label] = (hits[label] || 0) + 1; return '[' + label + ']'; }); };\n  for (const [v, label] of known) { if (v && v.length >= 2 && s.includes(v)) { hits[label] = (hits[label] || 0) + 1; s = s.split(v).join('[' + label + ']'); } }\n  rep(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g, 'EMAIL');\n  rep(/\\b\\d(?:[ -]?\\d){12,15}\\b/g, 'CARD');\n  rep(/(?<!\\d)\\(?\\d{3}\\)?[-.\\s]\\d{3}[-.\\s]\\d{4}(?!\\d)/g, 'PHONE');\n  rep(/\\+\\d{1,3}[-\\s]?\\(?\\d{1,4}\\)?[-\\s]?\\d{2,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<![\\d-])〒?\\s?\\d{3}-\\d{4}(?![\\d-])/g, 'POSTAL');\n  rep(/(?:\\+81[-\\s]?|\\b0)\\d{1,4}[-\\s]?\\d{1,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<=(?:注文|受付|会員|契約|お客様|order|account|member|contract|customer|reservation)\\s?(?:番号|No\\.?|ID|number|#)(?:\\s?(?:is|was|are|は|が))?[:：\\s#]*)[A-Za-z0-9-]{5,}/gi, 'ID');\n  return { text: s, hits };\n}\n// The shop's own phone numbers and emails (Learning settings > keep_contacts) are not personal data of a customer.\n// They are hidden from the masking while it runs and put back afterwards. Matching is exact text, ignoring case.\nfunction maskKeep(text, known, keep) {\n  let s = String(text ?? '');\n  const slots = [];\n  for (const v of keep) {\n    const re = new RegExp(v.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi');\n    s = s.replace(re, (m) => { slots.push(m); return '\\u0001' + (slots.length - 1) + '\\u0001'; });\n  }\n  const r = maskPII(s, known);\n  r.text = r.text.replace(/\\u0001(\\d+)\\u0001/g, (_, i) => slots[Number(i)]);\n  return r;\n}\n\nconst base = $('Prepare reply').first().json;\nconst keep = String($('Learning settings').first().json.keep_contacts || '').split(/[,\\n]/).map(x => x.trim()).filter(Boolean);\nconst name = String(base.customer_name || '').trim();\nconst known = [[name, 'NAME'], ...name.split(/[\\s　]+/).map(p => [p, 'NAME'])];\nconst cands = base.inquiries.map(t => maskKeep(t, known, keep));\nconst a = maskKeep(base.send_text, known, keep);\nconst case_id = base.thread_ts + '-' + base.reply_ts;\nreturn [{ json: {\n  ...base,\n  case_id,\n  cands_rx: cands.map(c => c.text),\n  question_rx: cands.map((c, i) => (i + 1) + '. ' + c.text).join('\\n'),\n  answer_rx: a.text,\n  keep_text: keep.length ? keep.join(', ') : '(none)',\n  keep_list: keep,\n  pre_reject: cands.length ? '' : 'No inquiry text is saved for this thread',\n  pii_hits: { question: cands.map(c => c.hits), answer: a.hits }\n} }];"
      }
    },
    {
      "id": "3d29da80-a7e4-40c3-9a70-9d6aeac9377c",
      "name": "AI: Mask remaining personal data",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        2626,
        -200
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "messages": {
          "values": [
            {
              "content": "=Replace personally identifiable information in the customer support conversation below with placeholders.\n\nThe customer sent the numbered message(s) under \"# Customer messages\". A staff member then wrote the answer under \"# A\". Decide which ONE customer message the staff answer replies to.\n\n# Replace\n- Personal names -> [NAME]\n- Addresses or place names that identify a private home -> [ADDRESS]\n- Phone numbers -> [PHONE], email addresses -> [EMAIL]\n- Birth dates -> [BIRTHDAY]\n- Bank account or card numbers -> [ACCOUNT]\n- Order, member or contract numbers and other IDs tied to a person -> [ID]\n\n# Rules\n- question_index: the number of the customer message that the answer clearly replies to. Use 0 if the answer does not clearly reply to any of them. Do not guess.\n- question: that one message only, after replacement, without its number (empty string if question_index is 0).\n- answer: the staff answer after replacement.\n- These values belong to the shop itself, not to the customer. Keep them exactly as written, do not replace them: {{ $json.keep_text }}\n- Keep existing placeholders such as [NAME] or [EMAIL] as they are.\n- Keep general information such as product names, service names, steps, prices and numbers of days exactly as written. Never summarize, paraphrase or add anything.\n- Keep the original language of the text.\n- Output JSON only (no explanation, no code block).\n\n# Output schema\n{\"question_index\": number, \"question\": \"message after replacement\", \"answer\": \"A after replacement\"}\n\n# Customer messages\n{{ $json.question_rx }}\n\n# A\n{{ $json.answer_rx }}",
              "role": "user"
            }
          ]
        },
        "simplify": true,
        "options": {
          "maxTokens": 3000,
          "temperature": 0
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "77070cc2-91fe-4c64-9775-1baf28c51efd",
      "name": "Check the masking result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2934,
        -200
      ],
      "parameters": {
        "jsCode": "function maskPII(text, known) {\n  let s = String(text ?? '');\n  const hits = {};\n  const rep = (re, label) => { s = s.replace(re, () => { hits[label] = (hits[label] || 0) + 1; return '[' + label + ']'; }); };\n  for (const [v, label] of known) { if (v && v.length >= 2 && s.includes(v)) { hits[label] = (hits[label] || 0) + 1; s = s.split(v).join('[' + label + ']'); } }\n  rep(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g, 'EMAIL');\n  rep(/\\b\\d(?:[ -]?\\d){12,15}\\b/g, 'CARD');\n  rep(/(?<!\\d)\\(?\\d{3}\\)?[-.\\s]\\d{3}[-.\\s]\\d{4}(?!\\d)/g, 'PHONE');\n  rep(/\\+\\d{1,3}[-\\s]?\\(?\\d{1,4}\\)?[-\\s]?\\d{2,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<![\\d-])〒?\\s?\\d{3}-\\d{4}(?![\\d-])/g, 'POSTAL');\n  rep(/(?:\\+81[-\\s]?|\\b0)\\d{1,4}[-\\s]?\\d{1,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<=(?:注文|受付|会員|契約|お客様|order|account|member|contract|customer|reservation)\\s?(?:番号|No\\.?|ID|number|#)(?:\\s?(?:is|was|are|は|が))?[:：\\s#]*)[A-Za-z0-9-]{5,}/gi, 'ID');\n  return { text: s, hits };\n}\n// The shop's own phone numbers and emails (Learning settings > keep_contacts) are not personal data of a customer.\n// They are hidden from the masking while it runs and put back afterwards. Matching is exact text, ignoring case.\nfunction maskKeep(text, known, keep) {\n  let s = String(text ?? '');\n  const slots = [];\n  for (const v of keep) {\n    const re = new RegExp(v.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'gi');\n    s = s.replace(re, (m) => { slots.push(m); return '\\u0001' + (slots.length - 1) + '\\u0001'; });\n  }\n  const r = maskPII(s, known);\n  r.text = r.text.replace(/\\u0001(\\d+)\\u0001/g, (_, i) => slots[Number(i)]);\n  return r;\n}\n\nconst base = $('Mask with regex').first().json;\nlet q = '', a = '', idx = 0, reason = base.pre_reject || '';\nif (!reason) {\n  try {\n    const rawText = $json.content?.[0]?.text ?? $json.text;\n    // Claude has no JSON mode, so it may wrap the JSON in a code fence. Strip it before parsing.\n    const t = String(rawText ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n    const o = JSON.parse(t);\n    idx = Number(o.question_index);\n    q = String(o.question ?? '');\n    a = String(o.answer ?? '');\n  } catch (e) { reason = 'The AI output could not be read as JSON'; }\n}\nif (!reason) {\n  if (!Number.isInteger(idx) || idx < 1 || idx > base.cands_rx.length) reason = 'The reply does not clearly answer one of the saved customer messages';\n  else if (!q.trim() || !a.trim()) reason = 'The AI output is empty';\n  else if (q.length < base.cands_rx[idx - 1].length * 0.5 || a.length < base.answer_rx.length * 0.5) reason = 'The AI output is much shorter than the input (possible summary or loss)';\n  else {\n    const r = maskKeep(q + '\\n' + a, [], base.keep_list);\n    const left = Object.keys(r.hits);\n    if (left.length) reason = 'Personal data patterns remain after masking: ' + left.join(',');\n  }\n}\nreturn [{ json: { ...base, learn_ok: !reason, mask_note: reason, question_masked: q, answer_masked: a } }];"
      }
    },
    {
      "id": "a1ad22a5-f583-4d41-857f-a778db32725e",
      "name": "OK to learn?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3242,
        -200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "l2",
              "leftValue": "={{ $json.learn_ok }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "58be876a-07be-4f44-963c-ea3bbbbc3845",
      "name": "Learn: add to vector store",
      "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
      "typeVersion": 1.3,
      "position": [
        3550,
        -300
      ],
      "parameters": {
        "mode": "insert",
        "memoryKey": {
          "__rl": true,
          "mode": "id",
          "value": "cs_rag_kb"
        }
      }
    },
    {
      "id": "00ea801b-49d7-40a3-9756-13ef094f983a",
      "name": "Log: learned",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        3858,
        -300
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "case_id": "={{ $('Check the masking result').first().json.case_id }}",
            "question_masked": "={{ $('Check the masking result').first().json.question_masked }}",
            "answer_masked": "={{ $('Check the masking result').first().json.answer_masked }}",
            "status": "learned",
            "learned_at": "={{ $now.toISO() }}",
            "staff": "={{ $('Check the masking result').first().json.staff }}",
            "note": ""
          },
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      }
    },
    {
      "id": "e0f8d6ee-75ea-4528-b352-9707193b1c62",
      "name": "Note in Slack: learned",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        4166,
        -300
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ \"📚 Learned this reply for future inquiries (personal data masked).\\n*Q:* \" + $(\"Check the masking result\").first().json.question_masked + \"\\n*A:* \" + $(\"Check the masking result\").first().json.answer_masked }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $(\"Prepare reply\").first().json.thread_ts }}"
            }
          }
        }
      }
    },
    {
      "id": "31f27672-a669-403c-9ea7-8e09aff4aefb",
      "name": "Log: not learned",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        3550,
        -100
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "case_id": "={{ $json.case_id }}",
            "status": "not_learned",
            "learned_at": "={{ $now.toISO() }}",
            "staff": "={{ $json.staff }}",
            "note": "={{ $json.mask_note }}",
            "question_masked": "",
            "answer_masked": ""
          },
          "matchingColumns": [],
          "schema": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      }
    },
    {
      "id": "0370d4a6-aae8-4636-abe3-2fa53a8e03fe",
      "name": "Note in Slack: not learned",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        3858,
        -100
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ \"ℹ️ The reply was sent to LINE, but it was not learned: \" + $(\"Check the masking result\").first().json.mask_note }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $(\"Prepare reply\").first().json.thread_ts }}"
            }
          }
        }
      }
    },
    {
      "id": "3ec4600d-b021-4111-9bbd-385d5a54b3aa",
      "name": "Embeddings Google Gemini",
      "type": "@n8n/n8n-nodes-langchain.embeddingsGoogleGemini",
      "typeVersion": 1,
      "position": [
        3550,
        100
      ],
      "parameters": {
        "modelName": "models/gemini-embedding-001"
      }
    },
    {
      "id": "4de39f7d-4066-462f-b874-14cf80188c0d",
      "name": "Default Data Loader",
      "type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader",
      "typeVersion": 1.1,
      "position": [
        3858,
        100
      ],
      "parameters": {
        "jsonMode": "expressionData",
        "jsonData": "={{ $json.question_masked }}",
        "options": {
          "metadata": {
            "metadataValues": [
              {
                "name": "answer_masked",
                "value": "={{ $json.answer_masked }}"
              },
              {
                "name": "case_id",
                "value": "={{ $json.case_id }}"
              }
            ]
          }
        }
      }
    },
    {
      "id": "d468036e-813d-482a-b298-018698831954",
      "name": "Receive LINE webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2.1,
      "position": [
        0,
        -1000
      ],
      "parameters": {
        "httpMethod": "POST",
        "path": "learning-desk",
        "responseMode": "onReceived",
        "options": {
          "rawBody": true
        }
      }
    },
    {
      "id": "97757c47-fe15-49a0-8d06-39b9e3294c9b",
      "name": "Compute signature",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 2,
      "position": [
        220,
        -1000
      ],
      "parameters": {
        "action": "hmac",
        "binaryData": true,
        "binaryPropertyName": "data",
        "type": "SHA256",
        "encoding": "base64",
        "dataPropertyName": "computedSignature"
      }
    },
    {
      "id": "da941747-254a-4f1e-a819-329356f2fe7c",
      "name": "Signature valid?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        440,
        -1000
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.computedSignature }}",
              "rightValue": "={{ $json.headers['x-line-signature'] }}",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "1c71603d-9612-44f7-a948-b46370fd3b38",
      "name": "Drop invalid request",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        660,
        -880
      ],
      "parameters": {}
    },
    {
      "id": "0b2e1581-1630-4c2b-af2b-3c33fda2f54f",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        660,
        -1200
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "auto_reply",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "c2",
              "name": "similarity_threshold",
              "value": 0.75,
              "type": "number"
            },
            {
              "id": "c3",
              "name": "acknowledgment",
              "value": "Thank you for contacting us. A staff member will review your message and reply in this chat.",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "c680c50e-0c50-44d3-ba1a-8ff898f076f7",
      "name": "Split events",
      "type": "n8n-nodes-base.splitOut",
      "typeVersion": 1,
      "position": [
        880,
        -1200
      ],
      "parameters": {
        "fieldToSplitOut": "body.events"
      }
    },
    {
      "id": "c6ebaf33-6032-4e9c-b31b-1a3888e18e71",
      "name": "Skip redelivered events",
      "type": "n8n-nodes-base.removeDuplicates",
      "typeVersion": 2,
      "position": [
        1100,
        -1200
      ],
      "parameters": {
        "operation": "removeItemsSeenInPreviousExecutions",
        "logic": "removeItemsWithAlreadySeenKeyValues",
        "dedupeValue": "={{ $json.webhookEventId }}"
      }
    },
    {
      "id": "f3c169ac-50fe-4106-87ac-6547c3e46bbe",
      "name": "Extract message fields",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1320,
        -1200
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "u",
              "name": "userId",
              "value": "={{ $json.source.userId }}",
              "type": "string"
            },
            {
              "id": "r",
              "name": "replyToken",
              "value": "={{ $json.replyToken }}",
              "type": "string"
            },
            {
              "id": "t",
              "name": "messageType",
              "value": "={{ $json.type === 'message' ? $json.message.type : $json.type }}",
              "type": "string"
            },
            {
              "id": "m",
              "name": "messageId",
              "value": "={{ $json.message?.id ?? \"\" }}",
              "type": "string"
            },
            {
              "id": "x",
              "name": "text",
              "value": "={{ $json.message?.text ?? \"\" }}",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "5e127a59-ca6a-4a72-a84a-3a3c16063c7d",
      "name": "Text messages only",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        1540,
        -1200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.messageType }}",
              "rightValue": "text",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "5e5152ac-47bd-48c0-919f-f7f63c1cdf0d",
      "name": "Process one event at a time",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        1760,
        -1200
      ],
      "parameters": {
        "batchSize": 1,
        "options": {}
      }
    },
    {
      "id": "62825237-8fc5-4d4e-887a-0d7e381be7fe",
      "name": "Mask inquiry",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2010,
        -1200
      ],
      "parameters": {
        "jsCode": "function maskPII(text, known) {\n  let s = String(text ?? '');\n  const hits = {};\n  const rep = (re, label) => { s = s.replace(re, () => { hits[label] = (hits[label] || 0) + 1; return '[' + label + ']'; }); };\n  for (const [v, label] of known) { if (v && v.length >= 2 && s.includes(v)) { hits[label] = (hits[label] || 0) + 1; s = s.split(v).join('[' + label + ']'); } }\n  rep(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g, 'EMAIL');\n  rep(/\\b\\d(?:[ -]?\\d){12,15}\\b/g, 'CARD');\n  rep(/(?<!\\d)\\(?\\d{3}\\)?[-.\\s]\\d{3}[-.\\s]\\d{4}(?!\\d)/g, 'PHONE');\n  rep(/\\+\\d{1,3}[-\\s]?\\(?\\d{1,4}\\)?[-\\s]?\\d{2,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<![\\d-])〒?\\s?\\d{3}-\\d{4}(?![\\d-])/g, 'POSTAL');\n  rep(/(?:\\+81[-\\s]?|\\b0)\\d{1,4}[-\\s]?\\d{1,4}[-\\s]?\\d{3,4}\\b/g, 'PHONE');\n  rep(/(?<=(?:注文|受付|会員|契約|お客様|order|account|member|contract|customer|reservation)\\s?(?:番号|No\\.?|ID|number|#)(?:\\s?(?:is|was|are|は|が))?[:：\\s#]*)[A-Za-z0-9-]{5,}/gi, 'ID');\n  return { text: s, hits };\n}\n\n// Regex masking only: the customer's name is not known yet. The raw text is kept for the Slack thread.\nconst e = $input.first().json;\nconst m = maskPII(e.text, []);\nreturn [{ json: { ...e, inquiry: String(e.text).trim(), question_masked: m.text } }];"
      }
    },
    {
      "id": "6e5c224e-5c5e-4055-871d-7f8881fc3197",
      "name": "Search similar Q&A",
      "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
      "typeVersion": 1.3,
      "position": [
        2318,
        -1200
      ],
      "parameters": {
        "mode": "load",
        "memoryKey": {
          "__rl": true,
          "mode": "id",
          "value": "cs_rag_kb"
        },
        "prompt": "={{ $json.question_masked }}",
        "topK": 3
      },
      "alwaysOutputData": true
    },
    {
      "id": "870d6929-5d08-49db-a63e-b298433d7b0b",
      "name": "Check similarity",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2626,
        -1200
      ],
      "parameters": {
        "jsCode": "const base = $('Mask inquiry').first().json;\nconst THRESHOLD = $('Config').first().json.similarity_threshold;\n// An empty store returns one empty item, so keep only items that carry a document\nconst hits = $input.all()\n  .map(i => i.json)\n  .filter(j => j.document)\n  .map(j => ({ score: j.score, q: j.document.pageContent, a: j.document.metadata?.answer_masked, case_id: j.document.metadata?.case_id }))\n  .sort((x, y) => y.score - x.score);\nconst top = hits[0];\nconst refs = hits.filter(h => h.score >= THRESHOLD && h.a).slice(0, 2);\nreturn [{ json: {\n  ...base,\n  top_score: top ? top.score : 0,\n  matched: refs.length > 0,\n  refs,\n  refs_text: refs.map((r, i) => 'Reference ' + (i + 1) + ' (similarity ' + r.score.toFixed(2) + ')\\nQ: ' + r.q + '\\nA: ' + r.a).join('\\n\\n')\n} }];"
      }
    },
    {
      "id": "c57074ea-53ae-4e82-9354-a481699108c7",
      "name": "Similar answer found?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2934,
        -1200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "a1",
              "leftValue": "={{ $json.matched }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "6636d3dd-7cf6-439b-84ed-48646b27d6f8",
      "name": "AI: Draft the reply",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        3242,
        -1300
      ],
      "parameters": {
        "resource": "text",
        "operation": "message",
        "modelId": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "messages": {
          "values": [
            {
              "content": "=You are a customer support agent. Using ONLY the reference Q&A below (answers that staff gave in the past), write the reply body for the new inquiry.\n\n# Rules\n- Do not add facts, prices, numbers of days or URLs that are not in the reference Q&A.\n- Include every fact from the reference Q&A that is needed to answer the question, such as amounts, times and conditions. Do not leave them out or replace them with vague wording.\n- If the reference Q&A does not answer the new inquiry, or the case needs individual checking, set can_answer to false.\n- Never include bracketed placeholders such as [NAME] or [EMAIL] in the output.\n- No greeting and no signature. Write only the body, politely, in the same language as the new inquiry.\n- Output JSON only (no explanation, no code block).\n\n# Output schema\n{\"can_answer\": true or false, \"reply\": \"reply body (empty string if can_answer is false)\"}\n\n# New inquiry\n{{ $json.question_masked }}\n\n# Reference Q&A\n{{ $json.refs_text }}",
              "role": "user"
            }
          ]
        },
        "simplify": true,
        "options": {
          "maxTokens": 1500,
          "temperature": 0
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "0994b2f8-9c39-4bd7-9c65-df65a6f4181a",
      "name": "Validate the draft",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3550,
        -1300
      ],
      "parameters": {
        "jsCode": "const base = $('Check similarity').first().json;\nlet can = false, reply = '';\ntry {\n  const rawText = $json.content?.[0]?.text ?? $json.text;\n  // Claude has no JSON mode, so it may wrap the JSON in a code fence. Strip it before parsing.\n  const t = String(rawText ?? '').replace(/^\\s*```(?:json)?\\s*/i, '').replace(/\\s*```\\s*$/, '').trim();\n  const o = JSON.parse(t);\n  can = o.can_answer === true;\n  reply = String(o.reply ?? '').trim();\n} catch (e) { can = false; }\n// Never use an empty draft or one that still contains a placeholder\nif (!reply || /\\[[A-Z_]+\\]/.test(reply)) can = false;\nconst footer = '\\n\\nThis is an automated reply based on past answers. If it does not solve your problem, just reply to this message and a staff member will help.';\nreturn [{ json: { ...base, can_answer: can, draft_reply: can ? reply : '', auto_text: can ? reply + footer : '' } }];"
      }
    },
    {
      "id": "b4749878-e404-4d43-9d63-dd06fc67756b",
      "name": "No similar answer",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3242,
        -1100
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": true,
        "assignments": {
          "assignments": [
            {
              "id": "n1",
              "name": "can_answer",
              "value": false,
              "type": "boolean"
            },
            {
              "id": "n2",
              "name": "draft_reply",
              "value": "",
              "type": "string"
            },
            {
              "id": "n3",
              "name": "auto_text",
              "value": "",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "cd6a85e2-9444-4680-a097-d1a2720fb0f4",
      "name": "Auto-reply on and answerable?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3800,
        -1200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "a2",
              "leftValue": "={{ $(\"Config\").first().json.auto_reply === true && $json.can_answer === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "4d0743ab-d48d-4dad-9549-5756fe97318e",
      "name": "Send AI reply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        4020,
        -1300
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.line.me/v2/bot/message/reply",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ replyToken: $(\"Mask inquiry\").first().json.replyToken, messages: [{ type: \"text\", text: $(\"Auto-reply on and answerable?\").first().json.auto_text }] }) }}",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "671a62f5-aeef-4397-b846-4ad28e95456a",
      "name": "Send acknowledgment",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        4020,
        -1100
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.line.me/v2/bot/message/reply",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "json",
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ replyToken: $(\"Mask inquiry\").first().json.replyToken, messages: [{ type: \"text\", text: $(\"Config\").first().json.acknowledgment }] }) }}",
        "options": {}
      },
      "onError": "continueRegularOutput"
    },
    {
      "id": "7467cb90-6afb-4b88-a556-6e5fd55d4329",
      "name": "Log: AI replied",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4240,
        -1300
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "k",
              "name": "status",
              "value": "={{ $json.error ? \"needs_reply\" : \"ai_replied\" }}",
              "type": "string"
            },
            {
              "id": "n",
              "name": "note",
              "value": "={{ $json.error ? \"⚠ The automatic reply could not be sent to LINE, so a staff reply is needed. Anything you write in this thread is sent to the customer.\\n\\nDraft that failed to send:\\n\" + $(\"Auto-reply on and answerable?\").first().json.draft_reply : \"🤖 The AI replied automatically (similarity \" + $(\"Auto-reply on and answerable?\").first().json.top_score.toFixed(2) + \"):\\n\" + $(\"Auto-reply on and answerable?\").first().json.draft_reply + \"\\n\\nIf this was wrong, reply in this thread: your reply is sent to the customer.\" }}",
              "type": "string"
            },
            {
              "id": "b",
              "name": "broadcast",
              "value": "={{ !!$json.error }}",
              "type": "boolean"
            }
          ]
        }
      }
    },
    {
      "id": "76781f87-ec37-4ba8-9a95-f49db1b5809f",
      "name": "Log: waiting for staff",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4240,
        -1100
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "k",
              "name": "status",
              "value": "needs_reply",
              "type": "string"
            },
            {
              "id": "n",
              "name": "note",
              "value": "={{ ($(\"Auto-reply on and answerable?\").first().json.can_answer ? \"💡 Suggested reply (NOT sent to the customer; similarity \" + $(\"Auto-reply on and answerable?\").first().json.top_score.toFixed(2) + \"):\\n\" + $(\"Auto-reply on and answerable?\").first().json.draft_reply + \"\\n\\nCopy, edit and reply in this thread to send it. Add #learn to your reply to teach the AI.\" : \"🙋 A staff reply is needed (no similar answer yet). Anything you write in this thread is sent to the customer. Add #learn to your reply to teach the AI.\") + ($json.error ? \"\\n⚠ The acknowledgment could not be sent to LINE either.\" : \"\") }}",
              "type": "string"
            },
            {
              "id": "b",
              "name": "broadcast",
              "value": true,
              "type": "boolean"
            }
          ]
        }
      }
    },
    {
      "id": "1f20c1cd-020b-46a9-b6a9-25680b4dc5c2",
      "name": "Post to Slack",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4490,
        -1200
      ],
      "parameters": {}
    },
    {
      "id": "26291c11-4964-46aa-bd22-5a532ec750dd",
      "name": "Find thread",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        4710,
        -1200
      ],
      "alwaysOutputData": true,
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "line_user_id",
              "condition": "eq",
              "keyValue": "={{ $(\"Mask inquiry\").first().json.userId }}"
            }
          ]
        },
        "limit": 1
      }
    },
    {
      "id": "c0acce53-e845-40f0-9895-e9a88e9fdf8b",
      "name": "Thread exists?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        4930,
        -1200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.slack_ts }}",
              "rightValue": "",
              "operator": {
                "type": "string",
                "operation": "notEmpty",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "6ebaf750-daa7-4ae9-bc06-fcefac8b6787",
      "name": "Reply in thread",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        5150,
        -1300
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ \"📩 \" + $(\"Mask inquiry\").first().json.inquiry + \"\\n\\n\" + $(\"Post to Slack\").first().json.note }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $json.slack_ts }}",
              "reply_broadcast": "={{ $(\"Post to Slack\").first().json.broadcast }}"
            }
          }
        }
      }
    },
    {
      "id": "3161a484-3831-43c8-8813-47c86d7ae03f",
      "name": "Update thread status",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        5370,
        -1300
      ],
      "parameters": {
        "resource": "row",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "line_user_id",
              "condition": "eq",
              "keyValue": "={{ $(\"Mask inquiry\").first().json.userId }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "status": "={{ $(\"Post to Slack\").first().json.status }}",
            "last_inquiry": "={{ $(\"Mask inquiry\").first().json.inquiry }}",
            "pending_inquiries": "={{ JSON.stringify((($(\"Find thread\").first().json.status === \"needs_reply\" ? JSON.parse($(\"Find thread\").first().json.pending_inquiries || \"[]\") : []).concat([$(\"Mask inquiry\").first().json.inquiry])).slice(-5)) }}"
          },
          "schema": [
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "last_inquiry",
              "displayName": "last_inquiry",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "pending_inquiries",
              "displayName": "pending_inquiries",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        }
      }
    },
    {
      "id": "3275fb8c-d32f-4c59-b29f-b42c510c326a",
      "name": "Get LINE display name",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        5150,
        -1100
      ],
      "onError": "continueRegularOutput",
      "parameters": {
        "method": "GET",
        "url": "=https://api.line.me/v2/bot/profile/{{ $(\"Mask inquiry\").first().json.userId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      }
    },
    {
      "id": "c82fe7b3-56ba-4904-8569-69c3087e2bde",
      "name": "Create new thread",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        5370,
        -1100
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "={{ \"LINE customer: \" + ($json.displayName || \"(name not available)\") + \"\\n\" + \"📩 \" + $(\"Mask inquiry\").first().json.inquiry + \"\\n\\n\" + $(\"Post to Slack\").first().json.note }}",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      }
    },
    {
      "id": "c335f883-5bae-4781-b785-0c4d01038886",
      "name": "Save thread",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        5590,
        -1100
      ],
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "line_user_id": "={{ $(\"Mask inquiry\").first().json.userId }}",
            "slack_ts": "={{ $json.message_timestamp }}",
            "status": "={{ $(\"Post to Slack\").first().json.status }}",
            "last_inquiry": "={{ $(\"Mask inquiry\").first().json.inquiry }}",
            "pending_inquiries": "={{ JSON.stringify([$(\"Mask inquiry\").first().json.inquiry]) }}",
            "customer_name": "={{ $(\"Get LINE display name\").first().json.displayName ?? \"\" }}"
          },
          "schema": [
            {
              "id": "line_user_id",
              "displayName": "line_user_id",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "slack_ts",
              "displayName": "slack_ts",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "status",
              "displayName": "status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "last_inquiry",
              "displayName": "last_inquiry",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "pending_inquiries",
              "displayName": "pending_inquiries",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "customer_name",
              "displayName": "customer_name",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        }
      }
    },
    {
      "id": "b19c79f1-c4ee-40e1-bf86-fd9aed0214aa",
      "name": "Every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.4,
      "position": [
        0,
        1500
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      }
    },
    {
      "id": "ecdb7614-6b0c-40c0-9260-71360f38b11d",
      "name": "Reminder settings",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        220,
        1500
      ],
      "parameters": {
        "mode": "manual",
        "includeOtherFields": false,
        "assignments": {
          "assignments": [
            {
              "id": "c1",
              "name": "wait_minutes",
              "value": 30,
              "type": "number"
            },
            {
              "id": "c2",
              "name": "max_reminders",
              "value": 3,
              "type": "number"
            },
            {
              "id": "c3",
              "name": "business_start_hour",
              "value": 9,
              "type": "number"
            },
            {
              "id": "c4",
              "name": "business_end_hour",
              "value": 19,
              "type": "number"
            },
            {
              "id": "c5",
              "name": "timezone",
              "value": "Asia/Tokyo",
              "type": "string"
            }
          ]
        }
      }
    },
    {
      "id": "492e8dcb-b4be-4137-a633-e14ff102767b",
      "name": "Within business hours?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        440,
        1500
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $now.setZone($json.timezone).hour >= $json.business_start_hour && $now.setZone($json.timezone).hour < $json.business_end_hour }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "34b92476-ae40-4be9-8a80-be26a92971df",
      "name": "Get threads waiting for a reply",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        660,
        1500
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "status",
              "condition": "eq",
              "keyValue": "needs_reply"
            }
          ]
        },
        "returnAll": true
      }
    },
    {
      "id": "03040f44-edf4-4ff3-9f62-ca0e29ddbaa7",
      "name": "Get reminder records",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        880,
        1500
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": []
        },
        "returnAll": true
      },
      "alwaysOutputData": true,
      "executeOnce": true
    },
    {
      "id": "f4210053-6643-4712-96f6-bf0c7f2a52e2",
      "name": "Work out the waiting times",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        1500
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "const threads = $('Get threads waiting for a reply').all().map(i => i.json);\nconst settings = $('Reminder settings').first().json;\n// An empty table returns one empty item, so keep only real records\nconst records = new Map($input.all().map(i => i.json).filter(r => r.slack_ts).map(r => [r.slack_ts, r]));\n\nreturn threads.map(thread => {\n  const record = records.get(thread.slack_ts) ?? {};\n  // The thread row's updatedAt is the time of the customer's latest message.\n  const lastActivity = DateTime.fromISO(thread.updatedAt);\n  const lastReminded = record.last_reminded_at ? DateTime.fromISO(record.last_reminded_at) : null;\n  // If the customer wrote again after our last reminder, start counting from zero.\n  const isNewWait = !lastReminded || lastReminded < lastActivity;\n  const remindersSent = isNewWait ? 0 : Number(record.reminder_count ?? 0);\n  const waitingSince = isNewWait ? lastActivity : lastReminded;\n  const minutesSinceLastNudge = Math.floor($now.diff(waitingSince, 'minutes').minutes);\n  const minutesWaiting = Math.floor($now.diff(lastActivity, 'minutes').minutes);\n  return { json: {\n    slack_ts: thread.slack_ts,\n    last_inquiry: thread.last_inquiry,\n    minutes_waiting: minutesWaiting,\n    reminder_number: remindersSent + 1,\n    max_reminders: settings.max_reminders,\n    due: minutesSinceLastNudge >= settings.wait_minutes && remindersSent < settings.max_reminders,\n  } };\n});"
      }
    },
    {
      "id": "019f9f2c-bb65-45cd-82a7-9842f257faf8",
      "name": "Only if a reminder is due",
      "type": "n8n-nodes-base.filter",
      "typeVersion": 2.3,
      "position": [
        1320,
        1500
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "leftValue": "={{ $json.due }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "0ff1cf95-34ec-4a22-8da1-0b73bbde84ba",
      "name": "Remind staff in the Slack thread",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.7,
      "position": [
        1540,
        1500
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "messageType": "text",
        "text": "=⏰ This customer has been waiting {{ $json.minutes_waiting }} min for a reply (reminder {{ $json.reminder_number }} of {{ $json.max_reminders }}).\nLast message: {{ $json.last_inquiry }}",
        "otherOptions": {
          "includeLinkToWorkflow": false,
          "thread_ts": {
            "replyValues": {
              "thread_ts": "={{ $json.slack_ts }}",
              "reply_broadcast": true
            }
          }
        }
      },
      "onError": "continueErrorOutput"
    },
    {
      "id": "c05eb305-18ca-4209-a55c-b7d10d327ac6",
      "name": "Save the reminder record",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        1760,
        1400
      ],
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "slack_ts",
              "condition": "eq",
              "keyValue": "={{ $('Only if a reminder is due').item.json.slack_ts }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "slack_ts": "={{ $('Only if a reminder is due').item.json.slack_ts }}",
            "reminder_count": "={{ $('Only if a reminder is due').item.json.reminder_number }}",
            "last_reminded_at": "={{ $now.toISO() }}"
          },
          "schema": [
            {
              "id": "slack_ts",
              "displayName": "slack_ts",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "reminder_count",
              "displayName": "reminder_count",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true
            },
            {
              "id": "last_reminded_at",
              "displayName": "last_reminded_at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            }
          ]
        },
        "options": {}
      }
    },
    {
      "id": "31e896de-46bf-4aee-bd52-484f3435c511",
      "name": "Slack failed (retry on the next run)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1760,
        1600
      ],
      "parameters": {}
    },
    {
      "id": "78843872-af8a-4757-9e99-2bd0c708673b",
      "name": "Rebuild trigger",
      "type": "n8n-nodes-base.n8nTrigger",
      "typeVersion": 1,
      "position": [
        0,
        700
      ],
      "parameters": {
        "events": [
          "init",
          "activate",
          "update"
        ]
      }
    },
    {
      "id": "0a31ed0c-7fc5-497b-a774-8e39d31d1b5d",
      "name": "Get learned Q&A",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "position": [
        220,
        700
      ],
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "list",
          "value": ""
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "status",
              "condition": "eq",
              "keyValue": "learned"
            }
          ]
        },
        "returnAll": true
      }
    },
    {
      "id": "bc11d13d-289c-45f2-bade-9fa3ccd7f4a0",
      "name": "Drop empty rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        700
      ],
      "parameters": {
        "jsCode": "return $input.all().filter(i => i.json.question_masked && i.json.answer_masked);"
      }
    },
    {
      "id": "8b7c360c-5bd7-4677-aafd-f4dc8d565178",
      "name": "Rebuild: add to vector store",
      "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
      "typeVersion": 1.3,
      "position": [
        660,
        700
      ],
      "parameters": {
        "mode": "insert",
        "memoryKey": {
          "__rl": true,
          "mode": "id",
          "value": "cs_rag_kb"
        },
        "clearStore": true
      }
    },
    {
      "parameters": {
        "content": "## 1. A LINE message arrives\nChecks the LINE signature, skips redelivered events and keeps text messages only. Each message is handled on its own, so several customers in one call are never mixed up.",
        "height": 610,
        "width": 1960,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -1320
      ],
      "id": "sticky-2-49c9f46f",
      "name": "Section 1"
    },
    {
      "parameters": {
        "content": "## 2. Look for a learned answer\nMasks personal data, then searches similar past Q&A. Claude drafts a reply from those answers only and says whether it can answer. No match or an unsure draft means staff.",
        "height": 490,
        "width": 1740,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1950,
        -1420
      ],
      "id": "sticky-3-271b37e4",
      "name": "Section 2"
    },
    {
      "parameters": {
        "content": "## 3. Suggest, answer or hand over\nBy default the customer gets an acknowledgment and staff see the draft. With auto_reply on, a safe draft is sent on LINE; if sending fails, staff are asked instead.",
        "height": 490,
        "width": 640,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3740,
        -1420
      ],
      "id": "sticky-4-b72d09b3",
      "name": "Section 3"
    },
    {
      "parameters": {
        "content": "## 4. One Slack thread per customer\nPosts the draft or the request for staff in the customer thread (new customers get a new thread) and saves every message the customer sent since the last reply.",
        "height": 490,
        "width": 1300,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        4430,
        -1420
      ],
      "id": "sticky-5-b49cc649",
      "name": "Section 4"
    },
    {
      "parameters": {
        "content": "## 5. Staff replies go to LINE\nOnly human replies in a customer thread are sent (messages starting with // are notes). #learn is removed first. A ✅ means it was sent; a failure is reported in the thread.",
        "height": 510,
        "width": 1960,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        -220
      ],
      "id": "sticky-6-358c186c",
      "name": "Section 5"
    },
    {
      "parameters": {
        "content": "## 6. Learn only #learn replies\nClaude picks which customer message the reply answers. The pair is masked by regex and by Claude, then checked for leftover patterns. Only passing Q&A are saved; otherwise the thread says why not.",
        "height": 690,
        "width": 2356,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1950,
        -420
      ],
      "id": "sticky-7-725213e6",
      "name": "Section 6"
    },
    {
      "parameters": {
        "content": "## 7. Rebuild the search index\nThe in-memory vector store is empty after a restart. This reloads the learned Q&A from the Data Table when the workflow is published or updated, when n8n starts, or when you run it by hand.",
        "height": 290,
        "width": 860,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        580
      ],
      "id": "sticky-8-f803ef7f",
      "name": "Section 7"
    },
    {
      "parameters": {
        "content": "## 8. Remind staff\nEvery 15 minutes in business hours, customers still waiting get a reminder in their thread, up to max_reminders times. If the customer writes again, the count starts over.",
        "height": 490,
        "width": 1960,
        "color": 7
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -60,
        1280
      ],
      "id": "sticky-9-662f6dd8",
      "name": "Section 8"
    },
    {
      "parameters": {
        "content": "## A LINE support desk that learns from your staff\nCustomers write on LINE, staff answer in Slack. Replies you tag with #learn are added, with personal data masked, to a knowledge base that suggests (or sends) answers to similar questions.\n\n### How it works\n1. A LINE message arrives. Similar learned Q&A are searched and Claude drafts a reply from them only.\n2. By default (auto_reply = false) the customer gets an acknowledgment and staff see the draft in their Slack thread. Turn auto_reply on to let the AI answer when it is sure.\n3. Staff reply in the thread; the reply goes to the customer on LINE. Add #learn to teach the AI. The tag is removed before sending.\n4. A reminder is posted when a customer waits too long.\n\n### Setup\n1. Create Data Tables: `line_slack_threads` (line_user_id, slack_ts, status, last_inquiry, customer_name, pending_inquiries), `learned_qa` (case_id, question_masked, answer_masked, status, learned_at, staff, note), `followup_reminders` (slack_ts, reminder_count, last_reminded_at). Select them in the Data table nodes.\n2. Credentials: Header Auth for LINE (`Authorization: Bearer <token>`), Crypto (LINE channel secret), Slack API, Anthropic, Google Gemini (embeddings).\n3. Select your Slack channel in every Slack node. Enter your shop phone numbers and emails in **Learning settings** so they are not masked.\n4. Publish, then set the LINE webhook to **Receive LINE webhook** and the Slack Event Subscriptions URL to **Slack message**.\n\n### Customization\nTune similarity_threshold in **Config**. Test with your own data first: masking is best-effort.",
        "height": 800,
        "width": 760,
        "color": 1
      },
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -130,
        -2360
      ],
      "id": "sticky-1-2a7ed968",
      "name": "Overview"
    }
  ],
  "connections": {
    "Slack message": {
      "main": [
        [
          {
            "node": "Learning settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Learning settings": {
      "main": [
        [
          {
            "node": "Staff thread replies only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Staff thread replies only": {
      "main": [
        [
          {
            "node": "Find customer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find customer": {
      "main": [
        [
          {
            "node": "Prepare reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare reply": {
      "main": [
        [
          {
            "node": "Send to LINE",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send to LINE": {
      "main": [
        [
          {
            "node": "Sent?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sent?": {
      "main": [
        [
          {
            "node": "Mark as sent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Report failure",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as sent": {
      "main": [
        [
          {
            "node": "Mark thread replied",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark thread replied": {
      "main": [
        [
          {
            "node": "Learn this reply?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Learn this reply?": {
      "main": [
        [
          {
            "node": "Mask with regex",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mask with regex": {
      "main": [
        [
          {
            "node": "AI: Mask remaining personal data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI: Mask remaining personal data": {
      "main": [
        [
          {
            "node": "Check the masking result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check the masking result": {
      "main": [
        [
          {
            "node": "OK to learn?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OK to learn?": {
      "main": [
        [
          {
            "node": "Learn: add to vector store",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log: not learned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Learn: add to vector store": {
      "main": [
        [
          {
            "node": "Log: learned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log: learned": {
      "main": [
        [
          {
            "node": "Note in Slack: learned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log: not learned": {
      "main": [
        [
          {
            "node": "Note in Slack: not learned",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Receive LINE webhook": {
      "main": [
        [
          {
            "node": "Compute signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute signature": {
      "main": [
        [
          {
            "node": "Signature valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature valid?": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Drop invalid request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "Split events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split events": {
      "main": [
        [
          {
            "node": "Skip redelivered events",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip redelivered events": {
      "main": [
        [
          {
            "node": "Extract message fields",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract message fields": {
      "main": [
        [
          {
            "node": "Text messages only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Text messages only": {
      "main": [
        [
          {
            "node": "Process one event at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process one event at a time": {
      "main": [
        [],
        [
          {
            "node": "Mask inquiry",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mask inquiry": {
      "main": [
        [
          {
            "node": "Search similar Q&A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Search similar Q&A": {
      "main": [
        [
          {
            "node": "Check similarity",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check similarity": {
      "main": [
        [
          {
            "node": "Similar answer found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Similar answer found?": {
      "main": [
        [
          {
            "node": "AI: Draft the reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No similar answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI: Draft the reply": {
      "main": [
        [
          {
            "node": "Validate the draft",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate the draft": {
      "main": [
        [
          {
            "node": "Auto-reply on and answerable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "No similar answer": {
      "main": [
        [
          {
            "node": "Auto-reply on and answerable?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Auto-reply on and answerable?": {
      "main": [
        [
          {
            "node": "Send AI reply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Send acknowledgment",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send AI reply": {
      "main": [
        [
          {
            "node": "Log: AI replied",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send acknowledgment": {
      "main": [
        [
          {
            "node": "Log: waiting for staff",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log: AI replied": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log: waiting for staff": {
      "main": [
        [
          {
            "node": "Post to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post to Slack": {
      "main": [
        [
          {
            "node": "Find thread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find thread": {
      "main": [
        [
          {
            "node": "Thread exists?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Thread exists?": {
      "main": [
        [
          {
            "node": "Reply in thread",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Get LINE display name",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply in thread": {
      "main": [
        [
          {
            "node": "Update thread status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get LINE display name": {
      "main": [
        [
          {
            "node": "Create new thread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create new thread": {
      "main": [
        [
          {
            "node": "Save thread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Update thread status": {
      "main": [
        [
          {
            "node": "Process one event at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save thread": {
      "main": [
        [
          {
            "node": "Process one event at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Embeddings Google Gemini": {
      "ai_embedding": [
        [
          {
            "node": "Search similar Q&A",
            "type": "ai_embedding",
            "index": 0
          },
          {
            "node": "Learn: add to vector store",
            "type": "ai_embedding",
            "index": 0
          },
          {
            "node": "Rebuild: add to vector store",
            "type": "ai_embedding",
            "index": 0
          }
        ]
      ]
    },
    "Every 15 minutes": {
      "main": [
        [
          {
            "node": "Reminder settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reminder settings": {
      "main": [
        [
          {
            "node": "Within business hours?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Within business hours?": {
      "main": [
        [
          {
            "node": "Get threads waiting for a reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get threads waiting for a reply": {
      "main": [
        [
          {
            "node": "Get reminder records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get reminder records": {
      "main": [
        [
          {
            "node": "Work out the waiting times",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Work out the waiting times": {
      "main": [
        [
          {
            "node": "Only if a reminder is due",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only if a reminder is due": {
      "main": [
        [
          {
            "node": "Remind staff in the Slack thread",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Remind staff in the Slack thread": {
      "main": [
        [
          {
            "node": "Save the reminder record",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack failed (retry on the next run)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Rebuild trigger": {
      "main": [
        [
          {
            "node": "Get learned Q&A",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get learned Q&A": {
      "main": [
        [
          {
            "node": "Drop empty rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Drop empty rows": {
      "main": [
        [
          {
            "node": "Rebuild: add to vector store",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Default Data Loader": {
      "ai_document": [
        [
          {
            "node": "Learn: add to vector store",
            "type": "ai_document",
            "index": 0
          },
          {
            "node": "Rebuild: add to vector store",
            "type": "ai_document",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}
