Where your assistant is reachable — push per thread

The owner, 2026-10-15:

Before this change a channel answered only the conversation that started on it. A WhatsApp conversation was answered on WhatsApp, a Teams conversation in Teams, and a portal chat only in the portal. A conversation could not send its replies to a second channel. This page describes the three pieces that change that, how they are built, and what is still open.

The model

There are three pieces. Each one is stored where its owner can edit it with the framework's own controls.

Piece Stored at Edited on Decides
Reachable on: the channels the person allows AiSettings.ReachableChannels on {user}/_Memex/AiSettings Settings › Connections › Messaging, top section whether a channel may receive pushed replies at all
Push replies to: one conversation's channels Thread.PushTo (+ PushToSetByHand) on the thread node the conversation's Push replies menu entry where that conversation's replies go
Routing rules: "this topic → that channel" one AssistantPushRule node per rule, at {user}/_Memex/push-… the same Messaging section, Routing rules what a conversation's push set gets when nobody chose by hand

A reply of conversation C, owned by person P, is pushed to channel X only when all five of these hold (AssistantPush.Decide, a single pure function):

  1. P has a link on X: a WhatsApp number or an iMessage handle. Teams is listed but cannot be linked for pushing yet (see What each channel cannot do).
  2. C is not X's own conversation. X's existing reply sender already answers that one.
  3. C's push set names X (AssistantPush.Effective, see below).
  4. P has turned X on (ReachableChannels). The portal is always on.
  5. The reply has not been sent yet, and X is not refusing right now (for WhatsApp, the 24-hour window).

Each no comes with a sentence that explains it, and the settings page and the push panel show that same sentence. The page never promises a reply the sender would hold back.

Channels: an open vocabulary, contributed by the modules

A channel id is a string, not an enum: web, whatsapp, teams, imessage. These are the same strings a conversation's ConversationOrigin.Client carries, so "the channel a conversation came in on" and "a channel it pushes to" share one vocabulary. Each channel module registers an IAssistantChannel through DI (AssistantChannelCatalog.AddAssistantChannel). That interface carries the id, label, symbol (🟢 WhatsApp, 🟣 Teams, 🔵 iMessage, the inbox's symbols), order, the channel's own push constraint, IsAvailableHere, ObserveHealth(user) and Connect(user, returnUrl). A module that is not installed adds no row. A stored id whose module is missing is kept and ignored, never coerced.

Health reuses the Connections layer where the two mean the same thing.

"Turn on and link" on a channel that is not linked yet starts that channel's existing link flow through GettingStartedAreas.FollowConnect. For WhatsApp and iMessage that is the LINK code in chat. Teams offers no link step yet, only the sentence saying why. No new link flow was built.

The push set of a conversation

Thread.PushTo can be read three ways (AssistantPush.Effective):

Routing rules: what "topic" binds to

A rule is { Topic, Channel }. The topic is kind:value, matched against facts that every thread already carries today. None of them is a new label to maintain:

Kind Fact Why
group: (also a bare word) the Threads app group, ThreadGroups.Of: declared, legacy-mapped, or Mine/Other the first candidate. It is the topic the person already sees in the Threads app
agent: Composer.AgentName, matched in full or by its last segment "Executive Assistant answers go to WhatsApp"
context: Composer.ContextPath, the subtree of the node the conversation was started about "everything about Acme/Deals goes to Teams"
namespace: where the thread node is stored, and below conversations stored in a Space
category: the node's category, the conversation's topic tag the tag a starter sets

Any other kind matches nothing. It is shown on the settings page and kept as written. A rule names one channel ("topic X → WhatsApp and Teams" is two rules), so the editor renders the channel as a dropdown of the installed channels. Rules are nodes rather than a list inside AiSettings, because the framework's node-bound editor (MeshNodeContentEditorControl) edits the top-level fields of one node. A list nested in the settings record could only be edited through a /data replica and a save loop, which is forbidden. Each rule opens on its own page (the rule node's default area), and the settings page lists the rules in a DataGrid with Edit and Remove.

Senders: one shared engine, each channel's own transport

AssistantPushEngine holds the push half every channel shares. Every effect is injected (targets, owner state, replies, send, claim, completion, the channel's refusal), so the same engine runs against the mesh and against a scripted transport in a test. For each linked person it follows their 50 most recent home conversations ({user}/_Thread, the inbox's anchor) and watches the ones whose push set names the channel.

What each channel cannot do

Channel Constraint What happens
WhatsApp Meta accepts a free-form message only within 24 hours of the person's last message to the business number. This module sends no message templates. The reply is held back with Blocked. The reason names the window, and the settings row shows it. After a day of silence nothing is pushed until the person writes again. That is the honest limit: getting past it needs approved templates, which this change does not add.
Teams The bot may push a private conversation only into the person's own personal chat with it. A TeamsConversation link (core's record) stores neither the conversation's scope (the bot also accepts group chats and channels) nor when the person last wrote in it. Pushing to Teams is not offered yet. The row reads Not linked with that reason, and a rule naming Teams says the same. Conversations started in Teams are still answered there as before, and their push panel row does not claim otherwise. Offering it needs the bot to record the scope and the last inbound time for each link: a core change to TeamsConversation, or a record of the Teams module's own.
iMessage The bridge runs on the Mac that hosts it. Off a Mac the channel reads Not on this installation. Replies are sent only while that Mac is on and signed in to Messages.

Fixed on the way

WhatsAppLinkStore.WatchLinks, AppleMessagesLinkStore.WatchLinks and the Teams link watch read each emission of IMeshService.Query as the full result set. An Added, Updated or Removed change carries only the changed items, so after one person's link changed, every other person's link dropped out of the sender's map until theirs changed too. They now fold the change feed (QuerySnapshots.Fold, which delegates to ConnectionCredentialStore.Fold). The push engine needs the full set, and the existing conversation senders benefit too.

Tests (executed)

Not established