# Creating a Plugin

> Build a DS Server plugin step by step: a .NET class library with IPlugin, a controller, middleware and settings, deployment, testing and OAuth protection.

- **Product:** DS Server
- **Section:** Documentation
- **Kind:** Topic
- **Path:** DS Server > Plugins > Creating a Plugin
- **4893 words**
- **Web URL:** https://docs.textcontrol.com/ds-server/plugins/creating/
- **LLMs URL:** https://docs.textcontrol.com/ds-server/plugins/creating/llms.txt
- **LLMs-Full URL:** https://docs.textcontrol.com/ds-server/plugins/creating/llms-full.txt

---

A plugin starts as an ordinary .NET class library. You add one NuGet package, implement the `IPlugin` interface, copy the compiled assembly into the *Plugins* folder of DS Server and restart the server. Follow the steps below to build *MyFirstPlugin*, a plugin with its own endpoint at `/plugin/hello`, a setting read from the configuration of DS Server and a middleware that logs each request to the endpoint.

The steps target DS Server 5.0 and version 5.0.1 of the plugin package. [Plugins](https://docs.textcontrol.com/ds-server/plugins/llms-full.txt) explains what a plugin can do and how DS Server loads it.

### Prerequisites

- **.NET 10 SDK.** DS Server 5.0 runs on .NET 10, and the plugin targets `net10.0`, as the official sample plugin does.
- **A DS Server 5.0 installation** that you can stop and start, with write access to its folder. [Installation Guide](https://docs.textcontrol.com/ds-server/installation/llms-full.txt) describes the ways to install it.
- **A terminal** for the .NET CLI and cURL. Visual Studio works as well: create a **Class Library** project for .NET 10 and add the package with the NuGet Package Manager.

### Create the Project

Create a class library and remove the class the template adds:

```bash
dotnet new classlib -n MyFirstPlugin
cd MyFirstPlugin
rm Class1.cs
```

### Install the NuGet Package

The interfaces of a plugin are in the package `TXTextControl.DocumentServices.Plugin.Abstractions` on nuget.org:

```bash
dotnet add package TXTextControl.DocumentServices.Plugin.Abstractions
```

The project file then references the package:

```xml
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="TXTextControl.DocumentServices.Plugin.Abstractions" Version="5.0.1" />
  </ItemGroup>

</Project>
```

The package brings the ASP.NET Core shared framework into the project, so controllers, middleware and dependency injection are available without another reference. The official sample plugin also adds `<FrameworkReference Include="Microsoft.AspNetCore.App" />` explicitly, which does no harm.

Use version 5.0.0 or later of the package with DS Server 5.0; [Versions](https://docs.textcontrol.com/ds-server/plugins/#versions) lists which package fits which DS Server version. Version 4.1.0 lacks the constant `PluginContext.AuthenticationScheme`, which [Protect the Endpoint With OAuth](#protect-the-endpoint-with-oauth) uses, and the services of [Plugin Services](https://docs.textcontrol.com/ds-server/plugins/services/llms-full.txt).

### Write the Code

The plugin consists of three files: a class that holds a setting, a controller and the plugin class.

#### The Setting

*GreetingState.cs* holds the greeting the endpoint returns:

```csharp
namespace MyFirstPlugin;

public class GreetingState
{
    public string Greeting { get; set; } = "Hello (default)";
}
```

#### The Controller

*HelloController.cs* is a standard ASP.NET Core API controller. DS Server adds the controllers of a plugin to its own routes, so the endpoint answers at `/plugin/hello` on the address of DS Server:

```csharp
using Microsoft.AspNetCore.Mvc;

namespace MyFirstPlugin;

[ApiController]
[Route("plugin/[controller]")]
public class HelloController : ControllerBase
{
    private readonly GreetingState m_state;

    public HelloController(GreetingState state)
    {
        m_state = state;
    }

    [HttpGet]
    public string Get() => m_state.Greeting;
}
```

The controller receives `GreetingState` through its constructor. The plugin class registers it in the next step.

#### The Plugin Class

*HelloPlugin.cs* implements `IPlugin`. DS Server finds this class in the assembly and calls its members while it starts:

```csharp
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TXTextControl.DocumentServices.Plugin.Abstractions;

namespace MyFirstPlugin;

public class HelloPlugin : IPlugin
{
    public string Name => "MyFirstPlugin";
    public string Description => "Adds a /plugin/hello endpoint and logs its requests.";
    public string Version => "1.0.0";

    public void ConfigureServices(IServiceCollection services, PluginContext context)
    {
        // Read the greeting from the appsettings.json file of DS Server
        string greeting = context.Configuration["MyFirstPlugin:Greeting"] ?? "Hello (default)";
        services.AddSingleton(new GreetingState { Greeting = greeting });
    }

    public void ConfigureMiddleware(WebApplication app, PluginContext context)
    {
        // Log every request to the plugin's endpoint
        app.Use(async (httpContext, next) =>
        {
            if (httpContext.Request.Path.StartsWithSegments("/plugin/hello"))
            {
                ILogger<HelloPlugin> logger =
                    httpContext.RequestServices.GetRequiredService<ILogger<HelloPlugin>>();
                logger.LogInformation("MyFirstPlugin intercepted a request to /plugin/hello");
            }

            await next();
        });
    }

    public void OnStart(IServiceProvider services, PluginContext context)
    {
        ILogger<HelloPlugin>? logger = services.GetService<ILogger<HelloPlugin>>();
        GreetingState? state = services.GetService<GreetingState>();

        logger?.LogInformation("MyFirstPlugin started on DS Server {Version}. Greeting: {Greeting}",
            context.DsServerVersion, state?.Greeting);
    }

    public void OnStop()
    {
        // Release the resources the plugin holds
    }
}
```

#### What the Code Does

The members of `IPlugin` are called in this order. Only `Name`, `Description` and `Version` are required: the interface has empty default implementations of the other members, so a plugin implements only the ones it needs.

| Member | When DS Server uses it | What the plugin does there |
|---|---|---|
| `Name`, `Description`, `Version` | At startup and on the **Plugins** page of the admin portal | Identify the plugin |
| `UIBasePath` | On the **Plugins** page | Optional: the address of a web page of the plugin, see [Add a Page to the Admin Portal](#add-a-page-to-the-admin-portal) |
| `ConfigureServices(IServiceCollection, PluginContext)` | Before the DS Server application is built | Register services in the dependency injection container |
| `ConfigureMiddleware(WebApplication, PluginContext)` | After the application is built | Add middleware and endpoints to the request pipeline |
| `OnStart(IServiceProvider, PluginContext)` | When the plugin is loaded, right before DS Server starts serving requests | Initialize resources, with access to the registered services |
| `OnStop()` | When the plugin is unloaded | Release resources |

`PluginContext` gives the plugin three properties: `DsServerVersion`, the version string of DS Server, such as `5.0.2.0`; `BaseDirectory`, the installation folder of DS Server; and `Configuration`, the configuration of the DS Server instance, which includes its *appsettings.json* file. Since version 5.0.0 of the package, it also has the constant `AuthenticationScheme`.

In the code above:

- `ConfigureServices` reads the key `MyFirstPlugin:Greeting` from `context.Configuration` and registers a `GreetingState` as a singleton, which the controller receives.
- `ConfigureMiddleware` adds a middleware with `app.Use`. It writes a log entry for every request whose path starts with `/plugin/hello` and passes the request on with `next()`.
- `OnStart` writes the version of DS Server and the greeting to the log.

The documentation of the package lists every type: [Plugin.Abstractions](https://docs.textcontrol.com/ds-server/reference/dotnet-api/txtextcontrol-documentservices-plugin-abstractions/llms-full.txt).

### Configure the Plugin

A plugin reads its settings from the *appsettings.json* file in the DS Server folder. Add a section for *MyFirstPlugin* beside the existing ones:

```json
{
  "Logging": { },
  "AllowedHosts": "*",
  "LiteDbOptions": {
    "DatabaseFileName": "Admin.db"
  },
  "MyFirstPlugin": {
    "Greeting": "Hello from MyFirstPlugin!"
  }
}
```

Keep the content of the existing sections; the example shortens `Logging`. Without the section, the endpoint returns the default greeting.

### Build and Deploy the Plugin

1. Build the project:

    ```bash
    dotnet build
    ```

    The assembly is written to *bin/Debug/net10.0/MyFirstPlugin.dll*.
2. Stop DS Server.
3. Create a subfolder for the plugin in the *Plugins* folder of DS Server and copy the assembly into it:

    ```text
    Plugins/
    ├── README.md
    └── MyFirstPlugin/
        └── MyFirstPlugin.dll
    ```

    The name of the subfolder is free. An assembly directly in *Plugins* is not loaded.
4. Start DS Server.

A plugin that references other NuGet packages needs their assemblies in the same subfolder. `dotnet publish -c Release -o publish` collects the plugin and its dependencies in the *publish* folder; copy its content into the subfolder. DS Server loads plugins only when it starts, so repeat the restart after every update of the assembly.

### Test the Plugin

When DS Server starts, its console output lists every plugin it loads:

```text
Loading plugins from '/opt/dsserver/Plugins'...
Loaded plugin: MyFirstPlugin (v1.0.0)
```

The entry from `OnStart` is in the log file of the day, in the *logs* folder that DS Server creates in its working directory:

```text
[2026-09-29 07:44:57] [Information] MyFirstPlugin started on DS Server 5.0.2.0. Greeting: Hello from MyFirstPlugin!
```

Call the endpoint. Replace the address with the one of your DS Server:

```bash
curl http://localhost:5000/plugin/hello
```

```text
Hello from MyFirstPlugin!
```

The middleware adds a line to the log for each request:

```text
[2026-09-29 07:45:06] [Information] MyFirstPlugin intercepted a request to /plugin/hello
```

In the admin portal, the **Plugins** page lists the plugin with its name, version and description and shows it as enabled:

![The Plugins page of the admin portal lists MyFirstPlugin with its version and description](https://docs.textcontrol.com/assets/img/ds-server/admin-portal-plugins-list.6d421663.webp)

### Protect the Endpoint With OAuth

The endpoints of a plugin are open: DS Server answers `/plugin/hello` without an access token, even when OAuth is enabled for the built-in services. To accept only requests with a valid access token of a security profile, add the `Authorize` attribute with the authentication scheme of DS Server. The constant `PluginContext.AuthenticationScheme` of the package holds its name, `TXTextControl`:

```csharp
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TXTextControl.DocumentServices.Plugin.Abstractions;

namespace MyFirstPlugin;

[ApiController]
[Route("plugin/[controller]")]
[Authorize(AuthenticationSchemes = PluginContext.AuthenticationScheme)]
public class SecretController : ControllerBase
{
    [HttpGet]
    public string Get() => "Only for callers with a valid access token";
}
```

The scheme checks the access token as it does for the built-in services. It accepts the token in an `Authorization: Bearer` header, in the `access_token` query parameter or in an access token cookie:

| Request to `/plugin/secret` | OAuth enabled | OAuth disabled |
|---|---|---|
| Without a token | 401 Unauthorized | 200 OK |
| With an invalid token | 401 Unauthorized | 200 OK |
| With a valid access token | 200 OK | 200 OK |

Name the scheme in the attribute: `[Authorize]` without a scheme ends in an error 500, because DS Server defines no default scheme for it. A client obtains the token as described in [OAuth Grant Types](https://docs.textcontrol.com/ds-server/administration/oauth-grant-types/llms-full.txt), and [Enable OAuth](https://docs.textcontrol.com/ds-server/administration/oauth/llms-full.txt) describes the switch in the admin portal.

### Add a Page to the Admin Portal

A plugin with a web interface returns its address in `UIBasePath`. The **Plugins** page then shows the plugin name as a link that opens this address in a new tab. Add the property to `HelloPlugin` and map a page to it at the end of `ConfigureMiddleware`:

```csharp
// The address the Plugins page of the admin portal links the plugin name to
public string UIBasePath => "/plugin-ui/my-first-plugin";

public void ConfigureMiddleware(WebApplication app, PluginContext context)
{
    // The logging middleware from above goes here

    // Serve a page at UIBasePath
    GreetingState state = app.Services.GetRequiredService<GreetingState>();
    app.MapGet(UIBasePath, () => Results.Content(
        $"<html><body><h3>MyFirstPlugin</h3><p>Greeting: {state.Greeting}</p></body></html>",
        "text/html"));
}
```

The page is served without the admin password of the portal. Protect it yourself if it shows or changes settings.

### Troubleshooting

#### The Type WebApplication Is Not Found

```text
error CS0246: The type or namespace name 'WebApplication' could not be found
```

The implicit `using` directives of a class library do not include ASP.NET Core. Add `using Microsoft.AspNetCore.Builder;` to the file of the plugin class. The first plugin in the README of the package and in the blog article of version 4.1.0 lacks this directive.

#### DS Server Loads No Plugin

```text
No plugins found in the plugin directory. No plugins will be loaded.
```

1. Check that the assembly is in a subfolder of *Plugins*, not in *Plugins* itself.
2. Check that the subfolder contains an assembly with a public class that implements `IPlugin`.
3. Restart DS Server after copying the files.

When DS Server finds assemblies but none of them holds a plugin it can load, it reports instead:

```text
Warning: No valid plugins found in the plugin directory. Make sure that the plugin assemblies reference the correct version of the TXTextControl.DocumentServices.Plugin.Abstractions.dll and that the plugins implement the IPlugin interface correctly.
```

Check the package version against [Versions](https://docs.textcontrol.com/ds-server/plugins/#versions), and check the class as in step 2. A plugin that fails while it is loaded is reported in a line that starts with `Failed to load plugin` and ends with the error.

#### A Request Ends in an Error 500 After Adding Authorize

The attribute has no authentication scheme. Write `[Authorize(AuthenticationSchemes = PluginContext.AuthenticationScheme)]`, or its value `"TXTextControl"`, as shown in [Protect the Endpoint With OAuth](#protect-the-endpoint-with-oauth).

### Next Steps

- [Plugin Services](https://docs.textcontrol.com/ds-server/plugins/services/llms-full.txt): convert and merge documents and work with Document Editor sessions from a plugin.
- [Custom Signing Endpoints](https://docs.textcontrol.com/ds-server/plugins/signing/llms-full.txt): receive the documents signed in the Document Viewer in a plugin.
- [TXTextControl.DocumentServices.SamplePlugin](https://github.com/TextControl/TXTextControl.DocumentServices.SamplePlugin): the official sample plugin on GitHub, with a controller, a middleware, a plugin page and both plugin services.
- [DS Server 4.1.0: Extend DS Server with Custom Plug-ins](https://www.textcontrol.com/blog/2025/09/26/ds-server-4-1-0-extend-ds-server-with-custom-plug-ins/): the release article that introduced plugins.

---

## Nearby

- **Previous:** [Plugins](https://docs.textcontrol.com/ds-server/plugins/llms-full.txt)
- **Next:** [Plugin Services](https://docs.textcontrol.com/ds-server/plugins/services/llms-full.txt)
