Skip to content

12 — Extending the Engine

Adding capabilities the Engine doesn't ship with — a tool, a capability boundary, a Workflow Template.

The Aetherium Engine has two kinds of "add a feature," and they take different paths. §10 — Development Workflow covers the first: a standard full-stack feature that moves through the same eight steps every time — model, DTO, DbSet and migration, service, controller, contract regeneration, client screen, navigation. If you're adding a settings page, a REST endpoint, or a database table, you're in §10's territory and this section isn't for you.

This section covers the second kind: the Engine's own extension points. A new tool an agent can call, a new MCP server class that groups a set of tools, or a new Workflow Template that wires a team. None of these follow the CRUD path — each plugs into machinery the earlier sections mapped (§06 — Key Services, §07 — MCP Architecture, §08 — Templates). Those sections own the what; this one owns the how to add. Every walkthrough below has the same shape: what you're adding and when, numbered steps with real code, the gotchas, and how to confirm it worked.

Adding a tool to an existing MCP server class

Reach for this when the capability fits a class that already exists — another file operation in WorkspaceTools, another check in QualityTools. You're adding one method and wiring it into the router.

A tool's schema is generated by reflection, but its execution is not — the router dispatches through a hand-written switch. That asymmetry is the source of the most common "looks done but isn't" mistake, which is why the steps below touch four places, not one.

1 — Write the tool method. Add a method to the server class with the [McpServerTool] attribute. The first parameter is always IServiceProvider; every tool argument carries its own [Description]; the method ends with a CancellationToken and returns Task<string>:

[McpServerTool(Name = "delete_file"), Description(
    "Delete a file from the project workspace. Sandboxed to the workspace container.")]
public async Task<string> DeleteFile(
    IServiceProvider serviceProvider,
    [Description("Workspace-relative path to the file to delete")] string path,
    CancellationToken cancellationToken = default)
{
    // resolve IWorkspaceExecutor from serviceProvider, delete the file, return a status string
    return $"Deleted {path}";
}

Dependencies are pulled from serviceProvider inside the body — the class has no injected constructor (the next walkthrough explains why). Failures come back as "Error: ..." strings, not exceptions. You never hand-write the JSON schema: it's reflected from these attributes, and optional C# parameters (string? focus = null) become optional schema fields automatically.

2 — Add a dispatch arm. The schema is reflected automatically, but the call is routed through an explicit switch in McpToolRouter.ExecuteToolAsync. Add one arm mapping the tool name to your method, extracting each argument with the typed GetString/GetStringOpt/GetInt helpers:

"delete_file" => await Invoke<WorkspaceTools>(t => t.DeleteFile(_serviceProvider, GetString(args, "path"))),

Skip this and the model sees the tool, calls it, and gets Error: Unknown tool 'delete_file' from the switch's fallthrough — nothing upstream catches the omission.

3 — Map the tool to a server group. Add an entry to McpToolRouter.ToolToServerGroup so the router knows which mcp_servers group exposes it:

["delete_file"] = "workspace",

Omit this and the tool is filtered out of every agent's catalog — it exists but no agent can ever see or call it.

4 — Document the tool. Add a ToolDocs entry in PromptEnhancementService:

["delete_file"] = new ToolDocumentation
{
    Name = "delete_file",
    Description = "Delete a file from the project workspace",
    Args = "{ \"path\": \"<relative_file_path>\" }",
    Example = "{ \"path\": \"build/stale.log\" }",
    Returns = "Confirmation string"
},

This step is not optional. ToolCatalogValidator runs at boot and asserts that the router's registered tools and the documented tools are the same set in both directions — a tool in one map but not the other fails the start. The gate is controlled by Aetherium:ToolCatalog:FailOnDrift, which defaults to true and is not environment-dependent — it fails fast everywhere unless you explicitly set it to false to downgrade drift to a warning during a bisect.

The tier gotcha

A fifth edit is optional — and it's the one that bites. A tool's security tier lives in McpToolRouter.ToolSecurityLevels, where only non-Standard tools are listed. The lookup defaults anything absent to Standard:

var level = ToolSecurityLevels.GetValueOrDefault(toolName, ToolSecurityLevel.Standard);

So a destructive tool like delete_file runs as Standard — no approval, basic audit — unless you add it explicitly:

["delete_file"] = ToolSecurityLevel.Elevated,

The map is the only source of truth for a tier; comments are not, and one in the tree is wrong today. The QualityTools class header reads:

Server group: "quality" — Standard security tier (validate_web_app/capture_api_schema are Elevated).

Both validate_web_app and capture_api_schema are absent from ToolSecurityLevels, so both are Standard, not Elevated. The comment lies and the compiler never catches it — §07 lists them correctly as Standard. Trust the map: if your tool needs gating, put it there, because nothing else will.

Verify it worked

Rebuild, then hit the live catalog — your tool appears with its generated schema:

dotnet build Aetherium_Refactor\Aetherium_Refactor.slnx
Invoke-RestMethod http://localhost:5257/api/tools/mcp-schemas | ConvertTo-Json -Depth 5
dotnet build Aetherium_Refactor/Aetherium_Refactor.slnx
curl -s http://localhost:5257/api/tools/mcp-schemas | jq '.tools[].function.name'

A clean boot is itself a signal: ToolCatalogValidator logs OK — N tools registered, all documented when your router and doc maps agree, and throws otherwise. The full tool and permission model is in §07 — MCP Architecture.

Adding a new MCP server class

Reach for this when you're adding a capability boundary, not just a tool — a related group of tools an agent opts into as a unit, with its own mcp_servers group key. Audio handling, a database client, a new media pipeline: each earns a class.

Note that you write a class, not a connection to an external MCP server. Consuming third-party MCP servers — stdio subprocesses or remote endpoints — is intentionally unsupported: the Engine's security gates (egress/DLP, approval, audit) only apply to tools running in-process, so a new boundary is always a class hosted by the Engine itself. The reasoning is documented at the top of McpToolRouter.

1 — Create the [McpServerToolType] class. It carries the class-level attribute and holds one or more [McpServerTool] methods in the same shape as above:

[McpServerToolType]
public class AudioTools
{
    [McpServerTool(Name = "transcribe_audio"), Description(
        "Transcribe an audio file in the workspace to text.")]
    public async Task<string> TranscribeAudio(
        IServiceProvider serviceProvider,
        [Description("Workspace-relative path to the audio file")] string path,
        CancellationToken cancellationToken = default)
    {
        using var scope = serviceProvider.CreateScope();
        var executor = scope.ServiceProvider.GetRequiredService<IContainerExecutor>();
        // run transcription inside the project container, return the transcript
    }
}

The class must have a public parameterless constructor. The router instantiates each class with new T(), so a constructor with injected dependencies will not be satisfied — resolve everything from serviceProvider inside the methods, exactly as the shipped classes do.

2 — Register it. Add a .WithTools<T>() call to the builder chain in Program.cs, before .WithHttpTransport():

.WithTools<Aetherium_Refactor.McpServers.ComfyUITools>()
.WithTools<Aetherium_Refactor.McpServers.AudioTools>()   // ← new
.WithHttpTransport();

.WithTools<T>() is what registers the class's tools into DI, which is how schema discovery finds them. Skip it and the tools never appear at all, no matter how the maps are wired.

3 — Wire each tool. For every method on the new class, repeat steps 2–4 from the tool walkthrough: a dispatch arm in ExecuteToolAsync, a ToolToServerGroup entry under a new group key, and a ToolDocs entry. Add a ToolSecurityLevels entry for any tool that should be gated.

["transcribe_audio"] = "audio",   // a new group key in ToolToServerGroup

Gotchas

  • Capability vs. connection. Registering the class makes the tools exist; agents still can't reach them until the new group key is added to their mcp_servers allowlist — that's authored template-side (§08 — Templates). The two layers are independent: a registered, documented tool that no agent lists is silently unreachable.
  • The catalog must balance. Because ToolCatalogValidator checks both maps, every tool on the new class needs both a ToolToServerGroup entry and a ToolDocs entry, or the Engine won't boot.

Verify it worked

The default catalog endpoint only lists the built-in groups, so pass your new group explicitly:

Invoke-RestMethod "http://localhost:5257/api/tools/mcp-schemas?servers=audio"
curl -s "http://localhost:5257/api/tools/mcp-schemas?servers=audio" | jq '.tools[].function.name'

The architecture this plugs into — the three-layer permission model and the in-process-only design — is §07 — MCP Architecture.

Adding a Workflow Template

Reach for this when you want a reusable team shape on the catalog. A template is a JSON file. Every topology now has at least one shipped example to copy from — sequential-docs and roundtable-review cover the two that had none — so start from the closest file rather than a blank one.

1 — Author the JSON. Follow the schema in §08 — Templates, in either form: the full form (every node spelled out, topology + edges) or the preset form the shipped files use (recipe + nodes that name a role_id from the role library; the Engine expands it on load). The single rule that trips everyone: the outer object is camelCase (id, name, defaultTier, seedKnowledgeBase) and everything inside team is snake_case (topology, is_supervisor, system_prompt, mcp_servers, model_id). Write seed_knowledge_base or modelId and the field is silently ignored — the deserializer keeps the default rather than erroring.

2 — Drop it in. User templates live in a templates/ folder under the workspace root. It isn't created for you, so make it once, then drop the file in:

New-Item -ItemType Directory -Force "$env:AETHERIUM_WORKSPACE_ROOT\templates"
Copy-Item my-team.json "$env:AETHERIUM_WORKSPACE_ROOT\templates\"
Invoke-RestMethod -Method Post http://localhost:5257/api/template/reload
mkdir -p "$AETHERIUM_WORKSPACE_ROOT/templates"
cp my-team.json "$AETHERIUM_WORKSPACE_ROOT/templates/"
curl -s -X POST http://localhost:5257/api/template/reload

A user template whose id matches an embedded one overrides it; a hash-based drift check logs a [TEMPLATE-DRIFT] warning when your copy of a shipped template diverges from the original.

Roles are nodes

There's no separate "add a role" step — a role is an entry in the team.nodes array. Each node is one agent: its id, name, system_prompt, tools, mcp_servers, model_id, and the is_supervisor flag — or just an id and a role_id, in which case all of those come from the named entry in Templates/composition/roles.json and anything you set on the node overrides it.

{
  "id": "reviewer",
  "name": "Code Reviewer",
  "role": "worker",
  "is_supervisor": false,
  "model_id": "google/gemma-4-e4b",
  "system_prompt": "You review diffs for correctness and security.",
  "tools": ["read_file", "list_files", "code_review", "reply"],
  "mcp_servers": ["workspace", "quality", "orchestration"]
}

The unshipped topologies

Sequential runs the nodes in array order, feeding each agent's output to the next. The chain is the nodes order; edges are documentation of intent, not something the runtime reads:

"team": {
  "topology": "sequential",
  "config": { "model": "google/gemma-4-e4b", "context_window": 32768 },
  "nodes": [
    { "id": "outline", "name": "Outliner", "role": "worker", "system_prompt": "...", "tools": ["read_file", "write_file", "reply"] },
    { "id": "draft",   "name": "Drafter",  "role": "worker", "system_prompt": "...", "tools": ["read_file", "write_file", "reply"] },
    { "id": "edit",    "name": "Editor",   "role": "worker", "system_prompt": "...", "tools": ["read_file", "edit_file", "reply"] }
  ],
  "edges": []
}

Roundtable runs every node in parallel against the same prompt and collects the responses. Same empty edges; only the topology string and the peer nodes change:

"team": {
  "topology": "roundtable",
  "nodes": [ /* several peer agents, none marked is_supervisor */ ],
  "edges": []
}

Gotchas

  • Casing, again. It's the one mistake that produces a template that loads but quietly misbehaves. Outer camelCase, team snake_case.
  • Supervisor wiring is auto-repaired. For Hierarchical and Sequential teams, the Engine normalizes the team at instantiation — it guarantees exactly one supervisor, gives it delegate and reply, gives every worker reply, and derives mcp_servers from each node's tools when you leave it blank (§06 — Key Services covers this pass). You don't hand-wire those; the shipped fullstack-web supervisor omits reply for exactly this reason.

Verify it worked

The template should appear in the catalog right after the reload, and instantiate cleanly:

Invoke-RestMethod http://localhost:5257/api/template
curl -s http://localhost:5257/api/template | jq '.[].id'

It also shows on the Templates page at /templates, where Launch Project instantiates it into the Unified Studio. The full schema and topology reference is §08 — Templates.

Adding a skill

Agent skills — markdown bundles an agent pulled in through a load_skill tool — were retired on 2026-09-06 and never shipped in a published build. The tool, its SkillService loader, the Skills/ directory and the Aetherium:SkillsDirectory key are gone. The manifests that existed are archived in the repository's docs/archive/ for anyone who wants to revive the idea; the current way to give an agent reusable, model-facing instructions is the role's system_prompt in a Workflow Template (§08 — Templates).

Adding other things

A few extensions live in other chapters; go there rather than duplicating the path here:

Where to go next