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": "Triple Pendulum Bot",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "seconds",
"secondsInterval": 30
}
]
}
},
"id": "b0000001-0001-0001-0001-000000000001",
"name": "Every 30s",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
240,
300
]
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// ---- CONFIG (placeholders, replaced by sync to live n8n) ----\nconst TELEGRAM_BOT_TOKEN = 'YOUR_TELEGRAM_BOT_TOKEN';\nconst ALLOWED_CHAT_IDS = [935389847];\nconst LAUNCHER_URL = 'http://10.1.4.232:8765';\nconst LAUNCHER_SECRET = 'YOUR_LAUNCHER_SECRET';\nconst MLFLOW_URL = 'http://10.1.4.230:5000';\nconst RUNPOD_API_KEY = 'YOUR_RUNPOD_API_KEY';\nconst RUNPOD_POD_ID = 'YOUR_RUNPOD_POD_ID';\n\n// HTML escape \u2014 Telegram parse_mode HTML treats <,>,& specially\nfunction esc(s) {\n if (s == null) return '';\n return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');\n}\n\nasync function tgSend(chat_id, text) {\n return await this.helpers.httpRequest({\n method: 'POST',\n url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,\n body: { chat_id, text, parse_mode: 'HTML', disable_web_page_preview: true },\n json: true,\n timeout: 10000,\n failOnNotOk: false,\n });\n}\n\n// ---- POLL TELEGRAM getUpdates (long-poll) ----\nconst staticData = $getWorkflowStaticData('global');\nconst lastOffset = staticData.lastOffset || 0;\n\nlet updates = [];\ntry {\n const r = await this.helpers.httpRequest({\n method: 'GET',\n url: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates`,\n qs: { offset: lastOffset + 1, timeout: 25, allowed_updates: 'message' },\n json: true,\n timeout: 30000,\n });\n if (r.ok && Array.isArray(r.result)) updates = r.result;\n} catch (e) {\n return [];\n}\n\nif (updates.length === 0) return [];\nstaticData.lastOffset = Math.max(...updates.map(u => u.update_id));\n\n// ---- DATA HELPERS ----\nasync function gqlRunPod(query) {\n return await this.helpers.httpRequest({\n method: 'POST',\n url: 'https://api.runpod.io/graphql',\n headers: { 'Authorization': `Bearer ${RUNPOD_API_KEY}`, 'Content-Type': 'application/json' },\n body: { query },\n json: true,\n timeout: 8000,\n failOnNotOk: false,\n });\n}\n\nasync function getRunPodStatus() {\n try {\n const q = `query { pod(input: {podId: \"${RUNPOD_POD_ID}\"}) { id name desiredStatus costPerHr runtime { uptimeInSeconds gpus { gpuUtilPercent memoryUtilPercent } } } }`;\n const r = await gqlRunPod.call(this, q);\n return r.data?.pod;\n } catch (e) { return { _error: String(e).slice(0,200) }; }\n}\n\nasync function getRunPodBalance() {\n try {\n const r = await gqlRunPod.call(this, `query { myself { clientBalance } }`);\n return r.data?.myself?.clientBalance;\n } catch (e) { return null; }\n}\n\nasync function getLauncherStatus() {\n try {\n return await this.helpers.httpRequest({ url: `${LAUNCHER_URL}/status`, json: true, timeout: 5000, failOnNotOk: false });\n } catch (e) { return { _error: 'launcher unreachable' }; }\n}\n\nasync function getMLflowExperimentIds() {\n try {\n const r = await this.helpers.httpRequest({\n url: `${MLFLOW_URL}/api/2.0/mlflow/experiments/search`,\n method: 'POST',\n body: { max_results: 50, view_type: 'ALL' },\n json: true,\n timeout: 5000,\n failOnNotOk: false,\n });\n return (r.experiments || []).map(e => e.experiment_id);\n } catch (e) { return ['1','2']; }\n}\n\nasync function getMLflowRecent(n=3) {\n try {\n const ids = await getMLflowExperimentIds.call(this);\n if (!ids.length) return [];\n const r = await this.helpers.httpRequest({\n method: 'POST',\n url: `${MLFLOW_URL}/api/2.0/mlflow/runs/search`,\n body: { experiment_ids: ids, max_results: n, order_by: ['start_time DESC'] },\n json: true,\n timeout: 5000,\n failOnNotOk: false,\n });\n return r.runs || [];\n } catch (e) { return []; }\n}\n\n// ---- FORMATTERS (HTML) ----\nfunction fmtUptime(s) {\n if (!s) return '0s';\n const h = Math.floor(s/3600), m = Math.floor((s%3600)/60), x = s%60;\n return h ? `${h}h${String(m).padStart(2,'0')}m` : (m ? `${m}m${x}s` : `${x}s`);\n}\n\nfunction fmtETA(seconds) {\n if (!seconds || seconds < 0 || !isFinite(seconds)) return '?';\n if (seconds < 60) return `${Math.round(seconds)}s`;\n const h = Math.floor(seconds/3600);\n const m = Math.round((seconds%3600)/60);\n return h ? `${h}h${m}m` : `${m}m`;\n}\n\nfunction fmtRunPod(pod) {\n if (!pod) return 'pod not found';\n if (pod._error) return `error: ${esc(pod._error)}`;\n const rt = pod.runtime;\n if (!rt) return `pod <b>${esc(pod.desiredStatus)}</b> (no runtime)`;\n const g = rt.gpus?.[0] || {};\n const cost = (rt.uptimeInSeconds * pod.costPerHr / 3600).toFixed(2);\n return `<b>${esc(pod.name)}</b> ${esc(pod.desiredStatus)}\\n` +\n ` uptime: ${fmtUptime(rt.uptimeInSeconds)} cost: $${cost}\\n` +\n ` gpu: ${g.gpuUtilPercent ?? '?'}% mem: ${g.memoryUtilPercent ?? '?'}%`;\n}\n\nfunction fmtMLflowRuns(runs) {\n if (!runs.length) return '<i>no recent runs</i>';\n return runs.map(r => {\n const m = (r.data?.metrics || []).reduce((a,x) => (a[x.key]=x.value, a), {});\n const p = (r.data?.params || []).reduce((a,x) => (a[x.key]=x.value, a), {});\n const name = (r.info.run_name || '?').slice(0,50);\n const sts = r.info.status;\n const ts = m.timesteps;\n const total = parseFloat(p.total_timesteps);\n const sps = m.steps_per_s;\n const rew = m.rollout_ep_rew_mean;\n const succ = m.final_overall_success_rate;\n const dev = p.device;\n let line = `<code>${esc(name)}</code> ${esc(sts)}`;\n if (ts != null && total > 0) {\n const pct = (ts / total * 100).toFixed(0);\n line += ` ${(ts/1000).toFixed(0)}K/${(total/1e6).toFixed(1)}M (${pct}%)`;\n if (sts === 'RUNNING' && sps > 0) line += ` ETA ${fmtETA((total - ts) / sps)}`;\n } else if (ts != null) {\n line += ` ${(ts/1000).toFixed(0)}K steps`;\n }\n if (rew != null) line += ` rew=${rew.toFixed(0)}`;\n if (succ != null) line += ` <b>succ=${(succ*100).toFixed(0)}%</b>`;\n if (dev && dev !== '?') line += ` [${esc(dev)}]`;\n return ' ' + line;\n }).join('\\n');\n}\n\n// ---- COMMAND DISPATCH ----\nasync function handleCommand(message) {\n const chat_id = message.chat?.id;\n // Silent drop on unauthorized \u2014 do NOT echo (avoids leaking bot identity)\n if (!ALLOWED_CHAT_IDS.includes(chat_id)) return null;\n\n const text = (message.text || '').trim();\n const tokens = text.split(/\\s+/);\n const cmd = tokens[0].toLowerCase();\n const args = tokens.slice(1);\n let reply = '';\n\n if (cmd === '/start' || cmd === '/help') {\n reply = `<b>Triple Pendulum Bot</b> \u2014 commands:\\n\\n` +\n `<code>/status</code> \u2014 overall pipeline\\n` +\n `<code>/runpod</code> \u2014 pod state + GPU + cost\\n` +\n `<code>/ct1018</code> \u2014 local launcher sessions\\n` +\n `<code>/mlflow [n]</code> \u2014 last n MLflow runs\\n` +\n `<code>/gpu</code> \u2014 GPU one-liner\\n` +\n `<code>/cost</code> \u2014 current cost + RunPod balance\\n` +\n `<code>/launch <m2|m3b|m3c|m4></code> \u2014 launch on CT 1018\\n` +\n `<code>/kill ct1018|runpod</code> \u2014 kill running training\\n` +\n `<code>/pod_start</code> \u2014 resume RunPod pod\\n` +\n `<code>/pod_stop confirm</code> \u2014 stop RunPod pod (requires <code>confirm</code>)\\n` +\n `<code>/help</code> \u2014 this message`;\n }\n else if (cmd === '/status') {\n const [pod, launcher, runs] = await Promise.all([\n getRunPodStatus.call(this), getLauncherStatus.call(this), getMLflowRecent.call(this, 3),\n ]);\n reply = `<b>Triple Pendulum status</b>\\n\\n` +\n `\ud83c\udf10 <b>RunPod</b>\\n${fmtRunPod(pod)}\\n\\n` +\n `\ud83d\udda5 <b>CT 1018 launcher</b>\\n ${launcher._error ? esc(launcher._error) : `${launcher.count || 0} active processes`}\\n\\n` +\n `\ud83d\udcca <b>MLflow</b> (latest)\\n${fmtMLflowRuns(runs)}`;\n }\n else if (cmd === '/runpod') {\n const pod = await getRunPodStatus.call(this);\n reply = `\ud83c\udf10 <b>RunPod</b>\\n${fmtRunPod(pod)}`;\n }\n else if (cmd === '/ct1018') {\n const launcher = await getLauncherStatus.call(this);\n if (launcher._error) reply = `\ud83d\udda5 <b>CT 1018</b> \u2014 ${esc(launcher._error)}`;\n else {\n const sessions = (launcher.sessions || []).slice(0,3).map(s => ` \u2022 <code>${esc(s.slice(0,80))}</code>`).join('\\n');\n reply = `\ud83d\udda5 <b>CT 1018</b> \u2014 ${launcher.count} active\\n${sessions || ' <i>none</i>'}`;\n }\n }\n else if (cmd === '/mlflow') {\n const n = parseInt(args[0]) || 3;\n const runs = await getMLflowRecent.call(this, Math.min(Math.max(n, 1), 10));\n reply = `\ud83d\udcca <b>MLflow</b> \u2014 last ${runs.length}\\n${fmtMLflowRuns(runs)}`;\n }\n else if (cmd === '/gpu') {\n const pod = await getRunPodStatus.call(this);\n if (!pod || pod._error) reply = `RunPod: ${esc(pod?._error || 'not reachable')}`;\n else {\n const g = pod.runtime?.gpus?.[0];\n reply = g ? `GPU ${g.gpuUtilPercent}% util / ${g.memoryUtilPercent}% mem` : 'no runtime';\n }\n }\n else if (cmd === '/cost') {\n const [pod, balance] = await Promise.all([getRunPodStatus.call(this), getRunPodBalance.call(this)]);\n let podPart = 'RunPod: not reachable';\n if (pod && !pod._error) {\n const cost = pod.runtime ? (pod.runtime.uptimeInSeconds * pod.costPerHr / 3600) : 0;\n podPart = `<b>${esc(pod.name)}</b> ${esc(pod.desiredStatus)}\\n current run cost: $${cost.toFixed(2)} (\\$${pod.costPerHr}/hr)`;\n }\n const balPart = balance != null ? `\\n\\n\ud83d\udcb3 RunPod balance: <b>$${balance.toFixed(2)}</b>` : '';\n reply = `\ud83d\udcb0 <b>Cost</b>\\n${podPart}${balPart}`;\n }\n else if (cmd === '/launch') {\n const stage = (args[0] || '').toLowerCase();\n const stageMap = {\n 'm2': { module: 'training.train_m2_upright', config: 'training/configs/m2_upright_tqc.yaml' },\n 'm3b': { module: 'training.train_m3_all_eps', config: 'training/configs/m3b_all_eps_tqc.yaml' },\n 'm3c': { module: 'training.train_m3_all_eps', config: 'training/configs/m3c_all_eps_tqc.yaml' },\n 'm4': { module: 'training.train_m4_transitions', config: 'training/configs/m4_transitions_tqc.yaml' },\n };\n if (!stageMap[stage]) {\n reply = `Unknown stage: <code>${esc(stage)}</code>\\nValid: m2, m3b, m3c, m4`;\n } else {\n // Ack immediately so user knows the request was received\n await tgSend.call(this, chat_id, `\u23f3 Launching <b>${stage.toUpperCase()}</b> on CT 1018...`);\n try {\n const r = await this.helpers.httpRequest({\n method: 'POST',\n url: `${LAUNCHER_URL}/launch`,\n body: { secret: LAUNCHER_SECRET, ...stageMap[stage] },\n json: true,\n timeout: 15000,\n failOnNotOk: false,\n returnFullResponse: true,\n });\n const body = r.body || r;\n if (body.ok) {\n reply = `\u2705 Launched <b>${esc(stage.toUpperCase())}</b>\\nsession: <code>${esc(body.session)}</code>`;\n } else {\n const code = r.statusCode || 'err';\n reply = `\u274c Launch failed (HTTP ${code})\\n${esc(body.error || JSON.stringify(body))}`;\n if (body.sessions) reply += `\\n<i>active:</i>\\n` + body.sessions.slice(0,2).map(s => ` \u2022 <code>${esc(s.slice(0,80))}</code>`).join('\\n');\n }\n } catch (e) {\n reply = `\u274c Launcher error: ${esc(String(e).slice(0,200))}`;\n }\n }\n }\n else if (cmd === '/kill') {\n const target = (args[0] || '').toLowerCase();\n if (target === 'ct1018') {\n await tgSend.call(this, chat_id, `\u26a0\ufe0f Killing CT 1018 training...`);\n try {\n const r = await this.helpers.httpRequest({\n method: 'POST',\n url: `${LAUNCHER_URL}/kill`,\n body: { secret: LAUNCHER_SECRET },\n json: true,\n timeout: 10000,\n failOnNotOk: false,\n returnFullResponse: true,\n });\n const body = r.body || r;\n if (body.ok) {\n reply = `\ud83d\uded1 CT 1018 killed\\n sessions: ${(body.killed_sessions||[]).join(', ') || '<i>none</i>'}\\n pids: ${(body.killed_pids||[]).join(', ') || '<i>none</i>'}`;\n } else {\n reply = `\u274c Kill failed: ${esc(body.error || JSON.stringify(body))}`;\n }\n } catch (e) {\n reply = `\u274c Kill error: ${esc(String(e).slice(0,200))}`;\n }\n } else if (target === 'runpod') {\n await tgSend.call(this, chat_id, `\u26a0\ufe0f Stopping RunPod pod (kills training)...`);\n try {\n const r = await gqlRunPod.call(this, `mutation { podStop(input: {podId: \"${RUNPOD_POD_ID}\"}) { id desiredStatus } }`);\n const s = r.data?.podStop?.desiredStatus;\n reply = s ? `\ud83d\uded1 RunPod stopped: <b>${esc(s)}</b>` : `\u274c stop failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n } else {\n reply = `<code>/kill ct1018</code> or <code>/kill runpod</code>\\n` +\n `<i>ct1018 sends TERM to local tmux + python; runpod stops the pod.</i>`;\n }\n }\n else if (cmd === '/pod_start') {\n try {\n const r = await gqlRunPod.call(this, `mutation { podResume(input: {podId: \"${RUNPOD_POD_ID}\", gpuCount: 1}) { id desiredStatus } }`);\n const s = r.data?.podResume?.desiredStatus;\n reply = s ? `\ud83c\udf10 RunPod resumed: <b>${esc(s)}</b>` : `\u274c resume failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n }\n else if (cmd === '/pod_stop') {\n if (args[0] !== 'confirm') {\n reply = `\u26a0\ufe0f <b>/pod_stop</b> stops the RunPod pod and KILLS any running training.\\n\\n` +\n `To confirm: <code>/pod_stop confirm</code>`;\n } else {\n try {\n const r = await gqlRunPod.call(this, `mutation { podStop(input: {podId: \"${RUNPOD_POD_ID}\"}) { id desiredStatus } }`);\n const s = r.data?.podStop?.desiredStatus;\n reply = s ? `\ud83d\uded1 RunPod stopped: <b>${esc(s)}</b>` : `\u274c stop failed: ${esc(JSON.stringify(r).slice(0,200))}`;\n } catch (e) { reply = `\u274c ${esc(String(e).slice(0,200))}`; }\n }\n }\n else {\n reply = `Unknown command: <code>${esc(cmd)}</code>\\nTry <code>/help</code>`;\n }\n return { chat_id, text: reply };\n}\n\nconst items = [];\nfor (const upd of updates) {\n const msg = upd.message;\n if (!msg || !msg.text) continue;\n const r = await handleCommand.call(this, msg);\n if (!r) continue;\n items.push({ json: { ...r, parse_mode: 'HTML', disable_web_page_preview: true, telegramBotUrl: `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage` } });\n}\nreturn items;\n"
},
"id": "b0000002-0002-0002-0002-000000000002",
"name": "Poll + Dispatch",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
480,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $json.telegramBotUrl }}",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ chat_id: $json.chat_id, text: $json.text, parse_mode: $json.parse_mode, disable_web_page_preview: true }) }}",
"options": {
"timeout": 10000
}
},
"id": "b0000003-0003-0003-0003-000000000003",
"name": "Send Reply",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
720,
300
]
}
],
"connections": {
"Every 30s": {
"main": [
[
{
"node": "Poll + Dispatch",
"type": "main",
"index": 0
}
]
]
},
"Poll + Dispatch": {
"main": [
[
{
"node": "Send Reply",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "workflowsFromSameOwner",
"availableInMCP": false
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Triple Pendulum Bot. Uses httpRequest. Scheduled trigger; 3 nodes.
Source: https://github.com/fawraw/triple-pendulum-sim2real/blob/main/n8n/triple_pendulum_bot.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.
As n8n instances scale, teams often lose track of sub-workflows—who uses them, where they are referenced, and whether they can be safely updated. This leads to inefficiencies like unnecessary copies o
This workflow is an improvement of this workflow by Greg Brzezinka.
N8N-Workflow-Github-Manager. Uses github, httpRequest, n8n. Scheduled trigger; 38 nodes.
This workflow uses KlickTipp community nodes, available for self-hosted n8n instances only.
This workflow acts as an automated engagement bot. It sends a Direct Message (DM) with a link or resource to any follower who replies to your post with a specific target keyword.