Documentation

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 pointWhat it does
toolsNew tools made available to the coding agent (/chat/code)
routesNew REST endpoints mounted under /api/plugins/<name>/
mcpToolsNew tools advertised to AI agents via the MCP server
hooksCallbacks 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:

ScopePathApplies 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.

Neither directory exists by default. Create ~/.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

~/.forage/plugins/my-plugin.js
module.exports = {
  // ... extension points
};

Directory plugin (recommended for anything non-trivial)

Directory layout
~/.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:

forage-plugin.json
{
  "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:

index.js — full template
'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.

index.js
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.

The tool name must be unique. If another tool (built-in or from another plugin) already uses the same name, the last-registered one wins.

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>/.

index.js
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.

Routes cannot be registered under /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.

index.js
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.

The following tool names are reserved and cannot be used by plugins: 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.

HookFires whenCan modify payload
afterCrawlA workspace crawl or re-index completesNo
beforeChatJust before the LLM is called for a chat messageYes — return modified messages
afterChatAfter the LLM returns a chat responseNo
onFileChangeThe file watcher detects a change in a watched workspaceNo

afterCrawl — run any post-index work:

index.js
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:

index.js
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:

index.js
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:

index.js
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`);
      }
    },
  },
};
Hook errors are caught and logged to the Forage console — a failing hook does not abort the operation it observed, and does not affect other hooks registered for the same event.

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:

~/.forage/plugins/changelog-tracker/index.js
'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:

Request
GET http://localhost:9988/api/plugins
Response
{
  "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:

RestrictionDetail
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

Do plugins need to be re-installed after a Forage update?

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.

Can I reload plugins without restarting Forage?

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.

Can a plugin require npm packages?

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().

How do I temporarily disable a plugin?

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.