KDCube
← Our Journal

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
ONE TELEGRAM UPDATE · ONE APPLICATION PATH Telegram Bot API POST update · secret header external HTTP caller app webhook + Telegram SDK verify · dedupe · hydrate resolve linked platform identity shared chat ingress external_events[] · attachments target = app default_agent event lane + one wake ordered bodies in Redis bodyless pointer in processor queue processor runs the app shared reactive turn door configured async agent runner queued Telegram delivery activity · answer · files from the side that ran the turn BOT API RESPONSE The webhook never runs the agent inline. Admission returns first; the processor owns execution and final delivery. ONE APP · ONE AGENT ID · ONE ORDERED CONVERSATION LANE
The webhook admits work; the processor runs the app and owns 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:

  1. the agent that receives a turn when the transport names no agent;
  2. the public webhook operation;
  3. 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 inputCanonical eventMeaning
ordinary textevent.user.promptA normal message admitted to the app's ordered conversation lane.
/followup <text>event.user.followupAdd work to the active turn when its agent supports live folding; otherwise become the next turn.
/steer <text> or /stop <text>event.user.steerRedirect the currently active turn.
/stopevent.user.steer with empty textStop the currently active turn.
photo, document, audio, or other fileevent.user.attachmentJoin 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
ONE PROTOCOL · DIFFERENT EVENT SEMANTICS TELEGRAM GESTURE CANONICAL TYPE LANE / RUNTIME RESULT ordinary message text or text + attachment batch event.user.prompt normal ordered lane work same admission path as browser chat /followup <text> new work while a turn is active event.user.followup ReAct: fold at a safe boundary run-to-completion: next ordered turn /stop empty control; no new request body event.user.steer server-stamped active-turn fence active turn only · idle = no-op never promoted into future work Transport parity is shared; live interruption still depends on the agent runner.
One protocol preserves meaning; the agent runner determines live-consumption capability.

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_responses enables final Bot API delivery;
  • stream_activity enables the activity observer;
  • stream_activity_display controls 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

PrimitiveOwnerPurpose
@api(... route="public")appExpose the Telegram webhook operation.
TelegramUserAdminStorageTelegram SDK + app storagePersist Telegram identity metadata, conversation bindings, and update deduplication state.
handle_webhook(...)Telegram SDKVerify, deduplicate, hydrate, resolve identity, and submit.
ChatIngressSubmitterplatform ingressReuse canonical chat admission from a non-browser transport.
RawAttachmentingress contractCarry hydrated bytes into normal hosting and validation.
external_events[]shared event protocolRepresent prompts, followups, steer/stop, and attachments.
ExternalEventPayloadingress + processor contractCarry canonical routing, identity, and accepted event input into the app run.
conversation event lane + wakeevent busRetain ordered work separately from processor scheduling.
@on_reactive_event app doorapp runtimeReceive the processor wake and invoke the selected app runner.
surfaces.as_consumer.default_agentapp descriptorSelect the agent and lane identity used by Telegram turns.
Connection Hub edgeplatform authorityResolve a verified Telegram actor to the allowed KDCube principal.
run_with_queued_telegram_delivery(...)Telegram SDK + app runnerJoin processor execution to Telegram progress and delivery.
TelegramActivityStreamerTelegram SDKTranslate selected live communicator events.
deliver_turn_to_telegram(...)Telegram SDKRender and send the final result and remaining files.

Highlights

Telegram is an app transport surface, not a second application runtime.
The SDK-owned Telegram registry is bound to durable app storage once.
Webhook acknowledgement and agent execution are separate phases.
Browser and Telegram inputs converge on one ingress and event protocol.
The configured default agent scopes dispatch, lane identity, grants, and accounting.
Attachments use the platform's normal validation and hosting boundary.
/stop is fenced active-turn control, never future work.
The processor-side wrapper delivers progress and the real app result.

Related articles and documentation

KDCube Journal · Entry № 21 · 23.07.2026