Add a plugin
This guide takes you from a copy of the example plugin to a plugin your local server loads, then ships it. Before you start, set up your environment.
1. Copy the example
src/Schuly.Plugin.Example/ is minimal on purpose:
src/Schuly.Plugin.Example/
├── ExamplePlugin.cs # ISchulyPlugin implementation
├── Schuly.Plugin.Example.csproj
└── Schuly.Plugin.Example.slnx # opens this plugin standalone in an IDE- Copy the folder to
src/Schuly.Plugin.<Name>/. - Rename
ExamplePlugin.csto<Name>Plugin.cs. Rename the class and namespace to match. The class implementsISchulyPlugin. - Rename the
.csprojand.slnxtoSchuly.Plugin.<Name>, and update the project path inside the.slnx.
You get this:
src/Schuly.Plugin.<Name>/
├── <Name>Plugin.cs
├── Schuly.Plugin.<Name>.csproj
└── Schuly.Plugin.<Name>.slnxReal plugins add Controllers/, Data/ (a DbContext and EF migrations), and Services/ as they need them. None of that is required to start. To see what it looks like, read Schuly.Plugin.Schulware or Schuly.Plugin.OdaOrg.
2. Set the project properties
In the .csproj:
Keep
<TargetFramework>net10.0</TargetFramework>and theSchuly.Plugin.AbstractionsPackageReference.Set
<Version>,<Description>, and<Authors>. They go into the published index.To use the shared helpers in
src/Shared/(namespaceSchuly.Plugin.Shared), compile them in:xml<ItemGroup> <Compile Include="..\Shared\**\*.cs" /> </ItemGroup>
3. Implement the plugin class
The plugin class is the composition root, slim like a Program.cs. The host creates it with Activator.CreateInstance, so it needs a public parameterless constructor.
Choose Name carefully and keep it stable. The host derives the plugin's database name, vault namespace, and SCHULY_PLUGIN_<NAME>_ environment prefix from it.
When it loads a plugin, the host calls these members in this order:
ConfigureServicesMigrateAsyncConfigureEndpoints
It then registers your controllers, schedules your background tasks, and adds your school system to the catalog.
ConfigureServices(IServiceCollection services, PluginServiceContext context)
Register your services, options, background task, and login. Each plugin has its own service container. context gives you:
context.ConnectionString: the plugin's own Postgres database. See migrations.context.Configuration: the host configuration with the plugin'sSchuly.Plugin.<Name>.ymlandSCHULY_PLUGIN_<NAME>_environment variables on top.
Typical registrations, from Schulware and OdaOrg:
services.AddDbContext<MyDbContext>(o => o.UseNpgsql(context.ConnectionString));
services.AddSingleton<MySyncTask>();
services.AddSingleton<IPluginBackgroundTask>(sp => sp.GetRequiredService<MySyncTask>());
// Takes the plugin's own IPluginVault, which the host registers.
services.AddScoped<MySecretStore>();
services.AddScoped<IPluginLogin, MyLogin>();The host registers these contract services in each plugin's container. Inject them, never the backend's own types. The host refuses to load a plugin that references a backend assembly.
| Service | Use it to |
|---|---|
ISchoolData | Provision the school and student, then sync agenda, grades, exams, absences, documents, and semester reports |
IPluginVault | Keep tokens and credentials, encrypted, in the plugin's own namespace |
IPluginFileStore | Store a downloaded file, such as a report card, and get its key |
IPluginUserContext | Read the current user during a login request |
ILinkedAccounts | Read and save accounts a user linked with an outside provider, shared by all plugins |
The host also provides ILogger<T>, IHttpClientFactory, IHttpContextAccessor, and IConfiguration.
MigrateAsync(IServiceProvider serviceProvider, CancellationToken)
Apply EF Core migrations. Resolve the DbContext from a scope and call db.Database.MigrateAsync(). Use MigrateAsync(), never EnsureCreatedAsync. See migrations.
A plugin without a database, such as the Example plugin, returns Task.CompletedTask.
ConfigureEndpoints(IEndpointRouteBuilder endpoints)
Map minimal-API routes here. The Example plugin does this:
endpoints.MapGet("/api/plugins/example/hello", () =>
{
return Results.Ok(new { Message = "Hello from the Example Plugin!", Plugin = Name, Version });
}).RequireAuthorization();
endpoints.MapGet("/api/plugins/example/info", () =>
{
return Results.Ok(new { Name, Version, Description = "A sample plugin demonstrating the Schuly plugin system." });
}).AllowAnonymous();Or leave this method empty and put your routes in Controllers/, as the Schulware, OdaOrg, and Microsoft plugins do. The host registers the plugin assembly as an MVC ApplicationPart, so it finds [ApiController] controllers automatically. Both approaches work. Controllers scale better for larger surfaces.
4. Add features
Add the parts your plugin needs. The contract reference lists every interface and record.
Let users connect an account
Implement IPluginLogin and register it. Its SchoolSystem descriptor is what makes your school system appear in the app.
Sync school data
Build plain records and hand them to ISchoolData:
- Give each record a stable
ExternalId(src/Shared/ExternalIds.cs). - Convert provider wall-clock times to UTC first (
src/Shared/SwissTime.csfor Swiss systems). The backend stores UTC only.
var records = lessons.Select(l => new AgendaRecord(ExternalIds.Of("lesson", l.Start.ToString("O"), l.Course), AgendaKind.Lesson, l.Title, l.Start, l.End, l.Room)).ToList();
await schoolData.SyncAgendaAsync(account.SchoolUserId!.Value, records, ct);Delete a user's data
If your plugin stores anything per user, implement IPluginEventHandler<PluginUserPurgeEvent> and register it, or deleted accounts leave that data behind:
- Remove everything the user owns, in the plugin's database and its vault.
- Remove vault secrets before the database rows. The vault can't be searched, so a retry finds secrets only through the row.
- Make it safe to run twice, and let exceptions reach the host. The host counts a thrown exception as a failed purge and retries it daily. It also redelivers the event for any
ApplicationUserIdcolumn in your database whose owner no longer exists, so name the column that and make it auuid.
Run recurring work
Implement IPluginBackgroundTask: Name, Schedule, and ExecuteAsync. The host runs ExecuteAsync on the Schedule. Operators override a schedule by the task's Name, so keep the name stable.
public class MySyncTask : IPluginBackgroundTask
{
public string Name => "My Data Sync";
public PluginSchedule Schedule => PluginSchedule.Every(TimeSpan.FromMinutes(30));
public async Task ExecuteAsync(IServiceProvider serviceProvider, CancellationToken ct)
{
using var scope = serviceProvider.CreateScope();
// resolve scoped services, do work
}
}5. Load it on your local server
- Follow Run a plugin on a local server.
- Look for
Loaded plugin <your plugin name> v<version>in the server log. If the load fails, the log hasFailed to load pluginand the reason. - Call one of your routes to confirm it responds.
6. Ship it
- Open an issue labeled
new-plugin, then follow the shared workflow. - Merge to
main.build_push.ymlfinds anysrc/Schuly.Plugin.*/*.csprojautomatically, so you don't change a workflow. See distribution. - To install the published plugin, add it to the server's
plugins.yml. See Plugin management.
