Skip to content

[feat] Connect Slack and Telegram from the agent page - #6737

Open
mmabrouk wants to merge 53 commits into
channels/telegram-hostedfrom
channels/telegram-ui
Open

[feat] Connect Slack and Telegram from the agent page#6737
mmabrouk wants to merge 53 commits into
channels/telegram-hostedfrom
channels/telegram-ui

Conversation

@mmabrouk

@mmabrouk mmabrouk commented Sep 9, 2026

Copy link
Copy Markdown
Member

Stacked on #6724 (hosted Telegram bot), which sits on #6679 (custom Telegram bot). Review the diff against channels/telegram-hosted. Do not merge yet: the whole channels stack merges bottom-up into the release branch.

Context

Connecting an agent to Slack or Telegram lived only in Settings > Channels, a technical screen built around connections, agents, spaces and grants. The designed connect screen belongs on the agent page: a user opens an agent, taps Connect, and the agent answers in the chat. A channel connection is one per project and answers as one agent, so the card has to say which agent a connection points at, and let this agent take it over.

Changes

The agent overview gets a Channels card in its rail (desktop and /m). Each platform row is in one of three states:

  • Not connected: "Connect" opens the connect flow.
  • Connected to this agent: the row opens a manage panel with the bot, the answering agent, the connection date and Disconnect.
  • Connected to another agent: the row reads "Connected to " and offers "Connect here", which points the connection's default channel agent at this app.

A hosted Telegram connection is created the moment a link is minted, before anyone taps Start in Telegram. So a fourth row state, "Not linked yet", covers a connection with no bound chat and always opens the connect flow instead of the manage view. Two small backend additions make that possible: the bind-link response returns the connection id, and a new read endpoint lists the chats bound to a hosted connection.

Before:

POST /catalog/channels/telegram_hosted/bind-link/  -> {url, expires_in_seconds}

After:

POST /catalog/channels/telegram_hosted/bind-link/  -> {url, expires_in_seconds, connection_id}
GET  /catalog/channels/telegram_hosted/bindings/?connection_id=  -> {count, bindings: [{chat_id, connection_id}]}

The flows:

  • Hosted Telegram: mint the link, show it as a QR code (a small dependency-free encoder in the package, no new npm dependency) and as a button, wait while polling the bindings, then switch to the manage view when the chat is bound. Expired links offer a new one. A mint failure shows the API's message with Try again and "Use your own bot".
  • Hosted Slack: "Add to Slack" opens the install redirect in a new tab and polls the connections until the workspace appears, then points it at this agent.
  • Custom Slack and Telegram: the credential fields come from the setup endpoint, so the form shows what the backend declares (Slack: bot token, signing secret, app id; Telegram: bot token). The Slack manifest is the real one, with copy and review.

One builder in @agenta/settings-ui holds every backend call and the row mapping; the desktop host wraps it in an antd Drawer and the /m host in a responsive Sheet. Per the frontend conventions the calls go through the generated client, which is regenerated for the channels resource only.

A bug found during the live run is fixed on the way: after Disconnect, Connect again returned a 500. Archiving a connection archives its channel agents, but unarchiving did not restore them, so the reconnect tried to create a second "default" agent and hit the unique keys. Unarchive now restores the agents that were archived with the connection.

Left out on purpose, so nothing shows as a placeholder: the chat list, the direct-message and group switches, the advanced policy rows and the allowed-user-id list. The old Settings screen is untouched.

Tests

  • API: 883 channels and secrets unit tests pass, including the new bindings route, the permission matrix entry, the trailing-slash audit, and the reconnect-after-disconnect case.
  • Frontend: tsc clean for @agenta/settings-ui, web/oss and web/mobile; eslint clean; the QR encoder has 7 unit tests; Storybook stories cover every card and flow state (co-located in the package).
  • Live on the channels dev stack, through the UI: all three card states, connect here, disconnect, the full hosted Telegram flow with a real /start from a Telegram account, the hosted Slack redirect reaching Slack's authorize page, the custom Slack manifest and fields, /m, dark mode. Captures: https://claude.ai/code/artifact/05425944-2133-40f2-9c51-35fa726fa245
  • Not verified in this run: completing the Slack OAuth (needs a workspace login) and submitting a custom app (the QA Slack env has no signing secret). The agent's answer in Telegram was verified earlier the same day on the QA project; on the throwaway project every QA model key failed at the model, which is a credential matter outside channels.

What to QA

  • Open an agent's Overview. The rail shows a Channels card with Slack and Telegram rows, both "Connect".
  • Telegram > Agenta bot: a QR code and a "Continue in Telegram" button appear within a second. Open the link on a phone and tap Start. The panel switches to the manage view on its own and the row reads "@ · Direct messages".
  • Open a second agent's Overview. Telegram reads "Connected to " with "Connect here". Tap it and confirm. The row now reads connected, and the first agent's row reads "Connected to ".
  • Disconnect Telegram, then connect again. The mint succeeds (this was the 500).
  • Slack > Agenta app > Add to Slack. A Slack authorize tab opens; the panel shows "Waiting for you to approve in Slack" with cancel and a fallback link.
  • Slack > Custom app > New app. The manifest loads; Next shows bot token, signing secret and app id; Connect stays disabled until all three are filled.
  • Regression: Settings > Channels still shows the old technical screen unchanged.

https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH

mmabrouk and others added 30 commits September 9, 2026 13:10
…ngs-ui (F1)

Scaffold only: ChannelsPage/ChannelConnectFlow/ChannelManagePanel + helpers/icons/
types under agenta-settings-ui/src/channels/. Imports resolve (@agenta/ui/ui +
phosphor are package deps). NOT exported from the package index and imported
nowhere, so the app build is untouched. Wiring to the real backend follows (F2/F3).
…ls data layer (F2)

web/oss/src/state/channels/api.ts: createTelegramHostedBindLink (direct authed
call via fetchJson, pending codegen) and archive/unarchive connection wrappers
over the generated client. Used by the agent-page connect flow wiring (F3).
…al connections (F3/F4 first pass)

- export ChannelsPage from @agenta/settings-ui.
- AgentChannelsCard (web/oss): loads real connections (queryChannelConnections),
  maps them to the design's per-platform shape, renders ChannelsPage in an antd
  Drawer. Read-only first pass: shows real connected state for Slack + Telegram on
  the agent page; connect/disconnect actions wired next.
- AgentOverviewBody: a channels rail slot; AgentOverview passes AgentChannelsCard.
Does not touch the old Settings channels screen.
… page (F3)

Opening the Telegram connect panel now mints the real one-time bind link via
createTelegramHostedBindLink({application:{id:appId}}) (which also ensures the
project's hosted connection), and the 'Continue in Telegram' button opens that
real deep link; a preparing state shows until it returns. Threaded an optional
onConnectHostedTelegram action through ChannelsPage -> ChannelConnectFlow, default
falls back to the first-pass placeholder so the component still compiles standalone.
Compiles cleanly on the stack. Browser QA + QR image + disconnect/Slack wiring next.
ChannelManagePanel's Disconnect now calls the real archiveChannelConnection via an
onDisconnect action threaded through ChannelsPage; the design connection carries the
backend connectionId (added to the type), supplied by AgentChannelsCard from the
mapped rows. Keeps the panel open on failure. Compiles on the stack.
…ions (Codex P1 #1,#2)

- AgentOverviewBody now renders {channels} in the config rail (it was accepted but
  never rendered, so the card never mounted).
- ChannelsPage syncs its state with initialConnections so the host's async-loaded
  connections actually appear (was init-only, ignored updates).
…s (Codex P2 #5,#6)

- schemas.ts: the nested flags object dropped is_hosted (strict nested object);
  added it + passthrough, and typed deleted_at. Hosted now maps to hosted, not custom.
- AgentChannelsCard.mapConnections skips rows with deleted_at, so an archived
  connection no longer shows as connected or blocks reconnect.
…tion 1, Codex P1 #4 backend)

ensure_hosted_telegram_connection now points the project's one hosted Telegram
connection at the CALLING agent: if the answering (default) agent already matches,
it is a no-op; if a different agent answers, its references are retargeted to this
app. This is the backend for 'disconnect from agent X and connect here'. Added
_reference_id (str-normalized id compare) and tests for retarget + no-op.
…onnect (Codex P1 #3)

The Telegram link-click no longer manufactures a fake connection with no id. Instead
AgentChannelsCard refetches connections after minting the link (the mint already
ensured the real connection and retargeted it to this agent) and after disconnect, so
the card reflects the real backend state with the real connection id -> Disconnect
now targets a real connection.
…t (Codex P1 #7)

A failed bind-link mint (e.g. 404 when the hosted bot is not configured) now shows a
clear error and a Try again button instead of leaving the panel on 'Preparing your
link…' forever.
…chive

- GET /catalog/channels/telegram_hosted/bindings/?connection_id= returns the chats a
  /start bound to the hosted connection (project-scoped, 404 without a hosted bot).
  The connect UI polls it to tell a minted link apart from a confirmed bind.
- The bind-link response carries connection_id.
- unarchive_connection restores the agents archived with the connection (same
  instant). A reconnect after a disconnect recreated the default agent and hit the
  (connection, slug) and one-default unique keys with a 500 on the mint.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
Mahmoud Mabrouk added 2 commits September 9, 2026 22:35
…#4)

AgentChannelsCard resolves the connection's answering agent (its default channel-agent's
referenced app id) and its name from the apps list, and sets answeredHere +
answeringAgentName on the design connection. The card now shows: not connected -> Connect;
connected as this agent -> manage; connected as another agent -> 'Answers as <name> · connect
here', and opening it shows the connect flow with a note that connecting switches Telegram to
this agent (the backend retarget makes it real). Compiles on the stack.
Mahmoud Mabrouk added 2 commits September 9, 2026 22:45
… Codex round-two fixes

- Drop the duplicate answeredHere / answeringAgentName fields that a parallel
  commit added on ChannelConnection; connection.agent + connectionScope() already
  carry the three states. This restores the package type check.
- A connection with no answering agent reads as "unassigned": the row and the
  manage panel offer "Connect here" instead of hiding a failed assignment.
- The hosted Telegram poll runs while the QR is on screen too, so a phone scan is
  detected without the button click; the link expiry is fixed once at mint.
- The custom setup guard resets on cancel, so leaving and returning loads again.
- The QR surface is dark-on-light in both themes.
- When the backend holds more than one connection per platform, a live one wins
  over a pending one, and a pending one over a revoked one.
- Both hosts ignore a reload that finishes after a newer one.
- API: archiving an already archived connection keeps its original timestamp, so
  the agents archived with it still come back on unarchive.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
@mmabrouk

mmabrouk commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Codex (gpt-astra, xhigh) reviewed the full diff. Verdict: the shared desktop and mobile presentation is sound; the connection lifecycle is not yet reliable. Fixed on the branch in 62ddd63: one agent-scope model (a parallel commit had added a second one and broke the type check), an "unassigned" state so a failed agent assignment is visible and repairable, the bindings poll on the QR step with one fixed expiry, the custom-setup guard reset, a live row preferred over a pending one, stale reloads ignored in both hosts, idempotent archive, and a fixed dark-on-light QR surface.

Deferred, because they need backend work or a wider refactor:

  • One backend operation "create or restore the connection and ensure its default agent for app X", with the target app carried in the signed Slack install state. Today the client does the assignment in three places.
  • Reconnect paths: a custom bot or app reconnect conflicts with its archived row, and a hosted Slack reinstall edits an archived row without unarchiving it. Both live in the foundation PRs' code.
  • A has_bindings or attempt status on the connection instead of the count > baseline heuristic; the endpoint's place under /catalog/.
  • Database-level tests for the unarchive cascade.
  • Layering: move the API calls into an @agenta/entities/channels domain with generated types and shared query state, rename ChannelsPage to AgentChannelsSection, split the connect flow into three components, and cut the unused chat and behavior fields.

The full list with file references is in docs/design/channels-research/v2/STATUS.md, last section.

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-pr-6737.up.railway.app/w
Project agenta-oss-clone-spike
Image tag pr-6737-1062974f
Status Deployed
Railway logs Open logs
Workflow logs View workflow run
Updated at 2026-09-10T13:03:55.522Z

Mahmoud Mabrouk added 2 commits September 10, 2026 13:25
…nders Markdown

- The turn-start indicator on Telegram is now a real "Thinking…" message plus the
  typing action. While the turn runs, the outbox keeps the chat alive: it re-sends
  the typing action every four seconds (a new adapter port, signal_activity, a
  no-op on platforms without one), moves the indicator's dots, and edits the answer
  so far into the message as records land. The loop stops on turn_ended, on a
  done record, or after twenty minutes.
- A turn that ends with no answer edits the indicator into a plain "the run
  failed" line instead of leaving "Thinking…" forever.
- The render layer converts the agent's Markdown into Telegram's HTML tag set
  (headings, lists, code, bold, italic, links, quotes, tables), dependency-free,
  and chunks between blocks so no message holds a half tag. The adapter escapes
  only plain parts; html parts pass through.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
…nd a Channel debug switch

- Removed the Slack hosted/own-app sections and the agent roster from Settings (and
  the drawers, forms and tests only they used): connecting and choosing the answering
  agent happen on the agent page now. buildSlackInstallUrl moved to state/channels/api.
- Spaces (not functional yet), threads, inbox events and outbox events render only
  when the per-user "Channel debug" preference is on, on desktop and /m, the same
  pattern as the playground inspector switch.
- Import-order lint fixes in the foundation files that kept the stack's lint job red.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
…al switches it off

- connection.data.allowed_senders ("Allowed users" on the agent page): when the
  list names anyone, resolve() drops a sender outside it before any space is
  provisioned. Empty means everyone.
- A Telegram 401 on a bot-API call raises ChannelCredentialRevoked; the outbox
  records the row as credential_revoked and deactivates the connection, so the
  card reads "token revoked" instead of retrying forever.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
Mahmoud Mabrouk added 2 commits September 10, 2026 14:32
…rants, and the allow-list

- Summary rows (bot, workspace or account, token with Update for a custom bot or
  app, status, connected date), "Answers in" from the connection's spaces with a
  Slack channel picker over discovery and a Telegram hint, the two Behavior switches
  written as one grant per chat kind (both on removes them), Allowed users on
  Telegram saved to the connection's allow-list, a collapsed Advanced section with
  the shipped defaults, and Disconnect behind a confirm.
- Connected to another agent: the retarget offer plus "Use your own bot for this
  agent", which opens the connect flow in custom mode.
- A platform may hold a hosted and a custom connection; the card shows the one that
  answers as this agent first. Revoked credentials read as such on the row and open
  the token update or a reconnect.
- The Telegram unavailable state links to the self-hosting docs page.
- Stories for every new state.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
@mmabrouk

Copy link
Copy Markdown
Member Author

Update after the design review on the canvas. The connected view is now the agreed design and wired for real: summary rows with status, "Answers in" from the connection's spaces (Slack channel picker over discovery, Telegram hint), the two Behavior switches written as one grant per chat kind, Allowed users on Telegram enforced by a new allow-list on the connection, Advanced collapsed, Disconnect. "Connected to another agent" adds "Use your own bot for this agent". A Telegram 401 now deactivates the connection and the card says so. Telegram shows progress while the agent works and renders Markdown. Settings > Channels shows connections only, with the logs behind a "Channel debug" preference. A self-hosting docs page for the hosted bot is included.

Live-checked on the dev stack: switches write and remove the grant rows, the allow-list drops an outside sender and lets a listed one through (answer received), the own-bot path opens the custom flow. Not verified live: the Slack channel picker and a real token revocation (both unit-tested and in stories).

compose_input puts a "From <name> (@username, <platform> id <id>):" text part
before each message's own words, so an agent that serves several people can
address them and keep them apart. The Telegram adapter now keeps the sender's
display name and username next to the id; Slack sends only the id, which is
what the agent gets there.

Claude-Session: https://claude.ai/code/session_011TDtGKUC7vQxxVjusKHMaH
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants