Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
afefcdf8fb fix: improve Perplexity web_search defaults (#1131) (thanks @CMLKevin) 2026-01-18 01:15:28 +00:00
Kevin Lin
9fc3926607 feat(web): add Perplexity Sonar as alternative search provider 2026-01-18 00:57:32 +00:00
12 changed files with 610 additions and 50 deletions

View File

@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs. - Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Tools: add Perplexity Sonar provider for `web_search` with direct Perplexity/OpenRouter support. (#1131) — thanks @CMLKevin.
### Fixes ### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config. - Memory: apply OpenAI batch defaults even without explicit remote config.

55
docs/perplexity.md Normal file
View File

@ -0,0 +1,55 @@
---
summary: "Perplexity Sonar setup for web_search"
read_when:
- You want to use Perplexity Sonar for web search
- You need PERPLEXITY_API_KEY or OpenRouter setup
---
# Perplexity Sonar
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
through Perplexitys direct API or via OpenRouter.
## API options
### Perplexity (direct)
- Base URL: https://api.perplexity.ai
- Environment variable: `PERPLEXITY_API_KEY`
### OpenRouter (alternative)
- Base URL: https://openrouter.ai/api/v1
- Environment variable: `OPENROUTER_API_KEY`
- Supports prepaid/crypto credits.
## Config example
```json5
{
tools: {
web: {
search: {
provider: "perplexity",
perplexity: {
apiKey: "pplx-...",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro"
}
}
}
}
}
```
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
to disambiguate.
## Models
- `perplexity/sonar` — fast Q&A with web search
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
- `perplexity/sonar-reasoning-pro` — deep research
See [Web tools](/tools/web) for the full web_search configuration.

View File

@ -1,15 +1,16 @@
--- ---
summary: "Web search + fetch tools (Brave Search API)" summary: "Web search + fetch tools (Brave Search API, Perplexity Sonar)"
read_when: read_when:
- You want to enable web_search or web_fetch - You want to enable web_search or web_fetch
- You need Brave Search API key setup - You need Brave Search API key setup
- You want to use Perplexity Sonar for web search
--- ---
# Web tools # Web tools
Clawdbot ships two lightweight web tools: Clawdbot ships two lightweight web tools:
- `web_search`Brave Search API queries (fast, structured results). - `web_search`Search the web via Brave Search API (default) or Perplexity Sonar (direct or via OpenRouter).
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text). - `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the These are **not** browser automation. For JS-heavy sites or logins, use the
@ -17,13 +18,37 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
## How it works ## How it works
- `web_search` calls Braves Search API and returns structured results - `web_search` calls your configured provider and returns results.
(title, URL, snippet). No browser is involved. - **Brave** (default): returns structured results (title, URL, snippet).
- **Perplexity**: returns AI-synthesized answers with citations from real-time web search.
- Results are cached by query for 15 minutes (configurable). - Results are cached by query for 15 minutes (configurable).
- `web_fetch` does a plain HTTP GET and extracts readable content - `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript. (HTML → markdown/text). It does **not** execute JavaScript.
- `web_fetch` is enabled by default (unless explicitly disabled). - `web_fetch` is enabled by default (unless explicitly disabled).
## Choosing a search provider
Provider docs: [Brave Search API](https://brave.com/search/api/) and [Perplexity Sonar](/perplexity).
| Provider | Pros | Cons | API Key |
|----------|------|------|---------|
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter credits | `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` |
Set the provider in config:
```json5
{
tools: {
web: {
search: {
provider: "brave" // or "perplexity"
}
}
}
}
```
## Getting a Brave API key ## Getting a Brave API key
1) Create a Brave Search API account at https://brave.com/search/api/ 1) Create a Brave Search API account at https://brave.com/search/api/
@ -42,14 +67,74 @@ current limits and pricing.
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables). service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
## Using Perplexity Sonar
Perplexity Sonar models have built-in web search capabilities and return AI-synthesized
answers with citations. You can use them either directly via Perplexitys API or via OpenRouter
(supports crypto/prepaid).
See [Perplexity Sonar](/perplexity) for a dedicated setup guide.
### Getting an API key
**Perplexity (direct):**
1) Create a Perplexity account and generate an API key.
2) Set `PERPLEXITY_API_KEY` in the Gateway environment or store the key in config.
**OpenRouter (alternative):**
1) Create an account at https://openrouter.ai/
2) Add credits (supports crypto, prepaid, or credit card)
3) Generate an API key in your account settings
### Setting up Perplexity search
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
// API key (optional if OPENROUTER_API_KEY or PERPLEXITY_API_KEY is set)
apiKey: "pplx-... or sk-or-v1-...",
// Base URL:
// - https://api.perplexity.ai (default when PERPLEXITY_API_KEY is set)
// - https://openrouter.ai/api/v1 (default when only OPENROUTER_API_KEY is set)
baseUrl: "https://api.perplexity.ai",
// Model (defaults to perplexity/sonar-pro)
model: "perplexity/sonar-pro"
}
}
}
}
}
```
**Environment alternative:** set `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY` in the Gateway
environment. For a daemon install, put it in `~/.clawdbot/.env`. If you set both keys, set
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`) to disambiguate.
### Available Perplexity models
| Model | Description | Best for |
|-------|-------------|----------|
| `perplexity/sonar` | Fast Q&A with web search | Quick lookups |
| `perplexity/sonar-pro` (default) | Multi-step reasoning with web search | Complex questions |
| `perplexity/sonar-reasoning-pro` | Chain-of-thought analysis | Deep research |
## web_search ## web_search
Search the web with Braves API. Search the web using your configured provider.
### Requirements ### Requirements
- `tools.web.search.enabled` must not be `false` (default: enabled) - `tools.web.search.enabled` must not be `false` (default: enabled)
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`) - API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY` (direct), `OPENROUTER_API_KEY` (OpenRouter), or `tools.web.search.perplexity.apiKey`
### Config ### Config

View File

@ -168,7 +168,7 @@ export function buildAgentSystemPrompt(params: {
ls: "List directory contents", ls: "List directory contents",
exec: "Run shell commands (pty available for TTY-required CLIs)", exec: "Run shell commands (pty available for TTY-required CLIs)",
process: "Manage background exec sessions", process: "Manage background exec sessions",
web_search: "Search the web (Brave API)", web_search: "Search the web (Brave or Perplexity)",
web_fetch: "Fetch and extract readable content from a URL", web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking. // Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser", browser: "Control web browser",

View File

@ -89,3 +89,102 @@ describe("web_search country and language parameters", () => {
expect(url.searchParams.get("ui_lang")).toBe("de"); expect(url.searchParams.get("ui_lang")).toBe("de");
}); });
}); });
describe("web_search Perplexity provider configuration", () => {
const priorFetch = global.fetch;
beforeEach(() => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
});
afterEach(() => {
vi.unstubAllEnvs();
// @ts-expect-error global fetch cleanup
global.fetch = priorFetch;
});
it("defaults to Perplexity base URL when PERPLEXITY_API_KEY is set", async () => {
const mockFetch = vi.fn((_input: RequestInfo, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
citations: [],
}),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
});
await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit?];
expect(url).toBe("https://api.perplexity.ai/chat/completions");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer pplx-test");
});
it("uses OpenRouter base URL when configured, even with PERPLEXITY_API_KEY set", async () => {
const mockFetch = vi.fn((_input: RequestInfo, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: "ok" } }],
citations: [],
}),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: { provider: "perplexity", perplexity: { baseUrl: "https://openrouter.ai/api/v1" } },
},
},
},
sandboxed: true,
});
await tool?.execute?.(1, { query: "test" });
expect(mockFetch).toHaveBeenCalled();
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit?];
expect(url).toBe("https://openrouter.ai/api/v1/chat/completions");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer sk-or-test");
});
it("returns missing key when OpenRouter base URL is configured without OPENROUTER_API_KEY", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "");
const mockFetch = vi.fn();
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: { provider: "perplexity", perplexity: { baseUrl: "https://openrouter.ai/api/v1" } },
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.(1, { query: "test" });
expect(mockFetch).not.toHaveBeenCalled();
expect(result?.details).toMatchObject({ error: "missing_openrouter_api_key" });
});
});

View File

@ -5,7 +5,7 @@ import { stringEnum } from "../schema/typebox.js";
import type { AnyAgentTool } from "./common.js"; import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const SEARCH_PROVIDERS = ["brave"] as const; const SEARCH_PROVIDERS = ["brave", "perplexity"] as const;
const EXTRACT_MODES = ["markdown", "text"] as const; const EXTRACT_MODES = ["markdown", "text"] as const;
const DEFAULT_SEARCH_COUNT = 5; const DEFAULT_SEARCH_COUNT = 5;
@ -20,6 +20,9 @@ const DEFAULT_FETCH_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"; "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search"; const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
const DEFAULT_PERPLEXITY_BASE_URL = "https://api.perplexity.ai";
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web type WebSearchConfig = NonNullable<ClawdbotConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search } ? Web extends { search?: infer Search }
@ -196,7 +199,34 @@ function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): nu
return DEFAULT_FIRECRAWL_MAX_AGE_MS; return DEFAULT_FIRECRAWL_MAX_AGE_MS;
} }
function missingSearchKeyPayload() { function missingSearchKeyPayload(
provider: (typeof SEARCH_PROVIDERS)[number],
perplexityBaseUrl?: string,
) {
if (provider === "perplexity") {
if (perplexityBaseUrl && isOpenRouterBaseUrl(perplexityBaseUrl)) {
return {
error: "missing_openrouter_api_key",
message:
"web_search (perplexity via OpenRouter) needs an API key. Set OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
if (perplexityBaseUrl && isPerplexityBaseUrl(perplexityBaseUrl)) {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity direct) needs an API key. Set PERPLEXITY_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.clawd.bot/tools/web",
};
}
return { return {
error: "missing_brave_api_key", error: "missing_brave_api_key",
message: message:
@ -210,10 +240,99 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
search && "provider" in search && typeof search.provider === "string" search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase() ? search.provider.trim().toLowerCase()
: ""; : "";
if (raw === "perplexity") return "perplexity";
if (raw === "brave") return "brave"; if (raw === "brave") return "brave";
return "brave"; return "brave";
} }
type PerplexityConfig = {
apiKey?: string;
baseUrl?: string;
model?: string;
};
function normalizeBaseUrl(value: string): string {
return value.trim().replace(/\/+$/, "");
}
function isPerplexityBaseUrl(value: string): boolean {
try {
return new URL(value).hostname === "api.perplexity.ai";
} catch {
return false;
}
}
function isOpenRouterBaseUrl(value: string): boolean {
try {
return new URL(value).hostname === "openrouter.ai";
} catch {
return false;
}
}
function inferPerplexityBaseUrlFromKey(apiKey: string): string | undefined {
if (apiKey.startsWith("pplx-")) return DEFAULT_PERPLEXITY_BASE_URL;
if (apiKey.startsWith("sk-or-")) return DEFAULT_OPENROUTER_BASE_URL;
return undefined;
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
if (!search || typeof search !== "object") return {};
const perplexity = "perplexity" in search ? search.perplexity : undefined;
if (!perplexity || typeof perplexity !== "object") return {};
return perplexity as PerplexityConfig;
}
function resolvePerplexityApiKey(
perplexity: PerplexityConfig | undefined,
baseUrl: string,
): string | undefined {
const fromConfig =
perplexity && "apiKey" in perplexity && typeof perplexity.apiKey === "string"
? perplexity.apiKey.trim()
: "";
if (fromConfig) return fromConfig;
const fromEnvPerplexity = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const fromEnvOpenRouter = (process.env.OPENROUTER_API_KEY ?? "").trim();
if (isPerplexityBaseUrl(baseUrl)) {
return fromEnvPerplexity || undefined;
}
if (isOpenRouterBaseUrl(baseUrl)) {
return fromEnvOpenRouter || undefined;
}
return fromEnvPerplexity || fromEnvOpenRouter || undefined;
}
function resolvePerplexityBaseUrl(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
? perplexity.baseUrl.trim()
: "";
if (fromConfig) return normalizeBaseUrl(fromConfig);
const fromConfigKey =
perplexity && "apiKey" in perplexity && typeof perplexity.apiKey === "string"
? perplexity.apiKey.trim()
: "";
const inferredFromConfigKey = fromConfigKey
? inferPerplexityBaseUrlFromKey(fromConfigKey)
: undefined;
if (inferredFromConfigKey) return inferredFromConfigKey;
const fromEnvPerplexity = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const fromEnvOpenRouter = (process.env.OPENROUTER_API_KEY ?? "").trim();
if (fromEnvPerplexity) return DEFAULT_PERPLEXITY_BASE_URL;
if (fromEnvOpenRouter) return DEFAULT_OPENROUTER_BASE_URL;
return DEFAULT_OPENROUTER_BASE_URL;
}
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
const fromConfig =
perplexity && "model" in perplexity && typeof perplexity.model === "string"
? perplexity.model.trim()
: "";
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
}
function resolveTimeoutSeconds(value: unknown, fallback: number): number { function resolveTimeoutSeconds(value: unknown, fallback: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback; const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(1, Math.floor(parsed)); return Math.max(1, Math.floor(parsed));
@ -486,6 +605,63 @@ export async function fetchFirecrawlContent(params: {
}; };
} }
type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
citations?: string[];
};
function resolvePerplexityEndpoint(baseUrl: string): string {
const trimmed = normalizeBaseUrl(baseUrl);
if (!trimmed) return `${DEFAULT_OPENROUTER_BASE_URL}/chat/completions`;
if (trimmed.endsWith("/chat/completions")) return trimmed;
return `${trimmed}/chat/completions`;
}
async function runPerplexitySearch(params: {
query: string;
apiKey: string;
baseUrl: string;
model: string;
timeoutSeconds: number;
}): Promise<{ content: string; citations: string[] }> {
const endpoint = resolvePerplexityEndpoint(params.baseUrl);
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${params.apiKey}`,
"HTTP-Referer": "https://clawdbot.com",
"X-Title": "Clawdbot Web Search",
},
body: JSON.stringify({
model: params.model,
messages: [
{
role: "user",
content: params.query,
},
],
}),
signal: withTimeout(undefined, params.timeoutSeconds * 1000),
});
if (!res.ok) {
const detail = await readResponseText(res);
throw new Error(`Perplexity API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as PerplexitySearchResponse;
const content = data.choices?.[0]?.message?.content ?? "No response";
const citations = data.citations ?? [];
return { content, citations };
}
async function runWebSearch(params: { async function runWebSearch(params: {
query: string; query: string;
count: number; count: number;
@ -496,14 +672,42 @@ async function runWebSearch(params: {
country?: string; country?: string;
search_lang?: string; search_lang?: string;
ui_lang?: string; ui_lang?: string;
perplexityBaseUrl?: string;
perplexityModel?: string;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const providerKey =
params.provider === "perplexity"
? `${params.provider}:${params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL}:${params.perplexityBaseUrl ?? DEFAULT_OPENROUTER_BASE_URL}`
: params.provider;
const cacheKey = normalizeCacheKey( const cacheKey = normalizeCacheKey(
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`, `${providerKey}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`,
); );
const cached = readCache(SEARCH_CACHE, cacheKey); const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) return { ...cached.value, cached: true }; if (cached) return { ...cached.value, cached: true };
const start = Date.now(); const start = Date.now();
if (params.provider === "perplexity") {
const { content, citations } = await runPerplexitySearch({
query: params.query,
apiKey: params.apiKey,
baseUrl: params.perplexityBaseUrl ?? DEFAULT_PERPLEXITY_BASE_URL,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
timeoutSeconds: params.timeoutSeconds,
});
const payload = {
query: params.query,
provider: params.provider,
model: params.perplexityModel ?? DEFAULT_PERPLEXITY_MODEL,
tookMs: Date.now() - start,
content,
citations,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
if (params.provider !== "brave") { if (params.provider !== "brave") {
throw new Error("Unsupported web search provider."); throw new Error("Unsupported web search provider.");
} }
@ -772,16 +976,32 @@ export function createWebSearchTool(options?: {
}): AnyAgentTool | null { }): AnyAgentTool | null {
const search = resolveSearchConfig(options?.config); const search = resolveSearchConfig(options?.config);
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) return null; if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) return null;
const provider = resolveSearchProvider(search);
const perplexityConfig = resolvePerplexityConfig(search);
const perplexityBaseUrl = resolvePerplexityBaseUrl(perplexityConfig);
const perplexityModel = resolvePerplexityModel(perplexityConfig);
// Determine description based on provider
const description =
provider === "perplexity"
? "Search the web using Perplexity Sonar (direct API or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
return { return {
label: "Web Search", label: "Web Search",
name: "web_search", name: "web_search",
description: description,
"Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: WebSearchSchema, parameters: WebSearchSchema,
execute: async (_toolCallId, args) => { execute: async (_toolCallId, args) => {
const apiKey = resolveSearchApiKey(search); // Resolve API key based on provider
const apiKey =
provider === "perplexity"
? resolvePerplexityApiKey(perplexityConfig, perplexityBaseUrl)
: resolveSearchApiKey(search);
if (!apiKey) { if (!apiKey) {
return jsonResult(missingSearchKeyPayload()); return jsonResult(missingSearchKeyPayload(provider, perplexityBaseUrl));
} }
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const query = readStringParam(params, "query", { required: true }); const query = readStringParam(params, "query", { required: true });
@ -796,10 +1016,12 @@ export function createWebSearchTool(options?: {
apiKey, apiKey,
timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),
provider: resolveSearchProvider(search), provider,
country, country,
search_lang, search_lang,
ui_lang, ui_lang,
perplexityBaseUrl,
perplexityModel,
}); });
return jsonResult(result); return jsonResult(result);
}, },

View File

@ -97,12 +97,15 @@ async function promptWebToolsConfig(
): Promise<ClawdbotConfig> { ): Promise<ClawdbotConfig> {
const existingSearch = nextConfig.tools?.web?.search; const existingSearch = nextConfig.tools?.web?.search;
const existingFetch = nextConfig.tools?.web?.fetch; const existingFetch = nextConfig.tools?.web?.fetch;
const hasSearchKey = Boolean(existingSearch?.apiKey); const existingProvider = existingSearch?.provider === "perplexity" ? "perplexity" : "brave";
const hasBraveKey = Boolean(existingSearch?.apiKey);
const hasPerplexityKey = Boolean(existingSearch?.perplexity?.apiKey);
const hasSearchKey = existingProvider === "perplexity" ? hasPerplexityKey : hasBraveKey;
note( note(
[ [
"Web search lets your agent look things up online using the `web_search` tool.", "Web search lets your agent look things up online using the `web_search` tool.",
"It requires a Brave Search API key (you can store it in the config or set BRAVE_API_KEY in the Gateway environment).", "Choose Brave (structured results) or Perplexity Sonar (answers + citations).",
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n"), ].join("\n"),
"Web search", "Web search",
@ -110,7 +113,7 @@ async function promptWebToolsConfig(
const enableSearch = guardCancel( const enableSearch = guardCancel(
await confirm({ await confirm({
message: "Enable web_search (Brave Search)?", message: "Enable web_search?",
initialValue: existingSearch?.enabled ?? hasSearchKey, initialValue: existingSearch?.enabled ?? hasSearchKey,
}), }),
runtime, runtime,
@ -122,27 +125,84 @@ async function promptWebToolsConfig(
}; };
if (enableSearch) { if (enableSearch) {
const keyInput = guardCancel( const provider = guardCancel(
await text({ await select<"brave" | "perplexity">({
message: hasSearchKey message: "Web search provider",
? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)" options: [
: "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)", { value: "brave", label: "Brave Search (structured results)" },
placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...", { value: "perplexity", label: "Perplexity Sonar (answers + citations)" },
],
initialValue: existingProvider,
}), }),
runtime, runtime,
); );
const key = String(keyInput ?? "").trim();
if (key) { nextSearch = { ...nextSearch, provider };
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasSearchKey) { if (provider === "brave") {
note( const keyInput = guardCancel(
[ await text({
"No key stored yet, so web_search will stay unavailable.", message: hasBraveKey
"Store a key here or set BRAVE_API_KEY in the Gateway environment.", ? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)"
"Docs: https://docs.clawd.bot/tools/web", : "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)",
].join("\n"), placeholder: hasBraveKey ? "Leave blank to keep current" : "BSA...",
"Web search", }),
runtime,
); );
const key = String(keyInput ?? "").trim();
if (key) {
nextSearch = { ...nextSearch, apiKey: key };
} else if (!hasBraveKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set BRAVE_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
} else {
const existingPerplexity = existingSearch?.perplexity ?? {};
const keyInput = guardCancel(
await text({
message: hasPerplexityKey
? "Perplexity/OpenRouter API key (leave blank to keep current or use PERPLEXITY_API_KEY/OPENROUTER_API_KEY)"
: "Perplexity/OpenRouter API key (paste it here; leave blank to use PERPLEXITY_API_KEY/OPENROUTER_API_KEY)",
placeholder: hasPerplexityKey ? "Leave blank to keep current" : "pplx-... or sk-or-...",
}),
runtime,
);
const key = String(keyInput ?? "").trim();
let nextPerplexity = { ...existingPerplexity };
if (key) {
nextPerplexity = { ...nextPerplexity, apiKey: key };
}
const baseUrlInput = guardCancel(
await text({
message: "Perplexity base URL (optional; leave blank for default)",
placeholder: existingPerplexity.baseUrl ?? "https://api.perplexity.ai",
}),
runtime,
);
const baseUrl = String(baseUrlInput ?? "").trim();
if (baseUrl) {
nextPerplexity = { ...nextPerplexity, baseUrl };
}
nextSearch = { ...nextSearch, perplexity: nextPerplexity };
if (!key && !hasPerplexityKey) {
note(
[
"No key stored yet, so web_search will stay unavailable.",
"Store a key here or set PERPLEXITY_API_KEY/OPENROUTER_API_KEY in the Gateway environment.",
"Docs: https://docs.clawd.bot/tools/web",
].join("\n"),
"Web search",
);
}
} }
} }

View File

@ -123,7 +123,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
if (!opts.json) { if (!opts.json) {
runtime.log( runtime.log(
"Tip: run `clawdbot configure --section web` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web", "Tip: run `clawdbot configure --section web` to choose Brave or Perplexity and store your API key for web_search. Docs: https://docs.clawd.bot/tools/web",
); );
} }
} }

View File

@ -45,7 +45,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
runtime.log(`Remote gateway: ${remoteUrl}`); runtime.log(`Remote gateway: ${remoteUrl}`);
runtime.log(`Auth: ${payload.auth}`); runtime.log(`Auth: ${payload.auth}`);
runtime.log( runtime.log(
"Tip: run `clawdbot configure --section web` to store your Brave API key for web_search. Docs: https://docs.clawd.bot/tools/web", "Tip: run `clawdbot configure --section web` to choose Brave or Perplexity and store your API key for web_search. Docs: https://docs.clawd.bot/tools/web",
); );
} }
} }

View File

@ -315,9 +315,15 @@ const FIELD_HELP: Record<string, string> = {
"tools.message.crossContext.marker.suffix": "tools.message.crossContext.marker.suffix":
'Text suffix for cross-context markers (supports "{channel}").', 'Text suffix for cross-context markers (supports "{channel}").',
"tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.message.broadcast.enabled": "Enable broadcast action (default: true).",
"tools.web.search.enabled": "Enable the web_search tool (requires Brave API key).", "tools.web.search.enabled": "Enable the web_search tool (requires provider API key).",
"tools.web.search.provider": 'Search provider (only "brave" supported today).', "tools.web.search.provider": 'Search provider ("brave" or "perplexity").',
"tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"tools.web.search.perplexity.apiKey":
"Perplexity/OpenRouter API key (fallback: PERPLEXITY_API_KEY or OPENROUTER_API_KEY env vars).",
"tools.web.search.perplexity.baseUrl":
"Perplexity/OpenRouter base URL for web_search (defaults to Perplexity when PERPLEXITY_API_KEY is set, otherwise OpenRouter).",
"tools.web.search.perplexity.model":
'Perplexity Sonar model to use for web_search (default: "perplexity/sonar-pro").',
"tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.maxResults": "Default number of results to return (1-10).",
"tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.",
"tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.", "tools.web.search.cacheTtlMinutes": "Cache TTL in minutes for web_search results.",

View File

@ -222,8 +222,8 @@ export type ToolsConfig = {
search?: { search?: {
/** Enable web search tool (default: true when API key is present). */ /** Enable web search tool (default: true when API key is present). */
enabled?: boolean; enabled?: boolean;
/** Search provider (currently "brave"). */ /** Search provider ("brave" or "perplexity"). */
provider?: "brave"; provider?: "brave" | "perplexity";
/** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */ /** Brave Search API key (optional; defaults to BRAVE_API_KEY env var). */
apiKey?: string; apiKey?: string;
/** Default search results count (1-10). */ /** Default search results count (1-10). */
@ -232,6 +232,15 @@ export type ToolsConfig = {
timeoutSeconds?: number; timeoutSeconds?: number;
/** Cache TTL in minutes for search results. */ /** Cache TTL in minutes for search results. */
cacheTtlMinutes?: number; cacheTtlMinutes?: number;
/** Perplexity-specific configuration (used when provider="perplexity"). */
perplexity?: {
/** API key for Perplexity or OpenRouter (defaults to PERPLEXITY_API_KEY or OPENROUTER_API_KEY env var). */
apiKey?: string;
/** Base URL for API requests (defaults to Perplexity if PERPLEXITY_API_KEY is set, otherwise OpenRouter). */
baseUrl?: string;
/** Model to use (defaults to "perplexity/sonar-pro"). */
model?: string;
};
}; };
fetch?: { fetch?: {
/** Enable web fetch tool (default: true). */ /** Enable web fetch tool (default: true). */

View File

@ -375,29 +375,52 @@ export async function finalizeOnboardingWizard(options: FinalizeOnboardingOption
); );
} }
const webSearchKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim(); const webSearchProvider =
const webSearchEnv = (process.env.BRAVE_API_KEY ?? "").trim(); nextConfig.tools?.web?.search?.provider === "perplexity" ? "perplexity" : "brave";
const hasWebSearchKey = Boolean(webSearchKey || webSearchEnv); const braveKey = (nextConfig.tools?.web?.search?.apiKey ?? "").trim();
const braveEnv = (process.env.BRAVE_API_KEY ?? "").trim();
const perplexityKey = (nextConfig.tools?.web?.search?.perplexity?.apiKey ?? "").trim();
const perplexityEnv = (process.env.PERPLEXITY_API_KEY ?? "").trim();
const openRouterEnv = (process.env.OPENROUTER_API_KEY ?? "").trim();
const perplexityBaseUrl = (nextConfig.tools?.web?.search?.perplexity?.baseUrl ?? "").trim();
const hasWebSearchKey =
webSearchProvider === "perplexity"
? Boolean(perplexityKey || perplexityEnv || openRouterEnv)
: Boolean(braveKey || braveEnv);
await prompter.note( await prompter.note(
hasWebSearchKey hasWebSearchKey
? [ ? [
"Web search is enabled, so your agent can look things up online when needed.", "Web search is enabled, so your agent can look things up online when needed.",
"", "",
webSearchKey webSearchProvider === "perplexity"
? "API key: stored in config (tools.web.search.apiKey)." ? perplexityKey
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).", ? "API key: stored in config (tools.web.search.perplexity.apiKey)."
: perplexityBaseUrl.includes("openrouter.ai")
? "API key: provided via OPENROUTER_API_KEY env var (Gateway environment)."
: perplexityBaseUrl.includes("api.perplexity.ai")
? "API key: provided via PERPLEXITY_API_KEY env var (Gateway environment)."
: perplexityEnv
? "API key: provided via PERPLEXITY_API_KEY env var (Gateway environment)."
: "API key: provided via OPENROUTER_API_KEY env var (Gateway environment)."
: braveKey
? "API key: stored in config (tools.web.search.apiKey)."
: "API key: provided via BRAVE_API_KEY env var (Gateway environment).",
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n") ].join("\n")
: [ : [
"If you want your agent to be able to search the web, youll need an API key.", "If you want your agent to be able to search the web, youll need an API key.",
"", "",
"Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search wont work.", webSearchProvider === "perplexity"
? "Clawdbot can use Perplexity Sonar for the `web_search` tool. Without a Perplexity/OpenRouter API key, web search wont work."
: "Clawdbot uses Brave Search for the `web_search` tool. Without a Brave Search API key, web search wont work.",
"", "",
"Set it up interactively:", "Set it up interactively:",
"- Run: clawdbot configure --section web", "- Run: clawdbot configure --section web",
"- Enable web_search and paste your Brave Search API key", "- Enable web_search and paste your API key",
"", "",
"Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).", webSearchProvider === "perplexity"
? "Alternative: set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment (no config changes)."
: "Alternative: set BRAVE_API_KEY in the Gateway environment (no config changes).",
"Docs: https://docs.clawd.bot/tools/web", "Docs: https://docs.clawd.bot/tools/web",
].join("\n"), ].join("\n"),
"Web search (optional)", "Web search (optional)",