paperclipai/paperclip · error
Plugin API route not found
Error message
Plugin API route not found
What it means
Returned as HTTP 404 when the plugin exposes scoped routes but no entry in manifestJson.apiRoutes matches the request. matchScopedApiRoute requires the HTTP method to match exactly (case-sensitive) and the path to have the same number of segments; trailing slashes are stripped, and ":param" segments capture values. Any extra/missing segment or method mismatch yields null and this 404.
Source
Thrown at server/src/routes/plugins.ts:1867
const isWorkerRunning = typeof bridgeDeps.workerManager.isRunning === "function"
? bridgeDeps.workerManager.isRunning(plugin.id)
: true;
if (!isWorkerRunning) {
res.status(503).json({ error: "Plugin worker is not running" });
return;
}
if (!plugin.manifestJson.capabilities.includes("api.routes.register")) {
res.status(404).json({ error: "Plugin does not expose scoped API routes" });
return;
}
const requestPath = req.path || "/";
const routes = plugin.manifestJson.apiRoutes ?? [];
const match = routes
.map((route) => ({ route, params: matchScopedApiRoute(route, req.method, requestPath) }))
.find((candidate) => candidate.params !== null);
if (!match || !match.params) {
res.status(404).json({ error: "Plugin API route not found" });
return;
}
try {
assertScopedApiAuth(req, match.route);
const companyId = await resolveScopedApiCompanyId(match.route, match.params, req);
if (!companyId) {
res.status(400).json({ error: "Unable to resolve company for plugin API route" });
return;
}
assertCompanyAccess(req, companyId);
await enforceScopedApiCheckout(req, match.route, match.params, companyId);
if (req.method !== "GET" && req.headers["content-type"] && !req.is("application/json")) {
res.status(415).json({ error: "Plugin API routes accept JSON requests only" });
return;
}
const requestBody = req.body ?? null;
const bodySize = Buffer.byteLength(JSON.stringify(requestBody));View on GitHub (pinned to a7e689b3c3)
Solutions
- GET /api/plugins/:pluginId and read manifestJson.apiRoutes to get the exact method/path pairs the plugin declares.
- Fix the client call to match a declared routeKey's method and segment count exactly (params only where the route has :param segments).
- If the route should exist, add it to the manifest's apiRoutes and upgrade/reinstall the plugin so the stored manifest picks it up.
Example fix
// before — manifest declares GET /issues, client posts to a subpath
await fetch(`/api/plugins/${id}/api/issues`, { method: "POST" });
// after — match the declared method and path
await fetch(`/api/plugins/${id}/api/issues`); // GET /issues Defensive patterns
Strategy: validation
Validate before calling
async function listDeclaredRoutes(apiBase: string, pluginId: string) {
const plugin = await (await fetch(`${apiBase}/api/plugins/${encodeURIComponent(pluginId)}`)).json();
return plugin.manifestJson?.apiRoutes ?? [];
}
async function routeIsDeclared(apiBase: string, pluginId: string, method: string, path: string): Promise<boolean> {
const routes = await listDeclaredRoutes(apiBase, pluginId);
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
return routes.some((r: { method: string; path: string }) =>
r.method === method &&
norm(r.path).split("/").filter(Boolean).length === norm(path).split("/").filter(Boolean).length
);
} Type guard
interface PluginApiRouteDeclaration { routeKey: string; method: string; path: string }
function isDeclaredRoute(
routes: PluginApiRouteDeclaration[],
method: string,
path: string,
): PluginApiRouteDeclaration | undefined {
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
return routes.find((r) => r.method === method && norm(r.path) === norm(path));
} Prevention
- Generate client call sites from the plugin's manifest apiRoutes instead of hand-writing paths.
- Treat route renames as breaking changes — bump the plugin major version and migrate clients.
- Remember the matcher is exact: no wildcards, no optional segments, case-sensitive literal segments.
When it happens
Trigger: POST to a route declared as method GET; requesting /api/plugins/:id/api/issues/123/comments when only /issues/:issueId is declared; calling a path that was renamed in a newer manifest version; case mismatch in a literal segment (comparison is exact).
Common situations: Client and plugin version drift after a route rename; typo in the client path; using the wrong HTTP verb; assuming Express-style optional segments or wildcard matching (the matcher supports neither).
Related errors
- Plugin does not expose scoped API routes
- Plugin does not declare a UI bundle
- Plugin is not ready (current status: ${plugin.status})
- Plugin worker is not running
- Unable to resolve company for plugin API route
AI-assisted analysis of paperclipai/paperclip@a7e689b3c3 (2026-08-18).
Data as JSON: /api/errors/3453ac6d0c0b66db.
Report an issue: GitHub.