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.