LLM-powered Telegram bot: safe tool calling with Node.js without exposing the bot token 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. 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. Start 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. npm init -y npm i telegraf openai dotenv Create a .env file that holds only the values the server needs: TELEGRAM BOT TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 OPENAI API KEY=sk-... Now 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. js // bot-unsafe.js require 'dotenv' .config ; const { Telegraf } = require 'telegraf' ; const { Configuration, OpenAIApi } = require 'openai' ; const bot = new Telegraf process.env.TELEGRAM BOT TOKEN ; const openai = new OpenAIApi new Configuration { apiKey: process.env.OPENAI API KEY } ;\n // Dangerous: we give the model the bot token in the instructions const 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. ; bot.on 'text', async ctx = { const userText = ctx.message.text; try { const completion = await openai.createChatCompletion { model: 'gpt-4o-mini', messages: { role: 'system', content: SYSTEM PROMPT }, { role: 'user', content: userText } , temperature: 0 } ; const reply = completion.data.choices 0 .message.content; // Assume the model returned a tool call in the format we described let toolCall; try { toolCall = JSON.parse reply ; } catch { await ctx.reply 'I did not understand the request.' ; return; } if toolCall.tool calls || Array.isArray toolCall.tool calls { await ctx.reply 'No tool call was returned.' ; return; } for const call of toolCall.tool calls { // No validation of call.name – we just try to run it if call.name === 'get order' { const args = JSON.parse call.arguments ; const result = await getOrder args.order id ; // function defined later await ctx.reply JSON.stringify result ; } else if call.name === 'create booking' { const args = JSON.parse call.arguments ; const result = await createBooking args ; await ctx.reply JSON.stringify result ; } else { // If the model hallucinated a tool name we still try to call it // This is where an attacker could invoke arbitrary code await ctx.reply Unknown tool: ${call.name} ; } } } catch err { console.error err ; await ctx.reply 'Something went wrong.' ; } } ; bot.launch ; // Stub implementations – in a real app they would talk to a DB async function getOrder orderId { return { order id: orderId, status: 'shipped' }; } async function createBooking data { return { booking id: Math.random .toString 36 .substr 2,9 , ...data }; } What goes wrong 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. The 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. First, install zod for runtime validation optional but helpful . npm i zod Now create a file tools.js that exports the schema and the handler functions. js // tools.js const { z } = require 'zod' ; // ---------- Tool schemas ---------- const GetOrderSchema = z.object { order id: z.string .regex /^ A-Z0-9 {6,12}$/i } ; const CreateBookingSchema = z.object { customer name: z.string .min 1 .max 100 , service: z.enum 'haircut', 'manicure', 'massage' , datetime: z.string .datetime { offset: true } // ISO 8601 with timezone } ; // Expose a union that the LLM can pick from const ToolDefinition = z.union z.object { name: z.literal 'get order' , arguments: GetOrderSchema } , z.object { name: z.literal 'create booking' , arguments: CreateBookingSchema } ; // ---------- Handler implementations ---------- async function getOrder args { // In production: query your DB, check ownership, etc. // For demo we just return a static object return { order id: args.order id, status: 'processed' }; } async function createBooking args { // Idempotency key: hash of the essential fields const idempotencyKey = require 'crypto' .createHash 'sha256' .update ${args.customer name}|${args.service}|${args.datetime} .digest 'hex' ; // Pretend we store it in Redis with a short TTL to avoid duplicates // if await redis.get idempotencyKey { return { error: 'duplicate' }; } // await redis.set idempotencyKey, '1', 'EX', 60 ; // Insert into DB here return { booking id: require 'crypto' .randomBytes 7 .toString 'hex' , ...args }; } module.exports = { GetOrderSchema, CreateBookingSchema, ToolDefinition, getOrder, createBooking }; Why this prevents the earlier failure 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. Now we rewrite the bot to use the schema. The flow is: ToolDefinition . Here is the complete, production‑ready bot. js // bot-safe.js require 'dotenv' .config ; const { Telegraf } = require 'telegraf' ; const { Configuration, OpenAIApi } = require 'openai' ; const { ToolDefinition, getOrder, createBooking } = require './tools' ; const bot = new Telegraf process.env.TELEGRAM BOT TOKEN ; const openai = new OpenAIApi new Configuration { apiKey: process.env.OPENAI API KEY } ; // System prompt that tells the model what tools exist, but hides the token const 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\":