Where your assistant is reachable — push per thread
The owner, 2026-10-15:
- "i should be able to just set where my agent should be visible"
- "imo we should push per thread"
- "we can have different configs, e.g. this topic goes to channel A, that to Channel B"
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):
- 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).
- C is not X's own conversation. X's existing reply sender already answers that one.
- C's push set names X (
AssistantPush.Effective, see below). - P has turned X on (
ReachableChannels). The portal is always on. - 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.
- WhatsApp and iMessage are reachable exactly when their link exists. Their connection
providers already report that, so they are wrapped in
ConnectionBackedAssistantChannelrather than reading the link a second time. - Teams is different, and this is why a channel is not just a connection. The
teamsconnection is the Microsoft 365 Graph consent ("read your channels and chats"). It says nothing about whether the Memex bot can post to the person. The bot can post only into a chat the person opened, and only a PERSONAL chat may receive a private conversation's replies. A link does not record which kind it is, soTeamsAssistantChannelreads Not linked, with that reason.
"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):
- absent: a conversation from before this change. It pushes to its origin channel only, exactly as before, and no rule applies to it.
- stamped at creation, not set by hand:
HubThreadExtensionsstamps the origin channel (or nothing, for a portal chat). The owner's matching rules are added when the set is read, so a rule edit, or a change to the conversation's topic, applies with no rewrite and no reconcile. That is why the default is derived rather than written: writing it would need a reconcile over every thread whenever a rule changed, which feeds a reconcile its own writes. - set by hand (
PushToSetByHand: true): the owner's choice, which no rule ever changes. The first toggle copies the set as shown, changes that one channel, and records it as hand-set.
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.
- Decided when the send STARTS, not when it was queued. Replies of one conversation go out one at a time. Each reply's decision is deferred until its turn, and the target and push choice are re-read then. Unlinking, a new address, or turning push off while an earlier send is slow therefore stops or redirects the next one.
- Who it can reach. A reply of a conversation found among P's conversations goes only to P's own target, the address P's link names at that moment. Nothing anywhere takes a recipient parameter.
- Exactly one replica sends a reply. Every portal replica runs its own sender. Before sending, a
replica asks the conversation's own hub for a lease on THAT reply (
ClaimPushDeliveryRequest, handled byPushDeliveryon every thread hub; both delivery requests require Update, and the senders ask as System). The owner decides one request at a time against its own node (Thread.PushClaims, keyed per reply, so a claim on a later reply never takes over the live lease on an earlier one), so a second replica getsHeldByOtherand does not send. After the send, the holder reports the outcome (CompletePushDeliveryRequest). An accepted reply moves the channel's delivery cursorThread.PushDelivered[channel], and the lease is released. The cursor only moves FORWARD in the conversation's reply order (Thread.Messages): a delayed claim for a reply at or before it isAlreadyDelivered, and a late completion never moves it back. A merge-patch written from a mirror would not be atomic, which is why the owner decides. - A won claim is decided again. A claim can take a while, so once it is won the sender re-reads the owner's state and target. A reply no longer allowed (opted out, unlinked, turned off) releases its lease unsent; an allowed one goes to the address linked at that moment.
- A lost claim is retried after the lease. A replica that gets
HeldByOtherreportsClaimedElsewhereand tries the reply again once the two-minute lease could have lapsed, a bounded number of times, deciding afresh each time. If the winner sent it, the retry is a silentAlreadyDelivered; if the winner died before sending, the survivor sends it. That is at-least-once across a crash, the same guarantee the channels' own senders give. - History is never pushed. A reply whose completion is earlier than the moment push was turned
on is skipped (
AssistantPush.EnabledSince). That moment is the later of two instants: when the channel was turned on in settings (AiSettings.ReachableSince), and when the conversation's own source turned it on (the hand toggle'sThread.PushOnAt, or a matching rule's last change). The first hand toggle on a conversation also stampsPushOnAtfor every channel a rule contributed, since from then on the hand-set set is all that is read. It stamps the boundary that channel ALREADY had (the push panel carries it per shown channel,PushRow.ShownSincefromAssistantPush.SourceSince), never the toggle's time: the channel stayed on, so a reply that completed under the rule and is still waiting for its claim is delivered, not dropped as history when the claim is re-decided. So:- turning push on during a running round still delivers that round's answer;
- turning it off and on again never replays what was written meanwhile, whether the restart came before or after re-enabling;
- an uninterrupted opt-in still recovers a reply completed while the sender was down.
- Bounded. The engine keeps no copy of a conversation's content: every decision reads it from
the owner's current state, the same snapshot as the settings. A conversation that leaves the
owner's recent window has its watch and send-once mark dropped. A person who unlinks has their feed and every watch disposed. If an owner's feed
fails, their cached state is invalidated at once. A reply then waits up to 30 s for fresh state
and is reported
Held(never sent on stale settings). The feed is re-read with backoff capped at five minutes. - A conversation that started on the channel stays that channel's own. The engine never pushes
it, so nothing is sent twice. Each existing sender learned exactly one thing: when the owner turned
the channel off by hand on that conversation (
AssistantPush.OriginAllows), it holds the reply back (DeliveryDecision.PushedOff,AppleMessageDelivery.PushedOff). That check reads the reply's completion time against the re-enable stamp (Thread.PushOnAt), so a reply written while the channel was off stays unsent after it is turned on again, including when a restarted sender replays it. Settings reachability is NOT applied to these replies. Every other existing branch is unchanged. - The push panel's origin row says only what is true of the origin. A channel whose health is
about pushing only (
IAssistantChannel.HealthCoversOriginfalse: Teams) does not mark the conversation it carries as "Nothing is sent". Its own sender still answers there. The installation check still applies. - A pushed reply starts with
↪ {conversation title}, so a reply arriving on a channel the conversation did not start on says which conversation it answers. - Health keeps its three answers. Couldn't tell (
Undetermined) is shown as "could not check — replies may still be sent", never as "not linked". A refused link (Rejected) has its own sentence.
What each channel cannot do
| Channel | Constraint | What happens |
|---|---|---|
| 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)
MeshWeaver.AI.Test.AssistantChannelsTests: the pure rules, the claim decision, the observation boundary, the health states, and the engine on a scripted transport. Every engine test runs over a shared "world" (who is linked, each owner's feed, the replies, an emulated conversation hub that decides claims one at a time):- a rule routes topic X to WhatsApp and Y to another channel;
- a hand-set toggle survives rule changes in both directions;
- a web thread pushed to WhatsApp reaches the owner's number, and a thread without it is not even watched;
- toggling off, and turning the channel off in settings, each stop the next reply (with a reason);
- two owners are each reached only on their own number;
- an owner who unlinks gets nothing, and their feed and watches are disposed;
- enabling during a running round delivers that round's answer;
- two replicas send a reply once;
- a queued reply is decided when its send starts (toggle-off and a new address);
- a failing owner feed is invalidated (the reply is decided on the fresh state, or held), and the feed is re-read;
- a conversation leaving the window is unwatched, and its re-entry replays nothing;
- re-enabling never replays, for both restart orderings;
- history is skipped;
- a refusing channel sends, claims and marks nothing;
- leases are held per reply, and the delivery cursor never moves backwards;
- a reply waiting through an owner-feed outage is decided on the recovered conversation;
- a claim that takes a while is decided again when won (new address, opt-out);
- a reply lost to a claimant that died is recovered once its lease lapses;
- a queued send is built when its turn comes (the senders' shared
InOrder); - the first hand toggle keeps a rule-contributed channel's history boundary;
- a claim pending across the first hand toggle of another channel still delivers the rule channel's reply;
- the origin channel, turned off and on again, never answers a reply written while it was off;
- an origin row is not refused by a push-only health, but still by an unavailable installation.
MeshWeaver.AI.Test.AssistantChannelsMeshTest(3) runs on a real mesh. It covers the setting round-trip; rule nodes; the creation stamp and the hand toggle; and two claimants racing on the real thread hub, where exactly one wins, the loser's report changes nothing, the winner's marks the reply delivered and releases the lease, and a delivered reply is never claimed again.WhatsAppPushTests,AppleMessagesPushTests,TeamsPushTests: one target per person, the 24 h refusal,PushedOff, Teams never reading linked, a Teams-started conversation's row not shown as refused, and in each sender off → reply completes → on → restart: the reply written while off is not sent, the next one is.- Falsified (each fix removed, the tests named fail):
- baselining the in-flight reply fails the running-round test;
- letting a live lease be taken over fails three (the pure claim test, the engine's two-replica test, and the mesh race);
- deciding at queue time fails the queued-send test;
- keeping a failed owner's cached state fails both outage tests;
- Teams reading linked fails the Teams test;
- keeping watches outside the window fails the window test;
- keying the lease per channel, reducing the cursor to identity, dropping the inherited stamp, building queued sends at queue time, and disabling the claim retry each fail their test;
- stamping inherited channels with the toggle time fails the delayed-claim test (the reply is dropped silently as history);
- authorizing an origin reply by membership only fails the core off/on test and the off/complete/on/restart test of all three senders;
- applying the push-only health to the origin row fails the Teams-origin tests;
- the earlier falsifications still hold (reachability check, hand-set over rules, owner-only target, creation stamp, rule-delete guard).
Not established
- No end-to-end run on a live channel. Nothing here sent a real WhatsApp, Teams or iMessage message. The engine is proven on a scripted transport, and each channel's transport is the one its existing sender already used.
- The push panel and the settings section were not rendered on a mesh. They are templates with fed grids, and their row builders are pinned as pure functions. Nobody has looked at a rendered frame, and the strings are English only.
- Only home conversations are pushed (
{user}/_Thread, the 50 most recent). A conversation stored in a Space shows a note instead of toggles. Widening that would need a cross-partition watch per linked person, which this change avoids on purpose. - Replying from the pushed channel lands in that channel's own conversation, not in the conversation whose reply was pushed. Routing an answer back into the pushed conversation is not built.
- An old WhatsApp or iMessage conversation after
/new(no longer the number's current one) is not pushed even if its owner turns the channel on for it. Its own sender declines it as before (NotCurrentThread), and the engine leaves channel-started conversations to their own sender. - Turning a channel off in settings does not stop inbound. A person who turns WhatsApp off can still write to the assistant there and get answers in that conversation. The setting governs pushing, not the channel's own conversation.
- Teams push is off (see the table). Nothing here verifies a personal chat, so nothing is pushed to Teams.
- A rule's boundary is when the rule changed, not when the conversation began to match it. An idle conversation whose topic changes to match an existing rule can push its last answer once (to the owner's own address). Fixing that needs a durable "matched since" on the conversation; using the watch's start instead would lose a reply completed while the sender was down.
- The lease is two minutes. A send slower than that could, in principle, be taken over by another replica. Nothing measured how slow a real send gets.