Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Set the API key via config or the `SERPAPI_API_KEY` environment variable:
entries: {
serpapi: {
enabled: true,
config: { webSearch: { apiKey: "YOUR_SERPAPI_KEY", hl: "en" } },
config: { webSearch: { apiKey: "YOUR_SERPAPI_KEY", hl: "en", format: "md" } },
},
},
},
Expand All @@ -58,6 +58,26 @@ export SERPAPI_API_KEY="YOUR_SERPAPI_KEY"

Get a key at [serpapi.com/users/sign_up](https://serpapi.com/users/sign_up).

### Response format

> **Breaking change in 0.2.0** — the specialized `serpapi_*` tools now return
> markdown by default.

`format` controls how the specialized `serpapi_*` tools return results:

- **`"md"` (default)** — SerpApi's markdown rendering, optimized for LLMs.
Results are returned under a single `markdown` field wrapped in
untrusted-content security markers, with the results table truncated to the
requested `count`.
- **`"json"`** — the previous structured JSON responses, useful when you need
to process specific fields programmatically.

Every specialized tool also accepts a per-call `output` argument (`"md"` or
`"json"`) that overrides the configured default.

The `web_search` provider always uses JSON: its contract (`results[]`,
`count`/`maxResults`) is structured, so markdown does not apply to it.

## Develop

Requires Node 22.19+ and [pnpm](https://pnpm.io).
Expand Down
10 changes: 10 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
"webSearch.hl": {
"label": "Default Language",
"help": "Default language code for all searches (e.g. en, de, uk). Defaults to en."
},
"webSearch.format": {
"label": "Response Format",
"help": "Response format for all SerpApi requests: 'md' (markdown, default, fewer LLM tokens) or 'json' (structured)."
}
},
"contracts": {
Expand Down Expand Up @@ -76,6 +80,12 @@
"hl": {
"type": "string",
"description": "Default language code for all searches (e.g. en, de, ja). Defaults to en."
},
"format": {
"type": "string",
"enum": ["md", "json"],
"default": "md",
"description": "Response format: 'md' returns SerpApi's LLM-optimized markdown (default, fewer tokens); 'json' returns structured JSON."
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@serpapi/openclaw-plugin",
"version": "0.1.0",
"version": "0.2.0",
"description": "SerpApi web search provider for OpenClaw (Google Light, 100+ engines). Specialized SerpApi tools follow in subsequent releases.",
"keywords": [
"openclaw",
Expand Down
19 changes: 18 additions & 1 deletion skills/serpapi/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ plugin must also be enabled via `plugins.entries.serpapi.enabled`:
"config": {
"webSearch": {
"apiKey": "your-serpapi-key",
"hl": "en"
"hl": "en",
"format": "md"
}
}
}
Expand All @@ -32,6 +33,22 @@ plugin must also be enabled via `plugins.entries.serpapi.enabled`:

`apiKey` can also be provided via the `SERPAPI_API_KEY` environment variable. `hl` defaults to `en`.

## Response format (breaking change in 0.2.0)

`format` selects the default response format for all `serpapi_*` tools:

- `"md"` (default) — SerpApi's LLM-optimized markdown. Tool results are
returned under a single `markdown` field wrapped in untrusted-content
security markers, with the results table truncated to `count` where the
tool supports it.
- `"json"` — the structured JSON responses documented per tool below.

Each tool also accepts a per-call `output` argument (`"md"` or `"json"`) to
override the configured default.

The `web_search` provider always uses JSON; the markdown format applies only
to the specialized tools.

## When to use which tool

| Need | Tool |
Expand Down
16 changes: 16 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ export const SERPAPI_DEFAULT_TIMEOUT_SECONDS = 30;
// Slightly under SerpApi's 1-hour server-side cache window so we refresh before it expires.
export const SERPAPI_CACHE_TTL_MS = 55 * 60_000;

export type SerpApiOutputFormat = "md" | "json";

type SerpApiConfig = {
apiKey?: unknown;
hl?: unknown;
format?: unknown;
};

export function resolveSerpApiPluginConfig(cfg?: OpenClawConfig): SerpApiConfig | undefined {
Expand All @@ -34,3 +37,16 @@ export function resolveSerpApiLanguage(cfg?: OpenClawConfig): string {
const pluginConfig = resolveSerpApiPluginConfig(cfg);
return normalizeOptionalString(pluginConfig?.hl)?.trim() || "en";
}

/** Invalid values throw instead of silently falling back. */
export function resolveSerpApiFormat(cfg?: OpenClawConfig): SerpApiOutputFormat {
const pluginConfig = resolveSerpApiPluginConfig(cfg);
const format = normalizeOptionalString(pluginConfig?.format)?.trim().toLowerCase();
if (format === undefined) {
return "md";
}
if (format === "md" || format === "json") {
return format;
}
throw new Error(`serpapi: invalid webSearch.format "${format}". Expected "md" or "json".`);
}
99 changes: 99 additions & 0 deletions src/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/** Limits result-table rows under a named heading; other sections stay unchanged. */

function normalizeHeading(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
}

function matchesHeading(value: string, expected: string): boolean {
const normalized = normalizeHeading(value);
const want = normalizeHeading(expected);
if (normalized === want || normalized.startsWith(`${want} `)) {
return true;
}
// "News Results (5)" normalizes to "news results 5".
const rest = normalized.startsWith(want) ? normalized.slice(want.length) : undefined;
return rest !== undefined && /^\s*\d/.test(rest);
}

function atxHeadingText(line: string): { level: number; text: string } | undefined {
const m = /^(#{1,6})\s+(.*)$/.exec(line);
if (!m || m[1] === undefined || m[2] === undefined) return undefined;
return { level: m[1].length, text: m[2].replace(/#+\s*$/, "").trim() };
}

function isTableRow(line: string): boolean {
const trimmed = line.trim();
return trimmed.startsWith("|") && trimmed.endsWith("|");
}

function isTableSeparator(line: string): boolean {
return /^\|?[\s:|-]+\|[\s:|-]*$/.test(line.trim()) && line.includes("-");
}
Comment on lines +32 to +34

export function limitResultTable(markdown: string, opts: { heading: string; limit: number }): string {
const { heading, limit } = opts;
const lines = markdown.split("\n");

let sectionLevel: number | undefined;
let sectionStart = -1;
let sectionEnd = lines.length;

for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
const h = atxHeadingText(line);
if (!h) continue;
if (sectionLevel === undefined) {
if (matchesHeading(h.text, heading)) {
sectionLevel = h.level;
sectionStart = i;
}
continue;
}
if (h.level <= sectionLevel) {
sectionEnd = i;
break;
}
}

if (sectionLevel === undefined || sectionStart < 0) {
return markdown;
}

let headerSeen = false;
let separatorSeen = false;
let rowCount = 0;
const removed = new Set<number>();

for (let i = sectionStart + 1; i < sectionEnd; i++) {
const line = lines[i];
if (line === undefined) continue;
if (!isTableRow(line)) {
if (separatorSeen && line.trim() === "") break;
continue;
}
if (!headerSeen) {
headerSeen = true;
continue;
}
if (!separatorSeen) {
if (isTableSeparator(line)) {
separatorSeen = true;
}
continue;
}
rowCount++;
if (rowCount > limit) {
removed.add(i);
}
}

if (removed.size === 0) {
return markdown;
}

return lines.filter((_, i) => !removed.has(i)).join("\n");
}
50 changes: 44 additions & 6 deletions src/serpapi-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import {
resolveSerpApiFormat,
resolveSerpApiKey,
resolveSerpApiLanguage,
SERPAPI_BASE_URL,
SERPAPI_CACHE_TTL_MS,
SERPAPI_DEFAULT_TIMEOUT_SECONDS,
type SerpApiOutputFormat,
} from "./config.js";

// In-process result cache — aligns with SerpApi's 1-hour server-side cache window.
Expand All @@ -27,11 +29,40 @@ export type SerpApiCallParams = {
*/
allowedParams: readonly string[];
params: Record<string, string | number | boolean | undefined>;
output?: SerpApiOutputFormat;
timeoutSeconds?: number;
signal?: AbortSignal;
};

export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<string, unknown>> {
export type SerpApiResponse = Record<string, unknown> | string;

/** The shared search cache stores records, so markdown payloads are wrapped. */
const MARKDOWN_CACHE_KEY = "__serpapi_markdown";

function wrapForCache(response: SerpApiResponse): Record<string, unknown> {
return typeof response === "string" ? { [MARKDOWN_CACHE_KEY]: response } : response;
}

function unwrapFromCache(cached: Record<string, unknown>): SerpApiResponse {
const markdown = cached[MARKDOWN_CACHE_KEY];
return typeof markdown === "string" ? markdown : cached;
}

function redactApiKey(text: string, apiKey: string): string {
return text.split(apiKey).join("[redacted]");
}

function resolveOutputFormat(cfg: OpenClawConfig | undefined, override?: SerpApiOutputFormat): SerpApiOutputFormat {
if (override !== undefined) {
if (override !== "md" && override !== "json") {
throw new Error(`serpapi: output must be "md" or "json", got "${override}"`);
}
return override;
}
return resolveSerpApiFormat(cfg);
}

export async function callSerpApi(opts: SerpApiCallParams): Promise<SerpApiResponse> {
const apiKey = resolveSerpApiKey(opts.cfg);
if (!apiKey) {
throw new Error(
Expand All @@ -41,6 +72,7 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
}

const configHl = resolveSerpApiLanguage(opts.cfg);
const format = resolveOutputFormat(opts.cfg, opts.output);
const allowed = new Set(opts.allowedParams);
// Build raw params; engine is always reserved, hl is honored when allowlisted.
const rawParams: Record<string, string> = {};
Expand All @@ -60,6 +92,10 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
Object.entries(rawParams).filter(([k]) => k === "engine" || k === "hl" || allowed.has(k)),
);

if (format === "md") {
filtered.output = "md";
}

const isZeroTrace = filtered.zero_trace === "true";

const cacheKey = buildSearchCacheKey([
Expand All @@ -73,7 +109,7 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
]);
if (!isZeroTrace) {
const cached = readCachedSearchPayload(cacheKey);
if (cached) return cached;
if (cached) return unwrapFromCache(cached);
}

const urlParams = new URLSearchParams({ ...filtered, api_key: apiKey });
Expand All @@ -87,7 +123,6 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Client-Source": "openclaw",
},
},
Expand All @@ -98,9 +133,12 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
if (response.status === 401) throw new Error("SerpApi: invalid or missing API key.");
if (response.status === 429) throw new Error("SerpApi: quota exhausted. Narrow the request or try later.");
if (response.status >= 500) throw new Error(`SerpApi: upstream error (${response.status}). Try again shortly.`);
throw new Error(`SerpApi (${opts.engine}) error (${response.status}): ${text}`);
throw new Error(`SerpApi (${opts.engine}) error (${response.status}): ${redactApiKey(text, apiKey)}`);
}
const text = redactApiKey(await response.text(), apiKey);
if (format === "md") {
return text;
}
const text = await response.text();
try {
return JSON.parse(text) as Record<string, unknown>;
} catch {
Expand All @@ -110,7 +148,7 @@ export async function callSerpApi(opts: SerpApiCallParams): Promise<Record<strin
);

if (!isZeroTrace) {
writeCachedSearchPayload(cacheKey, result, SERPAPI_CACHE_TTL_MS);
writeCachedSearchPayload(cacheKey, wrapForCache(result), SERPAPI_CACHE_TTL_MS);
}
return result;
}
4 changes: 3 additions & 1 deletion src/serpapi-search-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,12 @@ export function createSerpApiWebSearchProvider(): WebSearchProviderPlugin {
safe: readStringParam(args, "safe") ?? undefined,
start: readNumberParam(args, "start", { integer: true }) ?? undefined,
},
// web_search's contract (results[], count) is structured; md applies to the specialized tools.
output: "json",
signal: context?.signal,
timeoutSeconds: resolveSearchTimeoutSeconds(ctx.searchConfig),
});
return extract(raw, count);
return extract(raw as Record<string, unknown>, count);
},
}),
};
Expand Down
12 changes: 9 additions & 3 deletions src/tools/amazon-product.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/provider-web-search";
import { readStringParam } from "openclaw/plugin-sdk/provider-web-search";
import { callSerpApi } from "../serpapi-client.js";
import { resolveToolConfig, type SerpApiToolCtx } from "../utils.js";
import { readOutputArg, resolveToolConfig, type SerpApiToolCtx, serpApiResult } from "../utils.js";

const ALLOWED_PARAMS = [
"asin",
Expand Down Expand Up @@ -65,6 +65,11 @@ export function createSerpApiAmazonProductTool(api: OpenClawPluginApi, ctx?: Ser
type: "string",
description: "Country to filter shipping products by.",
},
output: {
type: "string",
enum: ["md", "json"],
description: "Response format: 'md' (markdown, default, fewer tokens) or 'json' (structured).",
},
},
required: ["asin"],
additionalProperties: false,
Expand All @@ -82,9 +87,10 @@ export function createSerpApiAmazonProductTool(api: OpenClawPluginApi, ctx?: Ser
delivery_zip: readStringParam(args, "delivery_zip") ?? undefined,
shipping_location: readStringParam(args, "shipping_location") ?? undefined,
},
output: readOutputArg(args),
signal,
});
return jsonResult(extract(raw));
return serpApiResult(typeof raw === "string" ? raw : extract(raw));
},
};
}
Loading