Skip to content
kazma.
Search Kazma Documentation & Site
ع

Gateways & Platforms

How Kazma’s one brain talks to many channels: the adapter layer, platform isolation, the session store, slash commands, and a feature-parity matrix — all verified against kazma-gateway/.


Kazma’s gateway package (kazma-gateway/kazma_gateway/) was refactored from a single agent_handler.py into an agent_handler/ package (decomposition noted in agent_handler/__init__.py:4). The shape is:

flowchart LR
    subgraph Platform Adapters
        TG[TelegramAdapter]
        DC[DiscordAdapter]
        SL[SlackAdapter]
    end
    subgraph agent_handler package
        ST[store.py<br/>SessionStore + isolation]
        GH[graph.py<br/>graph bridge]
        CM[commands.py<br/>interactive cmds]
    end
    SS[(SessionStore<br/>SQLite / in-memory)]
    GB[Supervisor Graph]

    TG & DC & SL -->|IncomingMessage| GH
    GH --> ST
    ST <--> SS
    GH -->|graph.ainvoke| GB
    GB -->|final state| GH
    GH -->|OutboundMessage| TG & DC & SL
  • Adapters (adapters/) — one per platform; all extend BaseAdapter (gateway.py:239). Each translates platform-native events into Kazma’s IncomingMessage and renders OutboundMessage back.
  • agent_handler package — the bridge between adapters and the supervisor graph.
  • Bus adapters (adapters/*_bus.py) — a separate set of adapters for swarm HITL approvals (Telegram/Discord/Slack). See Security & Safety.

AspectDetail
ClassTelegramAdapter (telegram.py:70), name = "telegram".
ReceiveManual getUpdates long-polling (HTTP), 1-3 s jitter; optional webhook router telegram_webhook_router() mounted at /api/webhooks/telegram, which resolves the running adapter on every request.
SendPOST /sendMessage with HTML parse_mode, 429 retry, message chunking.
Context keyschat_id (int), user_id (int), username (str), message_id (int), chat_type (str).
ExtrasVoice/STT (telegram_stt.py), inline keyboards (telegram_keyboards.py), reactions (✅/🎯/❌), typing indicator, registered command menu via setMyCommands, inbound photo/document/video/animation capture, outbound sendPhoto/sendDocument/sendVideo/sendAudio.
Helper modulestelegram_callbacks.py, telegram_keyboards.py, telegram_parse.py, telegram_send.py, telegram_stt.py.

Telegram is the most feature-complete adapter.

AspectDetail
ClassDiscordAdapter (discord.py:50), name = "discord".
ReceiveDiscord Gateway WebSocket (wss://gateway.discord.gg/?v=10&encoding=json).
SendREST POST /channels/\{channel_id\}/messages, 429 retry, 2000-char chunking.
Context keyschannel_id, guild_id, user_id, message_id, username, guild_name.
MarkdownPlain text (Discord’s own markdown is not generated).

Slash commands: Discord reserves /-prefixed commands for its own interaction system. Kazma receives them as plain text and parses internally.

AspectDetail
ClassSlackAdapter (slack.py:47).
ReceiveSocket Mode (when xapp- app_token present) OR fallback polling of conversations.history.
SendREST Slack API POST.
Context keyschannel_id, user_id, team_id, thread_ts, message_ts, username.

Slash commands: Slack blocks bot-issued slash commands, so the HITL approval prompt is issued as hitl approve|deny &lt;id> without the / prefix (graph.py:184).

Documents: all platforms that reach the agent handler support /documents (alias /docs) for list/status/read/convert/pdf-info/redact/search/health against the shared Document Intelligence service. Every chat acts as the install’s operator, as the Web Documents page and the agent’s document tools do, so a document uploaded on the page is listed and read here and the reverse (since 2026-10-13; chat used an agent principal that saw none of the page’s documents). With platform users (multi-user mode) the commands refuse: which user a chat message is has not been built. An id another tenant holds answers exactly like an unknown one. Chat attachments may auto-parse document types into fenced excerpts: a transient read in the isolated worker that stores nothing in the platform, creates no document_id and never goes to a remote parser. See Slash commands — Documents and Document Intelligence.

Tables: an answer’s markdown tables are rewritten as lines before they go to Telegram, Discord or Slack (kazma_gateway/chat_tables.py), so they read on a phone. Code blocks are left as they are.

2.4 Every message accounted for, and a Test that diagnoses

Section titled “2.4 Every message accounted for, and a Test that diagnoses”

Each adapter keeps a record of what its connection received (kazma_gateway/receive_log.py): the connection’s state and its last problem, and what became of each message — handed to Kazma, or left with a reason (the sender is not an allowed user, the message came from a bot, it carried nothing Kazma can read, the queue was full). A message left for a reason you can fix, such as an empty allowed-users list, is a WARNING in the log, at most every ten minutes per reason; a bot’s message is logged at DEBUG, the rest at INFO.

Settings → Providers & Connectors → Platform Connectors → Test asks the platform and joins its answer with that record:

PlatformWhat the Test checks
TelegramThe bot token; a webhook that would stop polling, and updates waiting; group privacy; the delivery chat (did the person press Start, did they block the bot); the group route; the last message a person sent and what became of it; allowed users; the connection
DiscordThe bot token; the Message Content intent; the servers the bot is in; the delivery channel; the newest message a person wrote there; each allowed user’s direct messages to the bot, with a link that opens the conversation; allowed users; the connection
SlackThe bot token; the app-level token (Socket Mode); the permissions the token carries; the delivery channel and whether the bot is in it; the newest message there and in each allowed user’s direct messages, with a link; allowed users; the connection

The Test writes nothing, except that on Discord and Slack it opens the direct-message channel with each allowed user, which sends no message. The card saves its fields before it tests, and the Test checks the connection made with what was saved: if the running adapter was built from other settings, it is rebuilt first, and the Test waits up to ten seconds for the new connection’s first attempt.

Slack’s Socket Mode connection. Slack asks for a new connection from time to time: refresh_requested every few hours, warning about ten seconds before a server goes away, and link_disabled when Socket Mode is switched off in the app’s settings or the app-level token the connection used is revoked. Kazma logs each request with its reason and Slack’s server, and reconnects at once. link_disabled is a WARNING, and the Test shows it with the fix. When the connection opens, Slack says how many connections the app has open. It hands each event to only one of them, so a second program connected to the same Slack app (a second Kazma, an old test bot) quietly takes some of Kazma’s messages. The count also includes a connection that ended without closing, until Slack notices, which can take hours. So more than one is not proof of a second program. When Slack counts more than one, the log gets a WARNING naming both, and the Test’s connection check says which it is from the messages it checks. If one of them never reached Kazma, another program is taking messages; if they all reached Kazma, the count is stale. A count taken within 15 seconds of Kazma’s own reconnect may still include Kazma’s previous connection, so it counts only when it shows a connection besides those two. Slack allows an app ten connections at once and refuses one more with too_many_websockets; Kazma is not connected until a slot frees. It retries every few seconds. The first refusal of a run is a WARNING and the Test’s problem, and the connection that follows logs how many refusals came first.

Messages one person sends too fast. Past gateway.rate_limits (30 messages a minute per person on each platform), Kazma answers “Slow down”, at most every 30 seconds, and leaves the rest unanswered. Each message it leaves is recorded as rate_limited in the platform’s record, so the log and the Test say what became of it. Until 2026-10-01 such messages left no trace, and Slack’s limit was 1 a minute.

2.5 A saved setting reaches the running adapter

Section titled “2.5 A saved setting reaches the running adapter”

A chat app’s settings take effect when they are saved, wherever they are saved: the Platform Adapters card, a settings restore, the terminal app, or the agent. Within a second Kazma:

  • rebuilds that platform’s adapter when its token, app-level token (Slack), webhook secret (Telegram) or on/off switch changed, and leaves the other platforms connected;
  • applies a changed allowlist to the running adapter in place;
  • rebuilds a platform’s swarm sender when its bot token, switch or destination channel changed. An unchanged sender is kept, with the approvals it is waiting for.

A platform switched off in Settings stays off at boot and on every rebuild. Refresh Gateway (and kazma gateway refresh) rebuilds every adapter through the same code as boot, for when you want everything to reconnect. Until 2026-10-01 a saved token reached no running adapter until a Refresh. That Refresh used its own copy of the boot code, which started a platform switched off, dropped Telegram’s webhook secret, and left adapters with no allowlist taking no messages. The code is kazma_gateway/chat_adapters.py, and the settings store tells it about every write.


3. Platform isolation (the core invariant)

Section titled “3. Platform isolation (the core invariant)”

This is the single most important contract in the gateway. The LangGraph state must never contain chat_id, user_id, or message_id. Breaking it leaks platform IDs into the graph and corrupts sessions.

_PLATFORM_KEYS (agent_handler/store.py:16-31) is the authoritative frozen set:

_PLATFORM_KEYS = frozenset({
# Telegram
"chat_id", "user_id", "message_id", "update_id", "chat_type",
# Discord / Slack
"channel_id", "guild_id", "team_id", "thread_ts", "message_ts",
})

_build_initial_state(msg, store) (store.py:95):

  1. Resolves a thread_id via _resolve_thread (store.py:34):
    • existing thread_id in context_metadata, else
    • deterministic from sender id (e.g. "telegram:12345" → "gw-telegram-12345"), else
    • fresh UUID4.
  2. Persists full platform context to the SessionStore: await store.put(thread_id, dict(ctx)).
  3. Builds a minimal graph state with only a _gateway block:
state["_gateway"] = {
"thread_id": thread_id,
"display_name": ctx.get("username") or "unknown",
"platform": msg.platform,
}
  1. A defense-in-depth pass strips any leaked _PLATFORM_KEYS (store.py:139-141).

After graph.ainvoke() completes (agent_handler/graph.py:316), the handler does ctx = await _store.get(thread_id) to rehydrate platform IDs, then routes via _build_target_id(platform, ctx) (store.py:146):

  • Telegram → chat_id
  • Discord/Slack → channel_id
  • Produces platform-prefixed IDs like "telegram:12345".

Persistence by design: Session entries are not deleted after a reply (graph.py:312-315) so crash-recovery routing can rehydrate context. Stale entries are evicted lazily by a 300-second TTL (graph.py:98, _session_ttl_seconds).


The SessionStore holds platform IDs ↔ thread_id mappings — never the graph.

ImplementationLocationUse
SessionStore (abstract)gateway.py:185get, put, delete, optional evict_older_than.
_InMemoryStoreagent_handler/store.py:63Testing/fallback; tracks monotonic timestamps for TTL.
SQLite SessionStorestores/sqlite.pyPersistent.
LangGraph checkpointerstores/checkpoint.pyUsed by the UI for conversation state (app.py:724).

The IncomingMessage docstring (gateway.py:62-66) states it plainly: “The Brain never touches platform-specific fields. Raw platform IDs live inside context_metadata.”


The Web UI uses Server-Sent Events as the graph transport.

  • Endpoint: POST /api/chat/stream (sse_chat/) — accepts {message, session_id, model}, returns a StreamingResponse (text/event-stream).
  • Telemetry WebSocket: /ws/chat/{session_id} is the Turn Delivery V2 cursor / live-frame bus. It does not run the graph and takes no turn control.
  • Client wiring: chat.js always calls KS.sse('/api/chat/stream', …) for turns and POST /api/approve/{thread_id} for HITL.

The full SSE event contract (token, tool_call, tool_result, approval_required, done, error) is documented in API & Extension Points.


PlatformMechanismCallback id patternHandler
TelegramInline keyboard buttonshitl:approve:\{id\} / hitl:deny:\{id\}telegram.py:733
DiscordComponents v2 buttonsswarm_approve_\{task_id\} / swarm_reject_\{task_id\}discord.py:312
SlackInteractive callbackswarm approval blockslack.py:401
Web UIButton → POST /api/approve/\{thread_id\} + approval_required SSE event—routes_direct/misc.py

The Discord and Slack rows are the swarm bus’s approvals. Chat approval cards use the same hitl:approve:<id> / hitl:deny:<id> / hitl:approve_task:<id> actions on all three platforms. An X card names its action and has no “Approve for task”. An expired card is announced in the chat, and the turn is delivered there.

The bus adapter singleton priority is Telegram > Discord > Slack (only one active at a time, wired in app.py:506-556). See Security & Safety.


FeatureTelegramDiscordSlackWeb UITUI
Receive mechanismHTTP long-pollGateway WSSocket Mode / pollHTTP POST (SSE)local
HITL approval buttons✅✅✅✅modal
Markdown rendering→ Telegram HTMLplain textplain textclient-sideplain
Slash commandsfull set (registered)plain-text parsehitl w/o /UI buttons—
Voice / STT (inbound transcribe)✅✅✅✅ /api/voice/stt—
Voice / TTS (outbound reply)✅✅✅✅ /api/voice/tts—
Media in/out (photo/doc/video)✅✅✅✅ /api/chat/upload—
Typing indicator✅✅✅n/an/a
Reactions✅ ✅/🎯/❌❌❌n/an/a
Model selector UI✅ inline keyboard❌❌✅ settingsread-only

A read-mostly observability dashboard over the same core singletons.

AspectDetail
Entry pointkazma_tui.app:main → KazmaTUI().run().
TabsDashboard (MetricsDashboard), Memory (MemoryTab), Chat (ChatPanel), Files (FilesPanel), Traces (TracesPanel), Swarm (SwarmPanel), Settings (SettingsPanel), Documents (DocumentsPanel).
Singleton init_initialize_core() initializes ModelRegistry and SwarmEngine if launched standalone.
HITLApproval modal (widgets/hitl_modal.py); _check_pending_approvals, _submit_hitl_decision.
RTLupdate_localization() toggles an rtl-mode CSS class and translates the tab labels, in the web UI’s words (TAB_LABELS).

_register_bot_commands (telegram.py:961) calls Telegram’s setMyCommands across three scopes (default, all_private_chats, all_group_chats). The registered list includes the standard slash commands plus /swarm. It does not register /hitl (because Slack needs the bare hitl form, and the registration is shared logic).


  • agent_handler.py no longer exists as a single file — it is the agent_handler/ package. Older docs referencing the file are stale.
  • Graph transport is SSE. /ws/chat/{session_id} is live as a telemetry / cursor bus; it does not run the graph.