From be10f5aa0e67c938b04c5b99a7c39cafa0c3e788 Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Mon, 22 Jun 2026 10:38:47 +0300 Subject: [PATCH 1/3] feat(agent): add Keenable web search provider (keyless by default) Keenable is a web search API built for AI agents. Unlike the other providers, it works without an API key by default (keyless public endpoint); setting AGENT_KEENABLE_API_KEY uses the authenticated endpoint and lifts rate limits. - web-browsing.js: _keenableSearch provider + switch case - updateENV.js / systemSettings.js: AGENT_KEENABLE_API_KEY + AGENT_KEENABLE_API_URL - agent_search_provider validation allowlist - frontend: provider entry + options form (optional key, base URL) + icon - .env.example (server + docker) --- docker/.env.example | 4 + .../SearchProviderOptions/index.jsx | 49 ++++++++++ .../WebSearchSelection/icons/keenable.svg | 5 ++ .../Admin/Agents/WebSearchSelection/index.jsx | 10 +++ server/.env.example | 4 + server/models/systemSettings.js | 3 + .../agents/aibitat/plugins/web-browsing.js | 89 +++++++++++++++++++ server/utils/helpers/updateENV.js | 8 ++ 8 files changed, 172 insertions(+) create mode 100644 frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg diff --git a/docker/.env.example b/docker/.env.example index 83e78f50454..17fb58dd26a 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -427,6 +427,10 @@ GID='1000' # AGENT_CRW_API_KEY= # AGENT_CRW_API_URL= # Optional. Defaults to https://fastcrw.com/api. Set for self-hosted fastCRW. +#------ Keenable ----------- https://keenable.ai +# AGENT_KEENABLE_API_KEY= # Optional. Keyless free tier by default; set a key to lift rate limits. +# AGENT_KEENABLE_API_URL= # Optional. Defaults to https://api.keenable.ai. + ########################################### ######## Other Configurations ############ ########################################### diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx b/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx index 4798e99968e..a5284434350 100644 --- a/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx +++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/SearchProviderOptions/index.jsx @@ -445,6 +445,55 @@ export function DuckDuckGoOptions() { ); } +export function KeenableSearchOptions({ settings }) { + return ( + <> +

+ Keenable works without an API key by default (keyless free tier). To + lift rate limits, add an API key{" "} + + from Keenable. + +

+
+
+ + +
+
+ + +
+
+ + ); +} + export function ExaSearchOptions({ settings }) { return ( <> diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg new file mode 100644 index 00000000000..be1d489ec4b --- /dev/null +++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx index 63c236b4983..972171b9da4 100644 --- a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx +++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx @@ -13,6 +13,7 @@ import ExaIcon from "./icons/exa.png"; import PerplexitySearchIcon from "./icons/perplexity.png"; import BraveSearchIcon from "./icons/brave.png"; import CrwSearchIcon from "./icons/crw.png"; +import KeenableSearchIcon from "./icons/keenable.svg"; import { CaretUpDown, MagnifyingGlass, @@ -36,6 +37,7 @@ import { PerplexitySearchOptions, BraveSearchOptions, CrwSearchOptions, + KeenableSearchOptions, } from "./SearchProviderOptions"; const SEARCH_PROVIDERS = [ @@ -46,6 +48,14 @@ const SEARCH_PROVIDERS = [ options: () => , description: "Free and privacy-focused web search using DuckDuckGo.", }, + { + name: "Keenable", + value: "keenable-search", + logo: KeenableSearchIcon, + options: (settings) => , + description: + "Web search built for AI agents. Works without an API key (keyless free tier); add a key to lift rate limits.", + }, { name: "Brave Search", value: "brave-search", diff --git a/server/.env.example b/server/.env.example index b8e1ca442c1..5ebaa87c7d7 100644 --- a/server/.env.example +++ b/server/.env.example @@ -433,6 +433,10 @@ STT_PROVIDER="native" # AGENT_CRW_API_KEY= # AGENT_CRW_API_URL= # Optional. Defaults to https://fastcrw.com/api. Set for self-hosted fastCRW. +#------ Keenable ----------- https://keenable.ai +# AGENT_KEENABLE_API_KEY= # Optional. Keyless free tier by default; set a key to lift rate limits. +# AGENT_KEENABLE_API_URL= # Optional. Defaults to https://api.keenable.ai. + ########################################### ######## Other Configurations ############ ########################################### diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index b9e8f451093..68c68e8ac59 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -166,6 +166,7 @@ const SystemSettings = { "perplexity-search", "brave-search", "crw-search", + "keenable-search", ].includes(update) ) throw new Error("Invalid SERP provider."); @@ -582,6 +583,8 @@ const SystemSettings = { AgentBraveApiKey: !!process.env.AGENT_BRAVE_API_KEY || null, AgentCrwApiKey: !!process.env.AGENT_CRW_API_KEY || null, AgentCrwApiUrl: process.env.AGENT_CRW_API_URL || null, + AgentKeenableApiKey: !!process.env.AGENT_KEENABLE_API_KEY || null, + AgentKeenableApiUrl: process.env.AGENT_KEENABLE_API_URL || null, // -------------------------------------------------------- // Compliance Settings diff --git a/server/utils/agents/aibitat/plugins/web-browsing.js b/server/utils/agents/aibitat/plugins/web-browsing.js index dd8c3036e8e..dcf9393898e 100644 --- a/server/utils/agents/aibitat/plugins/web-browsing.js +++ b/server/utils/agents/aibitat/plugins/web-browsing.js @@ -107,6 +107,9 @@ const webBrowsing = { case "crw-search": engine = "_crwSearch"; break; + case "keenable-search": + engine = "_keenableSearch"; + break; default: engine = "_duckDuckGoEngine"; } @@ -1332,6 +1335,92 @@ const webBrowsing = { ); return result; }, + + /** + * Use Keenable + * A web search API built for AI agents. Keyless by default (free + * tier); set AGENT_KEENABLE_API_KEY to lift rate limits. + * https://keenable.ai + */ + _keenableSearch: async function (query) { + // Unlike the other providers, a key is optional here: with none we + // use the keyless public endpoint, so this works out of the box. + const apiKey = (process.env.AGENT_KEENABLE_API_KEY || "").trim(); + + let baseUrl = "https://api.keenable.ai"; + if (process.env.AGENT_KEENABLE_API_URL) { + try { + const parsed = new URL(process.env.AGENT_KEENABLE_API_URL); + const isLoopback = ["localhost", "127.0.0.1", "::1"].includes( + parsed.hostname + ); + // HTTPS-only, except loopback for local development. + if (parsed.protocol === "https:" || isLoopback) + baseUrl = parsed.origin; + else throw new Error("KEENABLE base URL must be https://"); + } catch (e) { + this.super.handlerProps.log( + `invalid Keenable Search URL: ${e.message}` + ); + } + } + + this.super.introspect( + `${this.caller}: Using Keenable to search for "${ + query.length > 100 ? `${query.slice(0, 100)}...` : query + }"` + ); + + const headers = { + "Content-Type": "application/json", + "User-Agent": "keenable-anythingllm", + // Attribution header the Keenable backend segments traffic by. + "X-Keenable-Title": "AnythingLLM", + }; + // Keyless public endpoint by default; keyed endpoint + X-API-Key + // when a key is configured. + const path = apiKey ? "/v1/search" : "/v1/search/public"; + if (apiKey) headers["X-API-Key"] = apiKey; + + const { response, error } = await fetch(`${baseUrl}${path}`, { + method: "POST", + headers, + body: JSON.stringify({ query, mode: "pro" }), + }) + .then((res) => { + if (res.ok) return res.json(); + throw new Error(`${res.status} - ${res.statusText}`); + }) + .then((data) => { + return { response: data, error: null }; + }) + .catch((e) => { + this.super.handlerProps.log(`Keenable Search Error: ${e.message}`); + return { response: null, error: e.message }; + }); + if (error) + return `There was an error searching for content. ${error}`; + + const data = []; + response.results?.forEach((searchResult) => { + const { title, url, description } = searchResult; + data.push({ + title, + link: url, + snippet: description, + }); + }); + + if (data.length === 0) + return `No information was found online for the search query.`; + + this.reportSearchResultsCitations(data); + const result = JSON.stringify(data); + this.super.introspect( + `${this.caller}: I found ${data.length} results - reviewing the results now. (~${this.countTokens(result)} tokens)` + ); + return result; + }, }); }, }; diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index f91c02a8903..939565c0854 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -573,6 +573,14 @@ const KEY_MAPPING = { envKey: "AGENT_CRW_API_URL", checks: [], }, + AgentKeenableApiKey: { + envKey: "AGENT_KEENABLE_API_KEY", + checks: [], + }, + AgentKeenableApiUrl: { + envKey: "AGENT_KEENABLE_API_URL", + checks: [], + }, // TTS/STT Integration ENVS TextToSpeechProvider: { From 850dcb0e7af3ca6794ca55fd302a3162f507cdd5 Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Wed, 19 Aug 2026 09:26:33 +0300 Subject: [PATCH 2/3] fix: read Keenable result text from snippet, not description Keenable returns both fields on every result: snippet carries the page text and description is the page's meta description, which is empty for most pages, so the agent received citations with an empty snippet. Keenable also returns whole pages, so the text is collapsed and capped at 500 chars. --- server/utils/agents/aibitat/plugins/web-browsing.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/utils/agents/aibitat/plugins/web-browsing.js b/server/utils/agents/aibitat/plugins/web-browsing.js index 4e35c142aa1..d66876ab6a7 100644 --- a/server/utils/agents/aibitat/plugins/web-browsing.js +++ b/server/utils/agents/aibitat/plugins/web-browsing.js @@ -1408,11 +1408,19 @@ const webBrowsing = { const data = []; response.results?.forEach((searchResult) => { - const { title, url, description } = searchResult; + const { title, url, description, snippet } = searchResult; + // Keenable returns both fields: `snippet` carries the page text and + // `description` is the page's meta description, which is empty for + // most pages. It returns whole pages rather than an excerpt, so the + // text is collapsed and capped to snippet length for the agent. + const text = String(snippet || description || "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 500); data.push({ title, link: url, - snippet: description, + snippet: text, }); }); From c98b28c03311eba640c39f9226d4c581cf072ef4 Mon Sep 17 00:00:00 2001 From: Ilya Bogin Date: Sun, 23 Aug 2026 10:15:53 +0300 Subject: [PATCH 3/3] Use the real Keenable mark for the provider icon The file shipped a generic blue magnifying glass rather than our brandmark. This is the mark from our brand guidelines, transparent so it reads on both the light and the dark theme. --- .../Agents/WebSearchSelection/icons/keenable.svg | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg index be1d489ec4b..bc6f56f480e 100644 --- a/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg +++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/icons/keenable.svg @@ -1,5 +1,8 @@ - - - - + + + + + + +