diff --git a/services/crawler/app/main.py b/services/crawler/app/main.py
index 97a3116456..3438b859a9 100644
--- a/services/crawler/app/main.py
+++ b/services/crawler/app/main.py
@@ -28,6 +28,7 @@
     image_router,
     pdf_router,
     pptx_router,
+    web_router,
 )
 from app.services.crawler_service import get_crawler_service
 from app.services.image_service import get_image_service
@@ -93,6 +94,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
 app.include_router(image_router)
 app.include_router(docx_router)
 app.include_router(pptx_router)
+app.include_router(web_router)
 
 
 @app.get("/health", response_model=HealthResponse)
diff --git a/services/crawler/app/models.py b/services/crawler/app/models.py
index bbd3449a6a..92eae90f63 100644
--- a/services/crawler/app/models.py
+++ b/services/crawler/app/models.py
@@ -252,3 +252,27 @@ class ParseFileResponse(BaseModel):
     metadata: dict[str, Any] | None = Field(None, description="Document metadata")
     vision_used: bool | None = Field(None, description="Whether Vision API was used")
     error: str | None = Field(None, description="Error message if parsing failed")
+
+
+# ==================== Web Fetch & Extract Models ====================
+
+
+class WebFetchExtractRequest(BaseModel):
+    """Request to fetch URL and extract content."""
+
+    url: HttpUrl = Field(..., description="URL to fetch and extract content from")
+    instruction: str | None = Field(None, description="Optional AI instruction for content extraction")
+    timeout: int = Field(60000, description="Navigation timeout in ms (default: 60s)", ge=5000, le=120000)
+
+
+class WebFetchExtractResponse(BaseModel):
+    """Response from web fetch and extract operation."""
+
+    success: bool = Field(..., description="Whether the operation was successful")
+    url: str = Field(..., description="The fetched URL")
+    title: str | None = Field(None, description="Page title")
+    content: str = Field(..., description="Extracted text content")
+    word_count: int = Field(..., description="Number of words in content")
+    page_count: int = Field(..., description="Number of pages in PDF")
+    vision_used: bool = Field(False, description="Whether Vision API was used for extraction")
+    error: str | None = Field(None, description="Error message if operation failed")
diff --git a/services/crawler/app/routers/__init__.py b/services/crawler/app/routers/__init__.py
index 6edf81eb4a..427a6dcf5a 100644
--- a/services/crawler/app/routers/__init__.py
+++ b/services/crawler/app/routers/__init__.py
@@ -7,6 +7,7 @@
 - image: Image conversion (/api/v1/images)
 - docx: DOCX document generation and parsing (/api/v1/docx)
 - pptx: PPTX template generation and parsing (/api/v1/pptx)
+- web: Web fetch and extract (/api/v1/web)
 """
 
 from app.routers.crawler import router as crawler_router
@@ -14,6 +15,7 @@
 from app.routers.image import router as image_router
 from app.routers.pdf import router as pdf_router
 from app.routers.pptx import router as pptx_router
+from app.routers.web import router as web_router
 
 __all__ = [
     "crawler_router",
@@ -21,4 +23,5 @@
     "image_router",
     "pdf_router",
     "pptx_router",
+    "web_router",
 ]
diff --git a/services/crawler/app/routers/web.py b/services/crawler/app/routers/web.py
new file mode 100644
index 0000000000..4c2d2d1acf
--- /dev/null
+++ b/services/crawler/app/routers/web.py
@@ -0,0 +1,147 @@
+"""
+Web Router - URL content extraction endpoint.
+
+Combines URL-to-PDF conversion with Vision-based text extraction.
+"""
+
+import socket
+from ipaddress import ip_address
+from urllib.parse import urlparse
+
+from fastapi import APIRouter, HTTPException, status
+from loguru import logger
+
+from app.models import WebFetchExtractRequest, WebFetchExtractResponse
+from app.services.file_parser_service import get_file_parser_service
+from app.services.pdf_service import get_pdf_service
+
+router = APIRouter(prefix="/api/v1/web", tags=["Web"])
+
+
+def validate_url_not_private(url_str: str) -> str:
+    """
+    Validate that a URL does not resolve to a private/internal IP address.
+
+    Prevents SSRF attacks by blocking requests to loopback, link-local,
+    and private RFC1918/IPv6 addresses.
+
+    Args:
+        url_str: The URL to validate
+
+    Returns:
+        The hostname if validation passes
+
+    Raises:
+        HTTPException: If the URL host cannot be resolved or resolves to a private IP
+    """
+    parsed_url = urlparse(url_str)
+    hostname = parsed_url.hostname or ""
+
+    if not hostname:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Invalid URL: no hostname found",
+        )
+
+    try:
+        resolved_ips = {
+            ip_address(info[4][0])
+            for info in socket.getaddrinfo(hostname, None)
+        }
+    except socket.gaierror:
+        logger.warning(f"SSRF protection: unable to resolve hostname '{hostname}'")
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="Unable to resolve URL host",
+        )
+
+    blocked_ips = [
+        ip for ip in resolved_ips
+        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
+    ]
+
+    if blocked_ips:
+        logger.warning(
+            f"SSRF protection: blocked request to '{hostname}' "
+            f"(resolved to private/internal IPs: {blocked_ips})"
+        )
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail="URL host is not allowed (resolves to private/internal address)",
+        )
+
+    return hostname
+
+
+@router.post("/fetch-and-extract", response_model=WebFetchExtractResponse)
+async def fetch_and_extract(request: WebFetchExtractRequest):
+    """
+    Fetch a URL, convert to PDF, and extract text content.
+
+    Pipeline:
+    1. Navigate to URL with Playwright and render as PDF
+    2. Extract text using PyMuPDF + Vision API (handles images, OCR for scanned content)
+
+    Args:
+        request: URL and optional extraction instruction
+
+    Returns:
+        Extracted content with metadata
+    """
+    url_str = str(request.url)
+    hostname = validate_url_not_private(url_str)
+
+    try:
+        pdf_service = get_pdf_service()
+        if not pdf_service.initialized:
+            await pdf_service.initialize()
+
+        logger.info(f"Fetching URL as PDF: {url_str}")
+        pdf_bytes = await pdf_service.url_to_pdf(
+            url=url_str,
+            timeout=request.timeout,
+        )
+
+        logger.info(f"Extracting content from PDF ({len(pdf_bytes)} bytes)")
+        parser = get_file_parser_service()
+        result = await parser.parse_pdf_with_vision(
+            pdf_bytes,
+            filename=f"{hostname}.pdf",
+            user_input=request.instruction,
+            process_images=True,
+            ocr_scanned_pages=True,
+        )
+
+        if not result.get("success"):
+            return WebFetchExtractResponse(
+                success=False,
+                url=url_str,
+                content="",
+                word_count=0,
+                page_count=0,
+                error=result.get("error", "Failed to extract content from PDF"),
+            )
+
+        full_text = result.get("full_text", "")
+        word_count = len(full_text.split()) if full_text else 0
+
+        logger.info(
+            f"Content extracted: {word_count} words, {result.get('page_count', 0)} pages, "
+            f"vision_used={result.get('vision_used', False)}"
+        )
+
+        return WebFetchExtractResponse(
+            success=True,
+            url=url_str,
+            content=full_text,
+            word_count=word_count,
+            page_count=result.get("page_count", 0),
+            vision_used=result.get("vision_used", False),
+        )
+
+    except Exception:
+        logger.exception(f"Error fetching and extracting URL: {url_str}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail=f"Failed to fetch and extract content from URL: {url_str}",
+        ) from None
diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx
index ad11c4618b..3d23885508 100644
--- a/services/platform/app/features/chat/components/chat-interface.tsx
+++ b/services/platform/app/features/chat/components/chat-interface.tsx
@@ -56,6 +56,7 @@ export function ChatInterface({
     streamingMessage,
     pendingToolResponse,
     hasActiveTools,
+    isProcessingToolResult,
   } = useMessageProcessing(threadId);
 
   // Merge with pending messages from context for optimistic UI
@@ -221,6 +222,7 @@ export function ChatInterface({
             streamingMessage={streamingMessage}
             pendingToolResponse={pendingToolResponse}
             hasActiveTools={hasActiveTools}
+            isProcessingToolResult={isProcessingToolResult}
             aiResponseAreaRef={aiResponseAreaRef}
             onHumanInputResponseSubmitted={handleHumanInputResponseSubmitted}
           />
diff --git a/services/platform/app/features/chat/components/chat-messages.tsx b/services/platform/app/features/chat/components/chat-messages.tsx
index a46eebe22c..75e87eb1e9 100644
--- a/services/platform/app/features/chat/components/chat-messages.tsx
+++ b/services/platform/app/features/chat/components/chat-messages.tsx
@@ -23,6 +23,7 @@ interface ChatMessagesProps {
   streamingMessage: UIMessage | undefined;
   pendingToolResponse: UIMessage | undefined;
   hasActiveTools: boolean;
+  isProcessingToolResult: boolean;
   aiResponseAreaRef: RefObject<HTMLDivElement | null>;
   onHumanInputResponseSubmitted?: () => void;
 }
@@ -41,6 +42,7 @@ export function ChatMessages({
   streamingMessage,
   pendingToolResponse,
   hasActiveTools,
+  isProcessingToolResult,
   aiResponseAreaRef,
   onHumanInputResponseSubmitted,
 }: ChatMessagesProps) {
@@ -173,6 +175,7 @@ export function ChatMessages({
           (streamingMessage?.status === 'streaming' &&
             !streamingMessage.text) ||
           hasActiveTools ||
+          isProcessingToolResult ||
           !!pendingToolResponse) && (
           <ThinkingAnimation streamingMessage={streamingMessage} />
         )}
diff --git a/services/platform/app/features/chat/components/thinking-animation.tsx b/services/platform/app/features/chat/components/thinking-animation.tsx
index a0c20f526a..8ca09a3798 100644
--- a/services/platform/app/features/chat/components/thinking-animation.tsx
+++ b/services/platform/app/features/chat/components/thinking-animation.tsx
@@ -34,21 +34,19 @@ export function ThinkingAnimation({ streamingMessage }: ThinkingAnimationProps)
     toolName: string,
     input?: Record<string, unknown>,
   ): ToolDetail => {
-    if (toolName === 'web_read' && input) {
-      if (input.operation === 'search' && input.query) {
+    if (toolName === 'web' && input) {
+      if (input.operation === 'fetch_url' && input.url) {
         return {
           toolName,
-          displayText: t('thinking.searching', {
-            query: truncate(String(input.query), 30),
+          displayText: t('thinking.reading', {
+            hostname: extractHostname(String(input.url)),
           }),
         };
       }
-      if (input.operation === 'fetch_url' && input.url) {
+      if (input.operation === 'browser_operate' && input.instruction) {
         return {
           toolName,
-          displayText: t('thinking.reading', {
-            hostname: extractHostname(String(input.url)),
-          }),
+          displayText: t('thinking.browsing'),
         };
       }
     }
@@ -66,7 +64,7 @@ export function ThinkingAnimation({ streamingMessage }: ThinkingAnimationProps)
       customer_read: t('tools.customerRead'),
       product_read: t('tools.productRead'),
       rag_search: t('tools.ragSearch'),
-      web_read: t('tools.webRead'),
+      web: t('tools.web'),
       pdf: t('tools.pdf'),
       image: t('tools.image'),
       pptx: t('tools.pptx'),
diff --git a/services/platform/app/features/chat/hooks/use-message-processing.ts b/services/platform/app/features/chat/hooks/use-message-processing.ts
index d6759b9d3a..015a2660a3 100644
--- a/services/platform/app/features/chat/hooks/use-message-processing.ts
+++ b/services/platform/app/features/chat/hooks/use-message-processing.ts
@@ -34,6 +34,7 @@ interface UseMessageProcessingResult {
   streamingMessage: UIMessage | undefined;
   pendingToolResponse: UIMessage | undefined;
   hasActiveTools: boolean;
+  isProcessingToolResult: boolean;
 }
 
 /**
@@ -151,6 +152,39 @@ export function useMessageProcessing(
     );
   }, [streamingMessage?.parts]);
 
+  // Check if agent is processing tool result (tool completed but no text after it)
+  // This handles the gap when sub-agent tools complete but agent hasn't resumed streaming
+  const isProcessingToolResult = useMemo(() => {
+    if (!uiMessages?.length) return false;
+
+    const lastAssistant = uiMessages
+      .filter((m) => m.role === 'assistant')
+      .at(-1);
+    if (!lastAssistant?.parts?.length) return false;
+    if (lastAssistant.status === 'success' || lastAssistant.status === 'failed')
+      return false;
+
+    const lastToolIndex = lastAssistant.parts.findLastIndex(
+      (part: { type: string }) =>
+        part.type.startsWith('tool-') && part.type !== 'tool-result',
+    );
+    if (lastToolIndex === -1) return false;
+
+    const lastToolPart = lastAssistant.parts[lastToolIndex] as {
+      type: string;
+      state?: string;
+    };
+    if (lastToolPart?.state !== 'output-available') return false;
+
+    const partsAfterTool = lastAssistant.parts.slice(lastToolIndex + 1);
+    const hasTextAfterTool = partsAfterTool.some(
+      (part: { type: string; text?: string }) =>
+        part.type === 'text' && part.text,
+    );
+
+    return !hasTextAfterTool;
+  }, [uiMessages]);
+
   return {
     messages,
     uiMessages,
@@ -160,5 +194,6 @@ export function useMessageProcessing(
     streamingMessage,
     pendingToolResponse,
     hasActiveTools,
+    isProcessingToolResult,
   };
 }
diff --git a/services/platform/convex/_generated/api.d.ts b/services/platform/convex/_generated/api.d.ts
index e39ca445dd..06775e35e5 100644
--- a/services/platform/convex/_generated/api.d.ts
+++ b/services/platform/convex/_generated/api.d.ts
@@ -12,15 +12,6 @@ import type * as accounts_helpers from "../accounts/helpers.js";
 import type * as accounts_queries from "../accounts/queries.js";
 import type * as accounts_types from "../accounts/types.js";
 import type * as accounts_validators from "../accounts/validators.js";
-import type * as agent_tools_crawler_helpers_fetch_page_content from "../agent_tools/crawler/helpers/fetch_page_content.js";
-import type * as agent_tools_crawler_helpers_fetch_searxng_results from "../agent_tools/crawler/helpers/fetch_searxng_results.js";
-import type * as agent_tools_crawler_helpers_get_crawler_service_url from "../agent_tools/crawler/helpers/get_crawler_service_url.js";
-import type * as agent_tools_crawler_helpers_get_search_service_url from "../agent_tools/crawler/helpers/get_search_service_url.js";
-import type * as agent_tools_crawler_helpers_search_and_fetch from "../agent_tools/crawler/helpers/search_and_fetch.js";
-import type * as agent_tools_crawler_helpers_search_web from "../agent_tools/crawler/helpers/search_web.js";
-import type * as agent_tools_crawler_helpers_types from "../agent_tools/crawler/helpers/types.js";
-import type * as agent_tools_crawler_internal_actions from "../agent_tools/crawler/internal_actions.js";
-import type * as agent_tools_crawler_web_read_tool from "../agent_tools/crawler/web_read_tool.js";
 import type * as agent_tools_create_json_output_tool from "../agent_tools/create_json_output_tool.js";
 import type * as agent_tools_customers_customer_read_tool from "../agent_tools/customers/customer_read_tool.js";
 import type * as agent_tools_customers_helpers_count_customers from "../agent_tools/customers/helpers/count_customers.js";
@@ -70,9 +61,7 @@ import type * as agent_tools_sub_agents_document_assistant_tool from "../agent_t
 import type * as agent_tools_sub_agents_helpers_build_additional_context from "../agent_tools/sub_agents/helpers/build_additional_context.js";
 import type * as agent_tools_sub_agents_helpers_check_role_access from "../agent_tools/sub_agents/helpers/check_role_access.js";
 import type * as agent_tools_sub_agents_helpers_format_integrations from "../agent_tools/sub_agents/helpers/format_integrations.js";
-import type * as agent_tools_sub_agents_helpers_get_operator_service_url from "../agent_tools/sub_agents/helpers/get_operator_service_url.js";
 import type * as agent_tools_sub_agents_helpers_get_or_create_sub_thread from "../agent_tools/sub_agents/helpers/get_or_create_sub_thread.js";
-import type * as agent_tools_sub_agents_helpers_operator_types from "../agent_tools/sub_agents/helpers/operator_types.js";
 import type * as agent_tools_sub_agents_helpers_tool_response from "../agent_tools/sub_agents/helpers/tool_response.js";
 import type * as agent_tools_sub_agents_helpers_types from "../agent_tools/sub_agents/helpers/types.js";
 import type * as agent_tools_sub_agents_helpers_validate_context from "../agent_tools/sub_agents/helpers/validate_context.js";
@@ -82,6 +71,12 @@ import type * as agent_tools_sub_agents_workflow_assistant_tool from "../agent_t
 import type * as agent_tools_threads_context_search_tool from "../agent_tools/threads/context_search_tool.js";
 import type * as agent_tools_tool_registry from "../agent_tools/tool_registry.js";
 import type * as agent_tools_types from "../agent_tools/types.js";
+import type * as agent_tools_web_helpers_browser_operate from "../agent_tools/web/helpers/browser_operate.js";
+import type * as agent_tools_web_helpers_fetch_url_via_pdf from "../agent_tools/web/helpers/fetch_url_via_pdf.js";
+import type * as agent_tools_web_helpers_get_crawler_service_url from "../agent_tools/web/helpers/get_crawler_service_url.js";
+import type * as agent_tools_web_helpers_get_operator_service_url from "../agent_tools/web/helpers/get_operator_service_url.js";
+import type * as agent_tools_web_helpers_types from "../agent_tools/web/helpers/types.js";
+import type * as agent_tools_web_web_tool from "../agent_tools/web/web_tool.js";
 import type * as agent_tools_workflows_create_workflow_approval from "../agent_tools/workflows/create_workflow_approval.js";
 import type * as agent_tools_workflows_create_workflow_tool from "../agent_tools/workflows/create_workflow_tool.js";
 import type * as agent_tools_workflows_execute_approved_workflow_creation from "../agent_tools/workflows/execute_approved_workflow_creation.js";
@@ -897,15 +892,6 @@ declare const fullApi: ApiFromModules<{
   "accounts/queries": typeof accounts_queries;
   "accounts/types": typeof accounts_types;
   "accounts/validators": typeof accounts_validators;
-  "agent_tools/crawler/helpers/fetch_page_content": typeof agent_tools_crawler_helpers_fetch_page_content;
-  "agent_tools/crawler/helpers/fetch_searxng_results": typeof agent_tools_crawler_helpers_fetch_searxng_results;
-  "agent_tools/crawler/helpers/get_crawler_service_url": typeof agent_tools_crawler_helpers_get_crawler_service_url;
-  "agent_tools/crawler/helpers/get_search_service_url": typeof agent_tools_crawler_helpers_get_search_service_url;
-  "agent_tools/crawler/helpers/search_and_fetch": typeof agent_tools_crawler_helpers_search_and_fetch;
-  "agent_tools/crawler/helpers/search_web": typeof agent_tools_crawler_helpers_search_web;
-  "agent_tools/crawler/helpers/types": typeof agent_tools_crawler_helpers_types;
-  "agent_tools/crawler/internal_actions": typeof agent_tools_crawler_internal_actions;
-  "agent_tools/crawler/web_read_tool": typeof agent_tools_crawler_web_read_tool;
   "agent_tools/create_json_output_tool": typeof agent_tools_create_json_output_tool;
   "agent_tools/customers/customer_read_tool": typeof agent_tools_customers_customer_read_tool;
   "agent_tools/customers/helpers/count_customers": typeof agent_tools_customers_helpers_count_customers;
@@ -955,9 +941,7 @@ declare const fullApi: ApiFromModules<{
   "agent_tools/sub_agents/helpers/build_additional_context": typeof agent_tools_sub_agents_helpers_build_additional_context;
   "agent_tools/sub_agents/helpers/check_role_access": typeof agent_tools_sub_agents_helpers_check_role_access;
   "agent_tools/sub_agents/helpers/format_integrations": typeof agent_tools_sub_agents_helpers_format_integrations;
-  "agent_tools/sub_agents/helpers/get_operator_service_url": typeof agent_tools_sub_agents_helpers_get_operator_service_url;
   "agent_tools/sub_agents/helpers/get_or_create_sub_thread": typeof agent_tools_sub_agents_helpers_get_or_create_sub_thread;
-  "agent_tools/sub_agents/helpers/operator_types": typeof agent_tools_sub_agents_helpers_operator_types;
   "agent_tools/sub_agents/helpers/tool_response": typeof agent_tools_sub_agents_helpers_tool_response;
   "agent_tools/sub_agents/helpers/types": typeof agent_tools_sub_agents_helpers_types;
   "agent_tools/sub_agents/helpers/validate_context": typeof agent_tools_sub_agents_helpers_validate_context;
@@ -967,6 +951,12 @@ declare const fullApi: ApiFromModules<{
   "agent_tools/threads/context_search_tool": typeof agent_tools_threads_context_search_tool;
   "agent_tools/tool_registry": typeof agent_tools_tool_registry;
   "agent_tools/types": typeof agent_tools_types;
+  "agent_tools/web/helpers/browser_operate": typeof agent_tools_web_helpers_browser_operate;
+  "agent_tools/web/helpers/fetch_url_via_pdf": typeof agent_tools_web_helpers_fetch_url_via_pdf;
+  "agent_tools/web/helpers/get_crawler_service_url": typeof agent_tools_web_helpers_get_crawler_service_url;
+  "agent_tools/web/helpers/get_operator_service_url": typeof agent_tools_web_helpers_get_operator_service_url;
+  "agent_tools/web/helpers/types": typeof agent_tools_web_helpers_types;
+  "agent_tools/web/web_tool": typeof agent_tools_web_web_tool;
   "agent_tools/workflows/create_workflow_approval": typeof agent_tools_workflows_create_workflow_approval;
   "agent_tools/workflows/create_workflow_tool": typeof agent_tools_workflows_create_workflow_tool;
   "agent_tools/workflows/execute_approved_workflow_creation": typeof agent_tools_workflows_execute_approved_workflow_creation;
diff --git a/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts b/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts
deleted file mode 100644
index c0bb615f72..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/fetch_page_content.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * Helper: Fetch Page Content
- *
- * Fetches and extracts content from a web URL using the crawler service.
- */
-
-import { getCrawlerServiceUrl } from './get_crawler_service_url';
-import { type FetchUrlsApiResponse, type WebReadFetchUrlResult } from './types';
-import type { ToolCtx } from '@convex-dev/agent';
-import { internal } from '../../../_generated/api';
-import type { Doc } from '../../../_generated/dataModel';
-
-import { createDebugLog } from '../../../lib/debug_log';
-
-const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]');
-
-// Convex imposes a 1 MiB per-value limit. Some pages can be very large and would
-// cause tool result messages to exceed this limit when stored by @convex-dev/agent.
-// Truncate the extracted page content to keep each message comfortably below
-// the limit while still providing rich context for the model.
-const MAX_CONTENT_CHARS = 100_000;
-
-export async function fetchPageContent(
-  ctx: ToolCtx,
-  args: { url: string; word_count_threshold?: number },
-): Promise<WebReadFetchUrlResult> {
-  const { variables, organizationId } = ctx;
-  const crawlerServiceUrl = getCrawlerServiceUrl(variables);
-
-  debugLog('tool:web_read:fetch_url start', {
-    url: args.url,
-    crawlerServiceUrl,
-  });
-
-  // Try cache first when organizationId is available.
-  if (organizationId) {
-    try {
-      // @ts-ignore TS2589: Convex API type instantiation is excessively deep
-      const cachedPage = (await ctx.runQuery(internal.websites.queries.getWebsitePageByUrlInternal, { organizationId, url: args.url })) as Doc<'websitePages'> | null;
-
-      if (cachedPage && cachedPage.content) {
-        const rawContent = cachedPage.content ?? '';
-        const wasTruncated = rawContent.length > MAX_CONTENT_CHARS;
-        const content = wasTruncated
-          ? rawContent.slice(0, MAX_CONTENT_CHARS)
-          : rawContent;
-
-        debugLog('tool:web_read:fetch_url cache_hit', {
-          url: args.url,
-          organizationId,
-          title: cachedPage.title,
-          word_count: cachedPage.wordCount,
-          truncated: wasTruncated,
-          content_length: rawContent.length,
-          has_structured_data: !!cachedPage.structuredData,
-        });
-
-        return {
-          operation: 'fetch_url',
-          success: true,
-          url: cachedPage.url,
-          title: cachedPage.title ?? undefined,
-          content,
-          word_count: cachedPage.wordCount ?? 0,
-          metadata: {
-            ...(cachedPage.metadata ?? {}),
-            truncated: wasTruncated,
-            original_content_length: rawContent.length,
-          },
-          structured_data: cachedPage.structuredData ?? undefined,
-        };
-      }
-
-      debugLog('tool:web_read:fetch_url cache_miss', {
-        url: args.url,
-        organizationId,
-      });
-    } catch (error) {
-      debugLog('tool:web_read:fetch_url cache_error', {
-        url: args.url,
-        organizationId,
-        error: error instanceof Error ? error.message : String(error),
-      });
-    }
-  }
-
-  const apiUrl = `${crawlerServiceUrl}/api/v1/urls/fetch`;
-
-  const payload = {
-    urls: [args.url],
-    // Low threshold for single URL fetches - user explicitly requested this page
-    word_count_threshold: args.word_count_threshold ?? 0,
-  };
-
-  try {
-    const controller = new AbortController();
-    const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout
-
-    const response = await fetch(apiUrl, {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-      },
-      body: JSON.stringify(payload),
-      signal: controller.signal,
-    });
-
-    clearTimeout(timeoutId);
-
-    if (!response.ok) {
-      const errorText = await response.text();
-      throw new Error(`Crawler service error: ${response.status} ${errorText}`);
-    }
-
-    const result = (await response.json()) as FetchUrlsApiResponse;
-
-    if (!result.success) {
-      throw new Error(`Crawler service returned failure for URL: ${args.url}`);
-    }
-
-    if (result.pages.length === 0) {
-      throw new Error(
-        `No content extracted from URL: ${args.url}. The page may be empty, blocked, or have insufficient text content.`,
-      );
-    }
-
-    const page = result.pages[0];
-
-    const rawContent = page.content ?? '';
-    const wasTruncated = rawContent.length > MAX_CONTENT_CHARS;
-    const content = wasTruncated
-      ? rawContent.slice(0, MAX_CONTENT_CHARS)
-      : rawContent;
-
-    debugLog('tool:web_read:fetch_url success', {
-      url: args.url,
-      title: page.title,
-      word_count: page.word_count,
-      truncated: wasTruncated,
-      content_length: rawContent.length,
-      has_structured_data: !!page.structured_data,
-    });
-
-    // Return both structured_data (OpenGraph, JSON-LD) and content.
-    // structured_data contains machine-readable product info including variant prices.
-    // content is fallback for pages without structured data.
-    return {
-      operation: 'fetch_url',
-      success: true,
-      url: page.url,
-      title: page.title,
-      content,
-      word_count: page.word_count,
-      metadata: {
-        ...(page.metadata ?? {}),
-        truncated: wasTruncated,
-        original_content_length: rawContent.length,
-      },
-      structured_data: page.structured_data,
-    };
-  } catch (error) {
-    console.error('[tool:web_read:fetch_url] error', {
-      url: args.url,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    throw error;
-  }
-}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts b/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts
deleted file mode 100644
index ac99fb99c3..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/fetch_searxng_results.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-/**
- * Helper: fetchSearXNGResults
- *
- * Performs a search against the SearXNG meta search engine.
- */
-
-import {
-  type SearchOptions,
-  type SearchResult,
-  type SearXNGResponse,
-} from './types';
-export type { SearchOptions } from './types';
-import { searchResultsCache } from '../../../lib/action_cache';
-import type { ActionCtx } from '../../../_generated/server';
-
-export async function fetchSearXNGResults(
-  serviceUrl: string,
-  options: SearchOptions,
-): Promise<{
-  items: SearchResult[];
-  totalResults: number;
-  suggestions: string[];
-}> {
-  const url = new URL(`${serviceUrl}/search`);
-
-  // Build query - prepend site: filter if specified
-  let finalQuery = options.query;
-  if (options.site) {
-    finalQuery = `site:${options.site} ${options.query}`;
-  }
-  url.searchParams.set('q', finalQuery);
-  url.searchParams.set('format', 'json');
-  url.searchParams.set('pageno', String(options.pageNo ?? 1));
-
-  if (options.engines && options.engines.length > 0) {
-    url.searchParams.set('engines', options.engines.join(','));
-  }
-
-  if (options.categories && options.categories.length > 0) {
-    url.searchParams.set('categories', options.categories.join(','));
-  }
-
-  if (options.timeRange) {
-    url.searchParams.set('time_range', options.timeRange);
-  }
-
-  if (options.safesearch !== undefined) {
-    url.searchParams.set('safesearch', String(options.safesearch));
-  }
-
-  if (options.language) {
-    url.searchParams.set('language', options.language);
-  }
-
-  const response = await fetch(url.toString(), {
-    headers: { Accept: 'application/json' },
-  });
-
-  if (!response.ok) {
-    const errorText = await response.text();
-    throw new Error(`SearXNG Search API error: ${response.status} ${errorText}`);
-  }
-
-  const data = (await response.json()) as SearXNGResponse;
-  const numResults = options.numResults ?? 10;
-
-  const items: SearchResult[] = (data.results || [])
-    .slice(0, numResults)
-    .map((item) => ({
-      title: item.title || '',
-      link: item.url || '',
-      snippet: item.content || '',
-      engines: item.engines,
-      publishedDate: item.publishedDate,
-      category: item.category,
-    }));
-
-  return {
-    items,
-    totalResults: data.number_of_results || items.length,
-    suggestions: data.suggestions || [],
-  };
-}
-
-/**
- * Cached version of fetchSearXNGResults.
- * Use this when calling from actions to benefit from caching.
- * Results are cached for 30 minutes.
- */
-export async function fetchSearXNGResultsCached(
-  ctx: ActionCtx,
-  options: SearchOptions,
-): Promise<{
-  items: SearchResult[];
-  totalResults: number;
-  suggestions: string[];
-}> {
-  return await searchResultsCache.fetch(ctx, {
-    query: options.query,
-    site: options.site,
-    pageNo: options.pageNo,
-    engines: options.engines,
-    categories: options.categories,
-    timeRange: options.timeRange,
-    safesearch: options.safesearch,
-    language: options.language,
-    numResults: options.numResults,
-  });
-}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts b/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts
deleted file mode 100644
index af7aca76ca..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/get_search_service_url.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * Helper: getSearchServiceUrl
- *
- * Resolve the SearXNG search service base URL from agent variables or environment.
- */
-
-export function getSearchServiceUrl(
-  variables?: Record<string, unknown>,
-): string {
-  const fromVariables = variables?.SEARCH_SERVICE_URL;
-  if (typeof fromVariables === 'string' && fromVariables) {
-    return fromVariables;
-  }
-  return process.env.SEARCH_SERVICE_URL || 'http://localhost:8003';
-}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts b/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts
deleted file mode 100644
index 8197a33c6d..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/search_and_fetch.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Helper: Search and Fetch
- *
- * Combines web search with parallel content fetching for faster results.
- * Searches using SearXNG, then fetches top N results in parallel using batch API.
- */
-
-import { getSearchServiceUrl } from './get_search_service_url';
-import { getCrawlerServiceUrl } from './get_crawler_service_url';
-import { fetchSearXNGResults } from './fetch_searxng_results';
-import {
-  type FetchUrlsApiResponse,
-  type FetchedPageContent,
-  type WebReadSearchAndFetchResult,
-} from './types';
-import type { ToolCtx } from '@convex-dev/agent';
-
-import { createDebugLog } from '../../../lib/debug_log';
-
-const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]');
-
-// Content size limits
-const MAX_CONTENT_CHARS_PER_PAGE = 50_000; // Lower limit per page for batch results
-const DEFAULT_FETCH_COUNT = 5;
-
-export interface SearchAndFetchArgs {
-  query: string;
-  num_results?: number;
-  fetch_count?: number;
-  time_range?: 'day' | 'week' | 'month' | 'year';
-  categories?: string[];
-  safesearch?: '0' | '1' | '2';
-  site?: string;
-  language?: string;
-}
-
-export async function searchAndFetch(
-  ctx: ToolCtx,
-  args: SearchAndFetchArgs,
-): Promise<WebReadSearchAndFetchResult> {
-  const { variables } = ctx;
-  const searchServiceUrl = getSearchServiceUrl(variables);
-  const crawlerServiceUrl = getCrawlerServiceUrl(variables);
-
-  const numResults = args.num_results ?? 10;
-  const fetchCount = Math.min(args.fetch_count ?? DEFAULT_FETCH_COUNT, numResults);
-
-  debugLog('tool:web_read:search_and_fetch start', {
-    query: args.query,
-    num_results: numResults,
-    fetch_count: fetchCount,
-    time_range: args.time_range,
-    site: args.site,
-  });
-
-  const startTime = Date.now();
-
-  // Step 1: Search
-  const searchData = await fetchSearXNGResults(searchServiceUrl, {
-    query: args.query,
-    numResults,
-    pageNo: 1,
-    timeRange: args.time_range,
-    categories: args.categories,
-    safesearch: args.safesearch
-      ? (parseInt(args.safesearch) as 0 | 1 | 2)
-      : undefined,
-    site: args.site,
-    language: args.language,
-  });
-
-  const searchDuration = Date.now() - startTime;
-  debugLog('tool:web_read:search_and_fetch search_complete', {
-    query: args.query,
-    results_count: searchData.items.length,
-    duration_ms: searchDuration,
-  });
-
-  if (searchData.items.length === 0) {
-    return {
-      operation: 'search_and_fetch',
-      success: true,
-      query: args.query,
-      search_results: [],
-      total_search_results: 0,
-      fetched_pages: [],
-      pages_fetched: 0,
-      pages_failed: 0,
-      suggestions: searchData.suggestions,
-    };
-  }
-
-  // Step 2: Fetch top N URLs in parallel using batch API
-  const urlsToFetch = searchData.items.slice(0, fetchCount).map((item) => item.link);
-
-  debugLog('tool:web_read:search_and_fetch fetching_urls', {
-    urls: urlsToFetch,
-  });
-
-  const fetchStartTime = Date.now();
-  const fetchedPages: FetchedPageContent[] = [];
-  let pagesFailed = 0;
-
-  try {
-    const apiUrl = `${crawlerServiceUrl}/api/v1/urls/fetch`;
-    const controller = new AbortController();
-    const timeoutId = setTimeout(() => controller.abort(), 90_000); // 90 second timeout for batch
-
-    const response = await fetch(apiUrl, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        urls: urlsToFetch,
-        word_count_threshold: 0,
-      }),
-      signal: controller.signal,
-    });
-
-    clearTimeout(timeoutId);
-
-    if (!response.ok) {
-      const errorText = await response.text();
-      throw new Error(`Crawler service error: ${response.status} ${errorText}`);
-    }
-
-    const result = (await response.json()) as FetchUrlsApiResponse;
-
-    // Process fetched pages
-    for (const page of result.pages) {
-      const rawContent = page.content ?? '';
-      const wasTruncated = rawContent.length > MAX_CONTENT_CHARS_PER_PAGE;
-      const content = wasTruncated
-        ? rawContent.slice(0, MAX_CONTENT_CHARS_PER_PAGE)
-        : rawContent;
-
-      fetchedPages.push({
-        url: page.url,
-        title: page.title,
-        content,
-        word_count: page.word_count,
-        success: true,
-      });
-    }
-
-    // Track URLs that failed to fetch
-    const fetchedUrls = new Set(result.pages.map((p) => p.url));
-    for (const url of urlsToFetch) {
-      if (!fetchedUrls.has(url)) {
-        fetchedPages.push({
-          url,
-          content: '',
-          word_count: 0,
-          success: false,
-          error: 'Failed to fetch content',
-        });
-        pagesFailed++;
-      }
-    }
-  } catch (error) {
-    // If batch fetch fails completely, mark all as failed
-    console.error('[tool:web_read:search_and_fetch] batch_fetch_error', {
-      error: error instanceof Error ? error.message : String(error),
-    });
-    for (const url of urlsToFetch) {
-      fetchedPages.push({
-        url,
-        content: '',
-        word_count: 0,
-        success: false,
-        error: error instanceof Error ? error.message : 'Unknown error',
-      });
-      pagesFailed++;
-    }
-  }
-
-  const fetchDuration = Date.now() - fetchStartTime;
-  const totalDuration = Date.now() - startTime;
-
-  debugLog('tool:web_read:search_and_fetch complete', {
-    query: args.query,
-    search_results: searchData.items.length,
-    pages_fetched: fetchedPages.filter((p) => p.success).length,
-    pages_failed: pagesFailed,
-    search_duration_ms: searchDuration,
-    fetch_duration_ms: fetchDuration,
-    total_duration_ms: totalDuration,
-  });
-
-  // Note: success=true indicates the operation completed (search worked).
-  // Partial page fetch failures are expected and tracked in pages_failed.
-  // Callers should check pages_failed and fetched_pages[].success for per-page outcomes.
-  return {
-    operation: 'search_and_fetch',
-    success: true,
-    query: args.query,
-    search_results: searchData.items,
-    total_search_results: searchData.items.length,
-    fetched_pages: fetchedPages,
-    pages_fetched: fetchedPages.filter((p) => p.success).length,
-    pages_failed: pagesFailed,
-    suggestions: searchData.suggestions,
-  };
-}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/search_web.ts b/services/platform/convex/agent_tools/crawler/helpers/search_web.ts
deleted file mode 100644
index 4ac49686cf..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/search_web.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-/**
- * Helper: Search Web
- *
- * Searches the web using SearXNG meta search engine.
- * Returns links with titles and snippets for further processing.
- */
-
-import { getSearchServiceUrl } from './get_search_service_url';
-import { fetchSearXNGResults } from './fetch_searxng_results';
-import { type WebReadSearchResult } from './types';
-import type { ToolCtx } from '@convex-dev/agent';
-
-import { createDebugLog } from '../../../lib/debug_log';
-
-const debugLog = createDebugLog('DEBUG_CRAWLER', '[Crawler]');
-
-// =============================================================================
-// SEARCH WEB OPERATION
-// =============================================================================
-
-export interface SearchWebArgs {
-  query: string;
-  num_results?: number;
-  page_number?: number;
-  time_range?: 'day' | 'week' | 'month' | 'year';
-  categories?: string[];
-  safesearch?: '0' | '1' | '2';
-  site?: string;
-  language?: string;
-}
-
-export async function searchWeb(
-  ctx: ToolCtx,
-  args: SearchWebArgs,
-): Promise<WebReadSearchResult> {
-  const { variables } = ctx;
-  const searchServiceUrl = getSearchServiceUrl(variables);
-  const numResults = args.num_results ?? 10;
-  const pageNumber = args.page_number ?? 1;
-
-  // Standard search mode
-  debugLog('tool:web_read:search start', {
-    query: args.query,
-    num_results: numResults,
-    page_number: pageNumber,
-    time_range: args.time_range,
-    site: args.site,
-  });
-
-  try {
-    const pageData = await fetchSearXNGResults(searchServiceUrl, {
-      query: args.query,
-      numResults,
-      pageNo: pageNumber,
-      timeRange: args.time_range,
-      categories: args.categories,
-      safesearch: args.safesearch
-        ? (parseInt(args.safesearch) as 0 | 1 | 2)
-        : undefined,
-      site: args.site,
-      language: args.language,
-    });
-
-    const hasMore = pageData.items.length >= numResults;
-
-    debugLog('tool:web_read:search success', {
-      query: args.query,
-      results_count: pageData.items.length,
-      has_more: hasMore,
-    });
-
-    // Build the next action instruction based on results
-    const hasResults = pageData.items.length > 0;
-    const firstUrl = hasResults ? pageData.items[0].link : null;
-    const nextActionRequired = hasResults
-      ? `IMPORTANT: These are only search result snippets, NOT the actual page content. To get real data (weather, prices, news, etc.), you MUST now call web_read with { operation: "fetch_url", url: "${firstUrl}" } or another relevant URL from the results above.`
-      : 'No results found. Try a different search query.';
-
-    return {
-      operation: 'search',
-      success: true,
-      query: args.query,
-      results: pageData.items,
-      total_results: pageData.items.length,
-      estimated_total: pageData.totalResults,
-      has_more: hasMore,
-      next_start_index: hasMore ? pageNumber + 1 : null,
-      suggestions: pageData.suggestions,
-      next_action_required: nextActionRequired,
-    };
-  } catch (error) {
-    console.error('[tool:web_read:search] error', {
-      query: args.query,
-      error: error instanceof Error ? error.message : String(error),
-    });
-    throw error;
-  }
-}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/types.ts b/services/platform/convex/agent_tools/crawler/helpers/types.ts
deleted file mode 100644
index 1349800a14..0000000000
--- a/services/platform/convex/agent_tools/crawler/helpers/types.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * Shared types and interfaces for web_read tool and its helpers
- */
-
-// =============================================================================
-// FETCH URL TYPES
-// =============================================================================
-
-export interface PageContent {
-  url: string;
-  title?: string;
-  content: string;
-  word_count: number;
-  metadata?: Record<string, unknown>;
-  structured_data?: Record<string, unknown>;
-}
-
-export interface FetchUrlsApiResponse {
-  success: boolean;
-  urls_requested: number;
-  urls_fetched: number;
-  pages: PageContent[];
-}
-
-export type WebReadFetchUrlResult = {
-  operation: 'fetch_url';
-  success: boolean;
-  url: string;
-  title?: string;
-  /** Page content (markdown text extracted from page). */
-  content: string;
-  word_count: number;
-  metadata?: Record<string, unknown>;
-  /** OpenGraph and JSON-LD structured data. Use this for product pricing/variants when available. */
-  structured_data?: Record<string, unknown>;
-};
-
-// =============================================================================
-// WEB SEARCH TYPES
-// =============================================================================
-
-export interface SearchResult {
-  title: string;
-  link: string;
-  snippet: string;
-  engines?: string[];
-  publishedDate?: string;
-  category?: string;
-}
-
-export type WebReadSearchResult = {
-  operation: 'search';
-  success: boolean;
-  query: string;
-  results: SearchResult[];
-  total_results: number;
-  estimated_total: number;
-  has_more: boolean;
-  next_start_index: number | null;
-  suggestions?: string[];
-  /**
-   * Explicit instruction for the AI on what to do next.
-   * This helps ensure the AI calls fetch_url after search.
-   */
-  next_action_required: string;
-};
-
-// =============================================================================
-// SEARCH AND FETCH TYPES (Combined operation)
-// =============================================================================
-
-export interface FetchedPageContent {
-  url: string;
-  title?: string;
-  content: string;
-  word_count: number;
-  success: boolean;
-  error?: string;
-}
-
-export type WebReadSearchAndFetchResult = {
-  operation: 'search_and_fetch';
-  success: boolean;
-  query: string;
-  /** Search results metadata (all results from search) */
-  search_results: SearchResult[];
-  total_search_results: number;
-  /** Fetched page contents (top N results, fetched in parallel) */
-  fetched_pages: FetchedPageContent[];
-  pages_fetched: number;
-  pages_failed: number;
-  suggestions?: string[];
-};
-
-// =============================================================================
-// SEARXNG API TYPES
-// =============================================================================
-
-export interface SearXNGResult {
-  url: string;
-  title: string;
-  content?: string;
-  engine?: string;
-  engines?: string[];
-  publishedDate?: string;
-  category?: string;
-}
-
-export interface SearXNGResponse {
-  query: string;
-  results: SearXNGResult[];
-  number_of_results?: number;
-  suggestions?: string[];
-}
-
-export interface SearchOptions {
-  query: string;
-  numResults?: number;
-  pageNo?: number;
-  timeRange?: 'day' | 'week' | 'month' | 'year';
-  categories?: string[];
-  engines?: string[];
-  safesearch?: 0 | 1 | 2;
-  language?: string;
-  site?: string;
-}
-
-// (URL helper functions moved to dedicated one-function files
-//  get_crawler_service_url.ts and get_search_service_url.ts.)
diff --git a/services/platform/convex/agent_tools/crawler/internal_actions.ts b/services/platform/convex/agent_tools/crawler/internal_actions.ts
deleted file mode 100644
index 8a792bde40..0000000000
--- a/services/platform/convex/agent_tools/crawler/internal_actions.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-'use node';
-
-/**
- * Internal actions for crawler operations.
- * These wrap helper functions so they can be cached by ActionCache.
- */
-
-import { internalAction } from '../../_generated/server';
-import { v } from 'convex/values';
-import {
-  fetchSearXNGResults,
-  type SearchOptions,
-} from './helpers/fetch_searxng_results';
-import { getCrawlerServiceUrl } from './helpers/get_crawler_service_url';
-
-/**
- * Internal action for fetching SearXNG search results.
- * Wrapped for caching - same query should return cached results.
- */
-export const fetchSearXNGResultsUncached = internalAction({
-  args: {
-    query: v.string(),
-    site: v.optional(v.string()),
-    pageNo: v.optional(v.number()),
-    engines: v.optional(v.array(v.string())),
-    categories: v.optional(v.array(v.string())),
-    timeRange: v.optional(
-      v.union(
-        v.literal('day'),
-        v.literal('week'),
-        v.literal('month'),
-        v.literal('year'),
-      ),
-    ),
-    safesearch: v.optional(v.union(v.literal(0), v.literal(1), v.literal(2))),
-    language: v.optional(v.string()),
-    numResults: v.optional(v.number()),
-  },
-  returns: v.object({
-    items: v.array(
-      v.object({
-        title: v.string(),
-        link: v.string(),
-        snippet: v.string(),
-        engines: v.optional(v.array(v.string())),
-        publishedDate: v.optional(v.string()),
-        category: v.optional(v.string()),
-      }),
-    ),
-    totalResults: v.number(),
-    suggestions: v.array(v.string()),
-  }),
-  handler: async (_ctx, args) => {
-    const serviceUrl = getCrawlerServiceUrl();
-    const options: SearchOptions = {
-      query: args.query,
-      site: args.site,
-      pageNo: args.pageNo,
-      engines: args.engines,
-      categories: args.categories,
-      timeRange: args.timeRange,
-      safesearch: args.safesearch,
-      language: args.language,
-      numResults: args.numResults,
-    };
-    return await fetchSearXNGResults(serviceUrl, options);
-  },
-});
diff --git a/services/platform/convex/agent_tools/crawler/web_read_tool.ts b/services/platform/convex/agent_tools/crawler/web_read_tool.ts
deleted file mode 100644
index 69f000be05..0000000000
--- a/services/platform/convex/agent_tools/crawler/web_read_tool.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * Convex Tool: Web Read
- *
- * Unified web operations for agents.
- * Supports:
- * - operation = 'fetch_url': fetch and extract content from a web URL
- * - operation = 'search': search the web using SearXNG meta search engine
- */
-
-import { z } from 'zod/v4';
-import { createTool, type ToolCtx } from '@convex-dev/agent';
-import type { ToolDefinition } from '../types';
-
-import type {
-  WebReadFetchUrlResult,
-  WebReadSearchResult,
-  WebReadSearchAndFetchResult,
-} from './helpers/types';
-import { fetchPageContent } from './helpers/fetch_page_content';
-import { searchWeb } from './helpers/search_web';
-import { searchAndFetch } from './helpers/search_and_fetch';
-
-// Use a flat object schema instead of discriminatedUnion to ensure OpenAI-compatible JSON Schema
-// (discriminatedUnion produces anyOf/oneOf which some providers reject as "type: None")
-const webReadArgs = z.object({
-  operation: z
-    .enum(['fetch_url', 'search', 'search_and_fetch'])
-    .describe(
-      "Operation to perform: 'fetch_url' (fetch content from URL), 'search' (web search only), or 'search_and_fetch' (search + auto-fetch top results, RECOMMENDED)",
-    ),
-  // For fetch_url operation
-  url: z
-    .string()
-    .optional()
-    .describe(
-      "Required for 'fetch_url': The URL to fetch content from (must be a valid http/https URL)",
-    ),
-  word_count_threshold: z
-    .number()
-    .optional()
-    .describe(
-      "For 'fetch_url': Minimum word count for content extraction (default: 50). Lower values capture more content.",
-    ),
-  // For search operation
-  query: z
-    .string()
-    .optional()
-    .describe("Required for 'search': The search query text"),
-  num_results: z
-    .number()
-    .optional()
-    .describe(
-      "For 'search'/'search_and_fetch': Number of search results to return (default: 10, max: 50).",
-    ),
-  fetch_count: z
-    .number()
-    .optional()
-    .describe(
-      "For 'search_and_fetch': Number of top results to fetch content from (default: 5, max: num_results).",
-    ),
-  page_number: z
-    .number()
-    .optional()
-    .describe("For 'search': Page number for pagination (default: 1)."),
-  time_range: z
-    .enum(['day', 'week', 'month', 'year'])
-    .optional()
-    .describe("For 'search': Filter by time: day, week, month, or year."),
-  categories: z
-    .array(z.string())
-    .optional()
-    .describe(
-      "For 'search': Categories: general, news, science, it, images, videos.",
-    ),
-  safesearch: z
-    .enum(['0', '1', '2'])
-    .optional()
-    .describe("For 'search': Safe search: 0=off, 1=moderate, 2=strict."),
-  site: z
-    .string()
-    .optional()
-    .describe(
-      'For \'search\': Limit to domain (e.g., "github.com", "arxiv.org").',
-    ),
-  language: z
-    .string()
-    .optional()
-    .describe(
-      'For \'search\': Language code (default: "all"). Examples: "en", "de", "fr", "zh".',
-    ),
-});
-
-export const webReadTool: ToolDefinition = {
-  name: 'web_read',
-  tool: createTool({
-    description: `Web content tool for fetching URLs and searching the web.
-
-OPERATIONS:
-
-1. search_and_fetch (RECOMMENDED) - Search + auto-fetch top results in ONE call
-   Searches the web AND fetches content from top results in parallel.
-   Returns: search results metadata + actual page content from top 5 URLs.
-   USE THIS for any research task - it's faster than search + manual fetch!
-   Example: { operation: "search_and_fetch", query: "weather in Zurich today" }
-
-2. fetch_url - Fetch and extract content from a specific URL
-   Use when a user provides a URL and wants to know what's on the page.
-   Returns: page title, main content text, word count, structured_data.
-   LIMITATIONS:
-   - CANNOT read PDF, Excel, Word, or other binary files
-   - For documents in conversation thread, use "context_search" tool
-   - For knowledge base documents, use "rag_search" tool
-
-3. search - Search the web (returns snippets only, no content)
-   Use ONLY when you need many results without fetching content.
-   Returns URLs with titles and SHORT SNIPPETS ONLY - NOT the actual page content!
-
-EXAMPLES:
-• Research query: { operation: "search_and_fetch", query: "React 19 new features" }
-• Fetch specific URL: { operation: "fetch_url", url: "https://example.com/article" }
-• Site-specific research: { operation: "search_and_fetch", query: "hooks", site: "react.dev" }
-• Browse many results: { operation: "search", query: "React tutorials", num_results: 20 }`,
-    args: webReadArgs,
-    handler: async (
-      ctx: ToolCtx,
-      args,
-    ): Promise<WebReadFetchUrlResult | WebReadSearchResult | WebReadSearchAndFetchResult> => {
-      if (args.operation === 'fetch_url') {
-        if (!args.url) {
-          throw new Error("Missing required 'url' for fetch_url operation");
-        }
-        return fetchPageContent(ctx, {
-          url: args.url,
-          word_count_threshold: args.word_count_threshold,
-        });
-      }
-
-      if (args.operation === 'search_and_fetch') {
-        if (!args.query) {
-          throw new Error("Missing required 'query' for search_and_fetch operation");
-        }
-        return searchAndFetch(ctx, {
-          query: args.query,
-          num_results: args.num_results,
-          fetch_count: args.fetch_count,
-          time_range: args.time_range,
-          categories: args.categories,
-          safesearch: args.safesearch,
-          site: args.site,
-          language: args.language,
-        });
-      }
-
-      // operation === 'search'
-      if (!args.query) {
-        throw new Error("Missing required 'query' for search operation");
-      }
-      return searchWeb(ctx, {
-        query: args.query,
-        num_results: args.num_results,
-        page_number: args.page_number,
-        time_range: args.time_range,
-        categories: args.categories,
-        safesearch: args.safesearch,
-        site: args.site,
-        language: args.language,
-      });
-    },
-  }),
-};
diff --git a/services/platform/convex/agent_tools/files/helpers/parse_file.ts b/services/platform/convex/agent_tools/files/helpers/parse_file.ts
index 41628e8e7b..2199f2cf52 100644
--- a/services/platform/convex/agent_tools/files/helpers/parse_file.ts
+++ b/services/platform/convex/agent_tools/files/helpers/parse_file.ts
@@ -5,7 +5,7 @@
  */
 
 import { createDebugLog } from '../../../lib/debug_log';
-import { getCrawlerServiceUrl } from '../../crawler/helpers/get_crawler_service_url';
+import { getCrawlerServiceUrl } from '../../web/helpers/get_crawler_service_url';
 import type { ActionCtx } from '../../../_generated/server';
 import type { Id } from '../../../_generated/dataModel';
 
diff --git a/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts b/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts
deleted file mode 100644
index fdd4252181..0000000000
--- a/services/platform/convex/agent_tools/sub_agents/helpers/operator_types.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Type definitions for Operator service API responses.
- */
-
-export interface OperatorTokenUsage {
-  input_tokens: number;
-  output_tokens: number;
-  total_tokens: number;
-  cache_read_tokens?: number;
-}
-
-export interface OperatorChatResponse {
-  success: boolean;
-  message: string;
-  response: string | null;
-  error: string | null;
-  duration_seconds: number | null;
-  token_usage: OperatorTokenUsage | null;
-  cost_usd: number | null;
-  turns: number | null;
-  sources: string[] | null;
-}
diff --git a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts
index 25ab43b384..04ff23968b 100644
--- a/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts
+++ b/services/platform/convex/agent_tools/sub_agents/web_assistant_tool.ts
@@ -1,98 +1,112 @@
 /**
  * Web Assistant Tool
  *
- * Delegates web tasks to the Operator browser automation service.
- * Uses Playwright for browser control with AI-driven navigation.
+ * Delegates web tasks to the specialized Web Agent.
+ * This tool is a thin wrapper that creates sub-threads and calls the agent.
+ * All context management is handled by the agent itself.
  */
 
 import { z } from 'zod/v4';
 import { createTool } from '@convex-dev/agent';
 import type { ToolCtx } from '@convex-dev/agent';
 import type { ToolDefinition } from '../types';
+import { getOrCreateSubThread } from './helpers/get_or_create_sub_thread';
 import { validateToolContext } from './helpers/validate_context';
+import { buildAdditionalContext } from './helpers/build_additional_context';
 import {
   successResponse,
-  errorResponse,
   handleToolError,
   type ToolResponse,
 } from './helpers/tool_response';
-import { getOperatorServiceUrl } from './helpers/get_operator_service_url';
-import type { OperatorChatResponse } from './helpers/operator_types';
+import { getWebAgentGenerateResponseRef } from '../../lib/function_refs';
+
+const WEB_CONTEXT_MAPPING = {
+  url: 'url',
+} as const;
 
 export const webAssistantTool = {
   name: 'web_assistant' as const,
   tool: createTool({
-    description: `Delegate web tasks to the Operator browser automation service.
+    description: `Delegate web tasks to the specialized Web Agent.
 
 Use this tool for:
-- Browsing websites and extracting content
-- Interacting with web pages (clicking, filling forms)
-- Taking screenshots and visual analysis
-- Multi-step web automation workflows
+- Fetching and extracting content from URLs
+- Browsing websites and web pages
+- Searching the web for information
+- Multi-step web interactions and automation
+
+The Web Agent has access to:
+- fetch_url: Extract content from URLs (URL → PDF → Vision API extraction)
+- browser_operate: AI-driven browser automation for searching and interactions
 
-The Operator uses Playwright for browser control with AI-driven navigation.
+IMPORTANT: Preserve the user's INTENT in userRequest - include what they actually want to know.
+Do NOT reduce specific questions to generic "Get the content from URL" requests.
 
 EXAMPLES:
-- Browse: { userRequest: "Go to example.com and summarize the main content" }
-- Interact: { userRequest: "Search for 'AI news' on Google and list top 5 results" }
-- Extract: { userRequest: "Find the pricing information on this product page" }`,
+- Price query: { userRequest: "What is the price of the product at https://example.com/product", url: "https://example.com/product" }
+- Search: { userRequest: "Search for the latest news about AI" }
+- Specific extraction: { userRequest: "Find the opening hours from this page", url: "https://example.com/contact" }
+- Browse: { userRequest: "Go to GitHub and find trending repositories" }
+
+WRONG: { userRequest: "Get the content from https://example.com" } ← Loses the user's specific intent
+RIGHT: { userRequest: "What is the shipping policy on https://example.com" } ← Preserves full question`,
 
     args: z.object({
       userRequest: z
         .string()
         .describe("The user's web-related request in natural language"),
+      url: z
+        .string()
+        .optional()
+        .describe('Target URL if fetching a specific page'),
     }),
 
     handler: async (ctx: ToolCtx, args): Promise<ToolResponse> => {
       const validation = validateToolContext(ctx, 'web_assistant');
       if (!validation.valid) return validation.error;
 
-      const operatorUrl = getOperatorServiceUrl(ctx.variables);
-
-      console.log('[web_assistant_tool] Calling operator:', {
-        url: `${operatorUrl}/api/v1/chat`,
-        message: args.userRequest.slice(0, 100),
-      });
+      const { organizationId, threadId, userId } = validation.context;
 
       try {
-        const controller = new AbortController();
-        const timeoutId = setTimeout(() => controller.abort(), 300_000);
-
-        const response = await fetch(`${operatorUrl}/api/v1/chat`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ message: args.userRequest }),
-          signal: controller.signal,
-        });
-
-        clearTimeout(timeoutId);
-
-        if (!response.ok) {
-          const errorText = await response.text();
-          throw new Error(
-            `Operator service error: ${response.status} ${errorText}`,
-          );
-        }
+        const { threadId: subThreadId, isNew } = await getOrCreateSubThread(
+          ctx,
+          {
+            parentThreadId: threadId,
+            subAgentType: 'web_assistant',
+            userId,
+          },
+        );
 
-        const result = (await response.json()) as OperatorChatResponse;
+        console.log(
+          '[web_assistant_tool] Sub-thread:',
+          subThreadId,
+          isNew ? '(new)' : '(reused)',
+        );
 
-        if (!result.success) {
-          return errorResponse(result.error || 'Operator request failed');
-        }
+        const result = await ctx.runAction(
+          getWebAgentGenerateResponseRef(),
+          {
+            threadId: subThreadId,
+            userId,
+            organizationId,
+            promptMessage: args.userRequest,
+            additionalContext: buildAdditionalContext(args, WEB_CONTEXT_MAPPING),
+            parentThreadId: threadId,
+          },
+        );
 
         return successResponse(
-          result.response || '',
-          result.token_usage
-            ? {
-                inputTokens: result.token_usage.input_tokens,
-                outputTokens: result.token_usage.output_tokens,
-                totalTokens: result.token_usage.total_tokens,
-                durationSeconds: result.duration_seconds ?? undefined,
-              }
-            : undefined,
-          'opencode',
-          'operator',
-          result.sources ?? undefined,
+          result.text,
+          {
+            ...result.usage,
+            durationSeconds:
+              result.durationMs !== undefined
+                ? result.durationMs / 1000
+                : undefined,
+          },
+          result.model,
+          result.provider,
+          undefined,
           args.userRequest,
         );
       } catch (error) {
diff --git a/services/platform/convex/agent_tools/tool_registry.ts b/services/platform/convex/agent_tools/tool_registry.ts
index 75920516fe..ea727fd845 100644
--- a/services/platform/convex/agent_tools/tool_registry.ts
+++ b/services/platform/convex/agent_tools/tool_registry.ts
@@ -9,7 +9,7 @@ import type { ToolDefinition } from './types';
 import { customerReadTool } from './customers/customer_read_tool';
 import { productReadTool } from './products/product_read_tool';
 import { ragSearchTool } from './rag/rag_search_tool';
-import { webReadTool } from './crawler/web_read_tool';
+import { webTool } from './web/web_tool';
 import { workflowReadTool } from './workflows/workflow_read_tool';
 import { workflowExamplesTool } from './workflows/workflow_examples_tool';
 import { updateWorkflowStepTool } from './workflows/update_workflow_step_tool';
@@ -45,7 +45,7 @@ export const TOOL_NAMES = [
   'customer_read',
   'product_read',
   'rag_search',
-  'web_read',
+  'web',
   'pdf',
   'image',
   'pptx',
@@ -79,7 +79,7 @@ export const TOOL_REGISTRY = [
   customerReadTool,
   productReadTool,
   ragSearchTool,
-  webReadTool,
+  webTool,
   workflowReadTool,
   workflowExamplesTool,
   updateWorkflowStepTool,
diff --git a/services/platform/convex/agent_tools/web/helpers/browser_operate.ts b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts
new file mode 100644
index 0000000000..160588d57a
--- /dev/null
+++ b/services/platform/convex/agent_tools/web/helpers/browser_operate.ts
@@ -0,0 +1,96 @@
+/**
+ * Helper: browserOperate
+ *
+ * Delegates browser automation to the Operator service.
+ * Uses Playwright for browser control with AI-driven navigation.
+ */
+
+import type { ToolCtx } from '@convex-dev/agent';
+import { createDebugLog } from '../../../lib/debug_log';
+import { getOperatorServiceUrl } from './get_operator_service_url';
+import type { WebBrowserOperateResult, OperatorChatResponse } from './types';
+
+const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]');
+
+export async function browserOperate(
+  ctx: ToolCtx,
+  args: {
+    instruction: string;
+  },
+): Promise<WebBrowserOperateResult> {
+  const operatorUrl = getOperatorServiceUrl(ctx.variables);
+  const apiUrl = `${operatorUrl}/api/v1/chat`;
+
+  debugLog('tool:web:browser_operate start', {
+    instruction: args.instruction.slice(0, 100),
+  });
+
+  try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 300_000);
+
+    const response = await fetch(apiUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ message: args.instruction }),
+      signal: controller.signal,
+    });
+
+    clearTimeout(timeoutId);
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(`Operator service error: ${response.status} ${errorText}`);
+    }
+
+    const result = (await response.json()) as OperatorChatResponse;
+
+    if (!result.success) {
+      debugLog('tool:web:browser_operate failed', {
+        error: result.error,
+      });
+      return {
+        operation: 'browser_operate',
+        success: false,
+        response: '',
+        error: result.error || 'Operator request failed',
+      };
+    }
+
+    debugLog('tool:web:browser_operate success', {
+      hasResponse: !!result.response,
+      sourcesCount: result.sources?.length ?? 0,
+    });
+
+    return {
+      operation: 'browser_operate',
+      success: true,
+      response: result.response || '',
+      sources: result.sources,
+      usage: result.token_usage
+        ? {
+            inputTokens: result.token_usage.input_tokens,
+            outputTokens: result.token_usage.output_tokens,
+            totalTokens: result.token_usage.total_tokens,
+            durationSeconds: result.duration_seconds,
+          }
+        : undefined,
+    };
+  } catch (error) {
+    const isAborted = error instanceof Error && error.name === 'AbortError';
+    const errorMessage = isAborted
+      ? 'Request timed out after 5 minutes'
+      : error instanceof Error
+        ? error.message
+        : 'Unknown error';
+    console.error('[tool:web:browser_operate] error', {
+      error: errorMessage,
+    });
+    return {
+      operation: 'browser_operate',
+      success: false,
+      response: '',
+      error: errorMessage,
+    };
+  }
+}
diff --git a/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts
new file mode 100644
index 0000000000..f7f3527489
--- /dev/null
+++ b/services/platform/convex/agent_tools/web/helpers/fetch_url_via_pdf.ts
@@ -0,0 +1,116 @@
+/**
+ * Helper: fetchUrlViaPdf
+ *
+ * Fetches a URL's content using the PDF extraction pipeline:
+ * URL -> PDF (Playwright) -> Extract (Vision API)
+ */
+
+import type { ToolCtx } from '@convex-dev/agent';
+import { createDebugLog } from '../../../lib/debug_log';
+import { getCrawlerServiceUrl } from './get_crawler_service_url';
+import type { WebFetchUrlResult, WebFetchExtractApiResponse } from './types';
+
+const debugLog = createDebugLog('DEBUG_AGENT_TOOLS', '[AgentTools]');
+
+const MAX_CONTENT_LENGTH = 100_000;
+
+export async function fetchUrlViaPdf(
+  ctx: ToolCtx,
+  args: {
+    url: string;
+    instruction?: string;
+  },
+): Promise<WebFetchUrlResult> {
+  const crawlerServiceUrl = getCrawlerServiceUrl(ctx.variables);
+  const apiUrl = `${crawlerServiceUrl}/api/v1/web/fetch-and-extract`;
+
+  debugLog('tool:web:fetch_url start', {
+    url: args.url,
+    hasInstruction: !!args.instruction,
+  });
+
+  try {
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 120_000);
+
+    const response = await fetch(apiUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        url: args.url,
+        instruction: args.instruction,
+        timeout: 60000,
+      }),
+      signal: controller.signal,
+    });
+
+    clearTimeout(timeoutId);
+
+    if (!response.ok) {
+      const errorText = await response.text();
+      throw new Error(`Crawler service error: ${response.status} ${errorText}`);
+    }
+
+    const result = (await response.json()) as WebFetchExtractApiResponse;
+
+    if (!result.success) {
+      debugLog('tool:web:fetch_url failed', {
+        url: args.url,
+        error: result.error,
+      });
+      return {
+        operation: 'fetch_url',
+        success: false,
+        url: args.url,
+        content: '',
+        word_count: 0,
+        page_count: 0,
+        vision_used: false,
+        error: result.error || 'Failed to fetch and extract content',
+      };
+    }
+
+    let content = result.content || '';
+    const truncated = content.length > MAX_CONTENT_LENGTH;
+    if (truncated) {
+      content = content.slice(0, MAX_CONTENT_LENGTH);
+    }
+
+    debugLog('tool:web:fetch_url success', {
+      url: args.url,
+      wordCount: result.word_count,
+      pageCount: result.page_count,
+      visionUsed: result.vision_used,
+      truncated,
+    });
+
+    return {
+      operation: 'fetch_url',
+      success: true,
+      url: args.url,
+      title: result.title,
+      content,
+      word_count: result.word_count,
+      page_count: result.page_count,
+      vision_used: result.vision_used,
+      truncated,
+    };
+  } catch (error) {
+    const errorMessage =
+      error instanceof Error ? error.message : 'Unknown error';
+    console.error('[tool:web:fetch_url] error', {
+      url: args.url,
+      error: errorMessage,
+    });
+    return {
+      operation: 'fetch_url',
+      success: false,
+      url: args.url,
+      content: '',
+      word_count: 0,
+      page_count: 0,
+      vision_used: false,
+      error: errorMessage,
+    };
+  }
+}
diff --git a/services/platform/convex/agent_tools/crawler/helpers/get_crawler_service_url.ts b/services/platform/convex/agent_tools/web/helpers/get_crawler_service_url.ts
similarity index 100%
rename from services/platform/convex/agent_tools/crawler/helpers/get_crawler_service_url.ts
rename to services/platform/convex/agent_tools/web/helpers/get_crawler_service_url.ts
diff --git a/services/platform/convex/agent_tools/sub_agents/helpers/get_operator_service_url.ts b/services/platform/convex/agent_tools/web/helpers/get_operator_service_url.ts
similarity index 100%
rename from services/platform/convex/agent_tools/sub_agents/helpers/get_operator_service_url.ts
rename to services/platform/convex/agent_tools/web/helpers/get_operator_service_url.ts
diff --git a/services/platform/convex/agent_tools/web/helpers/types.ts b/services/platform/convex/agent_tools/web/helpers/types.ts
new file mode 100644
index 0000000000..6968d483e3
--- /dev/null
+++ b/services/platform/convex/agent_tools/web/helpers/types.ts
@@ -0,0 +1,66 @@
+/**
+ * Shared types and interfaces for web tool and its helpers
+ */
+
+// =============================================================================
+// FETCH URL RESULT (via PDF pipeline)
+// =============================================================================
+
+export type WebFetchUrlResult = {
+  operation: 'fetch_url';
+  success: boolean;
+  url: string;
+  title?: string;
+  content: string;
+  word_count: number;
+  page_count: number;
+  vision_used: boolean;
+  truncated?: boolean;
+  error?: string;
+};
+
+// =============================================================================
+// BROWSER OPERATE RESULT (via Operator service)
+// =============================================================================
+
+export type WebBrowserOperateResult = {
+  operation: 'browser_operate';
+  success: boolean;
+  response: string;
+  error?: string;
+  sources?: string[];
+  usage?: {
+    inputTokens?: number;
+    outputTokens?: number;
+    totalTokens?: number;
+    durationSeconds?: number;
+  };
+};
+
+// =============================================================================
+// API RESPONSE TYPES
+// =============================================================================
+
+export interface WebFetchExtractApiResponse {
+  success: boolean;
+  url: string;
+  title?: string;
+  content: string;
+  word_count: number;
+  page_count: number;
+  vision_used: boolean;
+  error?: string;
+}
+
+export interface OperatorChatResponse {
+  success: boolean;
+  response?: string;
+  error?: string;
+  sources?: string[];
+  duration_seconds?: number;
+  token_usage?: {
+    input_tokens?: number;
+    output_tokens?: number;
+    total_tokens?: number;
+  };
+}
diff --git a/services/platform/convex/agent_tools/web/web_tool.ts b/services/platform/convex/agent_tools/web/web_tool.ts
new file mode 100644
index 0000000000..25c0110ac4
--- /dev/null
+++ b/services/platform/convex/agent_tools/web/web_tool.ts
@@ -0,0 +1,81 @@
+/**
+ * Convex Tool: Web
+ *
+ * Unified web operations for agents.
+ * Supports:
+ * - operation = 'fetch_url': fetch content from URL via PDF extraction pipeline
+ * - operation = 'browser_operate': browser automation via operator service
+ */
+
+import { z } from 'zod/v4';
+import { createTool, type ToolCtx } from '@convex-dev/agent';
+import type { ToolDefinition } from '../types';
+
+import type { WebFetchUrlResult, WebBrowserOperateResult } from './helpers/types';
+import { fetchUrlViaPdf } from './helpers/fetch_url_via_pdf';
+import { browserOperate } from './helpers/browser_operate';
+
+const webToolArgs = z.object({
+  operation: z
+    .enum(['fetch_url', 'browser_operate'])
+    .describe("Operation: 'fetch_url' or 'browser_operate'"),
+  url: z
+    .string()
+    .optional()
+    .describe("Required for 'fetch_url': The URL to fetch content from"),
+  instruction: z
+    .string()
+    .optional()
+    .describe(
+      "For 'fetch_url': Optional AI instruction for extraction. For 'browser_operate': Required browser automation instruction",
+    ),
+});
+
+export const webTool: ToolDefinition = {
+  name: 'web',
+  tool: createTool({
+    description: `Web content tool with two operations:
+
+1. fetch_url - Fetch and extract content from a URL
+   Pipeline: URL -> PDF (Playwright) -> Extract (Vision API)
+   - url: Required, the URL to fetch
+   - instruction: Optional AI instruction (e.g., "extract pricing info")
+   Returns: page content, word count, page count, vision_used flag
+
+2. browser_operate - AI-driven browser automation via Operator service
+   - instruction: Required, natural language instruction for browser automation
+   Use for: searching the web, filling forms, multi-step interactions
+   Example: { operation: "browser_operate", instruction: "Search Google for React 19 features" }
+
+EXAMPLES:
+- Fetch URL: { operation: "fetch_url", url: "https://example.com/article" }
+- With instruction: { operation: "fetch_url", url: "https://example.com/pricing", instruction: "extract pricing tiers" }
+- Browser search: { operation: "browser_operate", instruction: "Search for latest AI news on Google" }
+- Browser interact: { operation: "browser_operate", instruction: "Go to github.com and find the React repository" }`,
+    args: webToolArgs,
+    handler: async (
+      ctx: ToolCtx,
+      args,
+    ): Promise<WebFetchUrlResult | WebBrowserOperateResult> => {
+      if (args.operation === 'fetch_url') {
+        if (!args.url) {
+          throw new Error("Missing required 'url' for fetch_url operation");
+        }
+        return fetchUrlViaPdf(ctx, {
+          url: args.url,
+          instruction: args.instruction,
+        });
+      }
+
+      // operation === 'browser_operate'
+      if (!args.instruction) {
+        throw new Error(
+          "Missing required 'instruction' for browser_operate operation",
+        );
+      }
+      return browserOperate(ctx, {
+        instruction: args.instruction,
+      });
+    },
+  }),
+};
diff --git a/services/platform/convex/agents/chat/agent.ts b/services/platform/convex/agents/chat/agent.ts
index d4ef30cfb5..1f83757f9f 100644
--- a/services/platform/convex/agents/chat/agent.ts
+++ b/services/platform/convex/agents/chat/agent.ts
@@ -103,7 +103,19 @@ CRITICAL RULES
 6) **ACT FIRST**
    Route to sub-agents immediately. Don't ask users for details that sub-agents can discover.
 
-7) **NO RAW CONTEXT OUTPUT**
+7) **PRESERVE USER'S INTENT**
+   When calling sub-agents, preserve the user's specific question or intent.
+   Do NOT reduce questions to generic requests like "Get the content from URL".
+
+   Example - WRONG:
+   User: "https://example.com/product 产品的价格是多少"
+   → web_assistant({ userRequest: "Get the content from https://example.com/product" }) ← Loses intent!
+
+   Example - CORRECT:
+   User: "https://example.com/product 产品的价格是多少"
+   → web_assistant({ userRequest: "这个产品的价格是多少", url: "https://example.com/product" })
+
+8) **NO RAW CONTEXT OUTPUT**
    The system context contains internal formats that are NOT for your output:
    • NEVER output lines starting with "Tool[" - these are internal tool result logs
    • NEVER output lines starting with "[Tool Result]" - these are internal records
@@ -114,12 +126,13 @@ CRITICAL RULES
    To use a tool, call it through the function calling mechanism.
    To report tool results, summarize them in natural language.
 
-8) **PRE-ANALYZED ATTACHMENTS**
+9) **PRE-ANALYZED ATTACHMENTS**
    If the user's CURRENT message contains "[PRE-ANALYZED CONTENT" or sections like:
    • "**Document: filename.pdf**" followed by content
    • "**Image: filename.jpg**" followed by description
    • "**Text File: filename.txt**" followed by analysis
-   These files have ALREADY been analyzed. Answer the user's question directly from this content.
+   These are attachments from the CURRENT message. They take PRIORITY over any previous context.
+   Answer the user's question directly from this content.
    ⚠️ Do NOT call document_assistant for content that is already in the CURRENT message.
    Note: For follow-up questions about files from PREVIOUS messages, you MAY call document_assistant.
 
@@ -150,6 +163,7 @@ export function createChatAgent(options?: {
       'integration_assistant',
       'workflow_assistant',
       'crm_assistant',
+      'request_human_input',
     ];
     convexToolNames = options?.convexToolNames ?? defaultToolNames;
 
diff --git a/services/platform/convex/agents/crm/agent.ts b/services/platform/convex/agents/crm/agent.ts
index ef182974e6..bf86ccf3ca 100644
--- a/services/platform/convex/agents/crm/agent.ts
+++ b/services/platform/convex/agents/crm/agent.ts
@@ -18,7 +18,6 @@ export const CRM_AGENT_INSTRUCTIONS = `You are a CRM assistant specialized in re
 **AVAILABLE TOOLS**
 - customer_read: Read customer data (get_by_id, get_by_email, list operations)
 - product_read: Read product data (get_by_id, list operations)
-- request_human_input: Ask user to select when multiple matches found
 
 **ACTION-FIRST PRINCIPLE**
 Search first, but STOP and ask when multiple matches are found.
@@ -31,11 +30,10 @@ ALWAYS search first:
 **CRITICAL - MULTIPLE MATCHES:**
 When you find 2 or more matching records and the user's request implies ONE specific target:
 1. DO NOT pick one arbitrarily or proceed with all
-2. MUST call request_human_input tool with format="single_select"
-3. Include email and distinguishing details in each option
-4. STOP after calling request_human_input - do NOT continue
+2. Return the list of matches with minimal distinguishing details (name, status, source) to help the user identify the correct record
+3. Ask the user to clarify which one they mean
 
-Do NOT ask (use plain text or request_human_input):
+Do NOT ask:
 • For IDs when you have a name to search
 • About scope for simple queries (just return results)
 • For confirmation on obvious single-match requests
@@ -81,7 +79,6 @@ export function createCrmAgent(options?: { maxSteps?: number }) {
   const convexToolNames: ToolName[] = [
     'customer_read',
     'product_read',
-    'request_human_input',
   ];
 
   debugLog('createCrmAgent Loaded tools', {
diff --git a/services/platform/convex/agents/document/agent.ts b/services/platform/convex/agents/document/agent.ts
index 42f36486a9..b68a364c94 100644
--- a/services/platform/convex/agents/document/agent.ts
+++ b/services/platform/convex/agents/document/agent.ts
@@ -45,7 +45,6 @@ Do NOT ask about:
 - txt: Parse/analyze text files OR generate new .txt files from content
 - image: Analyze images or generate screenshots from HTML/URLs
 - generate_excel: Create Excel files from structured data
-- request_human_input: Ask user for clarification when needed
 
 **FILE PARSING (pdf, docx, pptx)**
 When parsing PDF, DOCX, PPTX files:
@@ -130,7 +129,6 @@ export function createDocumentAgent(options?: { maxSteps?: number }) {
     'pptx',
     'txt',
     'generate_excel',
-    'request_human_input',
   ];
 
   debugLog('createDocumentAgent Loaded tools', {
diff --git a/services/platform/convex/agents/integration/agent.ts b/services/platform/convex/agents/integration/agent.ts
index a09ad9dbe1..eb244f90c9 100644
--- a/services/platform/convex/agents/integration/agent.ts
+++ b/services/platform/convex/agents/integration/agent.ts
@@ -20,7 +20,6 @@ export const INTEGRATION_AGENT_INSTRUCTIONS = `You are an integration assistant.
 - integration_batch: Execute multiple parallel read operations
 - integration_introspect: Discover available integrations and their operations
 - verify_approval: Verify approval card was created
-- request_human_input: Ask user to select when multiple matches found
 
 **INTEGRATION NAMES**
 Only use integrations listed in "## Available Integrations". Never guess names.
@@ -37,15 +36,13 @@ ALWAYS proceed directly when you can:
 **CRITICAL - MULTIPLE MATCHES:**
 When you find 2 or more matching records and the user's request implies ONE specific target:
 1. DO NOT pick one arbitrarily or proceed with all
-2. MUST call request_human_input tool with format="single_select"
-3. Include distinguishing details in each option (name, email, ID, etc.)
-4. STOP after calling request_human_input - do NOT continue
+2. Return the list of matches with distinguishing details (name, email, ID, etc.)
+3. Ask the user to clarify which one they mean
 
 Example - DO THIS:
 User: "Update guest Yuki Liu's email"
 → Search for "Yuki Liu" first
-→ If multiple found, call request_human_input with options for user to select
-→ STOP and wait for user selection
+→ If multiple found, return the list and ask user to specify
 
 Example - DON'T DO THIS:
 User: "Update guest Yuki Liu's email"
@@ -121,7 +118,6 @@ export function createIntegrationAgent(options?: { maxSteps?: number }) {
     'integration_batch',
     'integration_introspect',
     'verify_approval',
-    'request_human_input',
   ];
 
   debugLog('createIntegrationAgent Loaded tools', {
diff --git a/services/platform/convex/agents/web/agent.ts b/services/platform/convex/agents/web/agent.ts
index 47fa5975c0..9e18d0919c 100644
--- a/services/platform/convex/agents/web/agent.ts
+++ b/services/platform/convex/agents/web/agent.ts
@@ -17,38 +17,37 @@ export const WEB_AGENT_INSTRUCTIONS = `You are a web assistant specialized in fe
 
 **YOUR ROLE**
 You handle web-related tasks delegated from the main chat agent:
-- Searching the web for information
 - Fetching content from URLs
+- Searching the web and interacting with websites via browser automation
 - Extracting and summarizing web page content
 
 **AVAILABLE TOOLS**
-- web_read: Fetch URLs, search the web, or search and fetch in one call
-- request_human_input: Ask user for clarification when needed
+- web: Web content tool with two operations
+  - fetch_url: Fetch and extract content from a URL (URL -> PDF -> Vision API extraction)
+  - browser_operate: AI-driven browser automation for searching and interactions
 
 **ACTION-FIRST PRINCIPLE**
-Search first, refine if needed. Don't ask for clarification upfront.
+Act first, refine if needed. Don't ask for clarification upfront.
 
 ALWAYS proceed directly:
-• Any search query → just search it, even if broad
-• URL provided → fetch it immediately
-• Vague topic → search with reasonable interpretation, then offer to refine if results aren't helpful
+• URL provided → use web(operation='fetch_url', url='...')
+• Search needed → use web(operation='browser_operate', instruction='search for...')
+• Website interaction → use web(operation='browser_operate', instruction='...')
 
 Do NOT ask:
-• For topic clarification before searching
+• For topic clarification before acting
 • About scope or timeframe preferences
 • For URL confirmation unless it's clearly malformed
 
-**RECOMMENDED WORKFLOW**
-For most web research tasks, use the combined search_and_fetch operation:
-- web_read(operation='search_and_fetch', query='...'): Searches AND fetches top 5 results in ONE call
-- This is faster than search + manual fetch calls
-- Returns both search result metadata AND actual page content
+**FETCH URL OPERATION**
+Use for extracting content from a specific URL:
+- web(operation='fetch_url', url='https://...') - Basic extraction
+- web(operation='fetch_url', url='https://...', instruction='extract pricing info') - With AI instruction
 
-Example: { operation: "search_and_fetch", query: "weather in Zurich today" }
-
-**WHEN TO USE OTHER OPERATIONS**
-- web_read(operation='fetch_url', url='...'): When user provides a specific URL
-- web_read(operation='search', query='...'): When you only need result snippets without content
+**BROWSER OPERATE OPERATION**
+Use for searching the web or interacting with websites:
+- web(operation='browser_operate', instruction='Search Google for React 19 features')
+- web(operation='browser_operate', instruction='Go to github.com and find the trending repos')
 
 **CONTENT EXTRACTION TIPS**
 - For long pages, focus on the most relevant sections
@@ -65,7 +64,7 @@ Example: { operation: "search_and_fetch", query: "weather in Zurich today" }
 export function createWebAgent(options?: { maxSteps?: number }) {
   const maxSteps = options?.maxSteps ?? 5;
 
-  const convexToolNames: ToolName[] = ['web_read', 'request_human_input'];
+  const convexToolNames: ToolName[] = ['web'];
 
   debugLog('createWebAgent Loaded tools', {
     convexCount: convexToolNames.length,
diff --git a/services/platform/convex/agents/web/mutations.ts b/services/platform/convex/agents/web/mutations.ts
index 4d22f20706..89caba398a 100644
--- a/services/platform/convex/agents/web/mutations.ts
+++ b/services/platform/convex/agents/web/mutations.ts
@@ -13,7 +13,7 @@ import { WEB_AGENT_INSTRUCTIONS } from './agent';
 import type { SerializableAgentConfig } from '../../lib/agent_chat/types';
 import type { ToolName } from '../../agent_tools/tool_registry';
 
-const WEB_AGENT_TOOL_NAMES: ToolName[] = ['web_read', 'request_human_input'];
+const WEB_AGENT_TOOL_NAMES: ToolName[] = ['web', 'request_human_input'];
 
 const WEB_AGENT_CONFIG: SerializableAgentConfig = {
   name: 'web-assistant',
diff --git a/services/platform/convex/lib/action_cache/index.ts b/services/platform/convex/lib/action_cache/index.ts
index 390a162a3e..920a025443 100644
--- a/services/platform/convex/lib/action_cache/index.ts
+++ b/services/platform/convex/lib/action_cache/index.ts
@@ -53,22 +53,6 @@ export const imageAnalysisCache: ActionCache<
   ttl: TTL.INDEFINITE,
 });
 
-// ============================================
-// Web Crawling Caches
-// ============================================
-
-/**
- * Cache for search engine results.
- * Results change frequently, 30-minute TTL.
- */
-export const searchResultsCache: ActionCache<
-  FunctionReference<'action', 'internal'>
-> = new ActionCache(components.actionCache, {
-  action: internal.agent_tools.crawler.internal_actions.fetchSearXNGResultsUncached,
-  name: `search_results_${CACHE_VERSION}`,
-  ttl: TTL.THIRTY_MIN,
-});
-
 // ============================================
 // Organization Configuration Caches
 // ============================================
diff --git a/services/platform/convex/lib/attachments/process_attachments.ts b/services/platform/convex/lib/attachments/process_attachments.ts
index 171fc07857..b2f41ea0ad 100644
--- a/services/platform/convex/lib/attachments/process_attachments.ts
+++ b/services/platform/convex/lib/attachments/process_attachments.ts
@@ -252,7 +252,7 @@ export async function processAttachments(
     // Add a marker indicating content has been pre-analyzed in this message
     contentParts.push({
       type: 'text',
-      text: '\n\n[PRE-ANALYZED CONTENT BELOW - Answer directly from this content without calling document_assistant.]',
+      text: '\n\n[PRE-ANALYZED CONTENT BELOW - This is the attachment from the CURRENT message. It takes priority over any previous context. Answer directly from this content without calling document_assistant.]',
     });
 
     // Add parsed document content
diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json
index 8e11e41ef5..7712dc25be 100644
--- a/services/platform/messages/en.json
+++ b/services/platform/messages/en.json
@@ -1508,6 +1508,7 @@
       "default": "Thinking",
       "searching": "Searching \"{query}\"",
       "reading": "Reading {hostname}",
+      "browsing": "Browsing the web",
       "searchingKnowledgeBase": "Searching knowledge base for \"{query}\"",
       "searchingMultiple": "Searching {queries}",
       "searchingMore": "Searching {first}, {second} and {count} more",
@@ -1519,7 +1520,7 @@
       "customerRead": "Reading customer data",
       "productRead": "Reading product catalog",
       "ragSearch": "Searching knowledge base",
-      "webRead": "Fetching web content",
+      "web": "Fetching web content",
       "pdf": "Processing PDF",
       "image": "Analyzing image",
       "pptx": "Processing presentation",
