Push notification for chat replies you didn't wait for
Send a message to a chat, leave before the reply lands → get a push with the reply. Suppress the push when the reply was seen — in the mobile app or the OS web app.
Status summary#
Implementation complete; local checks all green (typecheck, lint, knip, format, full test suite). PR: #2422 (draft).
- Done: all contracts, producer sibling processor, device suppression (grace + claims + claim-before-intent race + per-user audience), mobile + web claim clients, producer/device unit tests.
- Possible follow-ups: reply-specific e2e (the devices e2e covers the delivery pipeline), notifying on threads created before this change (siblings attach at creation only).
Decisions (settled)#
- Reuse the existing notification pipeline. New producer appends
events.iterate.com/notification/requestedto the project root stream; device fan-out, Expo delivery, receipts, andagent-chatdeep-linking already exist (apps/os/src/domains/notifications/,apps/os/src/domains/devices/). - Presence claims, not app-lifecycle events. Copy the
project/approval-presentedpattern: a foregrounded client viewing the thread appends a claim when the reply renders; the device processor holds the push for a grace window and settles itsuppressedif a claim arrives. Noapp/exitedtelemetry — direct evidence beats lifecycle inference, works identically for web ("seen in OS app counts" falls out for free), and there's no events ingestion service to build on anyway. Generic mobile lifecycle telemetry = separate future task if wanted. - Trigger scope: any plain chat thread (created via
agents.create()— mobile/agents/mobile/*and web threads alike), gated on the message having a user actor. Sub-agent chatter (developerrole / agent actor) and integration threads (Slack/Telegram actors, which already notify in-channel) produce nothing. Existing threads don't get the sibling retroactively — new threads only. - Audience: the sender, not the project. Extend the intent's
audienceunion with{kind: "user", userId}; device processor skips obligations whose audience doesn't match itsownerId. Requires stamping the sender's identity (auth.principal) on useragents/context-addedevents — todayactor: {type:"user", origin}has no userId. - Producer: sibling processor on the agent stream (Slack/Telegram/Email precedent,
input.siblinginagent-defaults.ts). State machine: latest user-actor message opens a "pending turn" (captures userId);agents/web-message-sentwith a pending turn emits ONE intent to the root stream and clears it — so multi-message turns coalesce to one push per user turn. - Suppression identity:
(path, replyEventOffset). New claim eventproject/agent-reply-presented {path, replyEventOffset}(standalone catalog, same cycle-breaking pattern asapproval-presented-contract.ts). Intent carries top-levelagentReplyEventOffsetmirroringapprovalRequestEventOffset. Exact-offset matching so an old claim can't suppress a newer reply's push. - Grace window: new
replyGraceMsconfig knob, default 3000ms (longer than approvals' 1500ms: claim requires reply render + append round-trip on a possibly-just-woken socket). Reuses the approval-grace alarm machinery, generalized. Readtasks/complete/*approval-push-suppression*notes first — there was a fixed race inreleaseApprovalGraces. - Push content: title from the agent's latest summary (fallback "Agent replied"), body = reply text truncated ~500 chars.
expiresAt= reply eventcreatedAt+ 1h — deterministic from the event, nevernow(redelivery must re-append an identical body). - Web claim ships in this iteration — it's half the point (idea #1). OS web thread view appends the same claim when the newest reply renders while the document is visible.
Assumptions made on your behalf#
- Web-initiated threads notify too (not just mobile-initiated) — the mechanism lands at the generic
create()path and per-path filtering seemed artificial. Shout if you want/agents/mobile/*only. - One push per user turn (not per assistant message).
auth.principalis the right user identity to match against deviceownerId(it's whatdevices.enrollalready stamps).- No unread/badge work (explicitly out of scope, see
tasks/mobile-native-followups.md§3).
Checklist#
- Stamp sender identity: add optional
userIdto the user actor variant inagentContextItemSchema(agent-processor-contract.ts); populate fromauth.principalinAgentRpcTarget.message()/ask()(rpc-targets.ts#contextActor) (userId rides the user actor; matches devices.enroll's ownerId by construction) -
NotificationIntentContract0.3.0:audienceunion gains{kind:"user", userId}; optional top-levelagentReplyEventOffset(notification-intent-contract.ts) - New
agent-reply-presented-contract.tscatalog (project contract owns/spreads it; device contract consumes viaprocessorDeps) (claim payload is{path, replyEventOffset}— per-stream offsets need the pair) - New chat-reply-notify sibling processor (contract + implementation) in
apps/os/src/domains/notifications/; registered viainput.siblingfor plainagents.create()threads (chat-reply-notify-{contract,implementation}.ts; registered in agent-durable-object.ts + rpc-targets create()) -
DeviceProcessorContract0.6.0: consume the claim;replyGraceMsconfig (3s); obligation gainsagentReplyEventOffset; audience filter on copied intents; subscription filter gains the claim event type (also addedrecentReplyClaims— see notes) - Device processor implementation: generalized grace machinery (
releaseGraces,repointGraceAlarm, sharedobligationGraceUntil); claims markpresentedAton exact(path, replyEventOffset)match; settlesuppressed(alarm slice key kept as "device-approval-grace" — armed slices persist across deploys) - Mobile: append claim from chat screen when newest assistant message renders foregrounded (lib/reply-presented.ts
useClaimReplyPresented;appForegroundedmoved there, shared with in-thread-approval.tsx) - OS web app: same claim from the web thread view (document-visible gate) (project-stream-view.tsx
useClaimReplyPresented— SQLite max-offset query + connectItx append) - Tests: producer node tests (8, chat-reply-notify.test.ts); device suppression/audience tests (5 new, device-processor.test.ts) (reply-specific e2e left as possible follow-up; the devices e2e already exercises the delivery pipeline)
-
pnpm typecheck && pnpm lint && pnpm knip && pnpm format && pnpm test(all green; itx-api.generated.ts regenerated for the actor/audience contract changes)
Implementation notes#
- Claim-before-intent race (not present in the approval design): a reply's claim and its intent are both triggered by the same
web-message-sentevent, so a client watching the thread can get its claim onto the root stream before the producer's intent — and both ride the same ordered root→device subscription lane. Losing that race would ring a phone the user is actively looking at. Fix: the device processor remembers reduced claims instate.recentReplyClaims(bounded: 10min retention / 50 entries, pruned deterministically at reduce), and an intent that arrives after its claim opens pre-claimed (presentedAtset). Approvals keep their existing accepted-race behavior — their request always commits long before a client can render the batch. - Coalescing: the producer keys on "reply that closes an open user turn" — consecutive assistant messages in one turn yield ONE push (the first reply closes the turn), and agent↔agent traffic (developer role) never opens a turn.
- Audience plumbing:
auth.principal(a string; what devices.enroll already stamps asownerId) now rides user context-added actors asuserId. Admin/CLI-sent messages stamp a principal that matches no device → no push (acceptable; better than spamming everyone). - Deterministic intent bodies:
expiresAt= reply eventcreatedAt+ 1h, title from foldedagent/summary-updated, body = reply truncated at 500 chars — a redelivery re-appends the identical body and dedupes on the idempotency key. - Migration: existing threads don't get the sibling (subscriptions are configured at creation); existing devices pick up the widened subscription filter (with the claim event type) on their next enroll →
push-token-updatedre-arm, i.e. next app open. - Sibling registration reuses
agentCreationForPath's existing singlesiblingslot — plaincreate()passed none before. Integration threads (Slack/Telegram/Email) are born elsewhere and unaffected.