diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json
index 9605212f6e2..dfffe6eebe9 100644
--- a/apps/client/src/translations/en/translation.json
+++ b/apps/client/src/translations/en/translation.json
@@ -2346,6 +2346,17 @@
"mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.",
"mcp_endpoint_title": "Endpoint URL",
"mcp_endpoint_description": "Add this URL to your AI assistant's MCP configuration",
+ "web_search_title": "Web Search Engine",
+ "web_search_description": "Choose which search engine the AI agent uses for web searches. Provider default uses the built-in search of each LLM provider (Anthropic, OpenAI, Google). Tavily and SearXNG work with all providers including Ollama.",
+ "web_search_engine": "Search engine",
+ "web_search_engine_description": "Select the search engine to use for AI web searches",
+ "web_search_provider_default": "Provider default (built-in)",
+ "tavily_api_key": "Tavily API key",
+ "tavily_api_key_description": "Get a free API key at tavily.com (1,000 searches/month free)",
+ "searxng_url": "SearXNG instance URL",
+ "searxng_url_description": "URL of your self-hosted SearXNG instance",
+ "search_timeout": "Search timeout (seconds)",
+ "search_timeout_description": "Maximum time to wait for web search results before timing out",
"tools": {
"search_notes": "Search notes",
"get_note": "Get note",
diff --git a/apps/client/src/widgets/type_widgets/options/llm.tsx b/apps/client/src/widgets/type_widgets/options/llm.tsx
index 9da9f50ca62..922abed7977 100644
--- a/apps/client/src/widgets/type_widgets/options/llm.tsx
+++ b/apps/client/src/widgets/type_widgets/options/llm.tsx
@@ -23,6 +23,7 @@ export default function LlmSettings() {
return (
<>
+
>
);
@@ -86,6 +87,66 @@ function getMcpEndpointUrl() {
return `${window.location.protocol}//localhost:${port}/mcp`;
}
+function WebSearchSettings() {
+ const [searchEngine, setSearchEngine] = useTriliumOption("llmWebSearchEngine");
+ const [tavilyApiKey, setTavilyApiKey] = useTriliumOption("llmTavilyApiKey");
+ const [searxngUrl, setSearxngUrl] = useTriliumOption("llmSearxngUrl");
+ const [searchTimeout, setSearchTimeout] = useTriliumOption("llmSearchTimeout");
+
+ return (
+
+
{t("llm.web_search_description")}
+
+
+
+
+
+ {searchEngine === "tavily" && (
+
+ setTavilyApiKey((e.target as HTMLInputElement).value)}
+ placeholder="tvly-..."
+ />
+
+ )}
+
+ {searchEngine === "searxng" && (
+
+ setSearxngUrl((e.target as HTMLInputElement).value)}
+ placeholder="http://localhost:8888"
+ />
+
+ )}
+
+
+ setSearchTimeout((e.target as HTMLInputElement).value)}
+ />
+
+
+ );
+}
+
function McpSettings() {
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
const endpointUrl = useMemo(() => getMcpEndpointUrl(), []);
diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts
index 9e21dcb7b73..d171ad392d6 100644
--- a/apps/server/src/routes/api/options.ts
+++ b/apps/server/src/routes/api/options.ts
@@ -108,6 +108,10 @@ const ALLOWED_OPTIONS = new Set([
// LLM options
"llmProviders",
"mcpEnabled",
+ "llmWebSearchEngine",
+ "llmTavilyApiKey",
+ "llmSearxngUrl",
+ "llmSearchTimeout",
// OCR options
"ocrAutoProcessImages",
"ocrMinConfidence"
diff --git a/apps/server/src/services/llm/providers/base_provider.ts b/apps/server/src/services/llm/providers/base_provider.ts
index 5000b4fcbfd..d852309cb77 100644
--- a/apps/server/src/services/llm/providers/base_provider.ts
+++ b/apps/server/src/services/llm/providers/base_provider.ts
@@ -13,6 +13,8 @@ import { getSkillsSummary } from "../skills/index.js";
import { getNoteMeta,SYSTEM_PROMPT_LIMITS } from "../tools/helpers.js";
import { allToolRegistries } from "../tools/index.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
+import { addTavilySearchTool, addSearxngSearchTool } from "../web_search_tools.js";
+import optionService from "../../options.js";
const DEFAULT_MAX_TOKENS = 8096;
const TITLE_MAX_TOKENS = 30;
@@ -157,7 +159,28 @@ export abstract class BaseProvider implements LlmProvider {
const tools: ToolSet = {};
if (config.enableWebSearch) {
- this.addWebSearchTool(tools);
+ const searchEngine = optionService.getOptionOrNull("llmWebSearchEngine") || "provider";
+ const timeoutSec = parseInt(optionService.getOptionOrNull("llmSearchTimeout") || "15", 10);
+ const timeoutMs = (timeoutSec > 0 ? timeoutSec : 15) * 1000;
+
+ let customToolAdded = false;
+ if (searchEngine === "tavily") {
+ const apiKey = optionService.getOptionOrNull("llmTavilyApiKey");
+ if (apiKey) {
+ addTavilySearchTool(tools, apiKey, timeoutMs);
+ customToolAdded = true;
+ }
+ } else if (searchEngine === "searxng") {
+ const instanceUrl = optionService.getOptionOrNull("llmSearxngUrl");
+ if (instanceUrl) {
+ addSearxngSearchTool(tools, instanceUrl, timeoutMs);
+ customToolAdded = true;
+ }
+ }
+
+ if (!customToolAdded) {
+ this.addWebSearchTool(tools);
+ }
}
if (config.enableNoteTools) {
diff --git a/apps/server/src/services/llm/web_search_tools.ts b/apps/server/src/services/llm/web_search_tools.ts
new file mode 100644
index 00000000000..37204aad777
--- /dev/null
+++ b/apps/server/src/services/llm/web_search_tools.ts
@@ -0,0 +1,130 @@
+/**
+ * Custom web search tools for LLM chat.
+ * Provides Tavily and SearXNG search as alternatives to provider-built-in web search.
+ */
+
+import { tool } from "ai";
+import type { ToolSet } from "ai";
+import { z } from "zod";
+
+import log from "../log.js";
+
+const MAX_RESULTS = 5;
+const DEFAULT_TIMEOUT_MS = 15_000;
+
+/** Create an AbortSignal that times out after the given milliseconds. */
+function timeoutSignal(ms: number): AbortSignal {
+ return AbortSignal.timeout(ms);
+}
+
+interface TavilyResult {
+ title: string;
+ url: string;
+ content: string;
+}
+
+interface SearxngResult {
+ title: string;
+ url: string;
+ content: string;
+}
+
+/**
+ * Add a Tavily web search tool to the tool set.
+ * Tavily is an AI-optimized search API that returns clean, relevant results.
+ * Free tier: 1000 queries/month at https://tavily.com
+ */
+export function addTavilySearchTool(tools: ToolSet, apiKey: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): void {
+ tools.web_search = tool({
+ description: "Search the web for current information using Tavily. Use this when the user asks about recent events, real-time data, or anything requiring up-to-date web information.",
+ inputSchema: z.object({
+ query: z.string().describe("The search query")
+ }),
+ execute: async ({ query }) => {
+ try {
+ const response = await fetch("https://api.tavily.com/search", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ api_key: apiKey,
+ query,
+ max_results: MAX_RESULTS,
+ include_answer: true
+ }),
+ signal: timeoutSignal(timeoutMs)
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ log.error(`Tavily search failed: ${response.status} ${errorText}`);
+ return { error: `Search failed: ${response.status}` };
+ }
+
+ const data = await response.json() as { answer?: string; results: TavilyResult[] };
+
+ return {
+ answer: data.answer || undefined,
+ results: data.results.map((r: TavilyResult) => ({
+ title: r.title,
+ url: r.url,
+ snippet: r.content
+ }))
+ };
+ } catch (e) {
+ log.error(`Tavily search error: ${e}`);
+ return { error: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
+ }
+ }
+ });
+}
+
+/**
+ * Add a SearXNG web search tool to the tool set.
+ * SearXNG is a self-hosted metasearch engine that aggregates results from multiple sources.
+ * No API key required — just a running SearXNG instance URL.
+ */
+export function addSearxngSearchTool(tools: ToolSet, instanceUrl: string, timeoutMs: number = DEFAULT_TIMEOUT_MS): void {
+ // Normalize the URL (remove trailing slash)
+ const baseUrl = instanceUrl.replace(/\/+$/, "");
+
+ tools.web_search = tool({
+ description: "Search the web for current information using SearXNG. Use this when the user asks about recent events, real-time data, or anything requiring up-to-date web information.",
+ inputSchema: z.object({
+ query: z.string().describe("The search query")
+ }),
+ execute: async ({ query }) => {
+ try {
+ const params = new URLSearchParams({
+ q: query,
+ format: "json",
+ categories: "general",
+ language: "auto"
+ });
+
+ const response = await fetch(`${baseUrl}/search?${params}`, {
+ headers: { "Accept": "application/json" },
+ signal: timeoutSignal(timeoutMs)
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ log.error(`SearXNG search failed: ${response.status} ${errorText}`);
+ return { error: `Search failed: ${response.status}` };
+ }
+
+ const data = await response.json() as { results: SearxngResult[] };
+
+ return {
+ results: (data.results || []).slice(0, MAX_RESULTS).map((r: SearxngResult) => ({
+ title: r.title,
+ url: r.url,
+ snippet: r.content
+ }))
+ };
+ } catch (e) {
+ log.error(`SearXNG search error: ${e}`);
+ return { error: `Search failed: ${e instanceof Error ? e.message : String(e)}` };
+ }
+ }
+ });
+}
diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts
index 92912222d09..3d965422bc3 100644
--- a/apps/server/src/services/options_init.ts
+++ b/apps/server/src/services/options_init.ts
@@ -214,6 +214,10 @@ const defaultOptions: DefaultOption[] = [
// AI / LLM
{ name: "llmProviders", value: "[]", isSynced: true },
{ name: "mcpEnabled", value: "false", isSynced: false },
+ { name: "llmWebSearchEngine", value: "provider", isSynced: true },
+ { name: "llmTavilyApiKey", value: "", isSynced: true },
+ { name: "llmSearxngUrl", value: "", isSynced: true },
+ { name: "llmSearchTimeout", value: "15", isSynced: true },
// OCR options
{ name: "ocrAutoProcessImages", value: "false", isSynced: true },
diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts
index 590e3436dc6..a2f368235e2 100644
--- a/packages/commons/src/lib/options_interface.ts
+++ b/packages/commons/src/lib/options_interface.ts
@@ -146,6 +146,14 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions