# The token was never in the screenshot

_I wanted every project to push notifications to its own Telegram bot, not pile everything into one. So I taught Claude to create the bot itself — by driving @BotFather in a real browser. The build was easy. The gotchas were the story._

`2026-07-02·shipped·claude-telegram-bot-skill`

## Why one bot per project

Pushing a stock tracker, a scraper, and a deploy notifier all through the same bot is fine until it isn't — you can't mute one, revoke one, or hand one to a teammate without touching the others. Separate bots fix that. And it turns out separate bots are nearly free, because of one detail:

> Every bot you own DMs the same person — you. So the chat_id is your user id, identical across all of them. A new project bot needs only a new token.

That reframes "set up a bot" from a chore into: get one token from [@BotFather](https://t.me/BotFather), reuse the chat_id you already have, done. Which is exactly the kind of small, repeatable thing worth teaching an agent to do for you.

## Driving @BotFather in the browser

The flow a human does — `/newbot`, pick a name, pick a username, copy the token — maps cleanly onto browser automation. Claude opens Telegram Web, sends the commands, and reads the reply. Simple. Then reality showed up.

## Gotcha 1 — the fake BotFather

Search "BotFather" in Telegram and the real, verified account is joined by a crowd of impostors: `@ZF_BotFather88_bot`, `@SphPay_Bot`, and friends, all mimicking the name. Hand a token to one of those and you've handed it to an attacker. The rule the skill encodes: only ever trust the verified `@BotFather` under the "Chats" section — never a global-search result.

## Gotcha 2 — don't read secrets off a screenshot

BotFather returns the token as a message. The obvious move is to read it from a screenshot. The obvious move is wrong: OCR quietly confuses `0` and `O`, `1` and `l` and `I` — and a token that's one character off fails in a way that looks like a network bug. The fix was to stop looking at pixels and pull the exact string from the page's DOM instead:

```
// read the token from the text layer, not the image
const m = document.body.innerText
  .match(/\b(\d{8,10}:[A-Za-z0-9_-]{35})\b/);
```

Same lesson as everywhere else in this log: when a value has to be exact, get it from the source of truth, never from a lossy render of it.

## Gotcha 3 — the dialog that ends the session

One stray click landed on a chat and popped a _"Delete chat"_ confirmation. In a browser-automation loop, a modal like that is a trap: confirm it and you've destroyed something; ignore it and every following action silently targets the wrong place. The discipline is boring but absolute — on any destructive dialog, cancel, never confirm.

## Gotcha 4 — a single underscore kills the push

With the bot created, sending a test message failed with `400: can't parse entities`. The culprit was Markdown mode: one unbalanced `_` in the message body — the kind you get constantly in scraped text or code — and Telegram rejects the whole thing. Push in plain text and the problem vanishes. Fancy formatting isn't worth a notifier that dies on the wrong character.

Oh, and: a bot can't message you until you message it first. Skip pressing _Start_ and every send returns `403`. Obvious in hindsight; a confusing five minutes in the moment.

## What shipped

All of it became a Claude Code skill — packaged as an installable plugin, MIT-licensed, with a stdlib-only send helper and a test suite. The demo GIF in the README was itself recorded through the browser, cropped to hide the sidebar, and made against a throwaway bot that got deleted so the token on screen is already dead. Practising the paranoia it preaches.
