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.3 package advertises five Telegram-specific private SDK 0.36+ host-ingress operations, consumed by Core 0.53.0+ and never exposed as agent tools, so the next Frinz ticket can add the concrete integration without another package release:
| private operation | host payload | effect |
|---|---|---|
telegram-hosted-activate |
{"scope":{"agent_id":"…","tenant_id":"…"},"bot_id":"…","activation_generation":"…"} |
Validates the canonical public bot id against the configured token, binds the exact immutable scope and host-persisted activation generation, then stops and reaps polling before success. 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.
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.
The concrete Frinz host route/provisioner is the next integration layer. It
must own 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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kestrel_channel_telegram-0.1.3.tar.gz.
File metadata
- Download URL: kestrel_channel_telegram-0.1.3.tar.gz
- Upload date:
- Size: 78.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b7bc21335b1f2f7f3fca806bbc759a65879a023ba45fc04b388107f58af215ea
|
|
| MD5 |
9fea157238a3d489d2311ef438e58f06
|
|
| BLAKE2b-256 |
2bfde14cf67fc745b1ca18968b6c29992a4887f0457a29bffae43c74e255707c
|
Provenance
The following attestation bundles were made for kestrel_channel_telegram-0.1.3.tar.gz:
Publisher:
publish.yml on KestrelSovereignAI/kestrel-channel-telegram
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
kestrel_channel_telegram-0.1.3.tar.gz -
Subject digest:
b7bc21335b1f2f7f3fca806bbc759a65879a023ba45fc04b388107f58af215ea - Sigstore transparency entry: 2515990243
- Sigstore integration time:
-
Permalink:
KestrelSovereignAI/kestrel-channel-telegram@2f4a421c26cb9cebc83561d718ce31b46652462f -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/KestrelSovereignAI
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2f4a421c26cb9cebc83561d718ce31b46652462f -
Trigger Event:
push
-
Statement type:
File details
Details for the file kestrel_channel_telegram-0.1.3-py3-none-any.whl.
File metadata
- Download URL: kestrel_channel_telegram-0.1.3-py3-none-any.whl
- Upload date:
- Size: 44.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3a51c151d5af51d4b0267bea194b1a239c8e29ff196d17ebe32174d85abf11c0
|
|
| MD5 |
2843cbad2bd56b2dd60195064f422322
|
|
| BLAKE2b-256 |
3ea603dbbfd36f19a22418012920c88445d35a89990398b2347d4f103694ff47
|
Provenance
The following attestation bundles were made for kestrel_channel_telegram-0.1.3-py3-none-any.whl:
Publisher:
publish.yml on KestrelSovereignAI/kestrel-channel-telegram
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
kestrel_channel_telegram-0.1.3-py3-none-any.whl -
Subject digest:
3a51c151d5af51d4b0267bea194b1a239c8e29ff196d17ebe32174d85abf11c0 - Sigstore transparency entry: 2515990466
- Sigstore integration time:
-
Permalink:
KestrelSovereignAI/kestrel-channel-telegram@2f4a421c26cb9cebc83561d718ce31b46652462f -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/KestrelSovereignAI
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2f4a421c26cb9cebc83561d718ce31b46652462f -
Trigger Event:
push
-
Statement type: