AutomationFlowsAI & RAG › Personal Assistant with Long-Term Memory (StudioMeyer)

Personal Assistant with Long-Term Memory (StudioMeyer)

Personal Assistant with Long-Term Memory (StudioMeyer). Uses stickyNote, telegramTrigger, n8n-nodes-studiomeyer-memory, telegram. Event-driven trigger; 26 nodes.

Event trigger★★★★☆ complexityAI-powered26 nodesTelegram TriggerN8N Nodes Studiomeyer MemoryTelegramOpen AiAnthropic
AI & RAG Trigger: Event Nodes: 26 Complexity: ★★★★☆ AI nodes: yes

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 →

Download .json
{
  "name": "Personal Assistant with Long-Term Memory (StudioMeyer)",
  "nodes": [
    {
      "parameters": {
        "content": "## Personal Assistant with Long-Term Memory\n\n**Stack:** Telegram \u2192 intent classifier \u2192 either save-as-note OR memory-aware Q&A \u2192 reply.\n\n**Why this is different from a stateless ChatGPT bot:** Everything you tell it is permanently searchable. \"What did I say about the redesign on Monday?\" actually returns the right answer in March, even if Monday was three weeks ago.\n\n**Tool-use extensions (Calendar, Gmail, Notion)** are described in the README and can be added as additional Switch branches without changing the memory loop.",
        "height": 320,
        "width": 480,
        "color": 6
      },
      "id": "note-intro",
      "name": "Sticky Note - Intro",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -260,
        -60
      ]
    },
    {
      "parameters": {
        "updates": [
          "message"
        ],
        "additionalFields": {}
      },
      "id": "pa-1-trigger",
      "name": "Telegram Trigger",
      "type": "n8n-nodes-base.telegramTrigger",
      "typeVersion": 1.1,
      "position": [
        240,
        280
      ]
    },
    {
      "parameters": {
        "jsCode": "// Two-step intent classifier:\n// 1. If the message starts with a slash command (/note, /ask, /summary), trust it.\n// 2. Otherwise default to 'ask', Claude can always reply, but Memory still gets searched first.\n//\n// You can later replace this with a Claude-Haiku classifier for fuzzier intents.\n\nconst body = $input.first().json;\nconst message = body?.message ?? body;\nconst text = (message?.text ?? message?.caption ?? '').trim();\nconst chatId = message?.chat?.id;\nconst userId = message?.from?.id;\n\n// User-label fallback chain: username > first_name > tg:userId > chat:chatId\n// We never produce 'user-undefined' which would collapse channel posts and\n// forwarded messages without sender into one shared identity.\nlet userLabel;\nif (message?.from?.username) {\n  userLabel = `@${message.from.username}`;\n} else if (message?.from?.first_name) {\n  userLabel = message.from.first_name;\n} else if (userId !== undefined && userId !== null) {\n  userLabel = `tg:${userId}`;\n} else if (chatId !== undefined && chatId !== null) {\n  userLabel = `chat:${chatId}`;\n} else {\n  throw new Error('Cannot identify user: message has no from.username, no from.first_name, no from.id, and no chat.id');\n}\n\nlet intent = 'ask';\nlet payload = text;\n\nconst slashMatch = text.match(/^\\/(note|ask|summary)\\s+([\\s\\S]*)$/i);\nif (slashMatch) {\n  intent = slashMatch[1].toLowerCase();\n  payload = slashMatch[2].trim();\n} else if (/^\\/(note|ask|summary)$/i.test(text)) {\n  // Slash command without payload\n  intent = text.slice(1).toLowerCase();\n  payload = '';\n}\n\nreturn [{\n  json: {\n    intent,\n    payload,\n    rawText: text,\n    chatId,\n    userId,\n    userLabel,\n    receivedAt: new Date().toISOString(),\n  },\n}];"
      },
      "id": "pa-2-intent",
      "name": "Detect Intent",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        280
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "rule-note",
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "note",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "note"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "rule-summary",
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "summary",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "summary"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "rule-ask",
                    "leftValue": "={{ $json.intent }}",
                    "rightValue": "ask",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "ask"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "fallback"
        }
      },
      "id": "pa-3-switch",
      "name": "Route by Intent",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1100,
        280
      ]
    },
    {
      "parameters": {
        "resource": "memory",
        "operation": "learn",
        "content": "={{ $('Detect Intent').item.json.payload }}",
        "category": "insight",
        "project": "personal-assistant",
        "tags": "=note, {{ $('Detect Intent').item.json.userLabel }}",
        "confidence": 0.85
      },
      "id": "pa-4-learn-note",
      "name": "Memory: Save Note",
      "type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
      "typeVersion": 1,
      "position": [
        1340,
        100
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $('Detect Intent').item.json.chatId }}",
        "text": "=Saved. (Stored as a learning, searchable any time.)",
        "additionalFields": {}
      },
      "id": "pa-5-reply-note",
      "name": "Telegram: Note Saved",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1560,
        100
      ]
    },
    {
      "parameters": {
        "resource": "insight",
        "operation": "synthesize",
        "query": "={{ $('Detect Intent').item.json.payload || 'recent activity' }}",
        "category": ""
      },
      "id": "pa-6-synthesize",
      "name": "Memory: Synthesize",
      "type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
      "typeVersion": 1,
      "position": [
        1340,
        280
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $('Detect Intent').item.json.chatId }}",
        "text": "={{ $json.synthesis ?? $json.summary ?? $json.formatted ?? JSON.stringify($json).slice(0, 3500) }}",
        "additionalFields": {
          "parse_mode": "Markdown"
        }
      },
      "id": "pa-7-reply-summary",
      "name": "Telegram: Summary Reply",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        1560,
        280
      ]
    },
    {
      "parameters": {
        "resource": "memory",
        "operation": "search",
        "query": "={{ $('Detect Intent').item.json.payload }}",
        "limit": 8,
        "project": "personal-assistant",
        "recencyWeight": 0.5
      },
      "id": "pa-8-search",
      "name": "Memory: Search Context",
      "type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
      "typeVersion": 1,
      "position": [
        1340,
        460
      ]
    },
    {
      "parameters": {
        "jsCode": "// Build a context-aware prompt for Claude using whatever memory returned.\n\nconst question = $('Detect Intent').item.json.payload;\nconst userLabel = $('Detect Intent').item.json.userLabel;\n\nconst memData = $input.first().json;\nconst results = memData?.results ?? memData?.data?.results ?? [];\n\nconst contextLines = results.slice(0, 8).map((r, i) => {\n  const text = r.content ?? r.text ?? r.formatted ?? JSON.stringify(r).slice(0, 200);\n  const date = r.date ?? r.createdAt ?? '';\n  return `${i + 1}. [${date}] ${text}`;\n});\n\nconst contextBlock = contextLines.length\n  ? contextLines.join('\\n')\n  : '(no prior context found in memory for this query)';\n\nconst systemPrompt = `You are ${userLabel}'s long-term memory and personal assistant. You have access to everything they've ever told you (notes, decisions, observations).\\n\\nMemory results for the current question:\\n${contextBlock}\\n\\nReply concisely and reference specific past entries when relevant (e.g. \"On March 12 you said...\"). If the memory doesn't have a direct answer, say so honestly and offer to record the question as a note for future reference.`;\n\nreturn [{\n  json: {\n    question,\n    userLabel,\n    contextBlock,\n    systemPrompt,\n    memoryHits: results.length,\n  },\n}];"
      },
      "id": "pa-9-prompt",
      "name": "Build Prompt",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1560,
        460
      ]
    },
    {
      "parameters": {
        "mode": "manual",
        "duplicateItem": false,
        "assignments": {
          "assignments": [
            {
              "id": "set-provider",
              "name": "provider",
              "value": "openai",
              "type": "string"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "mp-set-provider",
      "name": "Set Provider",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        1800,
        460
      ]
    },
    {
      "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": "mp-route",
      "name": "Route by Provider",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2020,
        460
      ]
    },
    {
      "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.question }}",
              "role": "user"
            }
          ]
        },
        "jsonOutput": false,
        "options": {
          "maxTokens": 400,
          "temperature": 0.5
        }
      },
      "id": "mp-openai",
      "name": "OpenAI Reply",
      "type": "n8n-nodes-base.openAi",
      "typeVersion": 1.7,
      "position": [
        2240,
        340
      ],
      "onError": "continueErrorOutput"
    },
    {
      "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.question }}",
              "role": "user"
            }
          ]
        },
        "options": {
          "maxTokens": 600,
          "temperature": 0.4
        }
      },
      "id": "mp-anthropic",
      "name": "Anthropic Reply",
      "type": "@n8n/n8n-nodes-langchain.anthropic",
      "typeVersion": 1,
      "position": [
        2240,
        580
      ],
      "onError": "continueErrorOutput"
    },
    {
      "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  },\n}];"
      },
      "id": "mp-normalize",
      "name": "Normalize LLM Output",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2480,
        460
      ]
    },
    {
      "parameters": {
        "chatId": "={{ $('Detect Intent').item.json.chatId }}",
        "text": "={{ $json.replyText ?? 'I had trouble generating a reply.' }}",
        "additionalFields": {
          "parse_mode": "Markdown"
        }
      },
      "id": "pa-11-reply-ask",
      "name": "Telegram: Q&A Reply",
      "type": "n8n-nodes-base.telegram",
      "typeVersion": 1.2,
      "position": [
        2720,
        460
      ]
    },
    {
      "parameters": {
        "resource": "memory",
        "operation": "learn",
        "content": "=Q: {{ $('Detect Intent').item.json.payload }}\\nA: {{ ($json.replyText ?? '').slice(0, 400) }}",
        "category": "insight",
        "project": "personal-assistant",
        "tags": "=qa, {{ $('Detect Intent').item.json.userLabel }}",
        "confidence": 0.65
      },
      "id": "pa-12-learn-qa",
      "name": "Memory: Learn Q&A",
      "type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
      "typeVersion": 1,
      "position": [
        2960,
        380
      ]
    },
    {
      "parameters": {
        "content": ">> SET ME <<\n\n**Default intent is `ask`** for any text without a slash command.\n\nUsers can also send:\n- `/note <anything>`, store as a learning, no LLM call\n- `/ask <question>`, explicit Q&A path\n- `/summary <topic>`, runs `Memory: Synthesize` for a topic cluster summary\n\nExamples:\n- `/note redesign meeting Monday: agreed on Plan B, push to next sprint`\n- `What did I decide about the redesign?` \u2192 searches memory + answers\n- `/summary redesign` \u2192 multi-week cluster summary",
        "height": 320,
        "width": 380,
        "color": 5
      },
      "id": "note-intents",
      "name": "Sticky Note - Intents",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1100,
        -60
      ]
    },
    {
      "parameters": {
        "content": "## Tool-use extensions (optional)\n\nThis core workflow is intentionally small (~12 nodes). To add Calendar / Gmail / Notion as proper tools the assistant can invoke:\n\n1. Add a `Switch` branch on the intent name (e.g. `calendar_event`, `email`, `notion_page`).\n2. Use n8n's native nodes: Google Calendar (Create), Gmail (Send), Notion (Create Page).\n3. After execution, run `Memory: Learn` so the assistant remembers what it did.\n\nThe READMe walks through one full extension (Google Calendar) end-to-end.",
        "height": 240,
        "width": 380,
        "color": 7
      },
      "id": "note-tools",
      "name": "Sticky Note - Tools",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1800,
        -120
      ]
    },
    {
      "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.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": "pa-pp-1-verify",
      "name": "Verify Webhook (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        400,
        280
      ]
    },
    {
      "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\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": "pa-pp-2-ratelimit",
      "name": "Rate Limit (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        560,
        280
      ]
    },
    {
      "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. For production scale, swap the staticData block for Redis SET NX\n// EX 300 (atomic, cluster-aware, auto-expires).\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  return [{ json: item }];\n}\n\nconst data = $getWorkflowStaticData('global');\nconst seen = data.seenKeys ?? {};\nconst now = Date.now();\nconst WINDOW_MS = 5 * 60 * 1000;\n\nfor (const k of Object.keys(seen)) {\n  if (now - seen[k] > WINDOW_MS) delete seen[k];\n}\n\nif (seen[idempotencyKey]) {\n  return [];\n}\nseen[idempotencyKey] = now;\ndata.seenKeys = seen;\n\nreturn [{ json: item }];"
      },
      "id": "pa-pp-3-idempotency",
      "name": "Idempotency Check (opt-in)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        720,
        280
      ]
    },
    {
      "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// Builds a graceful customer-facing reply and an error-learn payload.\n//\n// The error pin from a node with onError=continueErrorOutput delivers\n// $json.error.message (and other error fields). $error.message does not\n// exist as a global in n8n, despite being often quoted online.\n//\n// Two arrival paths land here:\n//   1. LLM error (OpenAI or Anthropic returned non-2xx) ,  input has $json.error\n//   2. Router fallback (Route by Provider had no matching rule) ,  input has\n//      the original prompt object (systemPrompt, question, userLabel, etc.)\n//      WITHOUT an error field. We must NOT JSON.stringify the whole input\n//      because systemPrompt contains private user-memory context that would\n//      then end up in the Memory: Learn Error audit trail.\n\nconst errorRaw = $input.first().json;\nconst provider = $('Set Provider').item.json.provider ?? 'unknown';\n\n// Detect whether this is an error envelope or a router-fallback object\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  // Router fallback: the input has no error field. Synthesize a clean\n  // diagnostic that does not leak systemPrompt or any private context.\n  errorMessage = `Unknown provider value: ${provider}. Set \"provider\" to \"openai\" or \"anthropic\" in the Set Provider node.`;\n}\n\nconst userLabel = $('Detect Intent').item.json.userLabel;\nconst question = $('Detect Intent').item.json.payload;\nconst chatId = $('Detect Intent').item.json.chatId;\n\nconst fallbackText = `Sorry, I had trouble looking that up just now. Try again in a minute, or send /note <your question> to record it for later.`;\n\nreturn [{\n  json: {\n    replyText: fallbackText,\n    provider,\n    isFallback: true,\n    isRouterFallback: !isLlmError,\n    errorMessage: String(errorMessage),\n    userLabel,\n    question,\n    chatId,\n  },\n}];"
      },
      "id": "pa-fallback-llm",
      "name": "LLM Fallback Reply",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2480,
        700
      ]
    },
    {
      "parameters": {
        "resource": "memory",
        "operation": "learn",
        "content": "=LLM error in personal-assistant ({{ $json.provider }}): {{ $json.errorMessage }} | User: {{ $json.userLabel }} | Question: \"{{ ($json.question ?? '').slice(0, 150) }}\"",
        "category": "mistake",
        "project": "personal-assistant",
        "tags": "=llm-error, {{ $json.provider }}, personal-assistant",
        "confidence": 0.6
      },
      "id": "pa-13-learn-error",
      "name": "Memory: Learn Error",
      "type": "n8n-nodes-studiomeyer-memory.studioMeyerMemory",
      "typeVersion": 1,
      "position": [
        2720,
        700
      ]
    },
    {
      "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 Telegram retries on the same `update_id` (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 `chat.id`, no `text`).\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 Telegram 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.",
        "height": 360,
        "width": 540,
        "color": 7
      },
      "id": "note-production-patterns",
      "name": "Sticky Note - Production Patterns",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        400,
        -120
      ]
    },
    {
      "parameters": {
        "content": "## Error branch (always on)\n\nBoth LLM Reply nodes have `On Error: Continue (Error Output)` enabled. The red error pin lands at **LLM Fallback Reply**, which builds a graceful user message and feeds two destinations:\n\n1. **Telegram: Q&A Reply** so the user gets an answer instead of silence.\n2. **Memory: Learn Error** with `category: mistake, tags: [llm-error, <provider>]` so you spot patterns in the knowledge graph.\n\nThe `Route by Provider` fallback output (typo or unknown provider value) also lands here, so a misconfigured `provider` field still produces a reply instead of silent dead-end.\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": [
        2480,
        -120
      ]
    }
  ],
  "connections": {
    "Telegram Trigger": {
      "main": [
        [
          {
            "node": "Verify Webhook (opt-in)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect Intent": {
      "main": [
        [
          {
            "node": "Route by Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Intent": {
      "main": [
        [
          {
            "node": "Memory: Save Note",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Memory: Synthesize",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Memory: Search Context",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Memory: Save Note": {
      "main": [
        [
          {
            "node": "Telegram: Note Saved",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Memory: Synthesize": {
      "main": [
        [
          {
            "node": "Telegram: Summary Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Memory: Search Context": {
      "main": [
        [
          {
            "node": "Build Prompt",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build Prompt": {
      "main": [
        [
          {
            "node": "Set Provider",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize LLM Output": {
      "main": [
        [
          {
            "node": "Telegram: Q&A Reply",
            "type": "main",
            "index": 0
          },
          {
            "node": "Memory: Learn Q&A",
            "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
          }
        ]
      ]
    },
    "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": "Detect Intent",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "LLM Fallback Reply": {
      "main": [
        [
          {
            "node": "Telegram: Q&A Reply",
            "type": "main",
            "index": 0
          },
          {
            "node": "Memory: Learn Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  }
}

About this workflow

Personal Assistant with Long-Term Memory (StudioMeyer). Uses stickyNote, telegramTrigger, n8n-nodes-studiomeyer-memory, telegram. Event-driven trigger; 26 nodes.

Source: https://github.com/studiomeyer-io/n8n-templates/blob/main/templates/03-personal-assistant-long-term-memory/workflow.json — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →