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 →
{
"name": "Tourist-Bot Repeat-Visitor (Multi-Provider)",
"nodes": [
{
"parameters": {
"content": "## AI Tourist-Bot Repeat-Visitor Bot\n\n**Stack:** Web Chat (or WhatsApp / web chat) \u2192 StudioMeyer Memory entity lookup \u2192 Claude or OpenAI with visitor dossier \u2192 reply \u2192 persist outcome.\n\n**Why this beats a stateless bot:** Returning visitors don't have to re-explain who they are. The bot greets them by name, references past sessions, and the agent that takes over has full history one click away.\n\n**To swap Web Chat for WhatsApp:** replace the trigger node with a WhatsApp Trigger and the reply node with WhatsApp Send Message. The middle stays identical.\n\n**Production patterns ship in this workflow.json** as opt-in Code nodes. See the orange Sticky Notes below for the four env vars that toggle them on.",
"height": 380,
"width": 480,
"color": 6
},
"id": "note-intro",
"name": "Sticky Note - Intro",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-260,
-60
]
},
{
"parameters": {
"content": ">> SET ME <<\n\n**Web Chat Bot credential** required.\n\n1. Talk to your web-chat widget vendor, create a bot, get the token.\n2. n8n Credentials \u2192 New \u2192 Web Chat \u2192 paste token.\n3. After activation, message your bot once and check this node's incoming-data view.\n\n**Webhook security:** to harden the trigger, expand `additionalFields` and set `secretToken` to a strong random string. Then re-register the webhook so Web Chat sends `X-Web Chat-Bot-Api-Secret-Token` on every request. The trigger validates it automatically.\n\nIMPORTANT (self-hosted n8n): set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` env var, otherwise the Verify Webhook + Extract Session Key code nodes throw at runtime when calling require('crypto').",
"height": 320,
"width": 320,
"color": 5
},
"id": "note-telegram",
"name": "Sticky Note - Telegram",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
240,
-60
]
},
{
"parameters": {
"content": "## Production patterns (opt-in)\n\nThree Code nodes below are off by default. Toggle each with an n8n env var:\n\n- `IDEMPOTENCY_ENABLED=1` deduplicates Web Chat retries on the same `sessionKey + minute-bucket` (5-min window).\n- `RATE_LIMIT_ENABLED=1` caps each chat at 60 requests / 5 min.\n- `WEBHOOK_INTEGRITY_CHECK_ENABLED=1` rejects malformed payloads (no `message`, no `from`).\n\nEach node returns pass-through when its env var is unset, so the default import boots clean. Production deployments enable all three plus the Web Chat Trigger `secretToken`.\n\nFor clustered n8n deployments, swap the in-memory `$getWorkflowStaticData` blocks for Redis (`SET NX EX 300` for idempotency, `INCR + EXPIRE` for rate limit). Single-instance n8n is fine with the default.\n\nIMPORTANT (self-hosted n8n): set `NODE_FUNCTION_ALLOW_BUILTIN=crypto` env var, otherwise the Verify Webhook + Extract Session Key code nodes throw at runtime when calling require('crypto').",
"height": 360,
"width": 540,
"color": 7
},
"id": "note-production-patterns",
"name": "Sticky Note - Production Patterns",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
700,
-120
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "tourist-chat",
"responseMode": "responseNode",
"options": {
"rawBody": true
}
},
"id": "tourist-1-trigger",
"name": "Web Chat Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
240,
320
]
},
{
"parameters": {
"jsCode": "// Webhook integrity check (opt-in via WEBHOOK_INTEGRITY_CHECK_ENABLED=1).\n// Telegram Trigger handles HMAC via its secretToken option (set on the\n// trigger node itself). This Code node is the second defense layer:\n// reject malformed payloads that lack the fields downstream nodes expect.\n//\n// To enable: set the n8n env var WEBHOOK_INTEGRITY_CHECK_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n\nconst enabled = $env.WEBHOOK_INTEGRITY_CHECK_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\n\nif (!message || typeof message !== 'object') {\n throw new Error('Webhook integrity check failed: no message object');\n}\nif (typeof message.from?.id !== 'number' && typeof message.from?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.from.id');\n}\nif (typeof message.chat?.id !== 'number' && typeof message.chat?.id !== 'string') {\n throw new Error('Webhook integrity check failed: missing message.chat.id');\n}\nif (typeof message.text !== 'string' && typeof message.caption !== 'string') {\n throw new Error('Webhook integrity check failed: no text or caption');\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-1-verify",
"name": "Verify Webhook (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
460,
320
]
},
{
"parameters": {
"jsCode": "// Rate limit (opt-in via RATE_LIMIT_ENABLED=1).\n// Per-chat-id 60 requests in a 5-minute window. Tracked in workflow\n// static data (per-instance). For clustered n8n deployments or higher\n// throughput, use Nginx limit_req_zone or Cloudflare WAF instead.\n//\n// To enable: set the n8n env var RATE_LIMIT_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic. Under heavy\n// burst load the count can over-shoot the limit by a few percent. For\n// hard limits use a reverse proxy or Redis INCR + EXPIRE.\n\nconst enabled = $env.RATE_LIMIT_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst chatId = message?.chat?.id ?? message?.from?.id ?? 'unknown';\nconst bucketKey = `chat:${chatId}`;\n\nconst data = $getWorkflowStaticData('global');\nconst buckets = data.rateBuckets ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\nconst LIMIT = 60;\nconst MAX_BUCKETS = 5000;\n\nconst bucket = buckets[bucketKey] ?? { count: 0, windowStart: now };\nif (now - bucket.windowStart > WINDOW_MS) {\n bucket.count = 0;\n bucket.windowStart = now;\n}\nbucket.count++;\nbuckets[bucketKey] = bucket;\n\n// Bound the map: evict expired entries when full\nif (Object.keys(buckets).length > MAX_BUCKETS) {\n const cutoff = now - WINDOW_MS;\n for (const k of Object.keys(buckets)) {\n if (buckets[k].windowStart < cutoff) delete buckets[k];\n }\n}\ndata.rateBuckets = buckets;\n\nif (bucket.count > LIMIT) {\n throw new Error(`Rate limit exceeded for ${bucketKey}: ${bucket.count} requests in 5 min window`);\n}\n\nreturn [{ json: item }];"
},
"id": "support-pp-2-ratelimit",
"name": "Rate Limit (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
320
]
},
{
"parameters": {
"jsCode": "// Idempotency check (opt-in via IDEMPOTENCY_ENABLED=1).\n// Telegram retries on 5xx. Without dedup, the workflow fires twice and\n// writes Memory twice. This node holds a 5-minute in-memory window of\n// seen update_ids and short-circuits duplicates.\n//\n// To enable: set the n8n env var IDEMPOTENCY_ENABLED to '1'.\n// To disable: leave the env var unset (default). The node passes through.\n//\n// Concurrency note: $getWorkflowStaticData is not atomic and not cluster-\n// aware. Two simultaneous fires of the same update_id may both pass.\n// For production scale, swap the staticData block for Redis SET NX EX 300.\n\nconst enabled = $env.IDEMPOTENCY_ENABLED === '1';\nif (!enabled) {\n return [{ json: $input.first().json }];\n}\n\nconst item = $input.first().json;\nconst message = item?.message ?? item;\nconst updateId = item?.update_id ?? message?.update_id;\nconst messageId = message?.message_id;\nconst idempotencyKey = updateId ? `tg-update:${updateId}` : (messageId ? `tg-msg:${messageId}` : null);\n\nif (!idempotencyKey) {\n // No dedup key available, pass through\n return [{ json: item }];\n}\n\nconst data = $getWorkflowStaticData('global');\nconst seen = data.seenKeys ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\n\n// Purge expired entries\nfor (const k of Object.keys(seen)) {\n if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\n\nif (seen[idempotencyKey]) {\n // Duplicate detected. Emit a sentinel item that the\n // 'Skip If Duplicate' IF node routes to 'Respond Duplicate'\n // (200 OK + { deduped: true }). Without that 200 the source\n // provider would hold the HTTP connection until n8n's webhook\n // timeout (default 30s) and mark delivery failed.\n return [{ json: { skipped: true, reason: 'duplicate', dedupKey: String(idempotencyKey) } }];\n}\nseen[idempotencyKey] = now;\ndata.seenKeys = seen;\n\nreturn [{ json: item }];"
},
"id": "support-pp-3-idempotency",
"name": "Idempotency Check (opt-in)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
320
]
},
{
"parameters": {
"jsCode": "// Extract a stable session key from the web-chat payload.\n// Tourist context: a visitor browses your site multiple times across days.\n// We want to recognise them without forcing a login.\n//\n// Strategy: trust an explicit sessionId from a first-party cookie if present,\n// otherwise fall back to a fingerprint built from IP + user-agent + accept-\n// language, otherwise emit an \"anonymous-fresh\" key tied to the request id.\n\nconst body = $input.first().json;\nconst headers = $input.first().json?.headers ?? {};\n\nconst explicitSession = body?.sessionId\n ?? body?.session_id\n ?? headers?.['x-session-id']\n ?? null;\n\nconst ip = (headers?.['x-forwarded-for']?.split(',')[0]?.trim()\n ?? headers?.['x-real-ip']\n ?? 'unknown');\nconst ua = headers?.['user-agent'] ?? '';\nconst lang = headers?.['accept-language']?.split(',')[0]?.trim() ?? 'en';\n\nlet sessionKey;\nlet identitySource;\n\nif (explicitSession) {\n sessionKey = `session:${String(explicitSession).slice(0, 64)}`;\n identitySource = 'explicit-session';\n} else if (ip !== 'unknown') {\n // Lightweight fingerprint - not cryptographically stable, but stable enough\n // for repeat-visit recognition across 1-7 days.\n const crypto = require('crypto');\n const fp = crypto.createHash('sha256').update(`${ip}|${ua}|${lang}`).digest('hex').slice(0, 16);\n sessionKey = `fp:${fp}`;\n identitySource = 'fingerprint';\n} else {\n sessionKey = `anon:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;\n identitySource = 'anonymous-fresh';\n}\n\nconst messageText = String(body?.message ?? body?.text ?? body?.question ?? '').trim();\n\nif (!messageText) {\n throw new Error('Missing message text in payload (expected fields: message, text, or question).');\n}\n\nreturn [{\n json: {\n sessionKey,\n sessionLabel: explicitSession ? `session ${explicitSession}` : `${identitySource} ${sessionKey.slice(0, 24)}`,\n identitySource,\n messageText,\n locale: lang.slice(0, 5),\n receivedAt: new Date().toISOString(),\n },\n}];"
},
"id": "tourist-2-extract",
"name": "Extract Session Key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "search",
"query": "={{ $json.customerKey }}",
"entityType": "customer",
"limit": 1
},
"id": "tourist-3-lookup",
"name": "Memory: Lookup Visitor",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1340,
320
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-known",
"leftValue": "={{ ($json.entities ?? $json.results ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "tourist-3b-known",
"name": "Returning Visitor?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1560,
320
]
},
{
"parameters": {
"resource": "entity",
"operation": "open",
"entityRef": "={{ $('Extract Session Key').item.json.sessionKey }}"
},
"id": "tourist-3c-dossier",
"name": "Memory: Visitor Sessions",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
200
]
},
{
"parameters": {
"resource": "entity",
"operation": "create",
"name": "={{ $('Extract Session Key').item.json.sessionKey }}",
"entityType": "customer",
"project": "support-bot",
"observations": "=First contact via web chat on {{ $('Extract Session Key').item.json.receivedAt }}. Message: {{ $('Extract Session Key').item.json.messageText.slice(0, 200) }}"
},
"id": "tourist-3d-create",
"name": "Memory: Create Visitor",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
1800,
460
]
},
{
"parameters": {
"content": "## Why entity.open here\n\nFor returning visitors we use `entity.open` (not `memory.search`) because we want the **complete dossier**: entity-type, first-seen date, every observation, every relation. That's exactly what an agent needs to feel like a colleague who reviewed the visitor's file before saying hello.\n\nFor recency-weighted search across **all** memory (e.g. when the visitor asks about a product topic, not their own history), use the Memory: Search operation instead.",
"height": 240,
"width": 380,
"color": 7
},
"id": "note-entity-open",
"name": "Sticky Note - entity.open",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
1800,
-120
]
},
{
"parameters": {
"jsCode": "// Build a system + user prompt for the LLM with the visitor's prior session\n// context as part of the system prompt. Tourist context: we want to greet\n// returning visitors with awareness of what they asked about last time\n// without being creepy (\"welcome back, I see you were curious about Cala\n// Figuera last week, here's what is open today\").\n\nconst sessionKey = $('Extract Session Key').item.json.sessionKey;\nconst sessionLabel = $('Extract Session Key').item.json.sessionLabel;\nconst messageText = $('Extract Session Key').item.json.messageText;\nconst locale = $('Extract Session Key').item.json.locale;\n\nconst dossierRaw = $input.first().json;\nconst dossier = dossierRaw?.data ?? dossierRaw;\nconst observations = dossier?.observations ?? [];\nconst sessionCount = observations.length;\nconst recentObs = observations\n .slice(-8)\n .map(o => `- ${o?.created_at?.slice(0, 10) ?? '?'}: ${(o?.content ?? '').slice(0, 200)}`)\n .join('\\n');\n\nconst systemPrompt = `You are Clara, the friendly tourism concierge for a small town in Mallorca. You answer questions about beaches, restaurants, day-trips, and current events. When the visitor is returning (you can see prior questions below), reference the last topic naturally without being intrusive. Reply in the language of the visitor's message (German, English, Spanish, Catalan, French). Keep replies under 120 words and structured: one sentence answer, two to three sentences with the helpful detail, optional follow-up question. Never invent business details or prices, say \"let me check that\" if you do not know.\n\nVisitor session label: ${sessionLabel}\nPrior sessions on file: ${sessionCount}\nDetected locale: ${locale}\n\nRecent observations (most-recent last):\n${recentObs || '(no prior interactions on file, this is a first-time visitor)'}`;\n\nreturn [{\n json: {\n systemPrompt,\n messageText,\n sessionKey,\n sessionLabel,\n isReturning: sessionCount > 0,\n },\n}];"
},
"id": "tourist-4-prompt",
"name": "Build LLM Prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
320
]
},
{
"parameters": {
"mode": "manual",
"duplicateItem": false,
"assignments": {
"assignments": [
{
"id": "set-provider",
"name": "provider",
"value": "openai",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "tourist-5-set-provider",
"name": "Set Provider",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
2260,
320
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-openai",
"leftValue": "={{ $json.provider }}",
"rightValue": "openai",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "openai"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "rule-anthropic",
"leftValue": "={{ $json.provider }}",
"rightValue": "anthropic",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "anthropic"
}
]
},
"options": {
"fallbackOutput": "extra",
"renameFallbackOutput": "fallback"
}
},
"id": "tourist-5b-route",
"name": "Route by Provider",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
2480,
320
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "gpt-5-mini",
"mode": "list",
"cachedResultName": "gpt-5-mini"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText ?? $json.transcript ?? $json.payload }}",
"role": "user"
}
]
},
"jsonOutput": false,
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "tourist-6-openai",
"name": "OpenAI Reply",
"type": "n8n-nodes-base.openAi",
"typeVersion": 1.7,
"onError": "continueErrorOutput",
"position": [
2700,
200
]
},
{
"parameters": {
"resource": "text",
"operation": "message",
"modelId": {
"__rl": true,
"value": "claude-haiku-4-5",
"mode": "list",
"cachedResultName": "claude-haiku-4-5"
},
"messages": {
"values": [
{
"content": "={{ $json.systemPrompt }}",
"role": "system"
},
{
"content": "={{ $json.messageText }}",
"role": "user"
}
]
},
"options": {
"maxTokens": 400,
"temperature": 0.5
}
},
"id": "tourist-6-anthropic",
"name": "Anthropic Reply",
"type": "@n8n/n8n-nodes-langchain.anthropic",
"typeVersion": 1,
"onError": "continueErrorOutput",
"position": [
2700,
440
]
},
{
"parameters": {
"content": "## Error branch (always on)\n\nThe two LLM Reply nodes have `On Error: Continue (Error Output)` enabled. The red error pin lands at **LLM Fallback Reply**, which builds a graceful visitor message and feeds two destinations:\n\n1. **Web Chat Reply** so the visitor gets an answer instead of silence.\n2. **Memory: Learn Error** with `category: mistake, tags: [llm-error, <provider>]` so you spot patterns in your knowledge graph.\n\nNo env var, this branch is always wired. Without it, an OpenAI rate-limit or Anthropic 5xx leaves the visitor hanging.\n\nThe error syntax is `{{ $json.error.message }}`, not `$error.message` (which does not exist in n8n) and not `$json.execution.error.message` (which is for separate Error Trigger Workflows, not inline error pins).",
"height": 360,
"width": 460,
"color": 7
},
"id": "note-error-branch",
"name": "Sticky Note - Error Branch",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
2960,
-100
]
},
{
"parameters": {
"jsCode": "// LLM Fallback Reply: fires when OpenAI Reply or Anthropic Reply errors,\n// or when Route by Provider receives an unknown provider value.\n//\n// Two arrival paths land here:\n// 1. LLM error - input has $json.error\n// 2. Router fallback - input has the original prompt object including\n// systemPrompt with the visitor's prior session context.\n// We must NOT JSON.stringify the whole input.\n\nconst errorRaw = $input.first().json;\nconst provider = $('Set Provider').item.json.provider ?? 'unknown';\n\nconst isLlmError = !!(errorRaw?.error || errorRaw?.message);\nlet errorMessage;\nif (isLlmError) {\n errorMessage = errorRaw?.error?.message\n ?? errorRaw?.error?.name\n ?? errorRaw?.message\n ?? 'Unknown LLM error';\n} else {\n errorMessage = `Unknown provider value: ${provider}. Set \"provider\" to \"openai\" or \"anthropic\" in the Set Provider node.`;\n}\n\nconst sessionKey = $('Extract Session Key').item.json.sessionKey;\nconst sessionLabel = $('Extract Session Key').item.json.sessionLabel;\nconst messageText = $('Extract Session Key').item.json.messageText;\n\nconst fallbackText = `Sorry, I'm having trouble with my brain right now. Please try again in a minute, or check our website at https://example.com for current information.`;\n\nreturn [{\n json: {\n replyText: fallbackText,\n provider,\n isFallback: true,\n isRouterFallback: !isLlmError,\n errorMessage: String(errorMessage),\n sessionKey,\n sessionLabel,\n messageText,\n },\n}];"
},
"id": "tourist-7-fallback",
"name": "LLM Fallback Reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
580
]
},
{
"parameters": {
"jsCode": "// Normalize LLM output across providers into a single field `replyText`.\n// OpenAI: $json.choices[0].message.content\n// Anthropic: $json.content[0].text\n\nconst raw = $input.first().json;\nlet replyText = '';\n\nif (raw?.choices?.[0]?.message?.content) {\n replyText = raw.choices[0].message.content;\n} else if (Array.isArray(raw?.content) && raw.content[0]?.text) {\n replyText = raw.content[0].text;\n} else if (raw?.message?.content) {\n replyText = raw.message.content;\n} else if (raw?.text) {\n replyText = raw.text;\n} else if (raw?.reply) {\n replyText = raw.reply;\n} else {\n replyText = '(LLM returned no text, check provider response shape)';\n}\n\nreturn [{\n json: {\n replyText: String(replyText).trim(),\n provider: $('Set Provider').item.json.provider,\n isFallback: false,\n },\n}];"
},
"id": "tourist-8-normalize",
"name": "Normalize LLM Output",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2960,
320
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ reply: $json.replyText, sessionId: $('Extract Session Key').item.json.sessionKey, returningVisitor: $('Build LLM Prompt').item.json.isReturning, provider: $json.provider }) }}",
"options": {}
},
"id": "tourist-9-reply",
"name": "Web Chat Reply",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3200,
320
],
"credentials": {}
},
{
"parameters": {
"resource": "entity",
"operation": "observe",
"entityRef": "={{ $('Extract Session Key').item.json.sessionKey }}",
"observations": "=Ticket on {{ $('Extract Session Key').item.json.receivedAt }}, Customer: {{ $('Extract Session Key').item.json.messageText.slice(0, 200) }} | Bot reply: {{ ($json.replyText ?? '').slice(0, 200) }}"
},
"id": "tourist-9b-observe",
"name": "Memory: Observe Session",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
220
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=Support interaction with {{ $('Extract Session Key').item.json.sessionLabel }} ({{ $('Extract Session Key').item.json.sessionKey }}): \"{{ $('Extract Session Key').item.json.messageText.slice(0, 150) }}\" \u2192 Bot resolved with: \"{{ ($json.replyText ?? '').slice(0, 150) }}\"",
"category": "insight",
"project": "support-bot",
"tags": "=support, customer-{{ $('Extract Session Key').item.json.sessionKey }}",
"confidence": 0.7
},
"id": "tourist-9c-learn",
"name": "Memory: Learn Visit",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
380
]
},
{
"parameters": {
"resource": "memory",
"operation": "learn",
"content": "=LLM error in support bot ({{ $json.provider }}): {{ $json.errorMessage }} | Customer: {{ $json.customerLabel }} ({{ $json.customerKey }}) | Question: \"{{ ($json.messageText ?? '').slice(0, 150) }}\"",
"category": "mistake",
"project": "support-bot",
"tags": "=llm-error, {{ $json.provider }}, support-bot",
"confidence": 0.6
},
"id": "tourist-9d-error-learn",
"name": "Memory: Learn Error",
"type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
"typeVersion": 1,
"position": [
3440,
580
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "cond-05-tourist-bot-repeat-visitor-skipped",
"leftValue": "={{ $json.skipped }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "05-tou-if-skip-dup",
"name": "Skip If Duplicate",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1120,
320
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ok: true, deduped: true, reason: \"duplicate\" }) }}",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "X-Dedup",
"value": "1"
}
]
}
}
},
"id": "05-tou-respond-duplicate",
"name": "Respond Duplicate",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1340,
140
]
}
],
"connections": {
"Web Chat Webhook": {
"main": [
[
{
"node": "Verify Webhook (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Verify Webhook (opt-in)": {
"main": [
[
{
"node": "Rate Limit (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Rate Limit (opt-in)": {
"main": [
[
{
"node": "Idempotency Check (opt-in)",
"type": "main",
"index": 0
}
]
]
},
"Idempotency Check (opt-in)": {
"main": [
[
{
"node": "Skip If Duplicate",
"type": "main",
"index": 0
}
]
]
},
"Extract Session Key": {
"main": [
[
{
"node": "Memory: Lookup Visitor",
"type": "main",
"index": 0
}
]
]
},
"Memory: Lookup Visitor": {
"main": [
[
{
"node": "Returning Visitor?",
"type": "main",
"index": 0
}
]
]
},
"Returning Visitor?": {
"main": [
[
{
"node": "Memory: Visitor Sessions",
"type": "main",
"index": 0
}
],
[
{
"node": "Memory: Create Visitor",
"type": "main",
"index": 0
}
]
]
},
"Memory: Visitor Sessions": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Memory: Create Visitor": {
"main": [
[
{
"node": "Build LLM Prompt",
"type": "main",
"index": 0
}
]
]
},
"Build LLM Prompt": {
"main": [
[
{
"node": "Set Provider",
"type": "main",
"index": 0
}
]
]
},
"Set Provider": {
"main": [
[
{
"node": "Route by Provider",
"type": "main",
"index": 0
}
]
]
},
"Route by Provider": {
"main": [
[
{
"node": "OpenAI Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "Anthropic Reply",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Anthropic Reply": {
"main": [
[
{
"node": "Normalize LLM Output",
"type": "main",
"index": 0
}
],
[
{
"node": "LLM Fallback Reply",
"type": "main",
"index": 0
}
]
]
},
"Normalize LLM Output": {
"main": [
[
{
"node": "Web Chat Reply",
"type": "main",
"index": 0
},
{
"node": "Memory: Observe Session",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Visit",
"type": "main",
"index": 0
}
]
]
},
"LLM Fallback Reply": {
"main": [
[
{
"node": "Web Chat Reply",
"type": "main",
"index": 0
},
{
"node": "Memory: Learn Error",
"type": "main",
"index": 0
}
]
]
},
"Skip If Duplicate": {
"main": [
[
{
"node": "Respond Duplicate",
"type": "main",
"index": 0
}
],
[
{
"node": "Extract Session Key",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
About this workflow
Tourist-Bot Repeat-Visitor (Multi-Provider). Uses stickyNote, n8n-nodes-studiomeyer-memory, openAi, anthropic. Webhook trigger; 27 nodes.
Source: https://github.com/studiomeyer-io/n8n-templates/blob/main/templates/05-tourist-bot-repeat-visitor/workflow.json — original creator credit. Request a take-down →