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:

1// In schema.ts — add near other feature tables
2export const poll = pgTable('poll', {
3 id: uuid('id').primaryKey().defaultRandom(),
4 networkId: uuid('network_id').references(() => network.id, { onDelete: 'cascade' }),
5 postId: uuid('post_id').references(() => post.id, { onDelete: 'cascade' }),
6 question: text('question').notNull(),
7 options: text('options').array().notNull(), // JSON array of option strings
8 expiresAt: timestamp('expires_at'),
9 createdAt: timestamp('created_at').notNull().defaultNow(),
10});
11
12export const pollVote = pgTable('poll_vote', {
13 id: uuid('id').primaryKey().defaultRandom(),
14 pollId: uuid('poll_id').notNull().references(() => poll.id, { onDelete: 'cascade' }),
15 userId: uuid('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
16 optionIndex: integer('option_index').notNull(),
17 createdAt: timestamp('created_at').notNull().defaultNow(),
18});

3. Create the Service

1// packages/server/src/features/polls/PollsService.ts
2import { eq, and, sql } from 'drizzle-orm';
3import { db } from '../../db/client';
4import { poll, pollVote } from '../../db/schema';
5
6export class PollsService {
7 async create(data: {
8 networkId: string;
9 postId: string;
10 question: string;
11 options: string[];
12 expiresAt?: Date;
13 }) {
14 const [result] = await db.insert(poll).values(data).returning();
15 return result;
16 }
17
18 async vote(pollId: string, userId: string, optionIndex: number) {
19 const [result] = await db.insert(pollVote).values({
20 pollId,
21 userId,
22 optionIndex,
23 }).returning();
24 return result;
25 }
26
27 async getResults(pollId: string) {
28 const results = await db.execute(sql`
29 SELECT option_index, COUNT(*)::int as count
30 FROM poll_vote
31 WHERE poll_id = ${pollId}
32 GROUP BY option_index
33 ORDER BY option_index
34 `);
35 return results.rows;
36 }
37}
38
39export const pollsService = new PollsService();

4. Create the Router

1// packages/server/src/features/polls/polls.router.ts
2import { z } from 'zod';
3import { router, protectedProcedure, publicProcedure } from '../../trpc/init';
4import { pollsService } from './PollsService';
5import { requireFeature } from '../../trpc/middleware/features';
6
7const pollsProtected = protectedProcedure.use(requireFeature('polls'));
8const pollsPublic = publicProcedure.use(requireFeature('polls'));
9
10export const pollsRouter = router({
11 create: pollsProtected
12 .input(z.object({
13 postId: z.string().uuid(),
14 question: z.string().min(1).max(500),
15 options: z.array(z.string().min(1).max(200)).min(2).max(10),
16 expiresAt: z.date().optional(),
17 }))
18 .mutation(async ({ ctx, input }) => {
19 return pollsService.create({
20 networkId: ctx.network.id,
21 ...input,
22 });
23 }),
24
25 vote: pollsProtected
26 .input(z.object({
27 pollId: z.string().uuid(),
28 optionIndex: z.number().int().min(0),
29 }))
30 .mutation(async ({ ctx, input }) => {
31 return pollsService.vote(input.pollId, ctx.user.id, input.optionIndex);
32 }),
33
34 results: pollsPublic
35 .input(z.object({ pollId: z.string().uuid() }))
36 .query(async ({ input }) => {
37 return pollsService.getResults(input.pollId);
38 }),
39});

5. Create the Index

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

6. Register the Router

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

1import { pollsRouter } from '../../features/polls';
2
3export const appRouter = router({
4 // ... existing routers
5 polls: pollsRouter,
6});

7. Add Tests

1// packages/server/src/features/polls/__tests__/polls.test.ts
2import { describe, it, expect } from 'vitest';
3import { PollsService } from '../PollsService';
4
5describe('PollsService', () => {
6 it('should create a poll', async () => {
7 // Test implementation
8 });
9});

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:

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

Network Isolation

Always filter by networkId from context:

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

Error Handling

Use TRPCError for client-facing errors:

1import { TRPCError } from '@trpc/server';
2
3throw new TRPCError({
4 code: 'NOT_FOUND',
5 message: 'Poll not found',
6});

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.