diff --git a/.env.example b/.env.example
index 5d7f69a9de..acdcc79141 100644
--- a/.env.example
+++ b/.env.example
@@ -836,6 +836,11 @@ OPENWEATHER_API_KEY=
# Tavily (Search Provider and/or Scraper)
# TAVILY_API_KEY=your_tavily_api_key
+# Keenable (Agent Tool — search engine for AI agents)
+# KEENABLE_API_KEY=your_keenable_api_key
+# Optional: override the default Keenable API URL
+# KEENABLE_API_URL=https://api.keenable.ai/v1/search
+
# Scraper (Required)
# FIRECRAWL_API_KEY=your_firecrawl_api_key
# Optional: Custom Firecrawl API URL
diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js
index bb58e81221..f021799846 100644
--- a/api/app/clients/tools/index.js
+++ b/api/app/clients/tools/index.js
@@ -11,6 +11,7 @@ const GoogleSearchAPI = require('./structured/GoogleSearch');
const TraversaalSearch = require('./structured/TraversaalSearch');
const createOpenAIImageTools = require('./structured/OpenAIImageTools');
const TavilySearchResults = require('./structured/TavilySearchResults');
+const KeenableSearch = require('./structured/KeenableSearch');
const createGeminiImageTool = require('./structured/GeminiImageGen');
module.exports = {
@@ -25,6 +26,7 @@ module.exports = {
TraversaalSearch,
StructuredWolfram,
TavilySearchResults,
+ KeenableSearch,
createOpenAIImageTools,
createGeminiImageTool,
};
diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json
index 9637c20867..29c84178af 100644
--- a/api/app/clients/tools/manifest.json
+++ b/api/app/clients/tools/manifest.json
@@ -83,6 +83,19 @@
}
]
},
+ {
+ "name": "Keenable Search",
+ "pluginKey": "keenable_search",
+ "description": "Keenable is a search engine built for AI agents. Useful for answering questions about current events or anything beyond the model's training data.",
+ "icon": "assets/keenable.svg",
+ "authConfig": [
+ {
+ "authField": "KEENABLE_API_KEY",
+ "label": "Keenable API Key",
+ "description": "Get your API key from your Keenable account."
+ }
+ ]
+ },
{
"name": "Calculator",
"pluginKey": "calculator",
diff --git a/api/app/clients/tools/structured/KeenableSearch.js b/api/app/clients/tools/structured/KeenableSearch.js
new file mode 100644
index 0000000000..5d6ce5ace0
--- /dev/null
+++ b/api/app/clients/tools/structured/KeenableSearch.js
@@ -0,0 +1,96 @@
+const { ProxyAgent, fetch } = require('undici');
+const { Tool } = require('@librechat/agents/langchain/tools');
+const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env');
+
+const KEENABLE_DEFAULT_API_URL = 'https://api.keenable.ai/v1/search';
+
+const keenableSearchJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ minLength: 1,
+ description: 'The search query string.',
+ },
+ max_results: {
+ type: 'number',
+ minimum: 1,
+ maximum: 20,
+ description: 'The maximum number of search results to return. Defaults to 10.',
+ },
+ },
+ required: ['query'],
+};
+
+class KeenableSearch extends Tool {
+ static lc_name() {
+ return 'KeenableSearch';
+ }
+
+ constructor(fields = {}) {
+ super(fields);
+ this.envVar = 'KEENABLE_API_KEY';
+ this.urlEnvVar = 'KEENABLE_API_URL';
+ /* Used to initialize the Tool without necessary variables. */
+ this.override = fields.override ?? false;
+ this.apiKey = fields[this.envVar] ?? this.getApiKey();
+ this.apiUrl =
+ fields[this.urlEnvVar] ?? getEnvironmentVariable(this.urlEnvVar) ?? KEENABLE_DEFAULT_API_URL;
+
+ this.kwargs = fields?.kwargs ?? {};
+ this.name = 'keenable_search';
+ this.description =
+ "Keenable is a search engine built for AI agents. Returns relevant web pages with titles, URLs, and content snippets. Use it to answer questions about current events, recent information, or anything beyond the model's training data.";
+
+ this.schema = keenableSearchJsonSchema;
+ }
+
+ static get jsonSchema() {
+ return keenableSearchJsonSchema;
+ }
+
+ getApiKey() {
+ const apiKey = getEnvironmentVariable(this.envVar);
+ if (!apiKey && !this.override) {
+ throw new Error(`Missing ${this.envVar} environment variable.`);
+ }
+ return apiKey;
+ }
+
+ async _call(input) {
+ const { query, max_results, ...rest } = input;
+
+ const requestBody = {
+ query,
+ max_results: max_results ?? 10,
+ ...rest,
+ ...this.kwargs,
+ };
+
+ const fetchOptions = {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-Key': this.apiKey,
+ },
+ body: JSON.stringify(requestBody),
+ };
+
+ if (process.env.PROXY) {
+ fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
+ }
+
+ const response = await fetch(this.apiUrl, fetchOptions);
+
+ const json = await response.json();
+ if (!response.ok) {
+ throw new Error(
+ `Request failed with status ${response.status}: ${json?.detail?.error || json?.error || JSON.stringify(json)}`,
+ );
+ }
+
+ return JSON.stringify(json);
+ }
+}
+
+module.exports = KeenableSearch;
diff --git a/api/app/clients/tools/structured/specs/KeenableSearch.spec.js b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js
new file mode 100644
index 0000000000..b3b08006b6
--- /dev/null
+++ b/api/app/clients/tools/structured/specs/KeenableSearch.spec.js
@@ -0,0 +1,131 @@
+const { fetch, ProxyAgent } = require('undici');
+const KeenableSearch = require('../KeenableSearch');
+
+jest.mock('undici');
+
+describe('KeenableSearch', () => {
+ let originalEnv;
+ const mockApiKey = 'mock_api_key';
+
+ beforeAll(() => {
+ originalEnv = { ...process.env };
+ });
+
+ beforeEach(() => {
+ jest.resetModules();
+ jest.clearAllMocks();
+ process.env = {
+ ...originalEnv,
+ KEENABLE_API_KEY: mockApiKey,
+ };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should throw an error if KEENABLE_API_KEY is missing', () => {
+ delete process.env.KEENABLE_API_KEY;
+ expect(() => new KeenableSearch()).toThrow('Missing KEENABLE_API_KEY environment variable.');
+ });
+
+ it('should use provided KEENABLE_API_KEY field when env var is unset', () => {
+ delete process.env.KEENABLE_API_KEY;
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ expect(instance.apiKey).toBe(mockApiKey);
+ });
+
+ it('should default to https://api.keenable.ai/v1/search when KEENABLE_API_URL is unset', () => {
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ expect(instance.apiUrl).toBe('https://api.keenable.ai/v1/search');
+ });
+
+ it('should honor KEENABLE_API_URL override', () => {
+ process.env.KEENABLE_API_URL = 'https://staging.keenable.ai/search';
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ expect(instance.apiUrl).toBe('https://staging.keenable.ai/search');
+ });
+
+ describe('_call', () => {
+ const mockResponse = {
+ ok: true,
+ json: jest.fn().mockResolvedValue({ results: [] }),
+ };
+
+ beforeEach(() => {
+ fetch.mockResolvedValue(mockResponse);
+ });
+
+ it('should send a POST with X-API-Key auth and the query body', async () => {
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query', max_results: 3 });
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.keenable.ai/v1/search',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({
+ 'Content-Type': 'application/json',
+ 'X-API-Key': mockApiKey,
+ }),
+ body: JSON.stringify({ query: 'test query', max_results: 3 }),
+ }),
+ );
+ });
+
+ it('should default max_results to 10 when not provided', async () => {
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.keenable.ai/v1/search',
+ expect.objectContaining({
+ body: JSON.stringify({ query: 'test query', max_results: 10 }),
+ }),
+ );
+ });
+
+ it('should use ProxyAgent when PROXY env var is set', async () => {
+ const proxyUrl = 'http://proxy.example.com:8080';
+ process.env.PROXY = proxyUrl;
+
+ const mockProxyAgent = { type: 'proxy-agent' };
+ ProxyAgent.mockImplementation(() => mockProxyAgent);
+
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl);
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.keenable.ai/v1/search',
+ expect.objectContaining({ dispatcher: mockProxyAgent }),
+ );
+ });
+
+ it('should not use ProxyAgent when PROXY env var is not set', async () => {
+ delete process.env.PROXY;
+
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ await instance._call({ query: 'test query' });
+
+ expect(ProxyAgent).not.toHaveBeenCalled();
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.keenable.ai/v1/search',
+ expect.not.objectContaining({ dispatcher: expect.anything() }),
+ );
+ });
+
+ it('should throw on non-2xx response', async () => {
+ fetch.mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ json: jest.fn().mockResolvedValue({ error: 'invalid key' }),
+ });
+
+ const instance = new KeenableSearch({ KEENABLE_API_KEY: mockApiKey });
+ await expect(instance._call({ query: 'test' })).rejects.toThrow(
+ 'Request failed with status 401: invalid key',
+ );
+ });
+ });
+});
diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js
index 89a79f3cbd..a963107de2 100644
--- a/api/app/clients/tools/util/handleTools.js
+++ b/api/app/clients/tools/util/handleTools.js
@@ -32,6 +32,7 @@ const {
TraversaalSearch,
StructuredWolfram,
TavilySearchResults,
+ KeenableSearch,
createGeminiImageTool,
createOpenAIImageTools,
} = require('../');
@@ -179,6 +180,7 @@ const loadTools = async ({
'azure-ai-search': StructuredACS,
traversaal_search: TraversaalSearch,
tavily_search_results_json: TavilySearchResults,
+ keenable_search: KeenableSearch,
};
const customConstructors = {
diff --git a/client/public/assets/keenable.svg b/client/public/assets/keenable.svg
new file mode 100644
index 0000000000..e11d13d482
--- /dev/null
+++ b/client/public/assets/keenable.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts
index 5e953ce547..942073df4e 100644
--- a/packages/api/src/tools/registry/definitions.ts
+++ b/packages/api/src/tools/registry/definitions.ts
@@ -337,6 +337,25 @@ export const tavilySearchSchema: ExtendedJsonSchema = {
required: ['query'],
};
+/** Keenable Search tool JSON schema */
+export const keenableSearchSchema: ExtendedJsonSchema = {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ minLength: 1,
+ description: 'The search query string.',
+ },
+ max_results: {
+ type: 'number',
+ minimum: 1,
+ maximum: 20,
+ description: 'The maximum number of search results to return. Defaults to 10.',
+ },
+ },
+ required: ['query'],
+};
+
/** File Search tool JSON schema */
export const fileSearchSchema: ExtendedJsonSchema = {
type: 'object',
@@ -416,6 +435,13 @@ export const toolDefinitions: Record = {
schema: tavilySearchSchema,
toolType: 'builtin',
},
+ keenable_search: {
+ name: 'keenable_search',
+ description:
+ "Keenable is a search engine built for AI agents. Returns relevant web pages with titles, URLs, and content snippets. Useful for answering questions about current events or anything beyond the model's training data.",
+ schema: keenableSearchSchema,
+ toolType: 'builtin',
+ },
file_search: {
name: 'file_search',
description: