An n8n agent for Avito (Russian classifieds): debounce, human-in-the-loop via Telegram, and a kill switch

I built an n8n-based agent that answers buyer messages on Avito (a large Russian classifieds marketplace) on behalf of a seller account. Sharing the architecture and a few things I’d do differently.

The problem

Avito’s messaging API delivers events per message, not per conversation turn. Buyers frequently type in bursts — three or four short messages a few seconds apart instead of one — and a naive “webhook → LLM → reply” pipeline answers the first fragment before the buyer has finished the thought. It also doesn’t scale to “should this go out automatically or does a human need to see it first,” which matters when the agent is negotiating price on someone’s behalf.

Architecture

Webhook → debounce/merge → LLM call with structured output → router → kill-switch check → HITL gate (for flagged cases) → send back to Avito.

Debounce. Each inbound message writes to a small key-value store (a database node; Redis would work too) keyed by conversation ID, with a timestamp. Instead of processing immediately, the workflow waits a fixed window (10–15s), then checks whether the timestamp it wrote is still the latest. If a newer message arrived meanwhile, the earlier execution exits — only the last message in a burst “wins” and triggers processing, but it pulls and concatenates every message written since the window opened. The LLM sees one glued-together message instead of a fragment.

Structured outputs. The LLM call is constrained to a JSON schema — intent, draft reply, a confidence score, and an escalate boolean — rather than free text. Everything downstream branches on those fields instead of regexing a sentence. This was the single highest-leverage decision in the build: once the model’s output is a contract instead of prose, the rest of the workflow is just routing.

Human-in-the-loop. Anything with escalate: true or confidence below a threshold gets posted to a Telegram chat with inline buttons (approve / edit / reject) instead of going straight to Avito. An operator can approve as-is, send a corrected reply, or kill it. High-confidence, low-stakes replies go out automatically.

Kill switch. The first node in the main workflow reads a single flag from the same key-value store. If it’s off, the workflow stops immediately — no messages sent, no LLM calls. “Redeploy the workflow” is not an acceptable incident response mid-conversation; flipping a flag is.

What I’d do differently

The debounce logic is what I’d rebuild first. Doing stateful debounce with n8n’s Wait node plus a database lookup works, but it means an execution per message even for ones that get discarded, and it’s easy to get the race condition subtly wrong under load. I’d move that piece into a small external queue (Redis Streams, or a cheap Lambda-style function) and only hand n8n the already-merged message — keep n8n for orchestration, not for holding state across a timing window.

I’d also add structured execution logging from day one instead of retrofitting it — once the kill switch and HITL gate exist, you want a free audit trail of every decision (auto-sent / escalated / killed), and I ended up bolting that on afterward.

Last thing: I under-invested early in tuning the confidence threshold that decides auto-send vs. escalate. It’s tempting to treat it as a constant; in practice it needed to move as the prompt and product changed, and I’d wire it to be adjustable without a redeploy from the start, same as the kill switch.

Happy to go deeper on any piece — debounce implementation, the JSON schema, or the Telegram HITL flow.

Open to agency partnerships and contract work.

3 Likes

The debounce design is the part I’d steal. Checking whether your own timestamp is still the latest is cleaner than most approaches to this.

One window I’d test though: a message that arrives after the debounce closes but while processing is still running. It writes its timestamp, sees itself as latest, starts a second execution. The first one is already past its check.
Usually that’s a couple seconds and low stakes. The HITL path is where it gets real. A reply sitting in Telegram waiting on an operator is minutes, not seconds, and every message landing in that gap gets handled by an execution that has no idea a reply is pending. A high-confidence one could auto-send while the human is still deciding on the earlier draft. Buyer gets two answers, or gets them out of order, mid-negotiation.

Might be wrong if you’re handling that somewhere I’m not seeing.

Hit the same class of thing on a messaging pipeline of my own recently — per-conversation memory only persisted one side of the exchange, so a second turn had no record of what the first had already done. Different mechanism, same shape. State that’s real in one execution and invisible to the next.
Separate thing on the kill switch. Your architecture line puts the flag read after the LLM call but the description says it’s the first node. Which is it in the actual build?

1 Like