Skip to main content

Web search

Zeldoc.ai offers a private web search endpoint for agents. Search queries are answered by a self-hosted SearXNG metasearch instance running on Zeldoc.ai's own servers, and the endpoint is authenticated with the same API key you already use for models. Nothing else is needed: no search-provider account, no second key.

Use it when a coding agent needs to look something up: a library's current API, an error message, a changelog. The sections below show how to plug it into OpenCode, Pi and Open WebUI, and how to call it from anything else.

What leaves your machine, and where it goes

Your query is sent to Zeldoc.ai with your API key. Zeldoc.ai's search server then queries public search engines (Brave, Google, Mojeek, GitHub, Stack Overflow, MDN and others) without your API key, account, IP address or cookies. Zeldoc.ai does not store the query text. The engines are third parties and may be located outside the EU. This is a weaker guarantee than for models, which never leave Zeldoc.ai infrastructure.

The endpoint

curl https://api.zeldoc.ai/v1/search/zeldoc-search \
-H "Authorization: Bearer $ZELDOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "podman rootless ports below 1024"}'

The response is a JSON object with a results array:

{
"results": [
{
"title": "Rootless podman is unable to use host ports less than 1024",
"url": "https://access.redhat.com/solutions/7044059",
"snippet": "Rootless user is trying to map ports less than 1024 on the host ...",
"date": ""
}
]
}

date is often empty; engines rarely report one. Optional fields in the request body narrow the search:

FieldExampleEffect
time_range"week"Only results from the past day, week, month or year
language"de"Two-letter language code
categories"it"SearXNG category, for example it, news, science
engines"github,stackoverflow"Comma-separated list of engines to query

Your key's rate limit applies to search calls the same way it applies to model calls. Searches are not billed.

Need an API key?

OpenCode

OpenCode's built-in websearch tool sends queries to a third-party service (Exa AI). Replace it with a custom tool that calls Zeldoc.ai instead. Three steps:

1. Add the tool

Download the tool file into OpenCode's global tools directory. Custom tools in this directory are available in every project:

mkdir -p ~/.config/opencode/tools
curl -fsSL https://docs.zeldoc.ai/search/opencode/zeldoc_search.ts \
-o ~/.config/opencode/tools/zeldoc_search.ts

The file name becomes the tool name, so the agent sees a zeldoc_search tool. It reads your API key from the ZELDOC_API_KEY environment variable, or from the key you stored with opencode auth login.

What the tool does (full source)
~/.config/opencode/tools/zeldoc_search.ts
import { tool } from "@opencode-ai/plugin"
import { readFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"

const ENDPOINT = "https://api.zeldoc.ai/v1/search/zeldoc-search"

// ZELDOC_API_KEY, or the key stored by `opencode auth login`.
async function apiKey(): Promise<string> {
if (process.env.ZELDOC_API_KEY) return process.env.ZELDOC_API_KEY
try {
const file = join(homedir(), ".local/share/opencode/auth.json")
const auth = JSON.parse(await readFile(file, "utf8"))
if (auth.zeldoc?.type === "api" && auth.zeldoc.key) return auth.zeldoc.key
} catch {}
throw new Error("No Zeldoc.ai API key. Set ZELDOC_API_KEY or run `opencode auth login`.")
}

interface Result { title: string; url: string; snippet: string; date?: string }

export default tool({
description:
"Search the web through Zeldoc.ai's private search. Returns titles, URLs " +
"and snippets. Use webfetch to read a page when a snippet is not enough.",
args: {
query: tool.schema.string().describe("Search query"),
time_range: tool.schema
.enum(["day", "week", "month", "year"])
.optional()
.describe("Only results from this period"),
language: tool.schema.string().optional().describe("Two-letter language code, e.g. en, de, da"),
engines: tool.schema
.string()
.optional()
.describe("Comma-separated engines to use, e.g. github,stackoverflow"),
},
async execute(args) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${await apiKey()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(args),
})
if (!response.ok) throw new Error(`Zeldoc.ai search failed: HTTP ${response.status}`)
const data = (await response.json()) as { results?: Result[] }
const results = data.results ?? []
if (results.length === 0) return "No results."
return results
.slice(0, 10)
.map((r, i) =>
[
`--- Result ${i + 1} ---`,
`Title: ${r.title}`,
`Link: ${r.url}`,
r.date ? `Date: ${r.date}` : null,
`Snippet: ${r.snippet}`,
]
.filter(Boolean)
.join("\n"),
)
.join("\n\n")
},
})

2. Keep the built-in search off

The built-in websearch tool is only active with the OpenCode provider, or when OPENCODE_ENABLE_EXA or OPENCODE_ENABLE_PARALLEL is set. Do not set those variables, and deny the tool explicitly so the agent cannot pick it even if something enables it later. Add this to your opencode.jsonc:

{
"$schema": "https://opencode.ai/config.json",
"permission": {
"websearch": "deny",
"zeldoc_search": "allow"
}
}

webfetch can stay on. It downloads the URL you give it directly from your machine, and the agent needs it to read a page after a search.

Combine with your Zeldoc.ai config

Merge this into the same opencode.jsonc as your provider and recommended settings.

3. Verify

opencode run -m zeldoc/zdev "Use zeldoc_search to look up 'searxng json api' and list the first two titles"

You should see a zeldoc_search tool call followed by two titles.

Pi

Pi has no web search of its own; extensions add tools. This extension adds two: web_search, which calls Zeldoc.ai, and fetch_content, which downloads a page on your machine and returns its text so the agent can read what it found.

1. Add the extension

mkdir -p ~/.pi/agent/extensions
curl -fsSL https://docs.zeldoc.ai/search/pi/zeldoc_search.ts \
-o ~/.pi/agent/extensions/zeldoc_search.ts

Pi loads every .ts file in that directory on start. The extension takes your API key from Pi's credential store (/login, Zeldoc.ai) if you use the provider package, and otherwise from ZELDOC_API_KEY.

What the extension does (full source)
~/.pi/agent/extensions/zeldoc_search.ts
/**
* Zeldoc.ai web search for Pi.
*
* Tools:
* - web_search: query Zeldoc.ai's private search (POST /v1/search/zeldoc-search)
* - fetch_content: download a URL locally and return its readable text
*
* The API key comes from Pi's credential store (/login, Zeldoc.ai) or the
* ZELDOC_API_KEY environment variable. Nothing else leaves your machine.
*/

import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const SEARCH_URL = "https://api.zeldoc.ai/v1/search/zeldoc-search";
const MAX_RESULTS = 10;

interface SearchResult {
title: string;
url: string;
snippet: string;
date?: string;
}

async function resolveApiKey(ctx: ExtensionContext): Promise<string | undefined> {
const stored = await ctx.modelRegistry
.getApiKeyForProvider("zeldoc")
.catch(() => undefined);
return stored || process.env.ZELDOC_API_KEY || undefined;
}

async function zeldocSearch(
apiKey: string,
body: Record<string, unknown>,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const response = await fetch(SEARCH_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
throw new Error(`Zeldoc.ai search failed: HTTP ${response.status}`);
}
const data = (await response.json()) as { results?: SearchResult[] };
return data.results ?? [];
}

function formatResults(results: SearchResult[]): string {
if (results.length === 0) return "No results found.";
return results
.slice(0, MAX_RESULTS)
.map((r, i) =>
[
`--- Result ${i + 1} ---`,
`Title: ${r.title}`,
`Link: ${r.url}`,
r.date ? `Date: ${r.date}` : undefined,
`Snippet: ${r.snippet}`,
]
.filter(Boolean)
.join("\n"),
)
.join("\n\n");
}

/** Crude but dependency-free HTML to text. */
function htmlToText(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
.replace(/<\/(p|div|br|li|h[1-6]|tr|section|article)>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}

async function fetchContent(url: string, signal?: AbortSignal): Promise<string> {
const response = await fetch(url, {
headers: {
Accept: "text/html,text/plain,text/markdown;q=0.9,*/*;q=0.5",
"User-Agent": "pi-zeldoc-search/1.0",
},
signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText}`);
}
const text = await response.text();
const type = response.headers.get("content-type") ?? "";
return type.includes("html") ? htmlToText(text) : text;
}

const SEARCH_PARAMS = Type.Object({
query: Type.String({ description: "Search query" }),
time_range: Type.Optional(
StringEnum(["day", "week", "month", "year"] as const, {
description: "Only results from this period",
}),
),
language: Type.Optional(
Type.String({ description: "Two-letter language code, e.g. en, de, da" }),
),
engines: Type.Optional(
Type.String({
description: "Comma-separated engines to use, e.g. github,stackoverflow",
}),
),
});

const FETCH_PARAMS = Type.Object({
url: Type.String({ description: "URL to fetch" }),
maxChars: Type.Optional(
Type.Number({
description: "Max characters to return (default 20000)",
minimum: 500,
maximum: 200_000,
}),
),
});

export default function (pi: ExtensionAPI): void {
pi.registerTool({
name: "web_search",
label: "Web Search",
description:
"Search the web through Zeldoc.ai's private search. Returns titles, " +
"URLs and snippets. Call fetch_content on a result when the snippet " +
"is not enough.",
promptSnippet: "Search the web (private, via Zeldoc.ai)",
parameters: SEARCH_PARAMS,
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const apiKey = await resolveApiKey(ctx);
if (!apiKey) {
throw new Error(
"No Zeldoc.ai API key. Run /login and pick Zeldoc.ai, or set ZELDOC_API_KEY.",
);
}
const results = await zeldocSearch(apiKey, params, signal);
return {
content: [{ type: "text", text: formatResults(results) }],
details: { query: params.query, resultCount: results.length },
};
},
});

pi.registerTool({
name: "fetch_content",
label: "Fetch Content",
description:
"Download a URL on this machine and return its readable text. " +
"Use it to read a page found with web_search.",
promptSnippet: "Fetch a web page as text",
parameters: FETCH_PARAMS,
async execute(_toolCallId, params, signal) {
const content = await fetchContent(params.url, signal);
const maxChars = params.maxChars ?? 20_000;
const text =
content.length > maxChars
? `${content.slice(0, maxChars)}\n\n[truncated at ${maxChars} characters]`
: content;
return {
content: [{ type: "text", text }],
details: { url: params.url, length: content.length },
};
},
});
}

2. Remove other search extensions

If you already have a search extension, for example one backed by the Brave Search API or Jina Reader, remove it. Two tools with the same job confuse the model, and only this one keeps your queries away from third-party accounts.

3. Verify

pi --model zeldoc/zdev -p "Use web_search to look up 'searxng json api' and list the first two titles"

Open WebUI

Open WebUI's built-in web search providers do not fit this endpoint. Its SearXNG provider expects direct access to a SearXNG server, which Zeldoc.ai does not expose, and its generic External provider expects a different response format. Use a Tool instead: a small Python file that Open WebUI runs when the model decides to search.

1. Install the tool

  1. Download zeldoc_search.py.
  2. In Open WebUI, open Workspace, then Tools, and click +.
  3. Paste the file's content, give it a name such as Zeldoc.ai Web Search, and save.
  4. Open the tool's Valves (the gear icon) and paste your Zeldoc.ai API key into ZELDOC_API_KEY.
What the tool does (full source)
zeldoc_search.py
"""
title: Zeldoc.ai Web Search
description: Search the web through Zeldoc.ai's private search endpoint.
version: 1.0.0
"""

import json
import urllib.request
from typing import Optional

from pydantic import BaseModel, Field

SEARCH_URL = "https://api.zeldoc.ai/v1/search/zeldoc-search"
MAX_RESULTS = 10


class Tools:
class Valves(BaseModel):
ZELDOC_API_KEY: str = Field(default="", description="Your Zeldoc.ai API key")

def __init__(self):
self.valves = self.Valves()

def search_web(
self,
query: str,
time_range: Optional[str] = None,
language: Optional[str] = None,
) -> str:
"""
Search the web through Zeldoc.ai's private search. Returns titles,
URLs and snippets.
:param query: The search query.
:param time_range: Optional. Only results from this period: day, week, month or year.
:param language: Optional. Two-letter language code, e.g. en, de, da.
"""
if not self.valves.ZELDOC_API_KEY:
return "Zeldoc.ai API key is not set. Open the tool's valves and add it."

body = {"query": query}
if time_range:
body["time_range"] = time_range
if language:
body["language"] = language

request = urllib.request.Request(
SEARCH_URL,
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {self.valves.ZELDOC_API_KEY}",
"Content-Type": "application/json",
# Cloudflare rejects the default Python user agent.
"User-Agent": "open-webui-zeldoc-search/1.0",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
results = json.load(response).get("results", [])
except Exception as error: # noqa: BLE001
return f"Zeldoc.ai search failed: {error}"

if not results:
return "No results found."

lines = []
for index, result in enumerate(results[:MAX_RESULTS], start=1):
lines.append(f"--- Result {index} ---")
lines.append(f"Title: {result.get('title', '')}")
lines.append(f"Link: {result.get('url', '')}")
if result.get("date"):
lines.append(f"Date: {result['date']}")
lines.append(f"Snippet: {result.get('snippet', '')}")
lines.append("")
return "\n".join(lines).strip()

2. Enable it for a model

In a chat, click the + next to the message box and toggle the tool on. To have it always available, open Workspace, Models, edit the model and tick the tool under Tools.

Leave Open WebUI's own Web Search setting (under Admin Panel, Settings, Web Search) off. It would send queries to whichever provider is configured there, not to Zeldoc.ai.

Using chat.zeldoc.ai?

The tool is installed there by the administrators. Ask them to enable it for your model if you do not see it.

Any other tool

Anything that can make an HTTP request can use the endpoint from The endpoint above. Common shapes:

  • MCP or agent frameworks with an HTTP tool: POST the JSON body with the Authorization header, and map results[].url, title and snippet.
  • Shell scripts: the curl call at the top of this page, piped through jq.

If you build an integration for a tool not listed here, let the Zeldoc.ai team know and we will add it to this page.