Notification-to-Conversation Continuity
Persist lastNotificationContext per user. Inject when they reply so the agent knows what they're responding to.
Summary
When a user replies to a push notification (e.g., "Order #123 needs approval"), the agent needs context to understand what "it" refers to. Store the notification content (lastNotificationContext) per user; when they reply, inject it into the next prompt so the agent can reference it. Without this, the agent treats the reply as disconnected from the notification.
- Notification fires: "You have 3 new orders" → stored in Redis as
lastNotificationContext:{userId}. - User replies: "Approve the first one" → agent reads context from Redis, knows you're talking about orders.
- Storage: Redis (fast, real-time), or database (durable across server restarts).
- Injection point: System prompt or message history.
Problem
// ❌ Without context injection
Notification: "Order #123 from Alice needs approval"
(User reads notification on mobile, taps reply)
User: "Approve it"
Agent: "Approve what? I don't have context. Please clarify which order."Solution: Context Store
// services/notification-context.ts
import Redis from "redis";
const redis = Redis.createClient();
export interface NotificationContext {
eventType: string; // e.g., "transaction", "invoice_paid", "invoice_overdue"
type: "order" | "invoice" | "customer" | "record"; // Entity type
entityIds: string[]; // Support multiple entities (batch notifications)
summary: string; // Short summary of what the notification was about
timestamp: number; // When the notification was sent
action?: string; // Suggested action: "approve", "review", "send"
sourcePlatform: string; // Which platform received the notification
sourceMessageId?: string; // Platform message ID for threading
suggestedPrompts?: string[]; // Follow-up suggestions shown to user
}
export async function storeNotificationContext(
userId: string,
context: NotificationContext
): Promise<void> {
const key = `lastNotificationContext:${userId}`;
await redis.setEx(
key,
24 * 60 * 60, // 24 hours TTL
JSON.stringify(context)
);
}
export async function getNotificationContext(userId: string): Promise<NotificationContext | null> {
const key = `lastNotificationContext:${userId}`;
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
}
export async function clearNotificationContext(userId: string): Promise<void> {
const key = `lastNotificationContext:${userId}`;
await redis.del(key);
}Per-Event-Type Batch Windows
Not all events are equally urgent. Use different batching windows by event type to balance responsiveness with noise:
| Event Type | Batch Window | Rationale |
|---|---|---|
| Receipt/document match | Immediate | User just uploaded; wants confirmation now |
| New transactions | 10 min | Frequent but not urgent; batch avoids spam |
| Invoice paid | 10 min | Good news, not time-sensitive |
| Invoice overdue | 30 min | Important but action isn't instant |
| Recurring invoice upcoming | 60 min | Awareness only; no immediate action needed |
const BATCH_WINDOWS_MS: Record<string, number> = {
document_match: 0, // Immediate
transaction: 10 * 60 * 1000, // 10 minutes
invoice_paid: 10 * 60 * 1000, // 10 minutes
invoice_overdue: 30 * 60 * 1000, // 30 minutes
recurring_invoice_upcoming: 60 * 60 * 1000, // 60 minutes
};Immediate events skip the batch queue entirely — they're sent the moment they occur. Everything else accumulates during its window, then a single summary notification is dispatched.
Suggested Follow-Up Prompts
Include suggestedPrompts in the notification context. These serve two purposes: (1) shown as quick-reply buttons on mobile platforms, and (2) injected into the system prompt to guide the model's understanding of what the user might want:
await storeNotificationContext(userId, {
eventType: "transaction",
type: "record",
entityIds: newTransactionIds,
summary: `You have ${count} new transactions totaling ${total}`,
timestamp: Date.now(),
suggestedPrompts: [
"Show me them",
"Which ones need receipts?",
"Categorize them",
],
sourcePlatform: "whatsapp",
});When injecting into the prompt:
function formatNotificationContextForPrompt(
context: NotificationContext | null
): string {
if (!context) return "";
let result = `The user's latest notification: "${context.summary}".`;
if (context.suggestedPrompts?.length) {
result += ` Suggested follow-ups: ${context.suggestedPrompts.join(", ")}.`;
}
return result;
}Platform Session Constraints
Some platforms impose session windows. WhatsApp, for example, only allows free-form messages within 24 hours of the last user-initiated message. Outside this window, you must use pre-approved template messages:
function isWithinWhatsAppSessionWindow(
lastUserMessageAt: Date | null
): boolean {
if (!lastUserMessageAt) return false;
const elapsed = Date.now() - lastUserMessageAt.getTime();
return elapsed < 24 * 60 * 60 * 1000; // 24 hours
}Factor this into your notification dispatch — if outside the session window, send a template message (which has limited formatting) instead of a free-form AI-generated message.
Pattern: Dispatch Notification + Store Context
// services/notification-dispatcher.ts
import { storeNotificationContext } from "./notification-context";
import { sendNotification } from "@platform/notifications";
export async function notifyOrderApprovalNeeded(userId: string, orderId: string) {
const order = await db.getOrder(orderId);
// 1. Store context
await storeNotificationContext(userId, {
eventType: "order_approval",
type: "order",
entityIds: [orderId],
summary: `Order #${order.number} from ${order.customerName} needs approval`,
timestamp: Date.now(),
action: "approve",
sourcePlatform: "push",
});
// 2. Send notification
await sendNotification(userId, {
title: "Order Approval Needed",
body: `Order #${order.number} from ${order.customerName} awaits your approval`,
data: {
type: "order",
entityIds: [orderId],
platform: "push", // Used to route back to agent
},
});
}
export async function notifyNewInvoices(userId: string, invoiceIds: string[]) {
const invoices = await db.getInvoices(invoiceIds);
const summary = invoices.map(i => `${i.customerName}: ${i.amount}`).join(", ");
await storeNotificationContext(userId, {
eventType: "invoice_created",
type: "invoice",
entityIds: invoiceIds,
summary: `New invoices: ${summary}`,
timestamp: Date.now(),
action: "send",
sourcePlatform: "push",
});
await sendNotification(userId, {
title: "New Invoices",
body: `You have ${invoices.length} new invoices to review`,
});
}Pattern: Inject Context into Chat
When the user replies to a notification, retrieve and inject context:
// chat/assistant-runtime.ts
import { ToolLoopAgent } from "ai";
import { getNotificationContext, clearNotificationContext } from "@services/notification-context";
export async function streamAssistant(params: {
userId: string;
systemPrompt: string;
messages: ModelMessage[];
tools: Record<string, Tool>;
fromNotification?: boolean; // User replied to a notification
}) {
let { messages } = params;
// If reply-to-notification, inject context
if (params.fromNotification) {
const context = await getNotificationContext(params.userId);
if (context) {
// Prepend a system note to the conversation
const contextMessage: ModelMessage = {
role: "user",
content: `(Context: You previously sent a notification about "${context.summary}". The user is replying to that notification. Their message below is in response to: ${context.summary})`,
};
messages = [
...messages.slice(0, -1), // All messages except the latest
contextMessage,
messages[messages.length - 1], // The user's reply
];
}
}
const agent = new ToolLoopAgent({
model: openai("gpt-4o-mini"),
instructions: params.systemPrompt,
tools: params.tools,
stopWhen: stepCountIs(10),
});
const stream = await agent.stream({
messages,
experimental_transform: smoothStream(),
});
// After successful completion, clear the context (so next reply doesn't re-use old context)
await stream.finalMessage();
await clearNotificationContext(params.userId);
return stream;
}Alternate: Inject into System Prompt
Instead of adding a message, inject into the system prompt:
// chat/prompt.ts
export async function buildSystemPromptWithNotificationContext(
userCtx: UserContext,
userId: string
): Promise<string> {
const basePrompt = buildSystemPrompt(userCtx);
const notifContext = await getNotificationContext(userId);
if (notifContext) {
const contextBlock = `
## Recent Notification Context
The user was just notified: "${notifContext.summary}"
- Entity type: ${notifContext.type}
- Entity IDs: ${notifContext.entityIds.join(", ")}
- Suggested action: ${notifContext.action ?? "none"}
If the user's message seems to be in response to this notification, they are referring to the above ${notifContext.type}(s).
`;
return basePrompt + contextBlock;
}
return basePrompt;
}Platform-Specific Examples
WhatsApp Notification + Reply
// platforms/whatsapp/notification.ts
import { twilioClient } from "@platform/whatsapp/client";
import { storeNotificationContext } from "@services/notification-context";
export async function sendWhatsAppNotification(userId: string, orderId: string) {
const order = await db.getOrder(orderId);
// Store context
await storeNotificationContext(userId, {
eventType: "order_approval",
type: "order",
entityIds: [orderId],
summary: `Order #${order.number} from ${order.customerName} for ${order.total} needs your approval.`,
timestamp: Date.now(),
action: "approve",
sourcePlatform: "whatsapp",
});
// Send via WhatsApp
await twilioClient.messages.create({
from: `whatsapp:+1234567890`,
to: `whatsapp:${userId}`,
body: `Order Approval Needed:\n\nOrder #${order.number} from ${order.customerName}\nAmount: ${order.total}\n\nReply with "Approve" or "Reject"`,
});
}
// platforms/whatsapp/handler.ts — when user replies
export async function handleWhatsAppReply(message: WhatsAppMessage) {
const normalized = normalizeWhatsAppMessage(message);
const userCtx = await db.getUserContext(normalized.userId, normalized.teamId);
// Build prompt WITH notification context
const systemPrompt = await buildSystemPromptWithNotificationContext(
userCtx,
normalized.userId
);
// Run agent
const stream = await runCoreAgent({
userContext: userCtx,
platform: "whatsapp",
messages: [{ role: "user", content: normalized.text }],
tools: await getTools(normalized.teamId),
});
// ... send response ...
}Telegram Notification + Reply
// platforms/telegram/notification.ts
import { telegramBot } from "@platform/telegram/client";
import { storeNotificationContext } from "@services/notification-context";
export async function sendTelegramNotification(userId: string, itemCount: number) {
const items = await db.getNewItems(userId, itemCount);
await storeNotificationContext(userId, {
eventType: "new_items",
type: "record",
entityIds: items.map(i => i.id),
summary: `You have ${itemCount} new items to review`,
timestamp: Date.now(),
action: "review",
sourcePlatform: "telegram",
});
await telegramBot.sendMessage(userId, `
New items: ${itemCount}
${items.map(i => `• ${i.title} (${i.date})`).join("\n")}
Reply to manage them.
`);
}Database Fallback (for Server Restarts)
For durability across server restarts, use the database instead of (or in addition to) Redis:
// db/notification-context.ts
export async function storeNotificationContextInDb(
userId: string,
context: NotificationContext
): Promise<void> {
await db.notificationContexts.upsert({
userId,
context: JSON.stringify(context),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h TTL
});
}
export async function getNotificationContextFromDb(userId: string): Promise<NotificationContext | null> {
const row = await db.notificationContexts.findUnique({ where: { userId } });
if (!row || row.expiresAt < new Date()) return null;
return JSON.parse(row.context);
}
// Wrapper: try Redis first, fallback to DB
export async function getNotificationContext(userId: string): Promise<NotificationContext | null> {
// Try Redis (fast)
const cached = await redis.get(`lastNotificationContext:${userId}`);
if (cached) return JSON.parse(cached);
// Fall back to DB
return getNotificationContextFromDb(userId);
}TTL and Cleanup
Notification context should expire after a period (usually 24 hours) to avoid stale references:
// Automatic cleanup via TTL
// Redis: setEx(..., 24 * 60 * 60, ...) handles it
// Database: periodic cron job
// scheduled-tasks/cleanup-old-notification-contexts.ts
export async function cleanupOldContexts() {
const count = await db.notificationContexts.deleteMany({
where: { expiresAt: { lt: new Date() } },
});
logger.info(`Cleaned up ${count} expired notification contexts`);
}
// Schedule via cron (e.g., hourly)
// 0 * * * * → run at top of every hourTesting
// __tests__/notification-context.test.ts
import {
storeNotificationContext,
getNotificationContext,
type NotificationContext,
} from "@services/notification-context";
test("stores and retrieves notification context", async () => {
const userId = "user-123";
const context: NotificationContext = {
eventType: "order_approval",
type: "order" as const,
entityIds: ["order-456"],
summary: "Order #789 needs approval",
timestamp: Date.now(),
action: "approve",
sourcePlatform: "push",
};
await storeNotificationContext(userId, context);
const retrieved = await getNotificationContext(userId);
expect(retrieved).toEqual(context);
});
test("context expires after TTL", async () => {
const userId = "user-123";
const context = { ... };
await storeNotificationContext(userId, context);
// Simulate TTL expiry
jest.useFakeTimers();
jest.advanceTimersByTime(24 * 60 * 60 * 1000 + 1);
const retrieved = await getNotificationContext(userId);
expect(retrieved).toBeNull();
});
test("agent uses context when replying to notification", async () => {
const userId = "user-123";
await storeNotificationContext(userId, {
eventType: "order_approval",
type: "order",
entityIds: ["order-123"],
summary: "Order #789 from Alice needs approval",
timestamp: Date.now(),
action: "approve",
sourcePlatform: "push",
});
const stream = await streamAssistant({
userId,
systemPrompt: "...",
messages: [{ role: "user", content: "Approve it" }],
tools: {},
fromNotification: true,
});
const response = await stream.finalMessage();
// Agent should reference the order, not ask "what should I approve?"
expect(response.content[0].text).toContain("order");
});Checklist
- Create
NotificationContextinterface. - Store context in Redis (or DB) with 24h TTL.
- On notification dispatch, call
storeNotificationContext. - When user replies, retrieve context and inject into agent.
- Test that agent references notification context.
- Clear context after agent processes the reply.
- Add periodic cleanup for expired contexts.
- Monitor context hit rate (how often user replies to notifications).
See Also
- Platform-Agnostic Core — handling replies across platforms.
- System Prompt as Configuration — injecting context into prompts.
- Agentic Loop — where the agent runs.