Using Skills in Microsoft Agent Framework – C#

Welcome to today’s post! This blog series dives into the Microsoft Agent Framework and my recent speaking session at Build.AI 2026.

This feature has been around for quite some time—I actually started playing with it during the Songkran holiday. Lately, I’ve been thinking about how models like Claude or Hermes are integrating Skill.md files, and I wondered: Can the Microsoft Agent Framework do this too?

Spoiler alert: Yes, it can! That’s what inspired this little experiment with 'Cat' / Claude (lol).

Recap Microsoft Agent Framework

Microsoft Agent Framework (MAF) is a NuGet library designed to make building AI Agents across the .NET, Python, and Golang (preview) stacks easier and more streamlined. It supports everything from straightforward chat interactions and workflow automation, to building Harness Agents with built-in Memory and Tools Approval management.

However, for this specific blog post, the spotlight will be focus on Skills.

What is a Skill?

A "Skill" is essentially a focused set of capabilities extracted into concise prompts, accompanied by supporting scripts that enable the LLM to invoke them. Currently, these are mostly written in Python. I did experiment with C# Script, but it still comes with quite a few limitations at the moment.

Example: expense-report , Skill Structure explain below

skills/savings-calculator/
├── SKILL.md                             ← Instructions/guide for the agent to read
├── references/
│   └── formula.md                    ← Explains the script formulas in case the user asks
└── scripts/
    ├── project-savings.py           ← Script for calculating savings amount
    └── project-debt-payoff.py    ← Script for calculating debt installment payments
---
name: savings-calculator
description: Use this whenever a user needs an exact number for a savings plan — the projected balance a goal will reach by its target date, how many months a contribution plan needs to hit a target amount, or how many months it takes to pay off a debt at a given payment and interest rate. Always prefer this over estimating the math yourself.
license: MIT
compatibility: "Requires a Python 3 runtime available to the host process (python3 on PATH)."
metadata:
  author: pingkunga-finance
  version: "1.0"
---
INSTRUCTION

You can also build highly specialized skills, such as for Coding. I actually included one in my samples specifically for Code Review—it scans for Symbols and APIs first, and then lets the AI summarize the PR.

Skill vs. Workflow

AspectWhen to use a SkillWhen to use a Workflow
ControlYou want the AI to make its own decisions; it's flexible and creative.You need a strictly deterministic
execution path.
ResilienceIf it fails, you can simply retry the entire turn.It requires checkpoints to resume from the last step, which is crucial when re-running
the whole process is too costly.
Side EffectsOperations are idempotent or low-risk; repeating them yields the exact same result.It involves real side effects (e.g., sending
emails, processing payments) that cannot
be safely retried.
ComplexitySingle-domain tasks that a single agent can easily handle.Multi-step business processes involving multiple agents, human approvals,
or external systems.

How to MAF Select Skills (Progressive Disclosure)

Based on the SKILL.md file, which defines what a specific Skill does, the MAF-Agent Skills framework follows a 4-step selection and execution process:

  1. Advertise (~100 tokens per skill): The framework looks at the Skill names and descriptions, injecting them into the system prompt to inform the LLM about the available capabilities.
  2. Load (< 5000 tokens recommended): If a Skill matches the LLM's requirements, MAF loads and reads the entire SKILL.md file. It is highly recommended to keep the size of this file under 5,000 tokens.
  3. Read resources: Once the LLM selects a Skill and determines that it needs related reference materials, MAF invokes the read_skill_resource function to fetch them for context.
  4. Run scripts: Similarly, if the LLM decides that a script needs to be executed, MAF calls the run_skill_script function to run it, captures the output, and feeds the results back to the Agent.

Understanding Agent Skills in the Microsoft Agent Framework

In MAF, there are four distinct types of Skills: File-based skills, Class-based skills, Code-defined skills, and MCP-based skills.

- File-based skills

For this part, we prepare the SKILL.md file following the standard Agent Skills structure. On the coding side within MAF, there are a few key components to pay attention to:

  • AgentFileSkillsSourceOptions – This defines how Skills are loaded and specifies the supported file formats.
  • AgentSkillsProvider – This is responsible for loading the Skills into the agent. If your Skill includes scripts, you also need to configure which class handles script execution via the scriptRunner / UseFileScriptRunner.

One thing to note: if you read the official MAF documentation, it recommends using SubprocessScriptRunner. However, this class is not built-in to the framework. I actually had to copy the implementation and use it in my own project as well.

var fileOptions = new AgentFileSkillsSourceOptions
{
    AllowedResourceExtensions = [".md", ".txt"],
    AllowedScriptExtensions = [".py"],
    SearchDepth = 3, // Search up to 3 levels deep (default is 2)
    ResourceFilter = context => context.RelativeFilePath.StartsWith("references/"),
    ScriptFilter = context => context.RelativeFilePath.StartsWith("scripts/")
                           || context.RelativeFilePath.StartsWith("tools/"),
};

// Via constructor
var skillsProvider = new AgentSkillsProvider(
    Path.Combine(AppContext.BaseDirectory, "skills"),
    fileOptions: fileOptions, scriptRunner: SubprocessScriptRunner.RunAsync);

// Via builder
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills"), options: fileOptions)
    .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
    .Build();
- Class-based skills

If you have a class that you want the AI to interact with, you can decorate it with attributes—such as name, description, instructions, resources, and scripts—to turn the entire class into a Skill. Alternatively, it can act as a Driver to invoke related Services or Business Logic.

MAF conveniently provides AgentClassSkill for you to inherit from. On top of that, Skills can be distributed via NuGet packages, making it super easy for consumers to just add a reference and start using them right away.

The code snippet below shows an example of a Skill designed to read a Gitea repository. I'll use it to break down the components of AgentClassSkill:

  • AgentSkillFrontmatter – Defines the Skill's Name and Description, essentially telling the AI what this Skill can do.
  • Instructions – Contains the pre-written prompts. Tip: If the instructions get too lengthy, it's better to offload them into an AgentSkillResource.
  • AgentSkillResource – Provides supplementary context about tools, or can even act as a lookup table.
  • AgentSkillScript – Defines the specific methods that the Agent is allowed to call and execute.
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;

namespace GiteaAiSummarizer.Services;

public sealed class GiteaSkill : AgentClassSkill<GiteaSkill>
{
    private readonly GiteaApiClient _gitea;
    private readonly ILogger<GiteaSkill> _logger;

    public GiteaSkill(GiteaApiClient gitea, ILogger<GiteaSkill> logger)
    {
        _gitea = gitea;
        _logger = logger;
    }

    public override AgentSkillFrontmatter Frontmatter { get; } =
        new(
            "gitea-tools",
            "Provides access to Gitea repository information including issues, comments, and file history."
        );

    protected override string Instructions =>
        """
    Use this skill to gather additional context from the Gitea repository.

    1. Read `gitea-tool-catalog` before selecting a Gitea tool.
    2. Follow its selection rules and limitations.
    3. Only report conclusions supported by the diff or tool results.
    """;

    [AgentSkillScript("get_issue")]
    [Description("Fetches detailed information about a Gitea issue or pull request by its number.")]
    public async Task<string> GetIssueAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The issue or pull request number.")] int number,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Fetching issue details for {Owner}/{Repo}#{Number}",
            owner,
            repo,
            number
        );
        // Gitea API treats PRs and Issues similarly for metadata
        var url = $"{_gitea.BaseUrl}/api/v1/repos/{owner}/{repo}/issues/{number}";
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        _gitea.AddHeaders(request);

        var response = await _gitea.HttpClient.SendAsync(request, ct);
        if (!response.IsSuccessStatusCode)
            return $"Error: {response.StatusCode}";

        var content = await response.Content.ReadAsStringAsync(ct);
        return content;
    }

    [AgentSkillScript("get_issue_comments")]
    [Description("Fetches comments for a specific Gitea issue or pull request.")]
    public async Task<string> GetIssueCommentsAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The issue or pull request number.")] int number,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Fetching comments for {Owner}/{Repo}#{Number}",
            owner,
            repo,
            number
        );
        var comments = await _gitea.GetIssueCommentsAsync(owner, repo, number, ct);
        return JsonSerializer.Serialize(comments);
    }

    [AgentSkillScript("search_code")]
    [Description("Searches for a keyword or symbol within the repository code.")]
    public async Task<string> SearchCodeAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The keyword or symbol to search for.")] string keyword,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Searching code for '{Keyword}' in {Owner}/{Repo}",
            keyword,
            owner,
            repo
        );
        var url =
            $"{_gitea.BaseUrl}/api/v1/repos/{owner}/{repo}/search?q={Uri.EscapeDataString(keyword)}&limit=10";
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        _gitea.AddHeaders(request);

        var response = await _gitea.HttpClient.SendAsync(request, ct);
        if (!response.IsSuccessStatusCode)
            return $"Error: {response.StatusCode}";

        var content = await response.Content.ReadAsStringAsync(ct);
        return content;
    }

    [AgentSkillResource("gitea-tool-catalog")]
    [Description(
        "Lookup table for selecting the appropriate Gitea tool during pull request analysis."
    )]
    public string GiteaToolCatalog =>
        """
    # Gitea Tool Catalog

    | Need | Tool | Required inputs | Limitation |
    |---|---|---|---|
    | Read an issue or PR's metadata | `get_issue` | owner, repo, number | Returns raw Gitea JSON |
    | Understand issue or PR discussion | `get_issue_comments` | owner, repo, number | Returns raw JSON comments |
    | Find references to a changed symbol | `search_code` | owner, repo, keyword | Maximum 10 matches; no match does not prove unused |

    ## Selection rules
    - PR text or diff refers to `#<number>`: use `get_issue`.
    - Acceptance criteria or prior decisions may be in a discussion: use `get_issue_comments`.
    - `impact_graph` identifies a changed symbol: use `search_code` once per unique symbol.
    - Treat empty or incomplete search results as inconclusive.
    """;
}

Actually, if you've ever used Semantic Kernel before, you'll find that Class-based skills are quite similar to KernelFunction

- Code-defined skills

This one is conceptually similar to Class-based skills, but with a key difference: you can create Skills dynamically at runtime. Some common use cases include:

  • Personalizing per user session – Tailor the Skill's behavior based on the current user's context.
  • Reading values from env/DB in real-time – Or embedding logic directly at the call-site instead of relying on static files.
  • Using values from Local Variables – Effectively creating closures over call-site state.

From my perspective, there's another big advantage: if you already have existing Method or Function implementations, wrapping them with AgentInlineSkill lets you turn them into Skills with minimal refactoring

var skill = new AgentInlineSkill(
            name: $"receipt-ocr-{receiptId:N}",
            description: "Extract vendor, amount, and date from this specific uploaded receipt image and record it as a transaction.",
            instructions: "Call extract_receipt to run OCR on this receipt and create the resulting transaction from it. " +
                          "To check this receipt's current status without re-running OCR, read the receipt_status resource instead.",
            license: null,
            compatibility: null,
            allowedTools: null,
            metadata: null,
            serializerOptions: null,
            argumentMarshaler: null);

        skill.AddScript(
            "extract_receipt",
            async Task<string> () => await ExtractAsync(chatClient, scopeFactory, userId, receiptId, supportsVision),
            "Runs OCR on the uploaded receipt image and creates a Transaction from the extracted fields. Takes no arguments — the receipt is fixed at skill-creation time.",
            null);

        // ChatSessionService.cs: DisableReadSkillResourceApproval = true, unconditional, 
        // what's the status of this receipt"
        skill.AddResource(
            "receipt_status",
            async Task<string> () => await DescribeStatusAsync(scopeFactory, userId, receiptId),
            "Current status of this receipt (pending/succeeded/failed/manual) and any already-extracted fields, " +
            "without re-running OCR. Read this to answer status questions instead of calling extract_receipt again.",
            null);

Now, in the ExtractAsync / DescribeStatusAsync methods, you can simply implement your own custom logic

private static async Task<string> DescribeStatusAsync(IServiceScopeFactory scopeFactory, Guid userId, Guid receiptId)
{
	using var scope = scopeFactory.CreateScope();
	await using var db = CreateDbContext(scope.ServiceProvider, userId);
	
	var receipt = await db.Receipts.Include(r => r.ExtractedCategory).FirstOrDefaultAsync(r => r.Id == receiptId);
	// Logic for Check Receipts Status
      
	return $"Status: {receipt.OcrStatus}. {fields}. Linked transaction: {receipt.ResultingTransactionId}.";
}

private static async Task<string> ExtractAsync(IChatClient chatClient, IServiceScopeFactory scopeFactory, Guid userId, Guid receiptId, bool supportsVision)
{
	// Graceful degradation
	if (!supportsVision)
	{
		return "This AI provider doesn't support image input — please enter the receipt's vendor, " +
                   "amount, and date manually instead.";
	}
	
	// Check Same Receipt
	....
	
	// Call LLM to OCR Receipt
	string rawResponse;
	try
	{
		var response = await chatClient.GetResponseAsync(
		[
			new ChatMessage(ChatRole.User,
			[
				new TextContent(ExtractionPrompt),
				new DataContent(receipt.ImageBytes, receipt.ContentType),
			]),
		]);
		rawResponse = response.Text;
	}
	catch (Exception ex)
	{
		receipt.OcrStatus = ReceiptOcrStatus.Failed;
		receipt.OcrRawResponse = $"Error calling the AI provider: {ex.Message}";
		await db.SaveChangesAsync();
		return "Error: the AI provider call failed. The receipt is marked failed — try manual entry instead.";
	}

	// Extract Data 
	
	// Save in DB
	
    return $"Extracted vendor={extracted.Vendor ?? "(unknown)"}, amount={amount:C}, " +
               $"date={transaction.OccurredOn:d}, category={category.Name}{historyNote}. Recorded as a transaction.";
}

Before we move on, let's do a quick recap—even I got a bit confused myself 555

  • Class-based skills: Use these when your Skill requires complex DB connections, needs Services injected via the Constructor, or demands a clear structure for better Testability and Reusability.
  • Code-defined skills: Ideal for when a Skill needs to be generated dynamically at runtime (e.g., the Skill structure changes based on data in the DB), is extremely lightweight, or requires direct access to Local Variables/Closures.
- MCP-based skills

This feature consists of two main parts: the MCP Server (via the ModelContextProtocol.AspNetCore NuGet package) and the MCP Client (via the Microsoft.Agents.AI.Mcp NuGet package)

📌 MCP Server – Exposes a URL scheme like skill://index.json and supports two operational modes:

FormatBehaviorUse Case
skill-mdThe MCP Server allows the Agent to fetch the SKILL.md file and related resources on demand. If additional resources are needed
, the Agent will trigger further requests sequentially.
Ideal for Agents to pull the latest Skills individually (one by one).
archiveThe MCP Server allows fetching all Skills bundled together in
.zip, .tar, or .tar.gz formats.
Perfect for syncing and distributing groups of Skills to AI Agents (e.g., Skills for Finance, Customer Support, Data Operations, Dev, etc.).
  • Create Handler Resource
public sealed class MonthlySummaryResourceHandlers(
    FinanceDbContext db,
    ICurrentUserAccessor currentUserAccessor,
    ILogger<MonthlySummaryResourceHandlers> logger)
{
    private const string SkillName = "monthly-summary";
    private const string IndexUri = "skill://index.json";
    private const string SkillMdUri = "skill://monthly-summary/SKILL.md";
    private const string ResourceUriPrefix = "skill://monthly-summary/";

    // Thin wrappers around the *Core methods below, which take plain arguments rather than a
    // RequestContext<T> — ModelContextProtocol.Server.RequestContext<T>'s only constructor requires a real
    // (non-null) McpServer + JsonRpcRequest, too heavy to stand up in a unit test. Splitting the actual
    // logic out keeps it testable without a live transport, same "wrapper vs. testable core" split
    // ReceiptOcrSkillFactory uses for its own AI-facing script method (docs/spec.md §4.2).
    public static ValueTask<ListResourcesResult> ListResourcesAsync(RequestContext<ListResourcesRequestParams> _, CancellationToken __) =>
        ListResourcesCoreAsync();

    public ValueTask<ReadResourceResult> ReadResourceAsync(RequestContext<ReadResourceRequestParams> context, CancellationToken cancellationToken)
    {
        var uri = context.Params?.Uri
            ?? throw new McpException("Missing resource uri.");
        return ReadResourceCoreAsync(uri, cancellationToken);
    }

    public static ValueTask<ListResourcesResult> ListResourcesCoreAsync() =>
        ValueTask.FromResult(new ListResourcesResult
        {
            Resources =
            [
                new Resource { Uri = IndexUri, Name = "skill-index", MimeType = "application/json" },
                new Resource { Uri = SkillMdUri, Name = SkillName, MimeType = "text/markdown" },
            ],
        });

    public async ValueTask<ReadResourceResult> ReadResourceCoreAsync(string uri, CancellationToken cancellationToken)
    {
        logger.LogInformation("MCP read_skill_resource: {Uri}", uri);

        var text = uri switch
        {
            IndexUri => BuildIndexJson(),
            SkillMdUri => await ReadSkillMdAsync(cancellationToken),
            _ when uri.StartsWith(ResourceUriPrefix, StringComparison.Ordinal) =>
                await BuildSummaryJsonAsync(uri[ResourceUriPrefix.Length..], cancellationToken),
            _ => throw new McpException($"Unknown resource: {uri}"),
        };

        return new ReadResourceResult
        {
            Contents = [new TextResourceContents { Uri = uri, MimeType = "text/plain", Text = text }],
        };
    }

    private static string BuildIndexJson()
    {
        var index = new
        {
            skills = new[]
            {
                new
                {
                    name = SkillName,
                    type = "skill-md",
                    description = "Produces a monthly income/expense summary with budget-vs-actual status.",
                    url = SkillMdUri,
                    digest = "v1",
                },
            },
        };
        return JsonSerializer.Serialize(index);
    }

    private static Task<string> ReadSkillMdAsync(CancellationToken cancellationToken)
    {
        var path = Path.Combine(AppContext.BaseDirectory, "skills", "monthly-summary", "SKILL.md");
        return File.ReadAllTextAsync(path, cancellationToken);
    }

    /// <summary>
    /// <paramref name="resourceName"/> is expected as <c>summary-&lt;year&gt;-&lt;month&gt;</c> (e.g.
    /// <c>summary-2026-08</c>) — MCP resource reads don't carry free-form structured arguments the way
    /// tool calls do, so the month/year travel encoded in the resource name itself. SKILL.md tells the
    /// agent this exact convention (same "spell out the convention" approach already used for
    /// <c>skills/savings-calculator</c>'s script-path gotcha, docs/spec.md §4.3).
    /// </summary>
    private async Task<string> BuildSummaryJsonAsync(string resourceName, CancellationToken cancellationToken)
    {
        if (!TryParsePeriod(resourceName, out var period))
        {
            throw new McpException(
                $"Unrecognized resource '{resourceName}'. Expected 'summary-<year>-<month>', e.g. 'summary-2026-08'.");
        }

        // userId comes from this request's validated JWT claims (HttpUserContextAccessor), never from the
        // resource name or any other MCP-request-supplied value — the isolation boundary now lives here,
        var userId = currentUserAccessor.UserId
            ?? throw new McpException("No authenticated user for this request.");

        var summary = await MonthlySummaryRepository.GetMonthlySummaryAsync(db, userId, period, cancellationToken);

        logger.LogInformation(
            "MCP monthly-summary computed for user {UserId}, period {Period}: income={TotalIncome} expense={TotalExpense}",
            userId, period.ToString("yyyy-MM", CultureInfo.InvariantCulture), summary.TotalIncome, summary.TotalExpense);

        return JsonSerializer.Serialize(new
        {
            month = summary.PeriodMonth.ToString("yyyy-MM", CultureInfo.InvariantCulture),
            totalIncome = summary.TotalIncome,
            totalExpense = summary.TotalExpense,
            net = summary.TotalIncome - summary.TotalExpense,
            byCategory = summary.ByCategory.Select(c => new
            {
                category = c.CategoryName,
                kind = c.Kind.ToString(),
                total = c.TotalAmount,
            }),
            budgetStatuses = summary.BudgetStatuses.Select(b => new
            {
                category = b.CategoryName,
                limit = b.LimitAmount,
                spent = b.SpentAmount,
                percentUsed = b.PercentUsed,
                status = b.IsOver ? "Over" : b.IsNear ? "Near" : "Ok",
            }),
        });
    }

    private static bool TryParsePeriod(string resourceName, out DateOnly period)
    {
        period = default;
        const string prefix = "summary-";
        if (!resourceName.StartsWith(prefix, StringComparison.Ordinal))
        {
            return false;
        }

        var parts = resourceName[prefix.Length..].Split('-');
        if (parts.Length != 2
            || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var year)
            || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var month)
            || month is < 1 or > 12)
        {
            return false;
        }

        period = new DateOnly(year, month, 1);
        return true;
    }
}
  • Create MCP Server
builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithListResourcesHandler(MonthlySummaryResourceHandlers.ListResourcesAsync)
    .WithReadResourceHandler((context, cancellationToken) =>
        context.Services!.GetRequiredService<MonthlySummaryResourceHandlers>()
            .ReadResourceAsync(context, cancellationToken));

📌 MCP Client – Instantiate the mcpClient to establish a connection and invoke the skills.

// Create MCP Client
 var baseUrl = configuration["Mcp:BaseUrl"]
                ?? throw new InvalidOperationException("Missing Mcp:BaseUrl configuration.");
var token = tokenIssuer.IssueToken(userId);

var transport = new HttpClientTransport(new HttpClientTransportOptions
{
    Endpoint = new Uri(baseUrl),
    Name = $"finance-mcp-{userId:N}",
                AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {token}" },
 });
await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken);

// Build a skills provider that discovers skills over MCP
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseMcpSkills(client)
    .Build();
  • When fetching Skills via the Archive method, the Client must also pass AgentMcpSkillsSourceOptions to specify the search scope. Additionally, if a Skill contains scripts, MAF explicitly warns that 'Archive scripts are never executed.
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseMcpSkills(client, new AgentMcpSkillsSourceOptions
    {
        ArchiveSkillsDirectory = Path.Combine(AppContext.BaseDirectory, "extracted-skills"),
        ArchiveMaxFileCount = 50,
        ArchiveMaxSizeBytes = 2 * 1024 * 1024, // 2 MB
    })
    .Build();

Using Agent Skills + Harness Agents

So we know MAF offers four Skill types: File-based, Class-based, Code-defined, and MCP-based. The next step is writing code to load and expose these capabilities to the Agent + LLM. Here's a sample implementation

var budgetSkill = new BudgetSkill(scopeFactory, userId);
var skillsRoot = Path.Combine(AppContext.BaseDirectory, "skills");
var fileOptions = new AgentFileSkillsSourceOptions
{
    AllowedResourceExtensions = [".md", ".txt"],
    AllowedScriptExtensions = [".py"],
    SearchDepth = 3, // Search up to 3 levels deep (default is 2)
    ResourceFilter = context => context.RelativeFilePath.StartsWith("references/"),
    ScriptFilter = context => context.RelativeFilePath.StartsWith("scripts/")
                           || context.RelativeFilePath.StartsWith("tools/"),
};

// Via builder
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseSkill(budgetSkill)  //1. Class-based skills  
    .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills"), options: fileOptions)
    .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
	.UseSource(_ => new DynamicInlineSkillsSource(_dynamicSkills))
	//DisableCaching rather than snapshotted once — the whole point is that RegisterReceiptSkill
	.DisableCaching();
    .Build();
	
// Get Skill from MCP server such as monthly-summary 
_mcpClient = await mcpServerLauncher.TryStartAsync(userId, cancellationToken);
if (_mcpClient is not null)
{
	skillsBuilder = skillsBuilder.UseMcpSkills(_mcpClient, new AgentMcpSkillsSourceOptions());
}

Since some Skills interact with the environment, simply creating a regular Agent isn't enough. Instead, we bring in the Harness Agent to help manage this, specifically leveraging its Approval mechanism. This requires two essential configuration components:

  • UseOptions – Allows you to toggle approval requirements for specific Skill operations:
    • DisableLoadSkillApproval
    • DisableReadSkillResourceApproval
    • DisableRunSkillScriptApproval
  • ToolApprovalAgentOptions
 var skillsProvider = skillsBuilder
            .UseOptions(o =>
            {
                o.DisableLoadSkillApproval = true;
                o.DisableReadSkillResourceApproval = true;
                o.DisableRunSkillScriptApproval = false;   //Required Approval Rule for running skill scripts ToolApprovalAgentOptions
            })
            .Build();

        // Wired into AgentFactory's HarnessAgentOptions.ToolApprovalAgentOptions
        // an approval prompt now that DisableRunSkillScriptApproval is false above.
        var toolApprovalOptions = new ToolApprovalAgentOptions
        {
            AutoApprovalRules =
            [
                // Skill Read Only By Pass 
                AutoApprovalRules = [AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule],
                // If you wanyt to custom such as read from config 
                // SkillApprovalPolicy.BuildAutoApprovalRule(user.AutoApproveWrites, user.AutoApproveExecuteScript),
            ],
        };

So instead of a plain ChatClient with just AsAIAgent, we'll wrap it with the Harness Agent.

chatClient.AsAIAgent(options, loggerFactory);

Switch it over to AsHarnessAgent. For this, I've created a Helper method to build the Harness Agent, which takes three key parameters: skillsProvider, instructions, and ToolApprovalAgentOptions.

Oh, and one more thing—when creating the Agent, don't forget to pass in the LoggerFactory so that MAF can write logs

public AIAgent CreateAgent(AgentSkillsProvider skillsProvider, string? instructions = null, ToolApprovalAgentOptions? toolApprovalOptions = null)
{
      #pragma warning disable MAAI001 // HarnessAgentOptions is evaluation-purposes-only in this package version. DisableCompaction Error
      var options = new HarnessAgentOptions
      {
            ChatOptions = instructions is null ? null : new ChatOptions { Instructions = instructions },
            AIContextProviders = [skillsProvider],
            DisableAgentSkillsProvider = true,
            ToolApprovalAgentOptions = toolApprovalOptions,
            DisableCompaction = true,
            DisableFileMemory = true,
            DisableWebSearch = true,
            DisableTodoProvider = true,
            DisableAgentModeProvider = true,
            DisableOpenTelemetry = true,
      };

      #pragma warning restore MAAI001
      return chatClient.AsHarnessAgent(options, loggerFactory);
  }

In my session, I showcased two Demo Apps:

Sample App - gitea-aihook

💡 gitea-aihook – Right before Songkran, I wanted to build a GitHub Copilot Review-style experience for Gitea. I built it with .NET 10 + WebAPI, and it can be triggered via Webhooks or Gitea Actions to summarize PRs—flagging anything reviewers should pay attention to. It ships with 4 Skills.

  • review – A file-based Skill + script (impact_graph) used to analyze git diffs and identify potential impact areas in the system (e.g., Symbols / APIs) before letting the AI summarize the PR.
  • security-checker – A file-based Skill that detects hardcoded secrets/API keys, and checks authentication/encryption issues. Though honestly, this should probably be implemented as a script instead.
  • style-guard – A file-based Skill that checks code style, complexity, and naming conventions.
  • gitea-tools – A custom Class-based Skill (GiteaSkill : AgentClassSkill<GiteaSkill>) that exposes 3 tools/functions for the Agent to call the Gitea API: get_issue / get_issue_comments / search_code.

Oh, and while I was preparing for the presentation, I happened to check and realized Gitea actually has MCP support! So we could potentially just use the MCP instead of having to build a custom class skill.

Sample App - MyFinanceWithAgentSkill

💡 The second one – I went back to review the Agent Skill documentation and noticed it covers Harness / Code-defined skills and MCP-based skills. So I built a simple finance management app on .NET 10 + Blazor Server (Interactive Server) with MudBlazor. The main features demonstrate each type of Skill from the Microsoft Agent Framework end-to-end in real-world scenarios:

  • Transactions & Budgeting – Class-based AgentClassSkill<T> (record expenses, check budget status, transfer budgets between categories)
  • Receipt OCR – Inline AgentInlineSkill that processes one receipt file per session without needing to reset the ongoing chat
  • Savings Goals – File-based SKILL.md + references/ + Python (compound interest calculations)
  • Monthly Summary / Goals Progress – MCP-based via HTTP service + JWT bearer, which invokes Skills and provides resources such as reference documents or calculation APIs hosted on the MCP Server
  • Emergency Fund / Debt Payoff – MCP-based via HTTP service + JWT bearer, demonstrating the Archive Mode example where Skills are shared for the Agent to pull down and run locally
For Archive-based Skills, they're downloaded from the MCP Server
, but there's a catch: clearing out the old Skills is still a limitation

Here are the main screens:

  • Chat Interface – Features real-time streaming, Markdown rendering via Markdig, and Approve/Reject prompts handled by the Harness agent for run_skill_script executions.
  • Agent Activity Log – Displays real-time activity chips tracking Skill executions.
  • Standard CRUD Modules – For managing Receipts, Goals, Transactions, and Budgets.
  • User Management via ASP.NET Core Identity – This includes per-user configuration settings. Since constantly prompting users to click "Approve" can get annoying, I added AutoApproveWrites and AutoApproveExecuteScript flags for each user. These flags automatically bypass the approval prompts before load_skill and read_skill_resource are executed.

Security Best Practices

  • Review Before Use: Skills often contain executable scripts, so always review the code before running them.
  • Trust the Source: Only fetch Skills from trusted sources. Beware of "typosquatted" Skill names (e.g., rnicrosoft vs. microsoft).
  • Pin Versions & Verify Integrity: Always pin specific versions and verify the integrity of Skills fetched from an MCP Server.
  • Sandboxing: Since Skills can execute scripts, they should run in an isolated environment. Combine this with the Harness Agent's approval mechanism for an extra layer of safety.
  • Apply Least Privilege: Grant only the minimum permissions necessary for a Skill to function.
  • Treat Skill Content as Untrusted Input: Always sanitize and validate any data or parameters passed into a Skill.
  • Keep Secrets Out of Skills: Never hardcode secrets within a Skill. If a Skill needs to handle sensitive data, thoroughly review its internal scripts to ensure it isn't exfiltrating data.
  • Never Embed Credentials: Inject credentials at runtime instead (e.g., passing a per-user JWT to the MCP server). You can see an example of this in the provided code.
  • Approval Gates: Always enforce Human-in-the-Loop (HITL) for high-risk actions.
  • Audit and Logging: Maintain strict logs of exactly what Skills and resources are loaded and executed.

Closing Thoughts

Finally, thank you to everyone who attended the session! If you missed any details during the presentation, feel free to refer back to this blog.

As for the Sample Apps, if time permits, I plan to write a follow-up blog post to dive deeper into all the available resources and code. Stay tuned!

Reference