From Monolith to Modules — Restructuring FabrCore's Developer Tooling

Eric Brasher March 31, 2026 at 8:30 AM 5 min read

Early versions of FabrCore bundled tools directly into agent classes. An agent that needed database access, HTTP calls, and formatting utilities would define all of those as private methods in a single file. It worked, but as systems grew, agents became unwieldy and tool reuse across agents was impossible without copy-pasting code. This post covers how we restructured FabrCore's tooling into a modular plugin system — and why the distinction between plugins and standalone tools matters.

The Monolith Problem

In the original model, every tool an agent needed lived inside the agent class. A customer support agent might have a SearchOrders method, a FormatCurrency method, a SendEmail method, and a LookupCustomer method — all as private methods decorated with [Description] and wired up in OnInitialize.

This created three problems:

  • No reuse. If two agents needed the same SearchOrders tool, the code had to be duplicated. Changes to the search logic meant updating every agent that contained a copy.
  • Bloated agents. Agent classes grew to hundreds of lines, mixing business logic with utility functions. The LLM-facing tool surface area and the agent's orchestration logic were interleaved in a single file.
  • No independent lifecycle. Tools that needed initialization (HTTP clients, database connections, API keys) had to set up those resources in the agent's constructor or OnInitialize, tangling agent lifecycle with tool lifecycle.

The solution was to extract tools into two categories: stateful plugins for tools that need DI, configuration, and lifecycle management, and standalone tools for pure functions that need nothing.

Plugins: Stateful, Discoverable Tool Collections

A plugin implements IFabrCorePlugin and is decorated with [PluginAlias]. It has its own initialization lifecycle, access to the full DI container, and can hold state between tool calls. The framework discovers plugins by scanning assemblies registered in AdditionalAssemblies.

C# — A plugin with DI access and configuration
[PluginAlias("weather")]
[Description("Real-time weather data plugin")]
[FabrCoreCapabilities("Current conditions, forecasts, and severe weather alerts for any location.")]
[FabrCoreNote("Requires weather:ApiKey in Args.")]
public class WeatherPlugin : IFabrCorePlugin
{
    private IFabrCoreAgentHost _agentHost = default!;
    private ILogger<WeatherPlugin> _logger = default!;

    public Task InitializeAsync(
        AgentConfiguration config,
        IServiceProvider serviceProvider)
    {
        _agentHost = serviceProvider
            .GetRequiredService<IFabrCoreAgentHost>();
        _logger = serviceProvider
            .GetRequiredService<ILogger<WeatherPlugin>>();

        // Read plugin-specific settings from agent Args
        var apiKey = config.GetPluginSetting("weather", "ApiKey");

        return Task.CompletedTask;
    }

    [Description("Get current weather conditions for a location")]
    public async Task<string> GetCurrentWeather(
        [Description("City name or coordinates")] string location)
    {
        _logger.LogInformation("Weather lookup for {Location}", location);
        // API call, return formatted result
        return "72F, partly cloudy";
    }
}

The plugin lifecycle is well-defined: FabrCoreRegistry scans for [PluginAlias] at startup, FabrCoreToolRegistry.ResolvePluginToolsAsync() instantiates the plugin when an agent needs it, InitializeAsync runs once with the agent's configuration, and public methods decorated with [Description] become LLM-callable tools. If the plugin implements IAsyncDisposable, it is cleaned up on agent deactivation.

Plugin settings follow a convention-based pattern using the agent's Args dictionary with a "PluginAlias:Key" format:

JSON — Agent configuration with plugin settings
{
  "Handle": "my-agent",
  "Plugins": ["weather"],
  "Args": {
    "weather:ApiKey": "abc123",
    "weather:Timeout": "60"
  }
}

Standalone Tools: Pure Functions, Zero Ceremony

Not every tool needs state. Formatting a JSON string, generating a timestamp, or converting units are pure functions. For these, FabrCore supports standalone tools — static methods decorated with [ToolAlias] that are discovered and registered the same way plugins are, but without any lifecycle overhead.

C# — Standalone tools as static methods
using System.ComponentModel;
using FabrCore.Sdk;

public static class UtilityTools
{
    [ToolAlias("format-json")]
    [Description("Format a JSON string with proper indentation")]
    public static string FormatJson(
        [Description("The raw JSON string to format")] string json)
    {
        var parsed = JsonDocument.Parse(json);
        return JsonSerializer.Serialize(parsed,
            new JsonSerializerOptions { WriteIndented = true });
    }

    [ToolAlias("timestamp")]
    [Description("Get the current UTC timestamp in ISO 8601 format")]
    public static string GetTimestamp()
    {
        return DateTimeOffset.UtcNow.ToString("o");
    }
}

Standalone tools have no access to DI, no initialization phase, and no state. They are auto-discovered from assemblies at startup and configured on agents by name:

JSON — Configuring plugins and tools on an agent
{
  "Plugins": ["weather"],
  "Tools": ["format-json", "timestamp"]
}

The distinction matters because it keeps the simple things simple. A tool that formats a date should not need a class, an interface implementation, and an initialization method. A tool that calls an external API with credentials absolutely should.

Assembly Scanning and the Resolution Pipeline

Both plugins and standalone tools are discovered through the same assembly scanning mechanism. When AddFabrCoreServer is called with AdditionalAssemblies, the FabrCoreRegistry scans those assemblies for types decorated with [AgentAlias], [PluginAlias], and [ToolAlias].

C# — Registering assemblies for scanning
builder.AddFabrCoreServer(new FabrCoreServerOptions
{
    AdditionalAssemblies = [
        typeof(MyAgent).Assembly,
        typeof(WeatherPlugin).Assembly,
        typeof(UtilityTools).Assembly
    ]
});

At agent initialization time, ResolveConfiguredToolsAsync() walks the agent's configuration, resolves each plugin and tool by alias, initializes plugins, and returns a flat list of AITool instances ready for the LLM:

C# — Resolving tools in OnInitialize
public override async Task OnInitialize()
{
    // Resolves plugins, standalone tools, and MCP servers
    var tools = await ResolveConfiguredToolsAsync();

    // Optionally add local methods too
    tools.Add(AIFunctionFactory.Create(MyLocalTool));

    var result = await CreateChatClientAgent(
        config.Models ?? "default",
        threadId: config.Handle ?? fabrcoreAgentHost.GetHandle(),
        tools: tools);
    _agent = result.Agent;
    _session = result.Session;
}

This pipeline means agents are compositional. An agent's tool surface area is defined declaratively in its configuration, and new tools can be added by simply including the assembly and updating the agent's Plugins or Tools list — no code changes to the agent class itself.

The move from monolithic agent classes to modular plugins and standalone tools was one of the most impactful refactors in FabrCore's history. It made tools reusable, agents focused, and the overall developer experience significantly cleaner.


Built with FabrCore on .NET 10.


Eric Brasher

Builder of FabrCore and OpenCaddis.