<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lukas Walter </title>
    <description>The latest articles on DEV Community by Lukas Walter  (@lukaswalter).</description>
    <link>https://dev.to/lukaswalter</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3783973%2F8171c4c5-d69c-4059-b5d9-7b7af32a8962.png</url>
      <title>DEV Community: Lukas Walter </title>
      <link>https://dev.to/lukaswalter</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lukaswalter"/>
    <language>en</language>
    <item>
      <title>Trust Boundaries Around AI Features</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Fri, 14 Aug 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/trust-boundaries-around-ai-features-3c2i</link>
      <guid>https://dev.to/lukaswalter/trust-boundaries-around-ai-features-3c2i</guid>
      <description>&lt;p&gt;If an action requires approval, a prompt is never the control that enforces it.&lt;/p&gt;

&lt;p&gt;A prompt can tell the model to ask, "Should I close this ticket?" That helps the conversation. It does not guarantee that the model asks, that the user sees the exact operation, or that the arguments stay unchanged after confirmation. A deterministic execution boundary must verify mandatory approval before execution.&lt;/p&gt;

&lt;p&gt;I use the same rule for other AI output: the model may propose. The application decides whether that proposal becomes behavior.&lt;/p&gt;

&lt;p&gt;A trust boundary is where authority, security domain, data exposure, or an established trust property changes. Some boundaries protect an action. Others protect data as it moves to a model provider, log sink, browser, plugin, or another tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trust depends on the property you need
&lt;/h2&gt;

&lt;p&gt;"Trusted" and "untrusted" are too blunt on their own.&lt;/p&gt;

&lt;p&gt;An authenticated user has a verified identity, but their text is still untrusted input. An internal document may have known provenance while being stale or outside the current user's permissions. Model output can match a JSON schema and still request an action the caller cannot perform.&lt;/p&gt;

&lt;p&gt;For each value that crosses a boundary, write down which properties have been established:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identity and delegation: who initiated, approved, and executes the request, and under whose authority does each step run?&lt;/li&gt;
&lt;li&gt;Authorization: what may each identity access or change?&lt;/li&gt;
&lt;li&gt;Provenance and integrity: where did the data come from, and could it have changed outside the expected process?&lt;/li&gt;
&lt;li&gt;Freshness and state: is the data current enough, and does the target still satisfy the expected conditions?&lt;/li&gt;
&lt;li&gt;Validity: does the value satisfy its syntax and domain rules?&lt;/li&gt;
&lt;li&gt;Approval: did an authorized reviewer approve this exact operation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These properties do not substitute for one another. Authentication does not make input safe. Schema validation does not grant access. Approval does not freeze permissions or resource state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace the whole feature
&lt;/h2&gt;

&lt;p&gt;Consider a support assistant that can search customer records, summarize a ticket, draft a response, and request that a ticket be closed.&lt;/p&gt;

&lt;p&gt;The action path branches after policy classification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated support user
    -&amp;gt; application resolves identity and tenant
    -&amp;gt; trusted data-access path constrains retrieval
    -&amp;gt; selected content crosses to the model provider
    -&amp;gt; model proposes a response or action
    -&amp;gt; application parses and validates the proposal
    -&amp;gt; application loads the target inside the caller's tenant
    -&amp;gt; authorization evaluates caller and resource
    -&amp;gt; application policy classifies the canonical action

        Deny
            -&amp;gt; stop and record the decision

        Allow
            -&amp;gt; execution boundary re-checks current state, authorization, and policy
            -&amp;gt; executor atomically persists the action and Allow decision while claiming a durable Executing attempt

        RequireApproval
            -&amp;gt; approval service stores the canonical operation
            -&amp;gt; authorized reviewer approves or rejects that operation
            -&amp;gt; execution boundary re-checks current state, authorization, and policy
            -&amp;gt; executor atomically moves Approved to Executing and creates a durable attempt

    -&amp;gt; executor performs the effect
    -&amp;gt; executor records the execution outcome
    -&amp;gt; application applies destination-specific output handling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Logs and traces cross several parts of this path. They can capture prompts, retrieved documents, tool arguments, resource identifiers, approval details, and model responses. Treat telemetry as its own data boundary. Decide which fields may leave the application, redact sensitive values, and avoid turning full prompt capture into the default.&lt;/p&gt;

&lt;p&gt;The call to the model provider is another boundary. Send only the data the task needs. Application policy first determines the permitted routing set from the request, data classification, and security context. A model may then choose among non-security-sensitive options such as &lt;code&gt;fast&lt;/code&gt; or &lt;code&gt;reasoning&lt;/code&gt; within that set. Model output must not select arbitrary providers, deployments, regions, retention settings, or credentials.&lt;/p&gt;

&lt;p&gt;The model takes part in the flow, but it owns none of these security decisions. The same design applies to a chat UI, an agent framework, a background worker, or custom orchestration code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map the boundaries that change risk
&lt;/h2&gt;

&lt;p&gt;I pay closest attention to places where data gains authority because that is where a plausible string can turn into a real side effect. Data exposure matters just as much when confidential information crosses into a less trusted domain.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;th&gt;What remains untrusted or unresolved&lt;/th&gt;
&lt;th&gt;Application control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request to application&lt;/td&gt;
&lt;td&gt;Text, uploads, requested identifiers&lt;/td&gt;
&lt;td&gt;Authentication, request validation, size and rate limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application to retrieval&lt;/td&gt;
&lt;td&gt;Search text and model-selected identifiers&lt;/td&gt;
&lt;td&gt;Tenant and resource authorization in the retrieval or data-access path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieved content to model&lt;/td&gt;
&lt;td&gt;Documents, emails, pages, tool results&lt;/td&gt;
&lt;td&gt;Source labeling, separation from instructions, constrained tool access, downstream validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application to model provider&lt;/td&gt;
&lt;td&gt;Prompts and selected business data&lt;/td&gt;
&lt;td&gt;Data minimization, approved deployment and region, retention policy, scoped credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model output to application&lt;/td&gt;
&lt;td&gt;Text, structured output, tool arguments&lt;/td&gt;
&lt;td&gt;Parsing, allow lists, type-specific validation, controlled failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application to protected resource&lt;/td&gt;
&lt;td&gt;Requested action and resource ID&lt;/td&gt;
&lt;td&gt;Current-state lookup and resource-based authorization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval client to approval service&lt;/td&gt;
&lt;td&gt;Reviewer input and untrusted generated explanations&lt;/td&gt;
&lt;td&gt;Reviewer authentication and authorization, request integrity, safe rendering, replay protection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval service to executor&lt;/td&gt;
&lt;td&gt;Stored operation and approval artifact&lt;/td&gt;
&lt;td&gt;Durable state transitions, version checks, reauthorization, current policy, concurrency control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model output to client&lt;/td&gt;
&lt;td&gt;Generated Markdown, HTML, links, or code&lt;/td&gt;
&lt;td&gt;Output encoding, sanitization, content policy, data-loss checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application to telemetry&lt;/td&gt;
&lt;td&gt;Prompts, arguments, responses, decisions&lt;/td&gt;
&lt;td&gt;Data minimization, redaction, access control, retention limits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I would not label any one row "prompt injection defense." No filter can prove that retrieved text is harmless. The useful boundary limits what that text can influence and validates every privileged transition after the model has processed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep security context out of model arguments
&lt;/h2&gt;

&lt;p&gt;The model may propose an operation. It may also provide an identity or scope as target data when the operation needs it, such as asking to view configuration for a named tenant. It must never supply or override the authenticated identity, tenant scope, delegated authority, or authorization result under which the operation runs.&lt;/p&gt;

&lt;p&gt;Do not expose a tool contract like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CloseTicketAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;userIsAuthorized&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model controls every argument. The function cannot distinguish a real authorization result from a convincing boolean.&lt;/p&gt;

&lt;p&gt;Use authenticated application context for the trusted values. Normalize the proposal into a canonical action before policy evaluates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;ActionDisposition&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RequireApproval&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CloseTicketProposal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ResolutionSummary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;CloseTicketArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ResolutionSummary&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;RequesterId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ResourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;ResourceVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CloseTicketArguments&lt;/span&gt; &lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ApprovalRequirement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ReviewerPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;RiskClass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;AllowSelfApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TimeSpan&lt;/span&gt; &lt;span class="n"&gt;ValidFor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ActionDecision&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ActionDisposition&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ApprovalRequirement&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;policyVersionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Disposition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;disposition&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Approval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policyVersionId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ActionDisposition&lt;/span&gt; &lt;span class="n"&gt;Disposition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ApprovalRequirement&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Approval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt; &lt;span class="nf"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ProposedAction&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;policyVersionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ActionDisposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;policyVersionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt; &lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;policyVersionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ActionDisposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;policyVersionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt; &lt;span class="nf"&gt;RequireApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ProposedAction&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ApprovalRequirement&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;policyVersionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ActionDisposition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequireApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;decisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;policyVersionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary loads the ticket through a tenant-scoped path and checks resource authorization. Summary validation is deliberately small here. A real support system may restrict formatting, require a resolution code, or check for sensitive data before storage. The reviewer UI must still encode the summary as untrusted text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketActionBoundary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;TicketStore&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CurrentRequest&lt;/span&gt; &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IAuthorizationService&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TicketActionPolicy&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ticket-action-boundary:v1"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;EvaluateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CloseTicketProposal&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResolutionSummary&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Invalid proposal."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;SupportTicket&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Ticket not found."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;AuthorizationResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthorizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"CloseSupportTicket"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Action is not allowed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;Operation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"support-ticket.close"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;RequesterId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ResourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ResourceVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CloseTicketArguments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ASP.NET Core supports this resource-based check through &lt;code&gt;IAuthorizationService.AuthorizeAsync(user, resource, policyName)&lt;/code&gt;. The authorization handlers evaluating the policy receive the authenticated principal and the loaded ticket as the resource. The model sees neither an authorization flag nor a tenant selector.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ProposedAction&lt;/code&gt; is the application's canonical representation of the operation. It includes application-owned security context and execution preconditions. A denial before canonicalization has no action. Once an action has been canonicalized, a policy denial retains it so the audit record shows what was evaluated and rejected.&lt;/p&gt;

&lt;p&gt;Each deterministic decision point attaches its own stable source ID. &lt;code&gt;TicketActionBoundary&lt;/code&gt; identifies validation and authorization denials. &lt;code&gt;TicketActionPolicy&lt;/code&gt; identifies classification decisions and records its policy version separately. A caller cannot supply a stale policy version before the decision happens.&lt;/p&gt;

&lt;p&gt;In this sample, &lt;code&gt;CurrentRequest.UserId&lt;/code&gt; and &lt;code&gt;GetUserId()&lt;/code&gt; return the same application-owned canonical user ID. If an application compares external identities directly, it must include the issuer and relevant tenant or security domain with the subject value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make approval an application-policy decision
&lt;/h2&gt;

&lt;p&gt;Mandatory approval always belongs to application policy or another deterministic execution boundary. The prompt can ask for confirmation to make the interaction clearer. That conversational answer is not an approval artifact and must not bypass policy.&lt;/p&gt;

&lt;p&gt;The policy needs at least three outcomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketActionPolicy&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ticket-action-policy"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"support-ticket-close:v3"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt; &lt;span class="nf"&gt;Classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SupportTicket&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ProposedAction&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Resolved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"Only resolved tickets can be closed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEscalated&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
            &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Priority&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;TicketPriority&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Critical&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApprovalRequirement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;ReviewerPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ApproveSupportTicketClosure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;RiskClass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;AllowSelfApproval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;ValidFor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                &lt;span class="s"&gt;"Escalated or critical tickets require review."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ActionDecision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;DecisionSourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;PolicyVersionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example allows an authorized user to close an ordinary resolved ticket automatically. It requires review for a critical or escalated ticket and denies closure while work is unresolved.&lt;/p&gt;

&lt;p&gt;Real policies can also consider the environment, destination, amount, quantity, bulk scope, reversibility, and compliance impact. Read-only is not automatically low risk. Exporting every customer record or retrieving a secret may deserve a stricter policy than a reversible write.&lt;/p&gt;

&lt;p&gt;Do not require approval merely because a model selected a tool. Require it when the operation's disclosure, external, financial, destructive, privilege, or compliance impact exceeds the application's automatic-execution policy.&lt;/p&gt;

&lt;p&gt;If risk classification or policy lookup fails for a consequential action, stop. Do not fall back to automatic execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store the operation that was approved
&lt;/h2&gt;

&lt;p&gt;The approval service should persist the canonical &lt;code&gt;ProposedAction&lt;/code&gt;, policy decision, and &lt;code&gt;ApprovalRequirement&lt;/code&gt;. Another option is to persist one canonical representation with a consistency digest. The reviewer client receives an approval ID and display data. It does not reconstruct the operation for execution.&lt;/p&gt;

&lt;p&gt;Canonical serialization makes a digest reproducible. It does not make a plain cryptographic hash tamper-resistant. An attacker who can modify both the operation and its digest can simply recompute the hash. Use a plain hash only to detect accidental inconsistency inside an already protected store.&lt;/p&gt;

&lt;p&gt;If the value must protect integrity across a trust boundary, use an HMAC with a protected server-side key, a digital signature, or a digest stored in a separately protected trust domain. Compute it on the server from one documented serialization format. Property order, number and timestamp formats, Unicode normalization, null handling, and collection ordering must be deterministic.&lt;/p&gt;

&lt;p&gt;A useful approval record includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;approval ID and one-time nonce&lt;/li&gt;
&lt;li&gt;operation name and normalized arguments&lt;/li&gt;
&lt;li&gt;requester, tenant, environment, and security domain&lt;/li&gt;
&lt;li&gt;target resource and expected version or state&lt;/li&gt;
&lt;li&gt;decision source ID, policy version, and the typed approval requirement&lt;/li&gt;
&lt;li&gt;reviewer, decision, and the reviewer policy that was evaluated&lt;/li&gt;
&lt;li&gt;whether self-approval is allowed and any separation-of-duties rule&lt;/li&gt;
&lt;li&gt;creation, expiry, decision, and execution-state timestamps&lt;/li&gt;
&lt;li&gt;intended executor or delegated identity when relevant&lt;/li&gt;
&lt;li&gt;an idempotency or deduplication key for execution&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reviewer needs authorization too. Before accepting the decision, check whether this reviewer may approve this operation for its tenant, environment, resource, and risk class. For some operations, the requester must not approve their own proposal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ApprovalAuthorizationContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ProposedAction&lt;/span&gt; &lt;span class="n"&gt;Action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ApprovalRequirement&lt;/span&gt; &lt;span class="n"&gt;Requirement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;approvalContext&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApprovalAuthorizationContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AuthorizationResult&lt;/span&gt; &lt;span class="n"&gt;reviewerAccess&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AuthorizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approvalContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReviewerPolicy&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;reviewerAccess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Succeeded&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
    &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requirement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowSelfApproval&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
     &lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProposedAction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequesterId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserId&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ApprovalResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Denied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Reviewer is not authorized."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RecordDecisionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;pendingApproval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApprovalId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUserId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reviewer authorization may depend on current resource attributes that are not part of the canonical action, such as ownership, business unit, or classification. Load those attributes through the trusted resource path before evaluating the reviewer policy. Pass the resulting application-defined context to &lt;code&gt;AuthorizeAsync&lt;/code&gt;. Do not ask the reviewer client or model to supply it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RecordDecisionAsync&lt;/code&gt; must atomically move the record from &lt;code&gt;Pending&lt;/code&gt; to &lt;code&gt;Approved&lt;/code&gt; or &lt;code&gt;Rejected&lt;/code&gt;. It rejects expired, already decided, or otherwise invalid records. Use a transaction, row version, compare-and-swap update, or an equivalent concurrency mechanism so two reviewers cannot overwrite each other's decision. A check in the client or controller is not enough.&lt;/p&gt;

&lt;p&gt;An unauthorized submission is denied. It is not a rejection decision. Reserve &lt;code&gt;Rejected&lt;/code&gt; for an authorized reviewer who explicitly chooses not to approve the operation.&lt;/p&gt;

&lt;p&gt;The approval UI is a trust boundary. Render the model-generated summary as untrusted text and keep it separate from fields loaded by the application, such as the requester, ticket ID, current priority, environment, and expected effect. Generated markup must not impersonate authoritative UI or conceal part of the operation.&lt;/p&gt;

&lt;p&gt;Approval binds to the canonical operation, not to the persuasiveness of the explanation.&lt;/p&gt;

&lt;p&gt;For browser-based approval endpoints that use ambient credentials such as authentication cookies, apply the same request-integrity and anti-forgery protections as other state-changing endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-check everything that can change
&lt;/h2&gt;

&lt;p&gt;An automatic &lt;code&gt;Allow&lt;/code&gt; and an approved action enter the same execution boundary with different admission evidence. Neither path freezes authorization, policy, or resource state.&lt;/p&gt;

&lt;p&gt;The two paths should converge like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;automatically allowed action
    -&amp;gt; verify the Allow decision and canonical action
    -&amp;gt; reload the resource through the trusted tenant path
    -&amp;gt; re-authorize the requester
    -&amp;gt; compare resource version and expected state
    -&amp;gt; evaluate the current action policy
    -&amp;gt; atomically persist the action and current Allow decision while claiming a durable Executing attempt

approved action
    -&amp;gt; verify binding, expiry, evidence of decision-time reviewer authorization, and decision state
    -&amp;gt; reload the resource through the trusted tenant path
    -&amp;gt; re-authorize every identity whose current authority execution depends on
    -&amp;gt; compare resource version and expected state
    -&amp;gt; evaluate the current action policy and approval compatibility
    -&amp;gt; atomically move Approved to Executing and create a durable attempt

both paths
    -&amp;gt; perform the effect with idempotency or explicit duplicate handling
    -&amp;gt; move Executing to Completed, OutcomeUnknown, FailedRetryable, or FailedFinal
    -&amp;gt; record a result without leaking sensitive content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a resource change can invalidate execution, condition the claim on the version that was just checked. An optimistic concurrency condition is one option. Another is to prevent incompatible changes until the point of commitment. For an external effect, define that point and decide how to coordinate conflicting state changes.&lt;/p&gt;

&lt;p&gt;The durable-attempt and idempotency rules also apply to automatically allowed actions when their side effects require them. Approval changes the admission path, not the reliability requirements of execution.&lt;/p&gt;

&lt;p&gt;The automatic path does not need a separate durable &lt;code&gt;Allowed&lt;/code&gt; state. Its execution claim atomically persists the canonical action and current &lt;code&gt;Allow&lt;/code&gt; decision as admission evidence with the durable attempt.&lt;/p&gt;

&lt;p&gt;On the approval path, reviewer authorization is mandatory when the decision is made. Revalidating the reviewer during execution depends on policy. Some systems treat an approval as a valid historical act after the reviewer changes roles. Others require that authority to remain in place until execution. Record which rule your system uses.&lt;/p&gt;

&lt;p&gt;The automatic path must still receive &lt;code&gt;Allow&lt;/code&gt; when it re-evaluates policy. On the approval path, a current &lt;code&gt;Deny&lt;/code&gt; stops execution. A changed approval requirement invalidates the stored approval unless the current policy explicitly accepts approvals created under its recorded version. A later &lt;code&gt;Allow&lt;/code&gt; must not silently remove a control that the stored decision required.&lt;/p&gt;

&lt;p&gt;The transaction design depends on the side effect. A database update can often move the execution state and apply the change in one transaction with an optimistic concurrency token. An external email or payment cannot share that transaction.&lt;/p&gt;

&lt;p&gt;For external effects, use a durable execution record or outbox and an idempotency key when the provider supports one. Recovery must treat an abandoned or stale &lt;code&gt;Executing&lt;/code&gt; attempt as potentially applied. Reconcile the external result or rely on a safe idempotency mechanism before retrying. There is no generic exactly-once guarantee across a local database and an arbitrary external system.&lt;/p&gt;

&lt;p&gt;Approval-state transitions and idempotency solve different problems. The state machine stops an approval decision or execution claim from being replayed. Idempotency prevents duplicate effects for the same operation. The resource-version check detects changes covered by the resource's versioning policy. Explicit expected-state checks determine whether the security- or business-relevant preconditions still hold.&lt;/p&gt;

&lt;p&gt;Before initiating the effect, a failure in reauthorization, current-state loading, policy evaluation, durable-attempt persistence, or approval validation when required must prevent execution. After an external effect may have occurred, a persistence failure cannot turn that effect into a denial. Move the attempt to &lt;code&gt;OutcomeUnknown&lt;/code&gt; when possible, block blind retries, and reconcile the external result instead of marking the operation &lt;code&gt;Pending&lt;/code&gt; or &lt;code&gt;Rejected&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured output narrows the interface
&lt;/h2&gt;

&lt;p&gt;Structured output makes the boundary easier to inspect. It can reject missing properties, wrong types, and unsupported shapes before business logic runs.&lt;/p&gt;

&lt;p&gt;It cannot establish intent or permission. This is valid JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ticketId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ticket-from-another-tenant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resolutionSummary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Customer confirmed resolution."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not ask the model whether the ticket belongs to the current tenant. Load it through the trusted tenant path and evaluate authorization in application code.&lt;/p&gt;

&lt;p&gt;Different model-controlled values need different controls. Prefer predefined queries or query builders over arbitrary generated SQL.&lt;/p&gt;

&lt;p&gt;For URLs, restrict schemes and destinations. The network endpoint used for the connection must satisfy the DNS/IP policy. Do not validate one resolution and then allow the HTTP client to perform a second, unchecked resolution. Disable redirects or repeat the destination and DNS/IP checks for every redirect target. &lt;code&gt;HttpClientHandler.AllowAutoRedirect&lt;/code&gt; defaults to &lt;code&gt;true&lt;/code&gt;, so configure it intentionally for server-side fetches.&lt;/p&gt;

&lt;p&gt;Resolve file paths against an allowed root and handle traversal and links. Apply destination and data-loss policy to recipient addresses. Map configuration choices to allow-listed identifiers.&lt;/p&gt;

&lt;p&gt;There is no generic &lt;code&gt;ValidateModelOutput()&lt;/code&gt; call that makes all of those values safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieved content and tool results remain data
&lt;/h2&gt;

&lt;p&gt;Enforce tenant and resource authorization inside the trusted retrieval or data-access path before the caller or model can observe protected content or result metadata. A metadata filter is one option. Row-level security, ACL-aware repositories, separate indexes, per-tenant storage, and scoped service identities can enforce the same boundary.&lt;/p&gt;

&lt;p&gt;Remove unauthorized candidates inside the trusted authorization boundary. Their content, identifiers, scores, counts, and metadata must not reach an unauthorized component, model, client, cache, log, or trace.&lt;/p&gt;

&lt;p&gt;Keep that check as close to candidate generation as practical. A trusted application or retrieval service may create a broader internal candidate set and apply authoritative security trimming, but only when that broader access is legitimate and nothing unauthorized leaves the trusted path.&lt;/p&gt;

&lt;p&gt;A matching document can contain malicious instructions, inaccurate statements, or stale facts. Preserve source and version metadata, but do not mistake labels for enforcement. Retrieved content may inform a proposal. It does not authorize the next operation.&lt;/p&gt;

&lt;p&gt;Tool results have the same problem. An internal function may return text from an email, website, issue tracker, or database field that another user controls. The function call came from trusted code. Its returned text did not become a trusted instruction because of that.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/posts/indirect-prompt-injection/"&gt;Indirect prompt injection&lt;/a&gt; is one way this boundary fails. No prompt or content filter removes the risk completely, so downstream capabilities still need deterministic controls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Least privilege limits what a failure can do
&lt;/h2&gt;

&lt;p&gt;A document summarizer does not need permission to send email. A support assistant that drafts refunds may not need permission to issue them. Separate read and write capabilities where practical, and put sensitive operations behind narrow application services instead of exposing broad infrastructure clients to the model-facing layer.&lt;/p&gt;

&lt;p&gt;Prefer credentials and data-layer controls that make cross-tenant access impossible. Some systems still use a shared service identity with broad data access. When that is unavoidable, centralize tenant enforcement and test it so model-generated identifiers or queries cannot bypass the trusted path.&lt;/p&gt;

&lt;p&gt;Execution identity also needs an explicit design. The application can act with the requester's delegated authority, or it can use a service identity after checking requester authorization and approval. Those choices behave differently when permissions are revoked and when tokens expire. They also produce different audit trails. Record who requested, approved, and executed the operation rather than collapsing all three into "the user."&lt;/p&gt;

&lt;p&gt;OWASP describes excessive agency as excessive functionality, permissions, or autonomy. Reducing any one of them limits the damage when the model makes a bad decision or untrusted content influences it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review boundaries through failure cases
&lt;/h2&gt;

&lt;p&gt;An architecture diagram names components. A boundary review should say what happens when an assumption fails.&lt;/p&gt;

&lt;p&gt;For each transition, record:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which values cross it?&lt;/li&gt;
&lt;li&gt;Which property must be true first?&lt;/li&gt;
&lt;li&gt;Which deterministic component checks that property?&lt;/li&gt;
&lt;li&gt;What happens when the check fails?&lt;/li&gt;
&lt;li&gt;What evidence is logged without exposing protected content?&lt;/li&gt;
&lt;li&gt;What can the component still do if this check has a bug?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the ticket example:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Required property&lt;/th&gt;
&lt;th&gt;Enforcement&lt;/th&gt;
&lt;th&gt;Failure behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ticket ID&lt;/td&gt;
&lt;td&gt;Exists inside the caller's tenant&lt;/td&gt;
&lt;td&gt;Tenant-scoped repository&lt;/td&gt;
&lt;td&gt;Reject without revealing another tenant's ticket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolution summary&lt;/td&gt;
&lt;td&gt;Meets storage and display rules&lt;/td&gt;
&lt;td&gt;Application validator&lt;/td&gt;
&lt;td&gt;Return a controlled validation error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Close request&lt;/td&gt;
&lt;td&gt;Requester may close this resource&lt;/td&gt;
&lt;td&gt;Resource authorization policy&lt;/td&gt;
&lt;td&gt;Reject and audit the denial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval decision&lt;/td&gt;
&lt;td&gt;Reviewer may approve this risk class&lt;/td&gt;
&lt;td&gt;Reviewer authorization policy&lt;/td&gt;
&lt;td&gt;Reject the decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approval decision&lt;/td&gt;
&lt;td&gt;Record is &lt;code&gt;Pending&lt;/code&gt; and unchanged&lt;/td&gt;
&lt;td&gt;Approval service with atomic transition&lt;/td&gt;
&lt;td&gt;Reject expired or already decided records&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Current ticket&lt;/td&gt;
&lt;td&gt;Version and state still match&lt;/td&gt;
&lt;td&gt;Repository and action policy&lt;/td&gt;
&lt;td&gt;Invalidate the stale approval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution attempt&lt;/td&gt;
&lt;td&gt;Allowed or approved operation is claimed once&lt;/td&gt;
&lt;td&gt;Durable execution state machine&lt;/td&gt;
&lt;td&gt;Recover, reconcile, return the recorded result, or block retry&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That is more useful than writing "validate AI output" beside a model box. It names the property, owner, and failure contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this level of control is useful
&lt;/h2&gt;

&lt;p&gt;Map explicit trust boundaries when an AI feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieves protected or tenant-scoped data&lt;/li&gt;
&lt;li&gt;consumes uploaded, external, or user-controlled content&lt;/li&gt;
&lt;li&gt;turns model output into routing or workflow decisions&lt;/li&gt;
&lt;li&gt;sends data to an external provider, tool, plugin, or telemetry system&lt;/li&gt;
&lt;li&gt;writes application state or communicates with people&lt;/li&gt;
&lt;li&gt;affects money, permissions, production resources, or regulated data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small drafting feature with no protected data and no side effects needs fewer controls. It still needs safe rendering and appropriate storage rules.&lt;/p&gt;

&lt;p&gt;Sometimes the honest answer is that an LLM should not make the decision. If deterministic checks cannot make the transition safe enough for its consequence, keep that operation outside the AI feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical boundary checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping, check that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;identity, delegation, and tenant scope come from authenticated application context&lt;/li&gt;
&lt;li&gt;retrieval authorization runs inside the trusted authorization path before protected content or result metadata becomes observable outside it&lt;/li&gt;
&lt;li&gt;model-provider and telemetry boundaries minimize and protect sensitive data&lt;/li&gt;
&lt;li&gt;retrieved content and tool results remain untrusted data&lt;/li&gt;
&lt;li&gt;structured output receives validation appropriate to the value type and destination&lt;/li&gt;
&lt;li&gt;authorization runs against the current protected resource&lt;/li&gt;
&lt;li&gt;application policy chooses &lt;code&gt;Allow&lt;/code&gt;, &lt;code&gt;Deny&lt;/code&gt;, or &lt;code&gt;RequireApproval&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;mandatory approval is verified outside the prompt&lt;/li&gt;
&lt;li&gt;the reviewer is authorized for the operation and cannot bypass separation-of-duties rules&lt;/li&gt;
&lt;li&gt;approval binds to the canonical operation, resource version, policy decision, typed requirement, and expiry&lt;/li&gt;
&lt;li&gt;approval decisions use an atomic &lt;code&gt;Pending&lt;/code&gt; to &lt;code&gt;Approved&lt;/code&gt; or &lt;code&gt;Rejected&lt;/code&gt; transition&lt;/li&gt;
&lt;li&gt;automatically allowed and approved actions enter the execution boundary through a durable attempt&lt;/li&gt;
&lt;li&gt;recovery treats stale &lt;code&gt;Executing&lt;/code&gt; attempts as potentially applied and blocks unsafe retries&lt;/li&gt;
&lt;li&gt;the executor re-checks current authorization, resource state, and current policy&lt;/li&gt;
&lt;li&gt;when approval was required, the executor also validates policy compatibility and approval state&lt;/li&gt;
&lt;li&gt;retries are idempotent or deduplicated where possible, and blocked when safe retry cannot be guaranteed&lt;/li&gt;
&lt;li&gt;execution credentials follow least privilege and tenant enforcement cannot be influenced by model input&lt;/li&gt;
&lt;li&gt;approval and policy failures before effect initiation stop consequential execution&lt;/li&gt;
&lt;li&gt;logs record decisions without copying sensitive context by default&lt;/li&gt;
&lt;li&gt;each client applies output handling for its destination, such as contextual encoding, restricted Markdown, or URL validation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model can interpret, rank, summarize, and propose. The application owns the decision to act.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP AI Agent Security Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/LLM_Prompt_Injection_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP LLM Prompt Injection Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Authorization Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Business_Logic_Security_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Business Logic Security Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Cross-Site Request Forgery Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Server-Side Request Forgery Prevention Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/llmrisk/llm01-prompt-injection/" rel="noopener noreferrer"&gt;OWASP LLM01:2025 Prompt Injection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://genai.owasp.org/llmrisk/llm062025-excessive-agency/" rel="noopener noreferrer"&gt;OWASP LLM06:2025 Excessive Agency&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/aspnet/core/security/authorization/resource-based" rel="noopener noreferrer"&gt;Resource-based authorization in ASP.NET Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/security/engineering/cryptographic-recommendations" rel="noopener noreferrer"&gt;Microsoft SDL cryptographic recommendations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/dotnet/api/system.net.http.httpclienthandler.allowautoredirect" rel="noopener noreferrer"&gt;&lt;code&gt;HttpClientHandler.AllowAutoRedirect&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/dotnet/ai/how-to/handle-invalid-tool-input" rel="noopener noreferrer"&gt;Handle invalid function input from AI models in .NET&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/indirect-prompt-injection/"&gt;Indirect Prompt Injection Is a Trust Boundary Problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tips/separate-prompts-from-authorization/"&gt;Separate prompts from authorization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tips/use-approval-for-side-effects-not-for-every-tool-call/"&gt;Use approval for side effects, not for every tool call&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>dotnet</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Why Retrieval Exists</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Wed, 12 Aug 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/why-retrieval-exists-ebl</link>
      <guid>https://dev.to/lukaswalter/why-retrieval-exists-ebl</guid>
      <description>&lt;p&gt;Retrieval exists because the model is not your application's knowledge layer.&lt;/p&gt;

&lt;p&gt;A model can answer from patterns encoded in its parameters and from context supplied with the current request. It has no live access to your application data unless the application provides it. Internal documents, current product rules, tenant-specific policies, and recently changed runbooks do not become available just because the application can call a language model.&lt;/p&gt;

&lt;p&gt;The model's built-in knowledge lives in its weights. When the application relies on those weights, the model generates likely output rather than querying a versioned source of truth. The application cannot reliably ask where a fact came from, whether it reflects the approved version for a given date, or whether it applies to the current user. Even if a fact appeared in training data, the model might not recall it correctly.&lt;/p&gt;

&lt;p&gt;Retrieval lets the application select evidence from identifiable sources. When the pipeline preserves the necessary metadata, the result can retain source ownership, version, provenance, and access information. The model can still produce a bad answer, but the application no longer depends on model weights as its only knowledge source.&lt;/p&gt;

&lt;p&gt;Sending every possible document with every request is not a durable solution either. The application needs a pipeline that manages source freshness, determines which records the user may access, finds relevant evidence, and decides what is worth placing in the model context.&lt;/p&gt;

&lt;p&gt;In this article, retrieval means relevance-based evidence selection from a candidate corpus:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Select useful evidence from multiple candidates at runtime.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In RAG, the application makes that selected evidence available to the model as context.&lt;/p&gt;

&lt;p&gt;Retrieval does not make the evidence true or the model deterministic. It does not enforce permissions on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model has a knowledge boundary
&lt;/h2&gt;

&lt;p&gt;A language model draws on patterns stored in its weights and the context supplied with the current request. Neither one owns your application's source of truth.&lt;/p&gt;

&lt;p&gt;Built-in model knowledge is the wrong place to own application facts because it is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;not live: it does not automatically reflect a policy changed yesterday or the current state of a customer's order&lt;/li&gt;
&lt;li&gt;not authoritative: the model does not own your policies, runbooks, or business state&lt;/li&gt;
&lt;li&gt;not reliably attributable: the application usually cannot trace a parametric fact back to an approved source&lt;/li&gt;
&lt;li&gt;not guaranteed to be recalled correctly: training exposure does not guarantee an accurate answer&lt;/li&gt;
&lt;li&gt;not dynamically access-scoped: model weights do not enforce the current user's tenant, role, or document permissions at request time&lt;/li&gt;
&lt;li&gt;not version-addressable: the application cannot reliably request the approved policy version for a particular date&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Changing to a newer model may change what the model can answer from its built-in knowledge. It does not connect the model to your live application data.&lt;/p&gt;

&lt;p&gt;You can place additional information into the request. That works well when the input is already available and reasonably small. For example, a model can summarize a document the user has just uploaded without a retrieval system.&lt;/p&gt;

&lt;p&gt;The problem changes when the application owns thousands of possible documents and only a few of them matter for one question. At that point, context is a selection problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval is runtime context selection
&lt;/h2&gt;

&lt;p&gt;A minimal standard retrieval-augmented generation flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user question
-&amp;gt; retrieve relevant evidence
-&amp;gt; assemble the allowed context
-&amp;gt; generate an answer from that context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A common preparation flow for document-based retrieval looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source documents
-&amp;gt; parse and chunk
-&amp;gt; add metadata
-&amp;gt; index
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime side of that standard RAG flow selects information for one request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question
-&amp;gt; search and filter
-&amp;gt; rank results
-&amp;gt; select context
-&amp;gt; call the model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Several application layers sit between the source data and the model call. Source ownership determines which system is authoritative. Ingestion and synchronization determine which version is available for retrieval. Authorization determines which records are eligible. Retrieval and ranking find relevant evidence within that set. Context assembly decides what enters the model request.&lt;/p&gt;

&lt;p&gt;This is why RAG is an engineering concern rather than a prompt trick. Each stage affects the answer and fails in a different way.&lt;/p&gt;

&lt;p&gt;Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-solution-design-and-evaluation-guide" rel="noopener noreferrer"&gt;RAG design guidance&lt;/a&gt; separates these stages for the same reason: document preparation, retrieval, and generation have different failure modes and need to be evaluated separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrieval is not the same as vector search
&lt;/h2&gt;

&lt;p&gt;Retrieval means finding useful information for the request. Vector search is one way to do that.&lt;/p&gt;

&lt;p&gt;A retrieval path might use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;lexical or full-text search for terms and phrases&lt;/li&gt;
&lt;li&gt;metadata filters for tenant, product, language, or version&lt;/li&gt;
&lt;li&gt;vector search for semantic similarity&lt;/li&gt;
&lt;li&gt;hybrid search that combines text and vector results&lt;/li&gt;
&lt;li&gt;a relational database with suitable text or vector search&lt;/li&gt;
&lt;li&gt;an internal API that already exposes a search capability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-information-retrieval" rel="noopener noreferrer"&gt;information-retrieval phase of a RAG system&lt;/a&gt; may use full-text, vector, hybrid, filtered, or multi-query search. Embeddings are important, but they are not the definition of retrieval.&lt;/p&gt;

&lt;p&gt;In ordinary language, an API call that fetches application state also retrieves data. I use retrieval more narrowly here to mean selection from a candidate corpus. A structured lookup or query starts with a known key or explicit predicates and returns application state. Corpus retrieval starts with an information need and ranks relevant evidence among multiple candidates. They have different failure modes and should not be forced through the same search path.&lt;/p&gt;

&lt;p&gt;Without this distinction, teams often send every question through a vector database because the feature has an LLM in it.&lt;/p&gt;

&lt;p&gt;If a user asks for order &lt;code&gt;A-1842&lt;/code&gt;, semantic similarity is not the main problem. The application already has an exact identifier. Query the system of record.&lt;/p&gt;

&lt;p&gt;If a user asks, "Which runbook explains intermittent timeouts after a deployment?", semantic retrieval may be useful because the wording in the question might not match the document title.&lt;/p&gt;

&lt;p&gt;Choose the retrieval method from the shape of the question and the data, not from the AI label attached to the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What retrieval gives the application
&lt;/h2&gt;

&lt;p&gt;Corpus retrieval becomes useful when the required knowledge is outside the current request and must be selected from many candidates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Access to private and domain-specific knowledge
&lt;/h3&gt;

&lt;p&gt;Internal policies, product documentation, support notes, architecture decisions, and operational runbooks belong to the application or organization. Retrieval makes selected parts of that corpus available at request time.&lt;/p&gt;

&lt;p&gt;The source system still owns the knowledge. The model receives only the material selected from that corpus.&lt;/p&gt;

&lt;h3&gt;
  
  
  Selection from more information than one request needs
&lt;/h3&gt;

&lt;p&gt;A large knowledge base may contain millions of words, but a question might depend on three paragraphs.&lt;/p&gt;

&lt;p&gt;In a standard RAG pipeline, retrieval reduces the candidate set before the model call. A smaller, focused context is usually cheaper and easier to inspect than repeatedly sending the entire corpus. Agentic systems may search several times between model calls, but each search still performs the same selection job.&lt;/p&gt;

&lt;p&gt;A larger model context window can move the limit, but it does not remove the selection problem. More available space does not tell the application which document is current or which section answers the question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Independent updates
&lt;/h3&gt;

&lt;p&gt;Application knowledge changes on its own schedule.&lt;/p&gt;

&lt;p&gt;A runbook can be corrected, a policy can expire, or a product version can be retired without changing the model. A retrieval system can reindex that source data and expose the updated material at runtime.&lt;/p&gt;

&lt;p&gt;This does not make freshness automatic. The ingestion and deletion paths still have to work. Keeping knowledge outside the model allows it to change independently. Retrieval makes updated material selectable from a corpus without retraining the model.&lt;/p&gt;

&lt;h3&gt;
  
  
  Permission-aware context
&lt;/h3&gt;

&lt;p&gt;The application can apply tenant, role, product, region, or document-level constraints while it queries the corpus.&lt;/p&gt;

&lt;p&gt;Those constraints must come from trusted application context. Similarity is not authorization, and a model-supplied tenant ID is not an access-control decision.&lt;/p&gt;

&lt;p&gt;Keep eligibility filters separate from semantic ranking. Authorization should narrow the eligible records using trusted application context. Retrieval can then rank those records by relevance. I use the same boundary in &lt;a href="https://dev.to/tips/keep-vector-search-filters-separate-from-semantic-ranking/"&gt;Keep vector search filters separate from semantic ranking&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspectable evidence
&lt;/h3&gt;

&lt;p&gt;Retrieved results can carry document IDs, chunk IDs, versions, headings, and source URLs into the generation step.&lt;/p&gt;

&lt;p&gt;That gives the application a traceable retrieval path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;question
-&amp;gt; retrieval query
-&amp;gt; retrieved chunk
-&amp;gt; source document
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This metadata is the basis for an inspectable evidence path, but it does not prove that a generated claim came from a particular chunk. The model may combine sources, rely on its weights, cite the wrong chunk, or add an unsupported claim. To trace a claim back to evidence, the application has to create an explicit citation or attribution and validate it. Groundedness evaluation answers a separate question: whether the supplied evidence supports the generated claims. &lt;a href="https://dev.to/tips/keep-rag-citations-tied-to-retrieved-chunks/"&gt;Keep RAG citations tied to retrieved chunks&lt;/a&gt; covers the first part of that work. Microsoft's &lt;a href="https://learn.microsoft.com/en-us/azure/databricks/agents/tutorials/ai-cookbook/evaluate-assess-performance" rel="noopener noreferrer"&gt;RAG evaluation guidance&lt;/a&gt; treats retrieval quality and response groundedness as separate measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  One assistant, six different data problems
&lt;/h2&gt;

&lt;p&gt;Consider an internal support assistant. It receives these requests:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Best starting point&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Explain eventual consistency."&lt;/td&gt;
&lt;td&gt;Model knowledge or a supplied reference&lt;/td&gt;
&lt;td&gt;This is general knowledge unless the company has a specific definition or requires approved sources.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What is our current cancellation policy for German customers?"&lt;/td&gt;
&lt;td&gt;Filtered document retrieval, if the governed policy corpus owns the answer&lt;/td&gt;
&lt;td&gt;A policy service or rules engine may be the better source when it owns the current policy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"What is the status of order A-1842?"&lt;/td&gt;
&lt;td&gt;Direct API or database lookup&lt;/td&gt;
&lt;td&gt;The request identifies one structured record. Similarity search adds ambiguity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Which orders have been delayed for more than three days?"&lt;/td&gt;
&lt;td&gt;Structured API or database query&lt;/td&gt;
&lt;td&gt;The request applies explicit filters to application state. Relevance ranking adds no value.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Calculate the SLA deadline for this incident."&lt;/td&gt;
&lt;td&gt;Deterministic application code&lt;/td&gt;
&lt;td&gt;Business rules and date calculations should not depend on generated reasoning.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Summarize the incident report I attached."&lt;/td&gt;
&lt;td&gt;Put the report directly in context if it fits&lt;/td&gt;
&lt;td&gt;Retrieval may still help when the report is too long or the task needs section selection.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All six requests could be sent to a language model. They should not all use the same data path.&lt;/p&gt;

&lt;p&gt;The model can still provide the conversational interface. The application decides whether the request needs general generation, document retrieval, a structured query, deterministic code, or direct context.&lt;/p&gt;

&lt;p&gt;That routing decision is part of the application architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What retrieval does not solve
&lt;/h2&gt;

&lt;p&gt;It does not solve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;missing knowledge in the source corpus&lt;/li&gt;
&lt;li&gt;stale or contradictory documents&lt;/li&gt;
&lt;li&gt;poor chunk boundaries&lt;/li&gt;
&lt;li&gt;weak metadata&lt;/li&gt;
&lt;li&gt;incorrect permission filters&lt;/li&gt;
&lt;li&gt;bad ranking&lt;/li&gt;
&lt;li&gt;excessive or noisy context&lt;/li&gt;
&lt;li&gt;unsupported claims added during generation&lt;/li&gt;
&lt;li&gt;malicious instructions in retrieved content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the only indexed policy is obsolete, retrieval can return it perfectly and the final answer will still be wrong.&lt;/p&gt;

&lt;p&gt;Poor chunk boundaries can prevent the retrieval pipeline from returning enough context to preserve the author's meaning.&lt;/p&gt;

&lt;p&gt;Authorization should be enforced as part of the retrieval query or before results leave the trusted retrieval path. Filtering only after ranking can expose sensitive content to downstream components, logs, caches, or telemetry. It can also leave too few eligible results in the original top-k set. &lt;a href="https://learn.microsoft.com/en-us/azure/search/search-security-trimming-for-azure-search" rel="noopener noreferrer"&gt;Azure AI Search security filters&lt;/a&gt; are one example of applying document-level constraints during the query.&lt;/p&gt;

&lt;p&gt;Retrieval does not make content safe. Documents, web pages, and user-provided files can contain misleading instructions or indirect prompt injections. Treat retrieved content as data, not as trusted instructions. Keep tool permissions and authorization decisions outside the model. &lt;a href="https://dev.to/posts/indirect-prompt-injection/"&gt;Indirect Prompt Injection Is a Trust Boundary Problem&lt;/a&gt; explains this boundary in more detail.&lt;/p&gt;

&lt;p&gt;If you evaluate only the final answer, you may blame the prompt for a retrieval miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  When corpus retrieval is the wrong solution
&lt;/h2&gt;

&lt;p&gt;Building a dedicated corpus retrieval system is work. Depending on what already exists, the application may have to own ingestion, indexing, synchronization, ranking, evaluation, and operations. Build one when the use case needs runtime selection from a knowledge corpus.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use a structured lookup or query for application state
&lt;/h3&gt;

&lt;p&gt;Questions about a known customer, invoice, shipment, subscription, or ticket often belong in an authenticated API or database query.&lt;/p&gt;

&lt;p&gt;The same applies to filters and aggregations over many records. A request for all delayed orders or the number of cancellations last month belongs in SQL, an API with structured filters, or analytics infrastructure.&lt;/p&gt;

&lt;p&gt;Return the structured result to the application. Let the model explain it only if natural-language presentation adds value.&lt;/p&gt;

&lt;p&gt;Vector similarity should not decide which customer's invoice the user meant when the request already contains the invoice ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use deterministic code for rules and calculations
&lt;/h3&gt;

&lt;p&gt;Totals, eligibility checks, deadlines, authorization decisions, and state transitions should use application logic.&lt;/p&gt;

&lt;p&gt;A policy document may be retrieved to explain why a rule exists. The actual decision should still come from the code or policy engine that owns it.&lt;/p&gt;

&lt;p&gt;Retrieval provides evidence. It does not replace deterministic execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use direct context when the relevant input is already known
&lt;/h3&gt;

&lt;p&gt;If the user uploads one short document and asks for a summary, pass that document to the model.&lt;/p&gt;

&lt;p&gt;If every request needs the same small, stable set of instructions, keeping them in a versioned prompt or configuration file may be simpler than building a search index.&lt;/p&gt;

&lt;p&gt;Retrieval becomes useful when selection is real. Do not manufacture a search problem where none exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fix behavior problems at the behavior layer
&lt;/h3&gt;

&lt;p&gt;Retrieval is not the first tool for changing tone, output shape, refusal behavior, or tool-selection policy.&lt;/p&gt;

&lt;p&gt;Use prompts, structured output, validation, application logic, or, in suitable cases, fine-tuning for those concerns.&lt;/p&gt;

&lt;p&gt;Adding more documents will not repair a response-format contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do not treat an ungoverned corpus as authoritative
&lt;/h3&gt;

&lt;p&gt;Retrieval can work over public web pages, user uploads, support tickets, or sources that disagree. The application still needs to preserve provenance and account for the quality and trust level of each source.&lt;/p&gt;

&lt;p&gt;A research assistant can report that two sources conflict. An internal policy assistant should not present an outdated or unowned document as company policy. When the use case requires an authoritative answer, establish the source owner, update path, and access model before building retrieval around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When retrieval is enough without generation
&lt;/h2&gt;

&lt;p&gt;Sometimes the user needs a list of matching documents, not a generated answer.&lt;/p&gt;

&lt;p&gt;In that case, return search results with titles, snippets, filters, and links. Generation adds cost and can blur the distinction between what a source says and what the model inferred.&lt;/p&gt;

&lt;p&gt;RAG is useful when synthesis adds value. Search remains a valid product on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical decision sequence
&lt;/h2&gt;

&lt;p&gt;Before adding retrieval to an AI feature, ask these questions in order.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Does the task require application-owned or source-grounded information that is not already in the request?
&lt;/h3&gt;

&lt;p&gt;If no, retrieval is not needed. Use the model, direct context, or deterministic code according to the task.&lt;/p&gt;

&lt;p&gt;If yes, identify the system that owns or provides the missing information.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Is the need a structured query, deterministic computation, or relevance-based evidence selection?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Structured lookup or query: call the database or API using explicit keys, filters, or aggregations.&lt;/li&gt;
&lt;li&gt;Calculation or business decision: run deterministic code.&lt;/li&gt;
&lt;li&gt;Relevance-based evidence selection from a corpus: consider retrieval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some requests need more than one path. An assistant might retrieve the policy explanation and call an API for the customer's current account state.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. What authority does the source corpus have?
&lt;/h3&gt;

&lt;p&gt;Identify who owns it, how it changes, how deletions propagate, and whether the use case needs authoritative or merely discoverable sources.&lt;/p&gt;

&lt;p&gt;If the answer must be authoritative but the corpus has no freshness or ownership model, the work starts before embeddings.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Can the application enforce scope before generation?
&lt;/h3&gt;

&lt;p&gt;Define tenant, user, role, region, product, and document constraints. Apply them before the selected context reaches the model.&lt;/p&gt;

&lt;p&gt;Do not treat a good semantic match as permission to reveal the content.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Can you evaluate retrieval separately?
&lt;/h3&gt;

&lt;p&gt;Keep test questions with expected sources or relevant chunks. For routine telemetry, record document and chunk identifiers, scores, applied filters, and context-selection decisions. Keep raw retrieved content out of logs unless the application's privacy, security, and retention rules allow it.&lt;/p&gt;

&lt;p&gt;If a response is wrong, you should be able to distinguish:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no relevant source existed&lt;/li&gt;
&lt;li&gt;the source existed but was not retrieved&lt;/li&gt;
&lt;li&gt;the right source was retrieved but excluded during context assembly&lt;/li&gt;
&lt;li&gt;the right context reached the model but the generated answer was still wrong&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without that separation, every failure looks like a model failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use retrieval
&lt;/h2&gt;

&lt;p&gt;Several conditions make corpus retrieval worth considering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the answer depends on private, domain-specific, or changing knowledge that must be selected from multiple candidates&lt;/li&gt;
&lt;li&gt;the available corpus is larger than the useful context for one request&lt;/li&gt;
&lt;li&gt;the application must select evidence using relevance ranking and trusted metadata constraints&lt;/li&gt;
&lt;li&gt;the selected evidence should remain traceable to its source&lt;/li&gt;
&lt;li&gt;the source and retrieval paths can be maintained and evaluated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not use retrieval when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the required input is already present and small enough to send directly&lt;/li&gt;
&lt;li&gt;the request needs a structured lookup, filter, or aggregation over application state&lt;/li&gt;
&lt;li&gt;deterministic code owns the calculation or decision&lt;/li&gt;
&lt;li&gt;the problem is response behavior rather than missing knowledge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Retrieval can still help discover material in an ungoverned corpus. Do not use that corpus as the authoritative basis for an answer.&lt;/p&gt;

&lt;p&gt;Do not add generation when plain retrieval results solve the user need.&lt;/p&gt;

&lt;p&gt;Use corpus retrieval when the application must select relevant evidence from many candidates at runtime. When the application supplies that evidence to a model for synthesis, retrieval is the context-selection stage of RAG.&lt;/p&gt;

&lt;p&gt;Start with the system that owns the information. If the input is already known, use the simpler path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/ragisadataproblem/"&gt;RAG Is a Data Problem Before It’s a Prompt Problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/rag-efcore-pgvector/"&gt;RAG with EF Core and pgvector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tips/keep-rag-citations-tied-to-retrieved-chunks/"&gt;Keep RAG citations tied to retrieved chunks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-solution-design-and-evaluation-guide" rel="noopener noreferrer"&gt;Design and develop a RAG solution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/rag/rag-information-retrieval" rel="noopener noreferrer"&gt;Develop a RAG solution: information-retrieval phase&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/databricks/agents/tutorials/ai-cookbook/evaluate-assess-performance" rel="noopener noreferrer"&gt;Assess RAG performance: metrics that matter&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/search/search-security-trimming-for-azure-search" rel="noopener noreferrer"&gt;Security filters for trimming results in Azure AI Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/safety" rel="noopener noreferrer"&gt;Agent safety&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>vectordatabase</category>
    </item>
    <item>
      <title>The Model Is Only One Dependency. Map the Rest.</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/the-model-is-only-one-dependency-map-the-rest-16gj</link>
      <guid>https://dev.to/lukaswalter/the-model-is-only-one-dependency-map-the-rest-16gj</guid>
      <description>&lt;p&gt;A model endpoint can be healthy while the feature around it is broken.&lt;/p&gt;

&lt;p&gt;A valid model response says nothing about whether the application used authorized data, checked current business facts, or completed a requested state change.&lt;/p&gt;

&lt;p&gt;The model does not own most of the feature's contract. Application code must enforce account scope, choose the right systems of record, validate hard constraints, and report whether state changes succeeded.&lt;/p&gt;

&lt;p&gt;A dependency register adds behavioral contracts to the interactions shown in an architecture diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with enforceable properties
&lt;/h2&gt;

&lt;p&gt;"Recommend a suitable guitar from our current catalog" is a useful product promise, but it is not precise enough for an operational contract.&lt;/p&gt;

&lt;p&gt;Translate it into properties the application can enforce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every recommended product ID resolves against the accepted catalog version used for the request.&lt;/li&gt;
&lt;li&gt;Every displayed price comes from the designated pricing source and is no older than the allowed maximum when the response is produced.&lt;/li&gt;
&lt;li&gt;The feature says "in stock" only when the inventory observation is recent enough for that claim.&lt;/li&gt;
&lt;li&gt;Every customer record matches the authenticated account scope before the application uses it.&lt;/li&gt;
&lt;li&gt;The response reports a successful save only after the shortlist write is confirmed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Not every part of the product promise becomes a runtime invariant. Product existence, account scope, freshness, and save outcomes can be enforced deterministically. Whether a recommendation is genuinely useful or subjectively suitable remains an evaluation problem. It needs quality criteria, representative test cases, and feedback; the application cannot generally prove subjective suitability for each request.&lt;/p&gt;

&lt;p&gt;Freshness limits and time budgets belong to the product and its service objectives. Name them, assign an owner, and enforce them.&lt;/p&gt;

&lt;p&gt;For the guitar assistant, the request path might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated shopper
    -&amp;gt; resolve account scope
    -&amp;gt; authorize profile access
    -&amp;gt; read preferences and optional purchase history when enabled
    -&amp;gt; resolve the accepted catalog version
    -&amp;gt; verify that the version is still acceptable
    -&amp;gt; read product records and specifications
    -&amp;gt; read current price observations
    -&amp;gt; read inventory observations when availability matters
    -&amp;gt; resolve prompt, schema, route, deployment, and model versions
    -&amp;gt; call the model
    -&amp;gt; validate schema and selected product references
    -&amp;gt; resolve authoritative prices and availability when relevant
    -&amp;gt; revalidate budget and applicable availability constraints
    -&amp;gt; compose authoritative facts into the response
    -&amp;gt; write a shortlist when requested
    -&amp;gt; return recommendation and save outcome

observability: signals across the complete path
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Catalog-version selection and request-time freshness validation are separate because they fail differently.&lt;/p&gt;

&lt;p&gt;For exact business facts, the model can rank product IDs while application code resolves price and relevant availability from their systems of record. If the final price exceeds the budget or required availability no longer satisfies the request, reject that candidate. Use another validated candidate, make one bounded regeneration when the contract allows it, or state that the product-backed request cannot be completed.&lt;/p&gt;

&lt;p&gt;The register applies established reliability thinking to any distributed feature. AI makes it especially useful because a successful model call can still produce an invalid result. Generated references, probabilistic quality, token and quota limits, content filters, and fallback compatibility are especially easy to miss when teams rely mainly on service diagrams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate three dependency roles
&lt;/h2&gt;

&lt;p&gt;Not every dependency participates in the immediate response. I separate three roles before deciding how critical an interaction is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Guitar assistant example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Produces or validates the result or a requested state change&lt;/td&gt;
&lt;td&gt;Account authorization, price read, model request, shortlist write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prerequisite&lt;/td&gt;
&lt;td&gt;Creates usable state before requests can consume it&lt;/td&gt;
&lt;td&gt;Catalog ingestion and accepted-version publication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational&lt;/td&gt;
&lt;td&gt;Detects, supports, audits, or recovers the feature&lt;/td&gt;
&lt;td&gt;Telemetry export, security-event delivery, recovery scheduler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An operational dependency may not affect one response, yet remain required for audit, billing, safety, or recovery. Analytics may be best effort; required security-event delivery is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Classify interactions, not services
&lt;/h2&gt;

&lt;p&gt;A capability rarely has one relationship to the request: catalog publication happens beforehand, version validation inline, and recovery later. Give each identifiable interaction a row with its role, condition, criticality and horizon, timing, and contract reference. Split it again at another owned boundary or operating lifecycle; keep different failure classes of one call inside its failure contract.&lt;/p&gt;

&lt;p&gt;Timing does not imply importance. Criticality has three useful values:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Required:&lt;/strong&gt; The declared capability, claim, or operational obligation cannot be fulfilled without the interaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degradable:&lt;/strong&gt; The system may omit or reduce the affected capability or operational behavior under an explicitly defined degraded mode. Any user-visible limitation must be made clear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best effort:&lt;/strong&gt; Failure invalidates neither the user-visible result nor any required operational obligation, although it may reduce analytical or operational value.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Criticality describes whether the affected capability or operational obligation may be omitted under the stated condition. It never relaxes security, integrity, authorization, or correctness constraints when an interaction is attempted. "Live inventory is required" is too broad. "A current inventory observation is required for this request when the response claims an item is in stock" is operationally useful.&lt;/p&gt;

&lt;p&gt;For the guitar recommendation, an initial classification could look like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Interaction&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Criticality and horizon&lt;/th&gt;
&lt;th&gt;Timing&lt;/th&gt;
&lt;th&gt;Contracts and details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolve and enforce account scope&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Stored customer data is read&lt;/td&gt;
&lt;td&gt;Required for that read&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Profile access policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read purchase history&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Optional purchase-history personalization is enabled&lt;/td&gt;
&lt;td&gt;Degradable for this recommendation&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Profile read · feature detail
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publish an accepted catalog version&lt;/td&gt;
&lt;td&gt;Prerequisite&lt;/td&gt;
&lt;td&gt;Product-backed recommendations remain available&lt;/td&gt;
&lt;td&gt;Required over the freshness window&lt;/td&gt;
&lt;td&gt;Pre-request&lt;/td&gt;
&lt;td&gt;Catalog publication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify accepted catalog version&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Product-backed recommendation&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Catalog acceptance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read product record and specifications&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Response recommends a catalog product&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Catalog read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read authoritative price&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Response displays a price or enforces a budget&lt;/td&gt;
&lt;td&gt;Required for that claim&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Pricing read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read inventory&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Response makes an availability claim&lt;/td&gt;
&lt;td&gt;Required for that claim&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Inventory read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolve compatible prompt and output-schema versions&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Model-generated recommendation&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Prompt and schema contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolve model route and version&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Model-generated recommendation&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Model routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Call the model deployment&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Model-generated recommendation&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Model gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate schema and product references&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Model-generated recommendation&lt;/td&gt;
&lt;td&gt;Required for this request&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Output contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revalidate budget and applicable availability constraints&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Response enforces a budget or makes an availability claim&lt;/td&gt;
&lt;td&gt;Required for that constraint or claim&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Recommendation constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attempt shortlist save&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Shopper asks to save&lt;/td&gt;
&lt;td&gt;Required for the save-success claim&lt;/td&gt;
&lt;td&gt;Inline&lt;/td&gt;
&lt;td&gt;Shortlist write · feature detail
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discover eligible unresolved shortlist operations&lt;/td&gt;
&lt;td&gt;Operational&lt;/td&gt;
&lt;td&gt;Inline attempt lease expired and operation remains non-terminal&lt;/td&gt;
&lt;td&gt;Required over the recovery objective&lt;/td&gt;
&lt;td&gt;Deferred&lt;/td&gt;
&lt;td&gt;Recovery discovery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resume or reconcile non-terminal shortlist operation&lt;/td&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;Eligible operation remains unresolved&lt;/td&gt;
&lt;td&gt;Required until terminal, when promised by the persistence contract&lt;/td&gt;
&lt;td&gt;Deferred&lt;/td&gt;
&lt;td&gt;Shortlist recovery · feature detail
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export recommendation analytics&lt;/td&gt;
&lt;td&gt;Operational&lt;/td&gt;
&lt;td&gt;Product analytics is enabled&lt;/td&gt;
&lt;td&gt;Best effort unless another obligation requires it&lt;/td&gt;
&lt;td&gt;Deferred&lt;/td&gt;
&lt;td&gt;Analytics export&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In a working register, the last cell links the shared contract and any feature detail. An explicit promise to use stored purchase history makes that read required unless the request supplies the same information.&lt;/p&gt;

&lt;p&gt;Do not combine catalog, pricing, and inventory reads when they are separate calls or owned contracts. Conversely, DNS failure, throttling, content filtering, and malformed output may be separate failure classes of one model call rather than four register entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop at an owned boundary
&lt;/h2&gt;

&lt;p&gt;Stop at an observable boundary with a documented owner and support contract. Internals stay outside the register unless your application interacts with them separately or they have a distinct operating lifecycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand only risky interactions
&lt;/h2&gt;

&lt;p&gt;The classification table is the feature register. Expand rows that handle protected data, change state, return ambiguous outcomes, permit degradation, or need a distinct operational response. Other rows can reference a shared contract.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feature register&lt;/td&gt;
&lt;td&gt;Interaction-specific condition, criticality, time horizon, and degraded user behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared capability contract&lt;/td&gt;
&lt;td&gt;API or schema version, owner, general retry semantics, SLO, and compatibility policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runbook or threat model&lt;/td&gt;
&lt;td&gt;Operational response, escalation details, and trust-boundary analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Store shared facts once; feature entries contain only operation-specific behavior and overrides.&lt;/p&gt;

&lt;p&gt;Use this expanded template only where the risk warrants it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### &amp;lt;Interaction name&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; Feature register row: &lt;span class="nt"&gt;&amp;lt;link&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Capability contract: &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;and&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Data classification: &lt;span class="nt"&gt;&amp;lt;organization&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="na"&gt;s&lt;/span&gt; &lt;span class="na"&gt;classification&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Freshness or completion requirement: &lt;span class="nt"&gt;&amp;lt;maximum&lt;/span&gt; &lt;span class="na"&gt;age&lt;/span&gt; &lt;span class="na"&gt;or&lt;/span&gt; &lt;span class="na"&gt;completion&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Per-attempt and total time budget: &lt;span class="nt"&gt;&amp;lt;attempt&lt;/span&gt; &lt;span class="na"&gt;and&lt;/span&gt; &lt;span class="na"&gt;end-to-end&lt;/span&gt; &lt;span class="na"&gt;allocation&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Failure behavior: &lt;span class="nt"&gt;&amp;lt;response&lt;/span&gt; &lt;span class="na"&gt;by&lt;/span&gt; &lt;span class="na"&gt;material&lt;/span&gt; &lt;span class="na"&gt;failure&lt;/span&gt; &lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Retry override: &lt;span class="nt"&gt;&amp;lt;feature-specific&lt;/span&gt; &lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Degraded mode: &lt;span class="nt"&gt;&amp;lt;valid&lt;/span&gt; &lt;span class="na"&gt;reduced&lt;/span&gt; &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;or&lt;/span&gt; &lt;span class="na"&gt;none&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Consistency and idempotency: &lt;span class="nt"&gt;&amp;lt;read&lt;/span&gt; &lt;span class="na"&gt;or&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt; &lt;span class="na"&gt;guarantees&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Concurrency control: &lt;span class="nt"&gt;&amp;lt;lease&lt;/span&gt; &lt;span class="na"&gt;claims&lt;/span&gt; &lt;span class="na"&gt;and&lt;/span&gt; &lt;span class="na"&gt;conditional&lt;/span&gt; &lt;span class="na"&gt;transitions&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Diagnostic signals: &lt;span class="nt"&gt;&amp;lt;outcome&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempt&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt; &lt;span class="na"&gt;controlled&lt;/span&gt; &lt;span class="na"&gt;identifiers&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Escalation path: &lt;span class="nt"&gt;&amp;lt;owner&lt;/span&gt; &lt;span class="na"&gt;or&lt;/span&gt; &lt;span class="na"&gt;runbook&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Read authorized purchase history
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Feature register row: Execution, inline, and degradable when optional purchase-history personalization is enabled; authorization remains required
&lt;span class="p"&gt;-&lt;/span&gt; Capability contract: Versioned profile-read contract with enforced authenticated account scope
&lt;span class="p"&gt;-&lt;/span&gt; Data classification: Organization's protected-customer-data class
&lt;span class="p"&gt;-&lt;/span&gt; Freshness or completion requirement: No feature-specific freshness override
&lt;span class="p"&gt;-&lt;/span&gt; Per-attempt and total time budget: All attempts fit inside the request's profile-read allocation
&lt;span class="p"&gt;-&lt;/span&gt; Failure behavior:
&lt;span class="p"&gt;  -&lt;/span&gt; Unavailable or timed out: continue without purchase-history personalization
&lt;span class="p"&gt;  -&lt;/span&gt; Authorization denied: do not read; continue only if an unpersonalized result is valid
&lt;span class="p"&gt;  -&lt;/span&gt; Scope or cache-partition mismatch: suppress the result and start the security incident path
&lt;span class="p"&gt;  -&lt;/span&gt; Malformed response: reject the data and record a contract failure
&lt;span class="p"&gt;-&lt;/span&gt; Retry override: Do not retry authorization denials, scope mismatches, malformed data, or after caller cancellation
&lt;span class="p"&gt;-&lt;/span&gt; Degraded mode: Omit personalization without claiming otherwise. Suspected cross-account exposure has no degraded mode
&lt;span class="p"&gt;-&lt;/span&gt; Consistency and idempotency: Use account-partitioned cache keys and preserve account scope through every call
&lt;span class="p"&gt;-&lt;/span&gt; Diagnostic signals: Outcome, duration, attempt, authorization reference, and controlled scope identifier; exclude purchase-history contents
&lt;span class="p"&gt;-&lt;/span&gt; Escalation path: Profile runbook for availability; security incident runbook for suspected cross-account access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction is easy to miss: purchase-history personalization is degradable, but correct authorization is not. Missing data can produce a valid reduced result. Data from the wrong account invalidates the entire operation and may indicate that protected data was exposed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attempt shortlist save
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Feature register row: Execution, inline, and required for the save-success claim when the shopper asks to save
&lt;span class="p"&gt;-&lt;/span&gt; Capability contract: Versioned idempotent write state machine with status lookup by operation ID
&lt;span class="p"&gt;-&lt;/span&gt; Command durability: &lt;span class="sb"&gt;`PendingDispatch`&lt;/span&gt; stores the immutable save command, or a durable reference sufficient to reconstruct and verify it, plus its fingerprint, operation ID, idempotency key, attempt lease, and &lt;span class="sb"&gt;`nextAttemptAt`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Command identity: Reusing the operation ID or idempotency key with a different command fingerprint is rejected
&lt;span class="p"&gt;-&lt;/span&gt; Data classification: Organization's applicable customer-data class
&lt;span class="p"&gt;-&lt;/span&gt; Freshness or completion requirement: Report success only after the write is confirmed
&lt;span class="p"&gt;-&lt;/span&gt; Per-attempt and total time budget: Write and inline lookup share the request's save allocation
&lt;span class="p"&gt;-&lt;/span&gt; Failure behavior:
&lt;span class="p"&gt;  -&lt;/span&gt; Confirmed success: report that the shortlist was saved
&lt;span class="p"&gt;  -&lt;/span&gt; Confirmed rejection: return the recommendation with a separate save failure
&lt;span class="p"&gt;  -&lt;/span&gt; Timeout or lost response: mark &lt;span class="sb"&gt;`OutcomeUnknown`&lt;/span&gt; and query while inline time remains; if still unknown, return the operation ID with an unconfirmed save status
&lt;span class="p"&gt;-&lt;/span&gt; Retry override: After an unknown outcome, retry only with the same idempotency key when safe deduplication is guaranteed
&lt;span class="p"&gt;-&lt;/span&gt; Degraded mode: The recommendation may still succeed, but the save result remains separate
&lt;span class="p"&gt;-&lt;/span&gt; Consistency and idempotency: One operation ID, idempotency key, and command fingerprint identify the logical save
&lt;span class="p"&gt;-&lt;/span&gt; Concurrency control: Dispatch requires an atomic conditional update that acquires the lease and transitions the eligible state to &lt;span class="sb"&gt;`Submitting`&lt;/span&gt; using the expected version. Terminal updates require the expected version and attempt identity so an older attempt cannot overwrite a newer result
&lt;span class="p"&gt;-&lt;/span&gt; Diagnostic signals: Operation ID, outcome, duration, and attempt; exclude shortlist contents
&lt;span class="p"&gt;-&lt;/span&gt; Escalation path: Persistence errors use the write runbook; the recovery scheduler discovers eligible unresolved operations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A confirmed rejection and a timeout with an unknown outcome are different states. The inline interaction ends with the response; unresolved recovery does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resume or reconcile non-terminal shortlist operations
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;-&lt;/span&gt; Feature register row: Execution, deferred, and required until the operation reaches a terminal state when the persistence contract promises recovery
&lt;span class="p"&gt;-&lt;/span&gt; Capability contract: State machine, authoritative status lookup, and idempotent retry contract for the original operation ID
&lt;span class="p"&gt;-&lt;/span&gt; Data classification: Same class as the original save
&lt;span class="p"&gt;-&lt;/span&gt; Freshness or completion requirement: Reach a terminal outcome within the recovery objective
&lt;span class="p"&gt;-&lt;/span&gt; Per-attempt and total time budget: Scheduled attempts use the recovery budget, not the request budget
&lt;span class="p"&gt;-&lt;/span&gt; Recovery eligibility: The inline attempt lease has expired and &lt;span class="sb"&gt;`nextAttemptAt`&lt;/span&gt; is due
&lt;span class="p"&gt;-&lt;/span&gt; Concurrency control: The worker atomically acquires the lease and transitions the expected state and version before acting. Terminal updates require the active attempt identity and expected version
&lt;span class="p"&gt;-&lt;/span&gt; Recovery behavior:
&lt;span class="p"&gt;  -&lt;/span&gt; &lt;span class="sb"&gt;`PendingDispatch`&lt;/span&gt;: dispatch the stored command with its existing operation ID and idempotency key
&lt;span class="p"&gt;  -&lt;/span&gt; &lt;span class="sb"&gt;`Submitting`&lt;/span&gt; or &lt;span class="sb"&gt;`OutcomeUnknown`&lt;/span&gt;: perform authoritative lookup or a safe idempotent retry of the stored command when permitted by the shared contract
&lt;span class="p"&gt;  -&lt;/span&gt; Confirmed saved: record the terminal success
&lt;span class="p"&gt;  -&lt;/span&gt; Confirmed rejected, or authoritatively absent after the contract's visibility window: record the terminal failure
&lt;span class="p"&gt;  -&lt;/span&gt; Non-authoritative absence, unknown, or unavailable: reschedule within the recovery policy; escalate when its deadline is exhausted
&lt;span class="p"&gt;-&lt;/span&gt; Retry override: Reuse the original operation ID, idempotency key, and command fingerprint; never create a new logical save
&lt;span class="p"&gt;-&lt;/span&gt; Degraded mode: None for saving; the returned recommendation remains valid
&lt;span class="p"&gt;-&lt;/span&gt; Consistency and idempotency: Lookup, retry, and recovery refer to one logical operation
&lt;span class="p"&gt;-&lt;/span&gt; Result publication: Persist the terminal status so the shopper and support tooling can resolve the original operation ID
&lt;span class="p"&gt;-&lt;/span&gt; Diagnostic signals: Operation ID, outcome class, attempt, age, and terminal result; exclude shortlist contents
&lt;span class="p"&gt;-&lt;/span&gt; Escalation path: Exhausted recovery uses the persistence-reconciliation runbook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recovery resolves the requested operation to a terminal outcome; it does not always complete the state change. It is deferred execution work. The discovery scheduler is operational: it scans eligible unresolved operations and exposes recovery lag. The feature's operating or admission policy decides whether a breached recovery objective stops new saves, changes save behavior, restricts traffic, or triggers operator intervention.&lt;/p&gt;

&lt;p&gt;Fallback routes and cross-region or cross-provider attempts also consume the same remaining budget unless the feature contract explicitly defines another execution model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserve the contract when degrading
&lt;/h2&gt;

&lt;p&gt;Another model is not automatically a safe fallback. It may behave differently with structured output, context limits, tools, latency, or safety controls. An older catalog version may restore access while violating the freshness limit. Skipping a failed validation step is not graceful degradation.&lt;/p&gt;

&lt;p&gt;For a general comparison, valid degradation may omit optional purchase-history personalization or unverified availability claims. When the request explicitly requires products that are available now, return a clearly identified partial result or state that the availability-backed request cannot be completed. A recommendation may still return with a separate save failure.&lt;/p&gt;

&lt;p&gt;The feature may return less, but it must not silently change what success means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use existing artifacts as inputs
&lt;/h2&gt;

&lt;p&gt;The register synthesizes existing decisions. Diagrams identify interactions and trust boundaries; failure analysis supplies failure classes; SLOs supply budgets; threat models and runbooks supply controls and escalation paths.&lt;/p&gt;

&lt;p&gt;Register entries can also inform trace design. Use stable interaction names and record duration, outcome, retry count, and controlled correlation identifiers. Do not log prompts, retrieved documents, purchase history, or tool arguments indiscriminately. Observability has its own access and retention boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Apply it one operation at a time
&lt;/h2&gt;

&lt;p&gt;Start with one user-visible operation:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Separate enforceable properties from quality criteria.&lt;/li&gt;
&lt;li&gt;Map and classify the interactions; split distinct boundaries and lifecycles.&lt;/li&gt;
&lt;li&gt;Link shared contracts and expand only risky rows.&lt;/li&gt;
&lt;li&gt;Test slow, stale, malformed, unauthorized, duplicated, unavailable, and unknown-outcome paths.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use it for protected data, retrieval, tools, state changes, authoritative claims, or operational obligations. A disposable local spike may need only basic error handling.&lt;/p&gt;

&lt;p&gt;A useful dependency register determines runtime behavior before an incident rather than merely explaining the incident afterward. A successful model response does not override a violated account boundary, stale price, unverified availability claim, or unconfirmed write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/calling-a-model-is-easy-running-an-ai-system-is-not/"&gt;Calling a Model Is Easy. Running an AI System Is Not&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/agentframework_1_17/"&gt;Observability for Agents with OpenTelemetry, Aspire, and Application Insights&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;p&gt;The dependency-register structure in this article is a synthesis. These references provide the broader failure-analysis, AI application-design, testing, retry, asynchronous-processing, and telemetry guidance behind it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/reliability/failure-mode-analysis" rel="noopener noreferrer"&gt;Architecture strategies for performing failure mode analysis&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/application-design" rel="noopener noreferrer"&gt;Application design for AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/test" rel="noopener noreferrer"&gt;Test and evaluate AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/retry" rel="noopener noreferrer"&gt;Retry pattern&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Builders' Library: &lt;a href="https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/" rel="noopener noreferrer"&gt;Making retries safe with idempotent APIs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/asynchronous-request-reply" rel="noopener noreferrer"&gt;Asynchronous Request-Reply pattern&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenTelemetry: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai" rel="noopener noreferrer"&gt;Generative AI semantic conventions&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>software</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Stop Letting Provider SDKs Define Your .NET AI Architecture</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Mon, 03 Aug 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/stop-letting-provider-sdks-define-your-net-ai-architecture-3c58</link>
      <guid>https://dev.to/lukaswalter/stop-letting-provider-sdks-define-your-net-ai-architecture-3c58</guid>
      <description>&lt;p&gt;.NET developers can call language models through several provider SDKs. That part is solved.&lt;/p&gt;

&lt;p&gt;The harder decision is where provider-specific code ends and application code begins.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; introduces shared abstractions such as &lt;code&gt;IChatClient&lt;/code&gt;, common message and response types, and a middleware pipeline that fits normal .NET dependency injection patterns. Provider SDKs still matter, but they can stay near the composition root instead of defining every application service.&lt;/p&gt;

&lt;p&gt;Models and providers still behave differently. The abstraction gives the application one stable place to handle the behavior it owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first generation was organized around the SDK
&lt;/h2&gt;

&lt;p&gt;A small AI feature often starts by injecting a provider client directly into the class that needs it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketSummarizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AzureOpenAIClient&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IConfiguration&lt;/span&gt; &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SummarizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ChatClient&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_DEPLOYMENT"&lt;/span&gt;&lt;span class="p"&gt;]!);&lt;/span&gt;

        &lt;span class="n"&gt;ClientResult&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatCompletion&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompleteChatAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;$"Summarize this support ticket:\n\n&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This code is fine for a spike or a feature that needs provider-specific behavior.&lt;/p&gt;

&lt;p&gt;The architectural problem appears when this shape becomes the default across the application:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;application services know provider client types&lt;/li&gt;
&lt;li&gt;deployment names and provider options leak into business code&lt;/li&gt;
&lt;li&gt;streaming, tools, and structured output use different provider-specific shapes&lt;/li&gt;
&lt;li&gt;testing requires provider SDK objects or network calls&lt;/li&gt;
&lt;li&gt;logging, telemetry, caching, and retries are reimplemented around individual calls&lt;/li&gt;
&lt;li&gt;changing a provider becomes an application-wide migration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point, the code follows the provider's model instead of the application's use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;IChatClient&lt;/code&gt; creates an application boundary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; defines &lt;code&gt;IChatClient&lt;/code&gt; as the common chat abstraction. Provider integrations adapt their concrete clients to that interface.&lt;/p&gt;

&lt;p&gt;The code samples use the current &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; and &lt;code&gt;Azure.AI.OpenAI&lt;/code&gt; APIs available at the time of writing. Check package versions before copying them into an application.&lt;/p&gt;

&lt;p&gt;The provider client still exists, but it is created at the edge of the application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.AI.OpenAI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Identity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;WebApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AzureOpenAIClient&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;]!),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;providerClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_DEPLOYMENT"&lt;/span&gt;&lt;span class="p"&gt;]!)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsIChatClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sourceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Application services now depend on the capability they need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketSummarizer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;SummarizeAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"Summarize support tickets without inventing facts."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The summarizer no longer knows how the provider authenticates, which deployment name is configured, or which middleware surrounds the request.&lt;/p&gt;

&lt;p&gt;That split is what matters. The application service owns summarization. The composition root owns the provider connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The abstraction is more than provider switching
&lt;/h2&gt;

&lt;p&gt;Provider portability gets most of the attention. I think it is the smaller benefit.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;IChatClient&lt;/code&gt; gives application code a shared shape for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chat messages and roles&lt;/li&gt;
&lt;li&gt;complete and streaming responses&lt;/li&gt;
&lt;li&gt;tools and function calls&lt;/li&gt;
&lt;li&gt;response metadata and usage when the provider supplies it&lt;/li&gt;
&lt;li&gt;dependency injection&lt;/li&gt;
&lt;li&gt;test doubles&lt;/li&gt;
&lt;li&gt;middleware composition&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Teams can build application behavior once around that interface.&lt;/p&gt;

&lt;p&gt;A scripted &lt;code&gt;IChatClient&lt;/code&gt; can test a summarizer, router, approval flow, or agent without calling a live model. Telemetry middleware can wrap whichever provider is configured, and integration tests or local development can supply another client without changing the application service.&lt;/p&gt;

&lt;p&gt;This is ordinary dependency inversion applied to model access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Middleware turns cross-cutting behavior into composition
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; package provides a chat-client builder that can compose behavior around the underlying client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providerClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sourceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFunctionInvocation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaximumIterationsPerRequest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern should look familiar to anyone who has worked with HTTP handlers or ASP.NET Core middleware.&lt;/p&gt;

&lt;p&gt;The function-invocation layer removes a particularly repetitive piece of provider-specific code. Without it, the application has to inspect each model response for tool calls, invoke the requested functions, add their results to the conversation, and call the model again until it produces a final response or reaches a stop condition. &lt;code&gt;UseFunctionInvocation()&lt;/code&gt; handles that loop around the inner client. The iteration limit in the example makes one of those stop conditions explicit.&lt;/p&gt;

&lt;p&gt;The host can add shared behavior once instead of repeating it inside every use case:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;telemetry&lt;/li&gt;
&lt;li&gt;function invocation&lt;/li&gt;
&lt;li&gt;caching&lt;/li&gt;
&lt;li&gt;logging&lt;/li&gt;
&lt;li&gt;resilience policies&lt;/li&gt;
&lt;li&gt;request or response inspection&lt;/li&gt;
&lt;li&gt;custom policy middleware&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Order still matters. Middleware is not a bag of attributes. A cache placed outside telemetry observes different behavior from telemetry placed outside the cache. Function invocation introduces additional model calls and tool executions that need their own budgets and traces.&lt;/p&gt;

&lt;p&gt;A useful pipeline makes runtime behavior easier to inspect. If the order is difficult to explain, the composition is too clever.&lt;/p&gt;

&lt;p&gt;Keep use-case policy outside the pipeline. Authorization, approval rules, data access, argument validation, and side-effect safety still need explicit application boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared interfaces do not erase provider differences
&lt;/h2&gt;

&lt;p&gt;An abstraction can standardize a programming model. It cannot make models equivalent.&lt;/p&gt;

&lt;p&gt;Providers and model families still differ in areas such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tool and function-calling behavior&lt;/li&gt;
&lt;li&gt;structured-output guarantees&lt;/li&gt;
&lt;li&gt;multimodal input support&lt;/li&gt;
&lt;li&gt;token accounting&lt;/li&gt;
&lt;li&gt;safety systems&lt;/li&gt;
&lt;li&gt;streaming details&lt;/li&gt;
&lt;li&gt;response metadata&lt;/li&gt;
&lt;li&gt;model-specific options&lt;/li&gt;
&lt;li&gt;rate limits and regional availability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Application code can depend on &lt;code&gt;IChatClient&lt;/code&gt; while the composition layer still chooses a concrete provider and exposes required provider-specific capabilities deliberately.&lt;/p&gt;

&lt;p&gt;If a feature depends on a provider-only API, represent that dependency honestly. Do not force it through a generic interface until the important behavior disappears.&lt;/p&gt;

&lt;p&gt;Keep provider coupling visible and contained. A generic interface that hides provider-only capabilities gives the application a misleading kind of portability.&lt;/p&gt;

&lt;h2&gt;
  
  
  One application may need more than one client
&lt;/h2&gt;

&lt;p&gt;Registering one global &lt;code&gt;IChatClient&lt;/code&gt; is enough for a small application. Larger systems often need clients with different purposes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a fast model for classification&lt;/li&gt;
&lt;li&gt;a stronger model for complex generation&lt;/li&gt;
&lt;li&gt;a restricted client for sensitive workflows&lt;/li&gt;
&lt;li&gt;a local model for development&lt;/li&gt;
&lt;li&gt;a separate embedding generator for retrieval&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not hide those differences behind runtime string comparisons throughout the application.&lt;/p&gt;

&lt;p&gt;Use explicit typed services, keyed DI registrations, or a small routing abstraction at the composition boundary. Keep stable application names separate from provider deployment names.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;classificationClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_CLASSIFICATION_DEPLOYMENT"&lt;/span&gt;&lt;span class="p"&gt;]!)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsIChatClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddKeyedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"classification"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;classificationClient&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTransient&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SupportClassifier&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;FromKeyedServices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"classification"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The application depends on a stable purpose,&lt;/span&gt;
    &lt;span class="c1"&gt;// not a provider deployment name.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the consuming service itself through DI so the container resolves its keyed dependency.&lt;/p&gt;

&lt;p&gt;Use a key such as &lt;code&gt;classification&lt;/code&gt; because it describes the application's purpose. Configuration decides which provider and model serve that purpose.&lt;/p&gt;

&lt;p&gt;This helps during local development too. The &lt;code&gt;classification&lt;/code&gt; registration can use a local model container during development and an Azure-backed client in the cloud. &lt;code&gt;SupportClassifier&lt;/code&gt; stays unchanged because its dependency is the application's purpose rather than the environment's provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agents and frameworks sit above this foundation
&lt;/h2&gt;

&lt;p&gt;Not every AI feature needs an agent.&lt;/p&gt;

&lt;p&gt;A direct &lt;code&gt;IChatClient&lt;/code&gt; call is often enough for summarization, classification, extraction, translation, or another bounded inference task.&lt;/p&gt;

&lt;p&gt;Agent and orchestration frameworks become useful when the application needs session state, dynamic tool selection, multi-step workflows, approvals, handoffs, or coordination between agents.&lt;/p&gt;

&lt;p&gt;Microsoft Agent Framework uses the &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; abstractions as a lower layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application use case
    -&amp;gt; agent or workflow when needed
    -&amp;gt; Microsoft.Extensions.AI abstractions
    -&amp;gt; provider integration
    -&amp;gt; model endpoint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start at the lowest layer that solves the problem. Reach for an agent only when the use case needs agent behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical migration path
&lt;/h2&gt;

&lt;p&gt;A full platform rewrite is unnecessary. Move one boundary at a time.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Find provider clients outside the composition root
&lt;/h3&gt;

&lt;p&gt;Search for application services, handlers, and controllers that construct or depend directly on provider SDK clients.&lt;/p&gt;

&lt;p&gt;Do not change provider-specific code that genuinely needs provider-specific APIs. Identify the calls that only need normal chat behavior.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Adapt the provider client to &lt;code&gt;IChatClient&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Create the provider client and deployment configuration in the host. Adapt it once, then register the resulting &lt;code&gt;IChatClient&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Move prompts and use-case policy into application services
&lt;/h3&gt;

&lt;p&gt;Create clients in the composition root. Keep business prompts, authorization rules, and output policy in application services.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Add one cross-cutting behavior through the pipeline
&lt;/h3&gt;

&lt;p&gt;I would start with telemetry. It shows which calls already pass through the new boundary before caching, retries, tools, or custom middleware complicate the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Replace network-heavy tests with a fake client
&lt;/h3&gt;

&lt;p&gt;Test deterministic application behavior with a scripted &lt;code&gt;IChatClient&lt;/code&gt;. Keep a smaller set of provider integration tests and separate evaluations for model quality.&lt;/p&gt;

&lt;p&gt;This migration produces value even if the provider never changes. The application becomes easier to compose, test, and operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use the shared abstraction
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;IChatClient&lt;/code&gt; when application code needs common chat, streaming, tool, or structured-response capabilities and should participate in normal .NET dependency injection and middleware composition.&lt;/p&gt;

&lt;p&gt;It is a strong default for reusable libraries, application services, tests, and cross-cutting telemetry.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to keep the provider SDK visible
&lt;/h2&gt;

&lt;p&gt;Use the provider SDK directly when the shared abstraction cannot represent a capability cleanly. Examples include provider-specific batch APIs, file management, fine-tuning operations, deployment administration, and specialized request options.&lt;/p&gt;

&lt;p&gt;Keep that code close to an adapter or infrastructure service. Do not let one specialized requirement pull provider types through the rest of the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;Provider SDKs connect .NET applications to model platforms. Keep that role at the edge of the application.&lt;/p&gt;

&lt;p&gt;Create the provider client at the edge. Let application code depend on &lt;code&gt;IChatClient&lt;/code&gt; or another purpose-specific interface. Compose telemetry, tools, caching, and other cross-cutting behavior through explicit middleware. Keep provider differences visible where they matter.&lt;/p&gt;

&lt;p&gt;The result looks like the rest of a maintainable .NET application. Services own use cases, while provider setup and shared runtime behavior stay centralized in the host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/agentframework_1_1/"&gt;Microsoft Agent Framework: Introduction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tips/keep-model-name-and-deployment-name-in-config/"&gt;Keep model name and deployment name in config&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/tips/use-opentelemetry-before-you-need-production-debugging/"&gt;Use OpenTelemetry before you need production debugging&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/agentframework_1_18/"&gt;Testing Microsoft Agent Framework Applications&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai" rel="noopener noreferrer"&gt;Microsoft.Extensions.AI libraries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/ichatclient" rel="noopener noreferrer"&gt;Use the &lt;code&gt;IChatClient&lt;/code&gt; interface&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/conceptual/calling-tools" rel="noopener noreferrer"&gt;AI tool calling&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.chatclientbuilderservicecollectionextensions.addchatclient" rel="noopener noreferrer"&gt;&lt;code&gt;AddChatClient&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.chatclientbuilderservicecollectionextensions.addkeyedchatclient" rel="noopener noreferrer"&gt;&lt;code&gt;AddKeyedChatClient&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/evaluation/libraries" rel="noopener noreferrer"&gt;&lt;code&gt;Microsoft.Extensions.AI.Evaluation&lt;/code&gt; libraries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/quickstarts/build-chat-app" rel="noopener noreferrer"&gt;Build an AI chat app with .NET&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/application-design" rel="noopener noreferrer"&gt;Application design for AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
    </item>
    <item>
      <title>Calling a Model Is Easy. Running an AI System Is Not</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/calling-a-model-is-easy-running-an-ai-system-is-not-180g</link>
      <guid>https://dev.to/lukaswalter/calling-a-model-is-easy-running-an-ai-system-is-not-180g</guid>
      <description>&lt;p&gt;A successful model call proves that one request reached one model and produced one response. It does not prove that the feature is reliable, secure, observable, affordable, or useful.&lt;/p&gt;

&lt;p&gt;That gap contains most of the engineering work.&lt;/p&gt;

&lt;p&gt;The model call might be ten lines of code. The production feature is everything that happens before, around, and after those ten lines. It still has to authenticate the user, retrieve authorized data, manage time and cost, validate the result, handle failures, record what happened, and decide whether the output may trigger an action.&lt;/p&gt;

&lt;p&gt;Do not design around the model call. Design around the complete application flow that contains it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A successful response proves very little
&lt;/h2&gt;

&lt;p&gt;The first working version of an AI feature often looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Summarize this support ticket."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a useful spike. It verifies credentials, connectivity, request formatting, and basic model compatibility.&lt;/p&gt;

&lt;p&gt;It does not answer the questions that determine whether the feature can run in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was the caller allowed to read the ticket?&lt;/li&gt;
&lt;li&gt;Which ticket data was sent to the model?&lt;/li&gt;
&lt;li&gt;Was sensitive content removed or retained?&lt;/li&gt;
&lt;li&gt;What happens when retrieval returns incomplete context?&lt;/li&gt;
&lt;li&gt;How long may the operation run?&lt;/li&gt;
&lt;li&gt;How many retries are allowed?&lt;/li&gt;
&lt;li&gt;What is the token and cost budget?&lt;/li&gt;
&lt;li&gt;How is the output checked before it is displayed or used?&lt;/li&gt;
&lt;li&gt;What happens when the provider is unavailable?&lt;/li&gt;
&lt;li&gt;Can an operator explain why this request failed?&lt;/li&gt;
&lt;li&gt;How will a model, prompt, or retrieval change be evaluated?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The model response is one intermediate value in a larger application flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace the complete request path
&lt;/h2&gt;

&lt;p&gt;Take a support-reply feature. A user asks the application to prepare a response for a ticket.&lt;/p&gt;

&lt;p&gt;The actual path is closer to this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authenticated user
    -&amp;gt; request validation
    -&amp;gt; ticket authorization
    -&amp;gt; ticket and customer context
    -&amp;gt; prompt construction
    -&amp;gt; model call
    -&amp;gt; output validation
    -&amp;gt; policy decision
    -&amp;gt; draft persistence
    -&amp;gt; response to the user
    -&amp;gt; traces, metrics, and audit data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each arrow is a boundary where latency, failure, cost, or incorrect assumptions can enter the system.&lt;/p&gt;

&lt;p&gt;The model may answer successfully while the feature still fails because the wrong customer context was retrieved. A technically valid response may contain an unsupported claim. A good draft may be stored under the wrong tenant. A timeout may leave the user unsure whether the request is still running. A retry may create a duplicate side effect.&lt;/p&gt;

&lt;p&gt;This is why production readiness cannot be measured by asking whether the model returned text.&lt;/p&gt;

&lt;p&gt;The model call is not the architecture. It is one step inside a request path whose other parts can fail even when the model succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each boundary an explicit contract
&lt;/h2&gt;

&lt;p&gt;The application becomes easier to reason about when the model call is surrounded by normal software boundaries.&lt;/p&gt;

&lt;p&gt;For the support-reply example, I would separate at least these responsibilities:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Boundary&lt;/th&gt;
&lt;th&gt;Contract&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;The input is valid, bounded, and associated with an authenticated caller.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;The caller may access this ticket and its supporting data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;The application can identify which data was retrieved and why it was included.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model interaction&lt;/td&gt;
&lt;td&gt;The request has an explicit model, prompt version, options, timeout, and budget.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;The result can be parsed, checked, and rejected before use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy&lt;/td&gt;
&lt;td&gt;Application code decides whether the output may be displayed, stored, or acted on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence&lt;/td&gt;
&lt;td&gt;Repeated processing does not create inconsistent or duplicate state.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operations&lt;/td&gt;
&lt;td&gt;Traces and metrics reveal where time, tokens, failures, and retries occurred.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few named responsibilities and clear enforcement points are enough. A large framework is optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the model behind an application service
&lt;/h2&gt;

&lt;p&gt;Application code should not scatter model calls across endpoints, background workers, and controllers. Put the use case behind a service that owns the complete flow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportReplyService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ITicketContextReader&lt;/span&gt; &lt;span class="n"&gt;contextReader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IReplyPolicy&lt;/span&gt; &lt;span class="n"&gt;replyPolicy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IReplyDraftStore&lt;/span&gt; &lt;span class="n"&gt;draftStore&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;SupportReplyResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;PrepareAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ClaimsPrincipal&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;TicketContext&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;contextReader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAuthorizedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;BuildMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;ReplyDecision&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;replyPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsAccepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SupportReplyResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Rejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;draftId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;draftStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SupportReplyResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Prepared&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draftId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example is deliberately incomplete. A production implementation still needs telemetry, budgets, failure mapping, prompt versioning, and tests.&lt;/p&gt;

&lt;p&gt;The ownership boundary is the point. The endpoint asks for a support reply. The application service coordinates the use case. The model client is only one dependency inside it.&lt;/p&gt;

&lt;p&gt;That gives you a place to add policy without teaching the UI, controller, or provider SDK about business rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability is a set of decisions, not a retry policy
&lt;/h2&gt;

&lt;p&gt;Retries are useful for some transient failures. They are not a complete reliability strategy.&lt;/p&gt;

&lt;p&gt;A retry can make the situation worse when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the original request is still running&lt;/li&gt;
&lt;li&gt;the model call is expensive&lt;/li&gt;
&lt;li&gt;the operation already changed state&lt;/li&gt;
&lt;li&gt;the provider is throttling the entire workload&lt;/li&gt;
&lt;li&gt;the failure is caused by invalid input or an unsupported capability&lt;/li&gt;
&lt;li&gt;the next attempt silently uses different context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For every dependency, decide what the system should do when it is slow, unavailable, or returns an unusable result.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Possible system decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model timeout&lt;/td&gt;
&lt;td&gt;Cancel the attempt and return a retryable application result.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider throttling&lt;/td&gt;
&lt;td&gt;Apply bounded backoff or queue the work when the result can arrive later.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval miss&lt;/td&gt;
&lt;td&gt;Return an explicit no-context result instead of asking the model to guess.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid structured output&lt;/td&gt;
&lt;td&gt;Reject the output or make one bounded repair attempt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy violation&lt;/td&gt;
&lt;td&gt;Stop before persistence or tool execution.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Persistence failure&lt;/td&gt;
&lt;td&gt;Do not report success; retry only through an idempotent operation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telemetry outage&lt;/td&gt;
&lt;td&gt;Continue only if telemetry is not part of an audit requirement.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The correct response depends on the use case. Make those decisions before production traffic makes them for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality, latency, and cost are one operating envelope
&lt;/h2&gt;

&lt;p&gt;AI features do not have one definition of success.&lt;/p&gt;

&lt;p&gt;A response can be accurate but too slow. It can be fast but insufficiently grounded. It can satisfy the user while consuming an unacceptable number of tokens. It can pass an offline evaluation while failing under production concurrency.&lt;/p&gt;

&lt;p&gt;Define an operating envelope for the complete feature. For a retrieval-backed support reply, that means an acceptable answer quality and retrieval miss rate, a maximum useful latency, a token or cost budget, and strict limits on tool calls or side effects. It also means deciding what happens after a timeout, which fallback is acceptable, and what data the application retains in telemetry.&lt;/p&gt;

&lt;p&gt;These constraints interact. Adding more context may improve one evaluation score while increasing latency and cost. Adding retries may improve completion rate while amplifying throttling. A fallback model may improve availability while changing behavior.&lt;/p&gt;

&lt;p&gt;Treat those as system tradeoffs, not model settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation and observability answer different questions
&lt;/h2&gt;

&lt;p&gt;Evaluation asks whether the system behaves well enough across representative cases.&lt;/p&gt;

&lt;p&gt;Observability asks what happened during a specific execution in a running environment.&lt;/p&gt;

&lt;p&gt;You need both.&lt;/p&gt;

&lt;p&gt;An evaluation set can detect that a prompt change reduced groundedness for refund questions. A trace can show that one production request spent six seconds in retrieval, made two model calls, invoked a tool, and then failed during persistence.&lt;/p&gt;

&lt;p&gt;Logs and traces do not prove answer quality, and a quality score does not explain a production timeout. Unit tests cover deterministic application behavior, but they do not measure nondeterministic result quality. One successful manual prompt establishes neither quality nor reliability.&lt;/p&gt;

&lt;p&gt;Keep deterministic application tests around authorization, routing, parsing, policy, and state changes. Add evaluations for model and retrieval behavior. Add telemetry across the boundaries that operators must diagnose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define production readiness at the feature level
&lt;/h2&gt;

&lt;p&gt;Before calling an AI feature production-ready, I would want explicit answers to these questions:&lt;/p&gt;

&lt;h3&gt;
  
  
  Behavior
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Which user outcome does the feature produce?&lt;/li&gt;
&lt;li&gt;Which examples show acceptable and unacceptable behavior?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Boundaries
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Where are authentication and authorization enforced?&lt;/li&gt;
&lt;li&gt;Which inputs and outputs are treated as untrusted?&lt;/li&gt;
&lt;li&gt;Which actions require deterministic policy or human approval?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Runtime
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;What are the time, token, cost, retry, and tool-call budgets?&lt;/li&gt;
&lt;li&gt;Can the caller cancel the work, and should it stay in the request, stream, or become a background job?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Failure handling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Which failures are retryable?&lt;/li&gt;
&lt;li&gt;What does the user see when a dependency is unavailable?&lt;/li&gt;
&lt;li&gt;Can partial work be resumed without repeating a side effect?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Operations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Can traces separate retrieval, model, tool, and persistence time?&lt;/li&gt;
&lt;li&gt;Are sensitive inputs and outputs excluded or deliberately governed?&lt;/li&gt;
&lt;li&gt;Can operators detect degraded quality, latency, cost, or error rate?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Change control
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;How are model, prompt, retrieval, and tool changes evaluated before release?&lt;/li&gt;
&lt;li&gt;Can the application roll back without changing stored data contracts?&lt;/li&gt;
&lt;li&gt;Are provider-specific capabilities explicit, or are they hidden behind an abstraction that pretends all models behave the same?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If these questions do not have answers, the system may still be a useful prototype. It is not ready to be operated as a dependable product feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this framing is useful
&lt;/h2&gt;

&lt;p&gt;This framing matters whenever an AI result affects a user workflow, accesses private data, depends on retrieval, invokes tools, changes state, has meaningful cost, or creates an operational promise. At that point, the model call is no longer the architecture. The complete request path is.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a direct model call is enough
&lt;/h2&gt;

&lt;p&gt;A small direct call is reasonable for a local experiment, an internal spike, or a low-risk disposable tool where failure has little consequence and no sensitive data or side effects are involved.&lt;/p&gt;

&lt;p&gt;Do not build a platform before you have a use case. Once the feature creates a real product promise, stop treating the model call as the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;Start with one important AI request in your application and draw its complete path.&lt;/p&gt;

&lt;p&gt;Mark every dependency, policy decision, data boundary, side effect, and operational signal. Then define what should happen when each part is slow, unavailable, or wrong.&lt;/p&gt;

&lt;p&gt;The model call will probably remain one of the smallest boxes in the diagram.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/when-ai-request-becomes-background-job/"&gt;When an AI Request Should Become a Background Job&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/cancellation-token/"&gt;Why CancellationToken Matters More in .NET AI Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/posts/agentframework_1_17/"&gt;Observability for Agents with OpenTelemetry, Aspire, and Application Insights&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="///posts/eval-first-in-.net/"&gt;Eval-first: Why "It Worked Once" Is Not a Sign of Quality&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/get-started" rel="noopener noreferrer"&gt;AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/design-principles" rel="noopener noreferrer"&gt;Design principles for AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Learn: &lt;a href="https://learn.microsoft.com/en-us/azure/well-architected/ai/application-design" rel="noopener noreferrer"&gt;Application design for AI workloads on Azure&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>dotnet</category>
      <category>llm</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Microsoft Foundry: When Agent Framework Agents Become Managed Resources</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Wed, 29 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/microsoft-foundry-when-agent-framework-agents-become-managed-resources-g19</link>
      <guid>https://dev.to/lukaswalter/microsoft-foundry-when-agent-framework-agents-become-managed-resources-g19</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This is Part 19 of my series on the Microsoft Agent Framework. You can read the original post over on &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_19/" rel="noopener noreferrer"&gt;lukaswalter.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_18/" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, we treated an agent as an application with testable boundaries: model calls, tools, structured output, routing, workflows, approvals, and failure handling.&lt;/p&gt;

&lt;p&gt;The testing approach assumes the agent belongs to your application. C# code creates it, the process owns the session, your application stores the conversation, and your deployment platform runs the service.&lt;/p&gt;

&lt;p&gt;Foundry gives you another place to put some of that responsibility. The agent can become a managed cloud resource with its own identity, endpoint, version, conversation state, access policy, and operational lifecycle. C# may still define its behavior, but the agent now exists outside your application process as well.&lt;/p&gt;

&lt;p&gt;The ownership decision comes before the hosting details. You can keep the agent inside your application and call Foundry services, deploy the Agent Framework code as a hosted agent, or let Foundry own more of the definition through a prompt agent. Each choice puts the runtime, state, identity, and business policy in a different place.&lt;/p&gt;

&lt;p&gt;Foundry does more than expose another model endpoint. Its control plane can handle enterprise identity, RBAC, governance, deployment, observability, and persistent state. You pay for that convenience with more resource boundaries, permissions, lifecycle decisions, and a tighter dependency on the platform.&lt;/p&gt;

&lt;p&gt;My rule is simple:&lt;/p&gt;

&lt;p&gt;Keep agent behavior and business policy in code when they need normal software engineering control.&lt;br&gt;
Use Foundry when the agent itself needs managed identity, durable state, controlled sharing, centralized governance, or a managed runtime.&lt;/p&gt;
&lt;h2&gt;
  
  
  Choose the ownership model first
&lt;/h2&gt;

&lt;p&gt;Start by separating model access, agent runtime, conversation state, identity, and business authorization. Choosing Foundry for one of them does not force you to hand over the others.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/overview" rel="noopener noreferrer"&gt;current Foundry Agent Service documentation&lt;/a&gt; describes two main agent types: prompt agents and hosted agents. There is also a useful third deployment choice for Agent Framework applications: keep the code in your own process and call the Foundry Responses API without creating a managed agent resource.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Where the main behavior lives&lt;/th&gt;
&lt;th&gt;What Foundry manages&lt;/th&gt;
&lt;th&gt;What your application still manages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt agent&lt;/td&gt;
&lt;td&gt;Instructions, model selection, and tools configured through the portal, SDK, or REST API&lt;/td&gt;
&lt;td&gt;Runtime, endpoint, scaling, identity, conversations, tools, and lifecycle&lt;/td&gt;
&lt;td&gt;Product integration, end-user authorization, and domain policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted agent&lt;/td&gt;
&lt;td&gt;Your Agent Framework code, packaged as a container or source bundle&lt;/td&gt;
&lt;td&gt;Hosted runtime, endpoint, scaling, dedicated agent identity, session persistence, observability, and lifecycle&lt;/td&gt;
&lt;td&gt;Agent logic, tools, business policy, tests, and the release artifact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In-process Agent Framework agent using Foundry&lt;/td&gt;
&lt;td&gt;Your web app, worker, or service&lt;/td&gt;
&lt;td&gt;Models, platform tools, and the project-scoped Responses API&lt;/td&gt;
&lt;td&gt;Hosting, scaling, application endpoint, state mapping, and deployment lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Foundry visual workflows are a separate orchestration capability, not a third agent type alongside prompt and hosted agents. Microsoft has announced that the &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/workflow" rel="noopener noreferrer"&gt;visual workflow experience will retire on December 1, 2026&lt;/a&gt; and recommends Microsoft Agent Framework for new workflow implementations. I treat workflows as migration context here, not as another ownership model.&lt;/p&gt;

&lt;p&gt;An application may need Foundry model and tool access while keeping the runtime in its own process. Create a managed agent resource only when you want Foundry to operate that runtime.&lt;/p&gt;
&lt;h2&gt;
  
  
  A local Agent Framework agent starts as application state
&lt;/h2&gt;

&lt;p&gt;In the local version, the application creates the &lt;code&gt;AIAgent&lt;/code&gt;, creates or restores the &lt;code&gt;AgentSession&lt;/code&gt;, invokes the model through &lt;code&gt;IChatClient&lt;/code&gt;, and owns the surrounding endpoint, storage, authorization, and observability.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Local Agent Framework application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent definition&lt;/td&gt;
&lt;td&gt;C# code, configuration, and registered tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent lifetime&lt;/td&gt;
&lt;td&gt;The process creates and uses the agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session lifetime&lt;/td&gt;
&lt;td&gt;In memory by default, or stored by an application-owned provider&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation identity&lt;/td&gt;
&lt;td&gt;An application concept such as a user or chat ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;The application authenticates to the model and downstream services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;Application code and tool policy decide what the user may do&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Your web app, worker, container, or job platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Versioning&lt;/td&gt;
&lt;td&gt;Source control, package versions, and your deployment pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Your OpenTelemetry, logs, and monitoring setup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This keeps the agent close to the code that owns its business rules. Your team also owns state, user mapping, deployment, scaling, endpoint security, versioning, and observability.&lt;/p&gt;
&lt;h2&gt;
  
  
  Foundry adds an agent control plane
&lt;/h2&gt;

&lt;p&gt;Foundry puts resource boundaries above the running agent. The request path then looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application-owned boundary
--------------------------
User interface or API
        |
        v
Application API
- authentication
- end-user authorization
- tenant policy
- conversation mapping
        |
        v

Foundry-managed control plane
-----------------------------
Foundry project endpoint
        |
        v
Prompt agent or hosted Agent Framework runtime
        |
        +--&amp;gt; model deployment
        +--&amp;gt; tools and connections
        +--&amp;gt; conversation or session state
        +--&amp;gt; traces, metrics, versions, and lifecycle metadata
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new URL is the least interesting part. What matters is that the agent now has a life outside one application process.&lt;/p&gt;

&lt;p&gt;Foundry can provide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a project boundary for agents, files, evaluations, and related assets&lt;/li&gt;
&lt;li&gt;managed hosting and scaling&lt;/li&gt;
&lt;li&gt;a dedicated hosted-agent identity and a separate project identity&lt;/li&gt;
&lt;li&gt;RBAC for creating, managing, and invoking agents&lt;/li&gt;
&lt;li&gt;versioned agent endpoints and optional publishing or distribution lifecycle&lt;/li&gt;
&lt;li&gt;managed conversation or session state&lt;/li&gt;
&lt;li&gt;tracing and monitoring integrations&lt;/li&gt;
&lt;li&gt;networking, storage, and encryption options for enterprise requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the control plane in place, platform and security teams can manage the agent as an operational resource instead of finding it buried inside an application deployment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hosted agents keep the code, but move the runtime
&lt;/h2&gt;

&lt;p&gt;For an existing Agent Framework application, a hosted agent is usually the shortest path into Foundry. Your orchestration, tools, approval logic, and workflow can remain in C#. Foundry takes over the deployment and runtime around that code.&lt;/p&gt;

&lt;p&gt;A minimal Responses host still creates an &lt;code&gt;AIAgent&lt;/code&gt;, but exposes it through the Foundry hosting boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;WebApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AIProjectClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"FOUNDRY_PROJECT_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;]!),&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_AI_MODEL_DEPLOYMENT_NAME"&lt;/span&gt;&lt;span class="p"&gt;]!,&lt;/span&gt;
        &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;        &lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt; &lt;span class="n"&gt;operators&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;Use&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="p"&gt;-&lt;/span&gt;&lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="n"&gt;automatically&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
        &lt;span class="n"&gt;Request&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;production&lt;/span&gt; &lt;span class="n"&gt;side&lt;/span&gt; &lt;span class="n"&gt;effects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
        &lt;span class="s"&gt;""",
&lt;/span&gt;        &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"deployment-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"An operations assistant for deployment checks."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddFoundryResponses&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;WebApplication&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapFoundryResponses&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Responses fits conversational agents because it supports streaming and message continuity through &lt;code&gt;previous_response_id&lt;/code&gt; or a conversation ID. Invocations is better suited to non-conversational work or a custom request and response shape.&lt;/p&gt;

&lt;p&gt;Do not confuse a &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-sessions" rel="noopener noreferrer"&gt;hosted session&lt;/a&gt; with a conversation. A hosted session identifies sandbox compute and persisted filesystem state such as &lt;code&gt;$HOME&lt;/code&gt; and &lt;code&gt;/files&lt;/code&gt;; a Responses conversation stores message, tool call, and response history. Reusing an &lt;code&gt;agent_session_id&lt;/code&gt; routes work to the same sandbox but does not replay prior messages. Invocations leaves conversation history to your application.&lt;/p&gt;

&lt;p&gt;A hosted deployment also needs an &lt;code&gt;azure.yaml&lt;/code&gt; file that declares the agent protocol, source directory, project, model deployment, and deployment mode. The current &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/init-agent-project" rel="noopener noreferrer"&gt;&lt;code&gt;azd ai agent init&lt;/code&gt; flow&lt;/a&gt; can generate this from existing Agent Framework code.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;azd&lt;/code&gt; extension surface is still moving quickly, so I would treat the &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/how-to/develop/framework-hosted-agents" rel="noopener noreferrer"&gt;official hosted-agent guide&lt;/a&gt; as the source of truth for exact commands. The command sequence is not the architectural point. What matters is that the same C# agent now has a deployed Foundry version, a dedicated endpoint, and a runtime identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat persistent agents as compatibility context
&lt;/h2&gt;

&lt;p&gt;Documentation searches may still surface &lt;code&gt;Azure.AI.Agents.Persistent&lt;/code&gt; and the older agent, thread, message, and run model. That API is real, but it belongs to the classic Azure AI Agents surface. Microsoft has &lt;a href="https://learn.microsoft.com/en-us/azure/foundry-classic/agents/whats-new" rel="noopener noreferrer"&gt;deprecated Agents (classic)&lt;/a&gt; and lists March 31, 2027 as its retirement date.&lt;/p&gt;

&lt;p&gt;For new systems, choose the current Foundry Agent Service surface first: Responses API for in-process Agent Framework integrations, hosted agents when Foundry should run your Agent Framework code, and prompt agents when Foundry should own the agent definition. Use the persistent client mainly when maintaining or migrating an existing classic implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A managed conversation is still an application data record
&lt;/h2&gt;

&lt;p&gt;With a local &lt;code&gt;AgentSession&lt;/code&gt;, you decide whether the session is a short-lived object, a serialized row, or a custom &lt;code&gt;ChatHistoryProvider&lt;/code&gt;. Responses, hosted sessions, and classic persistent threads can move part of that durable record into Foundry.&lt;/p&gt;

&lt;p&gt;The convenience is real, but state now crosses an application boundary.&lt;/p&gt;

&lt;p&gt;The application now needs an explicit mapping such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application user and tenant
        |
        +--&amp;gt; application conversation ID
                    |
                    +--&amp;gt; Foundry conversation, response, or session ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not treat a raw service ID as authorization. The request handler must still verify that the authenticated caller may access the mapped conversation.&lt;/p&gt;

&lt;p&gt;A simple repository boundary keeps that decision in your application:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ConversationBinding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ApplicationConversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;TenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;UserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FoundryStateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FoundryStateKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AgentVersion&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IConversationBindingStore&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ConversationBinding&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;FindAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;applicationConversationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;ConversationBinding&lt;/span&gt; &lt;span class="n"&gt;binding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Foundry ID and its protocol-specific kind are implementation details. Keep an application-owned record so you have somewhere to enforce tenant isolation, user ownership, agent version selection, retention decisions, and audit correlation.&lt;/p&gt;

&lt;p&gt;Before calling managed conversation state "memory solved," answer these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How long should inactive conversations remain available?&lt;/li&gt;
&lt;li&gt;Can a user export or delete their conversation?&lt;/li&gt;
&lt;li&gt;Can support staff inspect it, and under which role?&lt;/li&gt;
&lt;li&gt;What happens when the agent version changes?&lt;/li&gt;
&lt;li&gt;Which data may be stored in the managed service?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Foundry provides storage and retention building blocks. In the basic agent setup, files, conversations, and related state can use Microsoft-managed multitenant storage with logical separation. The &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/concepts/architecture#agent-state-storage" rel="noopener noreferrer"&gt;standard agent setup&lt;/a&gt; lets you bring your own Azure resources for customer data, including files, conversations, and vector stores.&lt;/p&gt;

&lt;p&gt;Your product remains responsible for retention, deletion, export, and support access policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  RBAC introduces a second authorization plane
&lt;/h2&gt;

&lt;p&gt;In a local application, developers may have repository access and an application identity may have access to the model endpoint. The application then decides what each end user can do.&lt;/p&gt;

&lt;p&gt;Foundry adds a second access plane around the resource and project.&lt;/p&gt;

&lt;p&gt;The current Foundry architecture separates control-plane actions from data-plane actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;control plane: create resources, projects, model deployments, and security configuration&lt;/li&gt;
&lt;li&gt;data plane: build agents, run evaluations, upload files, invoke agents, and work with project assets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those permissions can be scoped at the Foundry resource and project levels.&lt;/p&gt;

&lt;p&gt;The role names are changing too. Current documentation uses names such as &lt;code&gt;Foundry User&lt;/code&gt;, &lt;code&gt;Foundry Project Manager&lt;/code&gt;, &lt;code&gt;Foundry Account Owner&lt;/code&gt;, and &lt;code&gt;Foundry Agent Consumer&lt;/code&gt;. Older pages and existing role assignments may still show the previous names.&lt;/p&gt;

&lt;p&gt;For that reason, a role table in an architecture document should record the role purpose and scope instead of copying a role name alone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Actor&lt;/th&gt;
&lt;th&gt;Typical responsibility&lt;/th&gt;
&lt;th&gt;Important boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Platform administrator&lt;/td&gt;
&lt;td&gt;Create the Foundry resource, networking, model deployments, and shared connections&lt;/td&gt;
&lt;td&gt;Resource or subscription scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Project manager&lt;/td&gt;
&lt;td&gt;Configure and deploy project agents and hosted versions&lt;/td&gt;
&lt;td&gt;Project or resource scope, depending on operation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent developer&lt;/td&gt;
&lt;td&gt;Build and test agents, tools, evaluations, and prompts&lt;/td&gt;
&lt;td&gt;Prefer project scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent consumer&lt;/td&gt;
&lt;td&gt;Invoke an agent endpoint without broad development access&lt;/td&gt;
&lt;td&gt;Project or individual-agent scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Application managed identity&lt;/td&gt;
&lt;td&gt;Call the project endpoint from the application&lt;/td&gt;
&lt;td&gt;Only the required project or agent permissions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent identity&lt;/td&gt;
&lt;td&gt;Reach models, search, storage, MCP servers, or APIs&lt;/td&gt;
&lt;td&gt;Downstream resource scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;End user&lt;/td&gt;
&lt;td&gt;Use an agent through the product&lt;/td&gt;
&lt;td&gt;Application authorization, not automatically Azure RBAC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every hosted agent receives a dedicated Microsoft Entra identity and endpoint when deployed. That agent identity is the runtime identity. The project managed identity handles project-level infrastructure operations and cannot stand in for the hosted agent's identity. The agent identity still needs explicit role assignments for any external Azure resources it uses.&lt;/p&gt;

&lt;p&gt;Use Microsoft Entra authentication when you need this granularity.&lt;br&gt;
Key-based authentication does not provide the same role-level separation because the key grants broad access to the service.&lt;/p&gt;

&lt;p&gt;Broad owner permissions are a tempting shortcut when the first setup fails. They may unblock a prototype while quietly turning every developer into a resource administrator.&lt;/p&gt;

&lt;p&gt;The subtler mistake is treating the agent's managed identity as end-user authorization. It is not.&lt;/p&gt;

&lt;p&gt;Suppose the agent can read an Azure AI Search index. The current user may still be allowed to see only some of its documents. User and tenant filters belong in the retrieval boundary. The same rule applies to tools that read tickets, invoices, deployments, or customer records.&lt;/p&gt;

&lt;p&gt;Foundry RBAC controls who can manage or invoke the agent resource.&lt;br&gt;
Your application still needs to control what a particular user may ask that agent to do.&lt;/p&gt;
&lt;h2&gt;
  
  
  Governance becomes part of the architecture
&lt;/h2&gt;

&lt;p&gt;When an agent lives only in code, the repository is usually the best record of its definition. Once it becomes a managed resource, its effective behavior may also depend on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;instructions configured in the portal or resource&lt;/li&gt;
&lt;li&gt;model and deployment selection&lt;/li&gt;
&lt;li&gt;tool definitions and connection settings&lt;/li&gt;
&lt;li&gt;content filters and guardrails&lt;/li&gt;
&lt;li&gt;project-level permissions&lt;/li&gt;
&lt;li&gt;managed identity assignments&lt;/li&gt;
&lt;li&gt;network configuration&lt;/li&gt;
&lt;li&gt;stored files and conversation state&lt;/li&gt;
&lt;li&gt;deployed version, endpoint routing, and optional publishing settings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The C# class is now only part of the system. Foundry can add versioned endpoints, optional publishing and distribution, centralized monitoring, and resource-level governance. It can also introduce configuration drift.&lt;/p&gt;

&lt;p&gt;A portal edit to instructions or tools is enough to make the repository disagree with the agent running in production.&lt;/p&gt;

&lt;p&gt;I would treat the Foundry configuration as code or as a versioned deployment artifact wherever possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;source code and prompt definitions
        |
        v
tests and evaluations
        |
        v
Foundry project configuration
        |
        v
deployed agent version
        |
        v
dedicated agent endpoint
        |
        v
optional publishing or distribution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a prompt agent, I would create and update the definition through an SDK or REST API in the deployment pipeline rather than make production-only portal edits. With a hosted Agent Framework agent, the C# code remains the main behavior definition and the Foundry resource describes its runtime and deployment.&lt;/p&gt;

&lt;p&gt;Whichever model you use, pick one authoritative layer.&lt;/p&gt;

&lt;p&gt;Treat instructions, tools, model deployment names, connections, identity assignments, and protocol versions as release inputs. Promote tested versions through development, test, and production instead of editing the production agent casually.&lt;/p&gt;

&lt;p&gt;Keep trace correlation across both ownership planes. A production trace should connect the application request, tenant, user, application conversation, Foundry agent version, Foundry state ID, model and tool spans, approvals, and side-effect IDs. Without that chain, Foundry may show a healthy agent run that you still cannot connect to a customer's request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Split responsibility deliberately
&lt;/h2&gt;

&lt;p&gt;Foundry can take over operations around the agent. Domain responsibility stays with your application.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Keep in application code&lt;/th&gt;
&lt;th&gt;Foundry can manage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agent behavior&lt;/td&gt;
&lt;td&gt;Business rules, tool contracts, structured results, and deterministic workflow transitions&lt;/td&gt;
&lt;td&gt;Prompt-agent configuration or the runtime around hosted code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization&lt;/td&gt;
&lt;td&gt;User, tenant, and record-level access checks&lt;/td&gt;
&lt;td&gt;RBAC for managing and invoking Foundry resources and endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Side effects&lt;/td&gt;
&lt;td&gt;Validation, approval policy, idempotency, and audit records&lt;/td&gt;
&lt;td&gt;Agent identity and secure access to downstream resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation data&lt;/td&gt;
&lt;td&gt;Product conversation ID, ownership mapping, retention policy, and domain data&lt;/td&gt;
&lt;td&gt;Response history, hosted sessions, or managed conversation storage, depending on the protocol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality&lt;/td&gt;
&lt;td&gt;Deterministic tests for tools, routing, workflows, approvals, and failures&lt;/td&gt;
&lt;td&gt;Evaluations, tracing, metrics, and managed monitoring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment&lt;/td&gt;
&lt;td&gt;Source review, release inputs, and promotion policy&lt;/td&gt;
&lt;td&gt;Hosted runtime, scaling, endpoint, agent versions, and platform lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Governance&lt;/td&gt;
&lt;td&gt;The authoritative definition of business behavior&lt;/td&gt;
&lt;td&gt;Project boundaries, identities, connections, networking, encryption, and resource inventory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A prompt such as "only show records the user can access" is not an authorization system. The API or tool must enforce user, tenant, and permission filters before returning data to the agent.&lt;/p&gt;

&lt;p&gt;The same applies to side effects. Foundry governance does not make a model-authorized deployment, refund, email, or permission change safe by itself. The application must still decide whether the action is allowed and whether it needs human approval, as described in the &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_15/" rel="noopener noreferrer"&gt;human-in-the-loop article&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Foundry evaluations complement deterministic tests. They do not replace them. The service conversation schema is an integration contract, not your product's domain model.&lt;/p&gt;

&lt;p&gt;Pick the side of the table based on the operational work your team wants to own. Keep the domain responsibilities that cannot be delegated in application code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use Foundry
&lt;/h2&gt;

&lt;p&gt;I would consider Foundry when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent needs a managed runtime instead of another application-owned container&lt;/li&gt;
&lt;li&gt;multiple products or teams need to invoke a shared agent&lt;/li&gt;
&lt;li&gt;the agent needs durable conversations or managed sessions&lt;/li&gt;
&lt;li&gt;platform teams need project boundaries and centralized RBAC&lt;/li&gt;
&lt;li&gt;an agent needs a dedicated identity for downstream resources&lt;/li&gt;
&lt;li&gt;security and compliance teams need network, storage, encryption, or audit controls&lt;/li&gt;
&lt;li&gt;the organization needs versioned endpoints, optional distribution, monitoring, and an agent inventory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hosted agents fit an existing Agent Framework application well: the orchestration stays in code and the runtime becomes a managed resource.&lt;/p&gt;

&lt;p&gt;Managed hosting may cost more than expected. &lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents#pricing" rel="noopener noreferrer"&gt;Hosted-agent runtime billing&lt;/a&gt; is based on CPU and memory consumed across active sessions, so concurrency and oversized sandboxes multiply the cost. An in-process agent can reuse existing application compute, while leaving scaling, isolation, and operations with your team. Compare the full operating cost of both models, including the engineering work outside the compute bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would stay local
&lt;/h2&gt;

&lt;p&gt;I would keep the agent primarily in the application when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it is a small feature used by one product&lt;/li&gt;
&lt;li&gt;the conversation state already belongs in the product database&lt;/li&gt;
&lt;li&gt;the agent must run locally or in multiple clouds&lt;/li&gt;
&lt;li&gt;the application owns complex domain workflows and side effects&lt;/li&gt;
&lt;li&gt;the agent changes frequently and needs tight source-level review&lt;/li&gt;
&lt;li&gt;a Foundry project would add more permissions and deployment ceremony than value&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using Foundry models and platform tools through the Responses API does not force you to create a managed agent. Model access and agent lifecycle are separate decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Microsoft Agent Framework gives you the .NET runtime abstractions. Foundry can put a managed control plane around them. Once it does, the agent definition, conversation state, runtime identity, deployed version, and operational lifecycle may no longer belong to one application team.&lt;/p&gt;

&lt;p&gt;I would keep Agent Framework in the application when the agent should share the product's host, state, and release cycle. A hosted agent fits when the C# orchestration should remain intact but the runtime needs a managed endpoint, identity, and lifecycle. A prompt agent makes sense when Foundry should own the definition too.&lt;/p&gt;

&lt;p&gt;None of these options lets the model decide business authorization or side-effect policy. Keep deterministic controls and application conversation IDs in code. Version the complete release input, and correlate Foundry state with the product request that created it.&lt;/p&gt;

&lt;p&gt;All three options use code somewhere. The decision is about what happens after deployment: which team owns the agent, and where do they need to operate it?&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/overview/" rel="noopener noreferrer"&gt;Microsoft Agent Framework overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/overview" rel="noopener noreferrer"&gt;What is Microsoft Foundry Agent Service?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/concepts/architecture" rel="noopener noreferrer"&gt;Microsoft Foundry architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/concepts/rbac-foundry" rel="noopener noreferrer"&gt;Role-based access control for Microsoft Foundry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/quickstarts/responses-api" rel="noopener noreferrer"&gt;Build agents using the Responses API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/how-to/develop/framework-hosted-agents" rel="noopener noreferrer"&gt;Host Microsoft Agent Framework agents as Foundry hosted agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents" rel="noopener noreferrer"&gt;Hosted agents in Foundry Agent Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/standard-agent-setup" rel="noopener noreferrer"&gt;Foundry standard agent setup and customer-owned resources&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_18/" rel="noopener noreferrer"&gt;Testing Microsoft Agent Framework Applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_17/" rel="noopener noreferrer"&gt;Observability for Agents with OpenTelemetry, Aspire, and Application Insights&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
    </item>
    <item>
      <title>When an AI Request Should Become a Background Job</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Mon, 27 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/when-an-ai-request-should-become-a-background-job-1887</link>
      <guid>https://dev.to/lukaswalter/when-an-ai-request-should-become-a-background-job-1887</guid>
      <description>&lt;p&gt;LLM calls introduce unpredictable network latency, but latency alone does not decide the execution model. An AI request should become a background job when its result still matters after the HTTP request has ended.&lt;/p&gt;

&lt;p&gt;A response that takes 30 seconds may still belong in an interactive streaming request. A five-second operation may already need a background job if it triggers durable work, must survive a deployment, or needs retries and duplicate handling outside the original HTTP request.&lt;/p&gt;

&lt;p&gt;The boundary is ownership:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A synchronous request is owned by the current HTTP exchange.&lt;/li&gt;
&lt;li&gt;A streaming request is still owned by that exchange, but returns partial output while work continues.&lt;/li&gt;
&lt;li&gt;A background job is owned by the application after the request has been accepted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That distinction changes the API contract, cancellation model, retry behavior, state storage, and operational responsibilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the lifecycle decision
&lt;/h2&gt;

&lt;p&gt;Before adding a queue, ask what should happen when the client disconnects or the application restarts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Execution model&lt;/th&gt;
&lt;th&gt;Good fit&lt;/th&gt;
&lt;th&gt;Important limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Synchronous request&lt;/td&gt;
&lt;td&gt;Short, bounded work whose result is needed immediately&lt;/td&gt;
&lt;td&gt;The caller and server must keep the request alive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming request&lt;/td&gt;
&lt;td&gt;Interactive output where partial progress improves the experience&lt;/td&gt;
&lt;td&gt;Streaming does not make the work durable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background job&lt;/td&gt;
&lt;td&gt;Work that must survive disconnects, expose status, or continue independently&lt;/td&gt;
&lt;td&gt;Requires persistent state and an explicit job lifecycle&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep the operation inside the request when all of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the caller needs the result immediately&lt;/li&gt;
&lt;li&gt;the work has a bounded execution time&lt;/li&gt;
&lt;li&gt;a disconnected caller no longer needs the result&lt;/li&gt;
&lt;li&gt;retrying the complete request is safe&lt;/li&gt;
&lt;li&gt;the hosting path can keep the connection open long enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Move it to a background job when one or more of these are true:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the result must survive a browser refresh, network interruption, or deployment&lt;/li&gt;
&lt;li&gt;the operation contains multiple model, retrieval, or tool steps&lt;/li&gt;
&lt;li&gt;the caller needs progress or a status page&lt;/li&gt;
&lt;li&gt;retries must happen without resubmitting the original HTTP request&lt;/li&gt;
&lt;li&gt;the workload needs queue-based load leveling or concurrency limits&lt;/li&gt;
&lt;li&gt;the operation creates an artifact that can be retrieved later&lt;/li&gt;
&lt;li&gt;duplicate submissions could create duplicate cost or side effects&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is not limited to chat. Document extraction, large summarization jobs, batch classification, evaluation runs, ingestion pipelines, audio transcription, and report generation are common candidates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming is not a durability mechanism
&lt;/h2&gt;

&lt;p&gt;Streaming solves a user-experience problem. It lets the caller see tokens, events, or progress before the complete result is available.&lt;/p&gt;

&lt;p&gt;It does not answer what happens when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the browser closes&lt;/li&gt;
&lt;li&gt;a reverse proxy ends the connection&lt;/li&gt;
&lt;li&gt;the application instance restarts&lt;/li&gt;
&lt;li&gt;the client retries after losing the final response&lt;/li&gt;
&lt;li&gt;the workflow must continue for several minutes&lt;/li&gt;
&lt;li&gt;another device needs to retrieve the result later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A streaming operation can also be backed by a durable job, but that is a separate design. The stream then becomes one view over persisted job events rather than the only place where progress exists.&lt;/p&gt;

&lt;p&gt;Use streaming when the interaction itself is the product. Use a job when completing and recording the work is the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request-bound version is simple, but fragile
&lt;/h2&gt;

&lt;p&gt;The direct version of a document-analysis endpoint is easy to write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"/documents/{documentId:guid}/analysis"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;DocumentRepository&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AnalysisRepository&lt;/span&gt; &lt;span class="n"&gt;analyses&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;$"Analyze this document:\n\n&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;analysis&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;analyses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;analysis&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a valid design when the caller is expected to wait and the operation can be safely repeated.&lt;/p&gt;

&lt;p&gt;It becomes a poor fit when the analysis must outlive the request. Passing the request cancellation token through the complete operation correctly stops work when the caller leaves, but that is the opposite of durability. Ignoring the token is not a durable solution either. The work still has no stable identifier, no persisted status, and no reliable recovery path after a process restart.&lt;/p&gt;

&lt;p&gt;The endpoint needs a different contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return a job resource, not an unfinished result
&lt;/h2&gt;

&lt;p&gt;For asynchronous processing, the initial request should validate and accept the work, create a stable job identifier, and return quickly.&lt;/p&gt;

&lt;p&gt;The common HTTP shape is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /documents/{documentId}/analysis-jobs
    -&amp;gt; 202 Accepted
    -&amp;gt; Location: /analysis-jobs/{jobId}
    -&amp;gt; Retry-After: 5

GET /analysis-jobs/{jobId}
    -&amp;gt; Pending | Running | Succeeded | Failed | Canceled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;202 Accepted&lt;/code&gt; does not mean the analysis succeeded. It means the server accepted responsibility for processing it.&lt;/p&gt;

&lt;p&gt;A minimal endpoint can make that contract explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"/documents/{documentId:guid}/analysis-jobs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HttpContext&lt;/span&gt; &lt;span class="n"&gt;httpContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;AnalysisJobSubmissionService&lt;/span&gt; &lt;span class="n"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;idempotencyKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;httpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Idempotency-Key"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"An Idempotency-Key header is required."&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;submission&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SubmitAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;httpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;statusUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"/analysis-jobs/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;httpContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Retry-After"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"5"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statusUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;submission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;StatusUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statusUrl&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;AnalysisJobSubmissionService&lt;/code&gt; is the important application boundary. It should:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify that the authenticated caller may analyze the document.&lt;/li&gt;
&lt;li&gt;Scope the idempotency key to the caller or tenant and operation.&lt;/li&gt;
&lt;li&gt;Bind the key to a request fingerprint.&lt;/li&gt;
&lt;li&gt;Check whether that scoped key already belongs to an existing submission.&lt;/li&gt;
&lt;li&gt;Return the existing job only when the stored and incoming fingerprints match.&lt;/li&gt;
&lt;li&gt;Reject the request when the key exists with a different fingerprint.&lt;/li&gt;
&lt;li&gt;Persist the new job as &lt;code&gt;Pending&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Arrange for the job identifier to reach the worker.&lt;/li&gt;
&lt;li&gt;Return the job to the endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fingerprint should cover every input that changes the meaning of the work, such as the &lt;code&gt;documentId&lt;/code&gt;, operation type, prompt or workflow version, and relevant options. Reusing the same key for a different fingerprint should produce a conflict response instead of returning an unrelated job. Enforce the scoped key with a unique database constraint so concurrent submissions cannot both create a job.&lt;/p&gt;

&lt;p&gt;Persisting the row and publishing a queue message are two separate writes. If losing a job between them is unacceptable, use an outbox or another design that can reliably recover pending jobs. A database commit followed by an unprotected queue send leaves a failure window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persist the job lifecycle
&lt;/h2&gt;

&lt;p&gt;The queue message should not be the only record that the job exists. Keep a persistent job resource that the API and worker can both use.&lt;/p&gt;

&lt;p&gt;A practical job record usually contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;job ID&lt;/li&gt;
&lt;li&gt;owner or tenant ID&lt;/li&gt;
&lt;li&gt;operation type&lt;/li&gt;
&lt;li&gt;input reference&lt;/li&gt;
&lt;li&gt;idempotency key&lt;/li&gt;
&lt;li&gt;request fingerprint&lt;/li&gt;
&lt;li&gt;state&lt;/li&gt;
&lt;li&gt;creation and update timestamps&lt;/li&gt;
&lt;li&gt;application processing attempt count&lt;/li&gt;
&lt;li&gt;prompt or workflow version&lt;/li&gt;
&lt;li&gt;model or deployment identifier&lt;/li&gt;
&lt;li&gt;result reference&lt;/li&gt;
&lt;li&gt;safe error code and message&lt;/li&gt;
&lt;li&gt;cancellation request timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not place raw prompts, private documents, or credentials in the queue message unless the queue is explicitly designed and governed for that data. A small message containing a job ID is easier to retry, inspect, and secure. The worker can load the authorized input from the system of record.&lt;/p&gt;

&lt;p&gt;Use a closed set of states. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AnalysisJobStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Running&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Succeeded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationRequested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Canceled&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terminal states should remain terminal. If a duplicate queue delivery reaches a job that is already &lt;code&gt;Succeeded&lt;/code&gt;, &lt;code&gt;Failed&lt;/code&gt;, or &lt;code&gt;Canceled&lt;/code&gt;, the processor should exit without running the model again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the worker separate from the HTTP request
&lt;/h2&gt;

&lt;p&gt;The worker owns execution after submission. A &lt;code&gt;BackgroundService&lt;/code&gt; is a useful .NET hosting boundary, but the durability comes from the queue and job store, not from &lt;code&gt;BackgroundService&lt;/code&gt; itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AnalysisJobWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;IAnalysisJobQueue&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IServiceScopeFactory&lt;/span&gt; &lt;span class="n"&gt;scopeFactory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ILogger&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisJobWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ExecuteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;delivery&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopeFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsyncScope&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceProvider&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AnalysisJobProcessor&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

            &lt;span class="k"&gt;try&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ProcessAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OperationCanceledException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stoppingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsCancellationRequested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;settlementTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReleaseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settlementTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="s"&gt;"Analysis job {JobId} failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JobId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;settlementTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;CancellationTokenSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FailAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settlementTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sample uses application abstractions because the queue semantics matter. &lt;code&gt;CompleteAsync&lt;/code&gt; acknowledges successful processing. &lt;code&gt;FailAsync&lt;/code&gt; should map the exception to the queue's retry or dead-letter behavior. &lt;code&gt;ReleaseAsync&lt;/code&gt; makes an interrupted delivery available again when the host shuts down. Settlement uses a short, independent timeout because the host shutdown token may already be canceled. Choose the timeout and settlement behavior for the backing broker.&lt;/p&gt;

&lt;p&gt;The backing queue determines what those operations can guarantee:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bounded &lt;code&gt;Channel&amp;lt;T&amp;gt;&lt;/code&gt; can provide backpressure inside one process, but queued items disappear when that process stops.&lt;/li&gt;
&lt;li&gt;A durable broker such as Azure Service Bus can survive restarts and support redelivery, but the processor must handle duplicate delivery.&lt;/li&gt;
&lt;li&gt;A database-backed queue can keep job state and dispatch close together, but needs safe claiming and concurrency control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not describe an in-memory queue as durable. It is appropriate only when the work is disposable or can be reconstructed from the persistent job store.&lt;/p&gt;

&lt;p&gt;Broker delivery count and application processing attempts are related but different signals. Redelivery can happen before business processing starts, while one delivery may contain multiple internal attempts. Use broker metadata for transport diagnostics and record the processing attempts that matter to the workflow in the job store.&lt;/p&gt;

&lt;p&gt;The worker is registered as a singleton hosted service. Creating a dependency-injection scope for each job lets the processor use scoped services such as &lt;code&gt;DbContext&lt;/code&gt; without keeping one instance alive for the complete worker lifetime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make processing idempotent
&lt;/h2&gt;

&lt;p&gt;There are two places where duplication can occur:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The caller retries the submission because it did not receive the first response.&lt;/li&gt;
&lt;li&gt;The queue redelivers a job because processing failed before acknowledgement.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The submission idempotency key handles the first case. The persistent job state and an idempotent processor handle the second.&lt;/p&gt;

&lt;p&gt;Before making an expensive model call, the processor should load the job and decide whether work is still required. Before committing a side effect, it should verify that the same effect has not already been applied.&lt;/p&gt;

&lt;p&gt;That check must include an atomic claim. Move the job from &lt;code&gt;Pending&lt;/code&gt; to &lt;code&gt;Running&lt;/code&gt; with a conditional update guarded by the current state, an optimistic concurrency token such as a row version, or a time-bound lease. If the claim fails, another worker owns the job and the current delivery should exit or be released. A lease also needs an expiry and recovery rule so a crashed worker does not leave the job stuck in &lt;code&gt;Running&lt;/code&gt; forever.&lt;/p&gt;

&lt;p&gt;For model-only work, repeating a call may only duplicate cost. For jobs that send messages, update records, or invoke tools, repeating the job can also duplicate business effects. Those effects need their own idempotency boundary. The model should not be responsible for deciding whether an action already happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate request cancellation from job cancellation
&lt;/h2&gt;

&lt;p&gt;The cancellation token on the submission endpoint belongs to the HTTP request. It is useful while validating and persisting the submission. It should not become the lifetime of the accepted job.&lt;/p&gt;

&lt;p&gt;Once the API returns &lt;code&gt;202 Accepted&lt;/code&gt;, cancellation becomes an application operation. A typical API exposes something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /analysis-jobs/{jobId}/cancellation
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That endpoint records &lt;code&gt;CancellationRequested&lt;/code&gt;. The worker checks that state before expensive boundaries and passes a job-specific cancellation signal to model, retrieval, and tool calls where possible.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DELETE /analysis-jobs/{jobId}&lt;/code&gt; is also defensible when deleting the resource intentionally means requesting cancellation. If the job remains available and only changes state, an explicit cancellation operation makes that contract clearer.&lt;/p&gt;

&lt;p&gt;Cancellation is cooperative. The application still needs to decide what happens to partial results and external side effects. A completed email cannot be canceled. A partially written report may need cleanup. A tool call may need compensation rather than cancellation.&lt;/p&gt;

&lt;p&gt;Host shutdown is different again. The &lt;code&gt;stoppingToken&lt;/code&gt; tells the worker that the process is stopping. With a durable queue, unfinished work should be released for redelivery rather than silently marked as a business failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expose useful status without leaking internals
&lt;/h2&gt;

&lt;p&gt;The status endpoint is part of the public contract, not an operational log viewer.&lt;/p&gt;

&lt;p&gt;Return information the caller can act on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jobId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"c7d8d96c-45ef-4c40-9a96-d6d12b9db4ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"createdAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-19T08:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lastUpdatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-19T08:30:07Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resultUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not return raw exception messages, model payloads, credentials, or internal queue details. Map failures to stable error codes and safe descriptions. Keep trace IDs in the response when they help support teams correlate the public failure with internal telemetry.&lt;/p&gt;

&lt;p&gt;Authorize every status and cancellation request against the job owner or tenant. Knowing a job ID must not be enough to read another user's inputs, results, or failure details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push completion without making notifications durable state
&lt;/h2&gt;

&lt;p&gt;Polling the status URL is the simplest client contract, but it is not the only user experience. SignalR or WebSockets can push progress and completion events to connected clients, including events sent by a background worker.&lt;/p&gt;

&lt;p&gt;The persisted job resource must remain the source of truth. A client can disconnect before receiving an event, reconnect after the event was sent, or miss a notification during deployment. Treat push as a fast notification path, then have the client read the status resource to confirm the current state. Do not make delivery of a SignalR or WebSocket event the durability boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common implementation mistakes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Returning &lt;code&gt;202&lt;/code&gt; without a status resource
&lt;/h3&gt;

&lt;p&gt;The caller needs a durable identifier and a place to observe the outcome. A bare &lt;code&gt;202&lt;/code&gt; only moves uncertainty from the server to the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Starting &lt;code&gt;Task.Run&lt;/code&gt; from the endpoint
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;Task.Run&lt;/code&gt; does not persist work, provide backpressure, survive a process restart, or create a retry contract. It is not a replacement for a job store and queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using only the queue as state
&lt;/h3&gt;

&lt;p&gt;Queue delivery state is not the same as business state. Persist whether the job is pending, running, complete, failed, or canceled.&lt;/p&gt;

&lt;h3&gt;
  
  
  Holding a database transaction open during the model call
&lt;/h3&gt;

&lt;p&gt;Persist the state transition, commit it, call the remote model outside that transaction, and persist the result in a later short transaction. Do not hold database locks while waiting on an unpredictable network dependency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retrying every failure automatically
&lt;/h3&gt;

&lt;p&gt;Retry transient transport failures deliberately. Do not retry invalid input, authorization failures, a request that exceeds the model's context limit, a non-retryable content-policy rejection, or deterministic tool errors as if they were temporary network problems.&lt;/p&gt;

&lt;p&gt;Classify these terminal failures before applying a generic retry policy. Persist the job as &lt;code&gt;Failed&lt;/code&gt; with a stable, safe error code. In a broker-backed design, dead-letter the delivery immediately when it represents poison input or requires operator inspection; otherwise complete the delivery after recording the terminal business failure. Either way, do not spend the full retry budget repeating a request that cannot succeed unchanged. A dead-letter queue also needs monitoring and an explicit inspect, correct, and resubmit process.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use a background job
&lt;/h2&gt;

&lt;p&gt;Use this pattern when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the work must continue after the request ends&lt;/li&gt;
&lt;li&gt;the result needs a stable URL or status page&lt;/li&gt;
&lt;li&gt;the operation is expensive enough to require queueing or concurrency control&lt;/li&gt;
&lt;li&gt;retries and duplicate delivery need explicit handling&lt;/li&gt;
&lt;li&gt;users may return later for the result&lt;/li&gt;
&lt;li&gt;the workflow produces a durable artifact&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When not to use a background job
&lt;/h2&gt;

&lt;p&gt;Keep the normal request or streaming model when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the interaction is short and the caller needs the immediate answer&lt;/li&gt;
&lt;li&gt;partial output is useful and losing the stream should stop the work&lt;/li&gt;
&lt;li&gt;the operation has no durable result or side effect&lt;/li&gt;
&lt;li&gt;adding a job store, queue, worker, and status API would create more complexity than the workload justifies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not move work to a queue only because model calls feel slow. First decide whether the application needs durability, independent retries, progress, or load leveling. If it does not, a bounded synchronous or streaming request is usually easier to operate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical takeaway
&lt;/h2&gt;

&lt;p&gt;The decision is not "HTTP or queue." It is who owns the work after the request ends.&lt;/p&gt;

&lt;p&gt;If the request owns the work, keep it synchronous or stream the response and propagate cancellation correctly.&lt;/p&gt;

&lt;p&gt;If the application owns the work, persist a job, return &lt;code&gt;202 Accepted&lt;/code&gt; with a status URL, claim it atomically, process it through an explicit worker boundary, and make retries, cancellation, and terminal failure handling part of the application contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/posts/debugging-llm-timeouts-in-.net/" rel="noopener noreferrer"&gt;Debugging LLM Timeouts in .NET&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/posts/cancellation-token/" rel="noopener noreferrer"&gt;Why CancellationToken Matters More in .NET AI Systems&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/tips/use-idempotency-keys-for-retryable-writes/" rel="noopener noreferrer"&gt;Use idempotency keys for retryable writes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/tips/design-queue-handlers-for-duplicate-delivery/" rel="noopener noreferrer"&gt;Design queue handlers for duplicate delivery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.lukaswalter.dev/tips/choose-rate-limiting-policies-by-endpoint-cost/" rel="noopener noreferrer"&gt;Choose rate-limiting policies by endpoint cost&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/azure/architecture/patterns/asynchronous-request-reply" rel="noopener noreferrer"&gt;Asynchronous Request-Reply pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/aspnet/core/fundamentals/host/hosted-services" rel="noopener noreferrer"&gt;Background tasks with hosted services in ASP.NET Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/aspnet/core/signalr/introduction" rel="noopener noreferrer"&gt;Overview of ASP.NET Core SignalR&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/ef/core/saving/concurrency" rel="noopener noreferrer"&gt;Handling concurrency conflicts in EF Core&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/azure/service-bus-messaging/service-bus-dead-letter-queues" rel="noopener noreferrer"&gt;Service Bus dead-letter queues&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/azure/architecture/best-practices/background-jobs" rel="noopener noreferrer"&gt;Best practices for background jobs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>ai</category>
      <category>csharp</category>
    </item>
    <item>
      <title>Testing Microsoft Agent Framework Applications</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Fri, 24 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/testing-microsoft-agent-framework-applications-4739</link>
      <guid>https://dev.to/lukaswalter/testing-microsoft-agent-framework-applications-4739</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This is Part 18 of my series on the Microsoft Agent Framework. You can read the original post over on &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_18/" rel="noopener noreferrer"&gt;lukaswalter.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_17/" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, we looked at observability for agents.&lt;br&gt;
The main idea was to make a run visible as a chain of model calls, tool calls, approvals, and workflow events.&lt;/p&gt;

&lt;p&gt;Testing starts from the same idea.&lt;/p&gt;

&lt;p&gt;An agent run is not one answer string.&lt;br&gt;
It is a small application flow with several boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user input
-&amp;gt; prompt and context
-&amp;gt; model request
-&amp;gt; model response
-&amp;gt; tool selection
-&amp;gt; tool arguments
-&amp;gt; tool execution
-&amp;gt; structured result or final answer
-&amp;gt; routing or workflow state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the only test is an end-to-end prompt against a live model, all of those boundaries are mixed together.&lt;br&gt;
When the test fails, you do not know whether the problem is the prompt, the model, the tool schema, the router, the workflow, or the real dependency behind the tool.&lt;/p&gt;

&lt;p&gt;The solution is not to pretend that an LLM is deterministic.&lt;br&gt;
The solution is to test each boundary at the level where it is deterministic, then add a smaller number of evaluation-style tests for behavior that genuinely depends on the model.&lt;/p&gt;

&lt;p&gt;This post covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fake model clients&lt;/li&gt;
&lt;li&gt;tool contract tests&lt;/li&gt;
&lt;li&gt;structured output tests&lt;/li&gt;
&lt;li&gt;routing tests&lt;/li&gt;
&lt;li&gt;workflow tests&lt;/li&gt;
&lt;li&gt;eval-style regression checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The examples use xUnit-style assertions, but the testing approach does not depend on xUnit.&lt;br&gt;
The snippets focus on the relevant testing boundary and omit some application-specific factory and workflow setup.&lt;/p&gt;
&lt;h2&gt;
  
  
  Do not start with the live model
&lt;/h2&gt;

&lt;p&gt;A live model test is useful.&lt;br&gt;
It is also expensive, slow, sometimes flaky, and difficult to diagnose.&lt;/p&gt;

&lt;p&gt;That makes it a poor replacement for normal unit and integration tests.&lt;/p&gt;

&lt;p&gt;I use a testing pyramid for agent applications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    evals
          realistic model and user examples

              application integration tests
       agent + tools + storage + workflow boundaries

              deterministic component tests
        fake model client, tools, schemas, routing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bottom layer should be the largest.&lt;br&gt;
It should catch ordinary programming mistakes without contacting Azure OpenAI, OpenAI, Foundry, or another provider.&lt;/p&gt;

&lt;p&gt;The middle layer proves that the pieces work together.&lt;br&gt;
It can use a fake &lt;code&gt;IChatClient&lt;/code&gt;, an in-memory repository, and a real workflow execution.&lt;/p&gt;

&lt;p&gt;The top layer checks behavior that cannot be completely specified with ordinary assertions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether an answer is useful&lt;/li&gt;
&lt;li&gt;whether a route is appropriate for an ambiguous request&lt;/li&gt;
&lt;li&gt;whether a summary preserves important facts&lt;/li&gt;
&lt;li&gt;whether an agent follows a policy in realistic conversations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last layer is closer to evaluation than to unit testing.&lt;br&gt;
It belongs in the test strategy, but it should not carry the whole strategy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Put the model behind &lt;code&gt;IChatClient&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most important test seam is the model client.&lt;/p&gt;

&lt;p&gt;Microsoft Agent Framework builds on the &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; abstractions, including &lt;code&gt;IChatClient&lt;/code&gt;.&lt;br&gt;
That means an application can construct an agent with a real provider in production and a scripted client in tests.&lt;/p&gt;

&lt;p&gt;The fake client does not need to simulate intelligence.&lt;br&gt;
It only needs to return the response that the test scenario requires and record what the application sent to it.&lt;br&gt;
I prefer this small fake over mocking &lt;code&gt;IChatClient&lt;/code&gt; with Moq or NSubstitute.&lt;br&gt;
Response streams and &lt;code&gt;ChatResponse&lt;/code&gt; to &lt;code&gt;ChatResponseUpdate&lt;/code&gt; conversions are tedious to configure correctly, while the fake exercises the real interface and conversion path.&lt;/p&gt;

&lt;p&gt;Here is a small scripted client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScriptedChatClient&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IChatClient&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;params&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;responseTexts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;responses&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="n"&gt;responseTexts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Assistant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Requests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToList&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;Requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Count&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"The scripted client has no response left."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Dequeue&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;IAsyncEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatResponseUpdate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStreamingResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;IEnumerable&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ChatOptions&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompilerServices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnumeratorCancellation&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;ChatResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatResponseUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToChatResponseUpdates&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;GetService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt; &lt;span class="n"&gt;serviceType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;serviceKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Dispose&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally simple.&lt;br&gt;
It gives the test three useful capabilities:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It never calls a model provider.&lt;/li&gt;
&lt;li&gt;It fails when the application makes more model calls than the scenario expects.&lt;/li&gt;
&lt;li&gt;It lets the test inspect the messages and options sent to the model boundary.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a real test project, I would usually add a few more features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scripted responses that include usage metadata&lt;/li&gt;
&lt;li&gt;a response factory that can inspect the request&lt;/li&gt;
&lt;li&gt;a separate queue for streaming updates&lt;/li&gt;
&lt;li&gt;a flag that records whether the request asked for JSON schema output&lt;/li&gt;
&lt;li&gt;cancellation support for timeout tests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not make the fake client clever enough to become a second model implementation.&lt;br&gt;
The point is to control the scenario, not to reproduce provider behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Test the agent boundary
&lt;/h2&gt;

&lt;p&gt;Once the client is injectable, the agent can be built exactly as it is in the application.&lt;/p&gt;

&lt;p&gt;For example, this factory keeps the model client outside the test itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportAgentFactory&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"support-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;            &lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;are&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="n"&gt;assistant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
            &lt;span class="n"&gt;Answer&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;the&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="n"&gt;support&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
            &lt;span class="n"&gt;Do&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;invent&lt;/span&gt; &lt;span class="n"&gt;ticket&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
            &lt;span class="s"&gt;""");
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A basic test can now verify the agent's application boundary without a network call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Agent_returns_scripted_answer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"The ticket is waiting for customer information."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SupportAgentFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;AgentResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"What is the status of ticket 42?"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"The ticket is waiting for customer information."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Single&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;!.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticket 42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test does not prove that a real model would answer the same way.&lt;br&gt;
That is not what it is trying to prove.&lt;/p&gt;

&lt;p&gt;It proves that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent can be constructed with the configured client&lt;/li&gt;
&lt;li&gt;the input reaches the model boundary&lt;/li&gt;
&lt;li&gt;the response is returned through the application API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is already valuable.&lt;/p&gt;

&lt;p&gt;You can also use the recorded requests to test prompt construction, context providers, chat reducers, or session behavior.&lt;br&gt;
For example, a test can assert that a tenant id is present in the request and that a reduced history does not exceed a known message count.&lt;/p&gt;

&lt;p&gt;Avoid asserting the entire serialized prompt unless the prompt itself is the contract.&lt;br&gt;
Small instruction changes should not break every test.&lt;br&gt;
Assert the important invariant instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Single&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tenant-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tool tests have two different jobs
&lt;/h2&gt;

&lt;p&gt;A tool has at least two contracts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The C# contract that executes the function.&lt;/li&gt;
&lt;li&gt;The AI contract that describes the function to the model.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Test both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the function as normal application code
&lt;/h3&gt;

&lt;p&gt;The function itself should have ordinary unit tests.&lt;br&gt;
It should not require an LLM just because it is exposed as an &lt;code&gt;AIFunction&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;NeedsCustomerReply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TicketService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"WaitingForCustomer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;NeedsCustomerReply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test the service directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Ticket_service_returns_current_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;TicketStatus&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TicketId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"WaitingForCustomer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;True&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NeedsCustomerReply&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches business logic errors, authorization mistakes, invalid mappings, and dependency failures.&lt;br&gt;
It is much easier to diagnose than an end-to-end test that only says the agent gave a bad answer.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test the generated AI contract
&lt;/h3&gt;

&lt;p&gt;Now expose the method as a function.&lt;br&gt;
Keep the service as trusted instance state rather than adding it to the model-controlled argument list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.ComponentModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SupportTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TicketService&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gets the current status of a support ticket."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetTicketStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The numeric support ticket identifier."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;AIFunction&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AIFunctionFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CancellationToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TicketStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;gt;)&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SupportTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TicketService&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="n"&gt;GetTicketStatusAsync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;AIFunctionFactoryOptions&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"get_ticket_status"&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test should create the same &lt;code&gt;AIFunction&lt;/code&gt; that the agent receives and inspect its declaration.&lt;br&gt;
The &lt;code&gt;TicketService&lt;/code&gt; instance is stored by &lt;code&gt;SupportTools&lt;/code&gt; and captured by the delegate, so it does not appear in the AI-facing JSON schema.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Ticket_tool_exposes_a_narrow_contract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AIFunction&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateTicketTool&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_ticket_status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"support ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;StringComparison&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrdinalIgnoreCase&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticketId"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;EnumerateObject&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;property&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;property&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReturnJsonSchema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important assertions are not necessarily the exact generated JSON string.&lt;br&gt;
They are the properties that affect model behavior:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the tool has the intended name&lt;/li&gt;
&lt;li&gt;the description says what it actually does&lt;/li&gt;
&lt;li&gt;required parameters are present&lt;/li&gt;
&lt;li&gt;parameters have the intended types&lt;/li&gt;
&lt;li&gt;internal services are not exposed as model parameters&lt;/li&gt;
&lt;li&gt;the result schema is present when the model needs it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The generated JSON schema is part of the prompt boundary.&lt;br&gt;
A rename, a missing &lt;code&gt;Description&lt;/code&gt;, or an accidentally exposed service parameter can change tool selection even when the C# function still compiles.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test side effects separately from tool selection
&lt;/h3&gt;

&lt;p&gt;A tool contract test does not prove that an agent will select the tool.&lt;br&gt;
It proves that the tool is described correctly.&lt;/p&gt;

&lt;p&gt;That distinction matters for side effects.&lt;/p&gt;

&lt;p&gt;For a tool that sends an email, deletes data, deploys a release, or charges a card, add tests for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;authorization&lt;/li&gt;
&lt;li&gt;validation of every argument&lt;/li&gt;
&lt;li&gt;idempotency behavior&lt;/li&gt;
&lt;li&gt;approval handling&lt;/li&gt;
&lt;li&gt;cancellation&lt;/li&gt;
&lt;li&gt;duplicate calls&lt;/li&gt;
&lt;li&gt;failure and retry behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then test that the tool is wrapped with the expected approval boundary.&lt;br&gt;
The model can request the function, but the function should not execute just because the model requested it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Structured output needs shape tests and meaning tests
&lt;/h2&gt;

&lt;p&gt;Structured output gives application code a type instead of an unstructured string.&lt;br&gt;
It does not make the model's values correct.&lt;/p&gt;

&lt;p&gt;That gives us two test categories.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test successful deserialization
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; structured-output extensions can request a typed &lt;code&gt;ChatResponse&amp;lt;T&amp;gt;&lt;/code&gt; from an &lt;code&gt;IChatClient&lt;/code&gt;.&lt;br&gt;
With a fake client, the response can be controlled as JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Structured_output_is_deserialized_into_the_expected_type&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"""
&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"The user asks about an invoice."&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;""");
&lt;/span&gt;
    &lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Classify this request."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;useJsonSchemaResponseFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;IntentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.91&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;useJsonSchemaResponseFormat: false&lt;/code&gt; argument keeps this test focused on deserialization.&lt;br&gt;
A separate integration test can verify that the provider supports native JSON schema response formatting.&lt;/p&gt;

&lt;p&gt;Also test malformed output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Structured_output_test_fixture_can_expose_invalid_model_output&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"{ \"intent\": \"billing\", \"confidence\": \"certain\" }"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;ChatResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetResponseAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"Classify this request."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;useJsonSchemaResponseFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryGetResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A model is not guaranteed to honor the requested schema.&lt;br&gt;
Your application still needs a policy for a response that cannot be parsed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retry with a repair request&lt;/li&gt;
&lt;li&gt;return a controlled error&lt;/li&gt;
&lt;li&gt;route to a human&lt;/li&gt;
&lt;li&gt;use a safe fallback&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test should prove that the policy is applied.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test business validation after deserialization
&lt;/h3&gt;

&lt;p&gt;Do not stop at &lt;code&gt;response.Result&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the router, a valid &lt;code&gt;IntentResult&lt;/code&gt; can still be unsafe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"delete-production-data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"The user wants cleanup."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON is valid.&lt;br&gt;
The intent is not one of the allowed routes.&lt;/p&gt;

&lt;p&gt;Keep the next validation step in normal C#:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;UserIntent&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Support&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Billing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Other&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ValidatedRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;UserIntent&lt;/span&gt; &lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;ValidatedRoute&lt;/span&gt; &lt;span class="nf"&gt;ValidateRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TryParse&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;ignoreCase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;UserIntent&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ValidatedRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Other&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ValidatedRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test invalid enum values, missing fields, out-of-range confidence, and a low-confidence result.&lt;br&gt;
These tests protect the application boundary even when the model produces a structurally valid object.&lt;/p&gt;
&lt;h2&gt;
  
  
  Routing tests should mostly be ordinary C# tests
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_10/" rel="noopener noreferrer"&gt;manual routing article&lt;/a&gt; used a small intent agent followed by a C# switch statement.&lt;br&gt;
That division makes routing easier to test.&lt;/p&gt;

&lt;p&gt;The model classifies.&lt;br&gt;
The application routes.&lt;/p&gt;

&lt;p&gt;The route function can be tested with a table of known decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Theory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Support&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"support-agent"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Billing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"billing-agent"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Other&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"fallback"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;Route_uses_the_application_mapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;UserIntent&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;expectedAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;actualAgent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;intent&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Support&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"support-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;UserIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Billing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"billing-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"fallback"&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actualAgent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real application method might return a delegate, an agent, or a command object.&lt;br&gt;
The testing principle stays the same: once the model has produced a validated route, routing should be deterministic.&lt;/p&gt;

&lt;p&gt;You should also test route boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;low confidence asks for clarification&lt;/li&gt;
&lt;li&gt;unknown intent uses the safe fallback&lt;/li&gt;
&lt;li&gt;a restricted tenant cannot select a privileged agent&lt;/li&gt;
&lt;li&gt;a request requiring approval cannot bypass the approval path&lt;/li&gt;
&lt;li&gt;the specialist receives the original user input and the minimum necessary context&lt;/li&gt;
&lt;li&gt;the fallback does not accidentally invoke an expensive specialist&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then test the model-based classifier separately with scripted responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Theory&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Where is my invoice?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;InlineData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"The login link is broken."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"support"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Classifier_uses_the_typed_model_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;expectedIntent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="s"&gt;$"""
&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="s"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"{{expectedIntent}}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="s"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Test fixture"&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;""");
&lt;/span&gt;
    &lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;classifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;AgentResponse&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;classifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IntentResult&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;userMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expectedIntent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Intent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This checks the classifier integration.&lt;br&gt;
It does not claim that the model will classify every real user message correctly.&lt;br&gt;
That is what the eval set is for.&lt;/p&gt;
&lt;h2&gt;
  
  
  Workflow tests should assert events and outputs
&lt;/h2&gt;

&lt;p&gt;A workflow is more than its final output.&lt;br&gt;
The previous workflow articles covered explicit executors, edges, events, and human-in-the-loop pauses.&lt;br&gt;
Those are all testable.&lt;/p&gt;

&lt;p&gt;For a simple workflow, run it in-process and inspect the emitted events:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI.Workflows&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;Workflow_emits_the_expected_steps_and_output&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Workflow&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CreateReviewWorkflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ScriptedChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s"&gt;"The document is safe to publish."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"The final review is approved."&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Run&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;InProcessExecution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lockstep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ReviewRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document-42"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutgoingEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;WorkflowOutputEvent&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"The final review is approved."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoesNotContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutgoingEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;WorkflowErrorEvent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact event types and workflow output depend on the workflow.&lt;br&gt;
The useful assertions are usually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the expected executor ran&lt;/li&gt;
&lt;li&gt;the output has the expected type&lt;/li&gt;
&lt;li&gt;a branch was or was not taken&lt;/li&gt;
&lt;li&gt;an error event was emitted when a step failed&lt;/li&gt;
&lt;li&gt;the workflow stopped at an approval request&lt;/li&gt;
&lt;li&gt;resuming with the response continues from the pause instead of restarting everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lockstep execution is useful for tests because it makes event ordering deterministic.&lt;br&gt;
That is especially helpful when the production workflow uses streaming execution and events arrive while the workflow is running.&lt;/p&gt;

&lt;p&gt;For a workflow with a branch, assert the branch rather than only the final text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutgoingEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ExecutorCompletedEvent&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutorId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"security-review"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DoesNotContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutgoingEvents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;eventItem&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;ExecutorCompletedEvent&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExecutorId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"publish-document"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That test says something meaningful about the process.&lt;br&gt;
An assertion such as &lt;code&gt;Assert.Contains("safe", finalText)&lt;/code&gt; would be much weaker.&lt;/p&gt;
&lt;h3&gt;
  
  
  Test pauses and resumptions
&lt;/h3&gt;

&lt;p&gt;A human approval workflow should have at least three tests:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The workflow requests approval before the side effect.&lt;/li&gt;
&lt;li&gt;Rejection ends the flow without executing the side effect.&lt;/li&gt;
&lt;li&gt;Approval resumes the flow and executes only after the decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep the side effect behind a spy or fake service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RecordingPublisher&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IPublisher&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;PublishedDocuments&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;PublishAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;PublishedDocuments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then assert the important safety property:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;publisher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PublishedDocuments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;after the rejected approval.&lt;/p&gt;

&lt;p&gt;The model's text is not the approval boundary.&lt;br&gt;
The workflow event and the application response are.&lt;/p&gt;
&lt;h2&gt;
  
  
  Eval-style regression checks are a separate layer
&lt;/h2&gt;

&lt;p&gt;Some behavior cannot be tested with exact string assertions.&lt;/p&gt;

&lt;p&gt;Suppose the support agent should:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answer from the ticket data&lt;/li&gt;
&lt;li&gt;avoid inventing a resolution&lt;/li&gt;
&lt;li&gt;ask for missing information&lt;/li&gt;
&lt;li&gt;use the billing route for invoices&lt;/li&gt;
&lt;li&gt;never expose another tenant's data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There may be many acceptable answers.&lt;br&gt;
An exact string assertion would reject good changes and accept bad ones that happen to match the fixture.&lt;/p&gt;

&lt;p&gt;Agent Framework now includes an evaluation framework for this layer.&lt;br&gt;
It provides local evaluators for fast checks and integrations with model-based evaluators when you need a semantic quality judgment.&lt;/p&gt;

&lt;p&gt;For this kind of behavior, create a small evaluation set and run it through the built-in APIs.&lt;/p&gt;

&lt;p&gt;For example, a local evaluator can check required words and tool usage without another model call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;LocalEvaluator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;EvalChecks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;KeywordCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ticket"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;EvalChecks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToolCalledCheck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"get_ticket_status"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;AgentEvaluationResults&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;EvaluateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"What is the current status of ticket 42?"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AssertAllPassed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For quality dimensions such as relevance, coherence, groundedness, or task adherence, use a configured evaluator such as the Azure AI Foundry evaluator or a &lt;code&gt;Microsoft.Extensions.AI.Evaluation&lt;/code&gt; evaluator.&lt;br&gt;
Those checks are still regression tests, but they have model and evaluator variance, so they should usually run in a separate CI or release-evaluation stage.&lt;/p&gt;

&lt;p&gt;The framework can also evaluate workflows and report per-agent results, which is useful when the final answer looks acceptable but one specialist or route has degraded.&lt;/p&gt;

&lt;p&gt;The evaluation set remains an application responsibility.&lt;br&gt;
The framework provides the execution and result types, but you still need to define representative cases, expected routes, expected tool calls, safety constraints, and acceptable quality thresholds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AgentCase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ExpectedRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;RequiredFacts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;ForbiddenPhrases&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AgentCase&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Cases&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"invoice question"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Why was I charged twice for invoice 42?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ExpectedRoute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;RequiredFacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"invoice"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;ForbiddenPhrases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"I refunded your payment"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;

    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"missing ticket context"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;Input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Can you fix my issue?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ExpectedRoute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"support"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;RequiredFacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"ticket"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"more information"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;ForbiddenPhrases&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"ticket is fixed"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each case can be run against a pinned model and evaluated with a mix of checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deterministic route assertions&lt;/li&gt;
&lt;li&gt;required and forbidden content checks&lt;/li&gt;
&lt;li&gt;structured output validation&lt;/li&gt;
&lt;li&gt;tool-call assertions&lt;/li&gt;
&lt;li&gt;human review for a small sample&lt;/li&gt;
&lt;li&gt;an evaluator model for semantic criteria&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The evaluator should not be the only judge.&lt;br&gt;
If a deterministic check can express the requirement, use the deterministic check.&lt;/p&gt;

&lt;p&gt;For example, the route is usually an enum and can be compared exactly.&lt;br&gt;
Whether an explanation is helpful may need a rubric.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;EvaluationResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CaseName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RoutePassed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RequiredFactsPassed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;ForbiddenPhrasesPassed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;double&lt;/span&gt; &lt;span class="n"&gt;HelpfulnessScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Notes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the result with enough metadata to compare runs later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model identifier and deployment&lt;/li&gt;
&lt;li&gt;Agent Framework and &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; package versions&lt;/li&gt;
&lt;li&gt;prompt or instruction version&lt;/li&gt;
&lt;li&gt;tool manifest version&lt;/li&gt;
&lt;li&gt;evaluation-set version&lt;/li&gt;
&lt;li&gt;timestamp&lt;/li&gt;
&lt;li&gt;token usage and latency&lt;/li&gt;
&lt;li&gt;pass/fail result and evaluator notes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not compare a new run with an old run while silently changing all of those inputs.&lt;br&gt;
If the model, prompt, tools, or data changed, record the change.&lt;/p&gt;
&lt;h3&gt;
  
  
  What belongs in a regression gate?
&lt;/h3&gt;

&lt;p&gt;Not every score should block a pull request.&lt;/p&gt;

&lt;p&gt;I would use three levels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pull request
-&amp;gt; deterministic tests
-&amp;gt; fake-client integration tests
-&amp;gt; small, stable eval smoke set

nightly or release candidate
-&amp;gt; larger eval set
-&amp;gt; live provider runs
-&amp;gt; human review for sampled cases
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pull request gate can reasonably fail when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;structured output no longer parses&lt;/li&gt;
&lt;li&gt;a required tool disappears from the schema&lt;/li&gt;
&lt;li&gt;a route changes for a locked fixture&lt;/li&gt;
&lt;li&gt;a workflow stops emitting a required event&lt;/li&gt;
&lt;li&gt;a forbidden action is invoked&lt;/li&gt;
&lt;li&gt;token usage exceeds a deliberately chosen budget&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A nightly eval can track softer changes such as helpfulness, completeness, or groundedness.&lt;/p&gt;

&lt;p&gt;Do not turn a noisy evaluator score into a hard gate before you understand its variance.&lt;br&gt;
Otherwise developers will learn to ignore the test suite.&lt;/p&gt;
&lt;h2&gt;
  
  
  Test the failure paths on purpose
&lt;/h2&gt;

&lt;p&gt;Agent systems have failure modes that ordinary CRUD applications do not have.&lt;br&gt;
Add explicit scenarios for them.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model fails
&lt;/h3&gt;

&lt;p&gt;Script the model client to throw a provider exception or return an empty response.&lt;br&gt;
Assert the application returns a controlled error and does not execute a side effect.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model returns invalid structured output
&lt;/h3&gt;

&lt;p&gt;Return malformed JSON, missing required properties, an unknown enum, or a value outside the allowed range.&lt;br&gt;
Assert the retry, fallback, or escalation policy.&lt;/p&gt;
&lt;h3&gt;
  
  
  The model requests the wrong tool
&lt;/h3&gt;

&lt;p&gt;If you can construct a provider-neutral function-call fixture, use it to prove that an unsupported tool call is rejected.&lt;br&gt;
Also verify that the tool layer validates arguments instead of trusting the model to be polite.&lt;/p&gt;
&lt;h3&gt;
  
  
  The tool fails
&lt;/h3&gt;

&lt;p&gt;Throw a timeout, an authorization exception, and a domain validation exception from the fake service.&lt;br&gt;
Assert what the agent sees and whether another model iteration is allowed.&lt;/p&gt;
&lt;h3&gt;
  
  
  The agent loops
&lt;/h3&gt;

&lt;p&gt;Configure a scripted client with repeated tool requests or repeated repair responses.&lt;br&gt;
Assert that the configured iteration cap ends the run.&lt;br&gt;
An agent test that can hang forever is a test suite problem.&lt;/p&gt;
&lt;h3&gt;
  
  
  The user cancels
&lt;/h3&gt;

&lt;p&gt;Cancel the token during a model call, tool call, or workflow run.&lt;br&gt;
Assert that the cancellation reaches the dependency and that the application does not report a successful side effect.&lt;/p&gt;
&lt;h3&gt;
  
  
  The context is too large
&lt;/h3&gt;

&lt;p&gt;Use a recording client to count messages or inspect approximate input size.&lt;br&gt;
This catches a disabled reducer, an over-eager retriever, or a tool that returns too much data.&lt;/p&gt;

&lt;p&gt;These tests are not about making the model predictable.&lt;br&gt;
They are about making the application predictable when the model or one of its dependencies is not.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keep test data inside the trust boundary
&lt;/h2&gt;

&lt;p&gt;Agent tests often contain realistic tickets, documents, prompts, and tool arguments.&lt;br&gt;
Treat those fixtures as application data.&lt;/p&gt;

&lt;p&gt;Do not put production conversations into a repository just because they are useful examples.&lt;br&gt;
Redact names, emails, tokens, internal URLs, credentials, and customer-specific identifiers.&lt;/p&gt;

&lt;p&gt;For live evaluation runs, decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which data may leave the test environment&lt;/li&gt;
&lt;li&gt;which provider and region receives it&lt;/li&gt;
&lt;li&gt;whether prompts and outputs are retained&lt;/li&gt;
&lt;li&gt;who can inspect the results&lt;/li&gt;
&lt;li&gt;how long artifacts are stored&lt;/li&gt;
&lt;li&gt;whether the evaluator itself sees sensitive content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/safety" rel="noopener noreferrer"&gt;Agent Framework safety guidance&lt;/a&gt; treats model calls, chat history, context providers, and tools as separate trust boundaries.&lt;br&gt;
Your test pipeline crosses the same boundaries.&lt;/p&gt;
&lt;h2&gt;
  
  
  A practical test plan
&lt;/h2&gt;

&lt;p&gt;When I add a new agent feature, I usually start with this checklist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Test the normal C# service or tool directly.
2. Test the generated AIFunction name, description, and schemas.
3. Test the agent with a scripted IChatClient.
4. Test structured output parsing and business validation.
5. Test routing with normal C# values and low-confidence cases.
6. Test workflow outputs, branches, errors, and approval pauses.
7. Add a small eval set for behavior that exact assertions cannot express.
8. Run a larger live evaluation outside the fast unit-test loop.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This ordering gives fast feedback first.&lt;br&gt;
It also keeps failures local.&lt;/p&gt;

&lt;p&gt;If a tool contract test fails, inspect the tool declaration.&lt;br&gt;
If a routing test fails, inspect the switch or policy.&lt;br&gt;
If a structured output test fails, inspect parsing and validation.&lt;br&gt;
If an eval fails while all deterministic tests pass, inspect the prompt, model, data, or rubric.&lt;/p&gt;

&lt;p&gt;That is a much better debugging experience than a single red test named &lt;code&gt;Agent_should_answer_correctly&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use this approach
&lt;/h2&gt;

&lt;p&gt;I would use this layered approach when the agent has tools or external side effects, returns structured data, uses multiple routes or workflows, needs approval handling, or is moving beyond a prototype.&lt;br&gt;
I would start with the fake client and tool tests even for a small prototype because they are cheap and keep the model boundary replaceable.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would not overbuild it
&lt;/h2&gt;

&lt;p&gt;I would not begin with a large evaluation platform while the agent has no stable behavior, no meaningful tools or routes, or no agreed definition of a good answer.&lt;br&gt;
Start with a scripted &lt;code&gt;IChatClient&lt;/code&gt;, a few failure cases, and one or two realistic examples.&lt;br&gt;
Add a larger evaluation set when the behavior is stable enough for the results to mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;An agent is not an untestable black box.&lt;br&gt;
The model is one nondeterministic component inside a larger .NET application, while the application still owns deterministic boundaries such as tool behavior and schemas, structured output validation, routing, workflow events, approvals, and failure policies.&lt;/p&gt;

&lt;p&gt;Use fake model clients for fast tests, ordinary C# tests for routing and business rules, workflow assertions for process behavior, and evals for quality that needs realistic language examples.&lt;br&gt;
The goal is not to make every agent answer identical text.&lt;br&gt;
It is to know which parts of the system are correct, which parts are model-dependent, and what changed when a test or evaluation fails.&lt;/p&gt;

&lt;p&gt;The next article moves from agents that live mainly in code to agents managed as cloud resources in Microsoft Foundry.&lt;br&gt;
We will compare local Agent Framework development with Foundry's persistent agents and threads, RBAC, governance, lifecycle management, and the enterprise trade-offs that come with adding a cloud control plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/" rel="noopener noreferrer"&gt;Agent Framework documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/evaluation" rel="noopener noreferrer"&gt;Evaluation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/safety" rel="noopener noreferrer"&gt;Agent safety&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.ichatclient" rel="noopener noreferrer"&gt;IChatClient&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.chatclientstructuredoutputextensions.getresponseasync" rel="noopener noreferrer"&gt;Structured output with &lt;code&gt;GetResponseAsync&amp;lt;T&amp;gt;&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/structured-output" rel="noopener noreferrer"&gt;Producing structured output with agents&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/get-started/add-tools" rel="noopener noreferrer"&gt;Tools&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/workflows/workflows" rel="noopener noreferrer"&gt;Workflows&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/workflows/events" rel="noopener noreferrer"&gt;Workflow events&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/workflows/advanced/execution-modes" rel="noopener noreferrer"&gt;Workflow execution modes&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.aifunction" rel="noopener noreferrer"&gt;&lt;code&gt;AIFunction&lt;/code&gt; and generated schemas&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Generate Useful Test Data in .NET with Bogus</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/generate-useful-test-data-in-net-with-bogus-59m0</link>
      <guid>https://dev.to/lukaswalter/generate-useful-test-data-in-net-with-bogus-59m0</guid>
      <description>&lt;p&gt;Most applications need more test data than a few hand-written objects can provide. A single unit test may need one customer, but an integration test often needs hundreds of customers, orders, timestamps, statuses, and relationships that look plausible together.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/bchavez/Bogus" rel="noopener noreferrer"&gt;Bogus&lt;/a&gt; is a .NET library for generating that kind of fake data. You describe how an object should be created, and Bogus generates one object or a collection of objects from those rules.&lt;/p&gt;

&lt;p&gt;The useful distinction is this: Bogus generates data. It does not replace mocks, assertions, contract tests, or production data-quality checks. It is a tool for creating controlled input for development and testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bogus does
&lt;/h2&gt;

&lt;p&gt;Bogus provides two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Faker&amp;lt;T&amp;gt;&lt;/code&gt; describes how to build instances of a type.&lt;/li&gt;
&lt;li&gt;Data sets such as &lt;code&gt;Name&lt;/code&gt;, &lt;code&gt;Address&lt;/code&gt;, &lt;code&gt;Internet&lt;/code&gt;, &lt;code&gt;Commerce&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;, and &lt;code&gt;Random&lt;/code&gt; provide reusable generators.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main API is fluent. A rule maps a property to either a fixed value or a callback that receives a Bogus faker instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;faker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;faker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;faker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Generate()&lt;/code&gt; creates one object. &lt;code&gt;Generate(count)&lt;/code&gt; creates a collection. Each call evaluates the rules again, so the generated values normally differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Bogus
&lt;/h2&gt;

&lt;p&gt;At the time of writing, NuGet lists version &lt;code&gt;35.6.5&lt;/code&gt;; pin the version in real projects when repeatability matters, and update it deliberately when upgrading dependencies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Bogus &lt;span class="nt"&gt;--version&lt;/span&gt; 35.6.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package is usually most useful in test projects, development-only seeders, demo applications, and tools that need a large amount of sample data. Keep it out of production application paths unless generating sample data is an explicit product feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with a simple faker
&lt;/h2&gt;

&lt;p&gt;Here is a small model for the examples in this post:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;CustomerStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Trial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Active&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Suspended&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Customer&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FirstName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;LastName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CountryCode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;CustomerStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now define the data rules in one place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Bogus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nextCustomerId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StrictMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nextCustomerId&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExampleEmail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PickRandom&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CustomerStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PastOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;UtcDateTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Customer&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter makes this faker stateful. Avoid sharing stateful faker instances across parallel tests. Prefer creating a fresh faker per test or per fixture operation when rules close over counters such as &lt;code&gt;nextCustomerId&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;f&lt;/code&gt; parameter is a &lt;code&gt;Faker&lt;/code&gt; instance. It exposes the built-in data sets and randomization helpers. For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PastOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PickRandom&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CustomerStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StrictMode(true)&lt;/code&gt; is valuable for reusable test fixtures. It makes Bogus complain when a writable property has no rule, so adding a property to the model does not silently leave the fixture incomplete. You can omit it for quick prototypes, but explicit rules make larger fixtures easier to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules can depend on the object being generated
&lt;/h2&gt;

&lt;p&gt;Some values should be related to other values on the same object. Use the overload that receives both the faker and the object under construction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces an email address that matches the generated name. For test systems that must never accidentally send mail, &lt;code&gt;ExampleEmail()&lt;/code&gt; is a safer default because it uses a reserved example domain. If the system under test needs a particular email format, prefer a reserved non-resolving domain such as &lt;code&gt;test.invalid&lt;/code&gt; and assert that the application treats it as test data.&lt;/p&gt;

&lt;p&gt;You can also use a custom callback for domain-specific rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Subscription&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Seats&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;planNames&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"Starter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Team"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Enterprise"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;subscriptionFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PickRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;planNames&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscription&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Plan&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"Starter"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s"&gt;"Team"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;26&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is that the generated values follow the rules your application cares about. A random number between 1 and 500 is not automatically realistic for every plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compose related objects
&lt;/h2&gt;

&lt;p&gt;Real test scenarios rarely contain isolated objects. A customer may have orders, and an order should refer to an existing customer rather than a random integer that happens to look like an ID.&lt;/p&gt;

&lt;p&gt;Generate the parent objects first, then use them when defining the child faker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;CustomerId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;decimal&lt;/span&gt; &lt;span class="n"&gt;Total&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nextOrderId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orderFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StrictMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nextOrderId&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PickRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Finance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Amount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;2_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PastOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;UtcDateTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern is often more useful than generating a large object graph in a single expression. Each faker has one responsibility, and the relationship between the generated collections is visible in the test setup.&lt;/p&gt;

&lt;p&gt;For a development database, the same approach can be used in a dedicated seeding step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orderFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SaveChangesAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep this code separate from production startup. A development seeder should be deliberate, repeatable, and impossible to run against a production connection by accident.&lt;/p&gt;

&lt;p&gt;If the database owns identity values, let EF Core generate the keys or build child records after the parent keys are known. Explicit integer IDs can conflict with identity and primary-key configuration, depending on how the model is mapped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make generated data repeatable
&lt;/h2&gt;

&lt;p&gt;Random data is useful for finding unexpected cases, but a failing test is much easier to investigate when the same input can be generated again. Bogus supports repeatable output through a randomizer seed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Bogus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Randomizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use a seed when you need to reproduce a failing scenario or keep a development dataset stable while debugging. Do not make tests depend on exact generated names or on the exact order of every random value. Test the properties that matter:&lt;/p&gt;

&lt;p&gt;For unit tests, prefer local seeding with &lt;code&gt;UseSeed&lt;/code&gt; when you want a specific generated object to be repeatable without coupling other tests to a global random sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local seeding controls Bogus randomness, not external state captured by your rules. If a faker closes over counters such as &lt;code&gt;nextCustomerId&lt;/code&gt;, create a fresh faker before generating the seeded object.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;customerFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NotEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;False&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;Assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"^[A-Z]{2}$"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Randomizer.Seed&lt;/code&gt; is global state. Set it deliberately, preferably at the boundary of a test or data-generation operation, and be aware that other tests using Bogus may be affected. Also remember that values generated outside Bogus, such as &lt;code&gt;Guid.NewGuid()&lt;/code&gt; or &lt;code&gt;DateTime.UtcNow&lt;/code&gt;, are not made deterministic by the Bogus seed.&lt;/p&gt;

&lt;p&gt;Even with a seed, generated values can change when you upgrade Bogus or its locale data. Avoid asserting exact fake names unless that exact value is the behavior under test. For maximum unit-test stability, stay within the same major version and treat dependency upgrades as a reason to review deterministic fixtures.&lt;/p&gt;

&lt;p&gt;For fully stable timestamp fixtures, use a fixed date range or inject a known reference time instead of relying on a &lt;code&gt;Past&lt;/code&gt; value relative to now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use custom constructors when necessary
&lt;/h2&gt;

&lt;p&gt;Some domain types cannot be created with a parameterless constructor. &lt;code&gt;CustomInstantiator&lt;/code&gt; lets the faker create the object using the constructor you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Account&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;accountFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CustomInstantiator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CompanyName&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;accountFaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the fixture aligned with the production construction rules instead of weakening the domain model just to make test data easier to create.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use locales when the scenario needs them
&lt;/h2&gt;

&lt;p&gt;Bogus includes locale-specific data sets. Pass a locale to the faker when names, addresses, or other generated values need to resemble a particular region:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;germanCustomerFaker&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"DE"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locales improve the shape of sample data, but they do not make it valid for every business or regulatory scenario. If a postal code, tax number, phone number, or address must satisfy a specific country’s rules, add an explicit domain generator and validate it independently.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Bogus is a good fit
&lt;/h2&gt;

&lt;p&gt;Bogus is a strong fit when you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Unit-test inputs with varied values.&lt;/li&gt;
&lt;li&gt;Integration-test datasets for APIs, databases, or message handlers.&lt;/li&gt;
&lt;li&gt;Seed data for local development and demos.&lt;/li&gt;
&lt;li&gt;Pagination, filtering, sorting, and search scenarios with enough rows to expose mistakes.&lt;/li&gt;
&lt;li&gt;Load-test input prepared before the run, where values need to look like the domain model.&lt;/li&gt;
&lt;li&gt;Reproducible examples that should not contain real customer data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest benefit is lower fixture maintenance. Instead of copying a large JSON document into every test, you can describe the few invariants that matter and generate the rest.&lt;/p&gt;

&lt;p&gt;It also makes edge cases easier to express. A fixture can generate a normal customer by default, then override one rule for a specific scenario:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;suspendedCustomer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CustomerFixtures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CustomerStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Suspended&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s"&gt;"suspended-user@test.invalid"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reuses the default rules from the shared fixture pattern shown below, then overrides only the values relevant to the scenario. Use fixed values for the behavior under test and randomness for the fields that are incidental.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Bogus is not the right tool
&lt;/h2&gt;

&lt;p&gt;Do not use Bogus as a substitute for the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A mock library.&lt;/strong&gt; Bogus creates objects and values. It does not configure a mocked service or verify an interaction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A contract fixture.&lt;/strong&gt; If a test documents an exact API payload, an explicit hand-written object is often clearer than generated data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production customer data.&lt;/strong&gt; Fake values can look realistic, but they are not anonymized copies of real data and may still create operational risk if sent to external systems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A domain validator.&lt;/strong&gt; Bogus may generate a plausible value without satisfying all of your business rules. Test the validator with intentional valid and invalid cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A security or payment simulator.&lt;/strong&gt; A generated card number, account number, or identity number is not permission to call a real provider. Keep integrations behind test doubles or provider sandboxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A replacement for boundary cases.&lt;/strong&gt; Random generation may never produce the exact empty, maximum, duplicate, expired, or malformed value your test needs. Add explicit examples for those cases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Randomness also makes a poor foundation for a test that should be completely obvious to the next person reading it. If the important input is three strings and one status, write those values directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical fixture shape
&lt;/h2&gt;

&lt;p&gt;For a larger test suite, keep faker definitions in a dedicated factory or fixture class instead of rebuilding them inside every test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomerFixtures&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;nextId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;Faker&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Customer&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StrictMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;nextId&lt;/span&gt;&lt;span class="p"&gt;++)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FirstName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LastName&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExampleEmail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Address&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CountryCode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PickRandom&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CustomerStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreatedAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PastOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;UtcDateTime&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests can then start with a clear default and override only what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;customer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CustomerFixtures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RuleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CustomerStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Suspended&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Generate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a good balance between convenience and control. The shared fixture owns the defaults; the test still makes its scenario-specific assumptions visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benefits and tradeoffs
&lt;/h2&gt;

&lt;p&gt;The benefits are practical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Less repetitive setup code.&lt;/li&gt;
&lt;li&gt;More variation than a small set of hand-written fixtures.&lt;/li&gt;
&lt;li&gt;Better-looking local development data.&lt;/li&gt;
&lt;li&gt;Explicit relationships between generated objects.&lt;/li&gt;
&lt;li&gt;Optional repeatability when debugging.&lt;/li&gt;
&lt;li&gt;Reusable rules across unit tests, integration tests, and seeders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoffs matter too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fixture can become difficult to understand if it contains too many hidden rules.&lt;/li&gt;
&lt;li&gt;Random output can make failures harder to reproduce if you do not capture a seed.&lt;/li&gt;
&lt;li&gt;Generated data may be plausible without being valid for your business domain.&lt;/li&gt;
&lt;li&gt;Large generated graphs can make tests slow and obscure the behavior under test.&lt;/li&gt;
&lt;li&gt;Global seeding can introduce coupling between tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution is to keep fakers small, use explicit overrides for important behavior, capture seeds when diagnosing failures, and assert invariants instead of snapshots of random output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Bogus is most useful when the problem is not “how do I mock this dependency?” but “how do I create enough controlled input to exercise this code?”&lt;/p&gt;

&lt;p&gt;Start with a &lt;code&gt;Faker&amp;lt;T&amp;gt;&lt;/code&gt;, add rules for the fields that matter, enable &lt;code&gt;StrictMode&lt;/code&gt; for shared fixtures, compose related objects from existing generated values, and use a seed when you need to reproduce a failure.&lt;/p&gt;

&lt;p&gt;Use it for test data, development data, demos, and load-test inputs. Do not use it as evidence that your business rules, external integrations, or security boundaries work. Those still need explicit tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/bchavez/Bogus" rel="noopener noreferrer"&gt;Bogus source repository and README&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.nuget.org/packages/Bogus" rel="noopener noreferrer"&gt;Bogus on NuGet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>testing</category>
    </item>
    <item>
      <title>Observability for Agents with OpenTelemetry, Aspire, and Application Insights</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/observability-for-agents-with-opentelemetry-aspire-and-application-insights-5fh3</link>
      <guid>https://dev.to/lukaswalter/observability-for-agents-with-opentelemetry-aspire-and-application-insights-5fh3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This is Part 17 of my series on the Microsoft Agent Framework. You can read the original post over on &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_17/" rel="noopener noreferrer"&gt;lukaswalter.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_16/" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, we looked at AG-UI and the frontend boundary for agents.&lt;br&gt;
The main point was that agent behavior should not be flattened into plain chat text.&lt;br&gt;
Tool calls, approvals, state updates, and cancellations are separate events.&lt;/p&gt;

&lt;p&gt;The same rule applies in production.&lt;/p&gt;

&lt;p&gt;Do not treat an agent run as one opaque log line.&lt;br&gt;
An agent run is a chain of model calls, tool calls, retries, approvals, state changes, and external dependencies.&lt;br&gt;
If you cannot see that chain, you cannot debug cost, latency, failure, or unsafe behavior.&lt;/p&gt;

&lt;p&gt;I want the trace to look more like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP request
-&amp;gt; agent run
-&amp;gt; model call
-&amp;gt; tool call
-&amp;gt; model call
-&amp;gt; approval request
-&amp;gt; tool execution
-&amp;gt; final response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that trace, I want to answer normal production questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which model was called?&lt;/li&gt;
&lt;li&gt;How many input and output tokens were used?&lt;/li&gt;
&lt;li&gt;Which tool ran?&lt;/li&gt;
&lt;li&gt;How long did each step take?&lt;/li&gt;
&lt;li&gt;Did the tool fail or retry?&lt;/li&gt;
&lt;li&gt;Did a human approve the side effect?&lt;/li&gt;
&lt;li&gt;Was sensitive prompt or tool data captured?&lt;/li&gt;
&lt;li&gt;Can I inspect the same flow locally before sending it to the cloud?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenTelemetry is the shared telemetry format.&lt;br&gt;
Aspire gives me the local loop.&lt;br&gt;
Application Insights is where the same signals become searchable in Azure, including the agent-focused views in Azure Monitor.&lt;/p&gt;
&lt;h2&gt;
  
  
  Start with the questions
&lt;/h2&gt;

&lt;p&gt;Before adding packages, decide what the telemetry must explain.&lt;/p&gt;

&lt;p&gt;For most agent systems, I would start with these questions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is the agent slow?&lt;/td&gt;
&lt;td&gt;Trace duration by run, model call, tool call, dependency call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the agent expensive?&lt;/td&gt;
&lt;td&gt;Token usage by agent, model, route, tenant, or feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the agent failing?&lt;/td&gt;
&lt;td&gt;Exceptions, failed tool calls, failed model calls, finish reasons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the agent looping?&lt;/td&gt;
&lt;td&gt;Model call count, tool call count, max-iteration exits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the agent using the wrong tool?&lt;/td&gt;
&lt;td&gt;Tool name, tool risk, tool result class, approval decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is sensitive data being exported?&lt;/td&gt;
&lt;td&gt;Explicit content-capture setting, redaction policy, sampling policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can support find one incident?&lt;/td&gt;
&lt;td&gt;Conversation id, run id, trace id, approval id, user-safe correlation id&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what is not in the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Log the whole prompt and hope it helps later.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can help in a development environment.&lt;br&gt;
In production, it is often the wrong default.&lt;br&gt;
Prompts, tool arguments, retrieved documents, and tool results may contain customer data, secrets, internal tickets, file contents, or personally identifiable information.&lt;/p&gt;

&lt;p&gt;Observability does not mean storing everything.&lt;br&gt;
Treat it like production data, with the same access and retention discipline you would apply anywhere else.&lt;/p&gt;
&lt;h2&gt;
  
  
  The basic OpenTelemetry setup
&lt;/h2&gt;

&lt;p&gt;Agent Framework builds on &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt;.&lt;br&gt;
That matters because &lt;code&gt;Microsoft.Extensions.AI&lt;/code&gt; provides an OpenTelemetry middleware for chat clients.&lt;br&gt;
The extension method is &lt;code&gt;UseOpenTelemetry&lt;/code&gt; on &lt;code&gt;ChatClientBuilder&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Start with the chat client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.AI.OpenAI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Identity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;WebApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AzureOpenAIClient&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;]!),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_DEPLOYMENT"&lt;/span&gt;&lt;span class="p"&gt;]!)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsIChatClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsBuilder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;sourceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;telemetry&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;telemetry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableSensitiveData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
                &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"AI:Telemetry:EnableSensitiveData"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseFunctionInvocation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;WebApplication&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"deployment-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;    &lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt; &lt;span class="n"&gt;operators&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;prepare&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Use&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;needed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Ask&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;production&lt;/span&gt; &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="s"&gt;""",
&lt;/span&gt;    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;AIFunctionFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckStagingStatusAsync&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;AIFunctionFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeployToProductionAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here.&lt;/p&gt;

&lt;p&gt;First, the &lt;code&gt;sourceName&lt;/code&gt; is deliberate.&lt;br&gt;
OpenTelemetry tracing only collects activity sources that the tracer provider listens to.&lt;br&gt;
Aspire service defaults already add the application name as a trace source.&lt;br&gt;
Using &lt;code&gt;builder.Environment.ApplicationName&lt;/code&gt; keeps the chat client activity source aligned with the application telemetry source.&lt;/p&gt;

&lt;p&gt;Second, sensitive data is disabled unless development configuration explicitly enables it.&lt;br&gt;
By default, the OpenTelemetry chat client records metadata such as model information and token counts, but not raw prompts, raw outputs, tool arguments, or tool results.&lt;br&gt;
That default is boring in the best possible way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: The exact telemetry attributes follow the OpenTelemetry Generative AI semantic conventions. Those conventions are still moving, so verify attribute names against your package version before you bake them into dashboards or KQL queries.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Add the AI source to Service Defaults
&lt;/h2&gt;

&lt;p&gt;If you use Aspire, your app probably has a &lt;code&gt;ServiceDefaults&lt;/code&gt; project with a &lt;code&gt;ConfigureOpenTelemetry&lt;/code&gt; method.&lt;br&gt;
The starter template already configures ASP.NET Core, &lt;code&gt;HttpClient&lt;/code&gt;, runtime metrics, OTLP export, and trace collection.&lt;/p&gt;

&lt;p&gt;For agent telemetry, make sure the same source name used by &lt;code&gt;UseOpenTelemetry&lt;/code&gt; is collected for both traces and metrics.&lt;/p&gt;

&lt;p&gt;In the service defaults project, extend the OpenTelemetry setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;OpenTelemetry.Metrics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;OpenTelemetry.Trace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="n"&gt;ConfigureOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;TBuilder&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IHostApplicationBuilder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logging&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IncludeFormattedMessage&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IncludeScopes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithMetrics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;metrics&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMeter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClientInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddRuntimeInstrumentation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;tracing&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
                        &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWithSegments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartsWithSegments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/alive"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;})&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClientInstrumentation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;tracing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetSampler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AlwaysOnSampler&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetryExporters&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These two lines matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddMeter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chat client creates an &lt;code&gt;ActivitySource&lt;/code&gt; and a &lt;code&gt;Meter&lt;/code&gt;.&lt;br&gt;
If your OpenTelemetry configuration does not listen to them, the code can be instrumented and still produce nothing in the dashboard.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the chat client gives you
&lt;/h2&gt;

&lt;p&gt;The OpenTelemetry chat client is the easiest part of the setup.&lt;br&gt;
It sits in the &lt;code&gt;IChatClient&lt;/code&gt; pipeline and observes calls made through that client.&lt;/p&gt;

&lt;p&gt;On a normal agent run, it can record model metadata such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;operation name&lt;/li&gt;
&lt;li&gt;requested model&lt;/li&gt;
&lt;li&gt;response model&lt;/li&gt;
&lt;li&gt;provider name&lt;/li&gt;
&lt;li&gt;server address&lt;/li&gt;
&lt;li&gt;streaming flag&lt;/li&gt;
&lt;li&gt;finish reason&lt;/li&gt;
&lt;li&gt;response id&lt;/li&gt;
&lt;li&gt;input token count&lt;/li&gt;
&lt;li&gt;output token count&lt;/li&gt;
&lt;li&gt;cached input token count, when available&lt;/li&gt;
&lt;li&gt;reasoning output token count, when available&lt;/li&gt;
&lt;li&gt;operation duration&lt;/li&gt;
&lt;li&gt;time to first streamed chunk&lt;/li&gt;
&lt;li&gt;time between streamed chunks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With that alone, you can answer the first set of production questions.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Why did this request take 34 seconds?
-&amp;gt; The first model call took 1.4 seconds.
-&amp;gt; The search tool took 29 seconds.
-&amp;gt; The final model call took 2.1 seconds.

Why did this request cost more than usual?
-&amp;gt; The input token count jumped after retrieval.
-&amp;gt; The agent included eight document chunks instead of two.
-&amp;gt; The route used a larger model than the normal path.

Why did streaming feel broken?
-&amp;gt; The model call started quickly.
-&amp;gt; Time to first chunk was 18 seconds.
-&amp;gt; The delay is provider-side or prompt-side, not frontend rendering.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A trace turns "the agent was slow" into something you can actually fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token usage is an operational signal
&lt;/h2&gt;

&lt;p&gt;Token usage is billing data, but it is also behavior data.&lt;/p&gt;

&lt;p&gt;High input tokens can mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the chat reducer is not running&lt;/li&gt;
&lt;li&gt;retrieval returned too much context&lt;/li&gt;
&lt;li&gt;the agent is carrying stale state&lt;/li&gt;
&lt;li&gt;the frontend is resending too much history&lt;/li&gt;
&lt;li&gt;tool results are too verbose&lt;/li&gt;
&lt;li&gt;an inner agent is dumping its full conversation into the outer agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;High output tokens can mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent is overexplaining&lt;/li&gt;
&lt;li&gt;the instructions are too broad&lt;/li&gt;
&lt;li&gt;structured output is not constrained&lt;/li&gt;
&lt;li&gt;the model is stuck in a repair loop&lt;/li&gt;
&lt;li&gt;the user asked for a large artifact&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OpenTelemetry middleware records token usage when the provider returns usage information.&lt;br&gt;
You can also inspect usage directly from responses when you need a local guardrail:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AgentResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Check release-2026-07-08 and tell me whether it is ready."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Usage&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"Agent run used {InputTokens} input tokens and {OutputTokens} output tokens."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InputTokenCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OutputTokenCount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I would not turn that into the main production telemetry path.&lt;br&gt;
Use OpenTelemetry for durable traces and metrics.&lt;br&gt;
Use direct response usage checks for local assertions, tests, or immediate guardrails.&lt;/p&gt;

&lt;p&gt;For example, an integration test can fail when a supposedly small route starts consuming a huge prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Usage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;InputTokenCount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;BeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches prompt and retrieval regressions before they turn into a cloud bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool calls need their own spans
&lt;/h2&gt;

&lt;p&gt;Model telemetry is not enough.&lt;br&gt;
Many agent failures happen outside the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a search index is slow&lt;/li&gt;
&lt;li&gt;an MCP server is down&lt;/li&gt;
&lt;li&gt;a deployment API rejects a change ticket&lt;/li&gt;
&lt;li&gt;a database query times out&lt;/li&gt;
&lt;li&gt;a tool result is too large&lt;/li&gt;
&lt;li&gt;approval is granted but execution fails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your tool is just normal C#, instrument it like normal C#.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.DependencyInjection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentTelemetry&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="n"&gt;ActivitySource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DeploymentAgent"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeploymentStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CheckStagingStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AgentTelemetry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ActivitySource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartActivity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"tool deployment.check_staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ActivityKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Internal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"agent.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"deployment-agent"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.tool.name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"CheckStagingStatusAsync"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ai.tool.risk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"read_only"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deployment.environment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IDeploymentService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;DeploymentStatus&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckStagingStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"deployment.status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ActivityStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;SetStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ActivityStatusCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;AddException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then collect the tool source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;tracing&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"DeploymentAgent"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClientInstrumentation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use low-cardinality tags for dashboards.&lt;br&gt;
For example, &lt;code&gt;deployment.environment=production&lt;/code&gt; is fine.&lt;br&gt;
Raw &lt;code&gt;releaseId&lt;/code&gt;, user messages, customer names, email addresses, and ticket descriptions need more care.&lt;/p&gt;

&lt;p&gt;When you need correlation without raw values, log a stable hash or an internal id that your support process can resolve under access control.&lt;/p&gt;
&lt;h2&gt;
  
  
  Approval telemetry is separate from tool telemetry
&lt;/h2&gt;

&lt;p&gt;In the human-in-the-loop article, the rule was:&lt;/p&gt;

&lt;p&gt;The model requests the action.&lt;br&gt;
The application decides whether the action continues.&lt;/p&gt;

&lt;p&gt;Telemetry should preserve that split.&lt;/p&gt;

&lt;p&gt;The approval request, human decision, and eventual tool execution are related, but they are not the same event.&lt;/p&gt;

&lt;p&gt;I usually want these approval fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;approvalId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;conversationId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;agentRunId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;traceId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toolName&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;toolArgumentsHash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;requestedBy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;approvedBy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decision&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decisionReason&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;decidedAt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;executedAt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;executionResult&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An approval log might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Agent tool approval decided. ApprovalId={ApprovalId} ToolName={ToolName} Decision={Decision} TraceId={TraceId}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Approved&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s"&gt;"approved"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;TraceId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not log full tool arguments by default.&lt;br&gt;
For production, I usually prefer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LogInformation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Approval request created. ApprovalId={ApprovalId} ToolName={ToolName} ArgumentsHash={ArgumentsHash}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;argumentsHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user interface can show exact arguments at approval time.&lt;br&gt;
That does not mean the telemetry backend should keep those arguments forever.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sensitive data is a feature flag, not a casual toggle
&lt;/h2&gt;

&lt;p&gt;The OpenTelemetry chat client has an &lt;code&gt;EnableSensitiveData&lt;/code&gt; setting.&lt;br&gt;
There is also an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When enabled, telemetry can include raw inputs and outputs such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;system instructions&lt;/li&gt;
&lt;li&gt;user messages&lt;/li&gt;
&lt;li&gt;assistant messages&lt;/li&gt;
&lt;li&gt;tool definitions&lt;/li&gt;
&lt;li&gt;tool call arguments&lt;/li&gt;
&lt;li&gt;tool call results&lt;/li&gt;
&lt;li&gt;additional request and response properties&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This helps during local debugging.&lt;br&gt;
It can also become a data-retention problem in production.&lt;/p&gt;

&lt;p&gt;I would treat it as an explicit environment policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;sourceName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;telemetry&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;telemetry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnableSensitiveData&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsDevelopment&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetValue&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"AI:Telemetry:EnableSensitiveData"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make the configuration explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AI"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Telemetry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"EnableSensitiveData"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, decide this with security, privacy, and compliance input.&lt;br&gt;
If you enable prompt capture, also decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;which environments may capture it&lt;/li&gt;
&lt;li&gt;who can query it&lt;/li&gt;
&lt;li&gt;how long it is retained&lt;/li&gt;
&lt;li&gt;whether sampling applies&lt;/li&gt;
&lt;li&gt;which fields are redacted before export&lt;/li&gt;
&lt;li&gt;whether customer data can cross region or tenant boundaries&lt;/li&gt;
&lt;li&gt;whether tool results include secrets or confidential documents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The default should be metadata only.&lt;br&gt;
Turn on content capture only when the debugging value justifies the data risk.&lt;/p&gt;
&lt;h2&gt;
  
  
  Redact before export when possible
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry processors can modify telemetry before it leaves the process.&lt;br&gt;
For agent systems, this matters because sensitive content often appears in attributes, logs, or exception messages.&lt;/p&gt;

&lt;p&gt;A minimal custom processor can remove known high-risk attributes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Diagnostics&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AgentRedactionProcessor&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BaseProcessor&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;SensitiveTags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s"&gt;"gen_ai.input.messages"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"gen_ai.output.messages"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"gen_ai.system.instructions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"gen_ai.tool.definitions"&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Activity&lt;/span&gt; &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;tagName&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SensitiveTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in tracing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTracing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracing&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;tracing&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ApplicationName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddProcessor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AgentRedactionProcessor&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAspNetCoreInstrumentation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClientInstrumentation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is just a sketch.&lt;br&gt;
Real redaction usually needs to happen earlier, closer to the application boundary.&lt;br&gt;
I prefer this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avoid collecting raw data
-&amp;gt; redact known sensitive fields
-&amp;gt; sample high-volume telemetry
-&amp;gt; restrict access in the telemetry backend
-&amp;gt; set retention intentionally
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not rely on one processor as your entire privacy story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local debugging with Aspire
&lt;/h2&gt;

&lt;p&gt;Aspire is a good local loop for agent observability.&lt;/p&gt;

&lt;p&gt;With an AppHost and service defaults, you can run the agent backend locally and inspect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;resources&lt;/li&gt;
&lt;li&gt;environment variables&lt;/li&gt;
&lt;li&gt;logs&lt;/li&gt;
&lt;li&gt;traces&lt;/li&gt;
&lt;li&gt;metrics&lt;/li&gt;
&lt;li&gt;outbound HTTP calls&lt;/li&gt;
&lt;li&gt;failed dependencies&lt;/li&gt;
&lt;li&gt;model-call spans&lt;/li&gt;
&lt;li&gt;tool-call spans, if you added them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I like about this setup is that the local flow uses OTLP.&lt;br&gt;
Your app emits OpenTelemetry data.&lt;br&gt;
The Aspire dashboard receives it.&lt;br&gt;
You can inspect the same telemetry before sending it to Azure Monitor.&lt;/p&gt;

&lt;p&gt;A minimal AppHost might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DistributedApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddProject&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeploymentAgent_Api&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"deployment-agent-api"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AI__Telemetry__EnableSensitiveData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run the AppHost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet run &lt;span class="nt"&gt;--project&lt;/span&gt; src/DeploymentAgent.AppHost
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the dashboard, inspect one request end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /chat
-&amp;gt; deployment-agent model call
-&amp;gt; CheckStagingStatusAsync tool span
-&amp;gt; deployment API HTTP dependency
-&amp;gt; deployment-agent model call
-&amp;gt; approval request log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what I want to see locally.&lt;br&gt;
If you only see ASP.NET Core request spans, the app is observable as a web API but not yet observable as an agent.&lt;/p&gt;

&lt;p&gt;If spans are missing, check the boring parts first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UseOpenTelemetry&lt;/code&gt; is in the &lt;code&gt;IChatClient&lt;/code&gt; pipeline before creating the agent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sourceName&lt;/code&gt; matches an &lt;code&gt;AddSource(...)&lt;/code&gt; entry.&lt;/li&gt;
&lt;li&gt;The chat client's meter name is added with &lt;code&gt;AddMeter(...)&lt;/code&gt; if you want token metrics.&lt;/li&gt;
&lt;li&gt;Custom tool &lt;code&gt;ActivitySource&lt;/code&gt; names are added with &lt;code&gt;AddSource(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; is set by Aspire or by your local environment.&lt;/li&gt;
&lt;li&gt;The provider actually returns usage information if token counts are missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also remember that the Aspire dashboard can display sensitive configuration and telemetry.&lt;br&gt;
If you enable prompt capture locally, treat the dashboard as sensitive too.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cloud monitoring with Application Insights
&lt;/h2&gt;

&lt;p&gt;For Azure Monitor and Application Insights, the usual .NET path is the Azure Monitor OpenTelemetry distro.&lt;br&gt;
For ASP.NET Core applications, install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Azure.Monitor.OpenTelemetry.AspNetCore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then enable Azure Monitor export:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Monitor.OpenTelemetry.AspNetCore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;WebApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServiceDefaults&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;IsNullOrWhiteSpace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"APPLICATIONINSIGHTS_CONNECTION_STRING"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddOpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAzureMonitor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In production, set the connection string through configuration, not as a hard-coded value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...;IngestionEndpoint=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Aspire service defaults template already includes a commented Azure Monitor exporter section.&lt;br&gt;
I usually wire it there so the same service defaults project controls local OTLP export and cloud export.&lt;/p&gt;

&lt;p&gt;Once telemetry reaches Application Insights, you can use the normal views:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Application map&lt;/li&gt;
&lt;li&gt;Transaction search&lt;/li&gt;
&lt;li&gt;End-to-end transaction details&lt;/li&gt;
&lt;li&gt;Failures&lt;/li&gt;
&lt;li&gt;Performance&lt;/li&gt;
&lt;li&gt;Logs&lt;/li&gt;
&lt;li&gt;Metrics&lt;/li&gt;
&lt;li&gt;Workbooks&lt;/li&gt;
&lt;li&gt;Alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a workspace-based Application Insights setup, the underlying trace and log data is queryable in the connected Log Analytics workspace.&lt;br&gt;
That is where the KQL queries for custom dashboards, incident review, and agent-specific investigations usually live.&lt;/p&gt;

&lt;p&gt;For AI systems, Application Insights also has an Agents view.&lt;br&gt;
It is built around OpenTelemetry Generative AI semantics and is meant to help inspect agent runs, model usage, tool calls, errors, token usage, and cost-related behavior.&lt;/p&gt;

&lt;p&gt;Still, keep normal application telemetry.&lt;br&gt;
Agent observability should sit beside request, dependency, database, queue, cache, and runtime telemetry.&lt;br&gt;
The agent is part of the application, not a separate universe.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to alert on
&lt;/h2&gt;

&lt;p&gt;Start with boring alerts.&lt;/p&gt;

&lt;p&gt;For agents, I would usually begin with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model call failure rate&lt;/li&gt;
&lt;li&gt;tool call failure rate&lt;/li&gt;
&lt;li&gt;high run duration&lt;/li&gt;
&lt;li&gt;high model duration&lt;/li&gt;
&lt;li&gt;high tool duration&lt;/li&gt;
&lt;li&gt;high input token count&lt;/li&gt;
&lt;li&gt;high output token count&lt;/li&gt;
&lt;li&gt;repeated max-iteration exits&lt;/li&gt;
&lt;li&gt;approval rejection spike&lt;/li&gt;
&lt;li&gt;approval timeout spike&lt;/li&gt;
&lt;li&gt;missing telemetry from an expected agent service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Avoid alerting on every weird model response.&lt;br&gt;
That belongs in evaluation and product analytics more than infrastructure alerting.&lt;/p&gt;

&lt;p&gt;I split it this way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Better tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service is down&lt;/td&gt;
&lt;td&gt;Application Insights alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model calls are failing&lt;/td&gt;
&lt;td&gt;Application Insights alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool latency is high&lt;/td&gt;
&lt;td&gt;Application Insights alert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token usage suddenly doubled&lt;/td&gt;
&lt;td&gt;Metric alert or workbook&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answer quality regressed&lt;/td&gt;
&lt;td&gt;Evaluation pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent chose a poor plan&lt;/td&gt;
&lt;td&gt;Trace review plus evaluation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User disliked the answer&lt;/td&gt;
&lt;td&gt;Product analytics plus evaluation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For token spikes, tie the alert back to the meter you configured earlier with &lt;code&gt;AddMeter(...)&lt;/code&gt;.&lt;br&gt;
If the OpenTelemetry pipeline exports the chat client token metrics to Azure Monitor, you can use Azure Monitor metric alerts for the fast signal and a workbook or KQL query for the investigation view.&lt;/p&gt;

&lt;p&gt;Observability tells you what happened.&lt;br&gt;
Evaluation tells you whether the behavior was good.&lt;br&gt;
You need both.&lt;/p&gt;
&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;p&gt;For a production agent, I would want this before launch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every service has &lt;code&gt;service.name&lt;/code&gt; or an equivalent cloud role name.&lt;/li&gt;
&lt;li&gt;The agent has a stable name.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UseOpenTelemetry&lt;/code&gt; is configured on the chat client.&lt;/li&gt;
&lt;li&gt;The chat client source is collected by tracing.&lt;/li&gt;
&lt;li&gt;The chat client meter is collected by metrics.&lt;/li&gt;
&lt;li&gt;Tool spans exist for important application tools.&lt;/li&gt;
&lt;li&gt;Tool spans include low-cardinality tags such as tool name, risk, and result class.&lt;/li&gt;
&lt;li&gt;Approval decisions are logged with correlation ids.&lt;/li&gt;
&lt;li&gt;Raw prompt and tool content capture is disabled by default.&lt;/li&gt;
&lt;li&gt;Any enabled content capture has a documented retention and access policy.&lt;/li&gt;
&lt;li&gt;The Aspire dashboard shows a full local agent run.&lt;/li&gt;
&lt;li&gt;Application Insights receives traces, metrics, and logs in the target environment.&lt;/li&gt;
&lt;li&gt;A failed tool call can be found from a trace id.&lt;/li&gt;
&lt;li&gt;A high-token request can be found and explained.&lt;/li&gt;
&lt;li&gt;Alerts exist for failure rate, latency, and missing telemetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A decent first version is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UseOpenTelemetry on the chat client
AddSource and AddMeter for the AI source
custom spans around important tools
metadata-only telemetry by default
Aspire locally
Application Insights in Azure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is enough to stop saying "the agent acted weird" and start looking at the actual execution path.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use this
&lt;/h2&gt;

&lt;p&gt;I would add this setup when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent calls real tools&lt;/li&gt;
&lt;li&gt;the agent runs in a web API or worker service&lt;/li&gt;
&lt;li&gt;token cost matters&lt;/li&gt;
&lt;li&gt;support needs to debug user incidents&lt;/li&gt;
&lt;li&gt;approval flows need auditability&lt;/li&gt;
&lt;li&gt;the agent has multiple model calls per request&lt;/li&gt;
&lt;li&gt;retrieval or tool latency affects user experience&lt;/li&gt;
&lt;li&gt;the system is moving from demo to production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The earlier you add it, the easier it is.&lt;br&gt;
Retrofitting observability after a production incident usually means you first have to guess where the system is opaque.&lt;/p&gt;
&lt;h2&gt;
  
  
  When I would not start here
&lt;/h2&gt;

&lt;p&gt;I would not start with full production telemetry when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the agent is still a throwaway prototype&lt;/li&gt;
&lt;li&gt;prompts contain data you are not allowed to store&lt;/li&gt;
&lt;li&gt;no one has decided telemetry retention and access rules&lt;/li&gt;
&lt;li&gt;the team has no baseline for normal token usage&lt;/li&gt;
&lt;li&gt;the agent has no stable tool boundaries yet&lt;/li&gt;
&lt;li&gt;the main problem is answer quality, not runtime behavior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In those cases, start smaller.&lt;br&gt;
Log request ids, model names, token usage, and tool names locally.&lt;br&gt;
Use Aspire for development.&lt;br&gt;
Add cloud export once the boundaries are stable enough to monitor intentionally.&lt;/p&gt;
&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Agent observability is more than "turn on logs".&lt;br&gt;
You need traces for the execution path, metrics for volume and cost, logs for decisions, and a clear policy for sensitive data.&lt;/p&gt;

&lt;p&gt;For .NET agents, I usually start here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Microsoft.Extensions.AI UseOpenTelemetry
-&amp;gt; Agent Framework agent
-&amp;gt; Aspire dashboard locally
-&amp;gt; Application Insights in Azure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fill the gaps that the model-call telemetry cannot know about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;custom tool spans&lt;/li&gt;
&lt;li&gt;approval logs&lt;/li&gt;
&lt;li&gt;application-specific correlation ids&lt;/li&gt;
&lt;li&gt;redaction and retention rules&lt;/li&gt;
&lt;li&gt;alerts that match real operational failure modes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not store every thought-like artifact an agent produces.&lt;br&gt;
Store enough evidence to explain failures, cost, latency, and side effects without guessing.&lt;/p&gt;

&lt;p&gt;The follow-up article moves from observing agent behavior to testing it.&lt;br&gt;
I will look at how to test Microsoft Agent Framework applications without treating the model as an untestable black box: fake model clients, tool contract tests, structured output tests, routing tests, workflow tests, and eval-style regression checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Microsoft Agent Framework docs: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/" rel="noopener noreferrer"&gt;Agent Framework documentation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft Agent Framework tools: &lt;a href="https://learn.microsoft.com/en-us/agent-framework/agents/tools/" rel="noopener noreferrer"&gt;Tools overview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI: &lt;a href="https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai" rel="noopener noreferrer"&gt;Microsoft.Extensions.AI libraries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI API reference: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.opentelemetrychatclientbuilderextensions.useopentelemetry" rel="noopener noreferrer"&gt;&lt;code&gt;UseOpenTelemetry&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Microsoft.Extensions.AI API reference: &lt;a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.ai.opentelemetrychatclient" rel="noopener noreferrer"&gt;&lt;code&gt;OpenTelemetryChatClient&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Aspire docs: &lt;a href="https://aspire.dev/fundamentals/telemetry/" rel="noopener noreferrer"&gt;Telemetry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Aspire docs: &lt;a href="https://aspire.dev/dashboard/overview/" rel="noopener noreferrer"&gt;Dashboard overview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Aspire docs: &lt;a href="https://aspire.dev/get-started/csharp-service-defaults/" rel="noopener noreferrer"&gt;C# Service Defaults&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Azure Monitor docs: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-monitor/app/opentelemetry-overview" rel="noopener noreferrer"&gt;Application Insights OpenTelemetry observability&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Azure Monitor docs: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-monitor/app/opentelemetry-enable" rel="noopener noreferrer"&gt;Enable Azure Monitor OpenTelemetry&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Azure Monitor docs: &lt;a href="https://learn.microsoft.com/en-us/azure/azure-monitor/app/agents-view" rel="noopener noreferrer"&gt;Monitor AI agents with Application Insights&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OpenTelemetry: &lt;a href="https://github.com/open-telemetry/semantic-conventions-genai" rel="noopener noreferrer"&gt;Generative AI semantic conventions repository&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Run Python in-process from .NET with CSnakes</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/run-python-in-process-from-net-with-csnakes-49f9</link>
      <guid>https://dev.to/lukaswalter/run-python-in-process-from-net-with-csnakes-49f9</guid>
      <description>&lt;p&gt;So I definitely don't do everything using C# and .NET. I also use Python, but sometimes I just do not want another tiny Python service.&lt;/p&gt;

&lt;p&gt;I want one Python library inside an otherwise normal .NET app. No container boundary. No HTTP wrapper. No extra deployment for something that is really just a function call.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CSnakes&lt;/code&gt; is interesting for that case. It runs Python in the same process and generates C# bindings from typed Python functions. The Python code stays in Python, but the .NET side can call it through a normal-looking method.&lt;/p&gt;

&lt;p&gt;That makes it a good fit for quick demos, proofs of concept, internal tools, or the one feature where Python has exactly the library you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;p&gt;Install the packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package CSnakes.Runtime
dotnet add package Microsoft.Extensions.Hosting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project file shape is still small. Add the package references and include your Python files as analyzer additional files. That is how the source generator finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"CSnakes.Runtime"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.2.1"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.Extensions.Hosting"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"10.0.9"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;AdditionalFiles&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"python/*.py"&lt;/span&gt; &lt;span class="na"&gt;SourceItemType=&lt;/span&gt;&lt;span class="s"&gt;"Python"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CopyToOutputDirectory&amp;gt;&lt;/span&gt;Always&lt;span class="nt"&gt;&amp;lt;/CopyToOutputDirectory&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/AdditionalFiles&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;None&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"requirements.txt"&lt;/span&gt; &lt;span class="na"&gt;CopyToOutputDirectory=&lt;/span&gt;&lt;span class="s"&gt;"PreserveNewest"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then wire up Python at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;CSnakes.Runtime&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.DependencyInjection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.Hosting&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateApplicationBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;pythonHome&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithPython&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pythonHome&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromRedistributable&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPythonEnvironment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FromRedistributable()&lt;/code&gt; can download and use an isolated Python runtime. If your environment already standardizes on Conda or system Python, CSnakes can use those too.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small example
&lt;/h2&gt;

&lt;p&gt;Imagine a .NET app that needs one fuzzy matching function from Python. That is small enough that a separate API would feel silly.&lt;/p&gt;

&lt;p&gt;Add a Python dependency in &lt;code&gt;requirements.txt&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rapidfuzz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a real app, pin the package version. Treat it like any other dependency you care about.&lt;/p&gt;

&lt;p&gt;We can update our startup code to use CSnakes' uv integration to handle this automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;requirements&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"requirements.txt"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithPython&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithHome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pythonHome&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromRedistributable&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithVirtualEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseDirectory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".venv"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithUvInstaller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requirements&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run may need network access because CSnakes can download the redistributable Python runtime and &lt;code&gt;uv&lt;/code&gt; installs the packages from &lt;code&gt;requirements.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now add &lt;code&gt;python/text_tools.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;rapidfuzz&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fuzz&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;match_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fuzz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_set_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;right&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the function has type hints, CSnakes can generate a typed C# wrapper. Python's &lt;code&gt;match_score&lt;/code&gt; shows up as &lt;code&gt;MatchScore&lt;/code&gt; in C#.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;textTools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TextTools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;textTools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MatchScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"Azure OpenAI invoice processing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"invoice processing with Azure AI"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The .NET code does not shell out to &lt;code&gt;python&lt;/code&gt;. It also does not serialize an HTTP request just to call one local function.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to keep in mind
&lt;/h2&gt;

&lt;p&gt;Keep the Python boundary boring.&lt;/p&gt;

&lt;p&gt;I would expose a few typed functions like &lt;code&gt;match_score&lt;/code&gt;, &lt;code&gt;extract_features&lt;/code&gt;, or &lt;code&gt;rank_candidates&lt;/code&gt;. I would not let half the domain model move into Python because it was convenient in the moment.&lt;/p&gt;

&lt;p&gt;Also remember that this is in-process. That is the point, but it changes the failure model. A Python dependency problem is now your application startup problem. A heavy Python call can affect your .NET process. The Python environment needs the same care as the rest of your runtime.&lt;/p&gt;

&lt;p&gt;Deployment is the other boundary to check early. CSnakes can work with Native AOT when you use the source-generated bindings shown above, but not every interop path is AOT friendly. Manual Python binding still runs into the usual AOT limits around reflection and dynamic type handling. A self-contained .NET publish also does not magically include Python, your packages, or the &lt;code&gt;.venv&lt;/code&gt; directory. Those still need to be bundled with the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use it
&lt;/h2&gt;

&lt;p&gt;I would reach for CSnakes when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the .NET app only needs a small slice of Python&lt;/li&gt;
&lt;li&gt;HTTP would add more ceremony than value&lt;/li&gt;
&lt;li&gt;the Python function has clear typed inputs and outputs&lt;/li&gt;
&lt;li&gt;the integration belongs inside the app, not beside it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would still use a separate Python service when the Python side has its own scaling needs, GPU runtime, deployment lifecycle, or team ownership. In-process interop is useful, but it is not a replacement for a real service boundary when you actually need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tonybaloney.github.io/CSnakes/" rel="noopener noreferrer"&gt;CSnakes documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tonybaloney.github.io/CSnakes/latest/getting-started/quick-start/" rel="noopener noreferrer"&gt;CSnakes quick start&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tonybaloney.github.io/CSnakes/latest/user-guide/environments/" rel="noopener noreferrer"&gt;CSnakes environment and package management&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tonybaloney.github.io/CSnakes/latest/advanced/native-aot/" rel="noopener noreferrer"&gt;CSnakes Native AOT support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=DqoxHNH9Iwo" rel="noopener noreferrer"&gt;Deep .NET: Using AI Python Libraries in .NET Apps with CSnakes (YouTube)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>python</category>
    </item>
    <item>
      <title>Agent Frontends with AG-UI: Streaming, Tool Calls and Human Approval</title>
      <dc:creator>Lukas Walter </dc:creator>
      <pubDate>Mon, 13 Jul 2026 15:30:00 +0000</pubDate>
      <link>https://dev.to/lukaswalter/agent-frontends-with-ag-ui-streaming-tool-calls-and-human-approval-4e00</link>
      <guid>https://dev.to/lukaswalter/agent-frontends-with-ag-ui-streaming-tool-calls-and-human-approval-4e00</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This is Part 16 of my series on the Microsoft Agent Framework. You can read the original post over on &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_16/" rel="noopener noreferrer"&gt;lukaswalter.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the &lt;a href="https://www.lukaswalter.dev/posts/agentframework_1_15/" rel="noopener noreferrer"&gt;previous article&lt;/a&gt;, we looked at human-in-the-loop agents.&lt;br&gt;
The main point was that approval is a system boundary.&lt;br&gt;
The model can request an action, but the application must decide whether that action is allowed to run.&lt;/p&gt;

&lt;p&gt;That immediately creates a frontend problem.&lt;/p&gt;

&lt;p&gt;A real agent UI cannot only show a chat transcript.&lt;br&gt;
It also has to show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;streamed text while the agent is still answering&lt;/li&gt;
&lt;li&gt;tool calls before and after execution&lt;/li&gt;
&lt;li&gt;long-running tool progress&lt;/li&gt;
&lt;li&gt;approval requests with exact arguments&lt;/li&gt;
&lt;li&gt;state updates that change the visible application&lt;/li&gt;
&lt;li&gt;errors, retries, cancellations, and run status&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AG-UI exists for this layer.&lt;br&gt;
It gives agent backends and frontends a protocol for exposing agent behavior as events instead of forcing everything through plain assistant text.&lt;/p&gt;

&lt;p&gt;A simple rule is:&lt;/p&gt;

&lt;p&gt;Use chat for language. Use events for agent behavior.&lt;/p&gt;
&lt;h2&gt;
  
  
  Plain chat is the wrong frontend boundary
&lt;/h2&gt;

&lt;p&gt;A text-only chat box is fine for a simple assistant.&lt;br&gt;
The user sends a message.&lt;br&gt;
The assistant streams a response.&lt;br&gt;
The interaction ends.&lt;/p&gt;

&lt;p&gt;That model breaks down once the agent can do real application work.&lt;/p&gt;

&lt;p&gt;Imagine a deployment assistant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: Deploy release-2026-06-24 to production using CHG-1042.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend should not wait silently until the final answer arrives.&lt;br&gt;
It should be able to show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Run started
Checking staging status...
Tool call: CheckStagingStatusAsync(release-2026-06-24)
Tool result: staging healthy
Approval required: DeployToProductionAsync(...)
User approved
Deploying...
Deployment complete
Run finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are not all assistant messages.&lt;br&gt;
Some are lifecycle events.&lt;br&gt;
Some are tool events.&lt;br&gt;
Some are approval requests.&lt;br&gt;
Some are state changes.&lt;/p&gt;

&lt;p&gt;If you flatten all of that into text, the frontend loses structure.&lt;br&gt;
It cannot reliably render progress, approval forms, state panels, or tool-specific UI.&lt;br&gt;
It has to parse generated prose, which is exactly the kind of boundary we have avoided throughout this series.&lt;/p&gt;
&lt;h2&gt;
  
  
  What AG-UI adds
&lt;/h2&gt;

&lt;p&gt;AG-UI is a protocol for agent user interfaces.&lt;br&gt;
In Agent Framework, the integration connects an agent backend to web clients over HTTP with Server-Sent Events.&lt;/p&gt;

&lt;p&gt;The useful part is not only "streaming text".&lt;br&gt;
We already had &lt;code&gt;RunStreamingAsync&lt;/code&gt; for that.&lt;/p&gt;

&lt;p&gt;The useful part is that the stream can carry richer agent events:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run lifecycle&lt;/li&gt;
&lt;li&gt;text message chunks&lt;/li&gt;
&lt;li&gt;tool calls&lt;/li&gt;
&lt;li&gt;tool results&lt;/li&gt;
&lt;li&gt;approval requests&lt;/li&gt;
&lt;li&gt;state snapshots&lt;/li&gt;
&lt;li&gt;state deltas&lt;/li&gt;
&lt;li&gt;errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives the frontend enough structure to render the agent as an application workflow instead of a typing indicator with a text box.&lt;/p&gt;

&lt;p&gt;The Agent Framework AG-UI documentation currently describes support for several protocol features, including agentic chat, backend tool rendering, human-in-the-loop approval, generative UI, shared state, and predictive state updates.&lt;br&gt;
The protocol is still evolving, so treat implementation details as something to verify against the current package version before shipping.&lt;/p&gt;
&lt;h2&gt;
  
  
  The backend shape in .NET
&lt;/h2&gt;

&lt;p&gt;For a .NET application, the basic backend shape is an ASP.NET Core endpoint.&lt;/p&gt;

&lt;p&gt;Install the AG-UI hosting package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Microsoft.Agents.AI.Hosting.AGUI.AspNetCore &lt;span class="nt"&gt;--prerelease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then expose an Agent Framework agent through &lt;code&gt;MapAGUI&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.AI.OpenAI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Azure.Identity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI.Hosting.AGUI.AspNetCore&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;WebApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAGUI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AzureOpenAIClient&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"AZURE_OPENAI_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;]!),&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;DefaultAzureCredential&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="n"&gt;IChatClient&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;azureClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsChatClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"gpt-5-mini"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"deployment-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"""
&lt;/span&gt;    &lt;span class="n"&gt;You&lt;/span&gt; &lt;span class="n"&gt;help&lt;/span&gt; &lt;span class="n"&gt;operators&lt;/span&gt; &lt;span class="n"&gt;inspect&lt;/span&gt; &lt;span class="n"&gt;deployment&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;prepare&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt; &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Explain&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="n"&gt;clearly&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;Ask&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;risky&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
    &lt;span class="s"&gt;""");
&lt;/span&gt;
&lt;span class="n"&gt;WebApplication&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapAGUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AddAGUI&lt;/code&gt; registers the AG-UI services.&lt;br&gt;
&lt;code&gt;MapAGUI&lt;/code&gt; exposes the agent through an HTTP endpoint and streams responses using Server-Sent Events.&lt;/p&gt;

&lt;p&gt;That endpoint is now the frontend boundary.&lt;br&gt;
The browser does not need to know the provider SDK.&lt;br&gt;
It talks to an AG-UI endpoint.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React or web client
-&amp;gt; HTTP POST with messages
-&amp;gt; ASP.NET Core MapAGUI endpoint
-&amp;gt; Agent Framework agent
-&amp;gt; model, tools, approvals, state
-&amp;gt; Server-Sent Events back to the client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A .NET client can consume it as an agent
&lt;/h2&gt;

&lt;p&gt;Agent Framework also has an AG-UI client package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package Microsoft.Agents.AI.AGUI &lt;span class="nt"&gt;--prerelease&lt;/span&gt;
dotnet add package Microsoft.Agents.AI &lt;span class="nt"&gt;--prerelease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client side can create an &lt;code&gt;AGUIChatClient&lt;/code&gt;, convert it to an agent, and consume streaming updates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Agents.AI.AGUI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.AI&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;serverUrl&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AGUI_SERVER_URL"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;??&lt;/span&gt; &lt;span class="s"&gt;"http://localhost:8888"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;httpClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;AGUIChatClient&lt;/span&gt; &lt;span class="n"&gt;aguiClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;httpClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aguiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"agui-client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Client-side proxy for the AG-UI agent endpoint."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;AgentSession&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateSessionAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChatRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Check release-2026-06-24 and tell me whether it is ready."&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentResponseUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunStreamingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                   &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIContent&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;TextContent&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful for console demos or service-to-service tests.&lt;br&gt;
For a real web frontend, you might use a React client such as CopilotKit or your own AG-UI event consumer.&lt;/p&gt;

&lt;p&gt;The important design point is the same either way:&lt;/p&gt;

&lt;p&gt;Do not treat the endpoint as "an API that returns a string".&lt;br&gt;
It is an event stream.&lt;/p&gt;
&lt;h2&gt;
  
  
  Stream text, but do not stop there
&lt;/h2&gt;

&lt;p&gt;Streaming text is the minimum viable agent frontend feature.&lt;/p&gt;

&lt;p&gt;It solves the waiting problem.&lt;br&gt;
The user sees output while the model is still generating.&lt;/p&gt;

&lt;p&gt;But once tools are involved, text streaming alone is not enough.&lt;br&gt;
The frontend also needs to understand the run lifecycle.&lt;/p&gt;

&lt;p&gt;At minimum, keep separate UI state for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Idle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Running&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;WaitingForApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;RunningTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Completed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Cancelled&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update it from events rather than from generated text.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AgentRunViewModel&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ConversationId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;RunId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AssistantText&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ToolCallViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ApprovalRequestViewModel&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PendingApproval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That separation matters.&lt;/p&gt;

&lt;p&gt;The assistant text is content.&lt;br&gt;
The run status is application state.&lt;br&gt;
Tool calls are structured events.&lt;br&gt;
Approvals are interactive decisions.&lt;/p&gt;

&lt;p&gt;If everything is appended to one markdown string, the frontend cannot behave like a product UI.&lt;/p&gt;
&lt;h2&gt;
  
  
  Show backend tool progress
&lt;/h2&gt;

&lt;p&gt;In AG-UI backend tool rendering, tools are defined and executed on the server.&lt;br&gt;
The frontend receives tool call and tool result updates.&lt;/p&gt;

&lt;p&gt;That is a good fit for actions that belong inside your application boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;search internal docs&lt;/li&gt;
&lt;li&gt;check deployment status&lt;/li&gt;
&lt;li&gt;calculate a quote&lt;/li&gt;
&lt;li&gt;query inventory&lt;/li&gt;
&lt;li&gt;create a support ticket&lt;/li&gt;
&lt;li&gt;call an authorized backend service&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple server-side tool looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.ComponentModel&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Extensions.DependencyInjection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Checks current deployment status for a release."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeploymentStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CheckDeploymentStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Release identifier, for example release-2026-06-24."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IDeploymentService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it with the agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;AITool&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;AIFunctionFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CheckDeploymentStatusAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chatClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"deployment-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Use tools when deployment state is needed."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapAGUI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the client side, handle tool call and tool result content separately from text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentResponseUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunStreamingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                   &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AIContent&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;TextContent&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;AppendAssistantText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;FunctionCallContent&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;MarkToolStarted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;FunctionResultContent&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;MarkToolFinished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual UI can then show tool progress in a side panel, status bar, timeline, or inline expandable detail.&lt;/p&gt;

&lt;p&gt;The point is not to expose every internal detail to the user.&lt;br&gt;
The point is to make the system understandable.&lt;/p&gt;

&lt;p&gt;Good frontend text might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Checking staging deployment status...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bad frontend text is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The model is thinking.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first tells the user what the system is doing.&lt;br&gt;
The second tells them nothing operationally useful.&lt;/p&gt;
&lt;h2&gt;
  
  
  Backend tools and frontend tools are different
&lt;/h2&gt;

&lt;p&gt;AG-UI can also support frontend tools.&lt;/p&gt;

&lt;p&gt;A frontend tool is registered on the client and executes in the client environment.&lt;br&gt;
The server can see the tool declaration and request the call, but the implementation runs on the client.&lt;/p&gt;

&lt;p&gt;That is useful for client-local capabilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read browser-local preferences&lt;/li&gt;
&lt;li&gt;access device location after user permission&lt;/li&gt;
&lt;li&gt;inspect UI selection&lt;/li&gt;
&lt;li&gt;copy something to clipboard&lt;/li&gt;
&lt;li&gt;trigger a client-side view change&lt;/li&gt;
&lt;li&gt;read state that only exists in the frontend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Gets the user's current client-side location after permission."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;GetUserLocationAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// In a real frontend, this would call browser or device APIs.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Berlin, Germany"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;AITool&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;frontendTools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;AIFunctionFactory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GetUserLocationAsync&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="n"&gt;AIAgent&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;aguiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsAIAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"agui-client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"AG-UI client with frontend tools."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;frontendTools&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server receives the frontend tool schema.&lt;br&gt;
The model can request the tool.&lt;br&gt;
The client executes it and returns the result.&lt;/p&gt;

&lt;p&gt;Do not use frontend tools for privileged operations.&lt;/p&gt;

&lt;p&gt;If the action changes server data, spends money, changes permissions, or calls internal systems, make it a backend tool with normal authentication, authorization, validation, and audit logging.&lt;/p&gt;

&lt;p&gt;Frontend tools are for client capabilities.&lt;br&gt;
Backend tools are for application capabilities.&lt;/p&gt;
&lt;h2&gt;
  
  
  Approval requests need real UI
&lt;/h2&gt;

&lt;p&gt;The previous article covered &lt;code&gt;ApprovalRequiredAIFunction&lt;/code&gt; on the backend.&lt;br&gt;
AG-UI brings that approval request across the frontend boundary.&lt;/p&gt;

&lt;p&gt;In the .NET pattern documented for Agent Framework, approval-required tools use &lt;code&gt;ApprovalRequiredAIFunction&lt;/code&gt;.&lt;br&gt;
Middleware converts &lt;code&gt;FunctionApprovalRequestContent&lt;/code&gt; into an AG-UI client tool call, commonly named &lt;code&gt;request_approval&lt;/code&gt;.&lt;br&gt;
The client renders an approval UI and sends the decision back as a tool result.&lt;br&gt;
Middleware then converts that response back into &lt;code&gt;FunctionApprovalResponseContent&lt;/code&gt; so the agent can continue.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent requests risky function
-&amp;gt; FunctionApprovalRequestContent
-&amp;gt; AG-UI client tool call: request_approval
-&amp;gt; frontend shows approval UI
-&amp;gt; user approves or rejects
-&amp;gt; frontend returns tool result
-&amp;gt; FunctionApprovalResponseContent
-&amp;gt; agent continues
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend must show the actual operation.&lt;br&gt;
Not just the model's explanation.&lt;/p&gt;

&lt;p&gt;For a deployment approval, show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tool: DeployToProductionAsync
Release: release-2026-06-24
Environment: production
Change ticket: CHG-1042
Requested by: lukas@example.com
Expires: 2026-06-24 15:30 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A view model might look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;ApprovalRequestViewModel&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ApprovalId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;FunctionName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyDictionary&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;object&lt;/span&gt;&lt;span class="p"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Arguments&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;RequestedBy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt; &lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;AgentExplanation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frontend should not approve vague text such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The agent wants to proceed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should approve a specific function call with specific arguments.&lt;/p&gt;

&lt;p&gt;This is where AG-UI matters.&lt;br&gt;
Approval becomes an interactive event in the frontend, not a paragraph in the chat.&lt;/p&gt;
&lt;h2&gt;
  
  
  State updates are separate from messages
&lt;/h2&gt;

&lt;p&gt;Some agent applications are not only conversations.&lt;br&gt;
They have visible application state.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a trip plan&lt;/li&gt;
&lt;li&gt;a deployment checklist&lt;/li&gt;
&lt;li&gt;a draft support reply&lt;/li&gt;
&lt;li&gt;a generated recipe&lt;/li&gt;
&lt;li&gt;a remediation plan&lt;/li&gt;
&lt;li&gt;a document outline&lt;/li&gt;
&lt;li&gt;a shopping cart&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent may update that state while it works.&lt;/p&gt;

&lt;p&gt;AG-UI state management exists so the client and server can keep a synchronized view of application state.&lt;br&gt;
The Agent Framework docs describe state snapshots, bidirectional state synchronization, and predictive state updates.&lt;/p&gt;

&lt;p&gt;For a deployment frontend, state might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeploymentReviewState&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;ReleaseId&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Environment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;CurrentStage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"not-started"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeploymentCheck&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Checks&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;RequiresApproval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeploymentCheck&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Details&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the UI can render a real checklist instead of hoping the model produces a readable markdown list every time.&lt;/p&gt;

&lt;p&gt;AG-UI state events let the frontend receive updates as structured data.&lt;br&gt;
The exact implementation depends on the client stack and package version.&lt;br&gt;
In the current .NET docs, one C# pattern emits &lt;code&gt;DataContent&lt;/code&gt; with &lt;code&gt;application/json&lt;/code&gt;; the AG-UI hosting layer converts that into state snapshot events.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent or middleware produces structured state
-&amp;gt; JSON state snapshot
-&amp;gt; AG-UI state event
-&amp;gt; frontend replaces or patches local state
-&amp;gt; UI rerenders structured panels
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key rule is:&lt;/p&gt;

&lt;p&gt;Do not make the frontend parse state out of assistant text.&lt;/p&gt;

&lt;p&gt;If the agent updates a checklist, send a checklist object.&lt;br&gt;
If it updates a plan, send a plan object.&lt;br&gt;
If it updates approval state, send approval state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Predictive state updates need discipline
&lt;/h2&gt;

&lt;p&gt;Predictive state updates are attractive because the frontend can update while the model is still generating tool arguments.&lt;br&gt;
That can make generative UI feel much faster.&lt;/p&gt;

&lt;p&gt;But optimistic state is still speculative.&lt;/p&gt;

&lt;p&gt;The model may change its arguments while streaming.&lt;br&gt;
The user may reject the approval.&lt;br&gt;
The tool may fail validation.&lt;br&gt;
The final backend state may differ from the predicted state.&lt;/p&gt;

&lt;p&gt;So treat predictive updates as provisional.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;StateConfidence&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Predicted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Confirmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Rejected&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;UiStateEnvelope&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;StateConfidence&lt;/span&gt; &lt;span class="n"&gt;Confidence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;SourceRunId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good UI can show predicted state differently from confirmed state.&lt;br&gt;
For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dim predicted checklist items&lt;/li&gt;
&lt;li&gt;show "drafting..." on generated sections&lt;/li&gt;
&lt;li&gt;require approval before applying risky state changes&lt;/li&gt;
&lt;li&gt;rollback predicted changes if the tool call is rejected&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters most when predictive state combines with human approval.&lt;/p&gt;

&lt;p&gt;If the frontend shows a deployment plan while the model streams tool arguments, that does not mean the plan has been approved or executed.&lt;br&gt;
The UI needs separate states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;predicted
pending approval
approved
executed
failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not let optimistic UI blur the boundary between "the model proposed this" and "the system did this".&lt;/p&gt;

&lt;h2&gt;
  
  
  AG-UI does not replace product design
&lt;/h2&gt;

&lt;p&gt;AG-UI gives you a protocol.&lt;br&gt;
It does not decide what your product should show.&lt;/p&gt;

&lt;p&gt;You still need to design the interaction.&lt;/p&gt;

&lt;p&gt;For each agent feature, decide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What should be visible to the user?&lt;/li&gt;
&lt;li&gt;What should stay in developer traces?&lt;/li&gt;
&lt;li&gt;Which tool calls deserve inline status?&lt;/li&gt;
&lt;li&gt;Which tool calls should be hidden behind a simple progress label?&lt;/li&gt;
&lt;li&gt;Which actions require approval?&lt;/li&gt;
&lt;li&gt;Which state updates are provisional?&lt;/li&gt;
&lt;li&gt;Which errors need user action?&lt;/li&gt;
&lt;li&gt;Which errors should only be logged?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deployment assistant, I would show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current run status&lt;/li&gt;
&lt;li&gt;deployment checklist state&lt;/li&gt;
&lt;li&gt;meaningful tool progress&lt;/li&gt;
&lt;li&gt;approval request details&lt;/li&gt;
&lt;li&gt;final result&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I would not show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every token event&lt;/li&gt;
&lt;li&gt;every internal message&lt;/li&gt;
&lt;li&gt;raw prompt text&lt;/li&gt;
&lt;li&gt;secrets or internal identifiers the user is not allowed to see&lt;/li&gt;
&lt;li&gt;model reasoning traces&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frontend is a boundary too.&lt;br&gt;
It should expose useful system behavior without leaking internal implementation details.&lt;/p&gt;
&lt;h2&gt;
  
  
  A practical event handling shape
&lt;/h2&gt;

&lt;p&gt;A frontend can treat the AG-UI stream as an event reducer.&lt;/p&gt;

&lt;p&gt;The backend emits events.&lt;br&gt;
The frontend reduces those events into UI state.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;AgentFrontendState&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt; &lt;span class="n"&gt;RunStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Idle&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;AssistantText&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Empty&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ToolCallViewModel&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ApprovalRequestViewModel&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;PendingApproval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;DeploymentReviewState&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;DeploymentState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ErrorMessage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then each event updates one part of state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;AgentFrontendState&lt;/span&gt; &lt;span class="nf"&gt;Reduce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;AgentFrontendState&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AIContent&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;TextContent&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;AssistantText&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AssistantText&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;FunctionCallContent&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;RunStatus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RunningTool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;AddToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;FunctionResultContent&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;CompleteToolCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;FunctionApprovalRequestContent&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;RunStatus&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AgentRunStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WaitingForApproval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;PendingApproval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ToApprovalViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;DataContent&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MediaType&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;DeploymentState&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ParseDeploymentState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;

        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This example uses Agent Framework content types because it fits the .NET client surface.&lt;br&gt;
A browser client may work with raw AG-UI protocol event names instead.&lt;br&gt;
The design is the same:&lt;/p&gt;

&lt;p&gt;Events update structured UI state.&lt;br&gt;
Text is only one part of that state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cancellation belongs in the frontend contract
&lt;/h2&gt;

&lt;p&gt;Agent frontends also need cancellation.&lt;/p&gt;

&lt;p&gt;Users close tabs.&lt;br&gt;
They navigate away.&lt;br&gt;
They reject an approval.&lt;br&gt;
They realize the prompt was wrong.&lt;/p&gt;

&lt;p&gt;The frontend should be able to cancel the current run and the backend should pass cancellation through the model call and tool calls.&lt;/p&gt;

&lt;p&gt;In .NET, keep the &lt;code&gt;CancellationToken&lt;/code&gt; flowing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AgentResponseUpdate&lt;/span&gt; &lt;span class="n"&gt;update&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;remoteAgent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RunStreamingAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                   &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For backend tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;DeploymentStatus&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;CheckDeploymentStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;IServiceProvider&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CancellationToken&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetRequiredService&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IDeploymentService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;deployments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CheckStatusAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;releaseId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the browser disconnects from the SSE stream, the server should not blindly keep doing expensive work unless the product explicitly requires background continuation.&lt;/p&gt;

&lt;p&gt;This is especially important with tools.&lt;br&gt;
A cancelled text generation is one thing.&lt;br&gt;
A cancelled deployment workflow is another.&lt;br&gt;
Make the cancellation behavior explicit per operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use AG-UI
&lt;/h2&gt;

&lt;p&gt;Use AG-UI when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the frontend needs streaming text&lt;/li&gt;
&lt;li&gt;users should see tool progress&lt;/li&gt;
&lt;li&gt;approval requests need real UI&lt;/li&gt;
&lt;li&gt;agent state should update visible components&lt;/li&gt;
&lt;li&gt;frontend tools need to run in the client&lt;/li&gt;
&lt;li&gt;backend tools should expose progress and results&lt;/li&gt;
&lt;li&gt;multiple clients should talk to the same agent endpoint shape&lt;/li&gt;
&lt;li&gt;you want a protocol boundary instead of custom ad-hoc streaming JSON&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is especially useful when the agent is part of an application, not just a chat demo.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;deployment assistant with approval and checklist state&lt;/li&gt;
&lt;li&gt;support copilot that drafts replies and creates tickets&lt;/li&gt;
&lt;li&gt;document review UI with extracted findings and reviewer actions&lt;/li&gt;
&lt;li&gt;planning assistant that updates a structured itinerary&lt;/li&gt;
&lt;li&gt;operations assistant that streams tool status while querying systems&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When I would not use AG-UI
&lt;/h2&gt;

&lt;p&gt;Do not add AG-UI just because an agent exists.&lt;/p&gt;

&lt;p&gt;It may be unnecessary when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the application only needs a simple text response&lt;/li&gt;
&lt;li&gt;there are no tools, approvals, or shared state&lt;/li&gt;
&lt;li&gt;a normal backend endpoint already gives enough structure&lt;/li&gt;
&lt;li&gt;the UI is internal and short-lived&lt;/li&gt;
&lt;li&gt;you do not want to take a dependency on a still-evolving protocol&lt;/li&gt;
&lt;li&gt;the frontend team cannot support event-driven state handling yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a background job, webhook processor, batch summarizer, or simple API endpoint, direct Agent Framework usage may be cleaner.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;RunAsync&lt;/code&gt; or &lt;code&gt;RunStreamingAsync&lt;/code&gt; directly when the frontend does not need the extra protocol semantics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Agent frontends need more than a plain chat box once agents start doing application work.&lt;/p&gt;

&lt;p&gt;Streaming text solves only one part of the experience.&lt;br&gt;
The frontend also needs structured events for tool progress, approval requests, state updates, errors, cancellation, and run lifecycle.&lt;/p&gt;

&lt;p&gt;AG-UI gives Agent Framework applications a protocol boundary for that.&lt;br&gt;
On the backend, &lt;code&gt;MapAGUI&lt;/code&gt; exposes an agent as an HTTP and SSE endpoint.&lt;br&gt;
On the client, AG-UI events can become structured UI state instead of being flattened into assistant text.&lt;/p&gt;

&lt;p&gt;The design I would carry forward is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;render text as text&lt;/li&gt;
&lt;li&gt;render tool calls as tool progress&lt;/li&gt;
&lt;li&gt;render approvals as approval forms&lt;/li&gt;
&lt;li&gt;render state as structured UI&lt;/li&gt;
&lt;li&gt;keep frontend tools separate from backend tools&lt;/li&gt;
&lt;li&gt;treat predictive state as provisional&lt;/li&gt;
&lt;li&gt;keep cancellation and errors explicit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That turns the frontend from a passive transcript into part of the agent system.&lt;/p&gt;

&lt;p&gt;In the next post, I will move from frontend behavior to production visibility.&lt;br&gt;
Once the UI can show streaming, tools, approvals, and state, the backend also needs traces that explain what happened across model calls, tool execution, AG-UI events, and user decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/" rel="noopener noreferrer"&gt;AG-UI Integration with Agent Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/getting-started" rel="noopener noreferrer"&gt;Getting Started with AG-UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/backend-tool-rendering" rel="noopener noreferrer"&gt;Backend Tool Rendering with AG-UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/frontend-tools" rel="noopener noreferrer"&gt;Frontend Tool Rendering with AG-UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/human-in-the-loop" rel="noopener noreferrer"&gt;Human-in-the-Loop with AG-UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/agent-framework/integrations/ag-ui/state-management" rel="noopener noreferrer"&gt;State Management with AG-UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ag-ui.com/introduction" rel="noopener noreferrer"&gt;AG-UI protocol documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ag-ui.com/concepts/events" rel="noopener noreferrer"&gt;AG-UI events&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
