Adding a plugin
Scaffold
src/Schuly.Plugin.Example/ - what you're copying - is intentionally minimal:
src/Schuly.Plugin.Example/
├── ExamplePlugin.cs # ISchulyPlugin implementation
├── Schuly.Plugin.Example.csproj
└── Schuly.Plugin.Example.slnx # opens this plugin standalone in an IDEReal plugins grow a Controllers/, Data/ (a DbContext + EF migrations), and Services/ folder as they need them - see src/Schuly.Plugin.Schulware/ or src/Schuly.Plugin.OdaOrg/ for what that looks like once a plugin talks to a database and an external API. None of that is required to start.
Copy
src/Schuly.Plugin.Example/tosrc/Schuly.Plugin.<Name>/:src/Schuly.Plugin.<Name>/ ├── <Name>Plugin.cs ├── Schuly.Plugin.<Name>.csproj └── Schuly.Plugin.<Name>.slnxRename
ExamplePlugin.cs→<Name>Plugin.cs; rename the class and namespace to match. The class implementsISchulyPlugin.Rename the
.csproj(and.slnx) toSchuly.Plugin.<Name>. Keep<TargetFramework>net10.0</TargetFramework>and theSchuly.Plugin.AbstractionsPackageReference. Set<Version>,<Description>,<Authors>- these flow into the published distribution index.Open an issue labeled
new-plugin, then follow the contributing workflow: branch → PR (Closes #<issue>) → squash-merge.
No workflow change is needed - build_push.yml discovers any src/Schuly.Plugin.*/*.csproj automatically. See setup/distribution.md.
The ISchulyPlugin lifecycle
The plugin class is the composition root (slim, like a Program.cs). The backend's plugin host drives these in order at startup:
ConfigureServices(IServiceCollection services, PluginServiceContext context)
Register your services, options, background task, and login. context exposes:
context.ConnectionString- the plugin's dedicated Postgres connection string (the host mutates it toschuly_plugin_<name>; see migrations.md).context.Configuration- the plugin's YAML config (Schuly.Plugin.<Name>.yml).
Typical registrations (from Schulware/OdaOrg):
services.AddDbContext<MyDbContext>(o => o.UseNpgsql(context.ConnectionString));
services.AddSingleton<MySyncTask>();
services.AddSingleton<IPluginBackgroundTask>(sp => sp.GetRequiredService<MySyncTask>());
// Per-plugin isolated, in-memory secret vault, keyed by the plugin name by the host.
services.AddScoped(sp => new MySecretStore(
sp.GetRequiredKeyedService<IPluginVault>(MyPlugin.PluginName)));
services.AddScoped<IPluginLogin, MyLogin>();The vault key must be a constant (used with
[FromKeyedServices(PluginName)]), so exposepublic const string PluginName. The vault is in-memory only - secrets do not survive a backend restart, and sync code must handle the empty-vault ("needs reconnect") case.
ConfigureEndpoints(IEndpointRouteBuilder endpoints)
Map minimal-API routes here. The Example plugin uses this directly:
endpoints.MapGet("/api/plugins/example/hello",
(IPluginUserContext userContext) => Results.Ok(...)).RequireAuthorization();
endpoints.MapGet("/api/plugins/example/info", () => Results.Ok(...)).AllowAnonymous();The Schulware and OdaOrg plugins instead leave this empty and put routes in Controllers/ as ASP.NET MVC controllers - the host registers the plugin assembly as an MVC ApplicationPart, so [ApiController] controllers are auto-discovered. Either approach works; controllers scale better for larger surfaces.
MigrateAsync(IServiceProvider serviceProvider, CancellationToken)
Apply EF Core migrations. Resolve the DbContext from a scope and call db.Database.MigrateAsync(). The Example plugin (no DB) returns Task.CompletedTask. See migrations.md - use MigrateAsync(), never EnsureCreatedAsync.
IPluginBackgroundTask (optional)
Recurring work. Implement Name, Interval, and ExecuteAsync. The backend's PluginBackgroundTaskHost invokes ExecuteAsync on each Interval tick:
public class MySyncTask : IPluginBackgroundTask
{
public string Name => "My Data Sync";
public TimeSpan Interval => TimeSpan.FromMinutes(30);
public async Task ExecuteAsync(IServiceProvider serviceProvider, CancellationToken ct)
{
using var scope = serviceProvider.CreateScope();
// resolve scoped services, do work
}
}