Degoog Docs

Degoog Extension development

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

degoog-cli is now available. It scaffolds plugin boilerplate from your terminal, so you don't copy-paste files or guess 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 a folder with an entry file in it. One plugin can do several jobs at once, such as a bang command, a slot, search bar actions, HTTP routes and 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 holds the HTML fragment for your plugin's output. Put {{placeholders}} in it and replace them inside execute(). Degoog injects the fragment into the search results. It is not a full page.
  • style.css loads automatically while your plugin is active. Scope your class names to your plugin so they don't clash with other styles. The Styling page lists the available CSS variables.
  • script.js is your client-side JavaScript, and it also 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()

Degoog calls your optional init(ctx) function once at startup, before configure(). The context has these members:

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.signFaviconUrl(url) returns a signed /api/proxy/favicon URL for the url's host, or "" when no favicon provider is enabled and the favicon store is off
  // ctx.fetch(url, init?) is a proxy-aware fetch, respects your outgoing proxy settings
  // ctx.searchTypes() resolves to the search types this instance has tabs for, "web" first
  // ctx.useCache(namespace, defaultTtlMs) returns an async TTL cache { get, set, delete, clear }
}

When your backend code builds 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 use ctx.apiBase or ctx.routeUrl("thumb") as the base. In frontend code, use 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, and your generic T types the values. Entries expire once the TTL, in milliseconds, runs out.

The namespace keeps one cache apart from another, for example "ext:my-plugin:articles". Degoog combines it with the instance's ID, so different plugins never collide. If you configure the optional Valkey sidecar with DEGOOG_VALKEY_URL, useCache becomes a cache shared by every replica of the instance. Without it, you get an in-memory store per process. The API is the same either way.

The usual pattern is to call useCache once in init, keep the returned cache in a module-level variable, and call await get / await set from execute, for example to hold fetched page excerpts. Use ctx.useCache / context.useCache instead of importing cache helpers from server internals, so your plugin behaves 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. Those requests then follow the proxy settings in Settings.

A plugin can use a command template and a separate card template. The RSS plugin does this, reading card.html with ctx.readFile("card.html").

Bang commands

A bang command runs when you type !trigger something or something !trigger into the search bar. Export one object with export default or export const command that has these properties:

  • name and description appear in Settings and the !help menu.
  • trigger is the word you type right after the ! symbol. For example, weather becomes !weather.
  • execute(args, context) is async. args holds the text before or after your trigger. Return an object with a title string and an html string. It can also have an optional totalPages number and an optional searchType.

By default the results page hides its tabs while a command's output is showing. Set searchType on your command, for example searchType: "images", and the results page switches to that tab instead. The tab shows as selected, the URL carries the type, and images uses the wide media layout. Returning searchType from execute overrides the declared one for that call. Pick one of the types from ctx.searchTypes(). That also lets you offer the choice as a setting:

let searchTypes = null;
let tab = "web";

export default {
  name: "My library",
  trigger: "library",
  settingsSchema: [
    {
      key: "tab",
      label: "Show results on",
      type: "select",
      default: "web",
      options: ["web"],
      optionsFrom: { dependsOn: [], refreshLabel: "Load tabs", auto: true },
    },
  ],
  init(ctx) { searchTypes = ctx.searchTypes; },
  configure(settings) { tab = settings.tab || "web"; },
  async getFieldOptions(key) {
    if (key !== "tab") return { options: [] };
    return { options: searchTypes ? await searchTypes() : ["web"] };
  },
  async execute(args, context) {
    return { title: "My library", html: render(args, context.searchType), searchType: tab };
  },
};

A command can return results instead of html. That is an array of normal search results with title, url, snippet, source and optional thumbnail and imageUrl. Degoog renders them the same way as results from an engine on that tab, with the image grid, the preview panel, pagination and infinite scroll. Give plain image URLs. Degoog signs them through the image proxy, so the browser never contacts your service. Each result gets the favicon of its own site. To show a different one, set favicon to a URL from ctx.signFaviconUrl(url). Degoog drops results that have no title or whose URL is not http or https.

return {
  title: `My library: ${args}`,
  html: "",
  results: items.map((item) => ({
    title: item.name,
    url: item.link,
    snippet: item.summary,
    source: "My library",
    thumbnail: item.posterUrl,
    favicon: signFaviconUrl("https://example.org"),
  })),
  totalPages,
};

The second argument to execute is context. It can hold clientIp, page, signProxyUrl(url) and searchType, which is the tab the search came from and defaults to web. 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. On an instance that serves the script-free /nojs page, context.nojs is true, and your command only runs there if you set supportsNojs: true. See No-JS support.

Optional properties include aliases for extra triggers and naturalLanguagePhrases, phrases that run the command without the ! symbol when natural language is on in Settings. You can also use settingsSchema, configure(settings), init(ctx), and isConfigured(). If isConfigured() is async and returns false, your command stays out of the !help menu until the user configures it.

Each settingsSchema field needs a key, label, and type. The type can be text, password, url, number, toggle, textarea, select, hex, range, file, urllist, list, or info. Optional flags include required, placeholder, description, and secret. Degoog never sends secret fields back to the browser. A select field needs an options array of string choices, or an optionsFrom block if your extension fetches the choices itself.

The hex type shows a colour picker next to a text input and stores a #rrggbb string. The range type renders a slider. Set min, max and step, and it stores the chosen number as a string. The file type lets users upload a file. Restrict it with accept, a comma-separated list of MIME types or extensions such as "image/*,.png", and with maxSizeKb / minSizeKb. Degoog stores the file in your plugin's own uploads/ folder, and the setting value is the served path, for example /plugins/<folderName>/uploads/logo-abc123.png. Use it as-is or read it with ctx.readFile.

To group related fields, give them the same fieldset string. The Configure modal renders matching fields together under one titled fieldset, in the order it first meets them.

The list type lets users manage a repeatable list of structured rows. Add an itemSchema array that describes one row. Each entry is itself a field with a key, label and a type such as text, toggle, hex, range or file. An optional addLabel names the add button. Users reorder rows by drag and drop, or with the arrow keys on the grip handle, and the stored order is the row position. Degoog stores the value as a JSON array of objects and passes it to configure(settings) that way, so parse it with JSON.parse.

settingsSchema: [
  {
    key: "bangs",
    label: "Custom bangs",
    type: "list",
    addLabel: "+ Add bang",
    itemSchema: [
      { key: "name", label: "Name", type: "text" },
      { key: "shortcut", label: "Shortcut", type: "text" },
      { key: "url", label: "URL template", type: "text" },
      { key: "openBase", label: "Open base path", type: "toggle" }
    ]
  }
]

Fields that fetch their own options

A select or text-style field can ask your extension for its choices at configure time instead of hardcoding them. Add an optionsFrom block to the field and export a getFieldOptions(key, values) function alongside configure(settings). Degoog renders a button next to the field. Clicking it calls your function with the values currently typed into the dialog, so you can call an API with a base URL the user has not saved yet.

Inside optionsFrom, dependsOn lists the field keys your lookup needs, refreshLabel renames the button, emptyHint shows before the first fetch, and auto: true runs the lookup when the dialog opens and every dependency already has a value. On a select, the fetched values replace the dropdown. On a text field they become suggestions in a datalist, so the user can still type a value that is not on the list.

Return { options, notice }. Each option is a plain string or { value, label }. notice is a short line of feedback under the field, a good place to report what your lookup found. Degoog handles secret fields for you. Your function gets the values with the stored secret filled in, even though the browser never sent it back. Lookups are capped at 500 options and 15 seconds.

settingsSchema: [
  { key: "baseUrl", label: "API Base URL", type: "url" },
  {
    key: "model",
    label: "Model",
    type: "text",
    optionsFrom: {
      dependsOn: ["baseUrl", "apiKey"],
      refreshLabel: "Fetch models",
      emptyHint: "Set the base URL, then fetch the model list."
    }
  }
],

async getFieldOptions(key, values) {
  if (key !== "model") return { options: [] };
  const res = await fetch(`${values.baseUrl}/api/tags`);
  const data = await res.json();
  return {
    options: data.models.map((m) => m.name),
    notice: `Found ${data.models.length} models`
  };
}

Fields that show and hide

Give a field visibleWhen and it only appears while another field holds a given value. The rule is { key, equals }. key names the field to watch, and equals is the value it must hold. For a toggle that value is the string "true" or "false".

equals also takes an array, and the field shows when the watched value is any of them. One dropdown can drive a whole form this way. Every field lists the options it belongs to, and the modal swaps fields as soon as the user picks another option, before anything is saved. Pass an array of rules instead of one and every rule has to match. Any field type takes a rule, info included, so a short note can follow the option it explains.

Use notEquals in place of equals to show a field while the watched value is anything else. notEquals: "" means "once it has a value", which suits a timeout that only matters after a URL is filled in. A saved secret counts as having a value even though the browser never sees it. To show a field when either of two things is true, wrap the rules in anyOf:

visibleWhen: {
  anyOf: [
    { key: "useContainer", equals: "true" },
    { key: "proxyType", notEquals: "none" }
  ]
}

Fields inside a list row's itemSchema take the same rules, and they read the other fields of their own row.

Hiding cascades. When the field a rule watches is hidden, the fields that watch it hide too, so a toggle can switch off a dropdown and everything that hangs off it. A hidden required field does not count as missing, so an extension is not flagged as needing setup over a field the user cannot see. Hidden fields keep their saved values and still reach configure(settings), so read the dropdown before trusting them.

settingsSchema: [
  {
    key: "provider",
    label: "Provider",
    type: "select",
    options: ["ollama", "openai", "azure"],
    optionLabels: ["Ollama", "OpenAI", "Azure OpenAI"],
    default: "ollama"
  },
  { key: "advancedMode", label: "Show tuning options", type: "toggle" },
  {
    key: "apiKey",
    label: "API key",
    type: "password",
    secret: true,
    required: true,
    visibleWhen: { key: "provider", equals: ["openai", "azure"] }
  },
  {
    key: "deployment",
    label: "Deployment name",
    type: "text",
    required: true,
    visibleWhen: { key: "provider", equals: "azure" }
  },
  {
    key: "keepAlive",
    label: "Keep the model loaded",
    type: "toggle",
    visibleWhen: [
      { key: "provider", equals: "ollama" },
      { key: "advancedMode", equals: "true" }
    ]
  }
]

How settings work

  1. Declare your settingsSchema and a Configure button appears in Settings then Plugins.
  2. Saving stores the values in data/plugin-settings.json under <folderName>-command.
  3. The configure(settings) function runs right after a save, and on every server restart when settings exist.
  4. Return false from isConfigured() when required settings are missing. That hides the command from the !help menu.
  5. Disable any plugin with its toggle in Settings then Plugins. Disabled plugins drop out of !help and return an error if you try to invoke them.

Set needsAppRestart: true on an exported plugin object if your plugin cannot work until the Degoog server process restarts, for example because it registers a WebSocket route that Degoog only mounts at boot. When someone installs or updates it from the Store, Degoog shows a restart required warning on the extension and in the admin Server tab until the instance owner restarts.

needsAppRestart: true,

Examples from the official store:

  • Custom Bangs lets users define their own bang shortcuts that redirect to any site. It uses a list setting for the bangs and a small client script that rewrites the query. Type !<shortcut> terms, with the bang before or after the terms. The query replaces the {{{s}}} or %s placeholder in the URL template, and a bare bang can open the base path or snap domain.
  • Weather is a bang command with a template, styles, settings such as default city or Fahrenheit, aliases, and natural language phrases.
  • Define, QR, Time, and Password are simple command-only plugins.
  • Jellyfin and Meilisearch search your own backend through a bang command.

Slot plugins

A slot injects a custom panel into the search results page when a query matches. Export slot or slotPlugin. One module can export both a slot and a bang command.

  • id and name are your unique identifier and display name.
  • position is one of above-results, below-results, full-width-above-results, above-sidebar, below-sidebar, knowledge-panel, or at-a-glance. Use the specific sidebar positions, not the old deprecated sidebar option.
  • trigger(query) returns true, or a Promise that resolves to true, when the slot should appear for a query.
  • execute(query, context) is async and returns an object with an optional title string and an html string. An empty html string shows nothing. The context includes clientIp; results (only when waitForResults: true); proxy-aware fetch(url, init?); signProxyUrl(url) for external images; signFaviconUrl(url) for a result's favicon, which returns an empty string when no favicon provider is enabled and the favicon store is off; and useCache(namespace, defaultTtlMs) for the same async TTL cache factory as in init(ctx). The results array is only filled when you set waitForResults: true on your plugin.

The optional settingsId sets the key your settings are stored under. It defaults to <id>-slot. You can also include settingsSchema, configure(settings), and init(ctx). When several slots match the same query, they all show.

Panels are left out of the script-free /nojs page unless you set supportsNojs: true, and your execute receives context.nojs when it renders there. The rules and the reasoning are in No-JS support.

Set the optional waitForResults flag to true to receive context.results in your execute() function. Slots run after the search finishes, so the results always exist by then, but Degoog only passes them to your plugin when this flag is on. It defaults to false, so plugins that don't need results stay independent of them.

The optional slotPositions array lists several positions, like ["knowledge-panel", "above-sidebar", "below-sidebar"]. With it, Degoog adds a Position dropdown to your plugin settings so you can choose where the slot appears. Leave it empty or out and the slot uses your fixed position value.

Full width above results slot

The full-width-above-results position renders your panel above the whole results layout, across the full content width of both the results column and the sidebar. Degoog gives this slot only the shared width and horizontal padding, so it lines up with the rest of the page. It adds no background, border, radius, or inner padding, so the look is up to you. Use this position for large media-style panels that need more room than the results column has.

Sidebar positions

above-sidebar renders at the top of the sidebar, before the main content. below-sidebar renders at the bottom of the sidebar, below the engine timings and related searches.

Knowledge panel slot

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

At a glance slot

A slot in the at-a-glance position fills the block above the search results, without delaying the search response. The client shows the results first and fetches the panel in a separate background request to POST /api/slots/glance with the query and results. For these slots, your execute(query, context) function always receives context.results.

Targeting a slot from CSS

The top element of every injected slot panel carries a data-slot attribute set to your extension id. Target a specific slot from your style.css with it instead of relying on the generic wrapper classes. For example, a slot with the id tmdb-slot can be styled with [data-slot="tmdb-slot"] { ... }. This works for the results and sidebar positions and for full-width-above-results.

Examples from the official store:

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

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

Search result tabs

A search result tab adds an entry to the main tab bar, next to tabs like Web and Images. Export tab or searchResultTab from your plugin folder. The folder structure is the same as for any other plugin.

A tab needs an id and a name. It also needs either an engineType string like web, images, videos, news, or a custom type from your engines, or an executeSearch(query, page?, context?) async function. The function returns an object with your results array and an optional totalPages number. Each result needs a title, url, snippet, and source, and can have a thumbnail and a duration.

The optional properties are icon, settingsId, settingsSchema, configure(settings), and init(ctx). To share the tab on the Store, set your type to search-result-tab in your package.json. If your tab relies on a specific engine, add a dependencies array of URLs, the way the File tab depends on the Internet Archive engine.

Set needsAppRestart: true if your tab cannot work until the Degoog server process restarts, for example because it registers a WebSocket route that Degoog only mounts at boot. When someone installs or updates it from the Store, Degoog shows a restart required warning on the extension and in the admin Server tab until the instance owner restarts.

needsAppRestart: true,

A GET request to /api/search-tabs returns the available tabs. A GET request to /api/tab-search with your tab id, query, and page number runs the search for that tab.

Search bar actions

Plugins can add buttons next to the search bar. Export searchBarActions as an array of objects, each with an id, a label, and a type. navigate opens a URL, bang fills the search bar with your trigger, and custom fires a search-bar-action event with the action details, which your script.js listens for. An action can also have an optional icon image URL.

Plugin routes

Plugins can expose their own HTTP endpoints under the /api/plugin/<folderName>/ path. Export routes as an array of objects with a method, path, and handler(req). The method can be get, post, put, delete, or patch. The path is the URL segment after your plugin id. Your handler receives the standard Request object and returns a Response or a Promise that resolves to one. Routes go live as soon as your plugin loads, so your script.js can fetch them right away.

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 __PLUGIN_ID__ constant, which Degoog injects into every plugin script when it serves it. 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">` };
  },
};

Routes are public, so anyone who can reach your instance can call them. Degoog's IP blocklist always applies. Set rateLimit: true on a route that does expensive work, such as calling a model, and Degoog applies the instance's own rate limit settings to it, the same ones searches use. Each extension gets its own bucket, so busy routes from one extension do not use up another's. Leave it off for routes the browser calls many times per page, such as thumbnails or model files.

routes: [
  { method: "post", path: "/describe", rateLimit: true, handler: describe },
  { method: "get", path: "/thumb", handler: thumb },
]

Re-ranking results from the browser

Plugin scripts can reorder, label and hide results on the Images and Videos tabs through window.degoog.results. Plugin scripts can load before Degoog's own script, so wait for the degoog-results-api-ready event when window.degoog is not there yet.

  • current() returns { query, type, search, settled, image } for the search on screen. search is a number that changes with every new search. settled turns true once every engine has answered for the first page, and stays false while results are still streaming in. image is null for a text search, otherwise { id, dataUrl, query }, where query is the text query a plugin generated from the image, or null if there is none yet.
  • list() returns the current results in display order.
  • setRanking(id, ranking) applies a ranking, and setRanking(id, null) removes it. A ranking can have compare(a, b) to sort results, hidden(result) to hide them, and badge(result) to label them with a string or { text, tone }, where tone is "strong" or "weak". Hidden results move to the end. Results that compare equal keep their original order.
  • refresh() runs the ranking again. Call it when your scores change.

A ranking only lasts for the search it was set on. Degoog drops it when a new search starts, and applies it to results loaded later by infinite scroll. Degoog fires degoog-results-ready on window whenever results are rendered or more are loaded. That is when to score the new ones. It fires degoog-results-settled once per search, with { search } in detail, when the last engine has answered. Wait for it before you tell the visitor you're finished, because a slow engine can still add results after the first ones render.

const api = window.degoog.results;
const scores = new Map();
api.setRanking(__PLUGIN_ID__, {
  compare: (a, b) => (scores.get(b.thumbnail) ?? 0) - (scores.get(a.thumbnail) ?? 0),
  badge: (r) => (scores.has(r.thumbnail) ? `${Math.round(scores.get(r.thumbnail) * 100)}%` : null),
});
window.addEventListener("degoog-results-ready", async () => {
  for (const r of api.list()) if (!scores.has(r.thumbnail)) scores.set(r.thumbnail, await score(r));
  api.refresh();
});

window.degoog.search(query, type) runs a search the same way the search bar does. On the results page it searches in place. From the home page it opens the results page, and keeps the query out of the URL when the visitor has POST search turned on.

window.degoog.pickImage() opens the file picker for an image search. It only exists while at least one enabled engine can search with an image. Degoog fires degoog-image-query on window with { query, error } in detail once it knows the generated query for the image search on screen.

Turning images into text queries

A bang command can give degoog a text query for an image. When someone searches with an image, degoog asks the first enabled command that has an imageQuery method, then runs the answer through the normal text engines of that tab, next to the engines that take the image itself (see Image input). Words typed next to the image arrive as context.text.

export default {
  name: "Image describer",
  trigger: "describe",
  async execute() { /* the bang itself */ },

  describesImages() {
    return settings.enabled;
  },

  async imageQuery(image, context) {
    // image: { bytes, mime, base64, hash }
    // context: { text, lang, signal }
    const query = await askMyModel(image.base64, context.text, context.signal);
    return query; // a string, or null for no query
  },
};
  • describesImages() is optional. Return false to sit out without disabling the whole plugin.
  • Throw an Error with a translated message when it fails. Degoog shows the message when the search has no other results, and the engines that take the image still run.
  • Degoog asks once per image and words. The browser keeps the answer and sends it back for later pages, retries and reloads, so your model isn't asked again.
  • The image is only in memory while the search runs. Don't store it, and if your plugin sends it somewhere else, say so in its settings.

Query interceptors

Interceptors run before the search engines. They receive the raw query the user typed and return the query to search. In between, the plugin can do anything, such as fix 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 change how the search runs without touching the query text. Use it when your interceptor already knows the best search context.

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 routes the search to a tab by its engine type, such as "images", "news", or any custom tab type a plugin registers.
  • lang overrides the language for this search.
  • timeFilter overrides the time filter, such as "day" or "week".

Every field is optional. When several interceptors run, Degoog merges their overrides in order, and the later interceptor wins for each field.

Interceptors are a kind of plugin and live in the same data/plugins/ directory as bang commands and slots. One plugin folder can export an interceptor alongside a slot or command. Degoog has no built-in interceptors. They all come from plugins.

Multi-hook plugins

One plugin folder can export any combination of hooks, such as 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 into one 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. All hooks live in the same file, so module-level state such as caches and config variables is shared between them. Plugins that don't export plugin still show as individual cards.

Request middleware

Middleware lets your plugin hook into specific request flows. Export middleware as an object with an id, a name, and a handle(req, context) function. The context can include route information such as settings-auth. Return a Response, a redirect URL object, or null to let the request continue. Degoog uses this middleware for the settings gate. To pick a plugin for the gate, click Use as settings gate in that plugin's configuration menu under Settings. That updates your plugin-settings.json file for you.

Settings gate (login flow)

Protect your settings with a password through DEGOOG_SETTINGS_PASSWORDS, or with a middleware plugin. With a plugin, anyone who opens the Settings page goes through your own flow, such as a magic link or an external login, and comes back to the settings with a valid session token.

What your middleware must do

  • For route "settings-auth", return a JSON object with required set to true, valid set to false, and your loginUrl.
  • For route "settings-auth-callback", return a redirect to /settings after a successful login. Degoog then issues a session token and redirects the user.
  • For route "settings-auth-post", return a 400 error or similar if you don't support password submissions via POST.

Expose a GET /login route that redirects back to the callback URL. To test the flow again, clear degoog-settings-token from your browser's Session Storage.

Example: minimal bang command

A simple plugin at data/plugins/greeting/ has an index.js, a template.html, and a style.css.

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>

In your style.css, use variables like var(--text-primary) so the plugin matches the rest of the page. The Styling page has the details.

Declaring client exposure

Each extension card on the settings page has a small icon that says whether the extension makes the user's browser 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, such as images, APIs, or 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, and context.signFaviconUrl(url) for a site's favicon. Then you can declare isClientExposed: false.

Don't build /api/proxy/favicon URLs by hand. The proxy answers 403 without a valid sig. Don't reuse the favicon field on context.results either. The browser sends it back and the result seal doesn't cover it. Call signFaviconUrl with the result's url instead.

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");
    const favicon = context.signFaviconUrl("https://example.com/page");
    return {
      html: `<img src="${imgUrl}">` + (favicon ? `<img src="${favicon}">` : ""),
    };
  },
};
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

Set LOG_LEVEL=debug to print the execution times for all your plugin types to the server console. The Environment variables page has more details.