Degoog Docs

Degoog Plugins

Create custom bang commands, slots, search result tabs, search bar actions, routes, and middleware.

degoog-cli is now available. Scaffold plugin 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.

What plugins are

Your plugins belong in the data/plugins/ directory, or whatever you set DEGOOG_PLUGINS_DIR to. A plugin is simply a folder containing an entry file. You can use a single plugin to handle multiple tasks like a bang command, a slot, search bar actions, HTTP routes, or request middleware.

Folder structure

data/plugins/
  my-plugin/
    index.js
    template.html
    style.css
    script.js
    card.html
    author.json     

Your entry file needs to be index.js, index.ts, index.mjs, or index.cjs.

Asset files

  • template.html: This is the HTML fragment for your plugin output. You can use {{placeholders}} and replace them inside execute(). This gets injected directly into the search results rather than rendering a full page.
  • style.css: This loads automatically when your plugin is active. Make sure to use class names scoped to your specific plugin to avoid styling conflicts. You can check the Styling page for available CSS variables.
  • script.js: Your client side JavaScript that loads automatically.

You can add other files and read them while the app runs using ctx.readFile("filename") inside your init(ctx) function.

Plugin context and init()

The system calls your optional init(ctx) function once during startup before running configure(). The context provides the following:

init(ctx) {
  // ctx.template contains your template.html
  // ctx.dir is the absolute path to your plugin folder
  // ctx.pluginId (alias ctx.id) is your real installed plugin folder ID
  // ctx.apiBase is /api/plugin/<ctx.pluginId>
  // ctx.routeUrl("path") builds /api/plugin/<ctx.pluginId>/path
  // ctx.readFile is an async function to read any file from your plugin folder
  // ctx.signProxyUrl(url) returns a signed /api/proxy/image URL for any external image
  // ctx.fetch(url, init?) is a proxy-aware fetch, respects your outgoing proxy settings
  // ctx.useCache(namespace, defaultTtlMs) returns an async TTL cache { get, set, delete, clear }
}

When your backend code generates a URL that points at one of your own plugin routes (for example an image cache that returns a link to a /thumb route), never hardcode the folder name or derive it from ctx.dir. Store-installed plugins run under a generated folder ID such as <author>-<repo>-<plugin-name>, so the only correct base is ctx.apiBase (or ctx.routeUrl("thumb")). The frontend equivalent is the injected __PLUGIN_ID__ constant.

TTL cache (useCache)

Both init(ctx) and slot execute(query, context) receive useCache: a factory that returns an async namespaced cache. Call ctx.useCache<T>(namespace, defaultTtlMs) (or context.useCache<T>(namespace, defaultTtlMs) in slots) to get an object with await get(key), await set(key, value, ttlMs?), await delete(key), and await clear(). Keys are strings; values are typed by your generic T. Entries expire automatically after the TTL (milliseconds).

The namespace is a per-cache discriminator (for example, "ext:my-plugin:articles"). It is combined with the instance's ID internally, so different plugins never collide. When the optional Valkey sidecar is configured (DEGOOG_VALKEY_URL), useCache transparently becomes a shared cache across all replicas of the instance; otherwise it falls back to a per-process in-memory store. The API is identical either way.

Typical usage: call useCache once in init, keep the returned cache on a module-level variable, and use await get / await set from execute (for example, for fetched page excerpts). Always prefer ctx.useCache / context.useCache over importing cache helpers from server internals so plugins behave the same in every environment.

Store ctx.fetch during init if your plugin makes outgoing HTTP requests outside of execute(), for example inside custom plugin routes. Using it keeps your requests consistent with the proxy settings configured in Settings.

For example, you might build a plugin that uses both a command template and a separate card template. The RSS plugin does exactly this by reading card.html through ctx.readFile("card.html").

Bang commands

Bang commands run whenever you type !trigger something or something !trigger into the search bar. You need to export a single object using export default or export const command containing these properties:

  • name and description: These appear in Settings and the !help menu.
  • trigger: The word you type right after the ! symbol. For example, typing weather becomes !weather.
  • execute(args, context) (async): The args parameter contains whatever text follows or precedes your trigger. You must return an object containing a title string and an html string. You can also include an optional totalPages number.

The second argument passed to execute is context, which might contain your clientIp, page, and signProxyUrl(url). Call context.signProxyUrl to get a signed /api/proxy/image URL for any external image you want to serve through the built-in image proxy.

You can also include optional properties like aliases for extra triggers, or naturalLanguagePhrases which are phrases that trigger the command without needing the ! symbol when natural language is enabled in Settings. You can also use settingsSchema, configure(settings), init(ctx), and isConfigured(). If isConfigured() is async and returns false, your command stays hidden from the !help menu until the user configures it.

For your settingsSchema, each field requires a key, label, and type. The type can be text, password, url, toggle, textarea, select, or urllist. You can also add optional flags like required, placeholder, description, or secret. Secret fields are never sent back to the browser. If you use the select type, you will need to add an options array containing your string choices.

How settings work

  1. Declare your settingsSchema so a Configure button appears in Settings then Plugins.
  2. When you save, the values are stored in data/plugin-settings.json under <folderName>-command.
  3. The configure(settings) function runs right after saving and every time the server restarts if settings are present.
  4. Use isConfigured() to return false when required settings are missing. This hides the command from the !help menu.
  5. You can disable any plugin using the toggle in Settings then Plugins. Disabled plugins disappear from !help and return an error if you try to invoke them.

Examples from the official store:

  • Weather is a bang command complete with a template, styles, settings for things like default city or Fahrenheit, aliases, and natural language phrases.
  • Define, QR, Time, and Password are examples of simple command only plugins.
  • Jellyfin and Meilisearch let you search your own personal backend through a bang command.

Slot plugins

Slots let you inject custom panels directly into the search results page when a query matches. Just export slot or slotPlugin. A single module can actually export both a slot and a bang command.

  • id and name: Your unique identifier and display name.
  • position: Choose between above-results, below-results, above-sidebar, below-sidebar, knowledge-panel, or at-a-glance. Please use the specific sidebar positions instead of the old deprecated sidebar option.
  • trigger(query): This needs to return true, or a Promise that resolves to true, whenever the slot should appear for a specific query.
  • execute(query, context) (async): Return an object with an optional title string and an html string. If you return an empty string for the html, nothing will show up. The context includes clientIp; results (only when waitForResults: true); proxy-aware fetch(url, init?); signProxyUrl(url) for external images; and useCache(namespace, defaultTtlMs) for the same async TTL cache factory as in init(ctx). Note that the results array is only populated if you set waitForResults: true on your plugin.

You can add an optional settingsId to specify the key where your settings are stored. This defaults to <id>-slot. You can also include settingsSchema, configure(settings), and init(ctx). If multiple slots match the exact same query, they will all display together.

Another optional flag is waitForResults. Set this to true if you need to receive context.results inside your execute() function. While results are always available when execution happens since slots run after the search finishes, they are only passed to your plugin if you explicitly enable this flag. It defaults to false so plugins that do not need results can stay completely independent.

You can also provide an optional slotPositions array. This lets you list multiple position values like ["knowledge-panel", "above-sidebar", "below-sidebar"]. If you include this, the application adds a Position dropdown in your plugin settings so you can choose exactly where the slot appears. If you leave it empty or omit it, the slot simply uses your fixed position value.

Sidebar positions

Using above-sidebar renders right at the top of the sidebar before any main content. Using below-sidebar renders at the very bottom of the sidebar below the engine timings and related searches.

Knowledge panel slot

Setting your position to knowledge-panel replaces only the very first block in the sidebar, which is usually the Wikipedia or knowledge panel. Your engine performance and related searches blocks stay exactly where they are. When your plugin returns content for this position, it shows up instead of the default knowledge panel.

At a glance slot

Slots using the at-a-glance position will fill the block directly above the search results. This does not delay your search response. The client shows your results immediately and fetches the panel content in a separate background request using POST /api/slots/glance with the query and results. Your execute(query, context) function will always receive context.results for these types of slots.

Examples from the official store:

  • TMDb is a slot above the results showing movie and TV details. It uses a secret API key stored in settings.
  • Math and GitHub are examples of slots appearing above the results.
  • RSS is a slot that reads feed URLs from settings and uses card.html through ctx.readFile().

Slot API: Calling POST /api/slots with the query and results returns the panels for the main positions. Calling POST /api/slots/glance returns only the glance panels. Slots also work perfectly for custom tabs. The client simply fetches the panels after a tab search and renders them in the exact same positions.

Search result tabs

Search result tabs let you add new entries to the main tab bar, just like the Web or Images tabs. Simply export tab or searchResultTab from your plugin folder. You use the exact same folder structure as any other plugin.

Required properties: You must provide an id and a name. You also need to provide either an engineType string like web, images, videos, news, or a custom type from your engines, or an executeSearch(query, page?, context?) async function. If you use the function, it must return an object containing your results array and an optional totalPages number. Each result needs a title, url, snippet, and source, plus an optional thumbnail and duration.

Optional properties: You can add an icon, settingsId, settingsSchema, configure(settings), and init(ctx). If you are sharing this on the Store, make sure to set your type to search-result-tab in your package.json. You can also add a dependencies array containing URLs if your tab relies on a specific engine, just like how the File tab depends on the Internet Archive engine.

API: A GET request to /api/search-tabs returns the list of available tabs. A GET request to /api/tab-search along with your tab id, query, and page number will run the specific search for that tab.

Search bar actions

Your plugins can also add buttons right next to the search bar. Just export searchBarActions as an array of objects containing an id, a label, and a type. The type can be navigate to open a specific URL, bang to fill the search bar with your trigger, or custom. If you use custom, your script.js will listen for a search-bar-action event that contains the action details. You can also include an optional icon image URL.

Plugin routes

Plugins can expose their own HTTP endpoints under the /api/plugin/<folderName>/ path. You do this by exporting routes as an array containing the method, path, and handler(req). Your method can be get, post, put, delete, or patch. The path is just the URL segment that comes after your plugin id. Your handler receives the standard Request object and needs to return a Response or a Promise that resolves to a Response. These routes become available the moment your plugin loads, meaning your script.js can call them immediately using fetch.

When installed from the store, the plugin folder name is <author>-<repo>-<plugin-name>. Never hardcode that in your API URLs. In frontend scripts use the injected __PLUGIN_ID__ constant, which Degoog injects automatically into every plugin script at serve time. In backend plugin code use ctx.apiBase or ctx.routeUrl("path") from init(ctx).

// frontend script.js
const MY_API = `/api/plugin/${__PLUGIN_ID__}/data`;
fetch(MY_API).then(r => r.json()).then(data => { /* ... */ });

// backend index.js
let apiBase = "";
export default {
  routes: [{ method: "get", path: "/thumb", handler: async (req) => { /* ... */ } }],
  init(ctx) { apiBase = ctx.apiBase; },
  async execute() {
    return { title: "Example", html: `<img src="${apiBase}/thumb?id=abc">` };
  },
};

Query interceptors

Interceptors run before the search engines execute. They receive the raw query the user typed and return the query that should actually be searched. The plugin can do anything in between: correct spelling, expand abbreviations, translate, enrich, or replace the query entirely. Export interceptor as an object with a name, a description, and an intercept(query, context) async function that returns { query: string }.

You can also return an optional overrides object to influence how the search is executed - without touching the query text at all. This is useful when your interceptor has enough information to determine the best search context upfront.

export const interceptor = {
  name: "My Interceptor",
  description: "Does something to the query.",

  settingsSchema: [
    { key: "enabled", label: "Enabled", type: "toggle", default: "true" },
  ],

  configure(settings) {
    // called when settings change
  },

  init(ctx) {
    // same ctx as other plugin types: ctx.fetch, ctx.useCache, ctx.readFile
  },

  async intercept(query, context) {
    // context: { fetch, useCache, lang }
    // return the query to actually search - may be the original or anything else
    // overrides is optional: searchType routes to a tab, lang/timeFilter override search params
    return { query, overrides: { searchType: "my-tab" } };
  },
};

Supported overrides

  • searchType - route the search to a specific tab by its engine type (e.g. "images", "news", or any custom tab type registered by a plugin)
  • lang - override the language for this search
  • timeFilter - override the time filter (e.g. "day", "week")

All fields are optional. When multiple interceptors run, overrides are merged in order - later interceptors win for each field.

Interceptors are a plugin subtype - they live in the same data/plugins/ directory as bang commands and slots. A single plugin folder can export an interceptor alongside a slot or command if needed. There are no builtin interceptors; all interceptors come from plugins.

Multi-hook plugins

A single plugin folder can export any combination of hooks - a slot, an interceptor, a bang command, a tab, middleware, or a route. By default each hook gets its own settings card in the UI. To merge them all into a single card with shared settings, export a top-level plugin object as the plugin's identity:

export const plugin = {
  id: "my-plugin",        // shared settingsId for all hooks in this file
  name: "My Plugin",      // name shown on the settings card
  description: "...",

  // fields listed here appear first in the combined settings form
  settingsSchema: [
    { key: "url", label: "Service URL", type: "url" },
    { key: "apiKey", label: "API Key", type: "password", secret: true },
  ],
};

// registered as a hook - not shown as a separate card
export const slot = {
  name: "My Slot",
  position: "above-results",
  // slot-specific fields (appended after plugin fields in the form)
  settingsSchema: [],
  configure(settings) { /* receives the full merged settings object */ },
  async trigger(query) { return query.length > 0; },
  async execute(query, context) { return { html: "" }; },
};

// registered as a hook - not shown as a separate card
export const interceptor = {
  name: "My Interceptor",
  // interceptor-specific fields (appended after slot fields in the form)
  settingsSchema: [
    { key: "firstMode", label: "Enable first mode", type: "toggle" },
  ],
  configure(settings) { /* also receives the full merged settings object */ },
  async intercept(query) { return { query }; },
};

When plugin is present, both hooks use plugin.id as their settingsId. The settings form shows manifest fields first, then slot fields, then interceptor fields. Saving calls configure() on each hook separately with the same settings object - shared module-level state (caches, config variables) works naturally since all hooks live in the same file. Plugins that do not export plugin are unaffected and continue to show as individual cards.

Request middleware

Middleware allows your plugin to hook into specific request flows. You export middleware as an object containing an id, a name, and a handle(req, context) function. The context might include specific route information like settings-auth. Your function should return a Response, a redirect URL object, or simply null to let the request continue normally. The application uses this middleware for the settings gate. To choose a plugin for the gate, just click Use as settings gate in that plugin configuration menu under Settings. This updates your plugin-settings.json file automatically.

Settings gate (login flow)

You can protect your settings using a simple password with DEGOOG_SETTINGS_PASSWORDS or by using a middleware plugin. When you use a plugin, anyone opening the Settings page goes through your custom flow like a magic link or external login before returning to the settings with a valid session token.

What your middleware must do

  • route "settings-auth": Return a JSON object with required set to true, valid set to false, and your loginUrl.
  • route "settings-auth-callback": After a successful authentication, return a redirect to /settings. The application will issue a session token and redirect the user automatically.
  • route "settings-auth-post": If you do not want to support password submissions via POST, just return a 400 error or something similar.

Make sure to expose a GET /login route that redirects back to the callback URL. You can clear the degoog-settings-token in your browser Session Storage anytime you need to test the flow.

Example: minimal bang command

Here is what a simple folder at data/plugins/greeting/ looks like with an index.js, template.html, and style.css file.

let template = "";
export default {
  name: "Greeting",
  description: "Say hello",
  trigger: "hello",
  init(ctx) { template = ctx.template; },
  async execute(args) {
    const name = args.trim() || "world";
    const html = template.replace("{{name}}", name);
    return { title: "Hello", html };
  },
};

template.html

<div class="command-result greeting">
  <h3 class="greeting-title">Hello, {{name}}!</h3>
</div>

For your style.css, you should use variables like var(--text-primary) to ensure everything matches. You can find more details on the Styling page.

Declaring client exposure

The settings page shows a small icon on each extension card indicating whether the extension causes the user's browser to contact external services directly. Set isClientExposed on your exported object so users know what to expect:

Value Meaning Badge
true The browser makes direct requests to external services (images, APIs, scripts rendered into HTML). Orange triangle
false All network access goes through the server. Nothing contacts the outside from the browser. Green checkmark
not set The extension does not declare its network behaviour. Users see an ambiguous indicator. Grey info

Use context.signProxyUrl(url) to route external images through the server proxy. Doing so means you can declare isClientExposed: false.

export const slot = {
  name: "My Slot",
  isClientExposed: false, // all images are proxied via signProxyUrl
  async execute(query, context) {
    const imgUrl = context.signProxyUrl("https://example.com/cover.jpg");
    return { html: `<img src="${imgUrl}">` };
  },
};
export const slot = {
  name: "My Slot",
  isClientExposed: true, // renders raw external URLs the browser will fetch
  async execute(query, context) {
    return { html: `<img src="https://example.com/cover.jpg">` };
  },
};

Debugging plugins

If you need to troubleshoot, set LOG_LEVEL=debug to print the execution times for all your plugin types directly to the server console. You can check the Environment variables page for more details.