> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hydradb.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build your own Glean with HydraDB

> Learn how to build a comprehensive workplace search and AI assistant platform using HydraDB APIs. This guide covers data ingestion, retrieval, and app-layer answer generation across multiple data sources.

This guide will walk you through building an extremely powerful workplace search and AI assistant platform that rivals Glean using HydraDB APIs. You'll learn how to create a unified retrieval experience across multiple data sources and generate answers in your application layer.

> **Note**: All code in this guide uses the official HydraDB Python SDK. Base URL: `https://api.hydradb.com`. Get your API key at [app.hydradb.com](https://app.hydradb.com).

## Prerequisites

**Required knowledge**: Python basics, REST APIs, environment variables\
**Required tools**:

* HydraDB API key
* Python 3.11 or 3.12 (`python --version`)
* `pip install hydradb-sdk`

## What You'll Build

By the end of this cookbook, you'll be able to:

* Ingest documents from multiple data sources (Slack, email, Google Drive, Jira) into a unified HydraDB knowledge base
* Run natural language workplace search across all sources with a single `full_recall` call
* Personalize search results per user by storing and retrieving AI memories
* Generate grounded AI answers from retrieved context using any LLM

## Overview

A Glean-like application typically includes these core features:

* **Universal Search**: Search across multiple data sources (documents, emails, chats, etc.)
* **Retrieval-Assisted Answers**: Generate intelligent answers from retrieved company knowledge
* **AI Memories for User Preferences**: Remember and adapt to individual user preferences, search patterns, and behavioral patterns
* **Data Ingestion**: Connect to various apps and services
* **Knowledge Graph**: Build connections between information
* **Security & Access Control**: Role-based permissions and data isolation

## Architecture Overview

```mermaid theme={"dark"}
graph TD
    A["Frontend UI<br/>• Search UI<br/>• Chat Interface<br/>• Results View"] 
    B["Backend API<br/>• Data Sync<br/>• Auth/ACL<br/>• App Connectors"]
    C["HydraDB APIs<br/>• Retrieval Engine<br/>• Document Index<br/>• Memory Store"]
    
    D["Data Sources<br/>• Google apps<br/>• Slack<br/>• Notion<br/>• Jira"]
    E["App Connectors for Fetching Data<br/><br/>• Composio<br/>• Vanilla APIs<br/>• Webhooks<br/>• Scheduled Jobs"]
    F["Retrieval Layer<br/>• HydraDB Memory<br/>• User Sessions<br/>• Metadata Store"]
    
    A <--> B
    B <--> C
    A --> D
    B <--> E
    C --> F
```

## Step 1: Data Ingestion Strategy

### 1.1 App Connectors Setup

You'll need to connect to various data sources. Here are recommended approaches:

#### Option A: Using Composio

[Composio](https://composio.dev/) provides pre-built connectors for popular apps:

```javascript theme={"dark"}
// Example: Setting up Composio connector
const composioConfig = {
  connectors: [
    {
      name: 'slack',
      config: {
        token: process.env.SLACK_BOT_TOKEN,
        channels: ['general', 'random', 'project-*']
      }
    },
    {
      name: 'gmail',
      config: {
        credentials: process.env.GMAIL_CREDENTIALS,
        labels: ['INBOX', 'SENT', 'IMPORTANT']
      }
    },
    {
      name: 'notion',
      config: {
        token: process.env.NOTION_TOKEN,
        databases: ['projects', 'docs', 'meetings']
      }
    }
  ]
};
```

#### Option B: Vanilla API Integration

For custom integrations, use the native APIs:

```javascript theme={"dark"}
// Example: Slack API integration
class SlackConnector {
  constructor(token) {
    this.token = token;
    this.client = new WebClient(token);
  }

  async fetchMessages(channelId, limit = 100) {
    const result = await this.client.conversations.history({
      channel: channelId,
      limit: limit
    });
    
    return result.messages.map(msg => ({
      id: msg.ts,
      text: msg.text,
      user: msg.user,
      timestamp: msg.ts,
      channel: channelId,
      type: 'slack_message'
    }));
  }

  async fetchChannels() {
    const result = await this.client.conversations.list();
    return result.channels;
  }
}

// Example: Gmail API integration
class GmailConnector {
  constructor(credentials) {
    this.gmail = google.gmail({ version: 'v1', auth: credentials });
  }

  async fetchEmails(query = 'in:inbox', maxResults = 100) {
    const response = await this.gmail.users.messages.list({
      userId: 'me',
      q: query,
      maxResults: maxResults
    });

    const emails = [];
    for (const message of response.data.messages) {
      const email = await this.gmail.users.messages.get({
        userId: 'me',
        id: message.id
      });
      
      emails.push({
        id: email.data.id,
        subject: this.getHeader(email.data.payload.headers, 'Subject'),
        from: this.getHeader(email.data.payload.headers, 'From'),
        body: this.getBody(email.data.payload),
        timestamp: email.data.internalDate,
        type: 'gmail'
      });
    }
    
    return emails;
  }
}
```

### 1.2 Data Normalization

Normalize every connector record into HydraDB's typed app-source contract. This preserves provider IDs, threads, actors, and relations for app-aware recall:

> **Important**: For optimal performance, limit each batch to a maximum of **20 app sources** per request. Send multiple batch requests with an interval of **1 second** between each request.

```javascript theme={"dark"}
// Typed app-source item for HydraDB app upload
const normalizedData = {
  id: "slack_C01_1716213600_000100",     // stable HydraDB source ID
  tenant_id: "your_tenant_id",
  sub_tenant_id: "workspace",
  kind: "message",                       // email | message | ticket | knowledge_base | comment | custom
  provider: "slack",                     // slack | gmail | jira | notion | salesforce | ...
  external_id: "1716213600.000100",      // provider's stable item ID
  fields: {
    kind: "message",
    body: "Main content text",
    author: "user@company.com",
    thread_id: "1716213600.000100",
    parent_id: undefined, // set to the root ts only for replies
    created_at: "2024-01-01T00:00:00Z",
    url: "https://app.com/item/123"
  },
  metadata: {
    container_type: "channel",
    container_id: "C01",
    container_name: "engineering",
    source: "slack"
  },
  relations: [],     // e.g. { predicate: "linked_to", target: { external_id: "AUTH-123", provider: "jira" } }
  attachments: []    // include content.text/markdown when you already extracted file text
};
```

### 1.3 Batch Upload to HydraDB

Use HydraDB's batch upload capabilities for efficient data ingestion:

> **Best Practice**: Always verify processing after upload using the `/ingestion/verify_processing` endpoint to ensure your data is properly indexed.

<CodeGroup>
  ```typescript TypeScript SDK theme={"dark"}
  import { HydraDBClient } from "@hydradb/sdk";

  const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! });

  // Upload a batch of typed app sources
  const uploadBatch = async (sources: any[], tenantId: string, subTenantId = "workspace") => {
    const appKnowledge = sources.map(source => ({
      ...source,
      tenant_id: tenantId,
      sub_tenant_id: subTenantId
    }));

    const result = await client.upload.knowledge({
      tenant_id: tenantId,
      app_knowledge: JSON.stringify(appKnowledge)
    });

    return result;
  };

  // Upload with verification — confirm each item is indexed before proceeding
  const uploadWithVerification = async (sources: any[], tenantId: string, subTenantId?: string) => {
    const uploadResult = await uploadBatch(sources, tenantId, subTenantId);

    const sourceIds = (uploadResult.results ?? [])
      .map((item: any) => item.source_id)
      .filter(Boolean);

    if (sourceIds.length) {
      for (const sourceId of sourceIds) {
        const status = await client.upload.verifyProcessing({
          tenant_id: tenantId,
          file_ids: [sourceId]
        });
        if (status.indexing_status === "errored") {
          throw new Error(`Processing failed for source ${sourceId}`);
        }
      }
    }

    return uploadResult;
  };
  ```

  ```python Python SDK theme={"dark"}
  import os
  import json
  from hydra_db import HydraDB

  client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

  # Upload a batch of typed app sources
  def upload_batch(sources: list, tenant_id: str, sub_tenant_id: str = "workspace"):
      sub_tenant_id = sub_tenant_id or "workspace"
      app_knowledge = [
          {**source, "tenant_id": tenant_id, "sub_tenant_id": sub_tenant_id}
          for source in sources
      ]

      result = client.upload.knowledge(
          tenant_id=tenant_id,
          app_knowledge=json.dumps(app_knowledge)
      )
      return result

  # Upload with verification — confirm each item is indexed before proceeding
  def upload_with_verification(sources: list, tenant_id: str, sub_tenant_id: str = None):
      upload_result = upload_batch(sources, tenant_id, sub_tenant_id)

      if upload_result.results:
          for item in upload_result.results:
              source_id = item.source_id
              status = client.upload.verify_processing(
                  tenant_id=tenant_id,
                  file_ids=[source_id]
              )
              items = status.statuses or []
              if items and items[0].indexing_status == "errored":
                  raise Exception(f"Processing failed for source {source_id}")

      return upload_result
  ```
</CodeGroup>

## Step 2: Search and Answer Generation

### 2.1 Universal Search Interface

Create a search interface that queries across all data sources:

> **Note**: App-aware search requires `search_apps: true`. If you need hard filters, mirror stable scope values into `metadata` during ingestion (for example `source: "slack"` or `container_id: "C01"`) and query them with `metadata_filters`.

<CodeGroup>
  ```typescript TypeScript SDK theme={"dark"}
  import { HydraDBClient } from "@hydradb/sdk";

  const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! });

  interface SearchOptions {
    sub_tenant_id?: string;
    max_results?: number;
    mode?: "fast" | "thinking";
    metadata_filters?: Record<string, any>;
  }

  const search = async (query: string, tenantId: string, options: SearchOptions = {}) => {
    const {
      sub_tenant_id,
      max_results = 10,
      mode = "thinking",
      metadata_filters,
    } = options;

    return await client.recall.fullRecall({
      query,
      tenant_id: tenantId,
      sub_tenant_id,
      max_results,
      mode,
      search_apps: true,
      alpha: 0.5,         // Balance semantic vs keyword search (0.0 to 1.0)
      recency_bias: 0.3,  // Recency preference (0.0 to 1.0)
      ...(metadata_filters && { metadata_filters }),
    });
  };

  // Filter by provider/source when you mirrored it into metadata at ingestion
  const searchByProvider = (query: string, tenantId: string, provider: string) =>
    search(`${provider} ${query}`, tenantId, { metadata_filters: { source: provider } });

  // Filter by container/channel/project when stored in metadata
  const searchByContainer = (query: string, tenantId: string, containerId: string) =>
    search(query, tenantId, { metadata_filters: { container_id: containerId } });
  ```

  ```python Python SDK theme={"dark"}
  import os
  from hydra_db import HydraDB

  client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

  def search(query: str, tenant_id: str, sub_tenant_id: str = None,
             max_results: int = 10, mode: str = "thinking",
             metadata_filters: dict = None):
      return client.recall.full_recall(
          query=query,
          tenant_id=tenant_id,
          sub_tenant_id=sub_tenant_id,
          max_results=max_results,
          mode=mode,
          search_apps=True,
          alpha=0.5,        # Balance semantic vs keyword search (0.0 to 1.0)
          recency_bias=0.3, # Recency preference (0.0 to 1.0)
          **({"metadata_filters": metadata_filters} if metadata_filters else {}),
      )

  # Filter by provider/source when you mirrored it into metadata at ingestion
  def search_by_provider(query: str, tenant_id: str, provider: str):
      return search(f"{provider} {query}", tenant_id, metadata_filters={"source": provider})

  # Filter by container/channel/project when stored in metadata
  def search_by_container(query: str, tenant_id: str, container_id: str):
      return search(query, tenant_id, metadata_filters={"container_id": container_id})
  ```
</CodeGroup>

### 2.2 Advanced Search Features

Implement advanced search capabilities:

> **Advanced Features**:
>
> * `mode`: Use `"thinking"` for multi-query retrieval with reranking, or `"fast"` for single-query retrieval
> * `alpha`: Controls semantic vs keyword matching (0.0-1.0, or `"auto"`)
> * `recency_bias`: Prioritizes recent content (0.0-1.0)

<CodeGroup>
  ```typescript TypeScript SDK theme={"dark"}
  import { HydraDBClient } from "@hydradb/sdk";

  const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! });

  // Search with optional metadata filters for provider/container scopes
  const searchWithFilters = async (
    query: string,
    tenantId: string,
    filters: { source?: string; containerId?: string } = {}
  ) => {
    const metadata_filters: Record<string, any> = {};
    if (filters.source) metadata_filters.source = filters.source;
    if (filters.containerId) metadata_filters.container_id = filters.containerId;

    return await client.recall.fullRecall({
      query,
      tenant_id: tenantId,
      max_results: 10,
      mode: "thinking",
      search_apps: true,
      alpha: 0.5,
      recency_bias: 0.3,
      ...(Object.keys(metadata_filters).length > 0 && { metadata_filters })
    });
  };

  // Guide retrieval with additional context — prepend context to the query string
  const searchWithContext = async (query: string, tenantId: string, context: string) =>
    client.recall.fullRecall({
      query: context ? `${context}\n\n${query}` : query,
      tenant_id: tenantId,
      max_results: 10,
      mode: "thinking",
      search_apps: true,
      alpha: 0.5,
      recency_bias: 0.3,
    });

  // Conversational search — fold prior conversation turns into the query
  const conversationalSearch = async (
    query: string,
    tenantId: string,
    conversationHistory: object[] = []
  ) =>
    client.recall.fullRecall({
      query: conversationHistory.length
        ? `Previous conversation context: ${JSON.stringify(conversationHistory)}\n\n${query}`
        : query,
      tenant_id: tenantId,
      max_results: 10,
      mode: "thinking",
      search_apps: true,
      alpha: 0.5,
      recency_bias: 0.3,
    });
  ```

  ```python Python SDK theme={"dark"}
  import os
  import json
  from hydra_db import HydraDB

  client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

  # Search with optional metadata filters for provider/container scopes
  def search_with_filters(query: str, tenant_id: str, source: str = None, container_id: str = None):
      metadata_filters = {}
      if source:
          metadata_filters["source"] = source
      if container_id:
          metadata_filters["container_id"] = container_id

      return client.recall.full_recall(
          query=query,
          tenant_id=tenant_id,
          max_results=10,
          mode="thinking",
          search_apps=True,
          alpha=0.5,
          recency_bias=0.3,
          **({"metadata_filters": metadata_filters} if metadata_filters else {})
      )

  # Guide retrieval with additional context — prepend context to the query string
  def search_with_context(query: str, tenant_id: str, context: str):
      return client.recall.full_recall(
          query=f"{context}\n\n{query}" if context else query,
          tenant_id=tenant_id,
          max_results=10,
          mode="thinking",
          search_apps=True,
          alpha=0.5,
          recency_bias=0.3,
      )

  # Conversational search — fold prior conversation turns into the query
  def conversational_search(query: str, tenant_id: str, conversation_history: list = None):
      history = conversation_history or []
      enriched_query = (
          f"Previous conversation context: {json.dumps(history)}\n\n{query}"
          if history else query
      )
      return client.recall.full_recall(
          query=enriched_query,
          tenant_id=tenant_id,
          max_results=10,
          mode="thinking",
          search_apps=True,
          alpha=0.5,
          recency_bias=0.3,
      )
  ```
</CodeGroup>

### 2.3 AI Memories and User Preferences

One of the most powerful features of building a Glean-like application with HydraDB is leveraging **AI Memories** to create truly personalized experiences. HydraDB automatically manages AI memories using `sub_tenant_id` for user-level isolation. This allows your application to remember user preferences, past interactions, and behavioral patterns, making every search and interaction more relevant and efficient.

#### Understanding AI Memories

HydraDB's AI memories are dynamic, user-specific profiles that evolve over time. They capture not just what users say, but their intentions, preferences, and unique behaviors. HydraDB automatically manages these memories using `sub_tenant_id` for user-level isolation. This enables your Glean clone to:

* **Remember User Preferences**: Format preferences, source preferences, search patterns
* **Understand Intent**: Learn what types of information users typically seek
* **Adapt Responses**: Tailor answers based on past interactions
* **Anticipate Needs**: Suggest relevant information before users ask

#### Implementing AI Memories in Your Search

<CodeGroup>
  ```typescript TypeScript SDK theme={"dark"}
  import { HydraDBClient } from "@hydradb/sdk";

  const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! });

  // In-memory profile store — replace with your own database in production
  const userProfiles = new Map<string, any>();

  const getUserProfile = (userId: string) => {
    if (!userProfiles.has(userId)) {
      userProfiles.set(userId, {
        userId,
        preferredMode: "fast",
        preferredSourceTypes: [] as string[],
        frequentQueries: [] as string[],
        preferredFormats: ["bullet_points"],
        responseStyle: "concise",
        favoriteSources: [] as string[],
        searchHistory: [] as object[],
        lastInteraction: null as string | null
      });
    }
    return userProfiles.get(userId)!;
  };

  const buildPersonalizedInstructions = (userProfile: any, query: string): string => {
    let instructions = "User preferences: ";
    if (userProfile.preferredFormats.includes("bullet_points")) instructions += "Prefer bullet point responses. ";
    if (userProfile.responseStyle === "concise") instructions += "Keep responses concise and to the point. ";
    if (userProfile.favoriteSources.length > 0) instructions += `User frequently uses sources: ${userProfile.favoriteSources.join(", ")}. `;
    if (userProfile.frequentQueries.length > 0) instructions += `User often searches for: ${userProfile.frequentQueries.slice(0, 3).join(", ")}. `;
    instructions += `Current query: ${query}`;
    return instructions;
  };

  const saveUserProfile = async (userId: string, profile: any) => {
    // Persist to your backend database
    console.log(`Saving profile for user ${userId}:`, profile);
  };

  // Search with personalized query enrichment derived from the user's local profile
  const searchWithMemory = async (query: string, tenantId: string, userId: string) => {
    const userProfile = getUserProfile(userId);
    const enrichedQuery = `${buildPersonalizedInstructions(userProfile, query)}\n\n${query}`;

    const searchResults = await client.recall.fullRecall({
      query: enrichedQuery,
      tenant_id: tenantId,
      sub_tenant_id: userId,
      max_results: 10,
      mode: userProfile.preferredMode || "thinking",
      search_apps: true,
      alpha: 0.5,
      recency_bias: 0.3,
      ...(userProfile.preferredSourceTypes?.length > 0 && {
        metadata_filters: { source: userProfile.preferredSourceTypes[0] }
      })
    });

    // Update local profile history
    userProfile.searchHistory.push({ query, timestamp: new Date().toISOString(), resultCount: searchResults.chunks?.length || 0 });
    if (userProfile.searchHistory.length > 50) userProfile.searchHistory = userProfile.searchHistory.slice(-50);

    const queryLower = query.toLowerCase();
    if (!userProfile.frequentQueries.some((q: string) => q.toLowerCase().includes(queryLower))) {
      userProfile.frequentQueries = [query, ...userProfile.frequentQueries].slice(0, 10);
    }

    if (searchResults.chunks) {
      for (const chunk of searchResults.chunks) {
        if (chunk.source && !userProfile.favoriteSources.includes(chunk.source)) {
          userProfile.favoriteSources.push(chunk.source);
        }
      }
      userProfile.favoriteSources = userProfile.favoriteSources.slice(0, 5);
    }

    userProfile.lastInteraction = new Date().toISOString();
    await saveUserProfile(userId, userProfile);

    return searchResults;
  };
  ```

  ```python Python SDK theme={"dark"}
  import os
  from datetime import datetime
  from hydra_db import HydraDB

  client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

  # In-memory profile store — replace with your own database in production
  user_profiles: dict = {}

  def get_user_profile(user_id: str) -> dict:
      if user_id not in user_profiles:
          user_profiles[user_id] = {
              "user_id": user_id,
              "preferred_mode": "fast",
              "preferred_source_types": [],
              "frequent_queries": [],
              "preferred_formats": ["bullet_points"],
              "response_style": "concise",
              "favorite_sources": [],
              "search_history": [],
              "last_interaction": None
          }
      return user_profiles[user_id]

  def build_personalized_instructions(profile: dict, query: str) -> str:
      instructions = "User preferences: "
      if "bullet_points" in profile["preferred_formats"]:
          instructions += "Prefer bullet point responses. "
      if profile["response_style"] == "concise":
          instructions += "Keep responses concise and to the point. "
      if profile["favorite_sources"]:
          instructions += f"User frequently uses sources: {', '.join(profile['favorite_sources'])}. "
      if profile["frequent_queries"]:
          instructions += f"User often searches for: {', '.join(profile['frequent_queries'][:3])}. "
      instructions += f"Current query: {query}"
      return instructions

  def save_user_profile(user_id: str, profile: dict):
      # Persist to your backend database
      print(f"Saving profile for user {user_id}:", profile)

  # Search with personalized query enrichment derived from the user's local profile
  def search_with_memory(query: str, tenant_id: str, user_id: str):
      profile = get_user_profile(user_id)
      enriched_query = f"{build_personalized_instructions(profile, query)}\n\n{query}"

      kwargs = dict(
          query=enriched_query,
          tenant_id=tenant_id,
          sub_tenant_id=user_id,
          max_results=10,
          mode=profile["preferred_mode"] or "thinking",
          search_apps=True,
          alpha=0.5,
          recency_bias=0.3,
      )
      if profile["preferred_source_types"]:
          kwargs["metadata_filters"] = {"source": profile["preferred_source_types"][0]}

      search_results = client.recall.full_recall(**kwargs)

      # Update local profile history
      profile["search_history"].append({
          "query": query,
          "timestamp": datetime.utcnow().isoformat(),
          "result_count": len(search_results.chunks or [])
      })
      profile["search_history"] = profile["search_history"][-50:]

      if not any(query.lower() in q.lower() for q in profile["frequent_queries"]):
          profile["frequent_queries"] = ([query] + profile["frequent_queries"])[:10]

      for chunk in (search_results.chunks or []):
          if chunk.source_title and chunk.source_title not in profile["favorite_sources"]:
              profile["favorite_sources"].append(chunk.source_title)
      profile["favorite_sources"] = profile["favorite_sources"][:5]

      profile["last_interaction"] = datetime.utcnow().isoformat()
      save_user_profile(user_id, profile)

      return search_results
  ```
</CodeGroup>

#### User Preference Learning

```javascript theme={"dark"}
class PreferenceLearner {
  constructor() {
    this.preferencePatterns = new Map();
  }

  async learnFromInteraction(userId, interaction) {
    const {
      query,
      selectedResults,
      responseFormat,
      searchFilters,
      timeSpent,
      followUpQueries
    } = interaction;

    const patterns = this.preferencePatterns.get(userId) || {
      queryPatterns: [],
      formatPreferences: {},
      sourcePreferences: {},
      timePatterns: [],
      filterPreferences: {}
    };

    // Learn query patterns
    this.learnQueryPatterns(patterns, query, followUpQueries);
    
    // Learn format preferences
    this.learnFormatPreferences(patterns, responseFormat, selectedResults);
    
    // Learn source preferences
    this.learnSourcePreferences(patterns, selectedResults);
    
    // Learn time patterns
    this.learnTimePatterns(patterns, timeSpent);
    
    // Learn filter preferences
    this.learnFilterPreferences(patterns, searchFilters);

    this.preferencePatterns.set(userId, patterns);
  }

  learnQueryPatterns(patterns, query, followUpQueries) {
    // Analyze query complexity, length, and type
    const queryAnalysis = {
      length: query.length,
      complexity: this.analyzeComplexity(query),
      type: this.classifyQueryType(query),
      hasFilters: query.includes('in:') || query.includes('from:'),
      timestamp: new Date().toISOString()
    };

    patterns.queryPatterns.push(queryAnalysis);
    
    // Keep only recent patterns
    if (patterns.queryPatterns.length > 100) {
      patterns.queryPatterns = patterns.queryPatterns.slice(-100);
    }
  }

  learnFormatPreferences(patterns, format, selectedResults) {
    if (!patterns.formatPreferences[format]) {
      patterns.formatPreferences[format] = 0;
    }
    patterns.formatPreferences[format]++;
  }

  learnSourcePreferences(patterns, selectedResults) {
    selectedResults.forEach(result => {
      const sourceType = result.source;
      if (!patterns.sourcePreferences[sourceType]) {
        patterns.sourcePreferences[sourceType] = 0;
      }
      patterns.sourcePreferences[sourceType]++;
    });
  }

  analyzeComplexity(query) {
    const words = query.split(' ').length;
    const hasQuotes = query.includes('"');
    const hasOperators = /AND|OR|NOT|in:|from:|to:/.test(query);
    
    let complexity = 'simple';
    if (words > 5 || hasQuotes || hasOperators) complexity = 'complex';
    if (words > 10 || (hasQuotes && hasOperators)) complexity = 'advanced';
    
    return complexity;
  }

  classifyQueryType(query) {
    if (query.includes('how to') || query.includes('steps')) return 'how-to';
    if (query.includes('what is') || query.includes('define')) return 'definition';
    if (query.includes('when') || query.includes('schedule')) return 'temporal';
    if (query.includes('who') || query.includes('contact')) return 'person';
    if (query.includes('where') || query.includes('location')) return 'location';
    return 'general';
  }
}
```

#### Memory-Enhanced Search Interface

```javascript theme={"dark"}
const MemoryEnhancedSearch = ({ userId }) => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(null);
  const [userPreferences, setUserPreferences] = useState(null);
  const [suggestions, setSuggestions] = useState([]);

  const searchClient = new PersonalizedSearch(API_KEY, TENANT_ID);
  const preferenceLearner = new PreferenceLearner();

  useEffect(() => {
    // Load user preferences on component mount
    loadUserPreferences();
  }, [userId]);

  const loadUserPreferences = async () => {
    const profile = await searchClient.getUserProfile(userId);
    setUserPreferences(profile);
    
    // Generate search suggestions based on user's history
    const suggestions = generateSuggestions(profile);
    setSuggestions(suggestions);
  };

  const generateSuggestions = (profile) => {
    const suggestions = [];
    
    // Suggest based on frequent queries
    if (profile.frequentQueries.length > 0) {
      suggestions.push({
        type: 'frequent',
        text: profile.frequentQueries[0],
        label: 'Frequently searched'
      });
    }
    
    // Suggest based on recent searches
    if (profile.searchHistory.length > 0) {
      const recentSearches = profile.searchHistory
        .slice(-3)
        .map(h => h.query);
      
      recentSearches.forEach(query => {
        suggestions.push({
          type: 'recent',
          text: query,
          label: 'Recent search'
        });
      });
    }
    
    return suggestions;
  };

  const handleSearch = async () => {
    const startTime = Date.now();
    
    try {
      const searchResults = await searchClient.searchWithMemory(query, userId);
      setResults(searchResults);
      
      // Learn from this interaction
      const interaction = {
        query,
        selectedResults: [], // Will be populated when user clicks results
        responseFormat: searchResults.format || 'default',
        searchFilters: {},
        timeSpent: Date.now() - startTime,
        followUpQueries: []
      };
      
      await preferenceLearner.learnFromInteraction(userId, interaction);
      
    } catch (error) {
      console.error('Search failed:', error);
    }
  };

  const handleResultClick = async (result) => {
    // Update user preferences when they click on results
    const profile = await searchClient.getUserProfile(userId);
    
    // Mark this source type as preferred
    if (result.source && !profile.preferredSourceTypes.includes(result.source)) {
      profile.preferredSourceTypes.push(result.source);
      await searchClient.saveUserProfile(userId, profile);
    }
  };

  return (
    <div className="memory-enhanced-search">
      <div className="search-header">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search with your preferences in mind..."
          className="search-input"
        />
        <button onClick={handleSearch}>Search</button>
      </div>

      {/* Show personalized suggestions */}
      {suggestions.length > 0 && (
        <div className="search-suggestions">
          <h4>Based on your preferences:</h4>
          {suggestions.map((suggestion, index) => (
            <button
              key={index}
              className="suggestion-chip"
              onClick={() => setQuery(suggestion.text)}
            >
              {suggestion.text}
              <span className="suggestion-label">{suggestion.label}</span>
            </button>
          ))}
        </div>
      )}

      {/* Show user preferences summary */}
      {userPreferences && (
        <div className="user-preferences">
          <details>
            <summary>Your Search Preferences</summary>
            <div className="preferences-content">
              <p><strong>Preferred sources:</strong> {userPreferences.preferredSourceTypes.join(', ') || 'None set'}</p>
              <p><strong>Response style:</strong> {userPreferences.responseStyle}</p>
              <p><strong>Frequent searches:</strong> {userPreferences.frequentQueries.slice(0, 3).join(', ')}</p>
            </div>
          </details>
        </div>
      )}

      {results && (
        <SearchResults 
          results={results} 
          onResultClick={handleResultClick}
          userPreferences={userPreferences}
        />
      )}
    </div>
  );
};
```

#### Benefits of AI Memories in Your Glean Clone

1. **Personalized Search Results**: Users get results tailored to their preferences and past behavior
2. **Faster Information Discovery**: The system learns what sources and formats users prefer
3. **Improved User Experience**: Every interaction feels more personal and relevant
4. **Reduced Cognitive Load**: Users don't need to repeat their preferences or search patterns
5. **Adaptive Learning**: The system continuously improves based on user interactions
6. **Automatic Management**: HydraDB handles memory updates automatically - no manual implementation required

#### Best Practices for AI Memories

* **Respect Privacy**: Always give users control over their data and preferences
* **Transparency**: Show users what preferences are being used and allow them to modify them
* **Graceful Degradation**: Ensure the system works well even without user history
* **Sub-Tenant Isolation**: Use `sub_tenant_id` to isolate user data for personalized experiences
* **Performance**: Cache user profiles to avoid repeated API calls

## Step 3: Frontend Implementation

### 3.1 Search Interface

```javascript theme={"dark"}
// React component example
import React, { useState, useEffect } from 'react';

const GleanSearchInterface = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(null);
  const [loading, setLoading] = useState(false);
  const [filters, setFilters] = useState({
    sourceTypes: [],
    dateRange: null,
    authors: []
  });

  const searchClient = new AdvancedSearch(API_KEY, TENANT_ID);

  const handleSearch = async () => {
    setLoading(true);
    try {
      const searchResults = await searchClient.searchWithFilters(query, filters);
      setResults(searchResults);
    } catch (error) {
      console.error('Search failed:', error);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="glean-search">
      <div className="search-header">
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="Search across all your work..."
          className="search-input"
        />
        <button onClick={handleSearch} disabled={loading}>
          {loading ? 'Searching...' : 'Search'}
        </button>
      </div>

      <div className="search-filters">
        <SourceTypeFilter
          value={filters.sourceTypes}
          onChange={(types) => setFilters({...filters, sourceTypes: types})}
        />
        <DateRangeFilter
          value={filters.dateRange}
          onChange={(range) => setFilters({...filters, dateRange: range})}
        />
      </div>

      {results && (
        <SearchResults results={results} />
      )}
    </div>
  );
};
```

### 3.2 Results Display

```javascript theme={"dark"}
const SearchResults = ({ results }) => {
  const { chunks, graph_context } = results;

  return (
    <div className="search-results">
      {chunks && chunks.length > 0 && (
        <div className="source-results">
          <h3>Results ({chunks.length})</h3>
          {chunks.map((chunk, index) => (
            <ChunkCard key={index} chunk={chunk} />
          ))}
        </div>
      )}

      {graph_context && graph_context.query_paths && (
        <div className="graph-context">
          <h3>Related Knowledge Graph Paths</h3>
          <pre>{JSON.stringify(graph_context.query_paths, null, 2)}</pre>
        </div>
      )}
    </div>
  );
};

const ChunkCard = ({ chunk }) => {
  return (
    <div className="source-card">
      <div className="source-header">
        <span className="source-type">{chunk.source}</span>
        <span className="source-title">{chunk.source_title}</span>
        <span className="source-date">{formatDate(chunk.timestamp)}</span>
      </div>

      <div className="source-chunk">
        <p>{chunk.chunk_content}</p>
        {chunk.bounding_box && (
          <div className="chunk-highlight">
            Position: {chunk.bounding_box.x}, {chunk.bounding_box.y}
          </div>
        )}
      </div>
    </div>
  );
};
```

## Step 4: Data Synchronization

### 4.1 Scheduled Sync Jobs

```javascript theme={"dark"}
class DataSyncManager {
  constructor(connectors, cortexIngestion) {
    this.connectors = connectors;
    this.cortexIngestion = cortexIngestion;
    this.syncIntervals = {
      slack: 5 * 60 * 1000, // 5 minutes
      gmail: 10 * 60 * 1000, // 10 minutes
      notion: 30 * 60 * 1000, // 30 minutes
      documents: 60 * 60 * 1000 // 1 hour
    };
  }

  async startSync() {
    // Start sync jobs for each connector
    Object.entries(this.syncIntervals).forEach(([connector, interval]) => {
      setInterval(() => {
        this.syncConnector(connector);
      }, interval);
    });
  }

  async syncConnector(connectorName) {
    try {
      const connector = this.connectors[connectorName];
      const newData = await connector.fetchNewData();
      
      if (newData.length > 0) {
        const normalizedData = newData.map(item => 
          this.normalizeData(item, connectorName)
        );
        
        // Use batch upload with verification
        await this.cortexIngestion.uploadWithVerification(normalizedData);
        console.log(`Synced ${newData.length} items from ${connectorName}`);
      }
    } catch (error) {
      console.error(`Sync failed for ${connectorName}:`, error);
      // Implement retry logic with exponential backoff
      await this.retrySync(connectorName, error);
    }
  }

  async retrySync(connectorName, error, attempt = 1) {
    const maxAttempts = 3;
    const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
    
    if (attempt < maxAttempts) {
      console.log(`Retrying sync for ${connectorName} in ${delay}ms (attempt ${attempt + 1})`);
      setTimeout(() => {
        this.syncConnector(connectorName);
      }, delay);
    } else {
      console.error(`Max retry attempts reached for ${connectorName}:`, error);
    }
  }

  normalizeData(item, sourceType) {
    const createdAt = item.timestamp || item.created_at || new Date().toISOString();

    if (sourceType === "slack") {
      return {
        id: `slack_${item.channel}_${item.id}`,
        kind: "message",
        provider: "slack",
        external_id: item.id,
        fields: {
          kind: "message",
          body: item.text,
          author: item.user,
          thread_id: item.thread_ts || item.id,
          parent_id: item.thread_ts && item.thread_ts !== item.id ? item.thread_ts : undefined,
          created_at: createdAt,
          url: item.url
        },
        metadata: {
          container_type: "channel",
          container_id: item.channel,
          container_name: item.channel_name,
          source: "slack"
        },
        attachments: item.attachments || [],
        relations: item.relations || []
      };
    }

    if (sourceType === "gmail") {
      return {
        id: `gmail_${item.id}`,
        kind: "email",
        provider: "gmail",
        external_id: item.id,
        fields: {
          kind: "email",
          subject: item.subject,
          body: item.body,
          from: item.from,
          to: item.to || [],
          thread_id: item.thread_id,
          reply_to_id: item.reply_to_id,
          created_at: createdAt,
          url: item.url
        },
        metadata: { container_type: "mailbox", source: "gmail" },
        attachments: item.attachments || [],
        relations: item.relations || []
      };
    }

    return {
      id: `${sourceType}_${item.id}`,
      kind: "custom",
      provider: sourceType,
      external_id: item.id,
      fields: {
        kind: "custom",
        thread_id: item.thread_id,
        data: item
      },
      metadata: { source: sourceType },
      relations: item.relations || [],
      attachments: item.attachments || []
    };
  }
}
```

### 4.2 Webhook Integration

<CodeGroup>
  ```typescript TypeScript SDK theme={"dark"}
  import { HydraDBClient } from "@hydradb/sdk";

  const client = new HydraDBClient({ token: process.env.HYDRA_DB_API_KEY! });

  // Express.js webhook handler
  app.post("/webhooks/slack", async (req, res) => {
    const { event } = req.body;

    if (event.type === "message") {
      const normalizedData = {
        id: `slack_${event.channel}_${event.ts}`,
        tenant_id: process.env.TENANT_ID!,
        sub_tenant_id: process.env.SUB_TENANT_ID || "workspace",
        kind: "message",
        provider: "slack",
        external_id: event.ts,
        fields: {
          kind: "message",
          body: event.text,
          author: event.user,
          thread_id: event.thread_ts || event.ts,
          parent_id: event.thread_ts && event.thread_ts !== event.ts ? event.thread_ts : undefined,
          created_at: new Date(Number(event.ts) * 1000).toISOString(),
          url: `https://slack.com/archives/${event.channel}/p${event.ts.replace('.', '')}`
        },
        metadata: {
          container_type: "channel",
          container_id: event.channel,
          source: "slack"
        }
      };

      await client.upload.knowledge({
        tenant_id: process.env.TENANT_ID!,
        app_knowledge: JSON.stringify([normalizedData])
      });
    }

    res.status(200).send("OK");
  });
  ```

  ```python Python SDK theme={"dark"}
  import os
  import json
  from hydra_db import HydraDB
  from flask import Flask, request

  app = Flask(__name__)
  client = HydraDB(token=os.environ["HYDRA_DB_API_KEY"])

  @app.post("/webhooks/slack")
  def slack_webhook():
      event = request.json.get("event", {})

      if event.get("type") == "message":
          from datetime import datetime
          normalized_data = {
              "id": f"slack_{event['channel']}_{event['ts']}",
              "tenant_id": os.environ["TENANT_ID"],
              "sub_tenant_id": os.environ.get("SUB_TENANT_ID", "workspace"),
              "kind": "message",
              "provider": "slack",
              "external_id": event["ts"],
              "fields": {
                  "kind": "message",
                  "body": event["text"],
                  "author": event["user"],
                  "thread_id": event.get("thread_ts", event["ts"]),
                  "parent_id": event.get("thread_ts") if event.get("thread_ts") != event["ts"] else None,
                  "created_at": datetime.utcfromtimestamp(float(event["ts"])).isoformat(),
                  "url": f"https://slack.com/archives/{event['channel']}/p{event['ts'].replace('.', '')}"
              },
              "metadata": {
                  "container_type": "channel",
                  "container_id": event["channel"],
                  "source": "slack"
              }
          }

          client.upload.knowledge(
              tenant_id=os.environ["TENANT_ID"],
              app_knowledge=json.dumps([normalized_data])
          )

      return "OK", 200
  ```
</CodeGroup>

## Step 5: Security and Access Control

### 5.1 Multi-Tenant Architecture

```javascript theme={"dark"}
class TenantManager {
  constructor() {
    this.tenants = new Map();
  }

  async createTenant(tenantId, config) {
    const tenant = {
      id: tenantId,
      subTenants: new Map(),
      permissions: config.permissions || {},
      dataSources: config.dataSources || []
    };
    
    this.tenants.set(tenantId, tenant);
    return tenant;
  }

  async createSubTenant(tenantId, subTenantId, config) {
    const tenant = this.tenants.get(tenantId);
    if (!tenant) throw new Error('Tenant not found');

    const subTenant = {
      id: subTenantId,
      parentTenant: tenantId,
      permissions: config.permissions || {},
      dataSources: config.dataSources || []
    };

    tenant.subTenants.set(subTenantId, subTenant);
    return subTenant;
  }

  async searchWithTenantContext(query, tenantId, subTenantId = null, userId = null) {
    const tenant = this.tenants.get(tenantId);
    if (!tenant) throw new Error('Tenant not found');

    // Check user permissions
    if (userId && !this.hasPermission(tenant, subTenantId, userId)) {
      throw new Error('Access denied');
    }

    const searchOptions = {
      tenantId,
      subTenantId,
      metadata: {
        tenant_id: tenantId
      }
    };

    if (subTenantId) {
      searchOptions.metadata.sub_tenant_id = subTenantId;
    }

    return await searchClient.search(query, searchOptions);
  }

  hasPermission(tenant, subTenantId, userId) {
    // Implement your permission logic here
    return true; // Simplified for example
  }
}
```

### 5.2 Data Privacy and Compliance

```javascript theme={"dark"}
class DataPrivacyManager {
  constructor() {
    this.retentionPolicies = new Map();
  }

  async applyRetentionPolicy(tenantId, policy) {
    const { retentionDays, dataTypes, autoDelete } = policy;
    
    if (autoDelete) {
      const cutoffDate = new Date();
      cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
      
      // Delete old data from HydraDB
      await this.deleteOldData(tenantId, cutoffDate);
    }
  }

  async deleteOldData(tenantId, memoryId, subTenantId = null) {
    // Use HydraDB's delete-memory endpoint for data deletion
    let url = `https://api.hydradb.com/memories/delete_memory?tenant_id=${tenantId}&memory_id=${memoryId}`;
    if (subTenantId) {
      url += `&sub_tenant_id=${subTenantId}`;
    }

    const response = await fetch(url, {
      method: 'DELETE',
      headers: {
        'Authorization': `Bearer ${API_KEY}`
      }
    });

    return response.json();
  }

  async anonymizeData(data, anonymizationRules) {
    // Implement data anonymization based on rules
    let anonymizedData = { ...data };
    
    anonymizationRules.forEach(rule => {
      if (rule.field && anonymizedData[rule.field]) {
        anonymizedData[rule.field] = this.anonymizeValue(
          anonymizedData[rule.field], 
          rule.method
        );
      }
    });
    
    return anonymizedData;
  }

  anonymizeValue(value, method) {
    switch (method) {
      case 'hash':
        return crypto.createHash('sha256').update(value).digest('hex');
      case 'mask':
        return value.replace(/./g, '*');
      case 'redact':
        return '[REDACTED]';
      default:
        return value;
    }
  }
}
```

## Step 6: Performance Optimization

### 6.1 Caching Strategy

```javascript theme={"dark"}
class SearchCache {
  constructor() {
    this.cache = new Map();
    this.ttl = 5 * 60 * 1000; // 5 minutes
  }

  async getCachedResults(query, filters) {
    const key = this.generateCacheKey(query, filters);
    const cached = this.cache.get(key);
    
    if (cached && Date.now() - cached.timestamp < this.ttl) {
      return cached.results;
    }
    
    return null;
  }

  async setCachedResults(query, filters, results) {
    const key = this.generateCacheKey(query, filters);
    this.cache.set(key, {
      results,
      timestamp: Date.now()
    });
  }

  generateCacheKey(query, filters) {
    return `${query}_${JSON.stringify(filters)}`;
  }

  clearExpired() {
    const now = Date.now();
    for (const [key, value] of this.cache.entries()) {
      if (now - value.timestamp > this.ttl) {
        this.cache.delete(key);
      }
    }
  }
}
```

### 6.2 Rate Limiting

```javascript theme={"dark"}
class RateLimiter {
  constructor(limit, windowMs) {
    this.limit = limit;
    this.windowMs = windowMs;
    this.requests = new Map();
  }

  async checkLimit(userId) {
    const now = Date.now();
    const userRequests = this.requests.get(userId) || [];
    
    // Remove old requests outside the window
    const validRequests = userRequests.filter(
      timestamp => now - timestamp < this.windowMs
    );
    
    if (validRequests.length >= this.limit) {
      return false; // Rate limit exceeded
    }
    
    // Add current request
    validRequests.push(now);
    this.requests.set(userId, validRequests);
    
    return true; // Request allowed
  }
}
```

## Step 7: Monitoring and Analytics

### 7.1 Search Analytics

```javascript theme={"dark"}
class SearchAnalytics {
  constructor() {
    this.metrics = {
      searches: 0,
      successfulSearches: 0,
      failedSearches: 0,
      averageResponseTime: 0,
      popularQueries: new Map(),
      sourceTypeUsage: new Map()
    };
  }

  async trackSearch(query, filters, results, responseTime) {
    this.metrics.searches++;
    
    if (results && results.answer) {
      this.metrics.successfulSearches++;
    } else {
      this.metrics.failedSearches++;
    }

    // Track popular queries
    const queryKey = query.toLowerCase().trim();
    this.metrics.popularQueries.set(
      queryKey, 
      (this.metrics.popularQueries.get(queryKey) || 0) + 1
    );

    // Track source type usage
    if (results && results.chunks) {
      results.chunks.forEach(chunk => {
        const sourceType = chunk.source;
        this.metrics.sourceTypeUsage.set(
          sourceType,
          (this.metrics.sourceTypeUsage.get(sourceType) || 0) + 1
        );
      });
    }

    // Update average response time
    this.updateAverageResponseTime(responseTime);
  }

  updateAverageResponseTime(newTime) {
    const currentAvg = this.metrics.averageResponseTime;
    const totalSearches = this.metrics.searches;
    
    this.metrics.averageResponseTime = 
      (currentAvg * (totalSearches - 1) + newTime) / totalSearches;
  }

  getMetrics() {
    return {
      ...this.metrics,
      popularQueries: Array.from(this.metrics.popularQueries.entries())
        .sort((a, b) => b[1] - a[1])
        .slice(0, 10),
      sourceTypeUsage: Array.from(this.metrics.sourceTypeUsage.entries())
        .sort((a, b) => b[1] - a[1])
    };
  }
}
```

## Best Practices and Recommendations

### 1. Data Ingestion Best Practices

* **Batch Processing**: Use HydraDB's batch upload endpoints for efficiency
* **Batch Limits**: Limit to 20 app sources per request with 1-second intervals between batches
* **Incremental Sync**: Only sync new/changed data to minimize API calls
* **Error Handling**: Implement retry logic with exponential backoff
* **Processing Verification**: Always verify upload processing using `/ingestion/verify_processing`
* **Rate Limiting**: Respect API rate limits and implement queuing

### 2. Search Optimization

* **Query Preprocessing**: Clean and normalize user queries
* **Result Ranking**: Use `alpha` and `recency_bias` for fine-tuning
* **App-Aware Recall**: Set `search_apps: true` for Slack/Gmail/Jira/Notion-style sources so HydraDB can use provider IDs, actors, threads, parents, and explicit relations.
* **Metadata Filtering**: Use `metadata_filters` only for metadata keys you stored at ingestion, such as `source`, `container_id`, project, space, or region.
* **Thinking Mode**: Use `mode: "thinking"` for complex queries that benefit from multi-query retrieval, relation expansion, and reranking
* **Caching**: Cache frequent queries and results

### 3. Security Considerations

* **Data Encryption**: Encrypt sensitive data at rest and in transit
* **Access Control**: Implement role-based access control (RBAC)
* **Audit Logging**: Log all search queries and data access
* **Data Retention**: Implement automatic data deletion policies

### 4. Performance Tips

* **Connection Pooling**: Reuse HTTP connections
* **Async Processing**: Use async/await for non-blocking operations
* **Memory Management**: Implement proper cleanup for large datasets
* **Batch Optimization**: Respect 20-source batch limits and 1-second intervals
* **Processing Verification**: Verify uploads to ensure data is properly indexed
* **Monitoring**: Track response times and error rates

### 5. User Experience

* **Autocomplete**: Implement search suggestions
* **Faceted Search**: Allow filtering by source type, date, author
* **Saved Searches**: Let users save and share search queries
* **Export Results**: Allow users to export search results

## Deployment Checklist

* Set up authentication and authorization
* Configure data connectors and sync schedules
* Implement error handling and monitoring
* Set up caching and rate limiting
* Configure backup and disaster recovery
* Test with production data volumes
* Set up analytics and reporting
* Document API usage and troubleshooting

## Conclusion

Building a Glean-like application with HydraDB APIs provides you with a powerful, scalable foundation for workplace search and AI assistance. By following this guide and implementing the best practices outlined, you can create a comprehensive solution that rivals commercial offerings while maintaining full control over your data and user experience.

The key to success is starting with a solid architecture, implementing proper data synchronization, and gradually adding advanced features like multi-step reasoning, conversation memory, and personalized responses. HydraDB's APIs provide the AI capabilities you need, while your application handles the data ingestion, user interface, and business logic.

Remember to monitor performance, gather user feedback, and continuously iterate on your implementation to create the best possible user experience.

## Change Log

| Date       | Change                                                                                                                                          |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-05-14 | Replaced raw `fetch()`-based `HydraDBDataIngestion` class with official SDK calls (`client.upload.knowledge`, `client.upload.verifyProcessing`) |
| 2026-05-14 | Replaced raw `fetch()`-based `GleanSearch` class with `client.recall.fullRecall` SDK call                                                       |
| 2026-05-14 | Replaced `AdvancedSearch` class with standalone SDK helper functions (`searchWithFilters`, `searchWithContext`, `conversationalSearch`)         |
| 2026-05-14 | Replaced `PersonalizedSearch` class with flat SDK-based `searchWithMemory` function                                                             |
| 2026-05-14 | Replaced webhook `cortexIngestion.uploadBatch()` call with `client.upload.knowledge()` SDK call                                                 |
| 2026-05-14 | Added Python SDK equivalents in `<CodeGroup>` tabs for all replaced code blocks                                                                 |
| 2026-05-14 | Removed "Status: In progress" notice                                                                                                            |
