How to consume playwright-search from your other projects (Jarvis, claude-experts, bpm-opencode-experts, ai-daytrader, …). Three integration modes ordered by ease.
Easiest. Subprocess from any language. Always-fresh JSON.
# Search only (top 10 from all engines, dedup'd via JSON output)
playwright-search "your query" --json
# Search + fetch + extract (enrichment pipeline)
playwright-search "your query" --enrich --enrich-top 5 --headless --jsonOutput for --enrich --json:
[
{
"rank": 1,
"engines": ["ddg", "brave", "bing"],
"title": "...",
"url": "https://...",
"snippet": "...",
"fetched": {
"url": "...", "finalUrl": "...", "status": 200,
"contentType": "text/html", "fetchedAt": "2026-04-27T...",
"cached": false,
"fetch": { "url": "...", "html": "<full html>", ... },
"extract": {
"title": "...", "byline": "...", "siteName": "...",
"excerpt": "...", "textContent": "<plain text>",
"contentHtml": "<readability html>", "paywalled": false
}
}
}
]Use this when: the consuming code is in a different language (Python, Go, Rust) or you want zero coupling.
Import directly. No subprocess overhead, full type safety.
import { searchAndFetch, search, searchAll } from "playwright-search";
// Highest-level: search + fetch + extract in one call
const enriched = await searchAndFetch("rust async runtime", {
engines: ["ddg", "brave", "bing"],
top: 10,
enrichTop: 5,
headless: true,
concurrency: 3,
fetchOpts: { useCache: true, respectRobots: true, ttlMs: 24 * 60 * 60 * 1000 },
});
for (const r of enriched) {
console.log(r.rank, r.title, r.url);
console.log(r.engines.join(", ")); // which engines surfaced this
console.log(r.fetched.extract?.textContent); // full article text
}
// Lower-level if you only want search results (no fetch)
const ddgOnly = await search("rust async", "ddg", { top: 5 });For Jarvis/claude-experts (already npm-based) this is the cleanest path. Add to package.json:
{ "dependencies": { "playwright-search": "file:../playwright-search" } }(Or push to a Gitea repo and reference it via git URL.)
3. MCP (built — see MCP.md)
The playwright-search-mcp binary speaks the standard MCP stdio protocol. Three tools:
tools:
- web_research(query, top?, engines?, max_chars_per_source?, relevance_query?)
- web_search(query, limit?, engines?)
- web_fetch(url, max_chars?, no_cache?, relevance_query?)
This is the path for opencode + Claude Code: register the MCP server in opencode.json or .mcp.json, every agent in the project gets the tools as native function calls — no subprocess plumbing. See MCP.md for setup commands and config snippets.
Sketch for projects that can't speak MCP:
POST /search {query, engines, top} → SearchResult[]
POST /search-fetch {query, engines, top, enrichTop} → EnrichedResult[]
POST /fetch {url} → FetchResult
GET /cache/:hash → cached entry
GET /healthz
Build only if you find a host that needs it.
The MCP is registered in examples/opencode.json. Every agent in the project (researcher, coding-agent, security-auditor, api-designer, etc.) can call web_research, web_search, web_fetch — see agents/shared/RESEARCH_TOOLS.md for the shared reference doc agents read at runtime.
The researcher agent uses these tools by default and runs an iterative loop (pass 1 broad → pass 2+ refined). Other agents reach for them on demand: e.g., security-auditor for CVE lookups, coding-agent before adopting a new library, api-designer for current REST/GraphQL standards.
Native WebSearch/WebFetch remain the default in Claude Code. The MCP is optional — register it via .mcp.json or ~/.claude.json if you want multi-engine, paragraph-ranked, cached research. The researcher agent's prompt documents the choice.
Already Node. Use the library path:
// In src/services/research.service.ts (or wherever)
import { searchAndFetch } from "playwright-search";
export async function deepResearch(query: string) {
const sources = await searchAndFetch(query, {
engines: ["ddg", "brave", "bing"],
top: 10,
enrichTop: 5,
headless: true,
});
return sources.map((s) => ({
title: s.title,
url: s.url,
sourceType: "web" as const,
text: s.fetched.extract?.textContent ?? s.snippet,
siteName: s.fetched.extract?.siteName ?? "",
extractedAt: s.fetched.fetchedAt,
}));
}This replaces / augments the existing playbook-based research path. Cached pages mean repeated queries cost nothing.
On-demand. Library when Node, CLI otherwise.
- First run is slow (8–30s for full enrichment of 5 pages). Cached runs are fast (<2s). Show a progress indicator or stream results.
- Cache lives at
~/.playwright-search/cache/<hash>.json. Wipe it withrm -rf ~/.playwright-search/cache. - Profile lives at
~/.playwright-search/profile/. Persistent Chromium profile — DON'T delete unless you want to lose accumulated cookies/consent state. - Headless caveats: Google still trips a captcha headless. Use
--headlessonly with--engines ddg,brave,bing, OR run headed (default) to also get Google. - Rate limits are process-local. Two parallel processes won't share cooldowns. If you need shared rate limit, lift
domainLimit.tsstate into Redis/SQLite (step 3 candidate). - Paywall results still come back, with
fetched.extract.paywalled = true. Downstream code should usually skip these or fall back tosnippet. - Robots-blocked URLs come back with
fetched.skipped = "robots"and no extract. We're polite by default; passrespectRobots: falseto override (don't).
For LLM context windows, format like this:
[Source 1: <title> — <siteName> — <url>]
<extract.textContent>
[Source 2: ...]
...
That keeps source attribution clean and makes hallucination guards easier downstream. Trim each textContent to ~3000 chars unless you need long-form. For a research summary, 5 sources × 3k chars = 15k tokens, very digestible.