/// /** * Hotel Membership Extractor (v2) * * Refactored to use the gmail-agentic-search base pattern. * Finds hotel loyalty program membership numbers in Gmail. * * Usage: wish({ query: "#hotelMemberships" }) to get discovered memberships. */ import { Default, derive, handler, ifElse, NAME, pattern, UI, Writable, // wish, // TEMPORARILY DISABLED - may cause self-referential loop } from "commontools"; import GmailAgenticSearch, { type SearchProgress, } from "../core/experimental/gmail-agentic-search.tsx"; import { defineItemSchema, InferItem, listTool, } from "../core/util/agentic-tools.ts"; // Scan mode: "full" = comprehensive all-time search, "recent" = last 7 days only type ScanMode = "full" | "recent"; // Debug flag for development - disable in production const DEBUG_HOTEL = false; // ============================================================================ // EFFECTIVE QUERY HINTS // ============================================================================ const EFFECTIVE_QUERIES = [ 'from:hilton.com subject:"welcome" OR subject:"hilton honors"', 'from:marriott.com subject:"welcome" OR subject:"bonvoy"', 'from:hyatt.com subject:"welcome to world of hyatt"', 'from:ihg.com subject:"welcome" OR subject:"ihg rewards"', 'from:accor.com subject:"welcome" OR subject:"accor"', 'from:hilton.com subject:"statement"', 'from:marriott.com subject:"statement"', "from:hilton.com OR from:hiltonhonors.com", "from:marriott.com OR from:email.marriott.com", "from:hyatt.com OR from:worldofhyatt.com", "from:ihg.com OR from:ihgrewardsclub.com", "from:accor.com OR from:accorhotels.com", ]; // ============================================================================ // SCHEMA - DEFINED ONCE! (replaces interface + input type + JSON schema) // ============================================================================ // The new elegant API: define schema once, get type-checked dedupe fields const MembershipSchema = defineItemSchema({ hotelBrand: { type: "string", description: "Hotel chain name (e.g., 'Marriott', 'Hilton')", }, programName: { type: "string", description: "Loyalty program name (e.g., 'Marriott Bonvoy', 'Hilton Honors')", }, membershipNumber: { type: "string", description: "The membership number (digits only)", }, tier: { type: "string", description: "Status tier if known (Member, Silver, Gold, Platinum, Diamond)", }, sourceEmailId: { type: "string", description: "The email ID from searchGmail results", }, sourceEmailSubject: { type: "string", description: "The email subject" }, sourceEmailDate: { type: "string", description: "The email date" }, confidence: { type: "number", description: "0-100 confidence score" }, }, [ "hotelBrand", "programName", "membershipNumber", "sourceEmailId", "sourceEmailSubject", "sourceEmailDate", "confidence", ]); // Derive TypeScript type from schema (for UI code) // Note: _fromWish is an internal property added by the wish system for imported records type MembershipRecord = InferItem & { extractedAt: number; _fromWish?: boolean; }; interface HotelMembershipInput { memberships?: Default; lastScanAt?: Default; isScanning?: Default; maxSearches?: Default; // 0 = unlimited, >0 = limit searches // Current scan mode - persisted to know if last scan was full or recent currentScanMode?: Default; // Multi-account support: which Google account to use accountType?: Default<"default" | "personal" | "work", "default">; // Shared with base pattern for coordinating progress UI searchProgress?: Default; } /** Hotel loyalty membership extractor from Gmail. #hotelMemberships */ interface HotelMembershipOutput { memberships: MembershipRecord[]; lastScanAt: number; count: number; } // NOTE: Schema defined above using defineItemSchema - no separate INPUT_SCHEMA needed! // ============================================================================ // HOTEL RESULT SCHEMA // ============================================================================ const HOTEL_RESULT_SCHEMA = { type: "object", properties: { searchesPerformed: { type: "array", items: { type: "object", properties: { query: { type: "string" }, emailsFound: { type: "number" }, }, }, }, membershipsFound: { type: "number", description: "Total count of memberships found via reportMembership", }, summary: { type: "string", description: "Brief summary of what was searched and found", }, }, required: ["membershipsFound", "summary"], }; // ============================================================================ // PATTERN // ============================================================================ // Helper to generate date filter for recent mode (last 7 days) const getRecentDateFilter = (): string => { const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); // Gmail date format: YYYY/MM/DD const year = weekAgo.getFullYear(); const month = String(weekAgo.getMonth() + 1).padStart(2, "0"); const day = String(weekAgo.getDate()).padStart(2, "0"); return `after:${year}/${month}/${day}`; }; // All hotel brands we search for const ALL_BRANDS = ["Marriott", "Hilton", "Hyatt", "IHG", "Accor"]; // Module-scope handler for starting a scan with mode configuration const startScan = handler< unknown, { mode: ScanMode; searchLimit: number; // 0 = unlimited, >0 = limit currentScanMode: Writable>; maxSearches: Writable>; isScanning: Writable>; searchProgress: Writable; } >((_, state) => { const mode = state.mode; if (DEBUG_HOTEL) { console.log( `[HotelMembership] Starting scan in ${mode} mode with limit ${state.searchLimit}`, ); } state.currentScanMode.set(mode); state.maxSearches.set(state.searchLimit); // Initialize progress to trigger progressUI display state.searchProgress.set({ currentQuery: "", completedQueries: [], status: "searching", searchCount: 0, }); state.isScanning.set(true); }); const HotelMembershipExtractorV2 = pattern< HotelMembershipInput, HotelMembershipOutput >( ( { memberships, lastScanAt, isScanning, maxSearches, currentScanMode, accountType, searchProgress, }, ) => { // ======================================================================== // CUSTOM TOOL: Report Membership // NEW ELEGANT API: Single call with type-checked dedupe fields! // ======================================================================== const reportMembership = listTool(MembershipSchema, { items: memberships, dedupe: ["hotelBrand", "membershipNumber"], // TypeScript checks these! idPrefix: "membership", timestamp: "extractedAt", }); // ======================================================================== // WISH IMPORT: TEMPORARILY DISABLED - may cause self-referential loop // when pattern wishes for #hotelMemberships but also exports it // ======================================================================== // Use local memberships only (no wish import for now) const allMemberships = memberships; // Track counts (simplified without wish) const localMembershipCount = derive( memberships, (list) => list?.length || 0, ); // ======================================================================== // MULTI-ACCOUNT DETECTION // ======================================================================== // Find brands with multiple different membership numbers const brandsWithMultipleAccounts = derive( allMemberships, (list: MembershipRecord[]) => { const brandNumbers: Record> = {}; for (const m of (list || [])) { if (!m) continue; // Skip null/undefined entries during hydration if (!brandNumbers[m.hotelBrand]) { brandNumbers[m.hotelBrand] = new Set(); } brandNumbers[m.hotelBrand].add(m.membershipNumber); } const multiAccountBrands: Record< string, { numbers: string[]; memberships: MembershipRecord[] } > = {}; for (const [brand, numbers] of Object.entries(brandNumbers)) { if (numbers.size > 1) { multiAccountBrands[brand] = { numbers: Array.from(numbers), memberships: (list || []).filter((m) => m && m.hotelBrand === brand ), }; } } return multiAccountBrands; }, ); const hasMultipleAccounts = derive( brandsWithMultipleAccounts, (brands) => Object.keys(brands).length > 0, ); // ======================================================================== // AGENT GOAL // ======================================================================== // IMPORTANT: Do NOT derive from memberships! Changing the goal during a scan // triggers an infinite loop (goal changes → agent restarts → finds membership // → goal changes → agent restarts...). Only derive from scan settings. const agentGoal = derive( [maxSearches, currentScanMode], ([max, scanMode]: [number, ScanMode]) => { const isQuickMode = max > 0; const isRecentMode = scanMode === "recent"; const dateFilter = isRecentMode ? getRecentDateFilter() : ""; return `Find hotel loyalty program membership numbers in my Gmail. ${ isRecentMode ? `šŸ“… RECENT SCAN MODE: Only searching emails from the last 7 days. Date filter to use: ${dateFilter} ` : "" } ${ isQuickMode ? `\nāš ļø QUICK TEST MODE: Limited to ${max} searches. Focus on high-value queries!\n` : "" } Your task: 1. Use searchGmail to search for hotel loyalty emails${ isRecentMode ? ` (ADD "${dateFilter}" to ALL queries!)` : "" } 2. Analyze the returned emails for membership numbers 3. When you find a membership: IMMEDIATELY call reportMembership to save it 4. Move on to the next brand after 1-2 queries per brand ${ isQuickMode ? "PRIORITY QUERIES (use these first in quick mode):" : "EFFECTIVE QUERIES (proven to find memberships):" } ${ EFFECTIVE_QUERIES.slice(0, isQuickMode ? 5 : EFFECTIVE_QUERIES.length) .map((q, i) => { const query = isRecentMode ? `(${q}) ${dateFilter}` : q; return `${i + 1}. ${query}`; }).join("\n") } Hotel brands to search for: ${ ALL_BRANDS.map((b) => { switch (b) { case "Marriott": return "- Marriott (Marriott Bonvoy)"; case "Hilton": return "- Hilton (Hilton Honors)"; case "Hyatt": return "- Hyatt (World of Hyatt)"; case "IHG": return "- IHG (IHG One Rewards)"; case "Accor": return "- Accor (ALL - Accor Live Limitless)"; default: return "- " + b; } }).join("\n") } In email bodies, look for patterns like: - "Member #" or "Membership Number:" followed by digits - "Bonvoy Number:", "Hilton Honors #:", "World of Hyatt #:" - Account numbers are typically 9-16 digits When you find a membership, call reportMembership with: - hotelBrand: Hotel chain name (e.g., "Marriott", "Hilton") - programName: Loyalty program name (e.g., "Marriott Bonvoy", "Hilton Honors") - membershipNumber: The actual number (digits only, no spaces) - tier: Status tier if mentioned (Member, Silver, Gold, Platinum, Diamond) - sourceEmailId: The email ID from searchGmail results - sourceEmailSubject: The email subject - sourceEmailDate: The email date - confidence: 0-100 how confident you are IMPORTANT: Call reportMembership for EACH membership as you find it. Don't wait! ${ isRecentMode ? "\nIMPORTANT: ALWAYS include the date filter in your search queries!" : "" } ${ isQuickMode ? "\nNote: If you hit the search limit, stop and return what you found." : "" } āš ļø STOPPING RULES - FOLLOW THESE STRICTLY: - Search each brand with AT MOST 2 queries, then move to the next brand - After checking all ${ALL_BRANDS.length} brands (${ ALL_BRANDS.join(", ") }), STOP and return your summary - Do NOT keep searching the same brand with variations - Do NOT search more than ~${ ALL_BRANDS.length * 2 } total queries (2 per brand max) - If a brand has no results after 1-2 tries, move on - don't keep trying - When you've covered all brands, IMMEDIATELY produce your final summary YOUR FINAL OUTPUT should summarize: which brands you searched, how many memberships found, and any issues.`; }, ); // ======================================================================== // SHARED SIGNAL CELL (for foundItems feature) // ======================================================================== // Create signal cell HERE and pass to base pattern - both share same cell // This follows the "share cells by making them inputs" pattern // See: community-docs/superstitions/2025-12-04-share-cells-between-composed-patterns.md const itemFoundSignal = Writable.of(0); // Track last membership count in a Writable (closure vars don't persist in derive) const lastMembershipCountCell = Writable.of(0); // ======================================================================== // CREATE BASE SEARCHER // ======================================================================== const searcher = GmailAgenticSearch({ agentGoal, systemPrompt: `You are a hotel loyalty membership extractor. Your job: Search Gmail to find hotel loyalty program membership numbers. You have TWO tools: 1. searchGmail({ query: string }) - Search Gmail and return matching emails 2. reportMembership({ hotelBrand, programName, membershipNumber, tier?, sourceEmailId, sourceEmailSubject, sourceEmailDate, confidence }) - SAVE a found membership WORKFLOW - Follow this order: 1. Search for emails from ONE hotel brand (1-2 queries max per brand) 2. Read the email bodies for membership numbers 3. When you find a membership: IMMEDIATELY call reportMembership 4. Move to the NEXT brand (don't keep searching the same brand) 5. After all brands checked: STOP and produce your final summary CRITICAL STOPPING RULES: - Maximum 2 searches per brand, then move on - After checking all brands once, you are DONE - Do NOT try variations of the same search - Do NOT search indefinitely - When finished, produce your final structured output IMMEDIATELY Report memberships as you find them. Don't wait until the end.`, suggestedQueries: EFFECTIVE_QUERIES, resultSchema: HOTEL_RESULT_SCHEMA, additionalTools: { reportMembership: { description: "Report a found membership number. Call this IMMEDIATELY when you find a valid membership number. It will be saved automatically.", handler: reportMembership, // Already bound - no second call needed! }, }, title: "šŸØ Hotel Membership Extractor", scanButtonLabel: "šŸ” Scan for Memberships", maxSearches, isScanning, lastScanAt, searchProgress, // Shared cell for coordinating progress UI accountType, // Multi-account support: passes through to reactive wish // Community query sharing // Note: Using hardcoded URL since import.meta.url not supported in CT compiler agentTypeUrl: "https://raw.githubusercontent.com/anthropics/community-patterns/main/patterns/jkomoros/hotel-membership-gmail-agent.tsx", enableCommunityQueries: true, // Enable fetching/upvoting community queries // Only show queries in "My Saved Queries" that actually found memberships onlySaveQueriesWithItems: true, // Pass shared signal cell - base pattern watches this itemFoundSignal, }); // ======================================================================== // WATCH MEMBERSHIPS TO MARK QUERIES AS EFFECTIVE // ======================================================================== // When reportMembership successfully adds a membership, signal the base pattern. // This is the idiomatic pattern: tool writes to state cell, parent watches and increments signal. // See: community-docs research on tool-to-parent communication patterns // NOTE: Using Cells to track state because closure vars don't persist across derive executions derive( [memberships, lastMembershipCountCell], ([list, _lastCountRef]: [MembershipRecord[], number]) => { // NOTE: derive doesn't unwrap locally-created cells, only pattern input cells // So we use .get() to read the actual value // See: community-docs/superstitions/2025-12-08-locally-created-cells-not-unwrapped-in-derive.md const currentCount = list?.length || 0; const lastCount = lastMembershipCountCell.get() || 0; if (currentCount > lastCount) { // New membership was added - signal the base pattern to mark the query if (DEBUG_HOTEL) { console.log( `[HotelMembership] New membership detected (${lastCount} -> ${currentCount}), signaling itemFoundSignal`, ); } // Increment the signal - base pattern watches this and marks the query const currentSignal = itemFoundSignal.get() || 0; itemFoundSignal.set(currentSignal + 1); // Update last count cell - prevents this derive from running again with same condition lastMembershipCountCell.set(currentCount); } }, ); // ======================================================================== // CUSTOM SCAN HANDLERS (with mode support) // ======================================================================== // Bind handlers for each mode (startScan is defined at module scope) // Full Scan: all time, unlimited searches const startFullScan = startScan({ mode: "full", searchLimit: 0, // Unlimited currentScanMode, maxSearches, isScanning, searchProgress, }); // Recent Scan: last 7 days, limited searches (quick check) const startRecentScan = startScan({ mode: "recent", searchLimit: 5, // Quick check currentScanMode, maxSearches, isScanning, searchProgress, }); // ======================================================================== // DERIVED VALUES // ======================================================================== // Use allMemberships (local + imported) for display const totalMemberships = derive( allMemberships, (list) => list?.length || 0, ); // Pre-compute button label (outside ifElse to avoid reactive loops) const fullScanLabel = derive( maxSearches, (max) => max > 0 ? "⚔ Quick Scan" : "šŸ” Full Scan", ); // Pre-compute scan mode message const scanModeMessage = derive( currentScanMode, (mode: ScanMode) => mode === "recent" ? "šŸ“… Recent mode: searching last 7 days only" : "šŸ” Full mode: searching all emails", ); // Pre-compute scan mode short label for debug const scanModeLabel = derive( currentScanMode, (mode: ScanMode) => mode === "recent" ? "šŸ“… Recent" : "šŸ” Full", ); // Pre-compute button disabled state (just scanning for now - simpler) // Auth check is handled by showing auth UI first const buttonsDisabled = derive(isScanning, (scanning: boolean) => scanning); const groupedMemberships = derive( allMemberships, (list: MembershipRecord[]) => { const groups: Record = {}; if (!list) return groups; for (const m of list) { if (!m) continue; // Skip null/undefined entries during hydration if (!groups[m.hotelBrand]) groups[m.hotelBrand] = []; groups[m.hotelBrand].push(m); } return groups; }, ); // ======================================================================== // UI - Compose base searcher with custom membership display // ======================================================================== return { [NAME]: "šŸØ Hotel Membership Extractor", // Output: Export memberships for wish({ query: "#hotelMemberships" }) memberships, lastScanAt, count: totalMemberships, [UI]: ( {/* WORKAROUND (CT-1090): Wish import disabled - see superstition about self-referential wish loops */}

Hotel Memberships

{/* Auth UI from base pattern */} {searcher.ui.auth} {/* Scan Mode Selection - Only show when authenticated */} {ifElse( searcher.isAuthenticated,
Scan Mode
{fullScanLabel} šŸ“… Check Recent
Full = all emails • Recent = last 7 days only
, null, )} {/* Stop button when scanning */} {ifElse( isScanning, ā¹ Stop Scan , null, )} {/* Scan mode indicator during scan */} {ifElse( isScanning,
{scanModeMessage}
, null, )} {/* Progress UI from base pattern */} {searcher.ui.progress} {/* Stats */}
Total Memberships: {totalMemberships}
{/* Multi-Account Warning */} {derive( [hasMultipleAccounts, brandsWithMultipleAccounts], ([hasMulti, multiBrands]) => hasMulti ? (
Multiple Accounts Detected
{Object.entries(multiBrands).map(( [brand, data], brandIdx, ) => (
{brand}
Found {data.numbers.length}{" "} different membership numbers:
    {data.numbers.map( (num: string, i: number) => { const membership = data.memberships.find(( m: MembershipRecord, ) => m.membershipNumber === num); return (
  • {num} {membership?.tier && ( ({membership.tier}) )}
  • ); }, )}
This could be: old vs new account, family member, or work vs personal
))}
) : null, )} {/* Memberships List - Hotel-specific UI */}

Your Memberships

{derive(groupedMemberships, (groups) => { const brands = Object.keys(groups).sort(); if (brands.length === 0) { return (
No memberships found yet. Click "Scan" to search your emails.
); } return brands.map((brand) => (
{brand || "Unknown Brand"} ({groups[brand].length}) {groups[brand].map((m: MembershipRecord) => (
{m.programName} {m._fromWish && ( imported )}
{m.membershipNumber}
{m.tier && (
⭐ {m.tier}
)}
šŸ“§ {m.sourceEmailSubject || "Unknown email"} • {" "} {m.sourceEmailDate ? new Date(m.sourceEmailDate) .toLocaleDateString() : "Unknown date"}
))}
)); })}
{/* Agent Activity Log - from base searcher */} {searcher.ui.extras} {/* Debug Info */}
šŸ”§ Debug Info
Is Authenticated: {derive( searcher.isAuthenticated, (a) => a ? "Yes āœ“" : "No", )}
Auth Source: {searcher.authSource}
Is Scanning:{" "} {derive(searcher.isScanning, (s) => (s ? "Yes ā³" : "No"))}
Scan Mode: {scanModeLabel}
Agent Pending: {derive( searcher.agentPending, (p) => p ? "Yes ā³" : "No āœ“", )}
Agent Result:{" "} {derive(searcher.agentResult, (r) => r ? "Yes āœ“" : "No")}
Max Searches: {maxSearches}
Local Memberships: {localMembershipCount}
Has Multiple Accounts:{" "} {derive(hasMultipleAccounts, (h) => h ? "Yes āš ļø" : "No")}
Pending Submissions: {derive( searcher.pendingSubmissions, (p) => (p || []).length, )}
), }; }, ); export default HotelMembershipExtractorV2;