Contents

Writing an extension

This page takes you from an empty folder to an extension PremAgentic loads: a reader for a made-up kind of file, proven with the conformance fixtures, installed, allowed by hash, shown as loaded, then upgraded, with the refusals you meet on the way. Every command and file was run as written, on Windows in PowerShell; on Linux or macOS the slashes turn. How the host hashes, loads and refuses an extension is on the Extensions page. This page is the doing.

An extension is a folder holding one assembly and an extension.json beside it; the assembly has a public class implementing IExtension, and nothing else marks it. The layout used here:

text01
work\
  premagentic\        your clone of this repository
  note-reader\        the extension project
  note-reader-tests\  its conformance tests
  extensions\         stands in for a deployment's extensions folder

You need the .NET 10 SDK, Docker, and the README's quick start done once in premagentic, so that prem reaches a database from your shell.

01The extension

note-reader\NoteReader.csproj. The one reference is Premagentic.Core, with Private="false" so no copy lands beside your assembly: the host supplies it. The target writes the manifest after every build, with the hash of the assembly just built.

xml02
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <AssemblyName>NoteReader</AssemblyName>
    <Version>1.0.0</Version>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\premagentic\src\Premagentic.Core\Premagentic.Core.csproj" Private="false" />
  </ItemGroup>
  <Target Name="WriteExtensionManifest" AfterTargets="Build">
    <GetFileHash Files="$(TargetPath)" Algorithm="SHA256" HashEncoding="hex">
      <Output TaskParameter="Items" ItemName="HashedAssembly" />
    </GetFileHash>
    <PropertyGroup>
      <AssemblyHash>@(HashedAssembly->'%(FileHash)')</AssemblyHash>
      <ManifestText>{
  "name": "note-reader",
  "version": "$(Version)",
  "assemblyFile": "$(TargetFileName)",
  "sha256": "$(AssemblyHash)",
  "seams": { "reader": 1 }
}</ManifestText>
    </PropertyGroup>
    <WriteLinesToFile File="$(TargetDir)extension.json" Lines="$(ManifestText)" Overwrite="true" WriteOnlyWhenDifferent="true" />
  </Target>
</Project>

Set the version here, never with -p:Version= on the command line: that is a global property, it stamps the Premagentic.Core reference too, and the host then refuses the assembly with did not load for asking a newer core.

note-reader\NoteReaderExtension.cs: the extension, and the reader it registers. A .note file is plain UTF-8 text whose first line is the title; no built-in reader claims it, and one reader gives every connector the format at once, because connectors find paths and readers read them.

csharp03
using System.Text;
using Premagentic.Core.Extensions;
using Premagentic.Core.Ingestion.Readers;

namespace NoteReader;

public sealed class NoteReaderExtension : IExtension
{
    public string Name => "note-reader";

    public void Register(ExtensionRegistrations registrations) =>
        registrations.AddReader(new NoteFileReader());
}

public sealed class NoteFileReader : IDocumentReader
{
    public string Name => "note";

    public IReadOnlyList<string> Extensions { get; } = [".note"];

    public async Task<ReadDocument> ReadAsync(Stream content, string path, CancellationToken ct)
    {
        using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, leaveOpen: true);
        var text = await reader.ReadToEndAsync(ct);
        var firstLine = text.Split('\n', 2)[0].Trim();
        return new ReadDocument(text, firstLine.Length == 0 ? null : firstLine, null);
    }
}

The whole text is indexed, title line included, so a search can cite it. A reader returns text taken from the file and writes nothing of its own, because what it returns is what a search serves and cites. Build it:

powershell04
dotnet build .\note-reader -c Release

02Prove it keeps the contract

note-reader-tests\NoteReader.Tests.csproj references the conformance project and the extension:

xml05
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\premagentic\tests\Premagentic.Conformance\Premagentic.Conformance.csproj" />
    <ProjectReference Include="..\note-reader\NoteReader.csproj" />
  </ItemGroup>
</Project>

note-reader-tests\NoteFileReaderConformanceTests.cs inherits DocumentReaderConformance, hands it the reader, and gives it one invented file with the words its text must carry:

csharp06
using System.Text;
using Premagentic.Conformance;
using Premagentic.Core.Ingestion.Readers;

namespace NoteReader.Tests;

public sealed class NoteFileReaderConformanceTests : DocumentReaderConformance
{
    protected override IDocumentReader Reader => new NoteFileReader();

    protected override IReadOnlyList<ReaderSample> Samples { get; } =
    [
        new("closing.note",
            Encoding.UTF8.GetBytes("Closing the yard\nThe last van leaves at six.\nThe gate is locked after it.\n"),
            ["Closing the yard", "The last van leaves at six.", "The gate is locked after it."]),
    ];
}
powershell07
dotnet test .\note-reader-tests

It prints Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3: it names itself and the extensions it reads, it returns what is in the file, and an empty file does not throw and has no text. Break the reader once to see the fixture work: return a description instead of the text and It_returns_what_is_in_the_file fails with "A reader returns the text of the file, not a description of it." Chunkers, connectors and embedding providers have fixtures of their own in the same project.

03Install it

An administrator installs an extension by putting its folder under the extensions folder and allowing it. From premagentic:

powershell08
cd .\premagentic
New-Item -ItemType Directory -Force ..\extensions\note-reader | Out-Null
$env:PREM_EXTENSIONS_DIR = (Resolve-Path ..\extensions).Path
Copy-Item ..\note-reader\bin\Release\net10.0\NoteReader.dll, ..\note-reader\bin\Release\net10.0\extension.json ..\extensions\note-reader
dotnet run --project src/Premagentic.Cli -- extensions list

PREM_EXTENSIONS_DIR names the extensions folder for this shell; a deployment sets extensions.folder instead, as a full path. Nothing loads yet, and that is the default: the list shows note-reader (not allowed) under Refused, and the deployment runs with the built-ins. allow reads the manifest, hashes the assembly itself, and writes the name and that hash to extensions.allowed, with an entry in the change record. Every prem command is a new process, so the list that follows shows it loaded, and from then on every source that holds .note files reads them.

powershell09
dotnet run --project src/Premagentic.Cli -- extensions allow ..\extensions\note-reader
dotnet run --project src/Premagentic.Cli -- extensions list
text10
Allowed note-reader with hash cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9. It loads the next time a Premagentic process starts.
Loaded:
  note-reader 1.0.0  cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9

04When the hash is wrong

Change the version in NoteReader.csproj to 1.0.1, build again, and copy only the assembly, which is the mistake an upgrade invites:

powershell11
dotnet build ..\note-reader -c Release
Copy-Item ..\note-reader\bin\Release\net10.0\NoteReader.dll ..\extensions\note-reader
dotnet run --project src/Premagentic.Cli -- extensions list
text12
Refused:
  note-reader  (hash mismatch)
    "NoteReader.dll" hashes to fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936 and the manifest says cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9.

Copy the manifest too and the reason changes to not allowed: the pair is consistent, but nobody allowed this hash. A new assembly is a new hash, and an upgrade is allowed again on purpose:

powershell13
Copy-Item ..\note-reader\bin\Release\net10.0\extension.json ..\extensions\note-reader
dotnet run --project src/Premagentic.Cli -- extensions allow ..\extensions\note-reader
dotnet run --project src/Premagentic.Cli -- extensions list
text14
Loaded:
  note-reader 1.0.1  fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936

Allowed (extensions.allowed):
  note-reader  cfda9c98c71fb0b25977aa72bb5898556f0e8378f5daddb134281543ec017af9  (not in the extensions folder)
  note-reader  fed3c30bcc6cc1c2662f20c18703cc35ec3c018faaa7e8018ae3685d50e51936  (loaded)

The earlier hash stays allowed, so the old build can go back without a new allow, and prem extensions disallow note-reader drops every hash under the name. Every other refusal reads the same way, and none stops the deployment.

What you ship is the folder: the assembly and its manifest, nothing else. The administrator copies it under their extensions folder, runs prem extensions allow on it, and restarts. Build against the release they run or an older one, keep every fixture invented, and if the seam you need does not exist, that is a proposal for an issue; CONTRIBUTING.md says how.