---
name: plugin-development
description: Build, register, and deploy plugins within everything.dev. Covers the _template scaffold, contract/service/index pattern, database setup with Drizzle, bos.config.json registration, plugin UI/sidebar, and CLI workflow. Use when creating new plugins, adding database-backed routes, or deploying plugins to production.
metadata:
  sources: "plugins/_template/src/index.ts,plugins/_template/src/contract.ts,plugins/_template/src/service.ts,plugins/_template/rspack.config.js,plugins/_template/plugin.dev.ts,plugins/projects/src/db/schema.ts,plugins/projects/src/db/layer.ts,api/src/db/index.ts,api/src/db/migrator.ts,packages/every-plugin/src/plugin.ts"
---

# Plugin Development

## Plugin Structure

```
plugins/your-plugin/
├── src/
│   ├── contract.ts          # oRPC route definitions + Zod schemas
│   ├── service.ts           # Business logic (class or plain functions)
│   ├── index.ts             # createPlugin() wiring
│   ├── db/                  # Optional: database schema + migrations
│   │   ├── schema.ts
│   │   ├── layer.ts
│   │   ├── migrator.ts
│   │   └── migrations/
│   ├── plugins-client.gen.ts  # Generated — in-process plugin client types
│   └── __tests__/            # Tests
├── package.json
├── rspack.config.js          # Build config
├── plugin.dev.ts             # Dev server (port, variables, secrets)
└── tsconfig.json
```

## Step 1: Copy from `_template`

The quickest start is to copy the template:

```bash
cp -r plugins/_template plugins/your-plugin
```

Then rename in `package.json`, `plugin.dev.ts`, and `rspack.config.js`.

Or use the CLI (no automated command yet — copy template manually).

## Step 2: Define the Contract (`src/contract.ts`)

Use `oc.router()` to define typed routes. Each route has a method, path, optional input/output schemas, and optional error declarations:

```ts
import { eventIterator, oc } from "every-plugin/orpc";
import { z } from "every-plugin/zod";

// Define errors this plugin throws
const Errors = {
  NOT_FOUND: { status: 404, message: "Item not found" },
  UNAUTHORIZED: { status: 401, message: "User ID required" },
};

export const ItemSchema = z.object({
  id: z.string(),
  title: z.string(),
  createdAt: z.string().datetime(),
});

export const contract = oc.router({
  getById: oc
    .route({ method: "GET", path: "/items/{id}" })
    .input(z.object({ id: z.string().min(1) }))
    .output(z.object({ item: ItemSchema, userId: z.string() }))
    .errors(Errors),

  ping: oc
    .route({ method: "GET", path: "/ping" })
    .output(z.object({ status: z.literal("ok"), timestamp: z.string().datetime() })),

  search: oc
    .route({ method: "GET", path: "/search" })
    .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
    .output(eventIterator(SearchResultSchema)),  // SSE streaming

  enqueueBackground: oc
    .route({ method: "POST", path: "/background/events" })
    .input(z.object({ id: z.string().optional() }))
    .output(z.object({ ok: z.boolean() })),
});

export type ContractType = typeof contract;
```

Key rules:
- Import from `every-plugin/zod`, `every-plugin/orpc`, `every-plugin/errors` — never directly from `zod` or `@orpc/*`
- Path params (`{id}`) automatically bind to Zod input keys
- `.output(eventIterator(Schema))` enables SSE streaming routes
- `.errors(Errors)` enables typed error handling in generated clients

## Step 3: Build Services (`src/service.ts`)

Services are plain classes or functions, optionally using Effect:

```ts
import { Effect } from "every-plugin/effect";

export class TemplateService {
  constructor(
    private baseUrl: URL,
    private apiKey: string,
    private timeout: number,
  ) {}

  ping(): Effect.Effect<{ status: string; timestamp: string }> {
    return Effect.succeed({ status: "ok", timestamp: new Date().toISOString() });
  }

  getById(id: string): Effect.Effect<{ id: string; title: string; createdAt: string }> {
    return Effect.tryPromise(async () => {
      const res = await fetch(`${this.baseUrl}/items/${id}`, {
        headers: { Authorization: `Bearer ${this.apiKey}` },
        signal: AbortSignal.timeout(this.timeout),
      });
      if (!res.ok) throw new Error("Item not found");
      return res.json();
    });
  }
}
```

Use `Effect.runPromise(service.method())` to bridge Effect and async handlers in `createRouter`.

## Step 4: Wire with `createPlugin` (`src/index.ts`)

```ts
import { createPlugin } from "every-plugin";
import { Effect } from "every-plugin/effect";
import { MemoryPublisher, ORPCError } from "every-plugin/orpc";
import { z } from "every-plugin/zod";
import { contract } from "./contract";
import type { PluginsClient } from "./plugins-client.gen";
import { TemplateService } from "./service";

export default createPlugin.withPlugins<PluginsClient>()({
  variables: z.object({
    baseUrl: z.url().default("https://api.example.com"),
    timeout: z.number().min(1000).max(60000).default(10000),
  }),

  secrets: z.object({
    apiKey: z.string().min(1).default("dev-key"),
  }),

  context: z.object({
    userId: z.string().optional(),
    user: z.object({
      id: z.string(),
      role: z.string().optional(),
      email: z.string().optional(),
      name: z.string().optional(),
    }).optional(),
    reqHeaders: z.custom<Headers>().optional(),
    getRawBody: z.custom<() => Promise<string>>().optional(),
  }),

  contract,

  initialize: (config) =>
    Effect.gen(function* () {
      const service = new TemplateService(config.variables.baseUrl, config.secrets.apiKey, config.variables.timeout);
      yield* service.ping(); // health check on startup

      const publisher = new MemoryPublisher({ resumeRetentionSeconds: 120 });

      return { service, publisher };
    }),

  shutdown: () => Effect.void,

  createRouter: (context, builder) => {
    const { service, publisher } = context;
    const requireAuth = builder.middleware(async ({ context, next }) => {
      if (!context.userId) throw new ORPCError("UNAUTHORIZED", { message: "User ID required" });
      return next({ context: { ...context, userId: context.userId } });
    });

    return {
      getById: builder.getById.use(requireAuth).handler(async ({ input, context }) => {
        const item = await Effect.runPromise(service.getById(input.id));
        return { item, userId: context.userId };
      }),

      ping: builder.ping.handler(async () => {
        return await Effect.runPromise(service.ping());
      }),

      search: builder.search.handler(async function* ({ input }) {
        const generator = await Effect.runPromise(service.search(input.query, input.limit));
        for await (const result of generator) yield result;
      }),
    };
  },
});
```

### Thing Provider Pattern

When the API owns the registry but the plugin owns the payload, add a small provider surface to your contract:

```ts
createThing: oc
  .route({ method: "POST", path: "/things" })
  .input(z.object({ thingId: z.string(), payload: z.unknown() }))
  .output(z.object({ type: z.string(), payload: z.unknown(), action: z.string().optional() })),

getThing: oc
  .route({ method: "GET", path: "/things/{thingId}" })
  .input(z.object({ thingId: z.string() }))
  .output(z.object({ type: z.string(), payload: z.unknown() })),
```

In `service.ts`, keep the plugin-specific type derivation close to the data source:

```ts
createThing(thingId: string, payload: unknown) {
  return Effect.sync(() => {
    const type = this.resolveThingType(payload);
    this.things.set(thingId, { thingId, type, payload, createdAt: now, updatedAt: now });
    return { type, payload, action: `${type}.created` };
  });
}
```

Good practice:
- Let the API store only `thingId`, `pluginId`, and timestamps.
- Let the plugin decide the public `type` string and payload shape.
- Use `pluginId` as the API routing key, not as a UI concern.
- Keep `plugins-client.gen.ts` local to the plugin scaffold so `createPlugin.withPlugins<PluginsClient>()` stays typed.

### Key Patterns

**`variables`** — Public config exposed in `bos.config.json` (typed, with defaults).  
**`secrets`** — Private values from `process.env` (typed, dev defaults).  
**`context`** — Per-request context injected by the host. See "Request Context Reference" below for all available fields.  
**`initialize`** — Effect-based startup. Create services, publishers, DB connections. Return value is passed as `context` to `createRouter`.  
**`createRouter`** — Maps contract procedures to handlers. Receives the value returned by `initialize` plus a pre-configured `builder`.

## Request Context Reference

The host injects a per-request context object into every plugin. The plugin **must declare the fields it uses** in its context zod schema. Only declared fields are available to route handlers and middleware:

```ts
context: z.object({
  userId: z.string().optional(),
  user: z.object({
    id: z.string(),
    role: z.string().optional(),   // "admin", "member", or null for anon
    email: z.string().optional(),
    name: z.string().optional(),
  }).optional(),
  organizationId: z.string().optional(),   // Active organization UUID
  organization: z.object({
    activeOrganizationId: z.string().nullable().optional(),
    organization: z.object({
      id: z.string(),
      name: z.string(),
      slug: z.string(),
      logo: z.string().nullable().optional(),
      metadata: z.record(z.string(), z.unknown()).optional(),  // daoAccountId lives here
    }).nullable().optional(),
    member: z.object({
      id: z.string(),
      role: z.string(),   // User's role within this org ("admin", "member")
    }).nullable().optional(),
    isPersonal: z.boolean(),
    hasOrganization: z.boolean(),
  }).optional(),
  near: z.object({
    primaryAccountId: z.string().nullable(),
    linkedAccounts: z.array(z.object({
      accountId: z.string(),
      network: z.string(),
      publicKey: z.string(),
      isPrimary: z.boolean(),
    })),
    hasNearAccount: z.boolean(),
  }).optional(),
  walletAddress: z.string().optional(),
  apiKey: z.object({
    id: z.string(),
    name: z.string().nullable().optional(),
    permissions: z.record(z.string(), z.array(z.string())).nullable().optional(),
  }).optional(),
  reqHeaders: z.custom<Headers>().optional(),
  getRawBody: z.custom<() => Promise<string>>().optional(),
}),
```

The zod schema is a filter — the host passes all fields, but your plugin only sees what you declare. For pre-built auth/organization middleware, see the `api-and-auth` skill.

## Step 5: Register in `bos.config.json`

Add a plugin entry:

```json
{
  "plugins": {
    "your-plugin": {
      "name": "your-plugin",
      "development": "local:plugins/your-plugin",
      "production": "https://cdn.example.com/your-plugin/remoteEntry.js",
      "ssr": "https://cdn.example.com/your-plugin/ssr/remoteEntry.js",
      "variables": {
        "baseUrl": "https://api.example.com",
        "timeout": 5000
      },
      "secrets": ["YOURPLUGIN_API_KEY"],
      "sidebar": [
        {
          "label": "Your Plugin",
          "icon": "Activity",
          "to": "/your-plugin",
          "roleRequired": "member"
        }
      ],
      "routes": ["/your-plugin/*"],
      "shared": {
        "react": { "version": "^19.0.0", "singleton": true },
        "react-dom": { "version": "^19.0.0", "singleton": true }
      }
    }
  }
}
```

The CLI updates `bos.config.json` automatically when you run `bos plugin add` / `bos plugin remove`.

## Step 6: `plugin.dev.ts`

Dev server config for local development:

```ts
import { defineConfig } from "every-plugin/dev";

export default defineConfig({
  port: 3015,
  variables: {
    baseUrl: "https://api.example.com",
    timeout: 10000,
  },
  secrets: {
    apiKey: "dev-key",
  },
});
```

## Step 7: Build Config (`rspack.config.js`)

```js
const { createRspackConfig } = require("every-plugin/rspack");

module.exports = createRspackConfig({
  name: "your-plugin",
  exposes: {
    ".": "./src/index.ts",
    "./contract": "./src/contract.ts",
  },
  plugins: [],
});
```

Key exports:
- `"."` — the plugin entry point (`createPlugin` export)
- `"./contract"` — the contract module (for type generation from remote plugins)

## Database Setup

### Schema (`src/db/schema.ts`)

```ts
import { pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";

export const items = pgTable("items", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
  titleIdx: uniqueIndex("items_title_idx").on(table.title),
}));
```

### Driver (`src/db/index.ts` or use `api/src/db/index.ts`)

```ts
export async function createDatabaseDriver(url: string) {
  // Supports:
  //   "pglite:.bos/your-plugin/:memory:" — in-memory PGlite (dev)
  //   "pglite:.bos/your-plugin/data"      — persistent PGlite (dev)
  //   "<postgres connection string>"      — real PostgreSQL (prod)
  // Returns { db: drizzle client, close(): Promise<void> }
}
```

### Layer Pattern

```ts
// src/db/layer.ts
import { Effect, Layer } from "every-plugin/effect";
import { createDatabaseDriver } from "./index";

export class DatabaseTag extends Context.Tag("plugins/your-plugin/Database")<
  DatabaseTag,
  { db: any; close: () => Promise<void> }
>() {}

export function DatabaseLive(url: string) {
  return Layer.scoped(
    DatabaseTag,
    Effect.acquireRelease(
      Effect.promise(async () => {
        const driver = await createDatabaseDriver(url);
        return driver;
      }),
      (driver) => Effect.promise(() => driver.close()),
    ),
  );
}
```

### Migration Generation

1. Create `drizzle.config.ts` in your plugin directory
2. Run `drizzle-kit generate` to produce SQL migration files
3. Store migrations in `src/db/migrations/`
4. Use the custom `migrate()` function during `initialize`:

```ts
initialize: (config) =>
  Effect.gen(function* () {
    const driver = yield* DatabaseLive(config.secrets.DATABASE_URL);
    // Or load and apply migrations directly:
    const { loadMigrations } = yield* Effect.promise(() => import("./db/load-migrations"));
    const { migrate } = yield* Effect.promise(() => import("./db/migrator"));
    const migrations = yield* loadMigrations();
    yield* migrate(driver.db, migrations);

    return { db: driver.db, ... };
  }),
```

### Per-Plugin Isolation

Each plugin manages its own database or schema. Connection strings are passed via secrets (`YOURPLUGIN_DATABASE_URL`). The convention:

```
YOURPLUGIN_DATABASE_URL=pglite:.bos/your-plugin/:memory:
```

In production, set this to a real PostgreSQL connection string in the host's environment.

## Calling Other Plugins

Use `createPlugin.withPlugins<PluginsClient>()` to get typed access to other plugin factories. The `plugins-client.gen.ts` file provides the `PluginsClient` type. Regenerate with `bos types gen`.

## Plugin UI

Add a UI remote via the `ui` field in `bos.config.json`. Sidebar entries use `icon` from `lucide-react` and `roleRequired` from `anon`, `member`, `admin`. These merge into `plugin-sidebar.gen.ts` at `ui/src/lib/`.

## Deploy

```bash
bos plugin publish          # Build and publish just this plugin
bos publish --deploy        # Build ALL packages + deploy + publish config
```

Deploy builds via `rspack.config.js`, uploads to Zephyr CDN, updates `bos.config.json` with production URL + integrity hash, and publishes to FastKV registry. Restart the host after publishing.

### CLI Lifecycle

1. `cp -r plugins/_template plugins/your-plugin` then edit names
2. Register in `bos.config.json` (or `bos plugin add`)
3. `bos types gen`
4. `bos dev` to develop
5. `bos plugin publish` to deploy
