kestrel-channel-telegram
Telegram channel for Kestrel agents — an isolated-venv feature where the agent talks over a Telegram bot it owns (a Bot API token), not a device grafted onto a personal account.
Unlike the WhatsApp channel (a companion device linked to a personal number), Telegram gives the agent its own identity with almost no friction:
- Create a bot with @BotFather → you get a token. No phone number, no QR, no linked device.
- You DM the bot; the agent replies as the bot.
- Access is default-deny: with an empty
allowed_sendersthe bot replies to no one. Obtain the user's immutable numeric Telegram ID through an operator-controlled path, then add it toallowed_sendersto enable the conversation. Unauthorized/startand/idare intentionally dropped.
Setup — the agent walks you through it
You don't need to remember these steps: enable the feature and ask the agent to
set up Telegram. It calls telegram_status, which returns a plain-language
next_step, and guides you through whichever step is next. The full flow:
- Create the bot (only you can do this — it's in your Telegram):
- Open Telegram, search @BotFather (the blue-check official one), press Start.
- Send
/newbot, choose a name (e.g.Emma) and a username ending inbot(e.g.emma_kestrel_bot). - BotFather replies with a token like
12345678:AAExample…(~46 chars). Copy it. (It's the bot's password — keep it private; it's fine to give to the agent/operator to store in config.)
- Configure the token: save the token in this feature's config (
token) via the features UI (or hand it to the operator). The agent tells you when it's set and connected (as your bot's@username). - Get on the allowlist (this is how you get access — default-deny):
- Ask the agent for the link, or run
telegram_link→ it returnshttps://t.me/<your_bot>. - Open it to confirm you have reached the right bot. Obtain your numeric Telegram ID through an operator-controlled path.
- Add that numeric ID to
allowed_senders. Usernames are not accepted: they are mutable, while authorization uses the sender's immutable ID.
- Ask the agent for the link, or run
- Chat: DM the bot — the agent replies. Done.
What the agent does vs. what you do. The agent can't create your bot or read your Telegram, so creating the bot and obtaining your numeric Telegram ID are operator/user steps. Everything else — telling you exactly what's missing (
telegram_status.next_step) and handing you thet.melink (telegram_link) — the agent drives. Setting the token/allowlist config today goes through the features UI/operator; an agent-sidefeature_configuretool would let the agent do that for you too (planned).
How it works
The package has two roles (same shape as kestrel-channel-whatsapp):
- Discovery marker
TelegramFeature(SDK-only) — installed into the host. - Transport service
kestrel_channel_telegram.service— its only dep ishttpx(the[service]extra), installed by the host loader into a per-agent isolated venv and run as thekestrel-telegram-serviceconsole script over the SDK stdio JSON-RPC contract.
The service registers telegram_send, telegram_status, and telegram_link.
Telegram 0.1.3 requires Kestrel Core 0.53.0+ before startup: Core injects the
acknowledged-inbound capability during the isolated-service handshake, and the
child fails clearly before polling if it is absent. Release Core first, then
Telegram; the package intentionally does not depend on Core because the feature
must not invert the framework → feature dependency direction.
Its active ingress is polling (the default): it removes any configured
Telegram webhook and long-polls the Bot API (getUpdates), delivering text
messages from allowed senders as channel.inbound and sending via
sendMessage. auto is a backwards-compatible deterministic alias for
polling.
Hosted Telegram delivery contract (for the Frinz host integration)
transport: webhook is intentionally absent from feature config and rejected
from both config and environment fallback. A user cannot select hosted delivery,
set route readiness, or pass a URL/secret into this child. The only ordinary
states are polling and its auto alias.
This 0.1.6 package advertises five Telegram-specific private SDK 0.36+ host-ingress operations, consumed by Core 0.53.0+ and never exposed as agent tools. Frinz owns the concrete hosted route; durable scheduled-outbound custody and host deduplication remain separate follow-up work:
| private operation | host payload | effect |
|---|---|---|
telegram-hosted-activate |
{"scope":{"agent_id":"…","tenant_id":"…"},"bot_id":"…","activation_generation":"…"} |
Binds the canonical public bot id, exact immutable scope, and host-persisted activation generation, then stops and reaps polling before success. If a child token is configured, its bot id must match; a tokenless child may receive but cannot send. Retrying the same generation is safe; another generation conflicts while active. |
telegram-update |
activation scope, bot id, activation_generation, and one authenticated Telegram update |
Accepted only while that exact generation binding is active. It returns a stable dedupe identity and deliver, dropped, or ignored disposition; an unauthorized command never creates a reply action. |
telegram-hosted-deactivate |
activation scope, bot id, activation_generation, plus "webhook_deleted": true |
Accepts only the exact active generation and the host's deletion attestation, then restores polling. A completed old deactivation replay cannot clear a newer generation. |
telegram-polling-ack |
{"dedupe_key":"telegram:v2:bot:<bot_id>:update:<update_id>","attempt_token":"…"} |
Releases an allowed polling callback only after Core completed/durably accepted that exact callback attempt; an opaque per-callback token prevents a delayed old ACK from acknowledging a newer redelivery. |
telegram-polling-nack |
{"dedupe_key":"telegram:v2:bot:<bot_id>:update:<update_id>","attempt_token":"…"} |
Releases that exact polling callback attempt for retry after retryable Core/cognition failure. It leaves the Bot API offset unchanged; delayed completions from an earlier attempt are rejected without affecting the current future. |
The child retains completed activation generations for its entire process
lifetime, but process-local memory is not a cross-restart replay defense. The
host route provisioner must durably record every spent
activation_generation and reject it after a child or host restart before it
can reach telegram-hosted-activate.
The service also advertises Core's generic private
external-ingress-quiesce / external-ingress-resume lifecycle capability.
Before a config transition Core asks the poller to cancel/reap getUpdates and
finish any already-emitted callback, then closes its event gate. If the old
child remains after a failed or live-applied transition, Core sends the exact
matching resume call while that gate is still closed. The child returns the
resume response before it can restart polling or produce another callback;
Core then reopens the gate after receiving that response. This preserves the
closed-gate handoff even for a newly-created disconnected adapter, which is
restarted through its polling supervisor. This never changes hosted-route
ownership and never deletes a webhook or pending Telegram updates.
Polling uses two private completions, telegram-polling-ack and
telegram-polling-nack. After it emits an allowed channel.inbound
notification, the child waits for Core to complete its scoped inbound handler
and issue exactly one completion with the canonical bot_id + update_id dedupe
key and that callback's opaque attempt token. ACK advances the Bot API
offset only after durable acceptance; NACK releases the callback, leaves the
offset unchanged, and makes the poller back off/redeliver. These callbacks are
not agent tools and the host must dispatch
them after the event handler returns (as separate JSON-RPC tasks), because
event reading and private-ingress responses share the SDK stream. Hosted
webhook delivery remains host-owned durable admission and does not use this
child polling-completion protocol.
For a routable text reply, normalization carries Telegram's chat-local
reply_to_message_id both in the private ingress result and in
channel.message metadata. It is present only when the reply points to the
same chat as the inbound message; malformed or cross-chat reply identities are
rejected. The nested replied-to message content is never copied. Hosts must
still correlate the reply against their own bound bot/chat, outbound receipt,
workflow run, and post-send timestamp before treating an OK as an
acknowledgement. The provider's message_date (Unix seconds) and optional
edit_date are retained without content; a reply lacking a valid sent time is
rejected. For an edited OK, compare the original message_date to the
outbound send time—an edit after the send must not turn an earlier message
into an acknowledgement.
Private ingress admission is the host attestation: no route URL, webhook
secret, or user-writable readiness flag crosses into the isolated service. The
child does not expose HTTP, verify Telegram's secret header, call setWebhook,
or call deleteWebhook while hosted. It reports host-activated distinctly
from polling and never claims route readiness; host activation merely confirms
the local poller is stopped and that a trusted caller selected a bound scope.
Outbound safety in 0.1.6: a startup-attested child cannot send before the host
activates its exact bot route. Once activated, the child may call sendMessage
only if its configured token identifies the same bot; a tokenless or mismatched
child fails before constructing a Telegram HTTP client. This also holds if
activation races a send. This is not durable scheduled-send custody: Frinz
still needs a user/companion/bot-scoped encrypted credential and fenced host
outbound boundary before the morning-check workflow can send autonomously.
Shutdown permanently latches the child closed: queued send/link/connect and
host lifecycle calls cannot revive polling after a hosted binding is cleared.
The concrete Frinz host route/provisioner owns the public URL,
secret-header verification, setWebhook/
deleteWebhook, durable enqueue-and-dedupe, and immutable agent/tenant/bot
route record. It must generate one 256-bit route value with
secrets.token_urlsafe(32), persist it as activation_generation, and use the
same value on every activation, update, and deactivation call for that route.
It never belongs in feature config and it must generate a new value after a
completed deactivation (a completed generation cannot be reactivated). Its
required order is:
- Start the child in polling and invoke
telegram-hosted-activatewith the resolved scope and bot identity. Only after it succeeds may the host install the authenticated durable route and callsetWebhook. - If provisioning fails, delete any partially installed webhook and invoke
telegram-hosted-deactivatewith the same binding andwebhook_deleted: true; this is what safely resumes polling. - For teardown, first call and confirm Telegram
deleteWebhook, then invoke the same private deactivation payload. A mismatch or missing confirmation is rejected and leaves hosted mode latched, avoiding dual consumers.
After authenticating the Telegram secret, the host invokes telegram-update,
then atomically durable-dedupes/enqueues any returned deliver event before
acknowledging Telegram. The child uses a canonical bot_id + update_id event
ID in both polling and hosted mode (not Telegram's chat-local message_id and
not host scope), and retains raw message_id in metadata.
Valid Telegram Messages without from (such as channel posts) are safely
ignored with a 2xx disposition; they are never authorized or routed to the
agent.
Config
| key | meaning |
|---|---|
enabled |
turn the channel on/off |
token |
Bot API token from @BotFather (the agent's identity) |
transport |
polling (default) or auto (a deterministic polling compatibility alias) |
allowed_senders |
Canonical positive decimal Telegram user IDs allowed to DM the agent. Empty = deny all. Usernames are not supported. |
Webhook route URL, secret, bot identity, readiness, and agent/tenant binding remain outside feature config and outside isolated-service initialize/config-transition RPCs. They are passed only in the private host-ingress payloads above after host resolution and authentication.
Development
uv run --no-project --with "kestrel-sovereign==0.53.0" --with httpx \
--with pytest --with pytest-asyncio python -m pytest -q
Release
Bump version in pyproject.toml (+ uv.lock), tag vX.Y.Z; publish.yml
verifies the tag matches and publishes to PyPI via OIDC trusted publishing
(environment: pypi).
License
MIT
Metadata
Release files for kestrel-channel-telegram 0.1.6
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| kestrel_channel_telegram-0.1.6.tar.gz | 82.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| kestrel_channel_telegram-0.1.6-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 128.2 kB
Release files / kestrel_channel_telegram-0.1.6.tar.gz
| Download URL | kestrel_channel_telegram-0.1.6.tar.gz |
|---|---|
| Size | 82.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cec40969a1c81311f4d6809fb6049a331e31260a4e786fc728d516189beb1515
|
|
BLAKE2b-256 checksum How to use checksums |
59bb2015b03da8fb3e9ef4dce9e4fb769f84bb08d2d3da9ca883b3cc10ba17ce
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency logRelease files / kestrel_channel_telegram-0.1.6-py3-none-any.whl
| Download URL | kestrel_channel_telegram-0.1.6-py3-none-any.whl |
|---|---|
| Size | 45.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
01e8d5c5fb40358c557fd4046016a3cffb7c8352a7b700b869b7042166461299
|
|
BLAKE2b-256 checksum How to use checksums |
ebd477af063de74b9ba749785f889a5efbef4a0aeb82abdbf9207ba9d0b6fbc4
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency log