Engineering

When "Notify the User" Meant "Notify the Tab They Already Had Open"

Eric Brasher August 18, 2026 at 8:20 AM 6 min read

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:

Delivery path
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:

C# — from inside an agent
// 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

CallEndpoint chosenLocal 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-After when 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:

fabrcore.json
"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.