How I designed my AI assistant's plugin system: every tool is just an ES module
My desktop AI assistant Ankita has 44 tools across 14 plugin families — web search, browser automation, git, scheduled routines, image generation, MCP servers — and the plugin system behind them is almost embarrassingly simple. There's no plugin SDK, no manifest format, no class hierarchy. A tool is one ES module that exports four things.
A plugin = name, description, parameters, needsApproval
Here's the entire contract, from tools/automation/schedule.mjs:
export const name = "schedule";
export const description =
"Manage scheduled routines: prompts that run on a cron schedule...";
export const parameters = {
type: "object",
properties: {
action: { type: "string", description: "add, list, remove, enable, or run." },
name: { type: "string", description: "Short label, e.g. 'Morning briefing'." },
cron: { type: "string", description: "When to run. Accepts cron ('0 8 * * *'), shorthands ('every 30m', 'daily 08:00'), or @aliases." },
prompt: { type: "string", description: "What to ask when it fires. Put every instruction here; nothing else is passed." },
},
required: ["action"],
};
export const needsApproval = false;
The description doubles as the tool's prompt-facing documentation, and parameters is the JSON schema the model sees. No wrapper, no registration call. Adding a capability to the assistant means dropping a file in tools/ and importing it.
That simplicity is deliberate. I've found that every layer between "I want a new capability" and "the model can use it" is a place where I'd procrastinate or make mistakes. With this design, adding image search support was literally creating tools/images/unsplash-search.mjs and one import line.
The core set stays small on purpose
tools/index.mjs imports every plugin namespace and splits them into two lists. The comment in the code says it best:
/**
* The tools almost every task needs, sent on every request. Deliberately short:
* every schema here is paid on every turn, forever.
*/
export const CORE = [
...alwaysOnTools,
readFile, writeFile, editFile, editLines, applyPatch,
listDir, searchFiles, glob, moveFile, deleteFile,
runCommand, jobStatus, jobStop, jobInput, jobWait,
writeTodos, httpRequest, findTools,
];
Every schema in CORE gets sent on every API request, forever. That's a real token cost per turn, so CORE only holds what nearly every task needs: file ops, command execution, job control, HTTP. Everything else — git, browser, scheduling, MCP management, GitHub notifications — is deferred and loaded on demand.
Deferred discovery without embeddings
This is the part I'm proudest of. tools/catalog.mjs groups deferred plugins into categories with plain keyword lists:
export const CATEGORIES = [
{
id: 'git',
summary: 'Git working tree, diffs, history, branches, staging, commits and stash',
keywords: ['git', 'commit', 'checkout', 'stage', 'unstage', 'stash', 'branch', 'blame', 'version control'],
tools: [git],
},
// ...
];
The find_tools tool matches a query against these categories with plain substring checks — no embeddings, no vector index, no search service. Category ids, keywords, and tool names all match, so "web_search" or "project_memory" work as queries too. The matcher is a pure function, so it's trivially testable. (I wrote a dedicated post about this matching design earlier.)
One subtle detail: the catalog lives in its own file on purpose. find_tools.mjs needs the categories, and index.mjs needs find_tools, so putting both in one module creates a cycle where a half-initialised namespace gets read. Separating them costs one file and avoids an entire class of bug.
Plugins for things the assistant didn't ship with
Two plugin types go beyond the built-in modules:
MCP servers. The mcp_manage tool can search the official MCP registry ("search for 'playwright', 'postgres', 'figma'") and install a server. One safety rule baked in: adding or installing a server records the exact command that will run, and nothing executes until the user approves it. So the plugin system is user-extensible, but external processes never sneak in unreviewed.
Browser plugins. src/integrations/browser-plugins.mjs holds a BrowserPluginStore with two plugins: isolated (a private Playwright Chromium "for Ankita") and local (your real Chrome, driven over MCP). Both are disabled by default, and each carries its own allow/block site lists — up to 100 domain rules per plugin, supporting wildcards like *.example.com. The assistant can automate the web, but only on sites you've approved.
What I'd do differently
Two honest lessons:
-
I should have added the
shared/helper layer sooner. For a while, every plugin module rolled its own file-writing and JSON-state handling. Nowtools/shared/_shared.mjsholds the common helpers, and the browser plugins' store code imports from it. Plugin authors (me, repeatedly) need the paved road first. -
The
needsApprovalflag is too coarse. It's a boolean —schedulesets it false, mutating tools default to true — but some tools want per-action approval (e.g.,git pushis fine,git push --forceis not). I'll probably need approval levels, not a toggle.
The repo is open source: akyourowngames/A.N.K.I.T.A. If you've built a plugin system for an AI agent, I'd genuinely love to hear how you handled discovery — did you go the embedding route, or something simpler? Always looking to learn from people who've shipped this stuff.
Top comments (0)