Telegram Agent Router
A Telegram bot with multiple specialists should be designed as a message router with durable state, not as one giant prompt.
The architecture in this repository optimizes for:
- idempotent webhook intake;
- prompt response to Telegram while slow work continues asynchronously;
- deterministic fast paths before LLM routing;
- explicit agent contracts and bounded permissions;
- durable PostgreSQL state where it fits the workload;
- privacy-aware memory and audit data;
- measurable routing quality and failure recovery.
Core architecture
Telegram update
→ webhook authenticity + update validation
→ durable dedupe/enqueue
→ successful acknowledgment
→ worker
→ active-session lookup/create
→ command/callback/session-lock fast path
→ bounded intent classification only if needed
→ agent contract
→ response validation
→ Telegram send with Retry-After handling
→ memory/audit writes under retention policy
Do not run a slow LLM call as the only work inside the webhook request path. Telegram can retry unsuccessful/interrupted deliveries; deduplicate using update_id before executing expensive work.
Routing order
Prefer deterministic routing before an LLM classifier:
- known command;
- callback prefix owned by a registered handler;
- active flow/session lock;
- high-confidence deterministic intent hints;
- bounded classifier for ambiguous free text;
- orchestrator/fallback.
Low confidence should not silently become a high-impact action. Ask for clarification or route to a bounded fallback.
Agent contract
Every agent must expose a narrow interface and explicit capability boundary.
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class AgentContext:
bot_id: str
user_id: int
chat_id: int
session_id: str
message: str
recent_history: tuple[dict[str, Any], ...] = ()
user_memory: tuple[dict[str, Any], ...] = ()
active_flow: str | None = None
@dataclass
class AgentResponse:
text: str
agent_name: str
memory_writes: list[dict[str, Any]] = field(default_factory=list)
follow_up_job: dict[str, Any] | None = None
lock_flow: str | None = None
unlock_flow: bool = False
Tool access is not inherited just because one agent hands work to another. The dispatcher must select an agent and the runtime must enforce that agent's own permissions.
Durable state model
The executable PostgreSQL schema is schema/telegram_agent_router.sql.
Key design choices:
bot_idscopes every tenant/bot-owned row;- RLS policies fail closed when
app.bot_idis missing; get_or_create_session()actually inserts a session when none exists;update_idis unique per bot for webhook dedupe;- jobs are durable rows claimed with
FOR UPDATE SKIP LOCKED; - messages, memory, jobs and audit rows carry expiration/retention fields where appropriate;
- long-term memory is explicit, not an automatic dump of every conversation.
PostgreSQL suitability still depends on measured workload. A durable broker/stream or external store remains justified when required semantics or isolation exceed the PostgreSQL design.
Telegram message limits and flood control
Do not hard-code one universal “30 messages/sec” limiter for all traffic classes.
Current Telegram guidance distinguishes at least:
- single-chat pacing — avoid sustained rates above roughly one message per second;
- groups — tighter per-minute limits apply;
- bulk notifications — free broadcast throughput is around tens of messages per second;
- paid broadcasts — eligible bots can obtain higher broadcast throughput.
Always honor API 429 responses and retry_after. Treat numeric platform limits as upstream facts that can change; see references/telegram-constraints.md and VERIFICATION.md.
Long responses
sendMessage accepts text up to the Bot API's documented character limit. Chunk only at safe boundaries and validate after entity parsing/formatting. The executable reference implementation includes a deterministic splitter in examples/router_contract.py.
For supported private-chat scenarios, Telegram also exposes draft-streaming methods such as sendMessageDraft. Treat draft streaming as optional UX; the durable final message still follows the normal send path and current upstream constraints must be checked.
Memory and privacy
Conversation content, user IDs, inferred preferences and dispatch logs can be personal data.
- collect only what the product needs;
- separate short-lived session context from deliberate long-term memory;
- attach source/confidence to inferred memory;
- define expiration/retention instead of “forever” by default;
- test RLS through a non-bypass application role;
- do not place secrets or full credentials in memory/audit text;
- support deletion/export obligations required by the product's jurisdiction and policy.
Failure handling
A production-capable design needs more than a queue table:
- idempotency for incoming updates and downstream side effects;
- retry budget and
retry_aftersupport; - processing timeout/reaper for abandoned jobs;
- dead-letter/failure inspection;
- bounded context size;
- explicit handling of partial Telegram sends;
- observability for route reason, confidence, latency, retries and failures.
Verification workflow
Before claiming the architecture works:
python scripts/validate_markdown.pypython scripts/validate_skill.pypython -m unittest discover -s tests -p 'test_*.py'- run
schema/telegram_agent_router.sqlandtests/runtime.sqlagainst PostgreSQL 18 or the production target major; - inspect current Telegram Bot API/FAQ for time-sensitive limits;
- classify untested target-bot behavior as
NOT_PROVEN.
References
- Current Telegram constraints:
references/telegram-constraints.md - Agent design patterns:
references/agent-design.md - Router patterns:
references/router-patterns.md - Anti-patterns:
references/anti-patterns.md - Privacy/security model:
references/privacy-security.md - Preserved pre-refactor skill:
references/SKILL.pre-2026-08-15.md - Verification matrix:
VERIFICATION.md - Evals:
evals/cases.jsonl