Plugins
Extend Forage with custom agent tools, API routes, MCP tools, and lifecycle hooks — at the global level or scoped to a single workspace.
Overview
Plugins let you add new capabilities to Forage without modifying its core. A plugin is a plain Node.js (CommonJS) module that exports any combination of four extension points:
| Extension point | What it does |
|---|---|
tools | New tools made available to the coding agent (/chat/code) |
routes | New REST endpoints mounted under /api/plugins/<name>/ |
mcpTools | New tools advertised to AI agents via the MCP server |
hooks | Callbacks fired at key points in Forage's lifecycle |
Plugins are loaded automatically when Forage starts — no registration step required.
Plugin locations
Forage looks for plugins in two places:
| Scope | Path | Applies to |
|---|---|---|
| Global | ~/.forage/plugins/ | All workspaces on this machine |
| Workspace | {workspace}/.forage/plugins/ | That workspace only |
If a global plugin and a workspace plugin share the same name, the workspace plugin takes precedence — it completely replaces the global one for that name. Plugins with different names from both levels are all active simultaneously.
~/.forage/plugins/ or .forage/plugins/ inside a workspace and Forage will pick it up on the next start.
Plugin structure
A plugin can be either a single .js file or a directory containing an index.js entry point and an optional manifest.
Single-file plugin
module.exports = {
// ... extension points
};
Directory plugin (recommended for anything non-trivial)
~/.forage/plugins/
my-plugin/
forage-plugin.json ← optional manifest
index.js ← required entry point
lib/
helper.js ← any supporting files
Manifest (forage-plugin.json) — optional, but recommended:
{
"name": "my-plugin",
"version": "1.0.0",
"description": "What this plugin does",
"main": "index.js",
"disabled": false
}
Set "disabled": true in the manifest to temporarily turn a plugin off without deleting it.
Entry point
index.js must export a plain object. All four keys are optional — export only what your plugin uses:
'use strict';
module.exports = {
tools: [...], // agent tools
routes: (router, { requireWs }) => { ... }, // API routes
mcpTools: [...], // MCP tools
hooks: { ... }, // lifecycle hooks
};
Agent tools
Agent tools are made available to the coding agent that powers Ask and Code tasks. Each tool has a name, a one-line description shown to the LLM, an optional args map, and an execute function.
module.exports = {
tools: [
{
name: 'fetch_readme',
description: 'Fetch the README for an npm package by name',
args: {
packageName: 'The npm package name, e.g. "express"',
},
async execute(args, context) {
// context.workspacePath — absolute path to the active workspace
// context.toolRegistry — the full ToolRegistry instance
const res = await fetch(
`https://registry.npmjs.org/${args.packageName}`
);
const data = await res.json();
return { readme: data.readme || 'No README available.' };
},
},
],
};
The execute function may be async. Whatever it returns is serialised and shown to the LLM as the tool result. Thrown errors are caught and surfaced as tool failures without crashing the agent loop.
API routes
The routes export is a factory function that receives an Express Router and a helpers object. Register your endpoints on the router — they will be mounted at /api/plugins/<plugin-name>/.
module.exports = {
routes(router, { requireWs }) {
// GET /api/plugins/my-plugin/status
router.get('/status', (req, res) => {
res.json({ ok: true, plugin: 'my-plugin' });
});
// POST /api/plugins/my-plugin/summarise
// requireWs resolves the workspace from the X-Workspace header
router.post('/summarise', requireWs, async (req, res) => {
const { filePath } = req.body;
// req.workspacePath is set by requireWs
// ... do work ...
res.json({ summary: '...' });
});
},
};
Use the requireWs middleware on any route that needs to know the active workspace. It reads the X-Workspace request header and populates req.workspacePath.
/license — that prefix is reserved and any attempt to use it is silently blocked.
MCP tools
MCP tools are advertised to connected AI agents via the Forage MCP server at http://localhost:9989/mcp. They follow the same JSON Schema input contract as the built-in tools.
module.exports = {
mcpTools: [
{
name: 'search_jira',
description: 'Search Jira issues related to a codebase symbol or keyword',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search term or symbol name',
},
workspace: {
type: 'string',
description: 'Workspace name or path. Omit to use the default.',
},
},
required: ['query'],
},
async execute(args, workspacePath) {
// args — the validated input object
// workspacePath — resolved workspace path
const issues = await myJiraClient.search(args.query);
return { issues };
},
},
],
};
Plugin MCP tools appear alongside the built-in tools in every tools/list response. The AI agent can call them like any other Forage tool.
list_workspaces, get_workspace_info, ask, create_snapshot, list_snapshots, rollback, show_file_diff, why_was_changed, graph_stats, get_documentation, update_documentation, code_task.
Lifecycle hooks
Hooks let plugins react to — or modify — events inside Forage without needing to patch core code. All hook functions may be async.
| Hook | Fires when | Can modify payload |
|---|---|---|
afterCrawl | A workspace crawl or re-index completes | No |
beforeChat | Just before the LLM is called for a chat message | Yes — return modified messages |
afterChat | After the LLM returns a chat response | No |
onFileChange | The file watcher detects a change in a watched workspace | No |
afterCrawl — run any post-index work:
module.exports = {
hooks: {
async afterCrawl({ workspacePath, result }) {
console.log(
`[my-plugin] crawl done for ${workspacePath}: ` +
`${result.indexed} files indexed`
);
},
},
};
beforeChat — inspect or modify the message list before it reaches the LLM. Return the (possibly modified) payload object to apply changes:
module.exports = {
hooks: {
async beforeChat({ messages, workspacePath, question }) {
// Inject an extra system instruction
messages.push({
role: 'system',
content: 'Always cite the file path when referencing code.',
});
// Return the full payload — omitting a return value leaves it unchanged
return { messages, workspacePath, question };
},
},
};
afterChat — log, audit, or forward completed responses:
module.exports = {
hooks: {
async afterChat({ question, answer, workspacePath }) {
await myAuditLog.write({ question, answer, workspacePath, ts: Date.now() });
},
},
};
onFileChange — react to file system events in watched workspaces:
module.exports = {
hooks: {
async onFileChange({ filePath, event, workspacePath }) {
// event is the raw watcher event string, e.g. 'change', 'add', 'unlink'
if (filePath.endsWith('.env')) {
console.warn(`[my-plugin] .env file changed — check for leaked secrets`);
}
},
},
};
Combining extension points
A single plugin can use any mix of extension points. Here is a complete example that adds an agent tool, an API route, and a hook together:
'use strict';
const fs = require('fs');
const path = require('path');
function logPath(workspacePath) {
return path.join(workspacePath, '.forage', 'changelog.ndjson');
}
module.exports = {
// Agent tool: let the LLM read the local changelog
tools: [
{
name: 'read_changelog',
description: 'Read the Forage change log entries for this workspace',
args: { limit: 'Max number of entries to return (default 20)' },
execute(args, { workspacePath }) {
const file = logPath(workspacePath);
if (!fs.existsSync(file)) return { entries: [] };
const lines = fs.readFileSync(file, 'utf8')
.trim().split('\n').filter(Boolean).slice(-(args.limit || 20));
return { entries: lines.map(l => JSON.parse(l)) };
},
},
],
// REST route: expose the changelog over HTTP
routes(router, { requireWs }) {
router.get('/entries', requireWs, (req, res) => {
const file = logPath(req.workspacePath);
if (!fs.existsSync(file)) return res.json({ entries: [] });
const lines = fs.readFileSync(file, 'utf8')
.trim().split('\n').filter(Boolean);
res.json({ entries: lines.map(l => JSON.parse(l)) });
});
},
// Hook: record every chat question and answer
hooks: {
async afterChat({ question, answer, workspacePath }) {
const entry = JSON.stringify({ ts: Date.now(), question, answer }) + '\n';
fs.appendFileSync(logPath(workspacePath), entry, 'utf8');
},
},
};
Listing installed plugins
You can see which plugins Forage has loaded via the management API endpoint:
GET http://localhost:9988/api/plugins
{
"plugins": [
{
"name": "changelog-tracker",
"manifest": { "name": "changelog-tracker", "version": "1.0.0" },
"dir": "/Users/you/.forage/plugins/changelog-tracker",
"scope": "global"
},
{
"name": "jira-search",
"manifest": { "name": "jira-search", "version": "0.3.0" },
"dir": "/Users/you/projects/my-app/.forage/plugins/jira-search",
"scope": "/Users/you/projects/my-app"
}
]
}
The scope field is "global" for plugins loaded from ~/.forage/plugins/, or the absolute workspace path for workspace-level plugins.
Restrictions
Plugins run in the same Node.js process as Forage with full filesystem and network access. The following restrictions are enforced to protect core functionality:
| Restriction | Detail |
|---|---|
| Reserved MCP tool names | The 12 built-in MCP tool names cannot be reused. A plugin that attempts to register one will have that tool silently dropped on load. |
| Protected API route prefix | Routes cannot be mounted under /license. Any route path starting with that prefix is blocked at mount time. |
| No licensing interference | Plugins cannot intercept, replace, or disable the Forage licensing mechanism. Attempts to patch licensing internals will result in unpredictable behaviour and may invalidate your license. |
Tips
No. Plugin directories are separate from the Forage binary and survive updates. If a Forage update changes the plugin API in a breaking way, the release notes will say so.
Not yet — plugins are loaded once at startup. Restart Forage with forage --restart to pick up changes to a plugin or to install a new one.
Yes. If your plugin is a directory, add a package.json and run npm install inside that directory. Node's module resolution will find the local node_modules folder when your plugin's index.js calls require().
Set "disabled": true in its forage-plugin.json manifest, then restart Forage. Alternatively, rename the directory or file so it starts with an underscore — files beginning with _ are skipped by the loader.