# Build a custom Plugin Server

Build and connect a Lynvo-compatible Custom Plugin Server.

Start with the published Cloudflare Worker template. Replace its example
Plugin, deploy the Worker, and connect it to Lynvo. This guide starts with the
core concepts and ends with deployment.

## What is a Custom Plugin Server?

A Plugin Server is a deployed HTTPS service that turns Source pages into
normalized Media Nodes. Lynvo sends it a URL, and the server returns files,
folders, groups, or content that needs later resolution.

The Plugin Server handles the work shared by every Source integration:

- The public manifest at `GET /manifest`
- Bearer authentication for protected endpoints
- URL routing to the correct Plugin
- Request and response validation
- Finite usage limits
- Protocol error responses
- Deployment, logs, and operational status

Lynvo handles the product behavior around that service. It registers the Plugin
Server, selects it for matching URLs, saves extracted nodes, tracks opened
markers and selected links, and sends URLs to an external Android player. An
opened marker records only that an item was opened. Neither Lynvo nor the
Plugin Server stores a playback position or resume state.

One Plugin Server can contain one Plugin or several Plugins. Deploy and connect
the server once, then publish each Plugin in its manifest.

> **Direct Media links**
>
> Direct Media is a first-party Plugin hosted by the Lynvo Plugin Server. Its
> bounded capability probe runs after explicit, configured-domain, and static
> Plugin matches have been considered.

### What is a Plugin?

A Plugin is the Source-specific code inside a Plugin Server. It recognizes URLs
from one website or service, fetches the Source data, and converts it into
Lynvo Media Nodes.

For example, a Plugin may support a self-hosted file index. It matches that
index's domain, handles its authentication rules, reads its folders and files,
and returns playable or lazy Media Nodes. The Plugin Server handles the shared
protocol around that work.

A Plugin should own:

- Its stable Plugin ID, display name, version, status, and icon
- Host and path matchers
- Source-specific credentials
- Upstream requests and response parsing
- Playable, group, folder, and lazy-node creation
- Source-specific errors and test fixtures

A Plugin should not implement bearer authentication, protocol routes, global
usage enforcement, or Lynvo interface behavior. Keep those concerns in the
Plugin Server runtime so every Plugin follows the same contract.

Register every user-visible Plugin under `extensions.lynvo.plugins`. Lynvo reads this catalog to show the Plugin in [Settings](/settings/plugins) and route supported URLs to the Plugin Server.

### How Plugin Servers and Plugins fit together

The Plugin Server is the deployed service. Plugins are the Source integrations
that run inside it:

1. Lynvo matches a URL against the Plugin Server Manifest
2. Lynvo sends the Extraction request to the Plugin Server
3. The Plugin Server authenticates and validates the request
4. The Plugin Server selects the matching Plugin
5. The Plugin converts the Source response into Media Nodes
6. The Plugin Server validates and returns the protocol response

## Create a Plugin Server with an agent

Generate the project with `pnpm create lynvo-plugin-server@latest`. Run the
prompt below from the generated project directory. It asks the agent to extend
the existing Worker instead of rebuilding its route and validation code.

```text
Open this existing Lynvo-compatible Plugin Server project.

Project rules:
- Keep the generated Hono Worker, Wrangler config, and package scripts.
- Keep @dg02002/lynvo-plugin-server-protocol as the protocol dependency.
- Keep createPluginServerRuntime and the four required route handlers.
- Read the bearer key from LYNVO_PLUGIN_SERVER_API_KEY.
- Do not add a frontend or move Source logic into route handlers.

Replace the example Plugin with support for this Source:
- Source name: your_source_name
- Supported hosts: your_source_host
- Supported paths: your_source_path_pattern
- Plugin ID: your_plugin_id

Implement the Source matcher, upstream requests, response parsing, and Media
Node mapping in src/plugins. Update the manifest and
extensions.lynvo.plugins in src/index.ts so they match the implementation.

Keep credentials out of URLs, manifests, responses, and logs. Use bounded
requests and return the protocol error codes for unsupported, temporary, and
permanent failures. Replace the usage fixture with durable accounting before
calling the Worker production-ready.

Add contract fixtures for supported URLs, unsupported URLs, authentication,
malformed requests, failure responses, and every Media Node kind you return.
Run pnpm check, pnpm test, and pnpm build. Report changed files and remaining
secrets or deployment configuration.
```

The agent should edit the Plugin implementation, manifest metadata, and tests.
Keep the shared runtime and route wiring unchanged unless the protocol requires
an explicit extension.

## Create a Plugin Server manually

Follow the manual path when you want to understand or control each part of the implementation.

### Prepare your development environment

Install Node.js and pnpm before generating the project. You need a Cloudflare
account for deployment and a Lynvo account for the final connection test.

Install or prepare:

- Node.js 26.8.1 or newer
- pnpm 12 or newer
- A Cloudflare account with Wrangler access
- A Lynvo account
- A Source website you are authorized to access and extract

If pnpm is already installed, run `pnpm self-update next-12` to get the latest
pnpm 12 release.

After you install these tools and create the accounts, [generate your Plugin
Server](/docs/plugin-server#generate-the-project).

### Generate the project

Run the creator from the directory where you want the project. It creates a
standalone Plugin Server project backed by a Cloudflare Worker and installs the
published protocol package.

```sh
pnpm create lynvo-plugin-server@latest my-lynvo-plugin-server
cd my-lynvo-plugin-server
cp .dev.vars.example .dev.vars
pnpm check
pnpm test
pnpm build
```

Set `LYNVO_PLUGIN_SERVER_API_KEY` in `.dev.vars`, then run `pnpm dev`. The generator installs dependencies unless you pass `--skip-install`. Run `pnpm install` before the checks when you skip installation.

The generator creates:

- `src/index.ts` with the manifest, shared runtime, and Hono routes
- `src/plugins/example.ts` with a deterministic example Source adapter
- `scripts/optimize-images.mjs` for Plugin icons in `public/icons/sources/`
- `tests/contract.test.ts` with the first protocol checks
- `wrangler.jsonc` and `.dev.vars.example`
- A semver dependency on `@dg02002/lynvo-plugin-server-protocol`

Edit these files first:

1. Replace the example matcher and Plugin metadata in `src/index.ts`
2. Replace the example Source adapter in `src/plugins/example.ts`
3. Add Source fixtures to `tests/contract.test.ts`

The `pnpm build` script runs a Wrangler dry run. Use `pnpm deploy` only after
the contract checks pass and the production secret is configured.

The example usage response is a finite test fixture. Replace it with durable accounting before production deployment.

The generated project has no workspace or local-link dependency, so you can
copy it to another repository without cloning Lynvo.

### Understand protocol version 1.0

Lynvo Custom Plugin Servers use Plugin Server Protocol version `1.0` over HTTPS
and JSON. Version `1.0` defines four required endpoints and one optional
discovery endpoint:

| Method | Route       | Authentication | Purpose                                            |
| ------ | ----------- | -------------- | -------------------------------------------------- |
| `GET`  | `/manifest` | Public         | Declares identity, matchers, features, and Sources |
| `POST` | `/discover` | Bearer         | Optionally identifies a Plugin for a Source URL    |
| `POST` | `/verify`   | Bearer         | Verifies the API key and Plugin Server readiness   |
| `GET`  | `/usage`    | Bearer         | Reports finite enforced usage metrics              |
| `POST` | `/extract`  | Bearer         | Resolves a Source URL or lazy node                 |

Implement `POST /discover` only when the manifest sets `features.discovery` to
`true`. Use `Content-Type: application/json` for JSON responses. Return protocol
error envelopes instead of unstructured error strings.

### Configure the manifest

The generated starter declares the manifest in `src/index.ts`. Keep the server
ID stable after you connect the Worker to Lynvo.

Start with the generated object and replace the example values:

```ts
import {
  validPluginServerManifestFixture,
  type PluginServerManifest,
} from "@dg02002/lynvo-plugin-server-protocol"

export const manifest = {
  ...validPluginServerManifestFixture,
  pluginServerId: "com.example.my_plugin_server",
  displayName: "My Plugin Server",
  matchers: [{ hosts: ["media.example.com"] }],
  extensions: {
    lynvo: {
      plugins: [
        {
          id: "example-source",
          displayName: "Example Source",
          status: "active",
          version: "0.1.0",
          hosts: ["media.example.com"],
        },
      ],
    },
  },
} satisfies PluginServerManifest
```

Replace the `com.example` namespace with an identifier you control. Keep the `pluginServerId` unchanged after users register the Worker.

Manifest fields follow these rules:

| Field             | Required | Rule                                                             |
| ----------------- | -------- | ---------------------------------------------------------------- |
| `protocolVersion` | Yes      | Must use a compatible `1.x` version. Lynvo currently uses `1.0`. |
| `pluginServerId`  | Yes      | Stable namespaced identifier                                     |
| `displayName`     | Yes      | Human-readable Plugin Server name                                |
| `hasIcon`         | No       | `true` requires `iconUrl`; `false` forbids it                    |
| `iconUrl`         | No       | HTTPS image URL; loopback HTTP is allowed locally                |
| `homepage`        | No       | HTTPS project URL                                                |
| `auth.type`       | Yes      | Must equal `"bearer"`                                            |
| `usage.endpoint`  | No       | Defaults to `"/usage"`                                           |
| `matchers`        | Yes      | Non-empty array                                                  |
| `features`        | Yes      | Declares password, lazy node, and Basic Auth support             |
| `extensions`      | No       | Namespaced non-core metadata                                     |

Keep `matchers` and `extensions.lynvo.plugins` aligned with the Source code. Lynvo uses the manifest to select the Worker before it sends an Extraction request.

### Wire the shared routes

The generated `src/index.ts` sends each required endpoint through
`createPluginServerRuntime`. Keep authentication, validation, usage, and
response serialization in that runtime.

The generated route layer looks like this:

```ts
const runtime = createPluginServerRuntime<Env>({
  manifest,
  auth: { validate: ({ request, env }) => hasValidBearer(request, env) },
  usage: () => validUsageResponseFixture,
  extract: ({ targetUrl }) =>
    extractExampleSource(targetUrl, manifest.pluginServerId),
})

const app = new Hono<{ Bindings: Env }>()
app.get("/manifest", (context) =>
  runtime.handleManifest(context.req.raw, context.env)
)
app.post("/verify", (context) =>
  runtime.handleVerify(context.req.raw, context.env)
)
app.get("/usage", (context) =>
  runtime.handleUsage(context.req.raw, context.env)
)
app.post("/extract", (context) =>
  runtime.handleExtract(context.req.raw, context.env)
)
```

Replace the manifest, authentication function, usage provider, and Source implementation. Keep the four route handlers unchanged unless you need a protocol-level extension.

The starter returns a finite usage fixture so its contract tests can run without
a database. Replace that provider with durable accounting before deployment.

## Build a Plugin and return Media Nodes

A Plugin recognizes one Source and converts its data into 4 product-level node types: direct media, container, folder, and lazy folder.

### Add a Source Plugin

The generated project includes one deterministic example Plugin. Replace it with
your Source implementation before deployment.

Start with these files:

1. Replace the Source implementation in `src/plugins/example.ts`
2. Update the matcher and Plugin metadata in `src/index.ts`
3. Add supported, unsupported, and failure fixtures to `tests/contract.test.ts`

The Source implementation returns a protocol response instead of a framework response:

```ts
import type { ExtractSuccessResponse } from "@dg02002/lynvo-plugin-server-protocol"

export const extractExampleSource = (
  targetUrl: string,
  pluginServerId: string
): ExtractSuccessResponse => ({
  plugin: {
    pluginServerId,
    displayName: "My Plugin Server",
    pluginId: "example-source",
    pluginName: "Example Source",
  },
  nodes: [{ kind: "playable", label: "Example item", url: targetUrl }],
  extensions: {},
})
```

Plugin metadata supports these fields:

| Field                   | Purpose                                        |
| ----------------------- | ---------------------------------------------- |
| `id`                    | Stable Plugin identifier                       |
| `displayName`           | Name shown by Lynvo                            |
| `description`           | Short Plugin capability summary                |
| `homepage`              | HTTPS Plugin project page                      |
| `hasIcon` and `iconUrl` | Plugin-specific WebP icon                      |
| `status`                | `active`, `maintenance`, `degraded`, or `down` |
| `version`               | Your adapter version                           |
| `routesToPluginId`      | Downstream Plugin in the same manifest         |
| `hosts` and `matchers`  | Source URLs handled by the Plugin              |
| `credential`            | Domain password or HTTP Basic Auth capability  |

A Plugin owns Source credentials, upstream requests, response parsing, Media
Node creation, and Source-specific errors. The shared runtime owns bearer
authentication, protocol validation, usage enforcement, and response
serialization.

For multiple Plugins, keep one catalog for manifest metadata and request
dispatch. Do not add Source-specific conditions to the Hono route layer.

## Choose among the 4 node types

Plugins return four product-level node types. Protocol version `1.0` represents
them with three `kind` values:

| Product item           | Protocol kind | Required fields                         |
| ---------------------- | ------------- | --------------------------------------- |
| Direct media           | `playable`    | `label`, `url`                          |
| Display-only container | `group`       | `label`, `children`                     |
| Selectable folder      | `group`       | `label`, `selectable: true`, `children` |
| Lazy folder            | `resolvable`  | `label`, `nodeUrl`                      |

### Return direct media

Use `playable` when the URL points directly to a video or audio file. Lynvo can send this URL to the selected media player without another extraction request.

```ts {2,5}
const playableItem = {
  kind: "playable",
  id: "episode-1",
  label: "Episode 1",
  url: "https://cdn.example.com/episode-1.mp4",
  badge: "1080p",
  size: "1.4 GB",
  expiry: 1767225600000,
  status: "up",
}
```

`status` may be `up`, `down`, or `unknown`. `expiry` is an optional Unix timestamp in milliseconds.

### Return a display-only container

Use a non-selectable `group` to label and organize child nodes. A container cannot be selected as a playable item or folder.

```ts {2,6}
const seasonContainer = {
  kind: "group",
  id: "season-1",
  label: "Season 1",
  selectable: false,
  children: [playableItem],
}
```

### Return a selectable folder

Set `selectable` to `true` when the group represents a whole folder. The folder
includes its children in the current response.

```ts {2,5}
const selectableFolder = {
  kind: "group",
  id: "all-seasons",
  label: "All seasons",
  selectable: true,
  children: [seasonContainer],
}
```

### Return a lazy folder

Use `resolvable` when loading the folder requires another request. Lynvo sends `nodeUrl` back to the same Plugin Server when someone opens the folder.

```ts {2,6}
const lazyFolder = {
  kind: "resolvable",
  id: "bonus-content",
  label: "Bonus content",
  badge: "Open folder",
  nodeUrl: "https://media.example.com/folders/bonus",
  resourceId: "bonus_v1",
}
```

The same Plugin Server must resolve every `nodeUrl` it emits. Never include credentials in `nodeUrl`, `resourceId`, labels, or metadata.

## Configure security and usage limits

Create one secret API key for Lynvo, then define the finite usage limits enforced by your server.

### Create the Plugin Server API key

The generated starter validates the `Authorization: Bearer` header through the
shared protocol runtime. Generate a random 32-byte key:

```sh
openssl rand -base64 32
```

Copy the generated value. Use the same value in Lynvo and in your Plugin Server environment.

Create the local secret file and set its value:

```dotenv
LYNVO_PLUGIN_SERVER_API_KEY=your_local_plugin_server_api_key_here
```

Keep `.dev.vars` out of version control. The template already ignores it.

Store the production key as a Cloudflare secret:

```sh
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEY
```

Lynvo sends this key in the `Authorization` header for `/verify`, `/usage`, and
`/extract`. Never place it in a manifest, URL, response, browser bundle, or
log.

If you replace the generated route layer, authenticate protected requests before
reserving usage or sending upstream requests.

### Define and enforce usage limits

Every connected credential must have at least one finite usage metric. The
Plugin Server must enforce the limits it reports.

Define the response schema:

```ts {3-11}
import { Schema } from "effect"

export const usageResponseSchema = Schema.Struct({
  metrics: Schema.NonEmptyArray(
    Schema.Struct({
      id: Schema.NonEmptyString,
      label: Schema.NonEmptyString,
      used: Schema.Number.pipe(
        Schema.check(Schema.isNonNegative()),
        Schema.check(Schema.isFinite())
      ),
      limit: Schema.Number.pipe(
        Schema.check(Schema.isPositive()),
        Schema.check(Schema.isFinite())
      ),
      unit: Schema.NonEmptyString,
      period: Schema.Literals(["daily", "monthly"]),
      resetsAt: Schema.String,
      pluginId: Schema.optional(Schema.NonEmptyString),
    })
  ),
})
```

Return one record for each independently enforced limit:

```json {3-11}
{
  "metrics": [
    {
      "id": "extract-requests",
      "label": "Extract requests",
      "used": 84,
      "limit": 1000,
      "unit": "requests",
      "period": "monthly",
      "resetsAt": "2026-08-01T00:00:00.000Z"
    }
  ]
}
```

Each metric must satisfy these invariants:

- `id` is unique within the response
- `limit` is positive and finite
- `used` is non-negative, finite, and no greater than `limit`
- `period` is `daily` or `monthly`
- `resetsAt` is an ISO 8601 timestamp
- `pluginId` identifies a Plugin-specific allowance

Use a Cloudflare Durable Object for atomic reservations. Reserve capacity before
the adapter performs upstream work. Return `RATE_LIMITED` when no capacity
remains. Settle the reservation after the adapter finishes. Release it when
your accounting policy does not charge failed work.

> **Use the reported limits**
>
> Report the same allowances you enforce. Lynvo renders the metrics returned by
> your Plugin Server and does not own third-party accounting.

## Handle protocol requests and responses

Validate every extraction request and return either normalized Media Nodes or a structured error.

### Validate Extraction requests

`POST /extract` accepts a Source URL or a lazy-node follow-up request. Reject malformed bodies before the Plugin runs.

```ts {3-11}
import { Schema } from "effect"

const sourceInput = Schema.Struct({
  kind: Schema.Literal("source"),
  sourceUrl: Schema.String,
})

const nodeInput = Schema.Struct({
  kind: Schema.Literal("node"),
  nodeUrl: Schema.String,
  resourceId: Schema.optional(Schema.String),
})
```

Add optional Plugin selection and credential fields to the request envelope:

```ts {2,4-8}
export const extractRequestSchema = Schema.Struct({
  input: Schema.Union([sourceInput, nodeInput]),
  pluginId: Schema.optional(Schema.NonEmptyString),
  password: Schema.optional(Schema.String),
  basicAuth: Schema.optional(
    Schema.Struct({
      username: Schema.String,
      password: Schema.String,
    })
  ),
})
```

A Source Extraction request uses `sourceUrl`:

```json
{
  "input": {
    "kind": "source",
    "sourceUrl": "https://media.example.com/watch/123"
  }
}
```

A lazy follow-up uses the `nodeUrl` returned by the same Plugin Server:

```json
{
  "pluginId": "example-media",
  "input": {
    "kind": "node",
    "nodeUrl": "https://media.example.com/folders/bonus",
    "resourceId": "bonus_v1"
  }
}
```

### Return successful responses

A successful Extraction response includes Plugin metadata, an array of Media
Nodes, and an extensions object.

```json {2-7,9-15}
{
  "plugin": {
    "pluginServerId": "com.example.my-plugin-server",
    "displayName": "My Plugin Server",
    "pluginId": "example-media",
    "pluginName": "Example Media",
    "pageTitle": "Example collection"
  },
  "nodes": [
    {
      "kind": "playable",
      "id": "episode-1",
      "label": "Episode 1",
      "url": "https://cdn.example.com/episode-1.mp4"
    }
  ],
  "extensions": {}
}
```

The `plugin` object supports:

| Field            | Required | Purpose                    |
| ---------------- | -------- | -------------------------- |
| `pluginServerId` | Yes      | Matches the manifest       |
| `displayName`    | Yes      | Plugin Server display name |
| `iconUrl`        | No       | Plugin Server icon         |
| `pluginId`       | No       | Matches a declared Plugin  |
| `pluginName`     | No       | Plugin display name        |
| `pluginIconUrl`  | No       | Plugin icon                |
| `pageTitle`      | No       | Extracted page title       |
| `audio`          | No       | Audio-language summary     |

Lynvo owns layout, opened markers, selected links, and link caching.
An opened marker only records that an item was opened; Lynvo does not store
playback positions or resume state. Do not return interface instructions.

### Return structured errors

Return an error code Lynvo can handle and a message that is safe to display. Do
not expose stack traces, cookies, secret values, or internal network details.

```json {2-7}
{
  "ok": false,
  "error": {
    "code": "PASSWORD_REQUIRED",
    "message": "This Source requires a content password.",
    "retryAfterSeconds": 60
  },
  "extensions": {}
}
```

Protocol version `1.0` defines these error codes:

| Code                | Use when                                    |
| ------------------- | ------------------------------------------- |
| `UNSUPPORTED_URL`   | No Source matcher accepts the URL           |
| `AUTH_INVALID`      | The Plugin Server bearer key is rejected    |
| `AUTH_REQUIRED`     | Required Source credentials are missing     |
| `RATE_LIMITED`      | A finite usage limit is exhausted           |
| `TEMPORARY_FAILURE` | A later retry may succeed                   |
| `PERMANENT_FAILURE` | The Source cannot complete the request      |
| `PASSWORD_REQUIRED` | A content password is required              |
| `INVALID_PASSWORD`  | The supplied content password failed        |
| `NODE_EXPIRED`      | A lazy node target is no longer valid       |
| `PROTOCOL_MISMATCH` | A request or response violates the contract |
| `BAD_REQUEST`       | The JSON body or route input is malformed   |

## Test, deploy, and connect

Run the contract checks locally before deploying the Worker and adding it to Lynvo.

### Test the protocol contract

Run the generated checks before deployment. The starter checks types, contract
behavior, and the Wrangler bundle without requiring a live Cloudflare Worker.

Run the local checks:

```sh
pnpm check
pnpm test
pnpm build
```

Start the Worker in a separate terminal:

```sh
pnpm dev
```

Inspect the public manifest:

```sh
curl http://localhost:8787/manifest
```

Use `http://localhost:8787` when Lynvo and the Plugin Server run on the same
machine. Lynvo allows local HTTP only when the hostname is exactly
`localhost`; loopback IP addresses, LAN addresses, and other hosts must use
HTTPS.

To test with a remotely hosted Lynvo instance, expose the local Worker through
an HTTPS tunnel. Wrangler's interactive development server can start one with
the `t` shortcut. Verify the public origin before connecting it:

```sh
curl https://your-tunnel.example/manifest
```

The response must succeed. Every absolute manifest URL, including Plugin icon
URLs, must use the public HTTPS origin. A tunnel commonly terminates TLS before
forwarding HTTP to the Worker. If the manifest generates absolute URLs from the
request, honor `X-Forwarded-Proto: https` or configure the public origin
explicitly. Never hardcode a temporary tunnel hostname.

Verify the local bearer key:

```sh
curl -X POST http://localhost:8787/verify \
  -H "Authorization: Bearer your_local_plugin_server_api_key_here"
```

Test a Source Extraction:

```sh
curl -X POST http://localhost:8787/extract \
  -H "Authorization: Bearer your_local_plugin_server_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "kind": "source",
      "sourceUrl": "https://media.example.com/watch/123"
    }
  }'
```

Extend the contract suite as you add Source behavior. Test these cases:

- Valid and invalid manifest metadata
- Missing, invalid, and valid bearer credentials
- Finite usage responses and unique metric IDs
- Supported and unsupported Source URLs
- Playable, group, selectable folder, and lazy folder nodes
- Lazy-node follow-up through the same Plugin Server
- Password-required and invalid-password flows
- Malformed JSON and invalid request shapes
- Exhausted usage before upstream work starts
- Safe temporary and permanent failure responses

The starter includes the first four contract checks in `tests/contract.test.ts`.
Add Source fixtures before you deploy a real Source implementation.

### Deploy the Plugin Server

Run the local contract checks before you send the Worker to Cloudflare. The
`pnpm build` command runs a dry run. `pnpm deploy` creates or updates the
Worker.

Log in to Wrangler and configure the production bearer key:

```sh
pnpm wrangler login
pnpm wrangler secret put LYNVO_PLUGIN_SERVER_API_KEY
```

Run the checks and deploy:

```sh
pnpm check
pnpm test
pnpm build
pnpm deploy
```

Call the four required endpoints at the deployed HTTPS origin. If the manifest enables discovery, call `POST /discover` too. Confirm the production secret works and the manifest contains only production URLs.

Bound upstream requests, reject unsupported hosts, and record structured logs
without credentials. Keep the deployment origin stable after users connect it.

### Connect the Plugin Server to Lynvo

Add the Plugin Server after its production contract checks pass:

1. Open [**Settings**](/settings/plugins) in Lynvo
2. Find **Custom Plugin Servers**
3. Select **Add Custom Plugin Server**
4. Enter the deployed origin in **Server URL**, without `/manifest`
5. Enter the production bearer key in **API key**
6. Select **Add Custom Plugin Server**

Lynvo fetches `GET /manifest` and checks protocol version `1.0`. It calls
`POST /verify` and stores the Plugin Server only when both responses pass
validation.

Production and remote Plugin Server origins must use HTTPS. For local
development on the same machine, use an origin whose hostname is exactly
`localhost`, such as `http://localhost:8787`. Lynvo rejects plain HTTP origins
that use `127.0.0.1`, a LAN address, or any other hostname.

Test one URL for every declared Source. Open one lazy folder to confirm that Lynvo sends the follow-up request to the same Plugin Server.
