How to Add Human Approval to an AI Content Pipeline (Telegram + n8n)
Letting a language model publish straight to a live account is a risk most people only take once. Reviewing everything by hand removes the point of automating it. This is the middle path I run in production: the pipeline does the work, and nothing goes out until a human taps a button in Telegram.
Why an Approval Step, and Why Telegram
An AI content pipeline has two obvious failure modes. Fully automatic, it will eventually publish something wrong, off-brand or embarrassing, and it will do it at 3am with nobody watching. Fully manual, you are back to doing the work yourself and the automation has bought you nothing.
The fix is a queue with a gate. The pipeline generates, stores the draft in a pending state, and stops. A human gets a notification, looks at it, and decides. Only an explicit approval moves anything forward.
Telegram is a good place to put that gate for a reason that has nothing to do with Telegram being fashionable: inline keyboard buttons give you a one-tap decision from a phone, with no app to build and no login to maintain. The reviewer gets a message with the draft and three buttons. That is the entire interface. Building the same thing as a web dashboard is a week of work and something else to keep online.
Everything below is the actual design of a pipeline I built and operate. The values are replaced with examples, but the structure, the guards and the failure modes are the real ones — including the mistakes I had to fix after they bit me.
The Shape of the System
The first design decision matters more than any node: split this into three separate workflows, not one. They communicate through a table, not through a single long execution.
pending_review, and sends the review message to Telegram. Then it ends.
callback_query. Fires when a button is tapped, updates the row, and answers the tap. Nothing else.
The reason to split it is that a human decision is not a step in an execution, it is a gap between executions. If you try to keep one workflow waiting for a button tap, you are holding an execution open for hours or days, and any restart, timeout or deployment loses the state. The table is the state. The workflows are stateless and short.
That table needs very little: an id, the draft content, a status, a scheduled time, and a field for the last error. Any store works — a database, a spreadsheet, or n8n’s own data tables.
Sending the Approval Message
The message carries the draft plus an inline keyboard. Each button sends back a
callback_data string that has to tell you two things: which action was chosen, and which
row it applies to.
ap:8f21 → approve post 8f21
rg:8f21 → regenerate post 8f21
ds:8f21 → discard post 8f21Action code, a colon, the id. On the receiving side you split on the colon and you have both values.
The short codes are not laziness. Telegram caps callback_data at 64 bytes.
That sounds generous until you use a descriptive action name and a UUID, at which point you are at 45
characters and one schema change away from silent truncation. Two-letter codes and a short id leave room.
Never put the content itself in callback_data. It goes in the message body; the callback
carries only a pointer. The button is a reference to a row, not a payload.
Five Things That Break
Every one of these works fine in a demo and fails in production. They are the actual difference between a tutorial and a system you can leave running.
1. Not answering the callback query
When someone taps an inline button, Telegram shows a loading spinner on it and waits.
It keeps spinning until your bot calls answerCallbackQuery. If you only
update your database and move on, the reviewer sees a button that appears to hang, so they tap it again,
and now you are processing the same decision twice.
Answer the callback on every path, including the ones where you reject the action. Use a quiet
toast for success and show_alert: true for anything the person needs to actually read
— a blocking dialog for “already processed”, a toast for “approved”.
2. No authorisation check
A Telegram bot will happily talk to anyone who finds it. If your flow acts on any
callback_query that arrives, then anyone who discovers the bot and guesses a post id can
approve content on your account.
The guard is one comparison, right after the trigger and before anything else:
chat id on the incoming callback === configured reviewer chat id
true → continue
false → answerCallbackQuery("You cannot approve posts") and stop
Read the id from callback_query.message.chat.id, falling back to
callback_query.from.id, because the two differ depending on whether the button was tapped in
a private chat or a group.
3. No state guard before acting
This is the one that cost me the most. Telegram messages do not expire. That approval message from last Tuesday is still sitting in the chat with live buttons, and sooner or later someone scrolls up and taps one. Double-taps do the same thing on a slow connection.
Before acting on any decision, re-read the row and confirm it is still pending:
fetch row by id
if (row.status !== "pending_review") {
answerCallbackQuery("Already processed: " + row.status, show_alert)
stop
}Checking the row instead of trusting the tap is what makes the whole thing idempotent. Without it, a tap on an old message can republish something you retired weeks ago.
4. One bot, one active listener
Telegram delivers each update to a single destination. If two workflows are active with a trigger on the same bot token, they fight over updates and one of them silently stops receiving them.
Keep exactly one active workflow listening per bot. If you need a second behaviour, create a second bot. This is easy to trip over while testing, because a forgotten duplicate left active in the background produces the most confusing symptom there is: it works sometimes.
5. Scheduling in the wrong timezone
Approval usually means “publish at the next good slot”, not “publish now”. The moment you compute that slot you are doing timezone arithmetic, and your server is almost certainly on UTC while your audience is not.
Compute in the target zone explicitly, and handle the case where the slot has already passed today:
slot = now.setZone("America/Bogota").set({ hour: 12, minute: 0 })
if (slot < now.plus({ minutes: 20 }))
slot = slot.plus({ days: 1 })The twenty-minute buffer matters. Without it, approving something at 11:58 schedules it for 12:00, the publisher wakes up at 12:05, and it goes out late and looks like a bug. Approving inside the buffer should roll to tomorrow.
The Regenerate Loop
Approve and discard are obvious. The third button is the one that makes the system pleasant to live with: regenerate. The draft is not good, but the topic is fine — try again.
The sequence is deliberate:
- Mark the current row
discarded, with a note saying the reviewer asked for another version. - Answer the callback immediately: “Regenerating. A new version will arrive shortly.”
- Call the generation workflow again, passing the original topic plus a feedback string.
Discarding first is what keeps the state clean. If you generate before you discard, there is a window where two rows for the same idea are both pending, and both messages have live buttons.
The feedback string is the part worth stealing. Regeneration without it just re-rolls the same prompt and returns something very similar, which is infuriating. Pass an explicit instruction:
feedback: "The reviewer asked for another version.
Use a different angle and a different hook
from the previous one."Call it without waiting for the sub-workflow to finish. Generation may take a minute or two; the reviewer should not be left with a spinning button while it runs. Answer the tap, fire the regeneration, end the execution. The new draft arrives as its own message when it is ready.
The State Machine
Four states are enough. Writing them down explicitly is what stops the edge cases, because every guard in the system is just a question about which state a row is in.
| State | Meaning | What can happen next |
|---|---|---|
pending_review | Generated, message sent, waiting for a human | approved, discarded, or regenerated |
approved | Reviewer said yes; a publish time is set | published, or failed |
discarded | Rejected, or replaced by a regenerated version | nothing — terminal |
published | Live, with the resulting link recorded | nothing — terminal |
Two rules keep it honest. Only pending_review accepts a decision —
that is guard number three from earlier. And the publisher only ever looks at
approved rows whose scheduled time has passed, so a row can never skip the gate,
whatever happens upstream.
Record the error on the row rather than only in execution logs. When something fails at 4am, the row tells you what happened without digging through history.
What This Design Does Not Solve
Three honest limitations, because the pattern is not free.
It does not scale to a team. One reviewer chat id is a single point of approval. Multiple reviewers means a group chat and a rule about who decides, and Telegram gives you nothing to enforce that. Past two or three people, you want a real tool.
It does not give you an audit trail worth the name. You know a row was approved and roughly when. You do not get a proper history of who looked at what and for how long. For regulated content that is not acceptable, and you should not use this.
It does not fix bad generation. An approval gate reduces the cost of a bad draft from “published mistake” to “wasted tap”, which is a real improvement. But if you are tapping regenerate three times out of four, the problem is the prompt, and the gate is just letting you tolerate it. Watch that ratio — it is the most useful number the system produces.
If you take one thing from this: the approval gate is not the hard part. The hard part is the state guard that makes decisions idempotent. Buttons are easy; a system that behaves correctly when someone taps a three-week-old message twice is the thing worth building carefully.

