> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.recursiv.io/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.recursiv.io/_mcp/server.

# Plugin Architecture

## What is a Plugin?

A plugin is a self-contained feature module that extends Recursiv. Plugins follow the same architecture as core features — they're just not bundled by default.

Plugins can:

* Add tRPC procedures (new API endpoints)
* Add database tables
* Add client-side screens and components
* Hook into existing features (posts, chat, agents)

## Plugin Architecture

A plugin lives in `packages/server/src/features/{plugin-name}/` and optionally `packages/client/src/features/{plugin-name}/`.

### Server-Side Structure

```text
packages/server/src/features/polls/
├── polls.router.ts      # tRPC procedures
├── PollsService.ts      # Business logic
├── index.ts             # Barrel exports
└── __tests__/
    └── polls.test.ts    # Tests
```

### Client-Side Structure (Optional)

```text
packages/client/src/features/polls/
├── screens/
│   └── PollScreen.tsx   # Screen component
├── components/
│   ├── PollCard.tsx     # Reusable component
│   └── PollForm.tsx
├── hooks.ts             # Custom hooks
└── index.ts             # Barrel exports
```

## Step-by-Step: Building a Plugin

### 1. Create the Feature Directory

```bash
mkdir -p packages/server/src/features/polls
```

### 2. Define the Database Schema

Add your tables to `packages/server/src/db/schema.ts`:

```typescript
// In schema.ts — add near other feature tables
export const poll = pgTable('poll', {
  id: uuid('id').primaryKey().defaultRandom(),
  networkId: uuid('network_id').references(() => network.id, { onDelete: 'cascade' }),
  postId: uuid('post_id').references(() => post.id, { onDelete: 'cascade' }),
  question: text('question').notNull(),
  options: text('options').array().notNull(), // JSON array of option strings
  expiresAt: timestamp('expires_at'),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

export const pollVote = pgTable('poll_vote', {
  id: uuid('id').primaryKey().defaultRandom(),
  pollId: uuid('poll_id').notNull().references(() => poll.id, { onDelete: 'cascade' }),
  userId: uuid('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
  optionIndex: integer('option_index').notNull(),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});
```

### 3. Create the Service

```typescript
// packages/server/src/features/polls/PollsService.ts
import { eq, and, sql } from 'drizzle-orm';
import { db } from '../../db/client';
import { poll, pollVote } from '../../db/schema';

export class PollsService {
  async create(data: {
    networkId: string;
    postId: string;
    question: string;
    options: string[];
    expiresAt?: Date;
  }) {
    const [result] = await db.insert(poll).values(data).returning();
    return result;
  }

  async vote(pollId: string, userId: string, optionIndex: number) {
    const [result] = await db.insert(pollVote).values({
      pollId,
      userId,
      optionIndex,
    }).returning();
    return result;
  }

  async getResults(pollId: string) {
    const results = await db.execute(sql`
      SELECT option_index, COUNT(*)::int as count
      FROM poll_vote
      WHERE poll_id = ${pollId}
      GROUP BY option_index
      ORDER BY option_index
    `);
    return results.rows;
  }
}

export const pollsService = new PollsService();
```

### 4. Create the Router

```typescript
// packages/server/src/features/polls/polls.router.ts
import { z } from 'zod';
import { router, protectedProcedure, publicProcedure } from '../../trpc/init';
import { pollsService } from './PollsService';
import { requireFeature } from '../../trpc/middleware/features';

const pollsProtected = protectedProcedure.use(requireFeature('polls'));
const pollsPublic = publicProcedure.use(requireFeature('polls'));

export const pollsRouter = router({
  create: pollsProtected
    .input(z.object({
      postId: z.string().uuid(),
      question: z.string().min(1).max(500),
      options: z.array(z.string().min(1).max(200)).min(2).max(10),
      expiresAt: z.date().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      return pollsService.create({
        networkId: ctx.network.id,
        ...input,
      });
    }),

  vote: pollsProtected
    .input(z.object({
      pollId: z.string().uuid(),
      optionIndex: z.number().int().min(0),
    }))
    .mutation(async ({ ctx, input }) => {
      return pollsService.vote(input.pollId, ctx.user.id, input.optionIndex);
    }),

  results: pollsPublic
    .input(z.object({ pollId: z.string().uuid() }))
    .query(async ({ input }) => {
      return pollsService.getResults(input.pollId);
    }),
});
```

### 5. Create the Index

```typescript
// packages/server/src/features/polls/index.ts
export { pollsRouter } from './polls.router';
export { pollsService, PollsService } from './PollsService';
```

### 6. Register the Router

In `packages/server/src/trpc/routers/index.ts`:

```typescript
import { pollsRouter } from '../../features/polls';

export const appRouter = router({
  // ... existing routers
  polls: pollsRouter,
});
```

### 7. Add Tests

```typescript
// packages/server/src/features/polls/__tests__/polls.test.ts
import { describe, it, expect } from 'vitest';
import { PollsService } from '../PollsService';

describe('PollsService', () => {
  it('should create a poll', async () => {
    // Test implementation
  });
});
```

## Conventions

### Naming

* Feature directory: lowercase, kebab-case (`rich-compose/`)
* Router file: `{name}.router.ts`
* Service file: `{Name}Service.ts` (PascalCase)
* Export: `{name}Router`, `{name}Service`

### Feature Gating

Use `requireFeature()` middleware so plugins can be toggled per tenant:

```typescript
const myProcedure = protectedProcedure.use(requireFeature('polls'));
```

### Network Isolation

Always filter by `networkId` from context:

```typescript
const items = await db.query.poll.findMany({
  where: eq(poll.networkId, ctx.network.id),
});
```

### Error Handling

Use TRPCError for client-facing errors:

```typescript
import { TRPCError } from '@trpc/server';

throw new TRPCError({
  code: 'NOT_FOUND',
  message: 'Poll not found',
});
```

## Submitting Your Plugin

1. Open a [Plugin Proposal](https://github.com/recursivlabs/recursiv/issues/new?template=plugin_proposal.md) issue
2. Get feedback from maintainers
3. Build the plugin following this guide
4. Submit a PR referencing the proposal issue
5. Pass code review and CI

### Plugin Review Criteria

* Follows the architecture patterns above
* Has tests (unit and/or integration)
* Uses feature gating (`requireFeature`)
* Scoped to network (`networkId`)
* No security vulnerabilities
* Clean TypeScript (passes `pnpm typecheck`)
* Documented (at minimum, JSDoc on public methods)

## Revenue Sharing

Paid plugins in the marketplace generate revenue. Plugin authors receive a share of subscription revenue from their plugins. Details are arranged per-plugin — open a proposal issue to discuss.