Plugin SDK
Build custom plugins for Riven TS using the Plugin SDK.
The Plugin SDK (@repo/util-plugin-sdk) provides everything you need to build plugins for Riven. Every integration in Riven is a plugin - from TMDB metadata lookup to Torrentio scraping.
The SDK is not yet published to npm. Currently, plugins live within the monorepo as workspace packages. Community plugin distribution (via npm install) is planned.
Plugin Interface
Every plugin must implement the RivenPlugin interface:
interface RivenPlugin {
name: symbol; // Unique identifier
version: string; // Semver version
resolvers: Function[]; // GraphQL resolvers (at least one)
hooks: EventHandlers; // Event handlers (may be empty)
settingsSchema: ZodObject; // Zod schema for the plugin's settings
validator: (ctx: PluginContext) => Promise<boolean>;
dataSources?: DataSourceConstructor[]; // HTTP API clients
context?: (ctx: PluginContext) => Promise<Record<string, unknown>>;
}
interface PluginContext {
dataSources: DataSourceMap;
settings: PluginSettings;
}Plugins read their configuration through settings.get(YourSettingsSchema),
never from process.env directly — the registrar strips plugin variables out
of the environment before importing any plugin, so one plugin can't read
another's secrets.
Creating a Plugin
Scaffold with Turbo Generator
turbo gen plugin --args my-serviceThis creates a complete plugin at packages/plugin-my-service/ and adds it as a dependency to the main app.
Define the Plugin Config
import type { RivenPluginConfig } from "@repo/util-plugin-sdk";
export const pluginConfig = {
name: Symbol("@repo/plugin-my-service"),
} satisfies RivenPluginConfig;The Symbol is used for plugin identification, queue naming, and dependency injection.
Define the Settings Schema
A Zod schema describes everything the plugin can be configured with. It drives
validation, the generated settings documentation, and the settings.get() call
below — so .describe() every field.
import z from "zod";
export const MyServiceSettings = z.object({
apiKey: z
.string()
.min(1, "MyService API key is required")
.describe("Your MyService API key"),
url: z
.url()
.default("http://localhost:8080")
.describe("Your MyService instance URL"),
updateIntervalSeconds: z.coerce
.number()
.int()
.nonnegative()
.default(60)
.describe("How often to re-check for new content"),
});
export type MyServiceSettings = z.infer<typeof MyServiceSettings>;Users set these as
RIVEN_PLUGIN_SETTING__REPO_PLUGIN_MY_SERVICE__apiKey="...".
Implement the DataSource
DataSources are HTTP API clients that extend BaseDataSource. They include built-in rate limiting, caching, retry logic, and telemetry. this.settings is your parsed settings object.
import { BaseDataSource, type RateLimiterOptions } from "@repo/util-plugin-sdk";
import type { MyServiceSettings } from "../my-service-settings.schema.ts";
export class MyServiceAPI extends BaseDataSource<MyServiceSettings> {
override baseURL = new URL("/api/v1/", this.settings.url).toString();
override serviceName = "MyService";
protected override readonly rateLimiterOptions: RateLimiterOptions = {
max: 50,
duration: 1000,
};
protected override willSendRequest(
_path: string,
requestOpts: AugmentedRequest,
) {
requestOpts.headers["x-api-key"] = this.settings.apiKey;
}
// Called on startup — use the cheapest endpoint available
override async validate() {
try {
await this.get("health");
return true;
} catch {
return false;
}
}
// Business methods
async getItems(listId: string): Promise<ExternalIds[]> {
const response = await this.get(`lists/${listId}/items`, {
cacheOptions: { ttl: 1000 * 60 * 5 },
});
return response.items.map((item) => ({
imdbId: item.imdb_id,
tmdbId: item.tmdb_id,
}));
}
}Create GraphQL Resolvers
import { PluginDataSource } from "@repo/util-plugin-sdk";
import { Query, Resolver } from "type-graphql";
import { MyServiceAPI } from "../datasource/my-service.datasource.ts";
import { pluginConfig } from "../my-service-plugin.config.ts";
@Resolver()
export class MyServiceResolver {
@Query((_returns) => Boolean)
async myServiceIsValid(
@PluginDataSource(pluginConfig.name, MyServiceAPI) api: MyServiceAPI,
): Promise<boolean> {
return await api.validate();
}
}Also create a settings resolver, which extends the global Settings type so
clients can discover the plugin's setting keys:
import { Settings } from "@repo/util-plugin-sdk";
import { FieldResolver, Resolver } from "type-graphql";
import { MyServiceSettings } from "./types/my-service-settings.type.ts";
@Resolver(() => Settings)
export class MyServiceSettingsResolver {
@FieldResolver(() => MyServiceSettings)
public myService(): MyServiceSettings {
return { apiKey: "my-service-api-key", url: "my-service-url" };
}
}Export the Plugin
The entry point exports a named plugin binding:
import packageJson from "../package.json" with { type: "json" };
import { MyServiceAPI } from "./datasource/my-service.datasource.ts";
import { pluginConfig } from "./my-service-plugin.config.ts";
import { MyServiceSettings } from "./my-service-settings.schema.ts";
import { MyServiceSettingsResolver } from "./schema/my-service-settings.resolver.ts";
import { MyServiceResolver } from "./schema/my-service.resolver.ts";
import type { RivenPlugin } from "@repo/util-plugin-sdk";
export const plugin: RivenPlugin = {
name: pluginConfig.name,
version: packageJson.version,
dataSources: [MyServiceAPI],
resolvers: [MyServiceResolver, MyServiceSettingsResolver],
settingsSchema: MyServiceSettings,
hooks: {
"riven.content-service.requested": async ({ dataSources, settings }) => {
const { updateIntervalSeconds } = settings.get(MyServiceSettings);
const api = dataSources.get(MyServiceAPI);
const items = await api.getItems("default-list");
return {
movies: items.filter((item) => item.type === "movie"),
shows: items.filter((item) => item.type === "show"),
updateIntervalSeconds,
};
},
},
async validator({ dataSources }) {
return dataSources.get(MyServiceAPI).validate();
},
};Write Tests
import { it } from "@repo/util-plugin-testing/plugin-test-context";
import { HttpResponse } from "msw";
import { expect } from "vitest";
import { pluginConfig } from "../../my-service-plugin.config.ts";
import { MyServiceAPI } from "../my-service.datasource.ts";
it("returns true if the request succeeds", async ({
server,
dataSourceConfig,
}) => {
server.use(getHealthCheckHandler());
const api = new MyServiceAPI({
...dataSourceConfig,
pluginSymbol: pluginConfig.name,
settings: { apiKey: "test-key" },
});
expect(await api.validate()).toBe(true);
});Run tests with:
pnpm turbo test --filter=@repo/plugin-my-serviceAvailable Hooks
Plugins can subscribe to system events through hooks:
| Event | Description | Return Type |
|---|---|---|
riven.core.started | System has started | void |
riven.core.shutdown | System is shutting down | void |
riven.content-service.requested | Fetch requested media items | { movies, shows } |
riven.media-item.index.requested.movie | Movie needs metadata indexing | - |
riven.media-item.index.requested.show | Show needs metadata indexing | - |
riven.media-item.scrape.requested | Item needs torrent scraping | - |
riven.media-item.download.requested | Item needs downloading/caching | - |
riven.media-item.stream-link.requested | Item needs stream URL | - |
riven.media-item.subtitle.requested | Item needs subtitles | - |
DataSource Features
BaseDataSource extends Apollo's RESTDataSource with:
- Rate Limiting - BullMQ-based request queue with configurable limits
- HTTP Caching - Automatic TTL-based caching
- Retry Logic - Default 3 attempts with exponential backoff
- Telemetry - OpenTelemetry tracing for all requests
- Logging - Winston logger integration
Plugin Structure
packages/plugin-my-service/
├── lib/
│ ├── index.ts # Plugin export
│ ├── my-service-plugin.config.ts # Plugin identifier
│ ├── my-service-settings.schema.ts # Zod settings schema
│ ├── datasource/
│ │ ├── my-service.datasource.ts
│ │ └── __tests__/
│ │ └── validate.spec.ts
│ └── schema/
│ ├── my-service.resolver.ts
│ ├── my-service-settings.resolver.ts
│ ├── types/
│ │ └── my-service-settings.type.ts
│ └── arguments/
│ └── list-id.arguments.ts
├── docs/
│ ├── __generated__/ # Generated from the settings schema
│ └── plugins/my-service/
│ ├── meta.json # Display name + description
│ └── settings.mdx
├── package.json
└── tsconfig.jsonAnything you add under docs/plugins/my-service/ shows up on this wiki
automatically — no registration needed. meta.json is what names the plugin in
the sidebar and on the Plugins page.
OpenAPI Code Generation (Optional)
If the target API has an OpenAPI spec, use Kubb for type generation:
import { buildKubbConfig } from "@repo/core-util-kubb-config";
import { defineConfig } from "@kubb/core";
export default buildKubbConfig({
input: { path: "https://api.myservice.com/swagger.json" },
name: "MyService",
baseURL: "https://api.myservice.com",
});pnpm turbo codegen --filter=@repo/plugin-my-serviceThis generates TypeScript types, Zod schemas, and MSW mock handlers in lib/__generated__/.
Best Practices
- Keep DataSources thin - they're API wrappers, not business logic
- Use Zod for all response validation
- Transform API responses to common types (
ExternalIds[]) - Test both success and failure cases
- Follow the naming convention:
{pluginName}{Action}for GraphQL queries - Never redefine
@ObjectType()classes that exist in the SDK (GraphQL requires unique names)