Plugin Architecture

Build self-contained feature modules that extend Recursiv

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

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)

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

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

2. Define the Database Schema

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

// 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

// 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

// 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

// 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:

import { pollsRouter } from '../../features/polls';
export const appRouter = router({
// ... existing routers
polls: pollsRouter,
});

7. Add Tests

// 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:

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

Network Isolation

Always filter by networkId from context:

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

Error Handling

Use TRPCError for client-facing errors:

import { TRPCError } from '@trpc/server';
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Poll not found',
});

Submitting Your Plugin

  1. Open a Plugin Proposal 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.