Degoog Extension development
Create custom bang commands, slots, search result tabs, search bar actions, routes, and middleware.
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 insideexecute(). 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
!helpmenu. -
trigger: The word you type right after the
!symbol. For example, typingweatherbecomes!weather. -
execute(args, context) (async): The
argsparameter contains whatever text follows or precedes your trigger. You must return an object containing atitlestring and anhtmlstring. You can also include an optionaltotalPagesnumber.
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, number,
toggle, textarea, select,
hex, range, file, urllist,
list, or info. 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.
The hex type shows a colour picker paired with 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
maxSizeKb / minSizeKb. The file is stored inside
your plugin's own uploads/ folder and the setting value is the
served path (for example
/plugins/<folderName>/uploads/logo-abc123.png), ready to
use directly or via ctx.readFile.
Group related fields by giving them the same fieldset
string; matching fields render together under one titled fieldset in the
Configure modal, in first-seen order.
The list type lets users manage a repeatable list of structured
rows. Add an itemSchema array describing one row (each entry is
itself a field with a key, label and
type such as text, toggle,
hex, range or file), and an optional
addLabel for the add button. Rows can be reordered by drag and
drop (or the keyboard arrow keys on the grip handle); the stored order is the
row position. The value is stored and passed to
configure(settings) as a JSON array of objects, 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" }
]
}
]
How settings work
-
Declare your
settingsSchemaso a Configure button appears in Settings then Plugins. -
When you save, the values are stored in
data/plugin-settings.jsonunder<folderName>-command. -
The
configure(settings)function runs right after saving and every time the server restarts if settings are present. -
Use
isConfigured()to return false when required settings are missing. This hides the command from the!helpmenu. -
You can disable any plugin using the toggle in Settings then Plugins.
Disabled plugins disappear from
!helpand return an error if you try to invoke them.
Set needsAppRestart: true on an exported plugin object if your
plugin cannot work correctly until the Degoog server process restarts, for
example because it registers a WebSocket route that Degoog only mounts at
boot. When installed or updated via the Store, Degoog will show 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
listsetting for the bangs and a small client script that rewrites the query. Type!<shortcut> terms(leading or trailing); the{{{s}}}or%splaceholder in the URL template is replaced with the query, and a bare bang can open the base path or snap domain. - 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,full-width-above-results,above-sidebar,below-sidebar,knowledge-panel, orat-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
titlestring and anhtmlstring. If you return an empty string for the html, nothing will show up. ThecontextincludesclientIp;results(only whenwaitForResults: true); proxy-awarefetch(url, init?);signProxyUrl(url)for external images; anduseCache(namespace, defaultTtlMs)for the same async TTL cache factory as ininit(ctx). Note that the results array is only populated if you setwaitForResults: trueon 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.
Full width above results slot
Setting your position to full-width-above-results
renders your panel above the whole results layout, spanning the full content
width across both the results column and the sidebar. Degoog only gives this
slot the shared width and horizontal padding so it lines up with the rest of
the page. It applies no background, border, radius, or inner padding, so the
look is entirely up to you. This is the recommended position for large
media-style panels that need more room than the results column allows.
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.
Targeting a slot from CSS
Every injected slot panel carries a
data-slot attribute set to your extension id on its top parent
element, so you can target a specific slot from your
style.css without relying on the generic wrapper classes. For
example, a slot with the id tmdb-slot can be styled with
[data-slot="tmdb-slot"] { ... }. This applies to the results and
sidebar positions as well as full-width-above-results.
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.htmlthroughctx.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.
Set needsAppRestart: true if your tab cannot work correctly until
the Degoog server process restarts, for example because it registers a
WebSocket route that Degoog only mounts at boot. When installed or updated via
the Store, Degoog will show a restart required warning on the extension and in
the admin Server tab until the instance owner restarts.
needsAppRestart: true,
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.