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": "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 →