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": "\ud83d\udc93 Heartbeat",
"settings": {
"executionOrder": "v1"
},
"connections": {
"Schedule Trigger": {
"main": [
[
{
"node": "Load Due Actions",
"type": "main",
"index": 0
},
{
"node": "Cleanup Expired Files",
"type": "main",
"index": 0
}
]
]
},
"Load Due Actions": {
"main": [
[
{
"node": "Has Actions",
"type": "main",
"index": 0
}
]
]
},
"Has Actions": {
"main": [
[
{
"node": "Loop Over Actions",
"type": "main",
"index": 0
}
]
]
},
"Loop Over Actions": {
"main": [
[],
[
{
"node": "Route by Notify Mode",
"type": "main",
"index": 0
}
]
]
},
"Route by Notify Mode": {
"main": [
[
{
"node": "Execute Background Checker",
"type": "main",
"index": 0
}
],
[
{
"node": "Execute Agent",
"type": "main",
"index": 0
}
]
]
},
"Execute Background Checker": {
"main": [
[
{
"node": "Handle Checker Response",
"type": "main",
"index": 0
}
]
]
},
"Handle Checker Response": {
"main": [
[
{
"node": "Should Notify",
"type": "main",
"index": 0
}
]
]
},
"Should Notify": {
"main": [
[
{
"node": "Send Telegram",
"type": "main",
"index": 0
},
{
"node": "Update Action",
"type": "main",
"index": 0
}
],
[
{
"node": "Update Action",
"type": "main",
"index": 0
}
]
]
},
"Execute Agent": {
"main": [
[
{
"node": "Update Action",
"type": "main",
"index": 0
}
]
]
},
"Update Action": {
"main": [
[
{
"node": "Loop Over Actions",
"type": "main",
"index": 0
}
]
]
}
},
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "minutes",
"minutesInterval": 15
}
]
}
},
"id": "schedule-trigger",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.3,
"position": [
0,
0
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Load due scheduled actions + proactive reminder check + open loop check\nconst SUPABASE_URL = '{{SUPABASE_URL}}';\nconst SUPABASE_KEY = '{{SUPABASE_SERVICE_KEY}}';\nconst CHAT_ID = '{{TELEGRAM_CHAT_ID}}';\nconst http = this.helpers.httpRequest;\n\nasync function pgrest(path) {\n const res = await http({\n method: 'GET',\n url: `${SUPABASE_URL}${path}`,\n headers: { 'apikey': SUPABASE_KEY, 'Content-Type': 'application/json' },\n returnFullResponse: true,\n ignoreHttpStatusErrors: true\n });\n return typeof res.body === 'string' ? JSON.parse(res.body) : res.body;\n}\n\nconst userId = `telegram:${CHAT_ID}`;\nconst now = new Date();\nconst nowISO = now.toISOString();\n\n// Load all context in parallel\nconst [dueActions, tasks, profiles, heartbeatCfg, mcpServers, openLoopCfg] = await Promise.all([\n pgrest(`/rest/v1/scheduled_actions?enabled=eq.true&next_run=lte.${nowISO}&order=next_run.asc`),\n pgrest(`/rest/v1/tasks?user_id=eq.${encodeURIComponent(userId)}&status=in.(pending,in_progress)&order=due_date.asc.nullslast,priority.desc`),\n pgrest(`/rest/v1/user_profiles?user_id=eq.${encodeURIComponent(userId)}&select=display_name,timezone,preferences`),\n pgrest('/rest/v1/heartbeat_config?check_name=eq.heartbeat&select=config,last_run,enabled'),\n pgrest('/rest/v1/mcp_registry?active=eq.true&select=server_name,mcp_url,description,tools'),\n pgrest('/rest/v1/heartbeat_config?check_name=eq.open_loop_check&select=config,last_run,enabled')\n]);\n\nconst profile = (Array.isArray(profiles) && profiles.length > 0) ? profiles[0] : {};\nconst prefs = profile.preferences || {};\nconst tz = profile.timezone || 'Europe/Berlin';\nconst lang = prefs.language || 'German';\n\n// Build MCP skills text for background checker\nlet mcpSkillsText = '';\nif (Array.isArray(mcpServers) && mcpServers.length > 0) {\n mcpSkillsText = mcpServers.map(s => {\n const tools = Array.isArray(s.tools) ? s.tools.join(', ') : '';\n return `- ${s.server_name}: ${s.mcp_url} (tools: ${tools}) \u2014 ${s.description || ''}`;\n }).join('\\n');\n}\n\n// Build result items from due scheduled actions\nconst items = [];\n\nif (Array.isArray(dueActions)) {\n for (const action of dueActions) {\n items.push({\n json: {\n type: 'scheduled_action',\n actionId: action.id,\n message: action.instruction,\n chat_id: action.chat_id,\n user_id: action.user_id,\n source: 'scheduled_action',\n schedule: action.schedule,\n timezone: action.timezone || tz,\n run_count: action.run_count || 0,\n max_runs: action.max_runs,\n name: action.name,\n notify_mode: action.notify_mode || 'always',\n last_run: action.last_run || null,\n mcp_skills: mcpSkillsText,\n language: lang\n }\n });\n }\n}\n\n// Proactive reminder check (existing logic \u2014 kept as synthetic item)\nconst hbEnabled = (Array.isArray(heartbeatCfg) && heartbeatCfg.length > 0) ? heartbeatCfg[0].enabled : false;\nconst hbLastRun = (Array.isArray(heartbeatCfg) && heartbeatCfg.length > 0 && heartbeatCfg[0].last_run) ? new Date(heartbeatCfg[0].last_run) : null;\nconst hbMinInterval = (Array.isArray(heartbeatCfg) && heartbeatCfg.length > 0 && heartbeatCfg[0].config) ? (heartbeatCfg[0].config.min_interval_hours || 2) : 2;\n\nif (hbEnabled) {\n const hoursSinceLastRun = hbLastRun ? (now.getTime() - hbLastRun.getTime()) / (1000 * 60 * 60) : 999;\n if (hoursSinceLastRun >= hbMinInterval) {\n const taskList = Array.isArray(tasks) ? tasks : [];\n const overdue = taskList.filter(t => t.due_date && new Date(t.due_date) < now);\n const urgentCutoff = new Date(now.getTime() + 24 * 60 * 60 * 1000);\n const urgent = taskList.filter(t => t.priority === 'urgent' && (!t.due_date || new Date(t.due_date) <= urgentCutoff));\n\n if (overdue.length > 0 || urgent.length > 0) {\n let instruction = `You are proactively checking in with the user. Respond in ${lang}. Send a brief, helpful reminder (1-3 sentences) about these items:\\n`;\n if (overdue.length > 0) {\n instruction += `\\nOverdue tasks:\\n${overdue.map(t => `- ${t.title} (due: ${t.due_date}, priority: ${t.priority})`).join('\\n')}`;\n }\n if (urgent.length > 0) {\n instruction += `\\nUrgent tasks:\\n${urgent.map(t => `- ${t.title}`).join('\\n')}`;\n }\n instruction += `\\n\\nBe helpful, not annoying. If nothing seems worth messaging about, just skip this.`;\n\n items.push({\n json: {\n type: 'proactive_reminder',\n heartbeat_check_name: 'heartbeat',\n actionId: null,\n message: instruction,\n chat_id: CHAT_ID,\n user_id: userId,\n source: 'scheduled_action',\n notify_mode: 'always'\n }\n });\n }\n }\n}\n\n// NEW: Open Loop check (every ~24h, configurable). Asks the user about old unfinished intentions.\nconst olCfg = (Array.isArray(openLoopCfg) && openLoopCfg.length > 0) ? openLoopCfg[0] : null;\nconst olEnabled = olCfg ? olCfg.enabled : false;\nconst olLastRun = (olCfg && olCfg.last_run) ? new Date(olCfg.last_run) : null;\nconst olMinInterval = (olCfg && olCfg.config) ? (olCfg.config.min_interval_hours || 24) : 24;\nconst olMinAgeDays = (olCfg && olCfg.config) ? (olCfg.config.min_age_days || 3) : 3;\n\nif (olEnabled) {\n const hoursSinceOL = olLastRun ? (now.getTime() - olLastRun.getTime()) / (1000 * 60 * 60) : 999;\n if (hoursSinceOL >= olMinInterval) {\n const ageCutoff = new Date(now.getTime() - olMinAgeDays * 24 * 60 * 60 * 1000).toISOString();\n const openLoops = await pgrest(`/rest/v1/memory_long?category=eq.open_loop&created_at=lt.${ageCutoff}&or=(expires_at.is.null,expires_at.gt.${nowISO})&select=id,content,importance,created_at,expires_at,metadata&order=importance.desc,created_at.asc&limit=10`);\n // Filter out closed entries client-side (PostgREST jsonb-path filtering is awkward across versions)\n const active = (Array.isArray(openLoops) ? openLoops : []).filter(ol => {\n const m = ol.metadata || {};\n return m.closed !== true && m.closed !== 'true';\n }).slice(0, 3);\n\n if (active.length > 0) {\n const lines = active.map(ol => {\n const days = Math.max(1, Math.floor((now.getTime() - new Date(ol.created_at).getTime()) / (1000 * 60 * 60 * 24)));\n return `- (id ${ol.id}, vor ${days} Tagen) ${ol.content}`;\n }).join('\\n');\n\n const instruction = `Du fragst proaktiv beim User nach alten offenen Vorhaben. Antworte auf ${lang}. Sende EINE kurze, freundliche Nachricht (1-3 S\u00e4tze), die EINEN oder mehrere der folgenden offenen Punkte aufgreift. Sei nicht nervig \u2014 wenn ein Punkt zu trivial wirkt, lass ihn aus. Wenn alle Punkte trivial wirken, antworte nur mit dem Text [SKIP] und nichts anderem.\\n\\nWenn der User in seiner Antwort best\u00e4tigt, dass etwas erledigt/verworfen wurde, nutze memory_update auf die jeweilige id und setze metadata.closed=true (siehe Memory-Behavior-Regeln zu open_loop).\\n\\nOffene Vorhaben:\\n${lines}`;\n\n items.push({\n json: {\n type: 'proactive_reminder',\n heartbeat_check_name: 'open_loop_check',\n actionId: null,\n message: instruction,\n chat_id: CHAT_ID,\n user_id: userId,\n source: 'scheduled_action',\n notify_mode: 'always'\n }\n });\n }\n }\n}\n\nif (items.length === 0) {\n return [{ json: { type: 'no_action' } }];\n}\n\nreturn items;"
},
"id": "load-due-actions",
"name": "Load Due Actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "has-actions",
"leftValue": "={{ $json.type }}",
"rightValue": "no_action",
"operator": {
"type": "string",
"operation": "notEquals"
}
}
],
"combinator": "and"
}
},
"id": "if-has-actions",
"name": "Has Actions",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
440,
0
]
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"id": "loop-actions",
"name": "Loop Over Actions",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
660,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-on-change",
"leftValue": "={{ $json.notify_mode }}",
"rightValue": "on_change",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
}
},
"id": "route-notify-mode",
"name": "Route by Notify Mode",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
880,
0
]
},
{
"parameters": {
"workflowId": {
"__rl": true,
"value": "REPLACE_BACKGROUND_CHECKER_ID",
"mode": "id"
},
"options": {
"waitForSubWorkflow": true
}
},
"id": "exec-bg-checker",
"name": "Execute Background Checker",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.3,
"position": [
1100,
-120
]
},
{
"parameters": {
"jsCode": "// Merge checker result with original action data for routing + update\nconst checkerResult = $input.first().json;\nconst actionData = $('Route by Notify Mode').first().json;\n\nreturn [{\n json: {\n ...actionData,\n notify: checkerResult.notify || false,\n checkerMessage: checkerResult.message || ''\n }\n}];"
},
"id": "handle-checker-response",
"name": "Handle Checker Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
-120
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose"
},
"conditions": [
{
"id": "should-notify",
"leftValue": "={{ $json.notify }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
}
},
"id": "if-should-notify",
"name": "Should Notify",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1540,
-120
]
},
{
"parameters": {
"operation": "sendMessage",
"chatId": "={{ $json.chat_id }}",
"text": "={{ ($json.checkerMessage || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') }}",
"additionalFields": {
"parse_mode": "HTML"
}
},
"id": "send-telegram",
"name": "Send Telegram",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
1760,
-200
],
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"workflowId": {
"__rl": true,
"value": "REPLACE_AGENT_WORKFLOW_ID",
"mode": "id"
},
"options": {
"waitForSubWorkflow": false
}
},
"id": "exec-agent",
"name": "Execute Agent",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.3,
"position": [
1100,
120
]
},
{
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Update scheduled action: last_run, run_count, next_run\nconst SUPABASE_URL = '{{SUPABASE_URL}}';\nconst SUPABASE_KEY = '{{SUPABASE_SERVICE_KEY}}';\nconst http = this.helpers.httpRequest;\n\n// Get the action data \u2014 always original data from Should Notify\n// (Send Telegram is a dead-end, doesn't feed into Update Action)\nconst item = $input.first().json;\nconst now = new Date();\n\n// Proactive reminder: update heartbeat_config (configurable check_name to support multiple proactive triggers)\nif (item.type === 'proactive_reminder') {\n const checkName = item.heartbeat_check_name || 'heartbeat';\n await http({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/heartbeat_config?check_name=eq.${checkName}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Content-Type': 'application/json',\n 'Prefer': 'return=minimal'\n },\n body: { last_run: now.toISOString() }\n });\n return [{ json: { status: 'updated', type: 'proactive_reminder', check: checkName } }];\n}\n\n// Scheduled action: compute next_run and update\nconst actionId = item.actionId;\nconst schedule = item.schedule;\nconst tz = item.timezone || 'Europe/Berlin';\nconst runCount = (item.run_count || 0) + 1;\nconst maxRuns = item.max_runs;\n\nfunction computeNextRun(schedule, tz) {\n const now = new Date();\n\n if (schedule.type === 'interval') {\n // Subtract 30s buffer so the next heartbeat cycle reliably finds this action due\n return new Date(now.getTime() + (schedule.minutes || 60) * 60000 - 30000).toISOString();\n }\n\n if (schedule.type === 'daily') {\n const [h, m] = (schedule.time || '08:00').split(':').map(Number);\n const userNow = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const target = new Date(userNow);\n target.setHours(h, m, 0, 0);\n if (target <= userNow) {\n target.setDate(target.getDate() + 1);\n }\n const diffMs = target.getTime() - userNow.getTime();\n return new Date(now.getTime() + diffMs).toISOString();\n }\n\n if (schedule.type === 'weekly') {\n const [h, m] = (schedule.time || '09:00').split(':').map(Number);\n const days = schedule.days || [1];\n const userNow = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const jsDay = userNow.getDay();\n const currentDay = jsDay === 0 ? 7 : jsDay;\n const currentMinutes = userNow.getHours() * 60 + userNow.getMinutes();\n const targetMinutes = h * 60 + m;\n\n let minDaysAhead = 8;\n for (const d of days) {\n let ahead = d - currentDay;\n if (ahead < 0) ahead += 7;\n if (ahead === 0 && currentMinutes >= targetMinutes) ahead = 7;\n if (ahead < minDaysAhead) minDaysAhead = ahead;\n }\n\n const target = new Date(userNow);\n target.setDate(target.getDate() + minDaysAhead);\n target.setHours(h, m, 0, 0);\n const diffMs = target.getTime() - userNow.getTime();\n return new Date(now.getTime() + diffMs).toISOString();\n }\n\n return new Date(now.getTime() + 3600000).toISOString();\n}\n\nconst nextRun = computeNextRun(schedule, tz);\nconst shouldDisable = maxRuns !== null && maxRuns !== undefined && runCount >= maxRuns;\n\nawait http({\n method: 'PATCH',\n url: `${SUPABASE_URL}/rest/v1/scheduled_actions?id=eq.${actionId}`,\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Content-Type': 'application/json',\n 'Prefer': 'return=minimal'\n },\n body: {\n last_run: now.toISOString(),\n run_count: runCount,\n next_run: shouldDisable ? null : nextRun,\n enabled: !shouldDisable,\n updated_at: now.toISOString()\n }\n});\n\nreturn [{ json: { status: 'updated', actionId, runCount, nextRun: shouldDisable ? null : nextRun, disabled: shouldDisable } }];"
},
"id": "update-action",
"name": "Update Action",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1980,
0
]
},
{
"id": "cleanup-expired-files",
"name": "Cleanup Expired Files",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
480,
300
],
"parameters": {
"jsCode": "const SUPABASE_URL = '{{SUPABASE_URL}}';\nconst SUPABASE_KEY = '{{SUPABASE_SERVICE_KEY}}';\nconst BRIDGE_URL = 'http://file-bridge:3200';\n\n// 1. Cleanup expired files on File Bridge\nlet bridgeResult = { cleaned: 0 };\ntry {\n bridgeResult = await helpers.httpRequest({\n method: 'DELETE',\n url: BRIDGE_URL + '/cleanup'\n });\n} catch(e) {\n // File Bridge may not be running yet\n}\n\n// 2. Cleanup expired metadata in DB\nlet dbResult = null;\ntry {\n dbResult = await helpers.httpRequest({\n method: 'DELETE',\n url: SUPABASE_URL + '/rest/v1/file_refs?expires_at=lt.' + new Date().toISOString(),\n headers: {\n 'apikey': SUPABASE_KEY,\n 'Authorization': 'Bearer ' + SUPABASE_KEY\n }\n });\n} catch(e) {}\n\nreturn [{ json: { bridge_cleaned: bridgeResult.cleaned || 0, db_cleanup: 'done' } }];"
}
}
]
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
telegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
💓 Heartbeat. Uses telegram. Scheduled trigger; 12 nodes.
Source: https://github.com/freddy-schuetz/n8n-claw/blob/1a4991fc541ef5d7ed2654d05ac9ef1241554c5d/workflows/heartbeat.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.
Auto Hunt. Uses httpRequest, googleSheets, rssFeedRead, telegram. Scheduled trigger; 78 nodes.
Auto-Hunt. Uses httpRequest, googleSheets, rssFeedRead, telegram. Scheduled trigger; 78 nodes.
Solo founders and spreadsheet gremlins who track everything in Notion and want crisp Telegram pings without opening a single page.
This workflow runs daily at 9 AM, uses Perplexity to compile vaping industry news, optionally captures article screenshots via Browserless, and generates a HeyGen avatar video from a template. It then
A robust workflow to back up and synchronize your n8n workflows to a GitHub repository, with intelligent change detection and support for file renames.