Riven

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

This creates a complete plugin at packages/plugin-my-service/ and adds it as a dependency to the main app.

Define the Plugin Config

lib/my-service-plugin.config.ts
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.

lib/my-service-settings.schema.ts
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.

lib/datasource/my-service.datasource.ts
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

lib/schema/my-service.resolver.ts
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:

lib/schema/my-service-settings.resolver.ts
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:

lib/index.ts
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

lib/datasource/__tests__/validate.spec.ts
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-service

Available Hooks

Plugins can subscribe to system events through hooks:

EventDescriptionReturn Type
riven.core.startedSystem has startedvoid
riven.core.shutdownSystem is shutting downvoid
riven.content-service.requestedFetch requested media items{ movies, shows }
riven.media-item.index.requested.movieMovie needs metadata indexing-
riven.media-item.index.requested.showShow needs metadata indexing-
riven.media-item.scrape.requestedItem needs torrent scraping-
riven.media-item.download.requestedItem needs downloading/caching-
riven.media-item.stream-link.requestedItem needs stream URL-
riven.media-item.subtitle.requestedItem 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.json

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

kubb.config.ts
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-service

This 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)

On this page