diff --git a/.github/labeler.yml b/.github/labeler.yml
index 8a359fea3b96..a72e1a427164 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -574,6 +574,10 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/openshell/**"
+"extensions: parallel":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/parallel/**"
"extensions: perplexity":
- changed-files:
- any-glob-to-any-file:
diff --git a/docs/docs.json b/docs/docs.json
index 221fc23f2f7b..ce35a56d47cf 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -1331,6 +1331,7 @@
"tools/kimi-search",
"tools/minimax-search",
"tools/ollama-search",
+ "tools/parallel-search",
"tools/perplexity-search",
"tools/searxng-search",
"tools/tavily"
diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md
index 14076aba19fa..b63bc4a13acf 100644
--- a/docs/reference/secretref-credential-surface.md
+++ b/docs/reference/secretref-credential-surface.md
@@ -50,6 +50,7 @@ Scope intent:
- `plugins.entries.firecrawl.config.webSearch.apiKey`
- `plugins.entries.minimax.config.webSearch.apiKey`
- `plugins.entries.tavily.config.webSearch.apiKey`
+- `plugins.entries.parallel.config.webSearch.apiKey`
- `plugins.entries.voice-call.config.realtime.providers.*.apiKey`
- `plugins.entries.voice-call.config.streaming.providers.*.apiKey`
- `plugins.entries.voice-call.config.tts.providers.*.apiKey`
diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json
index ee767548df19..c983df687009 100644
--- a/docs/reference/secretref-user-supplied-credentials-matrix.json
+++ b/docs/reference/secretref-user-supplied-credentials-matrix.json
@@ -589,6 +589,13 @@
"secretShape": "secret_input",
"optIn": true
},
+ {
+ "id": "plugins.entries.parallel.config.webSearch.apiKey",
+ "configFile": "openclaw.json",
+ "path": "plugins.entries.parallel.config.webSearch.apiKey",
+ "secretShape": "secret_input",
+ "optIn": true
+ },
{
"id": "plugins.entries.perplexity.config.webSearch.apiKey",
"configFile": "openclaw.json",
diff --git a/docs/tools/parallel-search.md b/docs/tools/parallel-search.md
new file mode 100644
index 000000000000..c5aedab543ee
--- /dev/null
+++ b/docs/tools/parallel-search.md
@@ -0,0 +1,127 @@
+---
+summary: "Parallel Search -- LLM-optimized dense excerpts from web sources"
+read_when:
+ - You want to use Parallel for web_search
+ - You need a PARALLEL_API_KEY
+ - You want dense excerpts ranked for LLM context efficiency
+title: "Parallel search"
+---
+
+OpenClaw supports [Parallel](https://parallel.ai/) as a `web_search` provider.
+Parallel returns ranked, LLM-optimized dense excerpts from a web index
+purpose-built for AI agents.
+
+## Get an API key
+
+
+
+ Sign up at [platform.parallel.ai](https://platform.parallel.ai) and
+ generate an API key from your dashboard.
+
+
+ Set `PARALLEL_API_KEY` in the Gateway environment, or configure via:
+
+ ```bash
+ openclaw configure --section web
+ ```
+
+
+
+
+## Config
+
+```json5
+{
+ plugins: {
+ entries: {
+ parallel: {
+ config: {
+ webSearch: {
+ apiKey: "par-...", // optional if PARALLEL_API_KEY is set
+ baseUrl: "https://api.parallel.ai", // optional; OpenClaw appends /v1/search
+ },
+ },
+ },
+ },
+ },
+ tools: {
+ web: {
+ search: {
+ provider: "parallel",
+ },
+ },
+ },
+}
+```
+
+**Environment alternative:** set `PARALLEL_API_KEY` in the Gateway environment.
+For a gateway install, put it in `~/.openclaw/.env`.
+
+## Base URL override
+
+Set `plugins.entries.parallel.config.webSearch.baseUrl` when Parallel requests
+should go through a compatible proxy or alternate Parallel endpoint (for
+example, the Cloudflare AI Gateway). OpenClaw normalizes bare hosts by
+prepending `https://` and appends `/v1/search` unless the path already ends
+there. The resolved endpoint is included in the search cache key, so results
+from different Parallel endpoints are not shared.
+
+## Tool parameters
+
+OpenClaw exposes Parallel's native search shape so the model can fill in both
+the natural-language goal and a few short keyword queries — the pairing
+Parallel [recommends](https://docs.parallel.ai/search/best-practices) for
+best results.
+
+
+Natural-language description of the underlying question or goal (max 5000
+chars). Should be self-contained.
+
+
+
+Concise keyword search queries, 3-6 words each (1-5 entries, max 200 chars
+each). Provide 2-3 diverse queries for best results.
+
+
+
+Results to return (1-40).
+
+
+
+Optional Parallel session id (max 1000 chars). Pass the `sessionId` from a
+previous Parallel result on follow-up searches that are part of the same task
+so Parallel can group related calls and improve subsequent results.
+
+
+
+Optional identifier of the model making the call (e.g. `claude-opus-4-7`,
+`gpt-5.5`). Lets Parallel tailor default settings for your model's
+capabilities. Pass the exact active model slug; do not shorten to a family
+alias.
+
+
+## Notes
+
+- Parallel ranks and compresses results based on LLM reasoning utility, not
+ human click-through; expect dense excerpts in each result rather than
+ full-page content
+- Result excerpts come back as the `excerpts` array and are also joined into
+ the `description` field for compatibility with the generic `web_search`
+ contract
+- Parallel returns a `session_id` on every response; OpenClaw surfaces it as
+ `sessionId` in the tool payload so callers can group follow-up searches
+- `searchId`, `warnings`, and `usage` from Parallel are passed through when
+ present
+- OpenClaw always forwards a resolved result count to Parallel as
+ `advanced_settings.max_results`. The caller's `count` arg wins, then the
+ top-level `tools.web.search.maxResults` setting, otherwise OpenClaw's
+ generic `web_search` default (5). This keeps result volume consistent
+ when switching between providers; Parallel on its own defaults to 10
+- Results are cached for 15 minutes by default (configurable via
+ `cacheTtlMinutes`)
+
+## Related
+
+- [Web Search overview](/tools/web) -- all providers and auto-detection
+- [Exa search](/tools/exa-search) -- neural search with content extraction
+- [Perplexity Search](/tools/perplexity-search) -- structured results with domain filtering
diff --git a/docs/tools/web.md b/docs/tools/web.md
index 9a958c416038..e107e3937512 100644
--- a/docs/tools/web.md
+++ b/docs/tools/web.md
@@ -84,6 +84,9 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
Search via a signed-in local Ollama host or the hosted Ollama API.
+
+ LLM-optimized dense excerpts from a web index purpose-built for AI agents.
+
Structured results with content extraction controls and domain filtering.
@@ -108,6 +111,7 @@ local while `web_search` and `x_search` can use xAI Responses under the hood.
| [Kimi](/tools/kimi-search) | AI-synthesized + citations; fails on ungrounded chat fallbacks | -- | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
| [MiniMax Search](/tools/minimax-search) | Structured snippets | Region (`global` / `cn`) | `MINIMAX_CODE_PLAN_KEY` / `MINIMAX_CODING_API_KEY` / `MINIMAX_OAUTH_TOKEN` |
| [Ollama Web Search](/tools/ollama-search) | Structured snippets | -- | None for signed-in local hosts; `OLLAMA_API_KEY` for direct `https://ollama.com` search |
+| [Parallel](/tools/parallel-search) | Dense excerpts ranked for LLM context | -- | `PARALLEL_API_KEY` |
| [Perplexity](/tools/perplexity-search) | Structured snippets | Country, language, time, domains, content limits | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
| [SearXNG](/tools/searxng-search) | Structured snippets | Categories, language | None (self-hosted) |
| [Tavily](/tools/tavily) | Structured snippets | Via `tavily_search` tool | `TAVILY_API_KEY` |
@@ -184,12 +188,13 @@ API-backed providers first:
7. **Firecrawl** -- `FIRECRAWL_API_KEY` or `plugins.entries.firecrawl.config.webSearch.apiKey` (order 60)
8. **Exa** -- `EXA_API_KEY` or `plugins.entries.exa.config.webSearch.apiKey`; optional `plugins.entries.exa.config.webSearch.baseUrl` overrides the Exa endpoint (order 65)
9. **Tavily** -- `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey` (order 70)
+10. **Parallel** -- `PARALLEL_API_KEY` or `plugins.entries.parallel.config.webSearch.apiKey`; optional `plugins.entries.parallel.config.webSearch.baseUrl` overrides the Parallel endpoint (order 75)
Key-free fallbacks after that:
-10. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100)
-11. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110)
-12. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
+11. **DuckDuckGo** -- key-free HTML fallback with no account or API key (order 100)
+12. **Ollama Web Search** -- key-free fallback via your configured local Ollama host when it is reachable and signed in with `ollama signin`; can reuse Ollama provider bearer auth when the host needs it, and can call direct `https://ollama.com` search when configured with `OLLAMA_API_KEY` (order 110)
+13. **SearXNG** -- `SEARXNG_BASE_URL` or `plugins.entries.searxng.config.webSearch.baseUrl` (order 200)
If no provider is detected, it falls back to Brave (you will get a missing-key
error prompting you to configure one).
@@ -198,7 +203,7 @@ error prompting you to configure one).
All provider key fields support SecretRef objects. Plugin-scoped SecretRefs
under `plugins.entries..config.webSearch.apiKey` are resolved for the
bundled API-backed web search providers, including Brave, Exa, Firecrawl,
- Gemini, Grok, Kimi, MiniMax, Perplexity, and Tavily,
+ Gemini, Grok, Kimi, MiniMax, Parallel, Perplexity, and Tavily,
whether the provider is picked explicitly via `tools.web.search.provider` or
selected through auto-detect. In auto-detect mode, OpenClaw resolves only the
selected provider key -- non-selected SecretRefs stay inactive, so you can
diff --git a/extensions/parallel/index.ts b/extensions/parallel/index.ts
new file mode 100644
index 000000000000..a8871aa469d7
--- /dev/null
+++ b/extensions/parallel/index.ts
@@ -0,0 +1,11 @@
+import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
+import { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js";
+
+export default definePluginEntry({
+ id: "parallel",
+ name: "Parallel Plugin",
+ description: "Bundled Parallel web search plugin",
+ register(api) {
+ api.registerWebSearchProvider(createParallelWebSearchProvider());
+ },
+});
diff --git a/extensions/parallel/openclaw.plugin.json b/extensions/parallel/openclaw.plugin.json
new file mode 100644
index 000000000000..13fd9e978da3
--- /dev/null
+++ b/extensions/parallel/openclaw.plugin.json
@@ -0,0 +1,50 @@
+{
+ "id": "parallel",
+ "activation": {
+ "onStartup": false
+ },
+ "setup": {
+ "providers": [
+ {
+ "id": "parallel",
+ "envVars": ["PARALLEL_API_KEY"]
+ }
+ ]
+ },
+ "uiHints": {
+ "webSearch.apiKey": {
+ "label": "Parallel API Key",
+ "help": "Parallel Search API key (fallback: PARALLEL_API_KEY env var).",
+ "sensitive": true,
+ "placeholder": "par-..."
+ },
+ "webSearch.baseUrl": {
+ "label": "Parallel Search Base URL",
+ "help": "Optional Parallel API base URL override. OpenClaw appends /v1/search when the URL does not already end there."
+ }
+ },
+ "contracts": {
+ "webSearchProviders": ["parallel"]
+ },
+ "configContracts": {
+ "compatibilityRuntimePaths": ["tools.web.search.apiKey"]
+ },
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "webSearch": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "apiKey": {
+ "type": ["string", "object"]
+ },
+ "baseUrl": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/parallel/package.json b/extensions/parallel/package.json
new file mode 100644
index 000000000000..6d369e73d118
--- /dev/null
+++ b/extensions/parallel/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@openclaw/parallel-plugin",
+ "version": "2026.5.21",
+ "private": true,
+ "description": "OpenClaw Parallel web search plugin",
+ "type": "module",
+ "devDependencies": {
+ "@openclaw/plugin-sdk": "workspace:*"
+ },
+ "openclaw": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/parallel/parallel.live.test.ts b/extensions/parallel/parallel.live.test.ts
new file mode 100644
index 000000000000..c7aef98132ca
--- /dev/null
+++ b/extensions/parallel/parallel.live.test.ts
@@ -0,0 +1,46 @@
+import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
+import { describe, expect, it } from "vitest";
+import { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js";
+
+const PARALLEL_API_KEY = process.env.PARALLEL_API_KEY?.trim() ?? "";
+const describeLive = isLiveTestEnabled() && PARALLEL_API_KEY.length > 0 ? describe : describe.skip;
+
+const PARALLEL_LIVE_TIMEOUT_MS = 120_000;
+
+describeLive("parallel plugin live", () => {
+ it(
+ "runs Parallel web search through the provider tool",
+ async () => {
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool?.({
+ config: {},
+ searchConfig: { parallel: { apiKey: PARALLEL_API_KEY } },
+ });
+ if (!tool) {
+ throw new Error("Expected Parallel provider tool");
+ }
+
+ const result = (await tool.execute({
+ objective:
+ "Find the OpenClaw GitHub repository and recent project activity for a quick smoke test.",
+ search_queries: ["openclaw github repository", "openclaw release notes"],
+ count: 3,
+ client_model: "claude-opus-4-7",
+ })) as {
+ provider?: string;
+ count?: number;
+ results?: Array<{ url?: string; title?: string }>;
+ sessionId?: string;
+ };
+
+ expect(result.provider).toBe("parallel");
+ expect(typeof result.count).toBe("number");
+ expect(Array.isArray(result.results)).toBe(true);
+ expect((result.results ?? []).length).toBeGreaterThan(0);
+ const first = result.results?.[0];
+ expect((first?.url ?? "").startsWith("http")).toBe(true);
+ expect(typeof result.sessionId).toBe("string");
+ },
+ PARALLEL_LIVE_TIMEOUT_MS,
+ );
+});
diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts
new file mode 100644
index 000000000000..507e76d1a566
--- /dev/null
+++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts
@@ -0,0 +1,443 @@
+import { createRequire } from "node:module";
+import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
+import {
+ buildSearchCacheKey,
+ DEFAULT_SEARCH_COUNT,
+ mergeScopedSearchConfig,
+ readCachedSearchPayload,
+ readConfiguredSecretString,
+ readNumberParam,
+ readProviderEnvValue,
+ readStringArrayParam,
+ readStringParam,
+ resolveProviderWebSearchPluginConfig,
+ resolveSearchCacheTtlMs,
+ resolveSearchTimeoutSeconds,
+ resolveSiteName,
+ type SearchConfigRecord,
+ withTrustedWebSearchEndpoint,
+ wrapWebContent,
+ writeCachedSearchPayload,
+} from "openclaw/plugin-sdk/provider-web-search";
+import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
+
+const PARALLEL_BASE_URL = "https://api.parallel.ai";
+const PARALLEL_SEARCH_PATHNAME = "/v1/search";
+const PARALLEL_MAX_SEARCH_COUNT = 40;
+// Parallel v1 Search caps each search_queries entry at 200 chars, the
+// objective field at 5000, and accepts up to 5 search queries. See
+// https://docs.parallel.ai/search/best-practices.
+const PARALLEL_MAX_SEARCH_QUERY_CHARS = 200;
+const PARALLEL_MAX_OBJECTIVE_CHARS = 5000;
+const PARALLEL_MAX_SEARCH_QUERIES = 5;
+const PARALLEL_SESSION_ID_MAX_LENGTH = 1000;
+const PARALLEL_CLIENT_MODEL_MAX_LENGTH = 100;
+
+const require = createRequire(import.meta.url);
+const PLUGIN_VERSION = readPluginPackageVersion({ require });
+const USER_AGENT = `openclaw-parallel/${PLUGIN_VERSION} (${process.platform})`;
+
+type ParallelConfig = {
+ apiKey?: string;
+ baseUrl?: string;
+};
+
+type ParallelSearchResult = {
+ title?: unknown;
+ url?: unknown;
+ publish_date?: unknown;
+ excerpts?: unknown;
+};
+
+type ParallelSearchResponse = {
+ search_id?: unknown;
+ session_id?: unknown;
+ results?: unknown;
+ warnings?: unknown;
+ usage?: unknown;
+};
+
+function resolveParallelConfig(searchConfig?: SearchConfigRecord): ParallelConfig {
+ const parallel = searchConfig?.parallel;
+ return parallel && typeof parallel === "object" && !Array.isArray(parallel)
+ ? (parallel as ParallelConfig)
+ : {};
+}
+
+function resolveParallelApiKey(parallel?: ParallelConfig): string | undefined {
+ return (
+ readConfiguredSecretString(parallel?.apiKey, "tools.web.search.parallel.apiKey") ??
+ readProviderEnvValue(["PARALLEL_API_KEY"])
+ );
+}
+
+function invalidBaseUrlPayload(value: string) {
+ return {
+ error: "invalid_base_url",
+ message: `plugins.entries.parallel.config.webSearch.baseUrl must be a valid http(s) URL. Got: ${value}`,
+ docs: "https://docs.openclaw.ai/tools/parallel-search",
+ };
+}
+
+function resolveParallelSearchEndpoint(
+ parallel?: ParallelConfig,
+): { endpoint: string } | { error: string; message: string; docs: string } {
+ const configured = normalizeOptionalString(parallel?.baseUrl);
+ if (!configured) {
+ return { endpoint: `${PARALLEL_BASE_URL}${PARALLEL_SEARCH_PATHNAME}` };
+ }
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(configured) && !/^https?:\/\//i.test(configured)) {
+ return invalidBaseUrlPayload(configured);
+ }
+ const candidate = /^https?:\/\//i.test(configured) ? configured : `https://${configured}`;
+ let parsed: URL;
+ try {
+ parsed = new URL(candidate);
+ } catch {
+ return invalidBaseUrlPayload(configured);
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return invalidBaseUrlPayload(configured);
+ }
+ const pathname = parsed.pathname.replace(/\/+$/, "");
+ parsed.pathname = pathname.endsWith(PARALLEL_SEARCH_PATHNAME)
+ ? pathname
+ : `${pathname === "" ? "" : pathname}${PARALLEL_SEARCH_PATHNAME}`;
+ parsed.hash = "";
+ return { endpoint: parsed.toString() };
+}
+
+function resolveParallelSearchCount(value: number): number {
+ return Math.max(1, Math.min(PARALLEL_MAX_SEARCH_COUNT, Math.floor(value)));
+}
+
+function normalizeParallelSessionId(value: string | undefined): string | undefined {
+ const trimmed = normalizeOptionalString(value);
+ return trimmed && trimmed.length <= PARALLEL_SESSION_ID_MAX_LENGTH ? trimmed : undefined;
+}
+
+function normalizeParallelObjective(value: string | undefined): string | undefined {
+ const trimmed = normalizeOptionalString(value);
+ if (!trimmed) {
+ return undefined;
+ }
+ return trimmed.length <= PARALLEL_MAX_OBJECTIVE_CHARS
+ ? trimmed
+ : trimmed.slice(0, PARALLEL_MAX_OBJECTIVE_CHARS);
+}
+
+function normalizeParallelClientModel(value: string | undefined): string | undefined {
+ const trimmed = normalizeOptionalString(value);
+ if (!trimmed) {
+ return undefined;
+ }
+ return trimmed.length <= PARALLEL_CLIENT_MODEL_MAX_LENGTH
+ ? trimmed
+ : trimmed.slice(0, PARALLEL_CLIENT_MODEL_MAX_LENGTH);
+}
+
+// Parallel's API caps each entry at 200 chars and accepts up to 5 queries.
+// We trim, drop empties/duplicates, truncate over-long entries to the API's
+// hard limit, and cap to the API's maximum so a malformed call from the
+// model doesn't 422 the request. See https://docs.parallel.ai/search/best-practices.
+function normalizeParallelSearchQueries(value: unknown): string[] {
+ const candidates = Array.isArray(value) ? value : [];
+ const seen = new Set();
+ const out: string[] = [];
+ for (const entry of candidates) {
+ if (typeof entry !== "string") {
+ continue;
+ }
+ const trimmed = entry.trim();
+ if (!trimmed) {
+ continue;
+ }
+ const capped =
+ trimmed.length <= PARALLEL_MAX_SEARCH_QUERY_CHARS
+ ? trimmed
+ : trimmed.slice(0, PARALLEL_MAX_SEARCH_QUERY_CHARS);
+ if (seen.has(capped)) {
+ continue;
+ }
+ seen.add(capped);
+ out.push(capped);
+ if (out.length === PARALLEL_MAX_SEARCH_QUERIES) {
+ break;
+ }
+ }
+ return out;
+}
+
+function invalidSearchQueriesPayload() {
+ return {
+ error: "invalid_search_queries",
+ message:
+ "search_queries must be a non-empty array of keyword strings (max 5, max 200 chars each). See https://docs.parallel.ai/search/best-practices.",
+ docs: "https://docs.openclaw.ai/tools/parallel-search",
+ };
+}
+
+function normalizeParallelResults(payload: unknown): ParallelSearchResult[] {
+ if (!payload || typeof payload !== "object") {
+ return [];
+ }
+ const results = (payload as ParallelSearchResponse).results;
+ if (!Array.isArray(results)) {
+ return [];
+ }
+ return results.filter((entry): entry is ParallelSearchResult =>
+ Boolean(entry && typeof entry === "object" && !Array.isArray(entry)),
+ );
+}
+
+function buildParallelCacheKey(params: {
+ endpoint: string;
+ objective?: string;
+ searchQueries: readonly string[];
+ count: number;
+ sessionId?: string;
+ clientModel?: string;
+}): string {
+ return buildSearchCacheKey([
+ "parallel",
+ params.endpoint,
+ params.objective,
+ // Search queries are already normalized (trimmed, deduped, length-capped,
+ // ≤5) before reaching the cache key so the join is stable.
+ params.searchQueries.join(""),
+ params.count,
+ // Different Parallel sessions can return different ranked excerpts for
+ // the same query set, so partition cached payloads by caller-provided
+ // session.
+ params.sessionId,
+ // Parallel tailors defaults/optimizations to client_model per its docs,
+ // so partition cached payloads by it; otherwise two models hitting the
+ // same query inside the cache TTL would silently share ranked excerpts.
+ params.clientModel,
+ ]);
+}
+
+function missingParallelKeyPayload() {
+ return {
+ error: "missing_parallel_api_key",
+ message:
+ "web_search (parallel) needs a Parallel API key. Set PARALLEL_API_KEY in the Gateway environment, or configure plugins.entries.parallel.config.webSearch.apiKey.",
+ docs: "https://docs.openclaw.ai/tools/parallel-search",
+ };
+}
+
+async function runParallelSearch(params: {
+ apiKey: string;
+ endpoint: string;
+ objective?: string;
+ searchQueries: readonly string[];
+ maxResults: number;
+ sessionId?: string;
+ clientModel?: string;
+ timeoutSeconds: number;
+}): Promise {
+ const body: Record = {
+ search_queries: [...params.searchQueries],
+ advanced_settings: { max_results: params.maxResults },
+ };
+ if (params.objective) {
+ body.objective = params.objective;
+ }
+ if (params.sessionId) {
+ body.session_id = params.sessionId;
+ }
+ if (params.clientModel) {
+ body.client_model = params.clientModel;
+ }
+
+ return withTrustedWebSearchEndpoint(
+ {
+ url: params.endpoint,
+ timeoutSeconds: params.timeoutSeconds,
+ init: {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ "x-api-key": params.apiKey,
+ "User-Agent": USER_AGENT,
+ },
+ body: JSON.stringify(body),
+ },
+ },
+ async (res) => {
+ if (!res.ok) {
+ const detail = await res.text().catch(() => "");
+ throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
+ }
+ try {
+ return (await res.json()) as ParallelSearchResponse;
+ } catch (cause) {
+ throw new Error("Parallel API returned malformed JSON", { cause });
+ }
+ },
+ );
+}
+
+export async function executeParallelWebSearchProviderTool(
+ ctx: { config?: Record; searchConfig?: SearchConfigRecord },
+ args: Record,
+): Promise> {
+ const searchConfig = mergeScopedSearchConfig(
+ ctx.searchConfig,
+ "parallel",
+ resolveProviderWebSearchPluginConfig(ctx.config, "parallel"),
+ ) as SearchConfigRecord | undefined;
+ const parallelConfig = resolveParallelConfig(searchConfig);
+ const apiKey = resolveParallelApiKey(parallelConfig);
+ if (!apiKey) {
+ return missingParallelKeyPayload();
+ }
+ const endpointResult = resolveParallelSearchEndpoint(parallelConfig);
+ if ("error" in endpointResult) {
+ return endpointResult;
+ }
+ const endpoint = endpointResult.endpoint;
+
+ // Generic `query` arg fallback: openclaw's operator-facing CLI
+ // (`openclaw capability web.search ...`) always passes the shared
+ // lowest-common-denominator shape `{ query, count, limit }` to whatever
+ // provider is active and doesn't know about Parallel's richer
+ // `{ objective, search_queries }` schema. When `search_queries` is absent
+ // we promote `query` into the lone search query. `objective` stays unset
+ // in that case rather than being faked from the keyword string — Parallel
+ // documents `objective` as natural-language intent and treats it as
+ // optional, so leaving it absent is more honest than reusing the keyword.
+ // Agent callers that supply `objective`/`search_queries` explicitly take
+ // precedence, and the documented schema still requires the native pair so
+ // the model is encouraged to provide both.
+ const objective = normalizeParallelObjective(readStringParam(args, "objective"));
+ const cliQuery = normalizeParallelObjective(readStringParam(args, "query"));
+ let searchQueries = normalizeParallelSearchQueries(readStringArrayParam(args, "search_queries"));
+ if (searchQueries.length === 0 && cliQuery) {
+ searchQueries = normalizeParallelSearchQueries([cliQuery]);
+ }
+ if (searchQueries.length === 0) {
+ return invalidSearchQueriesPayload();
+ }
+ const requestedCount =
+ readNumberParam(args, "count", { integer: true }) ??
+ (typeof searchConfig?.maxResults === "number" ? searchConfig.maxResults : undefined);
+ // Always pass max_results so Parallel matches the openclaw web_search
+ // default of 5 instead of falling back to Parallel's own default of 10.
+ // Switching providers should not silently change result volume / token cost.
+ const count = resolveParallelSearchCount(requestedCount ?? DEFAULT_SEARCH_COUNT);
+ const sessionId = normalizeParallelSessionId(readStringParam(args, "session_id"));
+ const clientModel = normalizeParallelClientModel(readStringParam(args, "client_model"));
+ const cacheKey = buildParallelCacheKey({
+ endpoint,
+ objective,
+ searchQueries,
+ count,
+ sessionId,
+ clientModel,
+ });
+ const cached = readCachedSearchPayload(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const start = Date.now();
+ const response = await runParallelSearch({
+ apiKey,
+ endpoint,
+ objective,
+ searchQueries,
+ maxResults: count,
+ sessionId,
+ clientModel,
+ timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
+ });
+ const results = normalizeParallelResults(response).map((entry) => {
+ const title = typeof entry.title === "string" ? entry.title : "";
+ const url = typeof entry.url === "string" ? entry.url : "";
+ const published =
+ typeof entry.publish_date === "string" && entry.publish_date ? entry.publish_date : undefined;
+ const excerpts = Array.isArray(entry.excerpts)
+ ? entry.excerpts
+ .filter((e): e is string => typeof e === "string")
+ .map((e) => wrapWebContent(e, "web_search"))
+ : [];
+ const description = excerpts.join("\n\n");
+ return Object.assign(
+ {
+ title: title ? wrapWebContent(title, "web_search") : "",
+ url,
+ description,
+ siteName: resolveSiteName(url) || undefined,
+ },
+ published ? { published } : {},
+ excerpts.length > 0 ? { excerpts } : {},
+ );
+ });
+
+ const payload: Record = {
+ ...(objective ? { objective } : {}),
+ searchQueries,
+ provider: "parallel",
+ count: results.length,
+ tookMs: Date.now() - start,
+ externalContent: {
+ untrusted: true,
+ source: "web_search",
+ provider: "parallel",
+ wrapped: true,
+ },
+ results,
+ };
+ if (typeof response.search_id === "string") {
+ payload.searchId = response.search_id;
+ }
+ if (typeof response.session_id === "string") {
+ payload.sessionId = response.session_id;
+ }
+ if (Array.isArray(response.warnings) && response.warnings.length > 0) {
+ payload.warnings = response.warnings;
+ }
+ if (Array.isArray(response.usage) && response.usage.length > 0) {
+ payload.usage = response.usage;
+ }
+
+ // Don't persist a Parallel-generated session id into the shared cache:
+ // identical queries from unrelated tasks would otherwise share that id and
+ // an agent threading `sessionId` into follow-ups would group unrelated
+ // tasks on Parallel's side. Caller-supplied session ids are already part
+ // of the cache key, so a cache-hit will only ever return the matching id.
+ const cachePayload = sessionId ? payload : stripParallelGeneratedSessionId(payload);
+ writeCachedSearchPayload(cacheKey, cachePayload, resolveSearchCacheTtlMs(searchConfig));
+ return payload;
+}
+
+function stripParallelGeneratedSessionId(
+ payload: Record,
+): Record {
+ if (!("sessionId" in payload)) {
+ return payload;
+ }
+ const { sessionId: _omitted, ...rest } = payload;
+ void _omitted;
+ return rest;
+}
+
+export const testing = {
+ buildParallelCacheKey,
+ invalidSearchQueriesPayload,
+ missingParallelKeyPayload,
+ normalizeParallelClientModel,
+ normalizeParallelObjective,
+ normalizeParallelResults,
+ normalizeParallelSearchQueries,
+ normalizeParallelSessionId,
+ resolveParallelApiKey,
+ resolveParallelConfig,
+ resolveParallelSearchCount,
+ resolveParallelSearchEndpoint,
+ USER_AGENT,
+} as const;
+
+export { testing as __testing };
diff --git a/extensions/parallel/src/parallel-web-search-provider.shared.ts b/extensions/parallel/src/parallel-web-search-provider.shared.ts
new file mode 100644
index 000000000000..6eb58563ea93
--- /dev/null
+++ b/extensions/parallel/src/parallel-web-search-provider.shared.ts
@@ -0,0 +1,26 @@
+import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract";
+
+const PARALLEL_CREDENTIAL_PATH = "plugins.entries.parallel.config.webSearch.apiKey";
+const PARALLEL_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"];
+
+export function createParallelWebSearchProviderBase() {
+ return {
+ id: "parallel",
+ label: "Parallel Search",
+ hint: "LLM-optimized dense excerpts from web sources",
+ onboardingScopes: [...PARALLEL_ONBOARDING_SCOPES],
+ credentialLabel: "Parallel API key",
+ envVars: ["PARALLEL_API_KEY"],
+ placeholder: "par-...",
+ signupUrl: "https://platform.parallel.ai",
+ docsUrl: "https://docs.openclaw.ai/tools/parallel-search",
+ autoDetectOrder: 75,
+ credentialPath: PARALLEL_CREDENTIAL_PATH,
+ ...createWebSearchProviderContractFields({
+ credentialPath: PARALLEL_CREDENTIAL_PATH,
+ searchCredential: { type: "scoped", scopeId: "parallel" },
+ configuredCredential: { pluginId: "parallel" },
+ selectionPluginId: "parallel",
+ }),
+ };
+}
diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts
new file mode 100644
index 000000000000..1373bc2efac4
--- /dev/null
+++ b/extensions/parallel/src/parallel-web-search-provider.test.ts
@@ -0,0 +1,603 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+type EndpointCall = {
+ url: string;
+ timeoutSeconds: number;
+ init: RequestInit;
+};
+
+const endpointMockState = vi.hoisted(() => ({
+ calls: [] as EndpointCall[],
+ responses: [] as Response[],
+}));
+
+vi.mock("openclaw/plugin-sdk/provider-web-search", async (importOriginal) => {
+ const actual = await importOriginal();
+ const runEndpoint = async (
+ params: EndpointCall,
+ run: (response: Response) => Promise,
+ ) => {
+ endpointMockState.calls.push(params);
+ const response = endpointMockState.responses.shift();
+ if (!response) {
+ throw new Error("Missing mocked Parallel response.");
+ }
+ return await run(response);
+ };
+ return {
+ ...actual,
+ withTrustedWebSearchEndpoint: vi.fn(runEndpoint),
+ };
+});
+
+function readMockedBody(call: EndpointCall | undefined): unknown {
+ if (!call || typeof call.init.body !== "string") {
+ throw new Error("Expected mocked Parallel request to carry a JSON string body.");
+ }
+ return JSON.parse(call.init.body);
+}
+
+import { testing } from "../test-api.js";
+import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
+import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
+
+describe("parallel web search provider", () => {
+ beforeEach(() => {
+ endpointMockState.calls = [];
+ endpointMockState.responses = [];
+ });
+
+ it("exposes the expected metadata and selection wiring", () => {
+ const provider = createParallelWebSearchProvider();
+ if (!provider.applySelectionConfig) {
+ throw new Error("Expected applySelectionConfig to be defined");
+ }
+ const applied = provider.applySelectionConfig({});
+
+ expect(provider.id).toBe("parallel");
+ expect(provider.onboardingScopes).toEqual(["text-inference"]);
+ expect(provider.credentialPath).toBe("plugins.entries.parallel.config.webSearch.apiKey");
+ const pluginEntry = applied.plugins?.entries?.parallel;
+ if (!pluginEntry) {
+ throw new Error("expected Parallel plugin entry");
+ }
+ expect(pluginEntry.enabled).toBe(true);
+ });
+
+ it("keeps the lightweight contract surface aligned with provider metadata", () => {
+ const provider = createParallelWebSearchProvider();
+ const contractProvider = createContractParallelWebSearchProvider();
+ if (!contractProvider.applySelectionConfig) {
+ throw new Error("Expected contract applySelectionConfig to be defined");
+ }
+ const applied = contractProvider.applySelectionConfig({});
+
+ expect({
+ id: contractProvider.id,
+ label: contractProvider.label,
+ hint: contractProvider.hint,
+ onboardingScopes: contractProvider.onboardingScopes,
+ credentialLabel: contractProvider.credentialLabel,
+ envVars: contractProvider.envVars,
+ placeholder: contractProvider.placeholder,
+ signupUrl: contractProvider.signupUrl,
+ docsUrl: contractProvider.docsUrl,
+ autoDetectOrder: contractProvider.autoDetectOrder,
+ credentialPath: contractProvider.credentialPath,
+ }).toEqual({
+ id: provider.id,
+ label: provider.label,
+ hint: provider.hint,
+ onboardingScopes: provider.onboardingScopes,
+ credentialLabel: provider.credentialLabel,
+ envVars: provider.envVars,
+ placeholder: provider.placeholder,
+ signupUrl: provider.signupUrl,
+ docsUrl: provider.docsUrl,
+ autoDetectOrder: provider.autoDetectOrder,
+ credentialPath: provider.credentialPath,
+ });
+ expect(contractProvider.createTool({ config: {}, searchConfig: {} })).toBeNull();
+ const pluginEntry = applied.plugins?.entries?.parallel;
+ if (!pluginEntry) {
+ throw new Error("expected contract Parallel plugin entry");
+ }
+ expect(pluginEntry.enabled).toBe(true);
+ });
+
+ it("prefers scoped configured api keys over environment fallbacks", () => {
+ expect(testing.resolveParallelApiKey({ apiKey: "par-secret" })).toBe("par-secret");
+ });
+
+ it("resolves Parallel search base URL overrides", () => {
+ expect(testing.resolveParallelSearchEndpoint()).toEqual({
+ endpoint: "https://api.parallel.ai/v1/search",
+ });
+ expect(
+ testing.resolveParallelSearchEndpoint({ baseUrl: "https://proxy.example/parallel" }),
+ ).toEqual({
+ endpoint: "https://proxy.example/parallel/v1/search",
+ });
+ expect(
+ testing.resolveParallelSearchEndpoint({ baseUrl: "proxy.example/parallel/v1/search/" }),
+ ).toEqual({
+ endpoint: "https://proxy.example/parallel/v1/search",
+ });
+ expect(
+ testing.resolveParallelSearchEndpoint({ baseUrl: "ftp://proxy.example/parallel" }),
+ ).toEqual({
+ docs: "https://docs.openclaw.ai/tools/parallel-search",
+ error: "invalid_base_url",
+ message:
+ "plugins.entries.parallel.config.webSearch.baseUrl must be a valid http(s) URL. Got: ftp://proxy.example/parallel",
+ });
+ });
+
+ it("partitions Parallel cache keys by resolved endpoint", () => {
+ const base = {
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ count: 5,
+ };
+ expect(
+ testing.buildParallelCacheKey({
+ ...base,
+ endpoint: "https://api.parallel.ai/v1/search",
+ }),
+ ).not.toBe(
+ testing.buildParallelCacheKey({
+ ...base,
+ endpoint: "https://proxy.example/parallel/v1/search",
+ }),
+ );
+ });
+
+ it("partitions Parallel cache keys by resolved result count", () => {
+ const base = {
+ endpoint: "https://api.parallel.ai/v1/search",
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ };
+ expect(testing.buildParallelCacheKey({ ...base, count: 5 })).not.toBe(
+ testing.buildParallelCacheKey({ ...base, count: 10 }),
+ );
+ });
+
+ it("partitions Parallel cache keys by objective and by search_queries set", () => {
+ const base = {
+ endpoint: "https://api.parallel.ai/v1/search",
+ count: 5,
+ };
+ expect(
+ testing.buildParallelCacheKey({
+ ...base,
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ }),
+ ).not.toBe(
+ testing.buildParallelCacheKey({
+ ...base,
+ objective: "Find the OpenClaw release notes",
+ searchQueries: ["openclaw github"],
+ }),
+ );
+ expect(
+ testing.buildParallelCacheKey({
+ ...base,
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ }),
+ ).not.toBe(
+ testing.buildParallelCacheKey({
+ ...base,
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github", "openclaw repository"],
+ }),
+ );
+ });
+
+ it("partitions Parallel cache keys by caller-provided session id", () => {
+ const base = {
+ endpoint: "https://api.parallel.ai/v1/search",
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ count: 5,
+ };
+ expect(testing.buildParallelCacheKey({ ...base, sessionId: "session-a" })).not.toBe(
+ testing.buildParallelCacheKey({ ...base, sessionId: "session-b" }),
+ );
+ expect(testing.buildParallelCacheKey({ ...base })).not.toBe(
+ testing.buildParallelCacheKey({ ...base, sessionId: "session-a" }),
+ );
+ });
+
+ it("partitions Parallel cache keys by client_model so per-model results never bleed", () => {
+ const base = {
+ endpoint: "https://api.parallel.ai/v1/search",
+ objective: "Find OpenClaw on GitHub",
+ searchQueries: ["openclaw github"],
+ count: 5,
+ };
+ expect(testing.buildParallelCacheKey({ ...base, clientModel: "claude-opus-4-7" })).not.toBe(
+ testing.buildParallelCacheKey({ ...base, clientModel: "gpt-5.5" }),
+ );
+ expect(testing.buildParallelCacheKey({ ...base })).not.toBe(
+ testing.buildParallelCacheKey({ ...base, clientModel: "claude-opus-4-7" }),
+ );
+ });
+
+ it("normalizes objectives by trimming and capping at 5000 chars", () => {
+ expect(testing.normalizeParallelObjective(" Find OpenClaw ")).toBe("Find OpenClaw");
+ expect(testing.normalizeParallelObjective(undefined)).toBeUndefined();
+ expect(testing.normalizeParallelObjective("")).toBeUndefined();
+ expect((testing.normalizeParallelObjective("x".repeat(6000)) ?? "").length).toBe(5000);
+ });
+
+ it("normalizes search_queries: trim, drop blanks, dedupe, cap length, cap count", () => {
+ expect(
+ testing.normalizeParallelSearchQueries([
+ "openclaw github",
+ " openclaw github ",
+ "",
+ " ",
+ 42,
+ "openclaw releases",
+ ]),
+ ).toEqual(["openclaw github", "openclaw releases"]);
+ expect(testing.normalizeParallelSearchQueries(undefined)).toEqual([]);
+ expect(testing.normalizeParallelSearchQueries("openclaw github")).toEqual([]);
+ expect(testing.normalizeParallelSearchQueries(["x".repeat(250)])).toEqual(["x".repeat(200)]);
+ const six = ["a", "b", "c", "d", "e", "f"];
+ expect(testing.normalizeParallelSearchQueries(six)).toEqual(["a", "b", "c", "d", "e"]);
+ });
+
+ it("normalizes session ids, rejecting blanks and overlong values", () => {
+ expect(testing.normalizeParallelSessionId("session-abc")).toBe("session-abc");
+ expect(testing.normalizeParallelSessionId(" ")).toBeUndefined();
+ expect(testing.normalizeParallelSessionId(undefined)).toBeUndefined();
+ expect(testing.normalizeParallelSessionId("x".repeat(1001))).toBeUndefined();
+ });
+
+ it("normalizes client_model identifiers", () => {
+ expect(testing.normalizeParallelClientModel("claude-opus-4-7")).toBe("claude-opus-4-7");
+ expect(testing.normalizeParallelClientModel(" gpt-5.5 ")).toBe("gpt-5.5");
+ expect(testing.normalizeParallelClientModel(undefined)).toBeUndefined();
+ expect((testing.normalizeParallelClientModel("a".repeat(200)) ?? "").length).toBe(100);
+ });
+
+ it("normalizes the Parallel /v1/search response shape", () => {
+ expect(
+ testing.normalizeParallelResults({
+ results: [
+ {
+ url: "https://example.com/a",
+ title: "Sample",
+ publish_date: "2026-04-01",
+ excerpts: ["first", "second"],
+ },
+ "not-an-object",
+ ],
+ }),
+ ).toEqual([
+ {
+ url: "https://example.com/a",
+ title: "Sample",
+ publish_date: "2026-04-01",
+ excerpts: ["first", "second"],
+ },
+ ]);
+ expect(testing.normalizeParallelResults({})).toEqual([]);
+ expect(testing.normalizeParallelResults(null)).toEqual([]);
+ });
+
+ it("clamps Parallel result counts to the documented 1-40 range", () => {
+ expect(testing.resolveParallelSearchCount(5)).toBe(5);
+ expect(testing.resolveParallelSearchCount(120)).toBe(40);
+ expect(testing.resolveParallelSearchCount(0)).toBe(1);
+ });
+
+ it("returns a stable missing-key payload that points at the real config path", () => {
+ expect(testing.missingParallelKeyPayload()).toEqual({
+ error: "missing_parallel_api_key",
+ message:
+ "web_search (parallel) needs a Parallel API key. Set PARALLEL_API_KEY in the Gateway environment, or configure plugins.entries.parallel.config.webSearch.apiKey.",
+ docs: "https://docs.openclaw.ai/tools/parallel-search",
+ });
+ });
+
+ it("identifies the plugin via a versioned User-Agent header", () => {
+ expect(testing.USER_AGENT).toMatch(/^openclaw-parallel\/\d+\.\d+\.\d+/);
+ });
+
+ it("treats objective as optional and omits it from the request when absent", async () => {
+ // Parallel's `/v1/search` API documents `objective` as `string | null`.
+ // When agent callers only supply `search_queries`, the runtime should not
+ // synthesize an objective from the keyword phrase (that would misrepresent
+ // intent); it should simply leave the field out of the request body.
+ endpointMockState.responses.push(
+ new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ const result = await tool.execute({ search_queries: ["openclaw"] });
+ expect(endpointMockState.calls).toHaveLength(1);
+ const body = readMockedBody(endpointMockState.calls[0]) as Record;
+ expect(body).not.toHaveProperty("objective");
+ expect(body).toMatchObject({ search_queries: ["openclaw"] });
+ expect(result).not.toHaveProperty("objective");
+ expect(result).toMatchObject({ provider: "parallel" });
+ });
+
+ it("returns an error payload when search_queries is missing or empty", async () => {
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ expect(await tool.execute({ objective: "Find OpenClaw on GitHub" })).toMatchObject({
+ error: "invalid_search_queries",
+ });
+ expect(
+ await tool.execute({ objective: "Find OpenClaw on GitHub", search_queries: [] }),
+ ).toMatchObject({ error: "invalid_search_queries" });
+ expect(endpointMockState.calls).toHaveLength(0);
+ });
+
+ it("promotes a generic `query` arg into search_queries when search_queries is absent (no synthesized objective)", async () => {
+ // The operator CLI (`openclaw capability web.search`) always sends the
+ // shared lowest-common-denominator shape `{ query, count, limit }` and
+ // doesn't know about provider-specific schemas. The runtime promotes
+ // `query` into the lone `search_queries` entry so that CLI keeps working
+ // when Parallel is the active provider. `objective` is *not* synthesized
+ // from the keyword phrase — Parallel treats it as optional natural-language
+ // intent and reusing a keyword as objective would misrepresent intent.
+ endpointMockState.responses.push(
+ new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ const result = await tool.execute({ query: "OpenClaw GitHub", count: 3 });
+ expect(endpointMockState.calls).toHaveLength(1);
+ const body = readMockedBody(endpointMockState.calls[0]) as Record;
+ expect(body).not.toHaveProperty("objective");
+ expect(body).toMatchObject({
+ search_queries: ["OpenClaw GitHub"],
+ advanced_settings: { max_results: 3 },
+ });
+ expect(result).not.toHaveProperty("objective");
+ expect(result).toMatchObject({ provider: "parallel" });
+ });
+
+ it("prefers explicit objective+search_queries over the generic `query` fallback when all are present", async () => {
+ endpointMockState.responses.push(
+ new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ await tool.execute({
+ objective: "Native objective",
+ search_queries: ["native query"],
+ query: "legacy fallback",
+ });
+ const body = readMockedBody(endpointMockState.calls[0]) as Record;
+ expect(body).toMatchObject({
+ objective: "Native objective",
+ search_queries: ["native query"],
+ });
+ });
+
+ it("honors top-level web search settings and sends the native Parallel payload shape", async () => {
+ endpointMockState.responses.push(
+ new Response(
+ JSON.stringify({
+ search_id: "search_test",
+ session_id: "session_test",
+ results: [{ url: "https://example.com/a", title: "A", excerpts: ["alpha"] }],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: {
+ parallel: { apiKey: "par-secret" },
+ maxResults: 3,
+ timeoutSeconds: 5,
+ },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ const result = await tool.execute({
+ objective: "Find the OpenClaw repository on GitHub",
+ search_queries: ["openclaw github", "openclaw repository"],
+ });
+
+ expect(endpointMockState.calls).toHaveLength(1);
+ const [call] = endpointMockState.calls;
+ expect(call.url).toBe("https://api.parallel.ai/v1/search");
+ expect(call.timeoutSeconds).toBe(5);
+ expect(readMockedBody(call)).toEqual({
+ objective: "Find the OpenClaw repository on GitHub",
+ search_queries: ["openclaw github", "openclaw repository"],
+ advanced_settings: { max_results: 3 },
+ });
+ const headers = (call.init.headers ?? {}) as Record;
+ expect(headers["x-api-key"]).toBe("par-secret");
+ expect(headers["User-Agent"]).toMatch(/^openclaw-parallel\//);
+ expect(result).toMatchObject({
+ provider: "parallel",
+ searchId: "search_test",
+ sessionId: "session_test",
+ });
+ });
+
+ it("threads caller-supplied session_id and client_model through to Parallel", async () => {
+ endpointMockState.responses.push(
+ new Response(
+ JSON.stringify({
+ search_id: "search_test",
+ session_id: "session-caller-supplied",
+ results: [],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ const result = await tool.execute({
+ objective: "Find the OpenClaw repository on GitHub",
+ search_queries: ["openclaw github"],
+ session_id: "session-caller-supplied",
+ client_model: "claude-opus-4-7",
+ });
+ const body = readMockedBody(endpointMockState.calls[0]) as Record;
+ expect(body).toMatchObject({
+ objective: "Find the OpenClaw repository on GitHub",
+ search_queries: ["openclaw github"],
+ session_id: "session-caller-supplied",
+ client_model: "claude-opus-4-7",
+ });
+ expect(result).toMatchObject({ sessionId: "session-caller-supplied" });
+ });
+
+ it("always sends max_results matching the OpenClaw web_search default when no count is provided", async () => {
+ endpointMockState.responses.push(
+ new Response(JSON.stringify({ search_id: "x", session_id: "y", results: [] }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ await tool.execute({
+ objective: "Find OpenClaw",
+ search_queries: ["openclaw"],
+ });
+ expect(endpointMockState.calls).toHaveLength(1);
+ const body = readMockedBody(endpointMockState.calls[0]) as {
+ advanced_settings?: { max_results?: number };
+ };
+ // OpenClaw's web_search default is 5 results; Parallel's own default is 10.
+ // Sending an explicit max_results keeps result volume consistent across providers.
+ expect(body.advanced_settings?.max_results).toBe(5);
+ });
+
+ it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
+ // Unique objective so this test does not collide with the SDK's
+ // module-level web-search cache across other cases.
+ const objective = `parallel-cache-isolation-${Date.now()}-${Math.random()}`;
+ endpointMockState.responses.push(
+ new Response(
+ JSON.stringify({
+ search_id: "first",
+ session_id: "session-generated-by-parallel",
+ results: [],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ const firstResult = (await tool.execute({
+ objective,
+ search_queries: ["openclaw github"],
+ })) as { sessionId?: string };
+ expect(firstResult.sessionId).toBe("session-generated-by-parallel");
+
+ // Second identical call without a caller-supplied session_id must hit the
+ // cache (no second HTTP call) and must NOT leak the first task's
+ // auto-generated sessionId — otherwise an agent threading it back into
+ // follow-up calls would group unrelated tasks on Parallel's side.
+ const secondResult = (await tool.execute({
+ objective,
+ search_queries: ["openclaw github"],
+ })) as { sessionId?: string };
+ expect(endpointMockState.calls).toHaveLength(1);
+ expect(secondResult.sessionId).toBeUndefined();
+ });
+
+ it("preserves caller-supplied sessionId across cache hits", async () => {
+ const objective = `parallel-cache-session-${Date.now()}-${Math.random()}`;
+ const sessionId = `session-${Date.now()}`;
+ endpointMockState.responses.push(
+ new Response(
+ JSON.stringify({
+ search_id: "first",
+ session_id: sessionId,
+ results: [],
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ const provider = createParallelWebSearchProvider();
+ const tool = provider.createTool({
+ config: {},
+ searchConfig: { parallel: { apiKey: "par-secret" } },
+ });
+ if (!tool) {
+ throw new Error("Expected tool definition");
+ }
+ await tool.execute({
+ objective,
+ search_queries: ["openclaw github"],
+ session_id: sessionId,
+ });
+ const cached = (await tool.execute({
+ objective,
+ search_queries: ["openclaw github"],
+ session_id: sessionId,
+ })) as { sessionId?: string };
+ expect(endpointMockState.calls).toHaveLength(1);
+ expect(cached.sessionId).toBe(sessionId);
+ });
+});
diff --git a/extensions/parallel/src/parallel-web-search-provider.ts b/extensions/parallel/src/parallel-web-search-provider.ts
new file mode 100644
index 000000000000..be29de8f416a
--- /dev/null
+++ b/extensions/parallel/src/parallel-web-search-provider.ts
@@ -0,0 +1,75 @@
+import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
+import { createParallelWebSearchProviderBase } from "./parallel-web-search-provider.shared.js";
+
+const PARALLEL_MAX_SEARCH_COUNT = 40;
+const PARALLEL_MAX_SEARCH_QUERIES = 5;
+const PARALLEL_MAX_SEARCH_QUERY_CHARS = 200;
+const PARALLEL_MAX_OBJECTIVE_CHARS = 5000;
+const PARALLEL_MAX_SESSION_ID_CHARS = 1000;
+const PARALLEL_MAX_CLIENT_MODEL_CHARS = 100;
+
+type ParallelWebSearchRuntime = typeof import("./parallel-web-search-provider.runtime.js");
+
+let parallelWebSearchRuntimePromise: Promise | undefined;
+
+function loadParallelWebSearchRuntime(): Promise {
+ parallelWebSearchRuntimePromise ??= import("./parallel-web-search-provider.runtime.js");
+ return parallelWebSearchRuntimePromise;
+}
+
+// Mirrors Parallel's recommended search tool schema:
+// https://docs.parallel.ai/search/best-practices#search-tool-definition
+const ParallelSearchSchema = {
+ type: "object",
+ properties: {
+ objective: {
+ type: "string",
+ description:
+ "Natural-language description of the underlying question or goal driving the search. Should be self-contained with enough context to understand the intent. Used together with search_queries to focus results on the most relevant content.",
+ maxLength: PARALLEL_MAX_OBJECTIVE_CHARS,
+ },
+ search_queries: {
+ type: "array",
+ description:
+ "Concise keyword search queries, 3-6 words each. Provide 2-3 diverse queries for best results (max 5). Vary entity names, synonyms, and angles. Each query is a keyword phrase, not a sentence; do not use site: operators.",
+ items: { type: "string", maxLength: PARALLEL_MAX_SEARCH_QUERY_CHARS },
+ minItems: 1,
+ maxItems: PARALLEL_MAX_SEARCH_QUERIES,
+ },
+ count: {
+ type: "integer",
+ description: "Number of results to return (1-40).",
+ minimum: 1,
+ maximum: PARALLEL_MAX_SEARCH_COUNT,
+ },
+ session_id: {
+ type: "string",
+ description:
+ "Optional session id returned by an earlier Parallel search. Pass it on follow-up searches that are part of the same task to keep Parallel's server-side context grouped (look for `sessionId` in the prior tool result).",
+ maxLength: PARALLEL_MAX_SESSION_ID_CHARS,
+ },
+ client_model: {
+ type: "string",
+ description:
+ "The identifier of the LLM model making this tool call (e.g. 'claude-opus-4-7', 'gpt-5.5', 'gemini-3.1-pro'). Pass the exact active model slug verbatim; never shorten or substitute a family alias like 'gpt-5'. Lets Parallel tailor default settings for your model's capabilities.",
+ maxLength: PARALLEL_MAX_CLIENT_MODEL_CHARS,
+ },
+ },
+ required: ["objective", "search_queries"],
+ additionalProperties: false,
+} satisfies Record;
+
+export function createParallelWebSearchProvider(): WebSearchProviderPlugin {
+ return {
+ ...createParallelWebSearchProviderBase(),
+ createTool: (ctx) => ({
+ description:
+ "Search the web using Parallel. Returns ranked, LLM-optimized dense excerpts from web sources. Pass an `objective` describing the underlying question along with 2-3 short keyword `search_queries` (Parallel's recommended pairing). For multi-step research, thread the prior result's `sessionId` back in as `session_id` to keep Parallel's context grouped.",
+ parameters: ParallelSearchSchema,
+ execute: async (args) => {
+ const { executeParallelWebSearchProviderTool } = await loadParallelWebSearchRuntime();
+ return await executeParallelWebSearchProviderTool(ctx, args);
+ },
+ }),
+ };
+}
diff --git a/extensions/parallel/test-api.ts b/extensions/parallel/test-api.ts
new file mode 100644
index 000000000000..2eba18dd2b63
--- /dev/null
+++ b/extensions/parallel/test-api.ts
@@ -0,0 +1 @@
+export { testing, testing as __testing } from "./src/parallel-web-search-provider.runtime.js";
diff --git a/extensions/parallel/tsconfig.json b/extensions/parallel/tsconfig.json
new file mode 100644
index 000000000000..b8a85a99ac3d
--- /dev/null
+++ b/extensions/parallel/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../tsconfig.package-boundary.base.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": ["./*.ts", "./src/**/*.ts"],
+ "exclude": [
+ "./**/*.test.ts",
+ "./dist/**",
+ "./node_modules/**",
+ "./src/test-support/**",
+ "./src/**/*test-helpers.ts",
+ "./src/**/*test-harness.ts",
+ "./src/**/*test-support.ts"
+ ]
+}
diff --git a/extensions/parallel/web-search-contract-api.ts b/extensions/parallel/web-search-contract-api.ts
new file mode 100644
index 000000000000..f75f2451fd8d
--- /dev/null
+++ b/extensions/parallel/web-search-contract-api.ts
@@ -0,0 +1,9 @@
+import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
+import { createParallelWebSearchProviderBase } from "./src/parallel-web-search-provider.shared.js";
+
+export function createParallelWebSearchProvider(): WebSearchProviderPlugin {
+ return {
+ ...createParallelWebSearchProviderBase(),
+ createTool: () => null,
+ };
+}
diff --git a/extensions/parallel/web-search-provider.ts b/extensions/parallel/web-search-provider.ts
new file mode 100644
index 000000000000..09ebf81c9d04
--- /dev/null
+++ b/extensions/parallel/web-search-provider.ts
@@ -0,0 +1 @@
+export { createParallelWebSearchProvider } from "./src/parallel-web-search-provider.js";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ea76561a2ebf..274643e64493 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1262,6 +1262,12 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
+ extensions/parallel:
+ devDependencies:
+ '@openclaw/plugin-sdk':
+ specifier: workspace:*
+ version: link:../../packages/plugin-sdk
+
extensions/perplexity:
devDependencies:
'@openclaw/plugin-sdk':
diff --git a/src/agents/tool-display-common.ts b/src/agents/tool-display-common.ts
index 71493e41ac68..a886e9326c2a 100644
--- a/src/agents/tool-display-common.ts
+++ b/src/agents/tool-display-common.ts
@@ -350,8 +350,13 @@ function collectWebSearchQueries(record: Record): string[] {
add(record.q);
add(record.search);
add(record.input);
+ // Parallel's `web_search` provider uses the native Parallel Search shape
+ // (`objective` + `search_queries`). Surface those so CLI progress and
+ // Codex activity metadata render the query context instead of a bare
+ // `search`.
+ add(record.objective);
- for (const key of ["search_query", "image_query", "queries"]) {
+ for (const key of ["search_query", "image_query", "queries", "search_queries"]) {
const value = record[key];
if (!Array.isArray(value)) {
continue;
diff --git a/src/agents/tool-display.test.ts b/src/agents/tool-display.test.ts
index 0171dd351c3e..1ad63abd75bd 100644
--- a/src/agents/tool-display.test.ts
+++ b/src/agents/tool-display.test.ts
@@ -162,6 +162,23 @@ describe("tool display details", () => {
).toBe('for "latest Kimi model", "latest Gemini model", "latest Claude model"…');
});
+ it("formats Parallel's native objective + search_queries shape", () => {
+ expect(
+ formatToolDetail(
+ resolveToolDisplay({
+ name: "web_search",
+ args: {
+ objective: "Find the OpenClaw repository on GitHub",
+ search_queries: ["openclaw github", "openclaw repository"],
+ count: 5,
+ },
+ }),
+ ),
+ ).toBe(
+ 'for "Find the OpenClaw repository on GitHub", "openclaw github", "openclaw repository" (top 5)',
+ );
+ });
+
it("summarizes exec commands with context", () => {
const detail = formatToolDetail(
resolveToolDisplay({
diff --git a/src/commands/doctor/shared/legacy-web-search-migrate.ts b/src/commands/doctor/shared/legacy-web-search-migrate.ts
index e6457c82832d..1194569e23d8 100644
--- a/src/commands/doctor/shared/legacy-web-search-migrate.ts
+++ b/src/commands/doctor/shared/legacy-web-search-migrate.ts
@@ -20,14 +20,15 @@ const BUNDLED_LEGACY_WEB_SEARCH_OWNERS = new Map([
["kimi", "moonshot"],
["minimax", "minimax"],
["ollama", "ollama"],
+ ["parallel", "parallel"],
["perplexity", "perplexity"],
["searxng", "searxng"],
["tavily", "tavily"],
]);
-// Tavily only ever used the plugin-owned config path, so there is no legacy
-// `tools.web.search.tavily.*` shape to migrate.
-const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["tavily"]);
+// Tavily and Parallel only ever used the plugin-owned config path, so there is
+// no legacy `tools.web.search..*` shape to migrate for them.
+const NON_MIGRATED_LEGACY_WEB_SEARCH_PROVIDER_IDS = new Set(["parallel", "tavily"]);
const LEGACY_GLOBAL_WEB_SEARCH_PROVIDER_ID = "brave";
function getBundledLegacyWebSearchOwners(): ReadonlyMap {
diff --git a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts
index 252bd6bedf6e..16a0211fbfa5 100644
--- a/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts
+++ b/src/plugin-sdk/test-helpers/plugin-registration-contract-cases.ts
@@ -185,6 +185,10 @@ export const pluginRegistrationContractCases = {
requireGenerateImage: true,
requireGenerateVideo: true,
},
+ parallel: {
+ pluginId: "parallel",
+ webSearchProviderIds: ["parallel"],
+ },
perplexity: {
pluginId: "perplexity",
webSearchProviderIds: ["perplexity"],
diff --git a/src/plugins/contracts/plugin-registration.parallel.contract.test.ts b/src/plugins/contracts/plugin-registration.parallel.contract.test.ts
new file mode 100644
index 000000000000..da9b0ac631a7
--- /dev/null
+++ b/src/plugins/contracts/plugin-registration.parallel.contract.test.ts
@@ -0,0 +1,4 @@
+import { pluginRegistrationContractCases } from "openclaw/plugin-sdk/plugin-test-contracts";
+import { describePluginRegistrationContract } from "openclaw/plugin-sdk/plugin-test-contracts";
+
+describePluginRegistrationContract(pluginRegistrationContractCases.parallel);
diff --git a/src/plugins/contracts/providers.contract.test.ts b/src/plugins/contracts/providers.contract.test.ts
index d529aaa865d5..51ec10ea2a99 100644
--- a/src/plugins/contracts/providers.contract.test.ts
+++ b/src/plugins/contracts/providers.contract.test.ts
@@ -23,6 +23,7 @@ for (const providerId of [
"google",
"minimax",
"moonshot",
+ "parallel",
"perplexity",
"tavily",
"xai",
diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts
index 293ebbc43e0c..395f346ab3bf 100644
--- a/src/plugins/web-search-providers.runtime.test.ts
+++ b/src/plugins/web-search-providers.runtime.test.ts
@@ -21,6 +21,7 @@ const BUNDLED_WEB_SEARCH_PROVIDERS = [
{ pluginId: "firecrawl", id: "firecrawl", order: 60 },
{ pluginId: "exa", id: "exa", order: 65 },
{ pluginId: "tavily", id: "tavily", order: 70 },
+ { pluginId: "parallel", id: "parallel", order: 75 },
{ pluginId: "duckduckgo", id: "duckduckgo", order: 100 },
] as const;
@@ -51,6 +52,7 @@ const EXPECTED_BUNDLED_RUNTIME_WEB_SEARCH_PROVIDER_KEYS = [
"google:gemini",
"xai:grok",
"moonshot:kimi",
+ "parallel:parallel",
"perplexity:perplexity",
"tavily:tavily",
] as const;
diff --git a/test/vitest/vitest.extension-misc-paths.mjs b/test/vitest/vitest.extension-misc-paths.mjs
index 01a14177104e..85e3cde88e4e 100644
--- a/test/vitest/vitest.extension-misc-paths.mjs
+++ b/test/vitest/vitest.extension-misc-paths.mjs
@@ -15,6 +15,7 @@ export const miscExtensionTestRoots = [
"extensions/opencode",
"extensions/opencode-go",
"extensions/openshell",
+ "extensions/parallel",
"extensions/perplexity",
"extensions/phone-control",
"extensions/searxng",