Skip to content

In-App Notification Architecture

Edit page

How the ReCursor bridge server notifies the mobile app about agent events — no Firebase, fully WebSocket-based.


Agent Event -> Bridge Server -> WebSocket -> Mobile App -> Local Notification (if backgrounded)

All notifications flow through the existing WebSocket connection. No external push services (FCM/APNs) are used.


  • Bridge sends a notification message over the WebSocket.
  • App displays an in-app banner/toast or updates the notification center badge.
  • No OS-level notification needed — the user is already in the app.
  • If the WebSocket connection is still alive (kept by OS background mode), the app receives the event and displays a local notification via flutter_local_notifications.
  • Local notifications support action buttons (e.g., “Approve” / “View”).
  • Bridge stores events in a pending event queue.
  • On reconnect, bridge replays all unacknowledged events.
  • App processes the backlog and shows relevant notifications.

TypeTriggerPriorityAction
Task CompleteAgent finishes a taskNormalNavigate to result
Approval RequiredAgent needs tool call approvalHighApprove/reject buttons
ErrorAgent encounters an errorHighNavigate to chat
Agent IdleAgent waiting for inputLowNavigate to chat

{
"type": "notification",
"id": "notif-001",
"payload": {
"session_id": "sess-abc123",
"notification_type": "approval_required",
"title": "Approval needed: Update bridge_setup_screen.dart",
"body": "Claude Code wants to tighten bridge URL validation before pairing.",
"priority": "high",
"data": {
"tool_call_id": "tool-001",
"screen": "approval_detail"
}
}
}

{
"type": "notification_ack",
"payload": {
"notification_ids": ["notif-001", "notif-002"]
}
}

The bridge removes acknowledged events from the pending queue.


  • Bell icon in the app bar with unread count badge.
  • Tap to open notification list (grouped by session).
  • Each notification is tappable — routes to the relevant screen.
  • “Mark all read” action.
  • Notifications persist locally in Drift for offline access.

  • WebSocket heartbeat ensures connection is alive.
  • If heartbeat fails, app shows “disconnected” banner — user knows notifications won’t arrive.
  • On reconnect, bridge replays full event backlog (bounded by configurable max age, e.g., 24 hours).
  • No silent failure — if the connection is down, the user sees it immediately.

WebSocket-onlyFirebase FCM
Works when app is killedNoYes
Requires Google/Apple servicesNoYes
PrivacyFull controlData via Google servers
ComplexitySimpler, no external setupFCM config, APNs certs
Reliability when connectedImmediate, guaranteedBest-effort delivery

The WebSocket-only approach is chosen because the app’s primary value requires an active bridge connection anyway. If the bridge is unreachable, notifications are moot.


// Initialize local notifications
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
await flutterLocalNotificationsPlugin.initialize(
const InitializationSettings(android: androidSettings, iOS: iosSettings),
onDidReceiveNotificationResponse: (response) {
// Handle notification tap
_handleNotificationTap(response.payload);
},
);
// Show local notification
Future<void> showLocalNotification(AppNotification notification) async {
const androidDetails = AndroidNotificationDetails(
'recursor_channel',
'ReCursor Notifications',
importance: Importance.high,
priority: Priority.high,
showWhen: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
await flutterLocalNotificationsPlugin.show(
notification.id.hashCode,
notification.title,
notification.body,
const NotificationDetails(android: androidDetails, iOS: iosDetails),
payload: jsonEncode(notification.data),
);
}


Last updated: 2026-03-17