June 23, 2026 — Matteo Merola

Group Chat Engineering: Multi-Agent Coordination Without Loops

How to run multiple agents in one Telegram group without bot-to-bot feedback loops, missed messages, or confused responses.

Put two bots in a Telegram group. Bot A says something. Bot B sees it, thinks it's a message worth responding to, and replies. Bot A sees Bot B's reply. It responds. Bot B responds to that. Within seconds you have an infinite loop burning through your API budget while your group chat scrolls into oblivion.

This is the fundamental problem of multi-agent group chat, and most systems solve it by not allowing it. One bot per group, or bots ignore each other by convention. But there are legitimate reasons to want multiple agents in one conversation — a DevOps bot and a research bot in a project channel, a personal assistant and a coding agent in your private group. humux runs this in production with four layers of protection.

Layer 1: The addressing protocol

The first and most important rule: agents only respond when addressed. No mention, no response. If a message mentions @forge, only Forge responds. If it mentions @forge and @atlas, only the first mentioned bot acts. This is deterministic — no ambiguity about who should respond.

The _addressing_target function parses the message entities and finds the first bot mention, skipping things that look like mentions but aren't — bot commands (/start@forge), URLs that happen to contain an @, and entity types that don't represent direct addressing.

addressing.py
def _addressing_target(message) -> str | None:
    """Return the bot username this message is addressed to, or None."""
    for entity in message.entities or []:
        if entity.type == "mention":
            username = message.text[entity.offset:entity.offset + entity.length]
            if username.lstrip("@") in known_bot_usernames:
                return username.lstrip("@")
    return None

# "Hey @forge can you check the deploy?" → "forge"
# "Check https://t.me/@forge_bot/123" → None (URL, not mention)
# "@forge @atlas review this"          → "forge" (first wins)

This single rule eliminates most loop scenarios. Bot A responds. Bot B sees Bot A's message. Bot B's username isn't in it, so Bot B stays quiet. No loop. The only way to create a loop is if the bots are configured to mention each other in their responses — and that's a character prompt problem, not an architecture problem.

Layer 2: The reply decision gate

Addressing handles the obvious case. But there are edge cases: a bot mentioned in a quote ("earlier @forge said X"), mentioned negatively ("don't bother @forge with this"), or mentioned in a forwarded message. The addressing protocol sees a mention and says "respond." The right answer is "don't."

The reply decision gate is a cheap classifier that runs after the addressing check passes. It takes the message, the bot's name, and the conversation context, and produces a binary decision: respond or stay silent. This runs on a fast, inexpensive model — not the main agent model. The cost per message is negligible.

reply gate
# The gate prompt (simplified):
"You are deciding whether the bot '{bot_name}' should reply.
The bot was mentioned, but is the user actually asking for
a response? Reply YES or NO."

# Examples where the gate says NO:
# "As @forge mentioned earlier, the deploy is fine" → NO
# "Don't ping @forge about this, it's not urgent"   → NO
# "I forwarded @forge's message above for context"   → NO

Layer 3: Hard rate cap

Layers 1 and 2 are smart. Layer 3 is dumb — and that's the point. If an agent has responded N times within M seconds in the same chat, it stops responding regardless of addressing or gate decisions. This is the circuit breaker that catches everything the smart layers miss.

config.yml
agents:
  - slug: forge
    rate_cap:
      max_responses: 5
      window_seconds: 60
    # If forge has already replied 5 times in the last 60s
    # in the same chat, it goes silent until the window passes.

The rate cap is deliberately conservative. In normal usage you'll never hit it — five responses per minute is well above natural conversation pace. But in a runaway loop, it fires within seconds and kills the spiral. The cap is per-chat, so an agent being active in one group doesn't affect its availability in another.

Layer 4: Speaker tags for context

In a 1:1 chat, the conversation is straightforward: user messages and agent responses. In a group, the agent needs to know who said what. humux injects speaker tags into the conversation history before sending it to the model:

group context
# What the model sees in a group conversation:
[Alice] can you check the deploy status?
[forge] Deploy #847 completed at 14:32 UTC. All health checks pass.
[Bob] @atlas what's on the calendar for Thursday?
[atlas] Thursday: standup at 10am, design review at 2pm.
[Alice] @forge any errors in the last hour?

The speaker tags include other bots. Forge can see that Atlas responded to Bob, which prevents it from jumping in with "I don't have calendar access" when nobody asked. Each agent has full visibility of the conversation flow but only acts when it's their turn.

Per-chat trigger settings

Not every group should have the same trigger rules. A project channel where everyone can invoke bots is different from a team channel where only leads should trigger the deploy agent. humux supports per-chat settings on each agent:

config.yml
agents:
  - slug: forge
    chat_settings:
      # Project group: anyone can trigger
      "-1001234567890":
        trigger: everyone

      # Ops channel: only specific users
      "-1009876543210":
        trigger: users
        allowed_users: [111222333, 444555666]

      # Announce channel: bot never responds
      "-1005555555555":
        trigger: nobody

Chat IDs are typed integers — Telegram's actual chat identifiers, not usernames or display names. The trigger field accepts three values: everyone (any user can address the bot), users (only user IDs in allowed_users), or nobody (the bot listens for context but never responds, useful for read-only monitoring). The Telegram Bot API doesn't expose group member lists, so there's no roster to enumerate — you add user IDs explicitly.

The defense-in-depth pattern

No single layer is perfect. The addressing protocol can be fooled by creative message formatting. The reply gate can misjudge a borderline case. The rate cap doesn't prevent the first few messages of a loop. But together, they create a defense-in-depth system where each layer catches what the previous one missed. In production, the addressing protocol stops 95% of unwanted responses. The gate catches most of the remaining 5%. The rate cap has fired exactly twice in six months — both times catching a conversation where two humans kept mentioning both bots in rapid succession, not an actual loop.

The result is a group chat where multiple agents coexist naturally. Each has its own personality, tools, and Telegram identity. Users address the one they want. The others stay quiet. And if anything goes wrong, the rate cap kills it before anyone's API budget takes a hit.

humux supports multiple agents in one Telegram group with addressing, reply gating, and per-chat settings. Try it →