Files
hub/.gral/design/method.md
T
2026-10-14 02:01:34 -05:00

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 kind resolved to a Python class (agents/expert.py), and holds config in data (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 raw inference.Message objects (role + content). Implemented in agent.py and mirrored in AgentMixin.prompt_agent.
    • received_message(message) — the chat-driven path. It builds a ChatView from the message's thread, converts thread messages to LLM messages (roles derived from senders via _role_for_message), collects remote resources from the thread, creates an AgentRun, 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

  • Chat is owned by a user (database.Chat.user), has messages and threads. Thread can be parented (forking) and carries denormalized summary fields (first_message, last_message, message_count).
  • Message.sender is 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}; resource messages 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 by tmp.is_tmp_id(...) throughout the mixins — a branching pattern repeated everywhere (get_agent, get_chat_messages, create_chat_thread, etc.).

1.3 Runs

  • AgentRun is 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 — see docs/api.md), handled in server/websocket.py and 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 by App.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 receive update JSON objects.
  • Inbound shape: update.message.from.{id,username}, message.chat.{id,type}, message.text, message.message_id, plus callback_query for inline buttons.
  • Outbound: sendMessage with chat_id + text; richer payloads (sendPhoto, etc.). Replies can be threaded via reply_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}/messages to 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) plus contacts[] 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_message for 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_verification challenge and HMAC signing-secret verification) or Socket Mode (websocket, no public URL). Outbound via Web API chat.postMessage with a bot token (xoxb-…).
  • Inbound shape: event envelope with team_id, event.{type,channel,user,text,ts,thread_ts}; events include message, 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_ts groups 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:

  1. An inbound event stream (webhook or long-lived socket) that yields "someone said something in an external thread."
  2. An outbound send addressed by a per-service destination id (chat_id / phone / channel_id / channel+ts).
  3. 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 into notify_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_message and 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, and getRemoteTools expansion.
  • 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:

  1. Add a database-backed channel layer. New models (sketch): ChannelConnection (user, service kind, credentials, display name), ChannelIdentity (connection, external sender id → Hub user), and ChannelBinding (connection + external destination id ↔ Hub chat/thread). This is the piece remotes cannot provide because they are in-memory.
  2. 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.
  3. Inbound adapters call the existing ChatMixin/message-creation path (translating an inbound event into send_chat_message), so agents are notified exactly as they are for a websocket message. No agent changes required — received_message already turns a thread into a run.
  4. 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 only App.emit_event/user_connections exist for fan-out; channels should hook the same event stream rather than being spliced into notify_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.sender contract ('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 Hub Chat vs Thread? One chat with many threads, or many chats?
  • Message kinds: external messages are plain conversation — confirm they are kind=message and that resource/note (the non-delivered kinds) never leak outward.
  • Temporary vs persistent: must channel-bound chats be forbidden from being temporary, given tmp.is_tmp_id branching 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_server hook)?
  • 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?