When "Notify the User" Meant "Notify the Tab They Already Had Open"
An agent finishes a long job and calls SendToUserAsync. The person gets nothing in Teams. They were sitting in a Surface tab at the time, so the message went there instead — delivered, technically, to a window nobody was looking at. This week we changed what an explicit delivery target means.
Observer-First Was the Right Default, and the Wrong Rule
FabrCore's principal-delivery pipeline routes agent-to-principal messages through a durable outbox to whichever channel relay can reach the person:
agent -> PrincipalGrain -> durable outbox -> channel relay -> provider
The original rule was observer-first: if the principal has a live client observer connected — Surface, a WebSocket client, a legacy observer — that observer consumes the message and no external relay runs. That is exactly right for a reply. Someone actively chatting should not get a duplicate Teams notification for every turn.
The problem is that "has a live observer" and "is paying attention" are not the same condition, and the pipeline could not tell them apart. A background job that finishes at 4pm has no idea whether the browser tab it is about to deliver into is in front of the person or behind eleven others. Observer-first quietly made that decision anyway, and made it the same way every time.
The Fix: an Explicit Channel Is Intent
A PrincipalDeliveryTarget with a nonblank channel is now treated as explicit external-routing intent. That message is not notified, recorded, or replayed through local observers, even when one is connected:
// Unchanged: observer-first. A connected Surface session consumes this.
await SendToUserAsync("Here is that summary.");
// New: channel-only. This provider's most recently active eligible endpoint,
// regardless of what else the principal has open.
await SendToUserAsync(
"Your report is ready",
target: new PrincipalDeliveryTarget("m365copilot"));
// Existing: a specific eligible conversation.
await SendToUserAsync(
"Send this to a specific conversation",
target: new PrincipalDeliveryTarget("m365copilot", endpointId));
The middle form is the one that did not exist before. Previously you could target a provider only by naming a specific endpoint, which meant the caller had to have captured an endpoint ID from an earlier turn and had to reason about whether it was still eligible. That is a lot of bookkeeping to ask of an agent whose actual intent was "put this in Teams."
A channel-only target says exactly that: this provider, its most recently active eligible endpoint, you work out which one. An endpoint target still pins a specific conversation when that matters — a verification code belongs in the conversation that asked for it, not the newest one.
Three Routing Modes, One Decision
| Call | Endpoint chosen | Local observers |
|---|---|---|
| No target | Most recently active eligible endpoint across every installed relay | Take precedence |
PrincipalDeliveryTarget("m365copilot") |
That provider's most recently active eligible endpoint | Bypassed |
PrincipalDeliveryTarget("m365copilot", endpointId) |
That eligible conversation | Bypassed |
The useful property here is that the decision moved to the call site. The agent author knows whether they are answering a question or interrupting someone's afternoon; the delivery pipeline does not. Encoding that as the presence or absence of a channel keeps the common case — a plain reply — exactly as short as it was.
What Did Not Change
Everything about durability stayed put, and it is worth restating because proactive delivery is where at-least-once semantics actually bite.
- Pending work is persisted before resolution and moved atomically to the outbox.
- Only the oldest outbox entry for a principal is leased, never several concurrently.
- Throttling, 5xx, network, and timeout failures retry, honoring
Retry-Afterwhen the provider sends it. - Permanent 4xx and mapping failures are dead-lettered. Stale conversation references are marked unavailable until another eligible inbound turn refreshes them.
- Relays send on bounded worker shards rather than blocking the principal grain.
Delivery remains durable at-least-once. A provider that accepts a request immediately before a crash or timeout can produce a duplicate on retry, so anything a person sees should be idempotent — and a proactive notification is precisely the kind of message where a duplicate is noticed.
Turning It On
Proactive delivery is opt-in and stays off until the ordinary turn path is working:
"Microsoft365Copilot": {
"Proactive": { "Enabled": true }
}
With it enabled, every eligible inbound turn stores a versioned conversation endpoint in the principal context before the turn observer subscribes, refreshes its last-active time, and stamps its stable ID into Args["Microsoft365Copilot:DeliveryEndpointId"]. The default allowlist captures only personal conversations and keeps at most eight endpoints per principal — which is also why a channel-only target is well defined: there is always a newest eligible one, or none at all.
Agent code stays provider-neutral throughout. Nothing in the snippets above names Teams, references a conversation object, or touches a Microsoft SDK type; conversation references never enter Core.