Reacting to your Telegram account
Your agents can already act as your Telegram user account (the telegram_* tools, see
features.md § Telegram MTProto). This page covers the other direction: something
happens in your account — a client replies, edits, deletes, reads your message — and an agent reacts.
There is nothing to switch on. Write a hook on a mtproto:message.* event and the engine starts
listening to every account that hook can match; remove the hook and it stops.
<!-- features/sales/client-replied.hook.md -->
---
event: mtproto:message.received
filter:
chatType: private
outgoing: false
agent: sales-followup
---
{{fromUser}} wrote in chat {{chatId}} (account {{account}}):
{{text}}
The pieces, in the order an update meets them:
- Listener — the worker holds the account’s connection and turns updates into events. Only
updates some hook’s
filteraccepts are kept (§ Scope). filter:— field equality on the event, declared in the hook’s frontmatter.- Gate —
client-replied.hook.tsbeside the hook: your code decides the rest (§ Deciding in code). - Agent — a fresh one per event, or one per chat that keeps the conversation (§ One agent per chat).
Deciding in code: the gate
A filter: can only say “these fields equal these values”. A keyword in the text, “is this a
contact”, office hours, “is this chat a client in my CRM” — anything else goes in the hook’s gate,
<hook>.hook.ts beside <hook>.hook.md:
// features/sales/client-replied.hook.ts
import { defineHookGate } from '@omnislash-ai/config/public';
export default defineHookGate<'mtproto:message.received'>(async (event, ctx) => {
if (!event.sender.isContact) return false; // strangers: no agent
if (!/invoice|payment|счёт|оплат/i.test(event.text)) return false;
ctx.log.info({ chatId: event.chatId }, 'client asked about money');
return { vars: { client: ctx.fence(event.sender.name) } }; // {{client}} in the prompt
});
eventis typed by the event’s schema — the fields listed in § Events below.- Return
falseto skip (the fire ends ashook:skipped, no agent),trueto run the hook as declared,{ vars }to run it with extra{{…}}template variables. A var renders exactly as returned: text the counterpart wrote (a name, a quoted line) goes throughctx.fence(text)first, as above — the engine cannot tell your words from theirs once they are in a var. - The role is the hook’s frontmatter; a gate cannot pick one. Two roles are two hooks.
- The gate runs after
filter:matched, inside the durable hook-trigger job, so it may run more than once for one event — keep it a decision, and make any side effect idempotent. - A gate that throws, times out (30 s by default;
hooks.gate.timeoutMsinconstitution/MANIFEST.yaml,gate: { timeoutMs }per hook) or returns anything else fails the fire (hook:failed) — the hook never runs ungated. Handctx.signalto yourfetchand queries so they stop with it. - An edit reaches the next event without a restart.
sender is nested, so filter: cannot reach sender.isContact or sender.isBot — those checks
belong in the gate. The full rules are in contract.md § 3 (Hook gates).
Events
| Event | When |
|---|---|
mtproto:message.received | A new message in any chat of the account — incoming, or sent from the account (by you, another device, or the engine) |
mtproto:message.edited | A message’s text or media was edited (reaction and view counters are not edits) |
mtproto:message.deleted | A message was deleted — one event per message |
mtproto:message.read | The other side read your messages up to maxId |
mtproto:gap | Telegram refused to replay a stretch of updates — those events are lost (see below) |
Every message event carries account, chatId (the marked id every telegram_* tool takes —
-100… for channels and supergroups) and chatType (private · group · supergroup · channel),
plus secretaryBound. received / edited add the message in the same shape telegram_history
returns — messageId, fromUser, text, date (ISO), replyTo, mediaType / mediaDescription,
contact, replyMarkup — and:
sender—{ id, username, name, isContact, isBot, isSelf }(a template sees it as JSON via{{sender}}; filter on the top-level fields)outgoing— sent from the account itselfsentByAgent— sent by the engine (see loop guard)fromScheduled— delivered from the chat’s scheduled queue (set up by you or by an agent)mentionsMe— @-mentions the account or replies to ittopicId— the forum topic, in forum supergroupseditDate— oneditedonly
Every inbound event also carries id — its permanent identity, the same on every replay of that
Telegram fact (the key described in § Delivery guarantees). It is what makes a hook fire once per
fact; a gate can use it as an idempotency key for its own side effects.
Media is not downloaded for you: hand chatId + messageId to telegram_download_media when the
agent needs the file. deleted carries messageId; read carries maxId.
Everything the sender chose — {{text}}, {{fromUser}}, {{sender}}, {{mediaDescription}} (a
file name), {{contact}}, {{replyMarkup}} (button labels) — renders fenced as untrusted outside
content (the engine’s external-content markers around it), never raw; the message text also carries
the security notice. Values your gate returns in { vars } render as given — fence counterpart text
in them with ctx.fence.
One agent per chat: agent.scope: chat
By default a hook starts a fresh agent on every fire — fine for “log this”, useless for “answer this
client”, which needs the conversation so far. Set agent.scope: chat and the hook keeps one agent
per chat: created on the chat’s first event, and every later event of that chat lands in the same
agent’s inbox. A message that arrives while it is still answering the previous one is picked up when
that turn ends — the same way a message to a chat agent is.
<!-- features/sales/client-replied.hook.md -->
---
event: mtproto:message.received
filter:
chatType: private
outgoing: false
secretaryBound: false
agent:
role: sales-followup
scope: chat
grants:
- capabilities: [messaging:send] # replies go out without an approval card
maxCalls: 50
---
{{fromUser}} wrote:
{{text}}
Answer with `telegram_send` to chatId {{chatId}}.
- One agent per hook and chat. Two hooks with
scope: chaton the same chat keep two agents — one per role. The agent lives until you delete it (dashboard /agent_delete); the next message after that starts a fresh one. - It acts as the account holder. The agent’s principal is the Telegram user the account is
logged in as (
tg:user:<id>) — that person’s connections and grants apply, like a secretary turn. - Pre-approve what it does on its own with
grants:. Sending is approval-gated (messaging:sendasks by default), so without a grant every reply waits for your click on an approval card. The hook’sgrants:block — the same one every hook has (contract.md § 3) — covers the chat’s agent: a grant is issued to that agent on a fire that finds none in force, and every later message’s turn uses it until it expires (ttlSeconds, 24 h by default) or itsmaxCallsare spent; the next message after that issues a fresh one. SomaxCallscaps how many calls one grant buys across the conversation, not per message — size it for a day of that chat, and keep the capability list to what a reply needs. - It is held to its chat. Every
telegram_*tool uses the chat’s account automatically and refuses anything else: anotherchatId, account-wide tools (telegram_dialogs, globaltelegram_search, contacts,telegram_invoke), naming anotheraccount(errorCHAT_SCOPE). A client-facing agent must not become a window into your whole account. - Only on events that name a chat — the four
mtproto:message.*events. On any other event (mtproto:gapincluded) the hook is not loaded, andomnislash doctorsays why. - The hook’s
agent:block (role,modelRef,runIn, …) is read when the agent is created; the role and memory settings also ride every message. EditingmodelReforrunInlater reaches only agents created after the edit. - The gate still decides. A hook’s gate runs before each event reaches the chat agent;
falsekeeps that message out of the inbox, and{ vars }feed that message’s prompt. - No failure notice is posted for a chat agent’s turns — watch it in the dashboard like any agent.
Scope: only what some hook wants is kept
The listener filters before anything is stored: an update becomes an event only if some enabled
hook on that event would accept it — a hook with no filter, or one whose filter matches (same
rules the hook itself fires by). A busy group no hook looks at costs nothing. So narrow hooks early:
filter: { account: work }— also limits which accounts are listened to at allfilter: { chatType: private },filter: { chatId: '-100123' },filter: { mentionsMe: true }
A private-chat deletion only names the chat when the listener saw that message arrive (Telegram sends message ids without a chat for private chats and basic groups); a deletion it cannot place is dropped. Channel and supergroup deletions always name their chat.
Your own messages: the loop guard
An agent that answers a chat produces an outgoing message in that chat — which is a
mtproto:message.received with outgoing: true. Every message the engine sends on an account is
recorded, and its event carries sentByAgent: true. A hook that reacts to outgoing messages (for
example “I replied to a client — log it”) should say which ones it means:
filter:
outgoing: true
sentByAgent: false # only messages you typed yourself
A hook with outgoing: false never sees the engine’s sends at all.
What sentByAgent does and does not cover:
- It marks new messages the engine put into a chat — every send-type tool, including a raw
telegram_invokesend (messages.sendMessage/sendMedia/forwardMessages/ …). - A message an agent scheduled (
telegram_sendwithscheduleAt) arrives later under a new id, unmarked, withfromScheduled: true— Telegram does not link it to the scheduled one. A hook that must skip the agent’s scheduled sends checksfromScheduled(withfilter:or in the gate). An agent’s own edit (telegram_edit_message) arrives asmtproto:message.editedwithoutgoing: trueandsentByAgent: false; a hook on edits that should ignore your side saysoutgoing: false. - The mark is written as the send returns, and the listener’s copy of the same message can arrive
first. A fresh outgoing message that is not marked yet is looked up once more after about a second
and a half before it is stamped yours — so a hook on outgoing messages sees them that much later.
A hook that must never answer itself still filters
outgoing: falserather than relying onsentByAgentalone.
Secretary mode side by side
If a private chat is also answered by your secretary bot (secretary-mode.md),
both would reply. secretaryBound: true marks such a chat — your account has an enabled Business
connection that may reply, and the bot has already served this chat. The engine does not hide these
events; decide in the hook:
filter:
chatType: private
outgoing: false
secretaryBound: false # leave chats the secretary handles to the secretary
Delivery guarantees
- Nothing missed across restarts. The listener keeps the account’s update position in
omnislash.mtproto_sessionsand, after a restart, a reconnect, a handover to another worker, or 15 quiet minutes, asks Telegram for everything since. A channel it had not heard from before the downtime is read back to the last sync. The first time an account is listened to, it starts from “now” — past history is not replayed as events. When the last hook for an account goes away, its position is forgotten: a hook added later starts from “now” too, instead of replaying the pause. - Nothing fired twice. Each event is recorded in
omnislash.external_events(sourcemtproto) under a permanent key, the event’sid— account, chat, message and kind (an edit also by its edit time) — before the listener moves on; the engine’s inbound router then hands it to your hooks, one fire per recorded event. A replayed update finds its key taken and fires nothing; an event recorded but not yet routed (the worker died in between) is routed by the engine’s sweep within about two minutes. The agent starts once, though its gate may be asked again if the job is retried. Each distinct edit is its own event — a gate that wants only the last of a burst of edits compareseditDate. - One listener per account, whichever worker replica holds it; another takes over if it dies.
- Kept for a week, then emptied. A handled event’s row keeps its key forever (that is the
dedup), but its payload — the counterpart’s message and sender — is emptied 7 days after it was
handed to your hooks. Set
telegram.inbound.payloadRetentionDaysinconstitution/MANIFEST.yamlto keep it longer or shorter. Store what an agent must remember in its memory or your own tables. - Losses are visible. When the account was away long enough that Telegram will not replay the
gap, the engine logs it and publishes
mtproto:gap(account,chatId— the channel, ornullfor private chats and groups —fromPts,toPts;fromPtsisnullfor a channel the listener had never heard from, where the start of the loss is unknown). Hook it if you want to know, e.g. to have an agent skimtelegram_dialogsfor what it missed.
When Telegram limits the account
Telegram limits an account it suspects of spam: a message to someone who does not have its number fails
with PEER_FLOOD, for hours or days. The engine keeps that as state, so your code reads it instead of
keeping a flag:
-
Recorded the moment it happens. Any call from the account that fails with
PEER_FLOOD, or a send (telegram_send, a scheduled message sent now, a raw send throughtelegram_invoke) that hits aFLOOD_WAIToffloodWaitMinutesor more, records the accountlimitedbefore the failure reaches you — whichever agent, pipeline or tool sent. A shorterFLOOD_WAITis only a retry hint (retryAfterMs). -
Read it.
telegram_account_status(an agent; omitaccountfor every account) or, in a pipeline,telegramAccountStatus(ctx, account?)from@omnislash-ai/pipeline-sdk(one journaled step; omitaccountfor the default):import { telegramAccountStatus } from '@omnislash-ai/pipeline-sdk'; const status = await telegramAccountStatus(ctx, 'outreach'); if (status.status === 'limited') { // { reason: 'PEER_FLOOD' | 'FLOOD_WAIT' | 'SPAMBOT', since, until?, check?, nextCheckAt? } return { parked: true, retryAfter: status.until ?? status.nextCheckAt }; }The engine does not refuse
telegram_sendwhile the account is limited — a limited account may still answer anyone who wrote first, and only your code knows which sends are cold outreach. Gate those on the status. -
Checked with @SpamBot, never with a stranger. While a limit with no known end lasts, the engine sends
/startto @SpamBot from the account — at once, then everycheckEveryHours— and reads the reply: “no limits” lifts the limit; “limited” keeps it, with the release date when the bot names one (the account readslimiteduntil the check at that moment confirms it lifted); a reply it cannot read (another language or wording) changes nothing and is kept incheck.reply. AFLOOD_WAITlimit simply ends atuntil, without a notice. @SpamBot’s replies are ordinary incoming messages — a hook on private chats sees them withsender.isBot: true. -
Told to the status chat. When a limit has lasted
alertAfterDays, the status chat gets a notice (repeated on every check while it lasts, folded into one message); when it lifts, a notice says so. -
By hand.
telegram_account_checkasks @SpamBot now;telegram_account_check { clear: true }marks the limit lifted without asking — for when a person confirmed it (e.g. after reading a reply the engine could not classify).
The numbers are yours, in constitution/MANIFEST.yaml:
telegram:
accountLimit:
floodWaitMinutes: 60 # a send FLOOD_WAIT this long or longer counts as a limit
checkEveryHours: 24 # how often @SpamBot is asked while a limit lasts
alertAfterDays: 2 # tell the status chat once a limit lasts this long
Notes
- Requires the Telegram API credentials and an authorized account (
/tg_auth); an account whose session was revoked is retried until it is authorized again.omnislash doctor(MTProto hook accounts) warns about a hook no authorized account can feed. - A hook added, removed or edited is picked up within about 30 seconds; no restart.
- Per-chat launch limit. A
scope: firehook starts a fresh agent per message, so it launches at most 6 per chat per minute; the rest end ashook:skipped(reason: rate). Tune it withhooks.chatLaunches.perMinuteinconstitution/MANIFEST.yamlorchatLaunches: { perMinute }in the hook. Ascope: chathook is not limited — its messages queue in one agent’s inbox. Narrow a hook on a busy group withfilter:(mentionsMe: true, achatId) or the gate anyway. - Scenario-inline hooks hear only their own run’s events, so an inline hook on a
mtproto:*event never fires —omnislash doctor(Hook events) says so. Give it its own.hook.md.