# Build a plugin

> End-to-end plugin authoring: plugin.json manifest, WASM backend, frontend extension points, migrations, build output, and local install.

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

## Source Files

- `docs/plugins/developer-guide.md`
- `docs/plugins/backend-plugin-system.md`
- `docs/plugins/frontend-plugin-system.md`
- `docs/plugins/sdk-reference.md`
- `scripts/install-local-plugin.sh`
- `services/api/internal/transport/http/handler/plugin_handler.go`

---

---
title: "Build a plugin"
description: "End-to-end plugin authoring: plugin.json manifest, WASM backend, frontend extension points, migrations, build output, and local install."
---

A Paca plugin ships as a versioned `plugin.json` manifest plus a WASM backend (`backend.wasm`), a Module Federation frontend bundle (`remoteEntry.js`), and optional SQL migrations under `migrations/`. The API loads WASM modules through wazero at startup, runs pending migrations into a per-plugin PostgreSQL schema, and proxies HTTP traffic to `/api/v1/plugins/{pluginId}/*`. The web host fetches enabled plugins from `GET /api/v1/plugins` and lazy-loads remote components at registered extension points.

<Note>
A full reference implementation lives at [github.com/Paca-AI/paca-plugin-example](https://github.com/Paca-AI/paca-plugin-example). SDK packages are published separately: `@paca-ai/plugin-sdk-react`, `github.com/Paca-AI/plugin-sdk-go`, and `@paca-ai/plugin-sdk-mcp`.
</Note>

## Prerequisites

| Requirement | Version / notes |
|---|---|
| Go | 1.21+ with `GOOS=wasip1 GOARCH=wasm` build support |
| Node.js runtime | 20+ with **bun** (used by `scripts/install-local-plugin.sh`) or pnpm |
| Running Paca stack | Local dev Compose or host-side services — see [Local development](/local-development) |
| Admin API key | Required for plugin registration; needs `users.write` global permission |

## Repository layout

Develop in a standalone repository or under `plugins/` in the monorepo.

:::files
my-plugin/
  plugin.json                 # manifest (source of truth)
  backend/
    go.mod
    main.go                   # WASM entry — plugin.Run(&myPlugin{})
    handler.go                # route and event handlers
    migrations/
      0001_create_my_table.sql
  frontend/
    package.json
    vite.config.ts
    src/
      TaskDetailSection.tsx   # extension-point component
      index.ts
  dist/                       # build output (gitignored)
:::

After local install, artifacts land in the Paca plugin store:

:::files
plugins/local/
  backend/<plugin-id>/
    plugin.json
    backend.wasm
    migrations/*.sql
  frontend/<plugin-id>/
    assets/remoteEntry.js     # Vite federation output
    assets/*.js               # chunks
  mcp/<plugin-id>/            # optional MCP ESM bundle
    mcp.js
:::

<Info>
Environment defaults: `PLUGINS_WASM_DIR=./plugins/local/backend`, `PLUGINS_FRONTEND_DIR=./plugins/local/frontend`, `PLUGINS_MCP_DIR=./plugins/local/mcp`.
</Info>

## Write the manifest

`plugin.json` is stored in the `plugins` table as JSONB and drives route registration, extension-point rendering, and MCP tool loading.

### Top-level fields

<ParamField body="id" type="string" required>
Reverse-DNS identifier (e.g. `com.example.my-plugin`). Must match the `name` field on install and stay stable after first release. Becomes part of API paths and the database schema name.
</ParamField>

<ParamField body="displayName" type="string" required>
Human-readable name shown in the admin UI and extension-point labels.
</ParamField>

<ParamField body="version" type="string" required>
Strict semver `X.Y.Z` (no pre-release or build metadata).
</ParamField>

<ParamField body="description" type="string">
Short summary of plugin behavior.
</ParamField>

<ParamField body="permissions" type="string[]">
Host-function capability scopes the WASM module may call (e.g. `db:read:tasks`, `db:write:plugin_data`, `http:register_routes`, `events:subscribe:task.*`, `events:emit`).
</ParamField>

<ParamField body="backend" type="object">
WASM route and event declarations.
</ParamField>

<ParamField body="frontend" type="object">
Module Federation `remoteEntryUrl` and extension-point registrations.
</ParamField>

<ParamField body="mcp" type="object">
Optional MCP entry module URL for AI client tool loading.
</ParamField>

### Backend section

```json
{
  "backend": {
    "routes": [
      { "method": "GET", "path": "/projects/:projectId/tasks/:taskId/my-items" },
      {
        "method": "POST",
        "path": "/projects/:projectId/tasks/:taskId/my-items",
        "middlewares": [
          { "name": "authn" },
          { "name": "requireFreshPassword" },
          {
            "name": "requirePermissions",
            "scope": "project",
            "permissions": ["tasks.write"]
          }
        ]
      },
      {
        "method": "POST",
        "path": "/webhook",
        "middlewares": [{ "name": "optionalAuthn" }]
      }
    ],
    "eventSubscriptions": ["task.deleted"],
    "allowedOutboundDomains": ["api.example.com"],
    "allowedConfigKeys": ["greeting.prefix"]
  }
}
```

| Route field | Behavior |
|---|---|
| `method` | `GET`, `POST`, `PATCH`, `PUT`, or `DELETE` |
| `path` | Relative to `/api/v1/plugins/{pluginId}/`. Include `/projects/:projectId/` for project-scoped handlers. Supports `:param` and trailing `*rest` wildcards. |
| `middlewares` | Ordered host middleware chain. `null` (omitted) applies defaults; `[]` disables all middleware. |
| `public` | Legacy flag equivalent to an empty middleware chain |

**Default middleware** when `middlewares` is omitted and `public` is false:

- `optionalAuthn`
- `requireFreshPassword`

Supported middleware names: `authn`, `optionalAuthn`, `requireFreshPassword`, `requireJWTAuth`, `requirePermissions` (with `scope`, `projectParam`, `permissions`).

### Frontend section

```json
{
  "frontend": {
    "remoteEntryUrl": "/plugins/com.example.my-plugin/assets/remoteEntry.js",
    "extensionPoints": [
      {
        "point": "task.detail.section",
        "component": "TaskDetailSection",
        "label": "My Feature",
        "order": 50
      }
    ]
  }
}
```

| Extension point ID | Surface |
|---|---|
| `sidebar.general.section` | Global left navigation |
| `sidebar.project.section` | Project sidebar |
| `task.detail.section` | Task detail drawer/page |
| `project.settings.tab` | Project settings tabs |
| `view` | Full board/view replacement |

For local dev through the nginx gateway, set `remoteEntryUrl` to `/plugins/<plugin-id>/assets/remoteEntry.js`. Production plugins use an HTTPS CDN origin allowlisted in server config.

### MCP section (optional)

```json
{
  "mcp": {
    "remoteEntryUrl": "/plugins-mcp/com.example.my-plugin/mcp.js"
  }
}
```

The Paca MCP server loads this ESM bundle at startup and merges exported tools. Prefix tool names (e.g. `my_plugin_list_items`) to avoid collisions.

## Build the WASM backend

### Go module and entry point

```go
//go:build wasip1

package main

import plugin "github.com/Paca-AI/plugin-sdk-go"

type myPlugin struct {
    db  *plugin.DB
    kv  *plugin.KV
    log *plugin.Logger
}

func (p *myPlugin) Init(ctx *plugin.Context) error {
    p.db  = ctx.DB()
    p.kv  = ctx.KV()
    p.log = ctx.Log()

    ctx.Route("GET",  "/projects/:projectId/tasks/:taskId/my-items", p.listItems)
    ctx.Route("POST", "/projects/:projectId/tasks/:taskId/my-items", p.createItem)
    ctx.On("task.deleted", p.onTaskDeleted)
    return nil
}

func (p *myPlugin) Shutdown() {}

func init() { plugin.Run(&myPlugin{}) }
func main() {}
```

Routes registered in `Init` must match paths declared in `plugin.json`. The host dispatches matching HTTP requests to the plugin's WASM `HandleRequest` export.

### Compile

<CodeGroup>
```bash title="Standard Go (recommended locally)"
cd backend
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o backend.wasm .
```

```bash title="TinyGo (smaller binary)"
cd backend
GOOS=wasip1 GOARCH=wasm tinygo build -o backend.wasm -target wasip1 .
```
</CodeGroup>

Output filename must be `backend.wasm` — the host reads `{PLUGINS_WASM_DIR}/{plugin-id}/backend.wasm`.

### Database migrations

Place additive SQL files in `backend/migrations/` using lexicographic names (`0001_create_items.sql`, `0002_add_column.sql`). The host discovers files from the filesystem; they are **not** listed in `plugin.json`.

On apply, the migration runner:

1. Creates schema `plugin_data_{plugin_id}` (dots → underscores, e.g. `com.paca.checklist` → `plugin_data_com_paca_checklist`)
2. Ensures `plugin_kv` and `plugin_schema_migrations` tables exist
3. Runs pending `.sql` files in order with `search_path` set to the plugin schema

```sql
-- backend/migrations/0001_create_my_items.sql
CREATE TABLE my_items (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    task_id    UUID NOT NULL,
    project_id UUID NOT NULL,
    title      TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON my_items (task_id);
```

<Warning>
Migrations are additive only. Never drop or rename columns in place; ship a new migration file instead.
</Warning>

## Build the frontend

### Vite Module Federation config

```ts
import federation from "@originjs/vite-plugin-federation";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: "com_example_my_plugin",
      filename: "remoteEntry.js",
      exposes: {
        "./TaskDetailSection": "./src/TaskDetailSection.tsx",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.3.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.3.0" },
        "@paca-ai/plugin-sdk-react": { singleton: true },
      },
    }),
  ],
  build: {
    target: "esnext",
    outDir: "../dist",
    emptyOutDir: false,
  },
});
```

### Extension-point component

```tsx
import type { TaskDetailSectionProps } from "@paca-ai/plugin-sdk-react";
import {
  PluginQueryClientProvider,
  usePluginQuery,
} from "@paca-ai/plugin-sdk-react";

export default function TaskDetailSection(props: TaskDetailSectionProps) {
  return (
    <PluginQueryClientProvider>
      <MyFeaturePanel {...props} />
    </PluginQueryClientProvider>
  );
}

function MyFeaturePanel({ api, meta, taskId, projectId }: TaskDetailSectionProps) {
  const { data: items = [], isLoading } = usePluginQuery(
    meta.pluginId,
    ["my-items", taskId],
    () =>
      api.pluginGet(
        meta.pluginId,
        `projects/${projectId}/tasks/${taskId}/my-items`,
      ),
  );

  if (isLoading) return <div>Loading…</div>;
  return (
    <section>
      <h3>My Feature</h3>
      <ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
    </section>
  );
}
```

```bash
cd frontend
bun install
bun run build
```

Vite emits `dist/assets/remoteEntry.js` plus hashed chunks. Copy the entire `dist/` tree into `plugins/local/frontend/<plugin-id>/`.

## Build output summary

| Artifact | Source | Install path | Consumed by |
|---|---|---|---|
| `backend.wasm` | Go/TinyGo compile | `plugins/local/backend/<id>/backend.wasm` | API wazero runtime |
| `plugin.json` | Project root | `plugins/local/backend/<id>/plugin.json` | API store + DB manifest |
| `migrations/*.sql` | `backend/migrations/` | `plugins/local/backend/<id>/migrations/` | `MigrationRunner` on API startup |
| `assets/remoteEntry.js` | Vite federation build | `plugins/local/frontend/<id>/assets/` | nginx gateway → web host |
| `mcp.js` | Vite lib build (optional) | `plugins/local/mcp/<id>/mcp.js` | `@paca-ai/paca-mcp` |

```mermaid
flowchart LR
  subgraph author [Plugin author repo]
    MJ[plugin.json]
    WASM[backend.wasm]
    FE[remoteEntry.js]
    SQL[migrations/*.sql]
  end

  subgraph store [plugins/local]
    BE[backend/id/]
    FF[frontend/id/]
  end

  subgraph runtime [Paca runtime]
    API[services/api wazero]
    WEB[apps/web Module Federation]
    MCP[paca-mcp loader]
  end

  MJ --> BE
  WASM --> BE
  SQL --> BE
  FE --> FF
  BE --> API
  FF --> WEB
  BE --> MCP
```

## Install locally

<Steps>
<Step title="Build and copy artifacts">

Use the install script (builds backend WASM, frontend bundle, copies into `plugins/local/`, then registers via API):

```bash
export API_KEY=<admin-api-key>
./scripts/install-local-plugin.sh /path/to/my-plugin \
  --api-url http://localhost \
  --api-key "$API_KEY"
```

Flags: `--skip-build` (install only), `--skip-install` (build only), `--paca-dir` (override monorepo root).

Or follow the manual sequence in `scripts/QUICK_START.md`: compile WASM, copy to `plugins/local/backend/<id>/`, build frontend, copy to `plugins/local/frontend/<id>/`.

</Step>

<Step title="Register the plugin in the database">

`POST /api/v1/admin/plugins` requires authentication and `users.write` permission. Use session cookie or `X-API-Key` header.

<RequestExample>
```bash
curl -X POST http://localhost/api/v1/admin/plugins \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
    \"name\": \"com.example.my-plugin\",
    \"version\": \"0.1.0\",
    \"manifest\": $(cat /path/to/my-plugin/plugin.json),
    \"enabled\": true
  }"
```
</RequestExample>

<ResponseExample>
```json
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "com.example.my-plugin",
    "version": "0.1.0",
    "enabled": true,
    "manifest": { "id": "com.example.my-plugin", "displayName": "My Plugin", "version": "0.1.0" }
  }
}
```
</ResponseExample>

To update an existing plugin, `PATCH /api/v1/admin/plugins/:pluginId` with new `version` and `manifest`.

<Note>
Unlike marketplace install, the admin install endpoint only writes the DB record. Migrations and WASM loading happen on the next API process start.
</Note>

</Step>

<Step title="Restart API services">

Restart `services/api` (or the full Compose stack). On startup the host:

1. Runs pending migrations for each enabled plugin
2. Loads `backend.wasm` into wazero and calls `Init()`
3. Registers plugin routes on the Gin router

The web app picks up the plugin on the next page load via `GET /api/v1/plugins`.

</Step>

<Step title="Verify">

**List installed plugins:**

```bash
curl -s http://localhost/api/v1/plugins -H "X-API-Key: $API_KEY" | jq '.data.plugins'
```

**Call a plugin route:**

```bash
curl -s http://localhost/api/v1/plugins/com.example.my-plugin/projects/{pid}/tasks/{tid}/my-items \
  -H "X-API-Key: $API_KEY" | jq .
```

**Frontend:** open a task detail panel — the `task.detail.section` component should render in order alongside other plugins.

</Step>
</Steps>

## Versioning and publishing checklist

- Follow semver: patch for fixes, minor for new extension points or routes, major for breaking API changes.
- Set `minCoreVersion` in `plugin.json` to the lowest Paca version you tested against.
- Keep all plugin tables inside the auto-created `plugin_data_*` schema.
- Never embed secrets in WASM or JS bundles; read config through host `paca.config_get` with `allowedConfigKeys`.
- Point `frontend.remoteEntryUrl` at HTTPS in production; sign WASM for third-party distribution.
- Add unit tests with `plugintest` for backend routes and events.

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| Plugin missing from UI | `enabled: false`, no `frontend.remoteEntryUrl`, or no extension-point registrations |
| `404` on plugin route | Route path mismatch between `plugin.json` and `ctx.Route`, or API not restarted after install |
| Migration failure on startup | Non-additive SQL, syntax error, or migration file not copied to `plugins/local/backend/<id>/migrations/` |
| Remote entry load error | Wrong `remoteEntryUrl` path — local installs need `/plugins/<id>/assets/remoteEntry.js` |
| `401`/`403` on install | API key lacks `users.write`; use an admin key |
| WASM load error | Binary not at `backend.wasm`, wrong build target, or missing `-buildmode=c-shared` with standard Go |

## Related pages

<CardGroup>
<Card title="Plugin system" href="/plugin-system">
WASM sandbox, extension points, MCP tools, capability permissions, and marketplace lifecycle.
</Card>
<Card title="Plugin SDK reference" href="/plugin-sdk-reference">
Typed APIs for React, Go, and MCP SDK packages.
</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="Local development" href="/local-development">
Dev Compose stack, hot-reload, and nginx gateway port map.
</Card>
<Card title="Connect MCP server" href="/connect-mcp">
Wire `@paca-ai/paca-mcp` and load plugin MCP tools in AI clients.
</Card>
</CardGroup>
