{"slug": "llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot", "title": "LLM-powered Telegram bot: safe tool calling with Node.js without exposing the bot token", "summary": "A developer published a Node.js guide showing how to build a Telegram bot that lets an LLM select tools while keeping the bot token server-side and validating tool calls against a strict schema. The writeup contrasts an unsafe implementation, which embeds the Telegram token in the system prompt and executes any tool name the model returns, with a hardened version using telegraf, openai and dotenv.", "body_md": "Telegram bots that rely on a large language model to decide which action to take are becoming common for booking, support, or e‑commerce flows. The model receives the user’s text, chooses a tool such as `get_order` or `create_booking`, and the bot executes the corresponding Node.js function before returning a final answer. If the bot gives the model access to its Telegram token or skips validation of the tool calls, an attacker can manipulate the model to leak credentials or trigger unwanted actions. This guide walks through a complete Node.js implementation that keeps the token server‑side, defines a strict tool schema, and shows the failure cases that appear when those safeguards are omitted.\n\nStart with a fresh folder and install the dependencies we need: `telegraf` for the Telegram Bot API, `openai` (or any LLM provider) for chatting with the model, and `dotenv` to keep secrets out of source.\n\n```\nnpm init -y\nnpm i telegraf openai dotenv\n```\n\nCreate a `.env` file that holds only the values the server needs:\n\n```\nTELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11\nOPENAI_API_KEY=sk-...\n```\n\nNow write a minimal bot that forwards every message to the model and blindly executes whatever tool name the model returns. This is the *failing* version: the bot puts the Telegram token into the system prompt so the model can “see” it, and it does not check that the tool name belongs to an allowed list.\n\n``` js\n// bot-unsafe.js\nrequire('dotenv').config();\nconst { Telegraf } = require('telegraf');\nconst { Configuration, OpenAIApi } = require('openai');\n\nconst bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN);\nconst openai = new OpenAIApi(new Configuration({ apiKey: process.env.OPENAI_API_KEY }));\\n\n// Dangerous: we give the model the bot token in the instructions\nconst SYSTEM_PROMPT = `You are a helpful assistant. You have access to two tools: get_order and create_booking.\\nIf you need to call a tool, output a JSON object with the key \\\"tool_calls\\\" containing an array of objects. Each object must have \\\"name\\\" (the tool name) and \\\"arguments\\\" (a JSON string).\\nYou also have the bot token: ${process.env.TELEGRAM_BOT_TOKEN}. Use it only if the tool requires it.`;\n\nbot.on('text', async (ctx) => {\n  const userText = ctx.message.text;\n  try {\n    const completion = await openai.createChatCompletion({\n      model: 'gpt-4o-mini',\n      messages: [\n        { role: 'system', content: SYSTEM_PROMPT },\n        { role: 'user', content: userText }\n      ],\n      temperature: 0\n    });\n    const reply = completion.data.choices[0].message.content;\n    // Assume the model returned a tool call in the format we described\n    let toolCall;\n    try {\n      toolCall = JSON.parse(reply);\n    } catch (_) {\n      await ctx.reply('I did not understand the request.');\n      return;\n    }\n    if (!toolCall.tool_calls || !Array.isArray(toolCall.tool_calls)) {\n      await ctx.reply('No tool call was returned.');\n      return;\n    }\n    for (const call of toolCall.tool_calls) {\n      // No validation of call.name – we just try to run it\n      if (call.name === 'get_order') {\n        const args = JSON.parse(call.arguments);\n        const result = await getOrder(args.order_id); // function defined later\n        await ctx.reply(JSON.stringify(result));\n      } else if (call.name === 'create_booking') {\n        const args = JSON.parse(call.arguments);\n        const result = await createBooking(args);\n        await ctx.reply(JSON.stringify(result));\n      } else {\n        // If the model hallucinated a tool name we still try to call it\n        // This is where an attacker could invoke arbitrary code\n        await ctx.reply(`Unknown tool: ${call.name}`);\n      }\n    }\n  } catch (err) {\n    console.error(err);\n    await ctx.reply('Something went wrong.');\n  }\n});\n\nbot.launch();\n\n// Stub implementations – in a real app they would talk to a DB\nasync function getOrder(orderId) { return { order_id: orderId, status: 'shipped' }; }\nasync function createBooking(data) { return { booking_id: Math.random().toString(36).substr(2,9), ...data }; }\n```\n\n**What goes wrong**\n\n`call.name` is one of the allowed tools. If the model is prompted to output a name like `sendMessage` (a real Telegram method) the bot will try to execute it, potentially causing unwanted side effects.\nThe fix is to treat the LLM as a *planner* only: it decides which tool to call and supplies the arguments, but it never sees the bot token or any internal secrets. We also give the model a JSON schema that limits the tool names and validates the argument shapes.\n\nFirst, install `zod` for runtime validation (optional but helpful).\n\n```\nnpm i zod\n```\n\nNow create a file `tools.js` that exports the schema and the handler functions.\n\n``` js\n// tools.js\nconst { z } = require('zod');\n\n// ---------- Tool schemas ----------\nconst GetOrderSchema = z.object({\n  order_id: z.string().regex(/^[A-Z0-9]{6,12}$/i)\n});\n\nconst CreateBookingSchema = z.object({\n  customer_name: z.string().min(1).max(100),\n  service: z.enum(['haircut', 'manicure', 'massage']),\n  datetime: z.string().datetime({ offset: true }) // ISO 8601 with timezone\n});\n\n// Expose a union that the LLM can pick from\nconst ToolDefinition = z.union([\n  z.object({ name: z.literal('get_order'), arguments: GetOrderSchema }),\n  z.object({ name: z.literal('create_booking'), arguments: CreateBookingSchema })\n]);\n\n// ---------- Handler implementations ----------\nasync function getOrder(args) {\n  // In production: query your DB, check ownership, etc.\n  // For demo we just return a static object\n  return { order_id: args.order_id, status: 'processed' };\n}\n\nasync function createBooking(args) {\n  // Idempotency key: hash of the essential fields\n  const idempotencyKey = require('crypto')\n    .createHash('sha256')\n    .update(`${args.customer_name}|${args.service}|${args.datetime}`)\n    .digest('hex');\n  // Pretend we store it in Redis with a short TTL to avoid duplicates\n  // if (await redis.get(idempotencyKey)) { return { error: 'duplicate' }; }\n  // await redis.set(idempotencyKey, '1', 'EX', 60);\n  // Insert into DB here\n  return {\n    booking_id: require('crypto').randomBytes(7).toString('hex'),\n    ...args\n  };\n}\n\nmodule.exports = { GetOrderSchema, CreateBookingSchema, ToolDefinition, getOrder, createBooking };\n```\n\n**Why this prevents the earlier failure**\n\n`name` field to exactly `get_order` or `create_booking`. Any other string causes validation to fail before we attempt to execute anything.`datetime` must be a proper ISO‑8601 string, stopping the model from injecting malformed data that could cause SQL injection or other errors.\nNow we rewrite the bot to use the schema. The flow is:\n\n`ToolDefinition`.\nHere is the complete, production‑ready bot.\n\n``` js\n// bot-safe.js\nrequire('dotenv').config();\nconst { Telegraf } = require('telegraf');\nconst { Configuration, OpenAIApi } = require('openai');\nconst { ToolDefinition, getOrder, createBooking } = require('./tools');\n\nconst bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN);\nconst openai = new OpenAIApi(new Configuration({ apiKey: process.env.OPENAI_API_KEY }));\n\n// System prompt that tells the model what tools exist, but hides the token\nconst SYSTEM_PROMPT = `You are a helpful booking assistant. You have access to two tools:\\n- get_order: retrieves the status of an order. Argument: { order_id: string }\\n- create_booking: creates a new salon booking. Arguments: {\\n    customer_name: string (1‑100 chars),\\n    service: one of \\\"haircut\\\", \\\"manicure\\\", \\\"massage\\\",\\n    datetime: ISO 8601 string with timezone\\n  }\\nWhen you need to use a tool, reply with a JSON object that matches the following shape:\\n{\\n  \\\"name\\\": \\\"get_order\\\" | \\\"create_booking\\\",\\n  \\\"arguments\\\": <object matching the tool\\'s argument schema>\\n}\\nIf no tool is needed, answer the user directly in plain text.`;\n\n// Helper to call the LLM and get a validated tool call\nasync function askModelForTool(userText) {\n  const completion = await openai.createChatCompletion({\n    model: 'gpt-4o-mini',\n    messages: [\n      { role: 'system', content: SYSTEM_PROMPT },\n      { role: 'user', content: userText }\n    ],\n    temperature: 0,\n    // We ask the model to output JSON only\n    response_format: { type: 'json_object' }\n  });\n  const raw = completion.data.choices[0].message.content;\n  let parsed;\n  try {\n    parsed = JSON.parse(raw);\n  } catch (e) {\n    throw new Error('Model did not return valid JSON');\n  }\n  // Validate against our union schema\n  const result = ToolDefinition.safeParse(parsed);\n  if (!result.success) {\n    throw new Error(`Model output invalid: ${result.error.message}`);\n  }\n  return result.data; // { name, arguments }\n}\n\nbot.on('text', async (ctx) => {\n  const userText = ctx.message.text;\n  try {\n    const toolCall = await askModelForTool(userText);\n    let toolResult;\n    switch (toolCall.name) {\n      case 'get_order':\n        toolResult = await getOrder(toolCall.arguments);\n        break;\n      case 'create_booking':\n        toolResult = await createBooking(toolCall.arguments);\n        break;\n      default:\n        // This should never happen because of the schema check\n        throw new Error(`Unknown tool ${toolCall.name}`);\n    }\n    // Now ask the model to turn the tool result into a natural reply\n    const completion2 = await openai.createChatCompletion({\n      model: 'gpt-4o-mini',\n      messages: [\n        { role: 'system', content: SYSTEM_PROMPT },\n        { role: 'user', content: userText },\n        { role: 'assistant', content: JSON.stringify({ name: toolCall.name, arguments: toolCall.arguments }) },\n        { role: 'tool', content: JSON.stringify(toolResult) }\n      ],\n      temperature: 0.7\n    });\n    const finalReply = completion2.data.choices[0].message.content;\n    await ctx.reply(finalReply, { parse_mode: 'HTML' });\n  } catch (err) {\n    console.error(err);\n    await ctx.reply('Sorry, I could not process that request. Please try again.');\n  }\n});\n\nbot.launch();\nconsole.log('Bot is running');\n```\n\n**Production notes**\n\n`Telegraf` constructor and in environment variables. It is never included in any prompt sent to the LLM.`update_id` in a set or database; ignore updates whose ID has already been processed. This prevents a network glitch from causing the same user message to be handled twice.`p-limit` to queue outgoing HTTP requests and respond with 429‑aware back‑off.`parse_mode: 'HTML'` run the result through `escapeHtml` or a similar utility.`tools.js` with a new Zod schema, implement the handler, and update the system prompt description. The validation step guarantees the model cannot call an undefined tool.\nBy keeping the bot token strictly server‑side, validating every tool call against a strict schema, and handling idempotency and replays, you obtain a reliable LLM‑driven Telegram agent that can safely take actions like fetching orders or creating bookings without exposing credentials or allowing unintended behavior.\n\n[BotCreator](https://botservice.biz) — studio that ships Telegram bots / Mini Apps.\n\nFurther reading:", "url": "https://wpnews.pro/news/llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot", "canonical_source": "https://dev.to/serhii_a9c08345ac360cf5c8/llm-powered-telegram-bot-safe-tool-calling-with-nodejs-without-exposing-the-bot-token-1715", "published_at": "2026-10-01 07:05:33+00:00", "updated_at": "2026-10-01 07:14:14.787006+00:00", "lang": "en", "topics": ["ai-agents", "large-language-models", "ai-tools", "developer-tools"], "entities": ["Telegram", "Node.js", "telegraf", "OpenAI", "GPT-4o-mini"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot", "markdown": "https://wpnews.pro/news/llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot.md", "text": "https://wpnews.pro/news/llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot.txt", "jsonld": "https://wpnews.pro/news/llm-powered-telegram-bot-safe-tool-calling-with-node-js-without-exposing-the-bot.jsonld"}}