Telegram Channel Integration: One App, One Event Bus
How a KDCube app verifies Telegram webhooks, admits updates to its event lane, runs its configured agent, and delivers results back.
Adding Telegram to a KDCube app does not create a Telegram copy of the app or a second agent runtime. It adds one verified transport surface to the same app. Telegram messages enter through the same chat ingress, ordered event lane, and processor path as browser messages. The configured agent runs later, on the processor side. Its progress and result then travel back through the Telegram delivery adapter.
That separation is the useful part:
webhook = verify, normalize, submit, acknowledge
event bus = retain, order, wake, fence
app runner = execute the configured agent
delivery adapter = stream progress and send the app result
The earlier journal entry, Connecting a Telegram Channel to KDCube Through Connection Hub, explains account linking and the Mini App. This entry follows one Bot API update through the application and event bus.
One update, through one app
Telegram Bot API
-> app public @api: telegram_webhook
-> Telegram SDK: verify + deduplicate + hydrate + resolve identity
-> ChatIngressSubmitter
-> canonical external_events[]
-> Redis conversation/agent lane + one wake
-> processor
-> app's configured default agent
-> app workflow
-> queued Telegram progress + final delivery
The webhook acknowledges admission. It does not wait for model execution, run the workflow inline, or create a private answer relay. If shared ingress is unavailable, the transport returns a short retry response and runs no agent. This keeps one user action from racing through two execution paths.
What the app declares
The application still lives in the current bundles.yaml
descriptor model. Its Telegram channel needs three non-secret declarations:
- the agent that receives a turn when the transport names no agent;
- the public webhook operation;
- the Telegram integration row and delivery behavior.
bundles:
version: "1"
items:
- id: "research@1-0"
config:
surfaces:
as_consumer:
default_agent: research
enabled:
api:
public.telegram_webhook.POST: true
integrations:
telegram.default:
provider: telegram
enabled: true
definition:
bot_name: "<BOT_NAME>"
bot_username: "<BOT_USERNAME>"
webhook:
url: "https://<PUBLIC_HOST>/api/integrations/bundles/<TENANT>/<PROJECT>/research@1-0/public/telegram_webhook?integration_id=telegram.default"
send_responses: true
stream_activity: true
stream_activity_display: true
The secrets belong in bundles.secrets.yaml or the configured
secret provider, never in app source or the public descriptor:
bundles:
version: "1"
items:
- id: "research@1-0"
secrets:
integrations:
telegram.default:
definition:
bot_token: "<TELEGRAM_BOT_TOKEN>"
webhook_secret: "<TELEGRAM_WEBHOOK_SECRET>"
integration_id=telegram.default is a non-secret selector for
the intended integration row. Telegram returns the configured webhook secret
in X-Telegram-Bot-Api-Secret-Token; the SDK rejects a missing or
mismatched value. The route is publicly reachable because Telegram calls it
from outside the platform, but it is not unauthenticated.
After the public route exists, the operator registers that exact URL and the same secret with Telegram. A changed deployment host or local tunnel requires a new registration:
curl -X POST "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \
-d "url=https://<PUBLIC_HOST>/api/integrations/bundles/<TENANT>/<PROJECT>/research@1-0/public/telegram_webhook?integration_id=telegram.default" \
-d "secret_token=<TELEGRAM_WEBHOOK_SECRET>"
surfaces.as_consumer.default_agent is also part of the data
contract. The SDK puts that agent id on the submission and every authored
event. The same id then scopes lane identity, app dispatch, delegated grants,
and accounting. The legacy platform default is only a fallback when the app
declares no default agent.
The app binds storage, then exposes two seams
At module setup, the app binds the SDK-owned Telegram registry to its durable app storage. That registry keeps Telegram user and chat metadata, conversation bindings, and webhook update-id claims. The app supplies the storage root; it does not copy or reimplement the registry:
from kdcube_ai_app.apps.chat.sdk.integrations.telegram import TelegramUserAdminStorage
from kdcube_ai_app.apps.chat.sdk.integrations.telegram import user_admin as telegram_user_admin
def _storage_root_or_error(entrypoint):
root = entrypoint.bundle_storage_root()
if not root:
raise RuntimeError("App storage is not configured")
return root
def _telegram_storage(entrypoint):
return TelegramUserAdminStorage(_storage_root_or_error(entrypoint))
telegram_user_admin.configure_telegram_user_admin(
storage_factory=_telegram_storage,
storage_root_or_error=_storage_root_or_error,
bundle_id="research@1-0",
)
The two runtime seams remain small. The public operation delegates Telegram protocol work to the SDK:
from kdcube_ai_app.apps.chat.sdk.integrations.telegram import user_admin as telegram_user_admin
@api(method="POST", alias="telegram_webhook", route="public")
async def telegram_webhook(self, request=None, **update):
return await telegram_user_admin.handle_webhook(
self,
request=request,
**update,
)
The processor-side turn path wraps the app's real async runner:
async def _run_app_turn():
return await self.execute_core(state=state, thread_id=conversation_id)
result = await telegram_user_admin.run_with_queued_telegram_delivery(
self,
runner=_run_app_turn,
)
These seams have different jobs. handle_webhook(...) verifies
and submits. run_with_queued_telegram_delivery(...) runs only
after the processor has claimed the turn; it observes communicator activity,
calls the app's runner, and renders the returned result for Telegram. For a
browser-originated turn, there is no Telegram metadata and the wrapper simply
awaits the runner.
The runner is framework-neutral. It may drive the built-in ReAct harness, LangGraph, CrewAI, or custom async code. Telegram does not select an execution framework.
Telegram becomes canonical conversation events
The SDK maps channel gestures into the same semantic event types used by other chat transports:
| Telegram input | Canonical event | Meaning |
|---|---|---|
| ordinary text | event.user.prompt | A normal message admitted to the app's ordered conversation lane. |
/followup <text> | event.user.followup | Add work to the active turn when its agent supports live folding; otherwise become the next turn. |
/steer <text> or /stop <text> | event.user.steer | Redirect the currently active turn. |
/stop | event.user.steer with empty text | Stop the currently active turn. |
| photo, document, audio, or other file | event.user.attachment | Join the same submitted event batch as its text. |
The transport-specific event_source_id, such as
telegram.user.attachment, preserves provenance. The semantic
type tells the runtime what the event means. Consumers must use
that semantic type rather than the lane's operational kind, which
can simply be external_event.
Telegram file ids are hydrated into bytes before submission. Those bytes
cross the shared boundary as RawAttachment. Ingress applies its
normal size and security checks, hosts the file, and enriches the corresponding
attachment event with stable references before queueing the turn. The app does
not need a second Telegram-only attachment store.
The event bus owns the turn
ChatIngressSubmitter calls the same
process_chat_message(...) core used by the browser transports.
Ingress resolves the full conversation/agent identity, stamps the batch and
event ids, and publishes accepted reactive work through one atomic operation:
all accepted events -> ordered Redis lane
one bodyless wake -> processor queue
or neither
The wake says there is work. The event body remains in the lane.
When a processor claims the wake, it resolves the retained event, acquires the
shared conversation execution fence, and invokes the app's reactive turn door.
There is no process-local Telegram lock: a Python asyncio.Lock
could coordinate only one event loop and would provide no ordering across
processes or replicas.
This is the same contract described in The Conversation Is a Lane and Connect Your Agentic Loop to the Event Bus.
Stop is active-turn control, not future work
/stop must reach ingress while a turn is still running, so it
cannot wait behind that turn in a webhook-local lock. Ingress reads current
conversation state and applies the active-turn fence:
active turn exists
-> accept event.user.steer
-> stamp the server-observed active turn id
-> notify that lane
conversation is idle, or the requested target is stale
-> acknowledge as a no-op
-> create no turn
An accepted steer is never promoted into a later turn. The processor will not start a turn from it, close-time handoff expires it if it remains unconsumed, and a later turn rejects a control fenced to an older owner.
Transport parity does not imply that every agent can interrupt work live. The built-in ReAct harness listens to the lane while it runs, so it can fold a followup or react to a steer at a safe boundary. A run-to-completion agent sees its fixed start batch and does not poll the lane; a later message becomes the next ordered turn, while an unconsumed stop expires. Such an agent needs an explicit cancellation adapter if it wants live stop behavior.
Delivery comes from the side that ran the turn
The webhook cannot know the final answer because it does not run the agent.
The processor-side wrapper can. It reads payload.telegram, opens
TelegramActivityStreamer while the runner executes, and then calls
deliver_turn_to_telegram(...).
Final rendering prefers a non-empty answer or
final_answer returned by the app. If needed, it reduces assistant
output from turn_log or timeline. Files already sent by
the live activity streamer are tracked so final delivery does not send them
twice.
The descriptor controls the transport behavior:
send_responsesenables final Bot API delivery;stream_activityenables the activity observer;stream_activity_displaycontrols the visible progress card while retaining file and error observation when the streamer remains enabled.
Identity is a separate edge
Telegram proves a Telegram identity; it does not silently become a KDCube platform identity. Before submitting a turn, the integration asks the platform authority to resolve the Telegram actor through Connection Hub. An unlinked actor receives the Connect prompt directly and no agent turn is created. A linked actor keeps Telegram provenance while the allowed platform identity, roles, permissions, and economics scope are projected through the stored edge.
That identity journey, including the Mini App, is covered in the Connection Hub journal entry.
The primitives in one view
| Primitive | Owner | Purpose |
|---|---|---|
@api(... route="public") | app | Expose the Telegram webhook operation. |
TelegramUserAdminStorage | Telegram SDK + app storage | Persist Telegram identity metadata, conversation bindings, and update deduplication state. |
handle_webhook(...) | Telegram SDK | Verify, deduplicate, hydrate, resolve identity, and submit. |
ChatIngressSubmitter | platform ingress | Reuse canonical chat admission from a non-browser transport. |
RawAttachment | ingress contract | Carry hydrated bytes into normal hosting and validation. |
external_events[] | shared event protocol | Represent prompts, followups, steer/stop, and attachments. |
ExternalEventPayload | ingress + processor contract | Carry canonical routing, identity, and accepted event input into the app run. |
| conversation event lane + wake | event bus | Retain ordered work separately from processor scheduling. |
@on_reactive_event app door | app runtime | Receive the processor wake and invoke the selected app runner. |
surfaces.as_consumer.default_agent | app descriptor | Select the agent and lane identity used by Telegram turns. |
| Connection Hub edge | platform authority | Resolve a verified Telegram actor to the allowed KDCube principal. |
run_with_queued_telegram_delivery(...) | Telegram SDK + app runner | Join processor execution to Telegram progress and delivery. |
TelegramActivityStreamer | Telegram SDK | Translate selected live communicator events. |
deliver_turn_to_telegram(...) | Telegram SDK | Render and send the final result and remaining files. |
Highlights
/stop is fenced active-turn control, never future work.Related articles and documentation
- Connecting a Telegram Channel to KDCube Through Connection Hub
- The Conversation Is a Lane
- Connect Your Agentic Loop to the Event Bus
- Telegram webhook submit and queued delivery
- Telegram SDK integration
- Telegram external prerequisites
- Event ingress to ReAct turn
- Reactive turn delivery
- Conversation event lane state
- App conversation events and ReAct output