AutomationFlowsWeb Scraping › Triple Pendulum Bot

Triple Pendulum Bot

Triple Pendulum Bot. Uses httpRequest. Scheduled trigger; 3 nodes.

Cron / scheduled trigger★★★★☆ complexity3 nodesHTTP Request
Web Scraping Trigger: Cron / scheduled Nodes: 3 Complexity: ★★★★☆ Added:

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": "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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');\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 &lt;m2|m3b|m3c|m4&gt;</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
  }
}
Pro

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 →

More Web Scraping workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Web Scraping

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

HTTP Request, n8n, N8N Trigger +1
Web Scraping

This workflow is an improvement of this workflow by Greg Brzezinka.

HTTP Request, Email Send, XML +1
Web Scraping

N8N-Workflow-Github-Manager. Uses github, httpRequest, n8n. Scheduled trigger; 38 nodes.

GitHub, HTTP Request, n8n
Web Scraping

This workflow uses KlickTipp community nodes, available for self-hosted n8n instances only.

N8N Nodes Klicktipp, Salesforce, Salesforce Trigger +1
Web Scraping

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.

HTTP Request