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 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 insideexecute(). 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
!helpmenu. -
trigger is the word you type right after the
!symbol. For example,weatherbecomes!weather. -
execute(args, context) is async.
argsholds the text before or after your trigger. Return an object with atitlestring and anhtmlstring. It can also have an optionaltotalPagesnumber and an optionalsearchType.
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
-
Declare your
settingsSchemaand a Configure button appears in Settings then Plugins. -
Saving stores the values in
data/plugin-settings.jsonunder<folderName>-command. -
The
configure(settings)function runs right after a save, and on every server restart when settings exist. -
Return false from
isConfigured()when required settings are missing. That hides the command from the!helpmenu. -
Disable any plugin with its toggle in Settings then Plugins. Disabled
plugins drop out of
!helpand 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
listsetting 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%splaceholder 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, orat-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
titlestring and anhtmlstring. An empty html string shows nothing. ThecontextincludesclientIp;results(only whenwaitForResults: true); proxy-awarefetch(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; anduseCache(namespace, defaultTtlMs)for the same async TTL cache factory as ininit(ctx). The results array is only filled when you setwaitForResults: trueon 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.htmlthroughctx.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.searchis a number that changes with every new search.settledturnstrueonce every engine has answered for the first page, and staysfalsewhile results are still streaming in.imageisnullfor a text search, otherwise{ id, dataUrl, query }, wherequeryis the text query a plugin generated from the image, ornullif there is none yet. -
list()returns the current results in display order. -
setRanking(id, ranking)applies a ranking, andsetRanking(id, null)removes it. A ranking can havecompare(a, b)to sort results,hidden(result)to hide them, andbadge(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. Returnfalseto sit out without disabling the whole plugin. -
Throw an
Errorwith 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
-
searchTyperoutes the search to a tab by its engine type, such as"images","news", or any custom tab type a plugin registers. langoverrides the language for this search.-
timeFilteroverrides 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
/settingsafter 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.