Degoog Docs

Degoog Search engines

Build your own custom search backends for web, images, videos or anything you can think of.

degoog-cli is now available. Scaffold engine boilerplate directly from your terminal - no copy-pasting, no guessing the folder structure. Install with curl -fsSL https://raw.githubusercontent.com/degoog-org/cli/main/install.sh | sh or grab it from github.com/degoog-org/cli.

Where engines live

You can drop your custom engines right into data/engines/ or whatever you set DEGOOG_ENGINES_DIR to. Each engine is just a file or folder with an entry file that exports an object or class containing a name and executeSearch.

Engine contract

What you need:

  • name: The display name shown in Settings then Engines.
  • executeSearch(query, page?, timeFilter?, context) (async): This needs to return a Promise that resolves to an array of results. Each result is an object holding a title, url, snippet, source, and optionally a thumbnail and duration. Make sure you use context.fetch for all your outbound HTTP requests so your engine respects any proxy settings the user has configured.

Optional extras:

  • bangShortcut: Lets users type a shortcut like !shortcut query or query !shortcut to search only this engine (like setting bangShortcut: "ecosia" so users can type !ecosia linux).
  • settingsSchema and configure(settings): These work just like they do for plugins. You will get a Configure button in Settings then Engines, and the values are securely saved in data/plugin-settings.json.

Your SettingField shape needs a key, label, and type (which can be text, password, url, toggle, textarea, select, or urllist). You can also add optional properties like required, placeholder, description, secret, options (if using select), and default.

If you have sensitive fields like API keys or tokens, mark them with secret: true. The UI will never expose the saved value to the user and will just show a Set or Not set indicator instead. When saving, if the field value equals the special "__SET__" string, the existing stored value stays put. This ensures a page reload never wipes out a key you already entered.

HTTP method

Your engines are completely free to use more than just GET requests. Because you control the fetch call inside executeSearch, you can use any method like POST and set whatever headers or body your API needs. Here is what a GraphQL engine using POST looks like:

async executeSearch(query, page = 1, _timeFilter, context) {
  const doFetch = context?.fetch ?? fetch;
  const response = await doFetch("https://graphql.example.com", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query: MY_QUERY, variables: { search: query, page } }),
  });
  context?.sentinel?.(response, this.name);
  const data = await response.json();
  // parse and return array of { title, url, snippet, source }
}

Search types

Export a type to declare which tab(s) your engine belongs to. The default is "web". Any other string automatically creates a dedicated tab on the search results page, all engines sharing that type run together when the tab is selected.

type can be a single string or an array of strings. When an array is used, the engine participates in every listed tab simultaneously.

export const type = "books"              // creates a Books tab
export const type = ["web", "karakeep"]  // shows in Web tab AND Karakeep tab

Users can also override the type from the engine's Advanced settings using the Engine type override field. Comma-separated values are supported for multiple types, e.g. web,karakeep. A runtime override always takes precedence over the exported type.

Language & time filters

When someone selects a language or time filter in the UI, those values get passed to every engine through the context argument. You do not have to read them if you do not want to. Engines that ignore these will just return unfiltered results.

Field Type Description
context.lang string | undefined The ISO 639 1 language code chosen by the user (like "en", "de", or "it"). If it is undefined, no language filter was selected.
context.buildAcceptLanguage() () => string Returns a ready to use Accept-Language header value derived from context.lang. It safely falls back to "en,en-US;q=0.9" when no language is set so you can pass it directly to your fetch headers.
context.dateFrom string | undefined The start of a custom date range formatted as YYYY-MM-DD. This is only set when the timeFilter is custom.
context.dateTo string | undefined The end of a custom date range formatted as YYYY-MM-DD. This is only set when the timeFilter is custom.
context.extractImageUrl($el, baseUrl?, selectors?) ($el, baseUrl?, selectors?) => string Easily extract an article image URL from a cheerio element. It checks all the common attributes like src and data src, normalizes URLs, and resolves relative paths against your baseUrl. It even skips tiny images and favicons. You can pass selectors to target a specific thumbnail container first.
context.signProxyUrl(url) (url: string) => string Returns a signed /api/proxy/image URL for the given external image URL. Use this when you include thumbnails in your results so they load correctly regardless of the outgoing allowlist configuration.
context.sentinel(response, name?) (response: { ok, status }, name?: string) => void Call this right after every doFetch in your engine. If the upstream returned a non-OK status (403, 429, 5xx, ...) it throws a structured SentinelBreach with a status of "blocked", "rate_limited" or "network" that the orchestrator logs and surfaces back to the UI as a real "engine blocked" signal instead of a silent 0 results. Always prefer this to if (!response.ok) return [];.
context.engineError(status, message, opts?) (status, message, opts?: { httpStatus?, engine? }) => Error Build the same structured SentinelBreach manually when you detect a soft block (Cloudflare challenge page, consent interstitial, captcha HTML, empty JSON envelope from a rate-limited API, ...). Valid statuses: "blocked", "rate_limited", "captcha", "interstitial", "parse_error", "timeout", "network".

Surfacing upstream failures

Engines used to silently return [] on a 403 or 429, which made "engine got walled" indistinguishable from "no results for this query". Don't do that anymore. Two rules:

  1. After every const response = await doFetch(...), call context?.sentinel?.(response, this.name); before reading the body. This converts non-OK HTTP into a structured throw the orchestrator understands.
  2. If you have an outer try / catch that returns [] on any error, re-throw structured engine errors so they reach the orchestrator:
    } catch (e) {
      if (e?.name === "SentinelBreach") throw e;
      return [];
    }
    Without the re-throw, your catch swallows the new signal and the UI is back to showing 0 results with no context.

For soft blocks you detect yourself (Cloudflare challenge HTML, JavaScript consent interstitial, empty API envelope from a rate-limited backend, ...) build the error explicitly:

const html = await response.text();
if (html.includes("cf-challenge")) {
  throw context.engineError(
    "captcha",
    `${this.name} hit a Cloudflare challenge`,
    { engine: this.name },
  );
}

The timeFilter parameter passed to executeSearch will be one of these options: "any", "hour", "day", "week", "month", "year", or "custom". If it is "custom", just read context.dateFrom and context.dateTo.

export default class MyEngine {
  name = "My Search";

  async executeSearch(query, page = 1, timeFilter, context) {
    const lang = context?.lang;
    const acceptLang = context?.buildAcceptLanguage?.() ?? "en,en-US;q=0.9";
    const doFetch = context?.fetch ?? fetch;

    const params = new URLSearchParams({ q: query });

    if (lang) params.set("lang", lang);

    if (timeFilter === "day") params.set("when", "24h");
    else if (timeFilter === "week") params.set("when", "7d");
    else if (timeFilter === "custom" && context?.dateFrom) {
      params.set("from", context.dateFrom);
      if (context.dateTo) params.set("to", context.dateTo);
    }

    const res = await doFetch(`https://example.com/search?${params}`, {
      headers: { "Accept-Language": acceptLang },
    });
    // parse and return array of { title, url, snippet, source }
  }
}

Safe Search

Our default engines like Brave and Bing include a Safe Search dropdown in Settings then Engines. The default behavior stays the same where Brave defaults to moderate and the rest default to off. Users can easily raise or lower the filter for each engine individually.

You can add this exact same setting to your custom engines by including a select field in your settingsSchema and applying the value in your executeSearch function:

settingsSchema = [
  {
    key: "safeSearch",
    label: "Safe Search",
    type: "select",
    options: ["off", "moderate", "strict"],
    description: "Filter explicit content from search results.",
  },
];

safeSearch = "moderate";

configure(settings) {
  if (typeof settings.safeSearch === "string") {
    this.safeSearch = settings.safeSearch;
  }
}

async executeSearch(query, page = 1, timeFilter, context) {
  const params = new URLSearchParams({ q: query, safesearch: this.safeSearch });
  // ...
}

Proxies

When someone enables a proxy for search, your engine requests can route through it. To make sure your engine plays nicely with proxies:

  1. Use context.fetch for every outbound HTTP request instead of the global fetch.
export default class MyEngine {
  name = "My Search";
  async executeSearch(query, page = 1, _timeFilter, context) {
    const url = `https://www.example.com/search?q=${encodeURIComponent(query)}`;
    const doFetch = context?.fetch ?? fetch;
    const response = await doFetch(url, { headers: { "User-Agent": "my-engine/1.0" } });
    const html = await response.text();
    // parse and return array of { title, url, snippet, source, ... }
  }
}

Every engine also features an Outgoing HTTP client control under the Advanced section in Settings then Engines. This setting is stored per engine and can be set to fetch, system curl, auto, or any custom transport you install from the Store. You should still use context.fetch in your custom engines so this setting applies automatically.

Client exposure: The network indicator on extension cards in Settings applies to plugins only, not search engines. Your executeSearch calls always run on the server when you use context.fetch. If your results include thumbnail or imageUrl fields, the search theme may load those URLs in the user's browser. Use context.signProxyUrl(url) so images are served through the server instead. See Declaring client exposure for how plugins declare isClientExposed.

Setup

Create your data/engines/ directory or set your DEGOOG_ENGINES_DIR. Add a single file like my-engine.js or a folder with an index.js file. The engine ID will simply be the filename or folder name with an -engine suffix (so my-engine becomes my-engine-engine).

How settings work

  1. Declare your settingsSchema so the Configure button appears in Settings then Engines.
  2. The user saves their preferences and the values are stored in data/plugin-settings.json.
  3. Your configure(settings) function is called right after saving and every time the server restarts.
  4. You can just return an empty array from executeSearch if any required settings are missing.

Examples from the official store

  • Ecosia is a web engine using bangShortcut while correctly utilizing context.fetch.
  • Startpage is another web engine featuring optional Anonymous View settings.
  • Internet Archive is a file type engine that works perfectly as a dependency for the File tab plugin.

If you plan on distributing your engine through the Store, make sure to add a screenshots/ folder inside your engine folder so the Store card looks great.