Chapter 11 · Building

Extending the Harness

Four end-to-end recipes for the changes you'll make most: adding a tool, adding a skill, adding a multi-skill agent that combines both, and integrating a local plugin. Every recipe is copy-paste friendly and includes the tests that should accompany the change.

Common vs Core — where does new code live?

Before any recipe, the most important question: am I adding harness infrastructure, or my application's logic? The answer decides which project owns the code.

If the new thing is...It lives in...
A generic capability any future fork would want — e.g. "summarize HTML", "convert units", "render Markdown" An existing .Common / .AI.Common project. You're improving the template.
Specific to your application — e.g. "look up customer record", "schedule a dog walk", "create a Salesforce opportunity" A .Core project, or a new domain-specific project you add (e.g. Infrastructure.PupWalk).
The agent's instructions for what to do A Markdown file in skills/ at the repo root. No project, no compile.
When in doubt, start in .Core or new

It's easy to promote code from your application project up into a .Common project later, once you're sure it's general. It's painful to untangle application-specific assumptions baked into a .Common project after the fact. Start narrow; widen only with evidence.

The recipes below use Infrastructure.AI/Tools/ as the destination for the example tool because temperature conversion is generic. For a tool specific to your application — say, a Stripe-charging tool for PupWalk — you'd create Infrastructure.PupWalk/Tools/ instead. Same pattern, different project.

Recipe 1 · Add a new tool

Scenario: you want the agent to be able to convert temperatures. We'll build a TemperatureConverterTool with celsius_to_fahrenheit and fahrenheit_to_celsius operations.

  1. Define the interface (only if it's a new contract)

    For tools, you don't need a new interface — they all implement ITool. Skip if your tool is just another implementation.

  2. Implement ITool

    Create the file under Infrastructure.AI/Tools/TemperatureConverterTool.cs:

    C# · TemperatureConverterTool.cs
    using Application.AI.Common.Interfaces.Tools;
    
    namespace Infrastructure.AI.Tools;
    
    public sealed class TemperatureConverterTool : ITool
    {
        public string Name => "temperature_converter";
        public string Description => "Converts temperatures between Celsius and Fahrenheit.";
        public IReadOnlyList<string> SupportedOperations =>
            ["celsius_to_fahrenheit", "fahrenheit_to_celsius"];
    
        public Task<ToolResult> ExecuteAsync(
            string operation,
            IReadOnlyDictionary<string, object?> parameters,
            CancellationToken cancellationToken)
        {
            if (!parameters.TryGetValue("value", out var raw) || raw is null)
                return Task.FromResult(ToolResult.Fail("Missing required parameter 'value'."));
    
            if (!double.TryParse(raw.ToString(), out var value))
                return Task.FromResult(ToolResult.Fail($"'value' must be a number, got '{raw}'."));
    
            var output = operation switch
            {
                "celsius_to_fahrenheit" => value * 9 / 5 + 32,
                "fahrenheit_to_celsius" => (value - 32) * 5 / 9,
                _ => double.NaN
            };
    
            return Task.FromResult(ToolResult.Ok(output.ToString("F2")));
        }
    }
    Declare the tool's risk with RiskTier

    ITool exposes a RiskTier property (typed as the BlastRadius enum: Trivial / Low / Medium / High / Critical) that defaults to BlastRadius.Medium. It feeds the graded-autonomy gate — higher autonomy tiers may auto-approve low-radius tools while still forcing human approval for high-radius ones — and the escalation severity is derived from it. Temperature conversion is a pure computation with no side effects, so override it downward: public BlastRadius RiskTier => BlastRadius.Trivial;. A tool that writes files, runs commands, or touches production state should instead return High or Critical.

  3. Register in keyed DI

    Open Infrastructure.AI/DependencyInjection.cs and add the registration:

    C# · DependencyInjection.cs
    services.AddKeyedSingleton<ITool, TemperatureConverterTool>("temperature_converter");

    The key string must match what skills will use in their allowed-tools.

  4. Write the test

    Create src/Content/Tests/Infrastructure.AI.Tests/Tools/TemperatureConverterToolTests.cs:

    C# · TemperatureConverterToolTests.cs
    public class TemperatureConverterToolTests
    {
        private readonly TemperatureConverterTool _sut = new();
    
        [Fact]
        public async Task ExecuteAsync_CelsiusToFahrenheit_ConvertsCorrectly()
        {
            var result = await _sut.ExecuteAsync(
                "celsius_to_fahrenheit",
                new Dictionary<string, object?> { ["value"] = 100 },
                CancellationToken.None);
    
            result.Success.Should().BeTrue();
            result.Output.Should().Be("212.00");
        }
    
        [Fact]
        public async Task ExecuteAsync_MissingValue_ReturnsFailure()
        {
            var result = await _sut.ExecuteAsync(
                "celsius_to_fahrenheit",
                new Dictionary<string, object?>(),
                CancellationToken.None);
    
            result.Success.Should().BeFalse();
            result.Error.Should().Contain("Missing");
        }
    }
  5. Reference it from a skill

    In any SKILL.md that should be able to use the tool, add it to allowed-tools:

    markdown
    ---
    allowed-tools:
      - temperature_converter
    ---
  6. Verify

    Build and test:

    bash
    dotnet build src/AgenticHarness.slnx
    dotnet test src/AgenticHarness.slnx --filter FullyQualifiedName~TemperatureConverter

    Then run the console UI, pick an agent referencing the skill, and ask "what is 100 degrees Celsius in Fahrenheit?" Watch it call the tool.


Recipe 2 · Add a new skill

Scenario: you want a skill that turns the agent into a "code reviewer." This is purely a Markdown change — no C# required.

  1. Create the skill folder

    At the repo root, create skills/code-reviewer/.

  2. Write the SKILL.md

    Create skills/code-reviewer/SKILL.md:

    markdown · skills/code-reviewer/SKILL.md
    ---
    id: code-reviewer
    name: Code Reviewer
    description: Reviews code changes for bugs, style, and design issues.
    category: development
    tags: [code, review, quality]
    version: 1.0.0
    prerequisites: [data-gathering]     # optional: this skill waits for data-gathering to complete
    completion_tool: submit_review      # optional: calling this tool marks the skill as done
    allowed-tools:
      - file_system
      - submit_review
    ---
    
    # Code Reviewer
    
    ## Role
    You review code with the rigor of a senior engineer. You catch bugs others
    miss, you push back on premature abstractions, and you say what you mean.
    
    ## Behavioral guidelines
    - Be specific. "This is bad" is not a review; "this allocates on every call,
      consider caching" is.
    - Categorize findings: BUG / STYLE / DESIGN / NIT.
    - If you don't have enough context to judge, say so — don't fake it.
    - Quote line ranges when referring to specific code.
    
    ## When invoked
    1. Use file_system to load the file(s) under review.
    2. Check ./references/review-checklist.md for the team's standards.
    3. Produce findings grouped by category, with line references.
  3. Add the reference content

    Create skills/code-reviewer/references/review-checklist.md with whatever standards your team uses. This is Tier-3 content — only loaded when the agent actually reads the file via file_system.

  4. Verify the loader picks it up

    Restart the host. The skill loader will discover the new SKILL.md at startup. From the console UI, you can now route requests to "code-reviewer" as an AgentName.

    No C# needed

    Skills are data. You don't recompile to add or modify them. This is the whole point of the SKILL.md format — non-developers can contribute skills, and you can A/B test variants without redeploying.

  5. Test the skill

    Skills can be tested at the harness level. Create src/Content/Tests/Application.Core.Tests/Skills/CodeReviewerSkillTests.cs that loads the skill and asserts the metadata:

    C# · CodeReviewerSkillTests.cs
    public class CodeReviewerSkillTests
    {
        [Fact]
        public void SkillRegistry_LoadsCodeReviewerSkill()
        {
            var registry = TestHarness.GetService<ISkillMetadataRegistry>();
            var skill = registry.TryGet("code-reviewer");
    
            skill.Should().NotBeNull();
            skill!.AllowedTools.Should().Contain("file_system");
            skill.Instructions.Should().Contain("Code Reviewer");
        }
    }

    For end-to-end testing of the skill's behavior, see the patterns in Application.Core.Tests for the existing research and orchestrator skills.


Recipe 3 · Add a new multi-skill agent

Scenario: you want a multi-skill agent that combines the code reviewer skill with a Git-aware skill. Agents are declared in AGENT.md files and can reference multiple skills — at context assembly time, AgentExecutionContextFactory merges their instructions and combines their tool lists into a single agent context. Prerequisites enforce ordering between skills.

  1. Create the agent folder

    At the repo root, create agents/code-review-bot/.

  2. Write the AGENT.md
    markdown · agents/code-review-bot/AGENT.md
    ---
    id: code-review-bot
    name: Code Review Bot
    description: Reviews pull requests by combining git history analysis with code review.
    domain: development
    category: review
    tags: [code, review, git, pull-requests]
    skill: code-reviewer        # primary skill — entry point
    autonomy: Supervised
    state-config:
      trackChanges: true
    decision-framework:
      type: GO_NO_GO
      gates:
        - name: PR is reviewable
          check: pr_has_diff
        - name: Files within scope
          check: scope_allowlist_matches
    skills:
      - code-reviewer
      - git-history
    ---
    
    # Code Review Bot
    
    ## Role
    You are the team's automated code review assistant. For each pull request:
    1. Use git-history to understand the change's context.
    2. Use code-reviewer to evaluate the diff for issues.
    3. Synthesize findings into a structured review comment.
    
    ## Decision framework
    - GO: every gate passes, review is published.
    - NO-GO: any gate fails, escalate to a human.
  3. Verify the agent loader picks it up

    Restart. The agent metadata registry now contains code-review-bot. Because this agent lists two skills, AgentExecutionContextFactory will merge both skills' instructions into the system prompt and combine both skills' tool lists. If code-reviewer declares prerequisites: [git-history], the git-history skill's CompletionTool must be called before code-reviewer activates.

  4. Register the autonomy tier policy (if non-default)

    If this agent should run with a tighter or looser permission set than the default, add a tier policy to appsettings.json:

    json
    "Permissions": {
      "TierPolicies": {
        "Supervised": {
          "DefaultBehavior": "Ask",
          "ToolOverrides": {
            "file_system": "Allow",
            "git_log": "Allow",
            "git_diff": "Allow"
          }
        }
      }
    }
  5. Test end-to-end

    Create an integration test that drives the agent through RunConversationCommand:

    C# · CodeReviewBotIntegrationTests.cs
    public class CodeReviewBotIntegrationTests : IClassFixture<TestHarnessFixture>
    {
        private readonly TestHarnessFixture _fixture;
    
        public CodeReviewBotIntegrationTests(TestHarnessFixture fixture) => _fixture = fixture;
    
        [Fact]
        public async Task ReviewBot_FindsBugsInSampleDiff()
        {
            var mediator = _fixture.GetService<IMediator>();
            var result = await mediator.Send(new RunConversationCommand
            {
                AgentName = "code-review-bot",
                UserMessage = "Review the sample diff at ./test-fixtures/buggy.diff",
            });
    
            result.Success.Should().BeTrue();
            result.FinalResponse.Should().Contain("BUG");
        }
    }

    Use WebApplicationFactory<Program> patterns in TestHarnessFixture to wire an in-memory composition of the harness — the same way the existing integration tests do.


Recipe 4 · Add a local plugin

Scenario: you have an external directory with skills and an MCP server that you want to integrate without copying code into the harness. Local plugins let you declare external directories that the harness loads at startup.

  1. Create the plugin directory

    Create a directory (e.g. plugins/code-analysis/) with a plugin.json manifest:

    json · plugins/code-analysis/plugin.json
    {
      "id": "code-analysis",
      "name": "Code Analysis Plugin",
      "version": "1.0.0",
      "skills": ["skills/analyzer/SKILL.md"],
      "mcpServers": [
        { "name": "ast-server", "command": "dotnet", "args": ["run", "--project", "src/AstServer"] }
      ]
    }
  2. Declare the plugin in config

    Add the plugin declaration to appsettings.json:

    json · appsettings.json
    "Plugins": {
      "Declarations": [
        {
          "Id": "code-analysis",
          "Path": "./plugins/code-analysis",
          "AllowedTools": ["file_system", "ast_parser"],
          "DeniedTools": ["file_system_write"],
          "AutonomyLevel": "Supervised"
        }
      ]
    }
  3. Understand boundary governance

    The AllowedTools list restricts what the plugin can access. DeniedTools is stronger — it's bypass-immune and cannot be overridden by any autonomy mode. Use DeniedTools for hard security boundaries. AutonomyLevel sets the permission tier for the plugin's tool calls.

    !
    DeniedTools cannot be overridden

    If you put file_system_write in DeniedTools, no configuration — not even an Autonomous autonomy tier — can grant the plugin write access. This is by design: it lets operators give plugins capability without unbounded risk.

  4. Skills from plugins are Injected mode

    Skills loaded from a plugin automatically use SkillMode.Injected — they receive all MCP tools from the plugin's servers, bypassing explicit allowed-tools declarations. The boundary governance (AllowedTools / DeniedTools) still applies on top.

  5. Verify

    Restart the harness. Check the logs for Plugin loaded: code-analysis. The plugin's skills should appear in the skill registry, and its MCP server should be connectable.


The general "add a new X" mental model

The above recipes share a structure. Any new capability follows the same pattern:

  1. Domain — model the concept if it's new (usually an interface in Application, with model types in Domain).
  2. Application — interface lives here, command/handler if it's a use case.
  3. Infrastructure — implement the interface.
  4. DI — register in the right project's DependencyInjection.cs.
  5. Configuration — add the config POCO under Domain.Common/Config/ if there's anything tunable.
  6. Tests — unit tests for the implementation, integration tests for the wired-up behavior.
  7. Wire from a skill / agent / behavior — make it discoverable to the agent.

Things to avoid

  • Skipping DI registration — your code compiles and your tests "pass" in isolation, but the agent never sees the new tool. Run the harness end-to-end before merging.
  • Bypassing factories — never construct AIAgent, IChatClient, or AgentExecutionContext directly. Lost content safety, OTel, and config consistency.
  • Skipping the SKILL.md frontmatter — without it, the loader can't discover the skill. Validate frontmatter renders correctly with the loader tests.
  • Hardcoding paths or model names — pull from AppConfig via IOptionsMonitor. Never embed credentials or endpoints.
i
Running on Azure AI Foundry instead of the console

None of these recipes change when you host the same agent on Azure AI Foundry. The IChatClientFactory already supports an AI Foundry backend (via the Microsoft.Agents.AI.Foundry package), and Presentation.FoundryHost is a ready-made host that composes the harness the same way the console UI does — your tools, skills, and plugins load unchanged. Pick the chat-client type through AppConfig.AI.AgentFramework config; you don't touch tool or skill code.


Where to go from here