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.
|
.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.
-
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. -
Implement
IToolCreate the file under
Infrastructure.AI/Tools/TemperatureConverterTool.cs:C# · TemperatureConverterTool.csusing 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 withRiskTierIToolexposes aRiskTierproperty (typed as theBlastRadiusenum:Trivial/Low/Medium/High/Critical) that defaults toBlastRadius.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 returnHighorCritical. -
Register in keyed DI
Open
Infrastructure.AI/DependencyInjection.csand add the registration:C# · DependencyInjection.csservices.AddKeyedSingleton<ITool, TemperatureConverterTool>("temperature_converter");The key string must match what skills will use in their
allowed-tools. -
Write the test
Create
src/Content/Tests/Infrastructure.AI.Tests/Tools/TemperatureConverterToolTests.cs:C# · TemperatureConverterToolTests.cspublic 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"); } } -
Reference it from a skill
In any
SKILL.mdthat should be able to use the tool, add it toallowed-tools:markdown--- allowed-tools: - temperature_converter --- -
Verify
Build and test:
bashdotnet build src/AgenticHarness.slnx dotnet test src/AgenticHarness.slnx --filter FullyQualifiedName~TemperatureConverterThen 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.
-
Create the skill folder
At the repo root, create
skills/code-reviewer/. -
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. -
Add the reference content
Create
skills/code-reviewer/references/review-checklist.mdwith whatever standards your team uses. This is Tier-3 content — only loaded when the agent actually reads the file viafile_system. -
Verify the loader picks it up
Restart the host. The skill loader will discover the new
SKILL.mdat startup. From the console UI, you can now route requests to"code-reviewer"as anAgentName.No C# neededSkills 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.
-
Test the skill
Skills can be tested at the harness level. Create
src/Content/Tests/Application.Core.Tests/Skills/CodeReviewerSkillTests.csthat loads the skill and asserts the metadata:C# · CodeReviewerSkillTests.cspublic 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.Testsfor 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.
-
Create the agent folder
At the repo root, create
agents/code-review-bot/. -
Write the AGENT.mdmarkdown · 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. -
Verify the agent loader picks it up
Restart. The agent metadata registry now contains
code-review-bot. Because this agent lists two skills,AgentExecutionContextFactorywill merge both skills' instructions into the system prompt and combine both skills' tool lists. Ifcode-reviewerdeclaresprerequisites: [git-history], the git-history skill'sCompletionToolmust be called before code-reviewer activates. -
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" } } } } -
Test end-to-end
Create an integration test that drives the agent through
RunConversationCommand:C# · CodeReviewBotIntegrationTests.cspublic 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 inTestHarnessFixtureto 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.
-
Create the plugin directory
Create a directory (e.g.
plugins/code-analysis/) with aplugin.jsonmanifest: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"] } ] } -
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" } ] } -
Understand boundary governance
The
AllowedToolslist restricts what the plugin can access.DeniedToolsis stronger — it's bypass-immune and cannot be overridden by any autonomy mode. Use DeniedTools for hard security boundaries.AutonomyLevelsets the permission tier for the plugin's tool calls.DeniedTools cannot be overriddenIf you put
file_system_writein 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. -
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 explicitallowed-toolsdeclarations. The boundary governance (AllowedTools / DeniedTools) still applies on top. -
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:
- Domain — model the concept if it's new (usually an interface in Application, with model types in Domain).
- Application — interface lives here, command/handler if it's a use case.
- Infrastructure — implement the interface.
- DI — register in the right project's
DependencyInjection.cs. - Configuration — add the config POCO under
Domain.Common/Config/if there's anything tunable. - Tests — unit tests for the implementation, integration tests for the wired-up behavior.
- 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, orAgentExecutionContextdirectly. 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
AppConfigviaIOptionsMonitor. Never embed credentials or endpoints.
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.