Agent Surface

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 TypeBatch WindowRationale
Receipt/document matchImmediateUser just uploaded; wants confirmation now
New transactions10 minFrequent but not urgent; batch avoids spam
Invoice paid10 minGood news, not time-sensitive
Invoice overdue30 minImportant but action isn't instant
Recurring invoice upcoming60 minAwareness 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 hour

Testing

// __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 NotificationContext interface.
  • 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

On this page