Intermediate · about 4-6 hours

Build a Telegram AI agent with memory and a tool

Run a Telegram bot on a small server that verifies each webhook call, keeps per-chat conversation history in Postgres, and lets Claude call one read-only tool to look up order status.

You'll end up with: Customers message your Telegram bot and get accurate order-status answers in seconds, with the conversation remembered and anything it can't handle handed to a person.

Telegram Bot APIClaude Messages APITypeScript route handler (Next.js on Vercel or any Node server)Postgres (Supabase works)

Before you start

  • □A bot token from @BotFather, stored as TELEGRAM_BOT_TOKEN
  • □An Anthropic API key stored as ANTHROPIC_API_KEY
  • □A public HTTPS endpoint (Telegram accepts webhooks on ports 443, 80, 88 and 8443)
  • □A Postgres database and an internal order-status API or table to query

Architecture

  1. Telegram user

    Sends a text message to your bot in a private chat.

  2. Webhook endpoint

    Telegram POSTs the Update as JSON. The handler checks the secret-token header and drops duplicates by update_id.

  3. History store (Postgres)

    Loads the last 20 text turns for this chat_id and saves the new turn after the reply.

  4. Agent loop (Claude Messages API)

    Sends history, system prompt and one tool definition; repeats while Claude asks for a tool, up to a fixed limit.

  5. Tool: get_order_status

    A read-only lookup against your order system, called with the order number Claude extracted.

  6. sendMessage

    Posts the final reply to the same chat_id, trimmed to Telegram's 4,096-character limit.

Build it, step by step

Tick steps off as you build.

0/8
  1. Create the bot and the tables

    Create a bot with @BotFather and store the token in an environment variable; anyone holding it controls the bot. Then create two tables: one for conversation history and one that records processed update ids. Telegram resends an update when your endpoint returns a non-2xx status, so without the second table a retry produces a second reply and a second tool call.

    sql
    create table chat_messages (
      id bigint generated always as identity primary key,
      chat_id bigint not null,
      role text not null check (role in ('user', 'assistant')),
      content text not null,
      created_at timestamptz not null default now()
    );
    create index on chat_messages (chat_id, created_at desc);
    
    create table processed_updates (
      update_id bigint primary key,
      created_at timestamptz not null default now()
    );
  2. Try long polling first

    Before building the webhook, message your bot and call getUpdates to see the raw Update JSON: update_id, message.chat.id and message.text are the fields you'll use. getUpdates only works while no webhook is set, so delete any webhook first.

    bash
    curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/deleteWebhook"
    curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates"
  3. Write the webhook handler

    The handler rejects calls without the right X-Telegram-Bot-Api-Secret-Token header, ignores non-text updates, skips update ids it has already processed, runs the agent and replies. It returns 200 even when the agent fails, after telling the user, because a non-2xx status makes Telegram retry the same update. Log ids and error messages, never message text or names.

    typescript
    import { timingSafeEqual } from "node:crypto";
    import { runAgent } from "./agent";
    import { claimUpdate } from "./store";
    
    const TELEGRAM = "https://api.telegram.org/bot" + process.env.TELEGRAM_BOT_TOKEN;
    
    function secretMatches(header: string | null): boolean {
      const expected = Buffer.from(process.env.TELEGRAM_WEBHOOK_SECRET ?? "");
      const given = Buffer.from(header ?? "");
      return expected.length > 0 && given.length === expected.length && timingSafeEqual(given, expected);
    }
    
    async function sendMessage(chatId: number, text: string): Promise<void> {
      const res = await fetch(TELEGRAM + "/sendMessage", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ chat_id: chatId, text: text.slice(0, 4096) }),
      });
      if (!res.ok) throw new Error("sendMessage failed with status " + res.status);
    }
    
    export async function POST(req: Request): Promise<Response> {
      if (!secretMatches(req.headers.get("x-telegram-bot-api-secret-token"))) {
        return new Response("forbidden", { status: 403 });
      }
      const update = await req.json();
      const chatId: number | undefined = update.message?.chat?.id;
      const text: string | undefined = update.message?.text;
      if (!chatId || !text) return new Response("ok");
      // Insert-or-skip on update_id, so Telegram's retries never double-reply.
      if (!(await claimUpdate(update.update_id))) return new Response("ok");
    
      try {
        const reply = await runAgent(chatId, text.slice(0, 2000));
        await sendMessage(chatId, reply);
      } catch (error) {
        console.error("telegram agent failed", { updateId: update.update_id, error: String(error) });
        await sendMessage(chatId, "Sorry, something went wrong on our side. A person will follow up here.");
      }
      return new Response("ok");
    }
  4. Write the agent loop with one tool

    Send the stored history plus the new message to the Messages API with one tool definition. When the response's stop_reason is tool_use, run the tool for each tool_use block and send the results back as tool_result blocks in a user message. Stop after a few rounds so a confused model can't loop. The request uses API version 2026-10-21, which rejects temperature, top_p and top_k, so steer behaviour through the system prompt instead.

    typescript
    import { loadHistory, saveTurn } from "./store";
    import { getOrderStatus } from "./orders";
    
    const SYSTEM =
      "You answer order questions for Brightline Supplies on Telegram. " +
      "Use get_order_status for any order number. Never guess a status or date. " +
      "If the user needs a refund, a complaint handled or anything you can't look up, " +
      "say a person will follow up. Keep replies under 80 words.";
    
    const tools = [
      {
        name: "get_order_status",
        description: "Look up the current status and delivery estimate of one order. Call it only with an order number the user gave.",
        input_schema: {
          type: "object",
          properties: { order_number: { type: "string", description: "Order number such as A-10422" } },
          required: ["order_number"],
        },
      },
    ];
    
    type Block = { type: string; text?: string; id?: string; input?: { order_number?: string } };
    
    async function callClaude(messages: unknown[]) {
      const res = await fetch("https://api.anthropic.com/v1/messages", {
        method: "POST",
        headers: {
          "x-api-key": process.env.ANTHROPIC_API_KEY ?? "",
          "anthropic-version": "2026-10-21",
          "content-type": "application/json",
        },
        body: JSON.stringify({ model: "claude-sonnet-5-5", max_tokens: 1024, system: SYSTEM, tools, messages }),
      });
      if (!res.ok) throw new Error("Claude API error " + res.status);
      return (await res.json()) as { stop_reason: string; content: Block[] };
    }
    
    export async function runAgent(chatId: number, userText: string): Promise<string> {
      const messages: unknown[] = [...(await loadHistory(chatId, 20)), { role: "user", content: userText }];
      for (let round = 0; round < 4; round++) {
        const response = await callClaude(messages);
        messages.push({ role: "assistant", content: response.content });
        if (response.stop_reason !== "tool_use") {
          const reply = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
          await saveTurn(chatId, userText, reply);
          return reply;
        }
        const results = [];
        for (const block of response.content) {
          if (block.type !== "tool_use") continue;
          const output = await getOrderStatus(block.input?.order_number ?? "");
          results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(output) });
        }
        messages.push({ role: "user", content: results });
      }
      throw new Error("agent hit the tool-round limit");
    }
  5. Store memory as plain text turns

    loadHistory returns the last 20 rows for the chat, oldest first, as role and content pairs; saveTurn inserts the user message and the final reply. Store only text turns, not tool calls, which keeps history small and avoids replaying old tool results as if they were current. Use your database client's parameterized queries for every read and write, and cap history so a long chat doesn't grow the prompt, and your bill, without limit.

  6. Make the tool safe

    getOrderStatus should validate the order number format before querying (for example, one letter, a dash and digits), return only the fields the customer needs, and return a clear 'not found' object rather than throwing. Keep it read-only. A tool that can refund or cancel needs authentication of the Telegram user against your customer record and a human approval step, which is a different project.

  7. Register the webhook

    Deploy the handler, then call setWebhook with your HTTPS URL and a secret token of 1-256 characters (letters, digits, underscore and hyphen). Telegram then sends that secret in the X-Telegram-Bot-Api-Secret-Token header of every request. Restrict allowed_updates to message so you don't pay to process update types you ignore.

    bash
    curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
      --data-urlencode "url=https://your-app.example.com/api/telegram" \
      --data-urlencode "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
      --data-urlencode 'allowed_updates=["message"]'
    
    curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getWebhookInfo"
  8. Or build the same thing in n8n

    If you'd rather not run a server, use a Telegram Trigger node, an AI Agent node with Postgres Chat Memory keyed by the chat id, an HTTP Request tool for the order lookup, and a Telegram node to send the reply. Telegram allows one webhook per bot, so switching between n8n's test and production URLs overwrites the registration; use a separate bot for testing.

Example run

  1. A customer sends 'Where is my order A-10422?'. Telegram POSTs an Update with update_id 905113, chat.id 77120458 and that text.
  2. The handler checks the secret header, inserts 905113 into processed_updates and loads two earlier turns for chat 77120458.
  3. Claude returns stop_reason tool_use with get_order_status and input {"order_number": "A-10422"}.
  4. The tool returns {"status": "shipped", "carrier": "DHL", "estimated_delivery": "2026-10-29"}, sent back as a tool_result block.
  5. Claude replies 'Your order A-10422 has shipped with DHL and should arrive by 29 October.' and the handler sends it with sendMessage.
  6. The customer follows up 'Can I change the address?'. History supplies the order number, and the bot says a person will follow up because it has no tool for changes.

Test it

  • ✓Message the bot with a real test order number and confirm the reply matches the order system.
  • ✓Failure case: POST to the endpoint without the secret header. It must return 403 and make no model call.
  • ✓Failure case: replay the same Update JSON twice. Only one reply should arrive and processed_updates should hold one row.
  • ✓Failure case: send an order number that doesn't exist. The bot should say it can't find it, not invent a status.
  • ✓Failure case: point ANTHROPIC_API_KEY at an invalid key. The user should get the fallback message and the endpoint should still return 200.
  • ✓Send a photo or sticker and confirm the handler ignores it without errors.

Watch out for

  • !Returning a 500 when the agent fails. Telegram retries non-2xx responses, so the user can get several replies or none.
  • !Skipping the secret_token check, which lets anyone who discovers your URL trigger paid model calls.
  • !Calling getUpdates while a webhook is set. It won't receive updates until you delete the webhook.
  • !Unbounded history. Every stored message is resent on every turn, so cost and latency grow with each chat.
  • !Logging message text or user names for debugging. Log update ids and error codes instead.
  • !Giving the bot write tools such as refunds without verifying who the Telegram user is.

Concepts used