This workflow follows the OpenAI → Telegram recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"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"
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
How this works
This workflow transforms your Telegram bot into a reliable personal assistant that remembers conversations and notes over time, helping you capture ideas, tasks, or reminders without losing track amid daily chaos. It's ideal for busy professionals or anyone seeking a simple, persistent AI companion to organise thoughts and provide context-aware responses. The key step involves the n8n-nodes-studiomeyer-memory integration, which stores and synthesises long-term information from your messages, while OpenAI or Anthropic powers intelligent intent detection and replies via Telegram.
Use this workflow when you need an always-on assistant for quick note-taking and follow-up queries in Telegram, such as recalling past project details during a chat. Avoid it for high-volume enterprise needs or if you require integrations beyond Telegram and basic AI models, as it focuses on personal, event-driven interactions. Common variations include adding calendar syncs for task reminders or expanding memory to handle voice inputs.
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
AI Customer Support with Customer History (StudioMeyer). Uses stickyNote, telegramTrigger, n8n-nodes-studiomeyer-memory, openAi. Event-driven trigger; 25 nodes.
Restaurant Stammgast-Bot (Multi-Provider). Uses stickyNote, telegramTrigger, n8n-nodes-studiomeyer-memory, openAi. Event-driven trigger; 25 nodes.
The workflow starts by listening for messages from Telegram users. The message is then processed, and based on its content, different actions are taken. If it's a regular chat message, the workflow ge
24-simple-telegram-ai-chatbot. Uses telegramTrigger, openAi, telegram. Event-driven trigger; 16 nodes.
This workflow connects a Telegram bot to OpenAi/Google Gemini (PaLM API) so the bot can reply to users with AI-generated answers. Useful for FAQs, assistants, classroom helpers, or bots that fetch doc