🧭 Evidence before explanation

Build an Evidence-First Incident Triage Agent with Microsoft Agent Framework

Create a tool-using C# agent that retrieves deterministic operational evidence, keeps runbook guidance separate from facts, streams its assessment, and reuses the same AgentSession for a follow-up—without pretending that correlation proves root cause.

What You Will Build

This tutorial builds an incident-triage agent around a small synthetic outage. The interesting part is not the outage itself; it is the boundary between what the tools actually return and what the model is allowed to infer.

Deterministic evidence

Service and deployment records are embedded JSON, so every reader begins with the same incident facts.

Three read-only tools

The model can retrieve health evidence, deployment evidence, and runbook guidance—but it cannot restart or roll back anything.

Probabilistic interpretation

The model may reason over evidence, but missing fields remain missing and causal claims must stay unproven unless evidence supports them.

GitHub companion sample: the complete verified source is available at dotnet-ai/agent-framework-incident-triage. The repository is deliberately smaller than this tutorial and contains the exact sample used for the verification record.

Why Tool Access Is Not Enough

A common mental model is: “Once the agent has tools, hallucination is solved.” That is too optimistic. A model can retrieve correct records and still invent a plausible bridge between them.

The missing timestamp is the most useful teaching detail in this sample. The deployment tool returns the identifier deploy-1839 but never returns a completion time for it. A grounded agent should preserve that gap instead of quietly completing the story.

Key idea: retrieval grounding protects the source facts; it does not automatically protect every sentence the model generates around those facts. Good agent design needs both controlled tools and explicit reasoning boundaries.

Prerequisites and Verified Baseline

This page is tied to a concrete, tested baseline rather than a floating “latest” package claim.

ComponentVerified value
Target frameworknet10.0
.NET SDK baseline10.0.401
Solution format.slnx
Direct Agent Framework packageMicrosoft.Agents.AI.OpenAI 1.22.0
Resolved Microsoft.Agents.AI1.22.0
Resolved Microsoft.Extensions.AI10.10.0
Resolved OpenAI package2.13.0
Deterministic tests14 passed, 0 failed
Live verification provider/modelOpenRouter / openai/gpt-4o-mini
global.json
{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "latestFeature",
    "allowPrerelease": false
  }
}
src/IncidentTriageAgent/IncidentTriageAgent.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.22.0" />
  </ItemGroup>

<ItemGroup>
  <EmbeddedResource Include="Data/services.json" />
  <EmbeddedResource Include="Data/deployments.json" />
  <EmbeddedResource Include="Data/runbooks.json" />
</ItemGroup>

</Project>
Why pin this tutorial? Agent APIs evolve. A tutorial is easier to trust when it says what was actually built and tested. If you use a newer package later, re-run the sample and treat compiler feedback as authoritative for that version.

Understand the Agent Framework Pieces

Before the code, separate the framework concepts. Microsoft Agent Framework provides a common AIAgent abstraction, and its chat-client-backed implementation works through IChatClient. Conversation state is represented by AgentSession, while ordinary C# methods can be wrapped as model-callable functions with AIFunctionFactory.Create.

IChatClient

The provider boundary. The rest of this sample can depend on the abstraction instead of knowing how OpenRouter was configured.

AIAgent

The primary agent abstraction used by application code.

ChatClientAgent

The concrete runtime agent created from an IChatClient in this sample.

AgentSession

Conversation state reused between the first incident assessment and the follow-up.

AIFunction

A model-callable function definition. Here it wraps deterministic read-only methods.

RunStreamingAsync

Streams AgentResponseUpdate items as the model produces its answer.

Current API note: older examples on the web may use different conversation abstractions. This verified sample uses AgentSession and CreateSessionAsync(). When examples disagree, check the package version before copying API names.

Project Architecture

The architecture separates provider configuration, deterministic sources, tool adapters, and model reasoning. That separation is more important than the number of classes.

Observed evidence and runbook guidance intentionally have separate stores. That makes it harder to accidentally turn “check the connection pool” into “the connection pool was exhausted.”

1

Create Deterministic Evidence

Start with facts that exist independently of the model. EvidenceItem is deliberately boring: it stores an ID, type, service, timestamp, and a dictionary of observed facts.

Evidence/EvidenceItem.cs
namespace IncidentTriageAgent.Evidence;

/// <summary>
/// Represents one deterministic piece of operational evidence.
///
/// Evidence items contain observed facts only.
/// They must not contain model-generated conclusions or inferred facts.
/// </summary>
public sealed record EvidenceItem(
    string Id,
    string Type,
    string Service,
    DateTimeOffset ObservedAt,
    IReadOnlyDictionary<string, string> Facts)
{
    /// <summary>
    /// Returns a fact value when the field exists.
    /// Missing fields remain missing rather than being inferred.
    /// </summary>
    public string? GetFact(string key)
    {
        return Facts.TryGetValue(key, out string? value)
            ? value
            : null;
    }
}

The store loads fixed JSON embedded into the assembly. That gives tests and readers a stable incident instead of requiring access to a monitoring account.

Evidence/EvidenceStore.cs
using System.Reflection;
using System.Text.Json;

namespace IncidentTriageAgent.Evidence;

/// <summary>
/// Loads deterministic operational evidence from the sample's
/// embedded JSON data files.
///
/// The evidence is intentionally fixed so that tool behavior and
/// automated tests remain repeatable without a live monitoring system.
/// </summary>
public sealed class EvidenceStore
{
    private static readonly JsonSerializerOptions JsonOptions =
        new()
        {
            PropertyNameCaseInsensitive = true
        };

    private readonly IReadOnlyList<EvidenceItem> _items;


    public EvidenceStore()
    {
        List<EvidenceItem> items = [];

        items.AddRange(
            LoadEvidence("services.json"));

        items.AddRange(
            LoadEvidence("deployments.json"));

        _items = items;
    }


    /// <summary>
    /// Returns the newest evidence item of the requested type
    /// for the requested service.
    /// </summary>
    public EvidenceItem? GetLatest(
        string serviceName,
        string evidenceType)
    {
        if (string.IsNullOrWhiteSpace(serviceName) ||
            string.IsNullOrWhiteSpace(evidenceType))
        {
            return null;
        }

        string service =
            Normalize(serviceName);

        string type =
            evidenceType
                .Trim()
                .ToLowerInvariant();

        return _items
            .Where(item =>
                item.Service.Equals(
                    service,
                    StringComparison.OrdinalIgnoreCase) &&
                item.Type.Equals(
                    type,
                    StringComparison.OrdinalIgnoreCase))
            .OrderByDescending(
                item => item.ObservedAt)
            .FirstOrDefault();
    }


    /// <summary>
    /// Returns all evidence currently available for a service.
    /// </summary>
    public IReadOnlyList<EvidenceItem> GetForService(
        string serviceName)
    {
        if (string.IsNullOrWhiteSpace(serviceName))
        {
            return [];
        }

        string service =
            Normalize(serviceName);

        return _items
            .Where(item =>
                item.Service.Equals(
                    service,
                    StringComparison.OrdinalIgnoreCase))
            .OrderBy(
                item => item.ObservedAt)
            .ToList();
    }


    /// <summary>
    /// Loads one embedded JSON evidence file.
    ///
    /// Resource discovery uses the filename suffix rather than
    /// assuming a particular root namespace.
    /// </summary>
    private static IReadOnlyList<EvidenceItem> LoadEvidence(
        string fileName)
    {
        Assembly assembly =
            typeof(EvidenceStore).Assembly;

        string expectedSuffix =
            $".Data.{fileName}";

        string? resourceName =
            assembly
                .GetManifestResourceNames()
                .SingleOrDefault(name =>
                    name.EndsWith(
                        expectedSuffix,
                        StringComparison.OrdinalIgnoreCase));

        if (resourceName is null)
        {
            throw new InvalidOperationException(
                $"Embedded evidence file '{fileName}' was not found.");
        }

        using Stream stream =
            assembly.GetManifestResourceStream(
                resourceName)
            ?? throw new InvalidOperationException(
                $"Embedded evidence file '{fileName}' could not be opened.");

        List<EvidenceItem>? evidence =
            JsonSerializer.Deserialize<List<EvidenceItem>>(
                stream,
                JsonOptions);

        return evidence
            ?? throw new InvalidOperationException(
                $"Embedded evidence file '{fileName}' contained no valid data.");
    }


    private static string Normalize(
        string serviceName)
    {
        return serviceName
            .Trim()
            .ToLowerInvariant();
    }
}

Service-health evidence

Data/services.json
[
  {
    "Id": "E-101",
    "Type": "service_health",
    "Service": "checkout-api",
    "ObservedAt": "2026-09-24T14:12:00Z",
    "Facts": {
      "status": "degraded",
      "error_rate": "18.7%",
      "error_spike_started": "2026-09-24T14:09:00Z",
      "database_pool_utilization": "96%"
    }
  },
  {
    "Id": "E-102",
    "Type": "service_health",
    "Service": "catalog-api",
    "ObservedAt": "2026-09-24T14:12:00Z",
    "Facts": {
      "status": "healthy",
      "error_rate": "0.2%",
      "database_pool_utilization": "34%"
    }
  }
]

Deployment evidence

Data/deployments.json
[
  {
    "Id": "E-201",
    "Type": "deployment",
    "Service": "checkout-api",
    "ObservedAt": "2026-09-24T14:02:00Z",
    "Facts": {
      "deployment": "deploy-1842",
      "deployment_status": "succeeded",
      "previous_deployment": "deploy-1839",
      "change_summary": "pricing-rule refresh and structured logging update",
      "rollback_performed": "false"
    }
  },
  {
    "Id": "E-202",
    "Type": "deployment",
    "Service": "catalog-api",
    "ObservedAt": "2026-09-23T19:40:00Z",
    "Facts": {
      "deployment": "deploy-991",
      "deployment_status": "succeeded",
      "rollback_performed": "false"
    }
  }
]
Look at E-201 carefully. It contains previous_deployment=deploy-1839, but there is no previous_deployment_completed_at. That missing field is intentional. The sample uses it as a regression test against plausible fabrication.
2

Keep Guidance Separate from Evidence

A runbook tells engineers what to inspect. It does not prove that any of those conditions occurred. Give guidance its own type and store so that the distinction exists in code before the prompt ever sees it.

Data/runbooks.json
[
  {
    "Id": "RB-CHECKOUT-03",
    "Service": "checkout-api",
    "Title": "Checkout API elevated 5xx response",
    "RecommendedChecks": [
      "Compare error-rate onset with recent deployments.",
      "Inspect database connection-pool utilization.",
      "Check database connection counts before and after deployment.",
      "Review traces for timeout or connection-acquisition failures.",
      "Compare configuration with the previous known-good release."
    ],
    "Caution": "These checks are diagnostic guidance. Their presence in this runbook does not establish root cause."
  },
  {
    "Id": "RB-CATALOG-02",
    "Service": "catalog-api",
    "Title": "Catalog API degradation",
    "RecommendedChecks": [
      "Inspect downstream dependency latency.",
      "Review cache hit rate.",
      "Check database query duration."
    ],
    "Caution": "These checks are diagnostic guidance. Their presence in this runbook does not establish root cause."
  }
]

The same separation appears in the tool output through information_type=runbook_guidance and the caution text that explicitly says the checks do not establish root cause.

Guidance/RunbookStore.cs
using System.Reflection;
using System.Text.Json;

namespace IncidentTriageAgent.Guidance;

/// <summary>
/// Loads deterministic runbook guidance from the
/// sample's embedded JSON data file.
/// </summary>
public sealed class RunbookStore
{
    private static readonly JsonSerializerOptions JsonOptions =
        new()
        {
            PropertyNameCaseInsensitive = true
        };

    private readonly IReadOnlyList<RunbookItem> _runbooks;


    public RunbookStore()
    {
        _runbooks =
            LoadRunbooks("runbooks.json");
    }


    /// <summary>
    /// Returns the runbook for the requested service.
    /// </summary>
    public RunbookItem? GetForService(
        string serviceName)
    {
        if (string.IsNullOrWhiteSpace(serviceName))
        {
            return null;
        }

        string service =
            serviceName
                .Trim()
                .ToLowerInvariant();

        return _runbooks
            .FirstOrDefault(runbook =>
                runbook.Service.Equals(
                    service,
                    StringComparison.OrdinalIgnoreCase));
    }


    private static IReadOnlyList<RunbookItem> LoadRunbooks(
        string fileName)
    {
        Assembly assembly =
            typeof(RunbookStore).Assembly;

        string expectedSuffix =
            $".Data.{fileName}";

        string? resourceName =
            assembly
                .GetManifestResourceNames()
                .SingleOrDefault(name =>
                    name.EndsWith(
                        expectedSuffix,
                        StringComparison.OrdinalIgnoreCase));

        if (resourceName is null)
        {
            throw new InvalidOperationException(
                $"Embedded runbook file '{fileName}' was not found.");
        }

        using Stream stream =
            assembly.GetManifestResourceStream(
                resourceName)
            ?? throw new InvalidOperationException(
                $"Embedded runbook file '{fileName}' could not be opened.");

        List<RunbookItem>? runbooks =
            JsonSerializer.Deserialize<List<RunbookItem>>(
                stream,
                JsonOptions);

        return runbooks
            ?? throw new InvalidOperationException(
                $"Embedded runbook file '{fileName}' contained no valid data.");
    }
}
3

Expose Read-Only Function Tools

Microsoft Agent Framework can expose an ordinary C# method as an AIFunction. Each tool in this sample is a thin adapter over a deterministic store.

Service health

Tools/ServiceHealthTool.cs
using System.ComponentModel;
using IncidentTriageAgent.Evidence;
using Microsoft.Extensions.AI;

namespace IncidentTriageAgent.Tools;

/// <summary>
/// Read-only tool that exposes observed service-health evidence.
/// </summary>
public sealed class ServiceHealthTool
{
    private readonly EvidenceStore _store;

    public ServiceHealthTool(EvidenceStore store)
    {
        _store = store;
    }


    /// <summary>
    /// Creates the Agent Framework function exposed to the model.
    /// </summary>
    public AIFunction CreateFunction()
    {
        return AIFunctionFactory.Create(
            GetServiceHealth,
            name: "get_service_health",
            description:
                "Returns observed read-only operational health evidence " +
                "for a named service.");
    }


    [Description(
        "Returns observed operational health evidence for a service.")]
    public string GetServiceHealth(
        [Description("Service name, for example checkout-api.")]
        string serviceName)
    {
        EvidenceItem? evidence =
            _store.GetLatest(
                serviceName,
                "service_health");

        if (evidence is null)
        {
            return
                $"NO-EVIDENCE | No service health record exists for '{serviceName}'.";
        }

        return $"""
            {evidence.Id}
            evidence_type={evidence.Type}
            service={evidence.Service}
            observed_at={evidence.ObservedAt:O}
            status={evidence.GetFact("status")}
            error_rate={evidence.GetFact("error_rate")}
            error_spike_started={evidence.GetFact("error_spike_started")}
            database_pool_utilization={evidence.GetFact("database_pool_utilization")}
            """;
    }
}

Recent deployment

Tools/DeploymentTool.cs
using System.ComponentModel;
using IncidentTriageAgent.Evidence;
using Microsoft.Extensions.AI;

namespace IncidentTriageAgent.Tools;

/// <summary>
/// Read-only tool that exposes observed deployment evidence.
/// </summary>
public sealed class DeploymentTool
{
    private readonly EvidenceStore _store;

    public DeploymentTool(EvidenceStore store)
    {
        _store = store;
    }


    /// <summary>
    /// Creates the Agent Framework function exposed to the model.
    /// </summary>
    public AIFunction CreateFunction()
    {
        return AIFunctionFactory.Create(
            GetRecentDeployment,
            name: "get_recent_deployment",
            description:
                "Returns observed read-only deployment evidence " +
                "for a named service.");
    }


    [Description(
        "Returns observed deployment evidence for a service.")]
    public string GetRecentDeployment(
        [Description("Service name, for example checkout-api.")]
        string serviceName)
    {
        EvidenceItem? evidence =
            _store.GetLatest(
                serviceName,
                "deployment");

        if (evidence is null)
        {
            return
                $"NO-EVIDENCE | No deployment record exists for '{serviceName}'.";
        }

        return $"""
            {evidence.Id}
            evidence_type={evidence.Type}
            service={evidence.Service}
            deployment={evidence.GetFact("deployment")}
            deployment_status={evidence.GetFact("deployment_status")}
            completed_at={evidence.ObservedAt:O}
            previous_deployment={evidence.GetFact("previous_deployment")}
            change_summary={evidence.GetFact("change_summary")}
            rollback_performed={evidence.GetFact("rollback_performed")}
            """;
    }
}

Runbook guidance

Tools/RunbookTool.cs
using System.ComponentModel;
using IncidentTriageAgent.Guidance;
using Microsoft.Extensions.AI;

namespace IncidentTriageAgent.Tools;

/// <summary>
/// Read-only tool that exposes operational reference guidance.
///
/// Runbook content is guidance, not observed incident evidence.
/// </summary>
public sealed class RunbookTool
{
    private readonly RunbookStore _store;

    public RunbookTool(
        RunbookStore store)
    {
        _store = store;
    }


    public AIFunction CreateFunction()
    {
        return AIFunctionFactory.Create(
            GetRunbook,
            name: "get_runbook",
            description:
                "Returns read-only operational runbook guidance for a named service. " +
                "Runbook guidance describes recommended investigation steps; " +
                "it is not evidence that a particular failure actually occurred.");
    }


    [Description(
        "Returns operational runbook guidance for a service. " +
        "Runbook content is reference guidance, not observed incident evidence.")]
    public string GetRunbook(
        [Description("Service name, for example checkout-api.")]
        string serviceName)
    {
        RunbookItem? runbook =
            _store.GetForService(serviceName);

        if (runbook is null)
        {
            return
                $"NO-GUIDANCE | No runbook exists for '{serviceName}'.";
        }

        List<string> lines =
        [
            runbook.Id,
            "information_type=runbook_guidance",
            $"service={runbook.Service}",
            $"title={runbook.Title}",
            "",
            "recommended_checks:"
        ];

        for (int i = 0;
             i < runbook.RecommendedChecks.Length;
             i++)
        {
            lines.Add(
                $"{i + 1}. {runbook.RecommendedChecks[i]}");
        }

        lines.Add("");
        lines.Add("caution:");
        lines.Add(runbook.Caution);

        return string.Join(
            Environment.NewLine,
            lines);
    }
}
Thin tools are easier to trust. These functions do not contain model reasoning and do not perform writes. They receive a service name, query a known store, and return text that preserves IDs and missing fields.
4

Build the Evidence-First Agent

The factory knows about the agent policy and tool composition, but it does not know how the model provider was configured. That provider-neutral boundary is the reason it receives IChatClient.

Agents/TriageAgentFactory.cs — composition
using IncidentTriageAgent.Evidence;
using IncidentTriageAgent.Guidance;
using IncidentTriageAgent.Tools;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;

namespace IncidentTriageAgent.Agents;

public sealed class TriageAgentFactory
{
    private readonly EvidenceStore _evidenceStore;
    private readonly RunbookStore _runbookStore;

    public TriageAgentFactory(
        EvidenceStore evidenceStore,
        RunbookStore runbookStore)
    {
        _evidenceStore = evidenceStore;
        _runbookStore = runbookStore;
    }

    public AIAgent Create(IChatClient chatClient)
    {
        ArgumentNullException.ThrowIfNull(chatClient);

        ServiceHealthTool healthTool = new(_evidenceStore);
        DeploymentTool deploymentTool = new(_evidenceStore);
        RunbookTool runbookTool = new(_runbookStore);

        List<AITool> tools =
        [
            healthTool.CreateFunction(),
            deploymentTool.CreateFunction(),
            runbookTool.CreateFunction()
        ];

        return chatClient.AsAIAgent(
            name: "IncidentTriageAgent",
            instructions: AgentInstructions,
            tools: tools);
    }

    // AgentInstructions contains the evidence-first rules shown below.
}

The system instructions then make the reasoning boundary explicit. These instructions do not mathematically guarantee perfect model behavior, but they make the intended contract inspectable and testable around the deterministic layers.

TriageAgentFactory.cs — evidence-first instruction excerpt
You are an evidence-first incident-triage agent.

1. Retrieve operational evidence before making factual claims.
2. Operational facts may come only from retrieved evidence.
3. Never invent or fill in a missing field.
4. Distinguish observed evidence, inference, guidance, unknowns,
   and unproven conclusions.
5. Temporal correlation is not proof of causation.
6. A high metric value may justify investigation, but does not
   by itself establish root cause.
7. Cite evidence IDs for operational facts.
8. Cite runbook IDs for guidance.
9. Say explicitly when evidence is insufficient.
10. You have read-only tools; never claim a write action occurred.
5

Configure the Model Provider at the Edge

The live verification used OpenRouter's OpenAI-compatible endpoint, but that provider-specific configuration stays in Program.cs. Once an IChatClient exists, the agent factory no longer needs to know which endpoint or model produced it.

Program.cs — provider boundary
// -------------------------------------------------------------------
// Provider
// -------------------------------------------------------------------
//
// Provider-specific configuration stops here.
//
// The rest of the sample works with IChatClient.
// -------------------------------------------------------------------

OpenAIClientOptions clientOptions =
    new()
    {
        Endpoint =
            new Uri(
                "https://openrouter.ai/api/v1")
    };

OpenAIClient openAiClient =
    new(
        new ApiKeyCredential(apiKey),
        clientOptions);

IChatClient chatClient =
    openAiClient
        .GetChatClient(modelName)
        .AsIChatClient();
The conversion order matters. The concrete OpenAI SDK chat client is first converted with AsIChatClient(). The generic AsAIAgent() extension is then used by TriageAgentFactory against the IChatClient abstraction.

The API key also stays outside source control. The application reads OPENROUTER_API_KEY from the environment and exits safely when it is absent.

6

Create an AgentSession and Stream Responses

Create one session for the incident, then reuse it for the follow-up. The session gives the second prompt conversational context without adding a separate persistent-memory subsystem.

Program.cs — agent and session
AIAgent agent =
    agentFactory.Create(
        chatClient);

AgentSession session =
    await agent.CreateSessionAsync();

The helper consumes AgentResponseUpdate values as they arrive:

Program.cs — streaming helper
static async Task StreamAgentResponseAsync(
    AIAgent agent,
    AgentSession session,
    string prompt)
{
    await foreach (
        AgentResponseUpdate update
        in agent.RunStreamingAsync(
            prompt,
            session))
    {
        Console.Write(update);
    }
}
Session continuity is not long-term memory. This tutorial uses the same in-process conversation session for two turns. Persistent memory, cross-session storage, and context providers are separate topics.
7

Build and Test Without an AI API Key

The deterministic layer should be verifiable in CI without paying for a model call or depending on a provider outage.

PowerShell / Bash
dotnet restore AgentFrameworkIncidentTriage.slnx
dotnet build AgentFrameworkIncidentTriage.slnx --configuration Release --no-restore
dotnet test AgentFrameworkIncidentTriage.slnx --configuration Release --no-build

The verified result was:

PASS: 14 total tests, 14 passed, 0 failed, 0 skipped. The GitHub Actions job also passed on the repository's Ubuntu runner without an AI provider key.

Two tests capture the design intent directly:

Tests — missing field and guidance boundary
[Fact]
public void DeploymentEvidence_DoesNotInventMissingTimestamp()
{
    EvidenceStore store = new();

    EvidenceItem? evidence =
        store.GetLatest(
            "checkout-api",
            "deployment");

    Assert.NotNull(evidence);

    Assert.Equal(
        "deploy-1839",
        evidence.GetFact("previous_deployment"));

    Assert.Null(
        evidence.GetFact(
            "previous_deployment_completed_at"));
}

[Fact]
public void RunbookTool_ReturnsGuidanceNotEvidence()
{
    RunbookStore store = new();
    RunbookTool tool = new(store);

    string result =
        tool.GetRunbook("checkout-api");

    Assert.Contains("RB-CHECKOUT-03", result);
    Assert.Contains("information_type=runbook_guidance", result);
    Assert.Contains("does not establish root cause", result);
}

The test suite also checks service-name normalization, unknown services, evidence IDs, tool output, and successful creation of all three AIFunction instances.

8

Run the Live Agent

Only the live path needs a provider key. Do not place the key in Program.cs, JSON, tests, or the repository.

PowerShell

PowerShell
$env:OPENROUTER_API_KEY="YOUR_TEMPORARY_KEY"
dotnet run --project src/IncidentTriageAgent

# Clear it when finished
$env:OPENROUTER_API_KEY=$null

Bash

Bash
export OPENROUTER_API_KEY="YOUR_TEMPORARY_KEY"
dotnet run --project src/IncidentTriageAgent

unset OPENROUTER_API_KEY
No automatic .env loading: this sample reads process environment variables directly. Creating a .env file by itself will not configure the application unless you add a loader.

Do not treat exact model wording as a test oracle. The useful verification target is the information boundary: evidence IDs should be preserved, missing fields should remain unknown, guidance should remain guidance, and chronology should not become causation.

What the Verified Run Proved

The live run produced an evidence-structured assessment and then answered a same-session follow-up. The important part is the reasoning boundary, not the exact prose.

14:02 UTC
E-201: deploy-1842 completed successfully.
14:09 UTC
E-101: the checkout-api error spike began.
14:12 UTC
E-101: service observed degraded, error rate 18.7%, database pool utilization 96%.
QuestionVerified behavior
Was deploy-1839 recognized?Yes, as the previous deployment.
Was a timestamp invented for deploy-1839?No. The timestamp stayed UNKNOWN.
Was RB-CHECKOUT-03 used?Yes, as diagnostic guidance rather than incident evidence.
Was 96% database pool utilization treated as proof of root cause?No. It was treated as an investigation signal.
Did the follow-up claim deploy-1842 caused the outage?No. The response explicitly separated correlation from causation.
One useful imperfection: a sentence in the live inference section was slightly stronger than an engineer might prefer, even though the final answer correctly rejected causation. That is precisely why the sample distinguishes deterministic evidence from probabilistic interpretation instead of claiming prompts guarantee perfect reasoning.

The Evidence-First Agent Contract

The project documents a small application-level contract for agents that reason over operational evidence. It is not an official Microsoft Agent Framework specification.

1
Retrieve before asserting. Current operational facts should come from an appropriate evidence source.
2
Never fill a missing evidence field. If the source lacks a timestamp, output UNKNOWN instead of a plausible time.
3
Separate evidence from inference. “Pool utilization is 96%” and “resource pressure may be involved” do not have the same evidentiary status.
4
Correlation is not causation. A deployment seven minutes before an error spike deserves investigation; the chronology alone does not prove cause.
5
Guidance is not evidence. A runbook can recommend checking traces without proving that a timeout occurred.

These rules are intentionally narrow. They do not solve authentication, prompt injection, malicious tools, persistent memory, compliance, or model evaluation. They solve one smaller problem: keeping retrieved facts, model interpretation, guidance, unknowns, and unproven claims visibly separate.

AI Agent Tool Risk Ladder

The second reference concept classifies tools by consequence, not by how easy they are to expose to the model. Again, this is an application-level design model, not an official Microsoft classification.

LevelCapabilityExampleThis sample?
0Referenceget_runbookYes
1Observationalget_service_healthYes
2Reversible writeCreate an incident noteNo
3Operational actionRestart a serviceNo
4Destructive / high impactDelete production dataNo

Microsoft's current ChatClientAgent source also warns that model-selected function arguments should be treated as untrusted input and that tools with side effects, sensitive access, or irreversible consequences deserve explicit approval controls. That aligns naturally with the sample's decision to stop at Levels 0 and 1.

Production Considerations

The sample is intentionally reproducible, not production-complete. The architecture can grow without discarding its central boundary.

  • Replace stores, not reasoning categories. Live telemetry can replace JSON while still preserving evidence IDs and missing values.
  • Authenticate and authorize every real data source. A read-only tool can still expose sensitive operational data.
  • Treat tool arguments as untrusted. Validate service names, identifiers, scopes, and query ranges outside the model.
  • Keep side effects behind stronger controls. Restart, rollback, deployment, deletion, customer messaging, and credential operations need policy and approval boundaries.
  • Audit both retrieval and action. You should be able to reconstruct which source record supported a claim and which actor initiated an operation.
  • Plan for prompt injection in external content. Logs, tickets, runbooks, and retrieved documents are data; do not blindly treat embedded instructions as trusted commands.
  • Evaluate model behavior separately from deterministic tests. Tool output can be asserted exactly; natural-language interpretation usually needs scenario-based evaluation rather than string equality.
Deliberately excluded from Agent Framework #1: MCP, RAG/vector search, persistent long-term memory, multi-agent workflows, automatic remediation, human approval workflows, durable orchestration, A2A, AG-UI, and production hosting. Each deserves its own focused tutorial.

Common Problems and What They Mean

AsAIAgent is not found on the OpenAI SDK chat client

Do not assume the provider-specific concrete client is already the Agent Framework abstraction. In the verified sample the working path is:

401 Unauthorized when using an OpenRouter key

An OpenRouter key sent to the default OpenAI endpoint will fail. Configure the OpenAI-compatible endpoint as https://openrouter.ai/api/v1 before creating the chat client.

The application exits before creating the agent

That is expected when OPENROUTER_API_KEY is absent. The sample is designed to build and test without a secret.

An older example uses AgentThread

Check the package version. This verified baseline uses AgentSession. Agent APIs have evolved, so copying code without version context is risky.

Your live output does not match the captured sample word for word

That is normal. The deterministic records are stable; model phrasing is not. Check whether the same evidence boundaries hold instead of comparing strings.

The model still makes an inference you dislike

Prompts are not formal proofs. Strengthen deterministic checks where possible, add evaluation scenarios, reduce tool ambiguity, and require human review for consequential decisions.

Frequently Asked Questions

What is Microsoft Agent Framework?

It provides agent and workflow abstractions for building AI applications. In this tutorial the important .NET pieces are AIAgent, ChatClientAgent, IChatClient, AgentSession, function tools, and streaming.

What is AgentSession used for?

It represents conversation state for an agent. This sample creates one session for the incident assessment and reuses it for the follow-up question about whether deploy-1842 caused the outage.

Does CI need an AI API key?

No. The GitHub Actions workflow restores, builds, and runs all 14 deterministic tests without a live model provider key.

Can the agent restart services or roll back deployments?

No. All three tools are read-only. The agent may recommend an investigation step, but it has no tool capable of performing remediation.

Why use embedded JSON instead of Azure Monitor or another live system?

Fixed data makes the tutorial reproducible. Every reader gets the same timestamps, error rate, deployment, missing field, and runbook. A production implementation can replace the stores with live adapters.

Is the Evidence-First Agent Contract official Microsoft guidance?

No. It is a design pattern created for this educational reference sample. The tutorial labels it as application-level guidance so it is not confused with a Microsoft specification.

Can I replace OpenRouter?

Yes, when your provider can be exposed through IChatClient. Keep provider configuration at the application edge and let the agent factory depend on the abstraction.

Official Microsoft References

The Microsoft Agent Framework API details discussed in this tutorial are based on official Microsoft documentation, samples, and source code.

  1. Microsoft Learn: Get started with Agent Framework
  2. Microsoft Learn: Add function tools
  3. Microsoft Learn: Using function tools with an agent
  4. Microsoft Agent Framework .NET samples
  5. Microsoft Agent Framework source: AIAgent
  6. Microsoft Agent Framework source: ChatClientAgent

Verified dotnet-guide.com Companion Sample

The complete implementation used for this tutorial is maintained separately in the dotnet-guide.com GitHub tutorial repository. It includes the application source, deterministic evidence, runbook guidance, automated tests, architecture notes, verification record, and captured sample run.

Independent educational sample: dotnet-guide.com is an independent educational publisher and is not affiliated with or endorsed by Microsoft. The Evidence-First Agent Contract and AI Agent Tool Risk Ladder are application-level patterns created for this tutorial. They are not Microsoft specifications or official Microsoft guidance.