Plugins

September 2, 2026 · View on GitHub

Portable Agent Plugins

GemiHub also loads portable Agent Plugins v1.0.0. This is additive to the GemiHub-specific manifest.json + main.js plugin format documented below.

Open Settings > Plugins > Agent Plugins, enter a GitHub repository (owner/repo), and preview the package before installing it. GemiHub uses the latest GitHub Release tag when one exists and otherwise uses the repository's default branch. The resolved commit SHA is stored so updates are reproducible.

Self-hosted deployments should set the optional server-side AGENT_PLUGINS_GITHUB_TOKEN environment variable to a fine-grained GitHub token with read-only access to the repositories being installed. The token is never sent to the browser or stored in Drive; it only raises the GitHub API rate limit for server-side discovery requests.

Supported portable components:

ComponentSupport
Agent Skills in skills/<name>/SKILL.mdYes
streamable-http servers in root mcp.jsonYes
stdio MCP serversNo (reported and skipped)
Legacy sse MCP serversNo (reported and skipped)
Client extension namespacesIgnored unless GemiHub adds explicit support

The repository root must contain plugin.json using the canonical v1.0.0 schema identifier. Skills appear in chat under an internal {plugin-name}.{skill-name} ID, which prevents collisions with Drive-native skills. Imported MCP URLs and literal headers remain package-managed; OAuth credentials discovered or configured by GemiHub are preserved across plugin updates.

Agent Plugin packages are stored separately under gemihub/agent-plugins/{plugin-name}/. Disabling a package hides its skills and MCP servers without deleting it. Uninstalling removes the package and only the MCP entries owned by that package.

Install and update call tools/list for each bundled MCP server and cache its tool definitions. A discovery failure produces a warning without discarding the package; retry with Test in Settings > MCP Servers after correcting the connection or credentials. Enabled Agent Plugin MCP servers are selected in chat once by default when this behavior is first introduced. Later updates do not overwrite the user's explicit selection stored in localStorage.

Extend Gemini Hub with community plugins installed from GitHub Releases. Inspired by Obsidian + BRAT.

Features

  • GitHub Release Install: Install plugins from any public GitHub repo
  • Trusted Execution: Plugins run with the same privileges as the host app. Only install plugins you trust
  • Drive Storage: Plugin files and data stored in Google Drive (plugins/ folder)
  • IndexedDB Cache: Plugin assets cached client-side for fast loading
  • Hot Enable/Disable: Toggle plugins without page reload
  • Plugin API: Access Gemini AI, Drive files, Google Calendar, Gmail, and plugin-scoped storage

For Users

Installing a Plugin

  1. Go to Settings > Plugins
  2. Enter the GitHub repository (e.g. owner/repo or https://github.com/owner/repo)
  3. Click Install

The latest GitHub Release must contain at least manifest.json and main.js. styles.css is optional.

Managing Plugins

ActionDescription
Enable/DisableToggle the power icon next to the plugin
UpdateClick the refresh icon to pull the latest release
UninstallClick the trash icon (removes all plugin data from Drive)

Where Plugin Data is Stored

gemihub/
  plugins/
    {plugin-id}/
      manifest.json   ← Plugin metadata
      main.js          ← Plugin code
      styles.css       ← Plugin styles (optional)
      data.json        ← Plugin-scoped storage

For Plugin Developers

Project Structure

A plugin release must contain these assets:

FileRequiredDescription
manifest.jsonYesPlugin metadata
main.jsYesPlugin entry point (bundled)
styles.cssNoPlugin styles

manifest.json

{
  "id": "my-plugin",
  "name": "My Plugin",
  "version": "1.0.0",
  "minAppVersion": "1.0.0",  // reserved for future use
  "description": "What my plugin does",
  "author": "Your Name",
  "assets": [
    { "name": "model.wasm", "url": "https://cdn.example.com/model.wasm" }
  ]
}

The id must be unique and is used as the Drive folder name and IndexedDB cache key.

The optional assets array declares external files (e.g. WASM binaries, large models) that the plugin needs at runtime. Each entry has a name (filename to reference from code) and a url (HTTPS URL the server downloads from). See External Assets for details.

main.js

Build with esbuild (or similar) with react and react-dom as externals. The entry point must export a class with an onload method:

class MyPlugin {
  onload(api) {
    // Called when plugin is loaded
    // Use api to register views, commands, or call host APIs
  }

  onunload() {
    // Called when plugin is disabled or uninstalled
    // Clean up event listeners, timers, etc.
  }
}

// Both export styles are supported:
module.exports = MyPlugin;
// or: module.exports.default = MyPlugin;

Example esbuild config:

require("esbuild").build({
  entryPoints: ["src/main.tsx"],
  bundle: true,
  outfile: "main.js",
  format: "cjs",
  external: ["react", "react-dom", "react-dom/client"],
  jsx: "automatic",
});

Plugin API

The api object passed to onload provides:

Language

// Current language setting ("en", "ja", etc.)
const lang = api.language;

UI Registration

// Register a sidebar or main view
api.registerView({
  id: "my-view",
  name: "My View",
  icon: "puzzle",       // optional icon identifier
  location: "sidebar", // or "main"
  extensions: [".csv", ".tsv"], // optional: dot-prefixed file extensions (main views only)
  component: MyReactComponent, // receives { api, fileId?, fileName? } as props
});

// Register a slash command for the chat
api.registerSlashCommand({
  name: "my-command",
  description: "Does something",
  execute: async (args) => {
    return "result text";
  },
});

// Register a settings tab (shown in Settings > Plugins via gear icon)
api.registerSettingsTab({
  component: MySettingsComponent, // receives { api, onClose } as props
});

// Register a custom dashboard widget type (same registry as core widgets)
api.registerWidget({
  type: "base",                  // referenced from a .dashboard widget's `type`
  label: "Base",
  defaultConfig: {},
  render: (config, ctx) => <MyWidget config={config} ctx={ctx} />,
  // optional ConfigEditor shown in the widget settings panel
});

A .dashboard references a plugin widget simply by type (e.g. { type: "base", config: { base: "Dashboards/x.base", view: "..." } }). Dashboards load before plugins, so a not-yet-registered type renders as a placeholder (UnknownWidget) with its config preserved on save; once the plugin calls registerWidget, the dashboard re-renders and swaps in the real widget automatically (via a dashboard-widgets-changed event). The widget render receives a ctx with size, widgetId, dashboardFileId, and onConfigChange(config) (persist config directly from the widget itself, without opening the settings panel). To read/create the referenced data file (e.g. a .base), use api.drive.readFile/searchFiles — note api.drive.listFiles does not enumerate the Dashboards/ folder.

Gemini AI

const response = await api.gemini.chat(
  [
    { role: "user", content: "Hello" },
  ],
  {
    model: "gemini-3.8-flash",      // optional
    systemPrompt: "You are helpful", // optional
  }
);
// response is the model's text reply

Active File Change Listener

// Subscribe to file selection changes in the IDE
const unsubscribe = api.onActiveFileChanged(({ fileId, fileName, mimeType }) => {
  if (fileId) {
    console.log(`File opened: ${fileName} (${fileId})`);
  } else {
    console.log("File closed");
  }
});

// Call the returned function to unsubscribe
unsubscribe();

Google Drive

All drive operations are local-first — reads come from the IndexedDB cache (with server fallback on cache miss), and writes update the local cache and edit history. Changes are synced to Google Drive when the user pushes.

// Read a file
const content = await api.drive.readFile(fileId);

// Search files by name
const files = await api.drive.searchFiles("query");

// List files in a folder (omit folder to list files in the gemihub root folder)
const files = await api.drive.listFiles(folder);

// Create a text file (created in the gemihub root folder)
const { id, name } = await api.drive.createFile("notes.md", "# Notes");

// Create a binary file (pass ArrayBuffer)
const response = await fetch(imageUrl);
const buffer = await response.arrayBuffer();
const { id, name } = await api.drive.createFile("photo.png", buffer);

// Update a file (text)
await api.drive.updateFile(fileId, "new content");

// Update a file (binary — updated locally, push to sync to Drive)
await api.drive.updateFile(fileId, arrayBuffer);

// Rebuild the file tree from Drive
await api.drive.rebuildTree();

Note: createFile returns a temporary new:* prefixed ID for newly created files. This ID works with all other api.drive methods. After a push, the file receives a real Drive ID. updateFile automatically resolves stale new:* IDs by falling back to file name lookup.

Google Calendar (Premium)

Requires the premium plan with calendar scope. The calendar property is optional — check for its existence before use.

if (api.calendar) {
  // List events
  const events = await api.calendar.listEvents({
    timeMin: "2026-04-01T00:00:00Z",
    timeMax: "2026-04-30T23:59:59Z",
    maxResults: 10,
  });

  // Create an event
  const { eventId, htmlLink } = await api.calendar.createEvent({
    summary: "Meeting",
    start: "2026-04-10T10:00:00+09:00",
    end: "2026-04-10T11:00:00+09:00",
  });

  // Update an event
  await api.calendar.updateEvent(eventId, { summary: "Updated Meeting" });

  // Delete an event
  await api.calendar.deleteEvent(eventId);
}

Gmail (Premium)

Requires the premium plan with gmail.send scope. The gmail property is optional — check for its existence before use.

if (api.gmail) {
  const { messageId } = await api.gmail.sendEmail({
    to: "recipient@example.com",
    subject: "Hello from GemiHub",
    body: "<p>This is an HTML email.</p>",
    cc: "cc@example.com",    // optional
    bcc: "bcc@example.com",  // optional
  });
}

Plugin Storage

Each plugin has its own data.json on Drive for persistent key-value storage:

// Get a value
const value = await api.storage.get("myKey");

// Set a value
await api.storage.set("myKey", { count: 42 });

// Get all stored data
const all = await api.storage.getAll();

External Assets

Plugins can declare external assets (WASM binaries, ML models, etc.) in manifest.json. The server acts as a caching proxy — it downloads the file from the declared URL on the first request and serves it from the local cache (data/plugins/{id}/) on subsequent requests.

// Fetch a declared asset by name (returns ArrayBuffer)
const buf = await api.assets.fetch("model.wasm");
const module = await WebAssembly.instantiate(buf);

Rules:

  • Assets must be declared in the assets array of manifest.json. Requests for undeclared names are rejected (403).
  • URLs must use HTTPS. Private/internal addresses (localhost, 169.254.x, 10.x, etc.) are blocked.
  • The asset cache is automatically cleared on plugin update or uninstall.
  • Asset names must be flat filenames (no /, \, or leading .).

React

The host's React instances are available to avoid version mismatches:

const React = api.React;
const ReactDOM = api.ReactDOM;

View Components

View components receive { api, fileId?, fileName? } as props. fileId and fileName are provided when a file is currently open (always set for extension-matched views, optional for panel-selected main views):

function MyPanel({ api, fileId, fileName }) {
  const React = api.React;
  const [data, setData] = React.useState(null);

  React.useEffect(() => {
    if (fileId) {
      api.drive.readFile(fileId).then(setData);
    }
  }, [fileId]);

  return <div>{fileName}: {JSON.stringify(data)}</div>;
}
  • Sidebar views appear as tabs in the right panel (alongside Chat and Workflow)
  • Main views replace the file editor in the main area

Security

  • Plugins run with the same privileges as the host app (full access to DOM, fetch, etc.)
  • Only install plugins from sources you trust
  • require() shim only provides react, react-dom, and react-dom/client
  • Plugin code runs in the browser, not on the server

Credential boundary

PluginAPI never exposes the decrypted Gemini API key (api-key-cache.ts) or the decrypted RSA private key and encryption password (crypto-cache.ts), and there is no "decrypt this for me" call — a plugin sees .encrypted files only as ciphertext. plugin-file-guard.ts additionally refuses api.drive access to the files that would make an offline attack on those credentials possible, or that would let one plugin rewrite another:

PathWhy
settings.json, _encrypted-auth.jsonhold encryptedApiKey, encryptedPrivateKey, and the PBKDF2 salt
plugins/**another plugin's code — writable code is privilege escalation
_sync-meta.jsonthe sync registry; rewriting it corrupts push/pull state

Reads, writes, listings, and searches are all filtered, including id-addressed calls (the id is resolved to its path first).

This is a boundary on the API surface, not a sandbox: plugin code shares the page's realm, so a hostile plugin can still patch globals such as fetch and observe what the host itself sends. Isolating plugins would require moving them out of the realm (worker or sandboxed iframe with a postMessage bridge), the way workflow script nodes already run. plugin-file-guard.test.ts fails the build if the API surface grows a member that carries credential material.

Local Development

In development mode (NODE_ENV !== "production"), plugins can be loaded directly from the local filesystem without installing via GitHub. Place your plugin files in the plugins/{id}/ directory at the project root:

plugins/
  my-plugin/
    manifest.json
    main.js
    styles.css     ← optional

Local plugins are automatically detected and always enabled. The IndexedDB cache is bypassed, so changes to main.js or styles.css take effect on the next page reload without needing to update the version. Local plugins cannot be uninstalled from the UI — simply remove the directory to unload them.

Publishing

  1. Create a GitHub repository for your plugin
  2. Build main.js and manifest.json (and optionally styles.css)
  3. Create a GitHub Release and attach the files as release assets
  4. Users install via owner/repo in Settings > Plugins

To publish an update, create a new release with an updated version tag. Users click the update button to pull the latest.


API Routes

MethodPathDescription
GET/api/pluginsList installed plugins
POST/api/pluginsInstall plugin { repo }
GET/api/plugins/:id?file={name}Serve plugin file (main.js, styles.css, manifest.json)
GET/api/plugins/:id?asset={name}Serve a cached external asset declared in manifest
POST/api/plugins/:idActions: toggle, getData, setData, update, checkUpdate
DELETE/api/plugins/:idUninstall plugin
POST/api/calendarCalendar operations: list, create, update, delete
POST/api/gmailGmail operations: send