16 KiB
Adding Channel Connections (Telegram, WhatsApp, Discord, Slack) to the Hub Backend
Purpose & Scope
This report maps the design seams in the current Hub backend against the shape of four chat-service APIs, and lays out the realistic integration options. It is deliberately analysis-first: we do not yet have a design for channels, and the goal is to decide where such a feature can live without distorting the existing agent/chat/run model.
Sourced from docs/agent.md, docs/api.md, docs/chat.md, docs/remotes.md, backend/src/app.py, backend/src/database.py, backend/src/agent.py, backend/src/agents/expert.py, backend/src/app_mixins/agent.py, backend/src/app_mixins/chat.py.
1. The Hub model we must fit into
The backend today has four concrete concepts that a channel feature must interact with. Understanding their contracts is what determines the integration options.
1.1 Agents
- An agent is created by a user, has a
kindresolved to a Python class (agents/expert.py), and holds config indata(schema fields) plus resources. - Two entry points drive an agent (
docs/agent.md):prompt(instruction, input=None, **options)— a one-shot; returns an async generator of rawinference.Messageobjects (role + content). Implemented inagent.pyand mirrored inAgentMixin.prompt_agent.received_message(message)— the chat-driven path. It builds aChatViewfrom the message's thread, converts thread messages to LLM messages (roles derived from senders via_role_for_message), collectsremoteresources from the thread, creates anAgentRun, and streams output/reasoning/tool-call messages back into the same thread as new chat messages.
- The agent object is explicitly ephemeral: "Everything durable (config, resources, chat membership) lives in the database or in resources." This matters — a channel feature must not assume the connection survives in an agent object.
1.2 Chats, Threads, Messages
Chatis owned by a user (database.Chat.user), has messages and threads.Threadcan be parented (forking) and carries denormalized summary fields (first_message,last_message,message_count).Message.senderis either the literal string'user'or an agent id. Content may be a stream reference updated in place when the stream finishes.- Message
kind∈ {message,log,note,resource};resourcemessages are declarations (e.g. remotes) and are not delivered to agents. - Chats may be temporary (in-memory, via
temporary.py) or persistent. Temporary ids are distinguished bytmp.is_tmp_id(...)throughout the mixins — a branching pattern repeated everywhere (get_agent,get_chat_messages,create_chat_thread, etc.).
1.3 Runs
AgentRunis the envelope for all agent activity: input dict (instruction/messages/tools/chat_id/thread_id/remotes), an event log with replay for late subscribers, output/reasoning streams, and cancellation. Runs are always in-memory (self.tmp.runs).- Events are broadcast to websocket clients as
run-event.
1.4 Transports already present
- Inbound user input arrives solely through websocket actions (
send-chat-message,send-agent-prompt— seedocs/api.md), handled inserver/websocket.pyand dispatched into the mixins. - Outbound to the user is the websocket fan-out:
App.user_connections[user_id]is a list of channels, and the chat mixin pushes packets (notify_message_to_user,thread-update, etc.) to them. - Remotes (
remote.py,docs/remotes.md) are the closest existing analog to an external integration: connection-scoped, keyed by(user_id, name), in-memory, torn down with the serving session, expanded into tools at run time byApp.getRemoteTools. They are inbound-less: a remote exposes functions the agent calls out to; nothing external ever initiates a run.
1.5 Key structural observation
There is exactly one private, user-scoped, session-bound external-integration mechanism today (remotes), and it is strictly agent-tool shaped. There is no notion of an external party initiating a conversation, no external-identity mapping, and no persistent outbound destination. Those three gaps are the whole problem.
2. What the four chat APIs actually look like
Grouped by the mechanics that matter for integration (inbound delivery, outbound send, credentials/scoping, threading model).
2.1 Telegram (Bot API)
- Transport: HTTPS REST to
api.telegram.org/bot<token>/…. Inbound is either long polling (getUpdates) or a webhook (setWebhook) — either way we receiveupdateJSON objects. - Inbound shape:
update.message.from.{id,username},message.chat.{id,type},message.text,message.message_id, pluscallback_queryfor inline buttons. - Outbound:
sendMessagewithchat_id+text; richer payloads (sendPhoto, etc.). Replies can be threaded viareply_to_message_id. - Credentials: a single bot token; one bot identity per token. No per-workspace OAuth — the mapping unit is bot ↔ (many) chats.
- Threading: chats/groups are the container; topics exist in forum groups.
- Server model: works from a normal always-on backend; long polling needs no public URL, webhooks need one.
2.2 WhatsApp (Meta Cloud API / Graph API)
- Transport: Graph API
POST /{phone-number-id}/messagesto send. Inbound is webhooks only — a GET verification handshake (hub.challenge) then POST payloads. - Inbound shape:
entry[].changes[].value.messages[](from,id,text.body,timestamp) pluscontacts[]for profile names; status updates share the envelope. - Outbound: session messages within a 24-hour customer-service window; outside it, pre-approved template messages are required. This is a real product constraint, not a transport detail.
- Credentials: business phone number id + access token; per-number scoping. Third-party BSPs common.
- Threading: 1:1 conversation keyed by the customer's phone number; no native threads.
- Server model: requires a public webhook endpoint.
2.3 Discord
- Transport: Gateway websocket for events (
MESSAGE_CREATE,INTERACTION_CREATE) + REST for sending (POST /channels/{channel_id}/messages). Interaction endpoints can also be HTTP webhooks. - Inbound shape: message object with
id,channel_id,guild_id,author.{id,username,bot},content,referenced_messagefor replies. - Credentials: bot token; requires declaring Gateway intents, and reading message content requires the privileged MESSAGE_CONTENT intent. A bot joins a guild/channel by invite.
- Threading: channels and threads are first-class (Guild → channel → thread); DMs are a special channel type.
- Server model: a persistent outbound websocket connection is mandatory (or an interaction webhook). This is a long-lived background task, not request/response.
2.4 Slack
- Transport: Inbound via Events API (HTTP webhook with
url_verificationchallenge and HMAC signing-secret verification) or Socket Mode (websocket, no public URL). Outbound via Web APIchat.postMessagewith a bot token (xoxb-…). - Inbound shape: event envelope with
team_id,event.{type,channel,user,text,ts,thread_ts}; events includemessage,app_mention. - Credentials: per-workspace OAuth install producing a bot token scoped by
team_id. This is a multi-tenant model unlike Telegram/Discord's single-bot model. - Threading: messages keyed by
(channel, ts);thread_tsgroups replies. - Server model: webhook (public URL + signature verification) or Socket Mode websocket.
2.5 Cross-cutting pattern
Every one of the four reduces to the same three primitives, with different transport details:
- An inbound event stream (webhook or long-lived socket) that yields "someone said something in an external thread."
- An outbound send addressed by a per-service destination id (chat_id / phone / channel_id / channel+ts).
- A credential/identity scope (bot token, phone-number id, bot token, workspace token) that a user authorizes once and which the server holds.
The variance is in transport lifecycle (poll vs webhook vs persistent socket), tenancy (single bot vs per-workspace OAuth), and product constraints (WhatsApp's 24h window and templates, Discord's privileged intent). None of these are agent-model questions; they are a new transport/connection layer.
3. The gap between the two
Mapping external concepts onto Hub concepts exposes the missing pieces:
| External concept | Hub equivalent today | Gap |
|---|---|---|
| External user identity (tg id, discord id, slack user, phone) | Message.sender = 'user' or agent id |
No external-identity ↔ Hub-user table |
| External thread/channel (tg chat, WS number, dc channel, slack channel+ts) | Chat / Thread |
No persistent binding external-destination ↔ Hub thread |
| Inbound "says something" event | websocket send-chat-message |
No non-websocket entry point into the chat/agent pipeline |
| Outbound "reply" | websocket fan-out to user_connections |
No outbound destination; replies only reach live browser sessions |
| Credential per service | remotes (user_id, name) in-memory |
Must be persisted (bots/workspaces outlive sessions) |
Two of these deserve emphasis because they cut against existing assumptions:
- Persistence of connections. Remotes are explicitly session-scoped and torn down with the serving session. Channels cannot be — a Telegram bot token and the resulting conversations exist whether or not a browser is connected. This means channels must be a database-backed resource, not an in-memory one like remotes.
- Bidirectional, unsolicited input. Every current run originates from an explicit user action (websocket send/prompt). A channel produces runs on its own, driven by external events. The run lifecycle (
AgentRun.create+received_message) already supports this, but nothing today instantiates it outside a websocket handler.
4. Integration options
Option A — Channel as a new App service layer ("channel mixin")
A ChannelMixin (parallel to ChatMixin/AgentMixin) owns channel connections: connect/disconnect actions, per-service adapters, inbound listeners, and an outbound dispatcher. It translates inbound events into app.send_chat_message(...) and subscribes to outgoing chat messages to relay them back out.
- Pros: matches the existing mixin topology; keeps transport concerns out of agents and chats; naturally bidirectional.
- Cons: the outbound leg has no hook today — the chat path only pushes to websocket
user_connections. We would need an extension point (e.g. an event emitted on message creation that channels subscribe to), or channels would have to be woven intonotify_message_to_user. This is the main new plumbing either way. - Fits: Telegram, WhatsApp, Slack, Discord all equally; credentials persisted in new tables.
Option B — Channel as a specialized agent kind
Model each external channel as an agent (kind telegram, etc.) that is a member of a chat. Inbound events become received_message; the agent's run sends output back out via the channel API instead of (or in addition to) streaming into the thread.
- Pros: reuses the entire agent lifecycle and membership model; the channel "participates" in chats exactly like other agents; no changes to the chat layer.
- Cons: conflates transport with behavior — a channel bot is not an LLM-driven agent, and forcing the loop (
chat_completion_loop) onto it is a poor fit;_role_for_messageand the run-message projection assume LLM roles. Also identity: the external sender must map to a Hub user independently of the agent. - Verdict: attractive for symmetry, but the agent contract (
prompt, inference loop, tool resolution) is the wrong abstraction for a raw transport adapter.
Option C — Channel as an extended "remote"
Generalize remotes to include an inbound direction: a remote that both exposes functions (outbound tools) and can push external events in.
- Pros: reuses the connection registry,
(user_id, name)scoping, andgetRemoteToolsexpansion. - Cons: remotes are deliberately in-memory and session-bound — the exact opposite of what channels need. Extending them would either break their lifecycle guarantees or require persisting them, muddling two different concepts.
Option D — Channel as a standalone gateway process
A separate service that speaks each chat API and talks to Hub through the existing websocket API (acting as a synthetic user session that sends send-chat-message and listens for run-event/message broadcasts).
- Pros: zero backend changes; channels become just another websocket client; deployment-isolated and independently restartable; each service's idiosyncrasies (WhatsApp templates, Discord intents) live outside Hub.
- Cons: forces an external identity model into the websocket auth (a synthetic user per channel or shared user); cannot easily correlate an incoming external thread to an existing Hub thread without additional storage it would have to own; and it inherits the websocket API's user-scoped shape awkwardly.
- Fits: good as a stepping stone and for prototyping; less clean as the long-term home for identity/binding storage that the rest of Hub should know about.
5. Recommendation
The cleanest structure is Option A, with Option D's containment discipline:
- Add a database-backed channel layer. New models (sketch):
ChannelConnection(user, service kind, credentials, display name),ChannelIdentity(connection, external sender id → Hub user), andChannelBinding(connection + external destination id ↔ Hubchat/thread). This is the piece remotes cannot provide because they are in-memory. - Define one small adapter interface — e.g.
start()/stop()for the inbound listener,send(destination, content)for outbound,normalize(event) → {external_destination, external_sender, content}— and implement it four times. This absorbs all the per-service variance listed in §2. - Inbound adapters call the existing
ChatMixin/message-creation path (translating an inbound event intosend_chat_message), so agents are notified exactly as they are for a websocket message. No agent changes required —received_messagealready turns a thread into a run. - Outbound requires the one genuinely new seam: a message-created/settled event that channels can subscribe to, oriented by
ChatBinding, so agent replies and streamed message updates relay back to the external service. Today onlyApp.emit_event/user_connectionsexist for fan-out; channels should hook the same event stream rather than being spliced intonotify_message_to_user.
Defer the outbound seam decision only if we choose Option D for a first cut (a channel is a websocket client), accepting the identity/binding cost.
6. Open questions to resolve before implementation
- Identity: do external senders become Hub users (1:1), or do external identities hang off a single owning Hub user? The
Message.sendercontract ('user'or agent id) does not currently express a third-party user. - Threading semantics: how do Telegram chats / WhatsApp numbers / Discord channels / Slack
(channel, ts)map onto HubChatvsThread? One chat with many threads, or many chats? - Message kinds: external messages are plain conversation — confirm they are
kind=messageand thatresource/note(the non-delivered kinds) never leak outward. - Temporary vs persistent: must channel-bound chats be forbidden from being temporary, given
tmp.is_tmp_idbranching everywhere? - Transport lifecycle: Discord/Socket-Mode need persistent background tasks; Telegram/WhatsApp/Slack-webhook need public endpoints. Where does the server lifecycle own these (a
start_serverhook)? - WhatsApp constraints: the 24-hour session window and template requirement are product-level; where are they enforced — adapter, or surfaced to the user as configuration?