cd /news/ai-agents/llm-powered-telegram-bot-safe-tool-c… · home › topics › ai-agents › article
[ARTICLE · art-143053] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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.

by read7 min views5 publishedOct 1, 2026

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.

// 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.

// 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.

// 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\": <object matching the tool\'s argument schema>\n}\nIf no tool is needed, answer the user directly in plain text.`;

// Helper to call the LLM and get a validated tool call
async function askModelForTool(userText) {
  const completion = await openai.createChatCompletion({
    model: 'gpt-4o-mini',
    messages: [
      { role: 'system', content: SYSTEM_PROMPT },
      { role: 'user', content: userText }
    ],
    temperature: 0,
    // We ask the model to output JSON only
    response_format: { type: 'json_object' }
  });
  const raw = completion.data.choices[0].message.content;
  let parsed;
  try {
    parsed = JSON.parse(raw);
  } catch (e) {
    throw new Error('Model did not return valid JSON');
  }
  // Validate against our union schema
  const result = ToolDefinition.safeParse(parsed);
  if (!result.success) {
    throw new Error(`Model output invalid: ${result.error.message}`);
  }
  return result.data; // { name, arguments }
}

bot.on('text', async (ctx) => {
  const userText = ctx.message.text;
  try {
    const toolCall = await askModelForTool(userText);
    let toolResult;
    switch (toolCall.name) {
      case 'get_order':
        toolResult = await getOrder(toolCall.arguments);
        break;
      case 'create_booking':
        toolResult = await createBooking(toolCall.arguments);
        break;
      default:
        // This should never happen because of the schema check
        throw new Error(`Unknown tool ${toolCall.name}`);
    }
    // Now ask the model to turn the tool result into a natural reply
    const completion2 = await openai.createChatCompletion({
      model: 'gpt-4o-mini',
      messages: [
        { role: 'system', content: SYSTEM_PROMPT },
        { role: 'user', content: userText },
        { role: 'assistant', content: JSON.stringify({ name: toolCall.name, arguments: toolCall.arguments }) },
        { role: 'tool', content: JSON.stringify(toolResult) }
      ],
      temperature: 0.7
    });
    const finalReply = completion2.data.choices[0].message.content;
    await ctx.reply(finalReply, { parse_mode: 'HTML' });
  } catch (err) {
    console.error(err);
    await ctx.reply('Sorry, I could not process that request. Please try again.');
  }
});

bot.launch();
console.log('Bot is running');

Production notes

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. By 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.

BotCreator — studio that ships Telegram bots / Mini Apps.

Further reading:

── more in #ai-agents 4 stories · sorted by recency
── more on @telegram 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/llm-powered-telegram…] indexed:0 read:7min 2026-10-01 · —