# Plugin system

> WASM backend sandbox, frontend extension points, MCP plugin tools, capability permissions, and marketplace-driven install lifecycle.

- Repository: Paca-AI/paca
- GitHub: https://github.com/Paca-AI/paca
- Human docs: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25
- Complete Markdown: https://www.grok-wiki.com/public/docs/paca-ai-paca-f238b2ab3d25/llms-full.txt

## Source Files

- `docs/plugins/overview.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/mcp-plugin-system.md`
- `services/api/internal/transport/http/handler/plugin_handler.go`
- `docs/plugins/marketplace.md`

---

---
title: "Plugin system"
description: "WASM backend sandbox, frontend extension points, MCP plugin tools, capability permissions, and marketplace-driven install lifecycle."
---

Paca extends the core product through versioned `plugin.json` bundles that register into three runtime surfaces: backend logic in WASM inside `services/api` (wazero), frontend React remotes loaded by `apps/web` via Module Federation, and MCP tools merged by `apps/mcp` at server startup. Installed plugins are recorded in PostgreSQL, artifacts land under configurable local or S3 stores, and the nginx gateway serves frontend bundles at `/plugins/` and MCP bundles at `/plugins-mcp/`.

## Architecture

```mermaid
flowchart TB
  subgraph Browser["Browser — apps/web"]
    Host["Host app"]
    Registry["PluginRegistryProvider"]
    EP["ExtensionPoint / PluginSlot"]
    Remote["RemoteComponent (Module Federation)"]
    Host --> Registry --> EP --> Remote
  end

  subgraph API["services/api"]
    Handler["PluginHandler"]
    Runtime["plugin.Runtime (wazero)"]
    Store["plugin.Store"]
    Migrator["MigrationRunner"]
    Handler --> Runtime
    Runtime --> Store
    Handler --> Migrator
  end

  subgraph MCP["apps/mcp"]
    Loader["plugin-loader.ts"]
    Reg["PluginRegistry"]
    Loader --> Reg
  end

  subgraph Storage["Artifact stores"]
    WASM["PLUGINS_WASM_DIR / S3"]
    FE["PLUGINS_FRONTEND_DIR → /plugins/"]
    MCPDir["PLUGINS_MCP_DIR → /plugins-mcp/"]
  end

  subgraph Catalog["Paca-AI/paca-plugins"]
    JSON["catalog/plugins.json"]
  end

  Remote -->|"GET /api/v1/plugins"| Handler
  Remote -->|"plugin API /api/v1/plugins/{id}/…"| Handler
  Loader -->|"GET /api/v1/plugins"| Handler
  Reg -->|"PluginAPIClient"| Handler
  Catalog -->|"marketplace install"| Handler
  Handler --> WASM
  Handler --> FE
  Handler --> MCPDir
```

| Surface | Runtime | Entry manifest field | Load timing |
|---|---|---|---|
| Backend | wazero WASM in API process | `backend.routes`, `backend.eventSubscriptions` | API startup + install/upgrade reload |
| Frontend | Vite Module Federation remote | `frontend.remoteEntryUrl`, `frontend.extensionPoints` | Lazy on first extension-point render |
| MCP | Node.js dynamic `import()` | `mcp.remoteEntryUrl` | MCP server startup (cached for process lifetime) |

<Note>
Backend hot-reload without an API process restart is not supported. Cross-plugin calls are out of scope for v1.
</Note>

## Extension points

The host recognizes five frontend extension point IDs. Registrations come from each plugin's manifest and are sorted by `order`, with super-admin overrides from `plugin_extension_settings`.

| ID | Surface | Host placement |
|---|---|---|
| `sidebar.general.section` | Global left navigation | `app-sidebar.tsx` |
| `sidebar.project.section` | Project sidebar | Project section in `app-sidebar.tsx` |
| `task.detail.section` | Task drawer/page | Task detail panel |
| `project.settings.tab` | Project settings | Settings tabs |
| `view` | Main board area | View selector / board |

`<ExtensionPoint>` renders all non-hidden registrations for a point; `<PluginSlot>` renders only the first. Each remote component is wrapped in an error boundary so one failing plugin does not break the host.

Super admins hide or reorder panels system-wide via `PATCH /api/v1/admin/plugin-extension-settings`. Settings apply to all users on the next registry build.

## Plugin manifest (`plugin.json`)

Every bundle ships a `plugin.json` that drives registration across all three surfaces.

```json
{
  "id": "com.paca.checklist",
  "displayName": "Checklist",
  "version": "1.0.0",
  "description": "Adds named checklists with checkable items to tasks.",
  "permissions": ["db:read:tasks", "db:write:plugin_data", "events:emit"],
  "frontend": {
    "remoteEntryUrl": "/plugins/com.paca.checklist/assets/remoteEntry.js",
    "extensionPoints": [
      {
        "point": "task.detail.section",
        "component": "TaskDetailSection",
        "label": "Checklist",
        "order": 10
      }
    ]
  },
  "backend": {
    "routes": [
      {
        "method": "GET",
        "path": "/projects/:projectId/tasks/:taskId/items",
        "middlewares": [
          { "name": "authn" },
          { "name": "requireFreshPassword" },
          {
            "name": "requirePermissions",
            "scope": "project",
            "permissions": ["tasks.read"]
          }
        ]
      }
    ],
    "eventSubscriptions": ["task.deleted"],
    "allowedOutboundDomains": ["api.github.com"],
    "allowedConfigKeys": ["GITHUB_APP_ID"]
  },
  "mcp": {
    "remoteEntryUrl": "https://paca.example.com/plugins-mcp/com.paca.checklist/mcp.js"
  }
}
```

<ResponseField name="id" type="string">
Reverse-DNS identifier; must match the `plugins.name` column and catalog `name`.
</ResponseField>

<ResponseField name="frontend.remoteEntryUrl" type="string">
URL to the Module Federation `remoteEntry.js`. For self-hosted installs, use `/plugins/<plugin-id>/assets/remoteEntry.js` (served by nginx).
</ResponseField>

<ResponseField name="backend.routes" type="array">
HTTP routes mounted under `/api/v1/plugins/{pluginId}/`. Project-scoped routes should include `/projects/:projectId` in the path.
</ResponseField>

<ResponseField name="mcp.remoteEntryUrl" type="string">
Node.js-compatible ESM module exporting a default `PluginMCPEntry` object.
</ResponseField>

## Backend WASM sandbox

Backend plugins compile to `backend.wasm` and load into an isolated wazero module per plugin. The host registers a `paca` namespace of bridge functions; plugins communicate through linear-memory calls, not direct host memory or filesystem access.

### WASM exports and lifecycle

| Export | When called |
|---|---|
| `Init()` | After module instantiation at load time |
| `HandleRequest(ptr, len)` | Each matching HTTP request |
| `HandleEvent(ptr, len)` | Core events listed in `eventSubscriptions` |
| `Shutdown()` | Before module unload |
| `ResetAllocator()` | After each `HandleRequest` (optional, SDK helper) |

Default resource limits per module instance:

| Resource | Default |
|---|---|
| Max call duration | 5 seconds |
| Linear memory | 1024 pages (64 MiB) |
| Outbound fetch response | 50 MiB cap |

### Host function bridge

All host functions are imported from the `paca` module.

| Function group | Exports | Behavior |
|---|---|---|
| Database | `db_query`, `db_exec`, `db_tx_begin`, `db_tx_commit`, `db_tx_rollback` | Queries run in plugin schema `plugin_data_{id}`; only `SELECT` allowed in `db_query`; DDL/DCL blocked in `db_exec` |
| Key-value storage | `storage_get`, `storage_set`, `storage_delete` | Backed by `{schema}.plugin_kv` table |
| Core reads | `tasks_list`, `task_get`, `project_get`, `members_list` | Read-only access to core tables |
| HTTP (in-handler) | `http_request_body`, `http_request_headers`, `http_caller_identity`, `http_respond` | Request context from Gin proxy; caller identity from JWT |
| Events | `event_emit`; `event_subscribe` (no-op) | Subscriptions declared in manifest; emit publishes to Valkey |
| Outbound HTTP | `fetch` | Only domains in `backend.allowedOutboundDomains` |
| Config | `config_get` | Only keys in `backend.allowedConfigKeys` |
| Logging | `log` | Structured logs tagged with plugin ID |

Plugin-owned SQL migrations run in the dedicated PostgreSQL schema (`com.paca.checklist` → `plugin_data_com_paca_checklist`). The `MigrationRunner` creates the schema, `plugin_kv`, and `plugin_schema_migrations` tracking table, then applies `.sql` files in lexicographic order.

### Route proxy and middleware

All backend routes are reached through a single Gin wildcard:

```
ANY /api/v1/plugins/:pluginId/*path
```

The proxy matches `method` + `path` against `backend.routes`, applies manifest-declared middleware, then dispatches to the plugin's `HandleRequest` export.

Supported middleware names:

| Name | Effect |
|---|---|
| `authn` | Require authenticated session or API key |
| `optionalAuthn` | Attach identity when present |
| `requireFreshPassword` | Block stale-password sessions |
| `requireJWTAuth` | Reject API-key-only callers |
| `requirePermissions` | Enforce global or project permissions |

<ParamField body="requirePermissions.scope" type="string">
`global` (default) or `project`. Project scope resolves the project UUID from a route param (default `projectId`).
</ParamField>

<ParamField body="requirePermissions.permissions" type="string[]" required>
Permission keys such as `projects.read`, `tasks.write`.
</ParamField>

When `middlewares` is omitted, the host applies `optionalAuthn` + `requireFreshPassword`. An explicit empty array `[]` disables all middleware. Legacy `public: true` on a route also disables middleware.

## Frontend extension system

At startup, `PluginRegistryProvider` fetches `GET /api/v1/plugins` and builds a `Map<ExtensionPointId, PluginRegistration[]>`. Enabled plugins with both `frontend.remoteEntryUrl` and `extensionPoints` contribute registrations.

Remote components load through dynamic `import()` with a shared React scope (`react`, `react-dom`, `@tanstack/react-query`) to prevent duplicate library instances. Plugin HTTP calls go through the SDK client scoped to `/api/v1/plugins/{pluginId}/` using the same session cookie as the host (`credentials: "include"`).

For self-hosted deployments, set `frontend.remoteEntryUrl` to a path under `/plugins/`:

```
/plugins/<plugin-id>/assets/remoteEntry.js
```

The nginx gateway serves `PLUGINS_FRONTEND_DIR` at `/plugins/` and never exposes WASM binaries or migration SQL.

## MCP plugin tools

The MCP server (`apps/mcp`) loads plugin tools at startup:

1. `GET /api/v1/plugins` with `PACA_API_KEY`
2. Skip disabled plugins and plugins without `mcp.remoteEntryUrl`
3. Dynamic `import(remoteEntryUrl)` and validate default export
4. Merge tool definitions into the flat tool list alongside core Paca tools

Plugin MCP modules export a default `PluginMCPEntry`:

```typescript
import type { PluginMCPEntry } from "@paca-ai/plugin-sdk-mcp";
import { PluginAPIClient, textResult, errorResult } from "@paca-ai/plugin-sdk-mcp";

const entry: PluginMCPEntry = {
  tools: [/* MCP Tool definitions */],
  async handleToolCall(name, args, context) {
    const api = new PluginAPIClient(context);
    // call plugin backend via /api/v1/plugins/{pluginId}/…
  },
};

export default entry;
```

Tool names must be unique across all plugins and match `[a-z][a-z0-9_]*`. Use a short prefix derived from the plugin ID (for example `checklist_list_items`).

<Warning>
MCP plugin modules run in the same Node.js process as the MCP server with no sandboxing. Install MCP-capable plugins only from trusted sources. Use `https://` for `remoteEntryUrl` in production; `http://` is permitted for local development.
</Warning>

Failed plugin loads log a warning to stderr; core MCP tools remain available.

## Capability and access control

Access control spans manifest declarations and host enforcement:

| Layer | Mechanism |
|---|---|
| Manifest `permissions` | Declares required host-function scopes (documented contract for SDK and review) |
| Route middleware | Per-route authn/authz enforced by `PluginHandler` before WASM dispatch |
| DB isolation | All plugin SQL runs under `plugin_data_{pluginId}` schema via `search_path` |
| Outbound HTTP | `paca.fetch` limited to `backend.allowedOutboundDomains` (exact hostname match) |
| Secrets | `paca.config_get` limited to `backend.allowedConfigKeys`; values come from host config, not the WASM binary |
| Admin operations | Marketplace install, upgrade, delete require global `users.write` permission |

## Install lifecycle

```mermaid
stateDiagram-v2
  [*] --> CatalogEntry: PR merged to paca-plugins
  CatalogEntry --> Downloaded: POST marketplace/install
  Downloaded --> ArtifactsExtracted: tar.gz → local stores
  ArtifactsExtracted --> DBRegistered: plugins table row
  DBRegistered --> Migrated: MigrationRunner.Run
  Migrated --> RuntimeLoaded: Runtime.Load (if enabled)
  RuntimeLoaded --> Active: routes + UI + MCP available
  Active --> Upgraded: POST :pluginId/upgrade
  Active --> Disabled: PATCH enabled=false
  Active --> Uninstalled: DELETE :pluginId
  Uninstalled --> [*]
```

<Steps>
<Step title="Resolve catalog entry">

Admin UI or API calls `POST /api/v1/admin/plugins/marketplace/install` with `{ "name": "com.paca.checklist" }`. The API fetches `catalog/plugins.json` from `Paca-AI/paca-plugins` (default URL in `PLUGINS_MARKETPLACE_CATALOG_URL`).

</Step>

<Step title="Download and extract artifacts">

The installer downloads tar.gz artifacts (manifest required; backend, frontend, migrations, and MCP optional) and writes:

- `{PLUGINS_WASM_DIR}/{name}/backend.wasm`
- `{PLUGINS_WASM_DIR}/{name}/plugin.json`
- `{PLUGINS_WASM_DIR}/{name}/migrations/*.sql`
- `{PLUGINS_FRONTEND_DIR}/{name}/…`
- `{PLUGINS_MCP_DIR}/{name}/…`

</Step>

<Step title="Register, migrate, and load">

The API inserts a `plugins` row, runs `MigrationRunner`, then calls `Runtime.Load`. On failure at any step after artifact download, the DB row is deleted and artifacts are cleaned up.

</Step>

<Step title="Verify">

- `GET /api/v1/plugins` lists the plugin with `enabled: true`
- Frontend remote entry resolves at `/plugins/{name}/assets/remoteEntry.js`
- Plugin API responds at `/api/v1/plugins/{name}/projects/{projectId}/…`
- MCP server logs successful plugin load on restart

</Step>
</Steps>

### Upgrade and uninstall

`POST /api/v1/admin/plugins/:pluginId/upgrade` fetches the latest catalog version, enforces strict `X.Y.Z` semver (no pre-release or build metadata), downloads artifacts, runs idempotent migrations, reloads WASM **before** persisting the new version, and rolls back on failure.

`DELETE /api/v1/admin/plugins/:pluginId` removes the DB record, unloads the WASM module, and deletes artifact directories. Plugin schema data is retained unless the plugin provides down migrations.

## API endpoints

| Method | Path | Auth | Purpose |
|---|---|---|---|
| `GET` | `/api/v1/plugins` | Optional | List installed plugins + extension settings |
| `ANY` | `/api/v1/plugins/:pluginId/*path` | Per-route manifest | Proxy to WASM `HandleRequest` |
| `GET` | `/api/v1/admin/plugins/marketplace` | `users.write` | List catalog entries |
| `POST` | `/api/v1/admin/plugins/marketplace/install` | `users.write` | Install from catalog |
| `POST` | `/api/v1/admin/plugins` | `users.write` | Manual install with inline manifest |
| `PATCH` | `/api/v1/admin/plugins/:pluginId` | `users.write` | Update version, manifest, or enabled flag |
| `POST` | `/api/v1/admin/plugins/:pluginId/upgrade` | `users.write` | Upgrade to latest catalog version |
| `DELETE` | `/api/v1/admin/plugins/:pluginId` | `users.write` | Uninstall plugin |
| `PATCH` | `/api/v1/admin/plugin-extension-settings` | `users.write` | Hide or reorder extension points |

:::endpoint GET /api/v1/plugins
List enabled and disabled plugins with parsed manifests and per-extension-point settings (`hidden`, `order`). Used by the web host registry and MCP plugin loader.
:::

:::endpoint POST /api/v1/admin/plugins/marketplace/install
Install a plugin by reverse-DNS `name` from the marketplace catalog. Downloads artifacts, registers the plugin, runs migrations, and loads the WASM runtime when `enabled` is true (default).
:::

:::endpoint ANY /api/v1/plugins/:pluginId/*path
Match the request against `backend.routes`, enforce route middleware, serialize the request to JSON, call the plugin's `HandleRequest` export, and return the plugin response envelope `{ status, headers, body }`.
:::

## Configuration

<ParamField body="PLUGINS_STORE" type="string" default="local">
`local` reads WASM from `PLUGINS_WASM_DIR`; `s3` reads from `STORAGE_BUCKET` + `PLUGINS_S3_PREFIX`.
</ParamField>

<ParamField body="PLUGINS_WASM_DIR" type="string" default="./plugins/local/backend">
Backend artifact root: `{dir}/{plugin-name}/backend.wasm`.
</ParamField>

<ParamField body="PLUGINS_FRONTEND_DIR" type="string" default="./plugins/local/frontend">
Frontend bundles; nginx maps to `/plugins/`.
</ParamField>

<ParamField body="PLUGINS_MCP_DIR" type="string" default="./plugins/local/mcp">
MCP ESM bundles; nginx maps to `/plugins-mcp/`.
</ParamField>

<ParamField body="PLUGINS_MARKETPLACE_CATALOG_URL" type="string" default="https://raw.githubusercontent.com/Paca-AI/paca-plugins/master/catalog/plugins.json">
Remote catalog JSON URL.
</ParamField>

<ParamField body="PLUGINS_MARKETPLACE_TIMEOUT" type="duration" default="20s">
HTTP timeout for catalog and artifact downloads.
</ParamField>

<ParamField body="PLUGINS_S3_PREFIX" type="string" default="plugins">
S3 key prefix when `PLUGINS_STORE=s3`; binaries at `{prefix}/{name}/backend.wasm`.
</ParamField>

:::files
plugins/local/
├── backend/
│   └── com.paca.checklist/
│       ├── plugin.json
│       ├── backend.wasm
│       └── migrations/
│           └── 0001_create_items.sql
├── frontend/
│   └── com.paca.checklist/
│       └── assets/
│           └── remoteEntry.js
└── mcp/
    └── com.paca.checklist/
        └── mcp.js
:::

## SDK packages

Plugin authoring uses three separate SDK repositories:

| Package | Language | Surface |
|---|---|---|
| `@paca-ai/plugin-sdk-react` | TypeScript | Frontend extension points + scoped HTTP client |
| `github.com/Paca-AI/plugin-sdk-go` | Go (TinyGo → WASM) | Backend host bridge wrappers |
| `@paca-ai/plugin-sdk-mcp` | TypeScript | MCP `PluginMCPEntry` + `PluginAPIClient` |

## Troubleshooting signals

| Symptom | Likely cause |
|---|---|
| Extension point empty after install | Plugin disabled, missing `frontend` manifest, or registration `hidden: true` |
| `remoteEntry.js` 404 | Frontend artifacts not installed or nginx `/plugins/` alias misconfigured |
| `plugin not found or disabled` on API call | Plugin name mismatch (`:pluginId` is reverse-DNS `name`, not DB UUID) or `enabled: false` |
| MCP tools missing | No `mcp.remoteEntryUrl`, MCP bundle not at `/plugins-mcp/`, or load warning in MCP stderr |
| Install rolls back | Migration failure or `Runtime.Load` error; check API logs for cleanup messages |
| `plugin is already up to date` | Installed version matches catalog; upgrade requires strictly newer `X.Y.Z` |

## Related pages

<CardGroup>
<Card title="Build a plugin" href="/build-plugin">
Author `plugin.json`, compile WASM and frontend remotes, and install locally.
</Card>
<Card title="Install marketplace plugins" href="/install-marketplace-plugins">
Install from the `Paca-AI/paca-plugins` catalog via admin UI or API.
</Card>
<Card title="Plugin SDK reference" href="/plugin-sdk-reference">
Typed APIs for React, Go host bridge, and MCP SDK packages.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` and load plugin-contributed tools.
</Card>
<Card title="MCP tools reference" href="/mcp-tools-reference">
Core and dynamically loaded plugin tool names and schemas.
</Card>
<Card title="Configuration reference" href="/configuration-reference">
Full environment variable catalog including plugin store paths.
</Card>
</CardGroup>
