) => {
const baseTools = {
searchGmail: {
description:
"Search Gmail with a query and return matching emails. Returns email id, subject, from, date, snippet, and body text.",
handler: searchGmailHandler({
auth,
authRefreshStream,
progress: searchProgress,
maxSearches,
debugLog,
localQueries,
communityQueryRefs,
registryWish,
agentTypeUrl,
lastExecutedQueryIdCell,
}),
},
};
// Merge additional tools if provided
if (additional && typeof additional === "object") {
return { ...baseTools, ...additional };
}
return baseTools;
},
);
// Default system prompt - includes suggested queries from all sources
const fullSystemPrompt = derive(
[systemPrompt, allSuggestedQueries],
([custom, suggested]: [string, string[]]) => {
const base =
`You are a Gmail search agent. Your job is to search through emails to find relevant information.
You have the searchGmail tool available. Use it to search Gmail with queries like:
- from:domain.com
- subject:"keyword"
- has:attachment
- after:2024/01/01
IMPORTANT - WHEN TO STOP SEARCHING:
- After you've searched each relevant category/source 1-2 times with good queries
- When searches start returning the same emails you've already seen
- When you've found what you're looking for (or confirmed it doesn't exist)
- DO NOT keep trying slight variations of the same query
- DO NOT search indefinitely - make a decision and produce your final result
When you're done searching, STOP calling tools and produce your final structured output.`;
// Add suggested queries if available
let prompt = base;
if (suggested && suggested.length > 0) {
prompt +=
`\n\nSuggested queries to try (from pattern config and community):\n${
suggested.map((q) => `- ${q}`).join("\n")
}`;
}
if (custom) {
prompt += `\n\n${custom}`;
}
return prompt;
},
);
// Create the agent
const agent = generateObject({
system: fullSystemPrompt,
prompt: agentPrompt,
tools: allTools,
model: "anthropic:claude-sonnet-4-5",
schema: derive(resultSchema, (schema: object) => {
if (schema && Object.keys(schema).length > 0) {
return schema;
}
// Default schema if none provided
return {
type: "object",
properties: {
summary: {
type: "string",
description: "Summary of what was searched and found",
},
searchesPerformed: { type: "number" },
},
required: ["summary"],
};
}),
});
const { result: agentResult, pending: agentPending } = agent;
// Detect when agent completes
const scanCompleted = derive(
[isScanning, agentPending, agentResult],
([scanning, pending, result]: [boolean, boolean, any]) =>
scanning && !pending && !!result,
);
// Detect auth errors from agent result or token validation
const hasAuthError = derive(
[agentResult, searchProgress],
([r, progress]: [any, SearchProgress]) => {
// Check progress status first (from token validation)
if (progress?.status === "auth_error") {
return true;
}
// Check agent result
const summary = r?.summary || "";
return (
summary.includes("401") ||
summary.toLowerCase().includes("authentication error")
);
},
);
// Get the specific auth error message
const authErrorMessage = derive(
[searchProgress, agentResult],
([progress, result]: [SearchProgress, any]) => {
if (progress?.authError) {
return progress.authError;
}
const summary = result?.summary || "";
if (summary.includes("401")) {
return "Token expired. Please re-authenticate.";
}
if (summary.toLowerCase().includes("authentication error")) {
return "Authentication error. Please re-authenticate.";
}
return "";
},
);
// Pre-bind handlers (important: must be done outside of derive callbacks)
// Use module-scope handlers (startScanHandler, stopScanHandler, completeScanHandler)
const boundStartScan = startScanHandler({
isScanning,
isAuthenticated,
progress: searchProgress,
auth,
debugLog,
authRefreshStream,
});
const boundStopScan = stopScanHandler({ lastScanAt, isScanning });
const boundCompleteScan = completeScanHandler({ lastScanAt, isScanning });
// Track if debug log is expanded (local UI state)
const debugExpanded = Writable.of(false);
// ========================================================================
// UI PIECES (extracted for flexible composition)
// ========================================================================
// Account type selector - built OUTSIDE derive so handler works
// Handlers don't work inside derive() callbacks
const accountTypeSelector = (
Account:
{derive(selectedAccountType, (type: string) =>
type !== "default"
? (
(using #{type === "personal"
? "googleAuthPersonal"
: "googleAuthWork"})
)
: null)}
);
// Auth UI - shows auth status, login buttons, or connect Gmail prompt
const authUI = (
{/* Account Type Selector (only shown if not using direct auth) */}
{ifElse(hasDirectAuth, null, accountTypeSelector)}
{/* Auth Status - use nested ifElse to avoid Cell-in-Cell problem */}
{
/* Only show custom error UIs for specific warning states;
authFullUI handles everything else (not-auth, selecting, ready) */
}
{ifElse(
// Show custom error UI only when authenticated AND has API error
derive(
[isAuthenticated, hasAuthError],
([auth, err]: [boolean, boolean]) => auth && err,
),
// Auth error state - custom warning UI
⚠️ {authErrorMessage}
{derive(wishedAuthPiece, (piece: any) =>
piece
? (
navigateTo(piece)}
size="sm"
variant="secondary"
>
Re-authenticate Gmail
)
: (
Connect Gmail
))}
,
// No auth error - check for token expiry warning
ifElse(
// Show expiry warning only when authenticated AND token may be expired
derive(
[isAuthenticated, tokenMayBeExpired],
([auth, exp]: [boolean, boolean]) => auth && exp,
),
// Token expiry warning - custom warning UI
⚠️ Gmail token may have expired - will verify on scan
{derive(wishedAuthPiece, (piece: any) =>
piece
? (
navigateTo(piece)}
size="sm"
variant="secondary"
>
Re-authenticate Gmail
)
: null)}
,
// All other cases: use authFullUI directly
// - Not authenticated → shows onboarding/picker UI
// - Authenticated success → shows user chip with avatar, email, Switch/Add buttons
authFullUI,
),
)}
);
// Check if agentGoal is empty (pattern not configured for a specific task)
const hasAgentGoal = derive(
agentGoal,
(goal: string) => !!(goal && goal.trim()),
);
// Controls UI - scan and stop buttons
const controlsUI = (
{/* Warning when no agent goal is set */}
{derive(
[isAuthenticated, hasAgentGoal],
([authenticated, hasGoal]: [boolean, boolean]) =>
authenticated && !hasGoal
? (
⚠️ No Search Goal Configured
This is the base Gmail Agentic Search pattern. To use it,
you need to either:
-
Use a specialized pattern (like Hotel Membership
Extractor) that has a built-in goal
-
Pass an
agentGoal{" "}
input when embedding this pattern
The agent won't run without a search goal.
)
: null,
)}
{/* Scan Button */}
{ifElse(
isAuthenticated,
scanning || !hasGoal,
)}
>
{derive(
[isScanning, hasAgentGoal],
([scanning, hasGoal]: [boolean, boolean]) =>
scanning
? "⏳ Scanning..."
: hasGoal
? scanButtonLabel
: "⚠️ No Goal Set",
)}
,
null,
)}
{/* Stop Button */}
{ifElse(
isScanning,
⏹ Stop Scan
,
null,
)}
);
// Progress UI - shows search progress and completion
// Note: We use searchProgress.status instead of agentPending because agentPending
// is false during tool execution (only true during initial prompt processing)
const progressUI = (
{/* Progress during scanning - hide when scan is complete */}
{ifElse(
scanCompleted,
null,
derive(
[isScanning, searchProgress],
([scanning, progress]: [boolean, SearchProgress]) =>
scanning && progress.status !== "idle" &&
progress.status !== "auth_error"
? (
Scanning emails...
{/* Current Activity */}
{derive(searchProgress, (progress: SearchProgress) =>
progress.currentQuery
? (
🔍 Currently searching:
{progress.currentQuery}
)
: (
))}
{/* Completed Searches */}
{derive(searchProgress, (progress: SearchProgress) =>
progress.completedQueries.length > 0
? (
✅ Completed searches ({progress.completedQueries
.length}
):
{[...progress.completedQueries]
.reverse()
.slice(0, 5)
.map(
(
q: { query: string; emailCount: number },
i: number,
) => (
{q?.query
? q.query.length > 50
? q.query.substring(0, 50) + "..."
: q.query
: "unknown"}
({q?.emailCount ?? 0} emails)
),
)}
)
: null)}
)
: null,
),
)}
{/* Scan Complete */}
{derive(scanCompleted, (completed: boolean) =>
completed
? (
✓ Scan Complete
{derive(agentResult, (r: any) => r?.summary || "")}
✓ Done
)
: null)}
);
// Stats UI - last scan timestamp
const statsUI = (
{derive(lastScanAt, (ts: number) =>
ts > 0
?
Last Scan: {new Date(ts).toLocaleString()}
: null)}
);
// Debug Log UI - collapsible log of agent activity
const debugLogUI = (
{derive(debugLog, (log: DebugLogEntry[]) =>
log && log.length > 0
? (
{/* Header - clickable to toggle */}
{derive(debugExpanded, (e: boolean) => e ? "▼" : "▶")}{" "}
Debug Log ({log.length} entries)
click to {derive(
debugExpanded,
(e: boolean) => e ? "collapse" : "expand",
)}
{/* Content - shown when expanded */}
{derive(debugExpanded, (expanded: boolean) =>
expanded
? (
{log.filter((e): e is DebugLogEntry => e != null).map((
entry: DebugLogEntry,
i: number,
) => (
{new Date(entry.timestamp).toLocaleTimeString()}
{" "}
{entry.type}
{" "}
{entry.message}
{entry.details && (
{JSON.stringify(entry.details, null, 2)}
)}
))}
)
: null)}
)
: null)}
);
// ========================================================================
// LOCAL QUERIES MANAGEMENT
// ========================================================================
// Note: rateQueryHandler, deleteLocalQueryHandler, flagForShareHandler are defined at module scope
// and called directly with all parameters in onClick handlers (no pre-binding needed)
// Note: localQueriesExpanded/toggleLocalQueries removed - using native / instead
// Pre-bind handler for creating registry
const boundCreateSearchRegistry = createSearchRegistry({});
// Local Queries UI - collapsible list of saved queries
// Uses native / to avoid nested derive closure issues
// (see superstition: 2025-12-06-use-native-details-summary-for-expand-collapse.md)
//
// Key fix: Instead of nested derive(localQueriesExpanded, ...) inside derive(localQueries, ...),
// we use native which handles expand/collapse via browser without reactive state.
// The derive(localQueries) renders the entire details block including content - no closure issues.
const localQueriesUI = (
{derive(
[localQueriesWithFoundItems, onlySaveQueriesWithItems],
([queries, onlyWithItems]: [LocalQuery[], boolean]) => {
// Filter queries: when onlyWithItems is true, only show queries that found target items
const filteredQueries = (queries || []).filter(
(q): q is LocalQuery => {
if (!q) return false;
if (onlyWithItems) {
return (q.foundItems || 0) > 0;
}
return true;
},
);
if (filteredQueries.length === 0) return null;
return (
{/* Summary - clickable header */}
My Saved Queries ({filteredQueries.length})
click to toggle
{/* Content - shown when expanded (handled by browser) */}
{[...filteredQueries]
.sort((a, b) =>
(b.effectiveness || 0) - (a.effectiveness || 0)
)
.map((query: LocalQuery) => (
{query.query}
Used {query.useCount}x
{query.lastUsed &&
` · Last: ${
new Date(query.lastUsed).toLocaleDateString()
}`}
{query.shareStatus === "pending_review" && (
(pending review)
)}
{query.shareStatus === "submitted" && (
(shared)
)}
{query.shareStatus === "private" && (
Share
)}
×
))}
);
},
)}
);
// ========================================================================
// PII SCREENING & PENDING SUBMISSIONS
// ========================================================================
// Schema for privacy/generalizability screening response
const piiScreeningSchema = {
type: "object" as const,
properties: {
hasPII: {
type: "boolean" as const,
description: "Whether PII was detected",
},
piiFound: {
type: "array" as const,
items: { type: "string" as const },
description:
"List of PII items found (e.g., 'email: john@example.com')",
},
isGeneralizable: {
type: "boolean" as const,
description: "Whether the query is general enough to help others",
},
generalizabilityIssues: {
type: "array" as const,
items: { type: "string" as const },
description: "List of reasons the query might not generalize",
},
sanitizedQuery: {
type: "string" as const,
description:
"Query with PII removed and made more general (empty string if not salvageable)",
},
confidence: {
type: "number" as const,
description: "Confidence in analysis (0-1)",
},
recommendation: {
type: "string" as const,
enum: ["share", "share_with_edits", "do_not_share"] as const,
description: "Whether to recommend sharing this query",
},
},
required: [
"hasPII",
"piiFound",
"isGeneralizable",
"generalizabilityIssues",
"sanitizedQuery",
"confidence",
"recommendation",
] as const,
};
// Note: flagQueryForSharingHandler is defined at module scope
// Run PII screening on pending submissions
// Uses derive to reactively screen new submissions
const piiScreeningPrompt = derive(
pendingSubmissions,
(submissions: PendingSubmission[]) => {
// Filter out any undefined/null items first, then find unscreened submissions
const validSubmissions = (submissions || []).filter((
s,
): s is PendingSubmission => s != null);
const unscreened = validSubmissions.filter(
(s) =>
s.sanitizedQuery === s.originalQuery &&
s.piiWarnings.length === 0 && !s.userApproved,
);
if (unscreened.length === 0) return "";
// Build prompt for the first unscreened submission
const submission = unscreened[0];
return `Analyze this Gmail search query for privacy issues and generalizability.
Query: "${submission.originalQuery}"
Check for TWO categories of problems:
1. PRIVACY (PII - Personally Identifiable Information):
- Email addresses (from:john@acme.com -> from:*@*.com)
- Personal names (from:john.smith -> from:*)
- Specific company domains that reveal employer
- Account numbers, confirmation codes, order IDs
- Specific dates that could identify events
2. GENERALIZABILITY (queries too specific to one person):
- Very specific sender domains that only this user uses
- Queries that reference specific subscription services/vendors unique to this user
- Highly specific subject line fragments that won't match others' emails
- Combinations of terms that are overly narrow
GOOD queries to share (generic patterns):
- "from:marriott.com subject:points" (common hotel chain)
- "from:noreply@* subject:confirmation" (generic pattern)
- "subject:receipt from:amazon.com" (common retailer)
BAD queries to share:
- "from:john.smith@acme.com" (specific person)
- "from:mycustomdomain.com" (personal domain)
- "subject:Order #12345" (specific order)
- "from:obscure-local-business@gmail.com" (won't help others)
Return a sanitized version that:
1. Removes/generalizes PII
2. Makes the query more general if it's too specific
3. Returns empty string "" if the query can't be made useful for others`;
},
);
// Only run PII screening when there's a prompt
const piiScreeningResult = derive(piiScreeningPrompt, (prompt: string) => {
if (!prompt) return null;
return generateObject({
prompt,
schema: piiScreeningSchema,
system:
`You are a privacy analyst and query curator for a community knowledge base.
Your job is to evaluate Gmail search queries for:
1. PRIVACY: Detect and remove/sanitize PII (emails, names, specific identifiers)
2. GENERALIZABILITY: Assess if the query pattern would help OTHER users
A query should only be shared if it represents a GENERAL PATTERN that others could benefit from.
Major hotel chains, airlines, common retailers, and widespread services are good candidates.
Personal domains, local businesses, and hyper-specific searches should not be shared.
Be conservative: when in doubt, recommend "do_not_share".`,
});
});
// Update pending submissions with screening results
// This is a side effect that runs when screening completes
derive(piiScreeningResult, (result: any) => {
if (!result || !result.result) return;
const screeningData = result.result as {
hasPII: boolean;
piiFound: string[];
isGeneralizable: boolean;
generalizabilityIssues: string[];
sanitizedQuery: string;
confidence: number;
recommendation: "share" | "share_with_edits" | "do_not_share";
};
const pendingWritable = pendingSubmissions as Writable<
PendingSubmission[]
>;
const submissions = (pendingWritable.get() || []).filter((
s: PendingSubmission | null,
): s is PendingSubmission => s != null);
// Find the submission that was screened (still pending)
const unscreened = submissions.filter(
(s: PendingSubmission) =>
s?.recommendation === "pending" && !s?.userApproved,
);
if (unscreened.length === 0) return;
const submission = unscreened[0];
const idx = submissions.findIndex((s: PendingSubmission) =>
s.localQueryId === submission.localQueryId
);
if (idx < 0) return;
// Update the submission with screening results using .key().key().set()
const itemCell = pendingWritable.key(idx);
(itemCell.key("sanitizedQuery") as Writable).set(
screeningData.sanitizedQuery || submission.originalQuery,
);
(itemCell.key("piiWarnings") as Writable).set(
screeningData.piiFound || [],
);
(itemCell.key("generalizabilityIssues") as Writable).set(
screeningData.generalizabilityIssues || [],
);
(itemCell.key("recommendation") as Writable<
"share" | "share_with_edits" | "do_not_share" | "pending"
>).set(screeningData.recommendation);
});
// Note: approvePendingSubmissionHandler, rejectPendingSubmissionHandler, updateSanitizedQueryHandler
// are defined at module scope
// Track if pending submissions UI is expanded
const pendingSubmissionsExpanded = Writable.of(false);
// Pending Submissions UI
const pendingSubmissionsUI = (
{derive(pendingSubmissions, (submissions: PendingSubmission[]) =>
submissions && submissions.length > 0
? (
{/* Header */}
{derive(pendingSubmissionsExpanded, (e: boolean) =>
e ? "▼" : "▶")}{" "}
Share Your Discoveries ({submissions.length} pending)
click to{" "}
{derive(pendingSubmissionsExpanded, (e: boolean) =>
e ? "collapse" : "expand")}
{/* Content */}
{derive(pendingSubmissionsExpanded, (expanded: boolean) =>
expanded
? (
{submissions.filter((s): s is PendingSubmission =>
s != null
).map((submission: PendingSubmission) => (
{/* Original query */}
Original Query:
{submission.originalQuery}
{/* Recommendation badge */}
{submission.recommendation !== "pending" && (
{submission.recommendation === "share"
? "✓ Good to share"
: submission.recommendation ===
"share_with_edits"
? "⚠ Needs editing"
: "✗ Not recommended"}
)}
{/* PII Warnings */}
{submission.piiWarnings.length > 0 && (
⚠️ Privacy Issues:
{submission.piiWarnings.join(", ")}
)}
{/* Generalizability Issues */}
{submission.generalizabilityIssues.length > 0 && (
⚠️ Generalizability Issues:
{submission.generalizabilityIssues.join(", ")}
)}
{/* Sanitized query (editable) */}
{/* Action buttons */}
{
// Reject
const pendingWritable =
pendingSubmissions as Writable<
PendingSubmission[]
>;
const localWritable =
localQueries as Writable;
const subs = pendingWritable.get() || [];
pendingWritable.set(
subs.filter((s: PendingSubmission) =>
s.localQueryId !== submission.localQueryId
),
);
// Reset local query status
const queries = localWritable.get() || [];
const idx = queries.findIndex((
q: LocalQuery,
) =>
q.id === submission.localQueryId
);
if (idx >= 0) {
(localWritable.key(idx).key(
"shareStatus",
) as Writable<
"private" | "pending_review" | "submitted"
>).set("private");
}
}}
variant="ghost"
size="sm"
style="color: #64748b;"
>
Keep Private
{
// Approve
const pendingWritable =
pendingSubmissions as Writable<
PendingSubmission[]
>;
const subs = pendingWritable.get() || [];
const idx = subs.findIndex((
s: PendingSubmission,
) =>
s.localQueryId === submission.localQueryId
);
if (idx >= 0) {
(pendingWritable.key(idx).key(
"userApproved",
) as Writable).set(true);
}
}}
variant={submission.userApproved
? "secondary"
: "default"}
size="sm"
disabled={submission.userApproved}
>
{submission.userApproved
? "✓ Approved"
: "Approve for Sharing"}
))}
{/* Submit all approved button */}
{derive(
[pendingSubmissions, registryWish, agentTypeUrl],
(
[subs, registry, typeUrl]: [
PendingSubmission[],
any,
string,
],
) => {
const approvedCount = (subs || []).filter((s) =>
s.userApproved && !s.submittedAt
).length;
const hasRegistry = !!registry?.result?.submitQuery;
return approvedCount > 0
? (
{
if (!hasRegistry || !typeUrl) {
return;
}
const approved = (subs || []).filter((
s: PendingSubmission,
) =>
s.userApproved && !s.submittedAt
);
const submitHandler = registry?.result
?.submitQuery;
const pendingWritable =
pendingSubmissions as Writable<
PendingSubmission[]
>;
const localWritable =
localQueries as Writable;
// Submit each approved query
approved.forEach(
(submission: PendingSubmission) => {
if (submitHandler) {
submitHandler({
agentTypeUrl: typeUrl,
query: submission.sanitizedQuery,
});
}
// Mark as submitted in pendingSubmissions
const currentSubs =
pendingWritable.get() || [];
const idx = currentSubs.findIndex((
s: PendingSubmission,
) =>
s.localQueryId ===
submission.localQueryId
);
if (idx >= 0) {
(pendingWritable.key(idx).key(
"submittedAt",
) as Writable)
.set(Date.now());
}
// Update local query status to submitted
const queries = localWritable.get() ||
[];
const qIdx = queries.findIndex((
q: LocalQuery,
) =>
q.id === submission.localQueryId
);
if (qIdx >= 0) {
(localWritable.key(qIdx).key(
"shareStatus",
) as Writable<
| "private"
| "pending_review"
| "submitted"
>).set("submitted");
}
},
);
}}
>
Submit {approvedCount} Approved{" "}
{approvedCount === 1 ? "Query" : "Queries"}
{" "}
to Community
{!hasRegistry && (
No community registry found. You can
create one to share queries with other
users.
Note: Registry will be created in your
current space. After creation, favorite
it with tag #gmailSearchRegistry.
Create Registry
)}
)
: null;
},
)}
)
: null)}
)
: null)}
);
// ========================================================================
// EXTRAS UI - Combined UI for subclasses to inherit naturally
// Includes: local queries, pending submissions, and debug log
// ========================================================================
const extrasUI = (
{localQueriesUI}
{pendingSubmissionsUI}
{debugLogUI}
);
// ========================================================================
// RETURN
// ========================================================================
return {
[NAME]: title,
// UI Pieces grouped for composition (like chatbot.tsx pattern)
ui: {
auth: authUI,
controls: controlsUI,
progress: progressUI,
stats: statsUI,
extras: extrasUI,
debugLog: debugLogUI,
localQueries: localQueriesUI,
pendingSubmissions: pendingSubmissionsUI,
},
// Auth state (exposed for embedding patterns)
auth,
isAuthenticated,
hasGmailScope,
authSource,
// Agent state
agentResult,
agentPending,
isScanning,
// Progress
searchProgress,
// Debug log
debugLog,
// Timestamps
lastScanAt,
// Actions
startScan: boundStartScan,
stopScan: boundStopScan,
// Local queries (shared search strings support)
localQueries,
pendingSubmissions,
rateQuery: rateQueryHandler,
deleteLocalQuery: deleteLocalQueryHandler,
// Cell for consuming patterns to signal "found an item"
// Increment with searcher.itemFoundSignal.set(current + 1) when your tool finds items
itemFoundSignal,
// Full UI (composed from pieces)
[UI]: (
{title}
{authUI}
{controlsUI}
{progressUI}
{statsUI}
{extrasUI}
),
};
},
);
export default GmailAgenticSearch;