FohenAIBuilder file.write tool – 100% dog food

The first FoehnAIBuilder tools had to be a scan and file read tools so the LLM could understand the structure and functionality of a project. Then the ability to write a text file was next so it could generate application files.

// Copyright (c) August 2026, devMobile Software
// 
using FoehnAIBuilder.Abstractions;

namespace FoehnAI.Tools.WriteFile;

/// <summary>
/// Writes text content to a file, creating the file (and any missing parent
/// directories) if it doesn't already exist.
/// </summary>
public sealed class WriteFileTool : ITool
{
    private readonly ILogger<WriteFileTool> _logger;

    public WriteFileTool(ILogger<WriteFileTool> logger)
    {
        _logger = logger;
    }

    public string Name => "write_file";

    public string Description =>
        "Writes text content to a file at the given path, creating the file (and any missing " +
        "parent directories) if it doesn't already exist.";

    public string Command => """
        {
          "type": "object",
          "properties": {
            "path": { "type": "string", "description": "Path to the file to write." },
            "content": { "type": "string", "description": "Text content to write to the file." },
            "overwrite": { "type": "boolean", "description": "Whether to overwrite the file if it already exists. Defaults to true." }
          },
          "required": ["path", "content"]
        }
        """;

    public ToolRiskLevel RiskLevel => ToolRiskLevel.Write;

    public async Task<ToolExecutionResult> ExecuteAsync(string argumentsJson, CancellationToken cancellationToken = default)
    {
        if (!ToolArguments.TryParse(argumentsJson, WriteFileJsonContext.Default.WriteFileArguments, out var args, out var jsonError))
        {
            _logger.LogWarning("Failed to parse write_file arguments: {Arguments} ({Error})", argumentsJson, jsonError);
            return ToolExecutionResult.Fail(jsonError!);
        }

        var path = args.Path;
        var content = args.Content;
        var overwrite = args.Overwrite ?? true;

        if (!ToolPath.TryResolve(path, out var fullPath, out var pathError))
            return ToolExecutionResult.Fail(pathError!);

        if (!overwrite && File.Exists(fullPath))
            return ToolExecutionResult.Fail($"File already exists and overwrite is false: {path}");

        _logger.LogInformation("Writing {Length} characters to {Path}", content.Length, path);

        try
        {
            var directory = Path.GetDirectoryName(fullPath);
            if (!string.IsNullOrEmpty(directory))
                Directory.CreateDirectory(directory);

            await File.WriteAllTextAsync(fullPath, content, cancellationToken);
            return ToolExecutionResult.Ok($"Wrote {content.Length} characters to \"{path}\".");
        }
        catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
        {
            _logger.LogError(ex, "Error writing file {Path}", path);
            return ToolExecutionResult.Fail($"Error writing \"{path}\": {ex.Message}");
        }
    }
}

I hadn’t noticed the LLM checking to see whether the class1.cs file existed before. I’m also considering creating an another ToolRiskLevel “Trusted” for tools like dotnet.

Again, the markdown response for the review was huge

Review: WriteFileTool.cs

Clean, small, focused implementation. Same shape as the earlier version — most of my prior comments still apply. Highlights below, with a couple of new items.

Notable changes since last review

  • Name is now "write_file" (was "file.write"). Fine — but make sure it matches the naming convention used by sibling tools (files.search, read_file, etc.). Pick one: dot-namespaced (files.write) or snake_case (write_file) and apply everywhere.

Correctness / robustness

1. TOCTOU race on the overwrite check

if (!overwrite && File.Exists(fullPath)) ...
await File.WriteAllTextAsync(fullPath, content, cancellationToken);

Another process could create the file between check and write. Use FileMode.CreateNew for the strict guarantee:

var mode = overwrite ? FileMode.Create : FileMode.CreateNew;
await using var fs = new FileStream(fullPath, mode, FileAccess.Write, FileShare.None,
    bufferSize: 4096, FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var sw = new StreamWriter(fs); // UTF-8 no BOM
await sw.WriteAsync(content.AsMemory(), cancellationToken);

Then catch IOException when the file already exists to return the friendly message.

2. Exception filter still too narrow

Catches only UnauthorizedAccessException | IOException. Also possible:

  • NotSupportedException (invalid path characters / colon)
  • SecurityException
  • ArgumentException from Path.GetDirectoryName on malformed input

Add them, or ensure ToolPath.TryResolve guarantees they can’t occur.

3. File.WriteAllTextAsync is truly async only with the right stream options

The overload you use internally opens a FileStream without FileOptions.Asynchronous, so the write happens on a thread-pool thread doing sync I/O. If you build the FileStream yourself (as in #1), pass FileOptions.Asynchronous for real async I/O.

Schema

4. Command doesn’t declare defaults or forbid extras

Same finding as SearchFilesTool:

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "path":      { "type": "string",  "minLength": 1, "description": "Path to the file to write." },
    "content":   { "type": "string",  "description": "Text content to write to the file." },
    "overwrite": { "type": "boolean", "default": true, "description": "Whether to overwrite the file if it already exists." }
  },
  "required": ["path", "content"]
}

default: true matches your args.Overwrite ?? true fallback and stops the LLM from guessing.

Consistency with SearchFilesTool

5. ToolPath.TryResolve signature differs

Here you call:

ToolPath.TryResolve(path, out var fullPath, out var pathError)

SearchFilesTool calls the 4-arg overload with an explicit sandboxRoot:

ToolPath.TryResolve(sandboxRoot, path, out var fullPath, out var pathError)

If both resolve against Directory.GetCurrentDirectory(), no functional difference — but the inconsistency is a code-review smell. Prefer the explicit-sandbox overload everywhere so escape-from-sandbox behavior is provable per tool.

Observability / safety

6. Logging argumentsJson on parse failure could leak content

logger.LogWarning("Failed to parse write_file arguments: {Arguments} ({Error})", argumentsJson, jsonError);

content may contain secrets/PII. Log only length + jsonError, or redact.

7. content.Length = UTF-16 chars, not bytes

The success message says "Wrote {N} characters". Accurate but not a byte count. If callers expect bytes, use Encoding.UTF8.GetByteCount(content).

8. Cancellation before sync work

Directory.CreateDirectory is synchronous and not cancellable. Add:

cancellationToken.ThrowIfCancellationRequested();

just before it — cheap fast-fail.

Style nits

  • Mixed indentation: primary constructor line uses 3-space indent ( public string Name), the rest 4 spaces. Run a format pass.
  • Consider making Description and the schema static readonly fields instead of expression-bodied properties allocating the same strings per call (micro-optimization, only matters if Command is read on every LLM request).

Suggested consolidated write block

try
{
    cancellationToken.ThrowIfCancellationRequested();

    var directory = Path.GetDirectoryName(fullPath);
    if (!string.IsNullOrEmpty(directory))
        Directory.CreateDirectory(directory);

    var mode = overwrite ? FileMode.Create : FileMode.CreateNew;
    await using (var fs = new FileStream(
        fullPath, mode, FileAccess.Write, FileShare.None,
        bufferSize: 4096, FileOptions.Asynchronous))
    await using (var sw = new StreamWriter(fs)) // UTF-8 no BOM
    {
        await sw.WriteAsync(content.AsMemory(), cancellationToken);
    }

    return ToolExecutionResult.Ok($"Wrote {content.Length} characters to \"{path}\".");
}
catch (IOException) when (!overwrite && File.Exists(fullPath))
{
    return ToolExecutionResult.Fail($"File already exists and overwrite is false: {path}");
}
catch (Exception ex) when (ex is UnauthorizedAccessException
                              or IOException
                              or NotSupportedException
                              or System.Security.SecurityException)
{
    logger.LogError(ex, "Error writing file {Path}", path);
    return ToolExecutionResult.Fail($"Error writing \"{path}\": {ex.Message}");
}

Priority order

  1. Schema: additionalProperties:false + default:true — tiny change, prevents LLM confusion.
  2. FileMode.CreateNew for overwrite=false — closes real race condition.
  3. Broaden exception filterNotSupportedException, SecurityException.
  4. Real async I/O via explicit FileStream — only matters for large writes.
  5. Consistent ToolPath.TryResolve signature across tools.

Want me to produce the fully rewritten file?

The review was ok for throw away PoC so the only change I made was modifying the class to use a primary constructor. In a future version I’m considering loading the commandText from a file so it can be “tweaked” without requiring recompilation.

FohenAIBuilder file.read tool – with some dog fooding

The first FoehnAIBuilder tool had to be a very scan tool so the LLM could understand the structure of a project. Then the ability to load a text file so it could figure out what the underlying code did. At this point the LLM couldn’t generate some code to read a file, so I wrote as basic implementation.

// Copyright (c) August 2026, devMobile Software
// 
using FoehnAIBuilder.Abstractions;
using FoehnAI.Tools.ReadFile;

namespace FoehnAIBuilder.Tools.ReadFile;

/// <summary>
/// Reads and returns the full text contents of a file.
/// </summary>
public sealed class ReadFileTool : ITool
{
    private const int MaxCharacters = 200_000;

    private readonly ILogger<ReadFileTool> _logger;

    public ReadFileTool(ILogger<ReadFileTool> logger)
    {
        _logger = logger;
    }

    public string Name => "file.read";

    public string Description => "Reads and returns the full text contents of a file at the given path.";

    public string Command => """
        {
          "type": "object",
          "properties": {
            "path": { "type": "string", "description": "Path to the file to read (relative or absolute)." }
          },
          "required": ["path"]
        }
        """;

    public ToolRiskLevel RiskLevel => ToolRiskLevel.ReadOnly;

    public async Task<ToolExecutionResult> ExecuteAsync(string argumentsJson, CancellationToken cancellationToken = default)
    {
        if (!ToolArguments.TryParse(argumentsJson, ReadFileJsonContext.Default.ReadFileArguments, out var args, out var jsonError))
        {
            _logger.LogWarning("Failed to parse read_file arguments: {Arguments} ({Error})", argumentsJson, jsonError);
            return ToolExecutionResult.Fail(jsonError!);
        }

        var path = args.Path;
        if (!ToolPath.TryResolve(path, out var fullPath, out var pathError))
            return ToolExecutionResult.Fail(pathError!);

        _logger.LogInformation("Reading file {Path}", path);

        if (!File.Exists(fullPath))
            return ToolExecutionResult.Fail($"File not found: {path}");

        try
        {
            var content = await File.ReadAllTextAsync(fullPath, cancellationToken);
            if (content.Length > MaxCharacters)
            {
                var truncated = content[..MaxCharacters];
                return ToolExecutionResult.Ok(
                    $"{truncated}\n\n[Output truncated at {MaxCharacters} characters; file is {content.Length} characters long.]");
            }

            return ToolExecutionResult.Ok(content);
        }
        catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
        {
            _logger.LogError(ex, "Error reading file {Path}", path);
            return ToolExecutionResult.Fail($"Error reading \"{path}\": {ex.Message}");
        }
    }
}

The LLM took a couple of attempts to get the right file path. If could be the way I phrased the question or files.scan tool needs some work.

The markdown response for the review was huge

Overview

The ReadFileTool class is a tool designed to read and return the contents of a file. It implements the ITool interface and is part of the FoehnAIBuilder.Tools.ReadFile namespace. The tool is marked as ReadOnly in terms of risk level, indicating it does not modify any files or system state.


Key Components

  1. Constants and Fields:

    • MaxCharacters: A constant set to 200,000, defining the maximum number of characters to read from a file. If the file exceeds this limit, the output is truncated.
    • _logger: A logger instance for logging information, warnings, and errors.
  2. Properties:

    • Name: Returns "file.read", the name of the tool.
    • Description: Provides a brief description of the tool’s functionality.
    • Command: Defines the JSON schema for the tool’s input arguments. It expects a single property, path, which is a required string.
    • RiskLevel: Specifies the tool’s risk level as ReadOnly.
  3. Constructor:

    • The constructor initializes the logger.
  4. ExecuteAsync Method:

    • This method is responsible for executing the tool’s functionality asynchronously.
    • It parses the input arguments, resolves the file path, checks if the file exists, reads the file content, and handles potential errors.

Detailed Review

Argument Parsing

  • The method uses ToolArguments.TryParse to parse the input JSON into a structured object (ReadFileArguments). If parsing fails, it logs a warning and returns a failure result with the parsing error.

Path Resolution

  • The tool resolves the provided path using ToolPath.TryResolve. If the path resolution fails, it returns a failure result with the path error.

File Existence Check

  • The tool checks if the file exists at the resolved path. If the file does not exist, it returns a failure result indicating the file was not found.

File Reading

  • The tool reads the file content asynchronously using File.ReadAllTextAsync.
  • If the file content exceeds MaxCharacters, the content is truncated, and a message is appended to indicate truncation.

Error Handling

  • The tool catches specific exceptions (UnauthorizedAccessException and IOException) and logs them as errors. It returns a failure result with a descriptive error message.

Strengths

  1. Asynchronous Operation: The tool uses asynchronous file reading, which is efficient and non-blocking.
  2. Error Handling: It handles specific exceptions and provides meaningful error messages.
  3. Truncation Handling: The tool gracefully handles large files by truncating the output and informing the user.
  4. Logging: Comprehensive logging is implemented for debugging and monitoring purposes.

Potential Improvements

  1. Configurable MaxCharacters: The MaxCharacters constant could be made configurable, allowing users to adjust the truncation limit based on their needs.
  2. Additional File Information: The tool could optionally return additional file information, such as file size, last modified date, or encoding.
  3. Support for Binary Files: Currently, the tool only supports text files. Adding support for binary files could extend its functionality.
  4. Path Validation: Additional validation could be added to ensure the resolved path is within an allowed directory, enhancing security.

Conclusion

The ReadFileTool is a well-designed and robust tool for reading file contents. It handles errors gracefully, provides meaningful feedback, and operates efficiently. With minor enhancements, it could become even more versatile and user-friendly.

The review was pretty good so the only change I made was modifying the class to use a primary constructor. In a future version I’m considering loading the commandText from a file so it can be “tweaked” without requiring recompilation.

FoehnAIBuilder Plug-in Tools Implementation

FoehnAIBuilder uses a plugin-based architecture where every tool implements a standard .NET class that conforms to the ITool C# interface. As part of the startup process, the application scans the plugin directory, loads all the available assemblies, and includes all that implement this contract.

namespace FoehnAIBuilder.Abstractions;

public interface ITool
{
    string Name { get; }
    string Description { get; }
    string Command { get; }

    ToolRiskLevel RiskLevel { get; }
    Task<ToolExecutionResult> ExecuteAsync(string argumentsJson, CancellationToken cancellationToken = default);
}

Tools also have a risk level (considering increasing the number of options and training an ML.NET model to detect potentially malicious arguments), so the host application can apply safety controls such as requiring user confirmation before operations that may have significant side effects.

public enum ToolRiskLevel
{
    Undefined = 0,
    ReadOnly,
    Write,
    Destructive,
}

FoehnAIBuilder enforces a maximum tool iteration count. This prevents runaway execution loops, stops the context growing to the point where it impacts on the LLM’s performance (The dumb zone), and “burning” lots of Tokens

   "Mistral": {
      "BaseUrl": "https://api.mistral.ai",
      "APIKey": "This is not the APIKey you are looking for",
      "DefaultModel": "devstral-latest",
      "TimeoutSeconds": 120,
      "MaxRetries": 3,
      "EnableStreaming": false
   },
   "FoehnAIBuilder": {
      "SystemMessageFile": "foehn.md",
      "PluginsPath": ".plugins",
      "WorkingDirectory": "",
      "MaxToolIterations": 30
   },
}

Each tool exposes metadata that allows the LLM to understand how to invoke it. This includes a unique function name, a human-readable description, and a JSON Schema describing the parameters the tool expects. I’m considering implementing the parameters for a plug-in using Data Transfer Objects (DTO) rather than the current approach using strings.

public string Name => "scan";

public string Description =>
    "Recursively lists files and directories under a given path, or the current working " +
    "folder if no path is supplied. Use this first to discover what exists before reading, " +
    "writing, deleting, or executing anything.";

public string Command => """
{
   "type": "object",
   "properties": {
        "path": { "type": "string", "description": "Directory to scan. Defaults to the application's current working folder if omitted." },
        "pattern": { "type": "string", "description": "Search pattern, e.g. '*.cs'. Defaults to '*' (all files)." },
         "recursive": { "type": "boolean", "description": "Whether to recurse into subdirectories. Defaults to true." }
    },
    "required": []
}

The plugins have code to detect an LLM directory escape with a path in a parameter like “directory to scan”. When the LLM chooses to invoke a tool, FoehnAIBuilder calls the tool’s ExecuteAsync method and passes the arguments as a JSON document that conforms to the schema exposed by the tool.

FoehnAIBuilder processes the request and returns a ToolExecutionResult, which provides a standardised way for both the application and the LLM to understand the outcome. The result contains a boolean success indicator and a descriptive message that may include returned data, status information, or error details.

public sealed class ToolExecutionResult
{
    public required bool Success { get; init; }

    public required string Result { get; init; }

    public static ToolExecutionResult Ok(string result) => new() { Success = true, Result = result };

    public static ToolExecutionResult Fail(string result) => new() { Success = false, Result = result };
}

ToolExecutionResult approach follows the result pattern rather than an exception-driven programming model. Every tool invocation returns a result containing both a success indicator and a human-readable message describing the outcome. This provides a consistent contract between the tool, the host, and the language model. This allows the LLM to reason about both successful operations and expected failure conditions such as validation errors, missing resources, or access restrictions.

The plug-in implementations handle and translate all anticipated error conditions into a ToolExecutionResult.Fail response rather than allowing exceptions to propagate to the FoehnAIBuilder host (this would be bad). Returning structured failure information enables the language model to understand what went wrong and potentially adjust its behaviour and retry with different inputs.

try
{
   var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;

   ...

   return Task.FromResult(ToolExecutionResult.Ok(sb.ToString()));
}
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
{
   _logger.LogError(ex, "Error scanning {Path}", path);
   return Task.FromResult(ToolExecutionResult.Fail($"Error scanning \"{path}\": {ex.Message}"));
}

Exceptions are reserved for genuinely unexpected conditions such as programming errors, infrastructure failures, or unrecoverable runtime errors. As a general rule, no exception in a tool plug-in should be returned to FoehnAIBuilder for business logic or user-correctable error, these should always be represented as a failed ToolExecutionResult containing a clear and actionable explanation of the problem.

The next couple of posts will explore progressively more capable (read dangerous) operations. First, file and directory tools, where path traversal, deletion, and privilege boundaries (file and directory permissions) introduce real risk.

Mistral Chat Completion with tools first fail

When I tested the Mistral Vibe Code generated client for the Mistral Chat Completion API, everything looked good until I added a couple of tool calls. The very first request exploded with a runtime exception:

The Mistral Chat Completion API rejected the malformed assistant message: “Assistant message must have either content or tool_calls, but not none” This was the first clue that the polymorphic message model wasn’t being serialised correctly.

Inspecting the raw JSON request inside Visual Studio’s JSON Visualizer. The error object clearly showed the invalid assistant message structure generated by the client.

After digging through the JSON returned by Mistral and inspecting the request object in Visual Studio, I figured out that System.Text.Json wasn’t serializing the polymorphic MessageBase hierarchy correctly.

namespace MistralAI.Client.DTOs.Shared
{
   /// <summary>
   /// Represents a message in a chat conversation.
   /// </summary>
   [JsonPolymorphic(TypeDiscriminatorPropertyName = "role")]
   [JsonDerivedType(typeof(SystemMessage), "system")]
   [JsonDerivedType(typeof(UserMessage), "user")]
   [JsonDerivedType(typeof(AssistantMessage), "assistant")]
   [JsonDerivedType(typeof(ToolMessage), "tool")]
   public abstract class MessageBase
    {
        /// <summary>
        /// The role of the message author.
        /// </summary>
        [JsonPropertyName("role")]
        public string Role { get; set; } = string.Empty;

        /// <summary>
        /// The content of the message.
        /// </summary>
        [JsonPropertyName("content")]
        public string? Content { get; set; }
    }

    /// <summary>
    /// A message from the system.
    /// </summary>
    public class SystemMessage : MessageBase
    {
        public SystemMessage() => Role = "system";
    }

    /// <summary>
    /// A message from the user.
    /// </summary>
    public class UserMessage : MessageBase
    {
        public UserMessage() => Role = "user";
    }

    /// <summary>
    /// A message from the assistant.
    /// </summary>
    public class AssistantMessage : MessageBase
    {
        public AssistantMessage() => Role = "assistant";

        /// <summary>
        /// Tool calls made by the assistant.
        /// </summary>
        [JsonPropertyName("tool_calls")]
        public List<ToolCall>? ToolCalls { get; set; }
    }

The fix was to annotate the base class with JsonPolymorphic and JsonDerivedType attributes so the serialiser could select the correct concrete type based on the role field.

Ironically both Claude Code and Github Copilot had the serialisation attributes correct.

Mistral Steaming Chat Completions

This sample demonstrates how to consume Mistral’s streaming chat completion endpoint using HttpClient and Server-Sent Events (SSE). The implementation streams tokens to the console as they arrive, supports cancellation with Ctrl+C, and captures token usage information. A CancellationTokenSource is used to support user-initiated cancellation. Pressing Ctrl+C cancels the active request without terminating the application, allowing the user to continue interacting with the chat client.

Rather than waiting for the complete response, the request is sent with Stream = true and the response is processed as an SSE stream. This provides a significantly better user experience because generated tokens are displayed as soon as they are received. Each chunk contains a delta payload that may include newly generated content.

// Ctrl+C cancels the current request and returns to the prompt; second Ctrl+C exits the process.
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
   if (!cts.IsCancellationRequested)
   {
      e.Cancel = true;
      cts.Cancel();
   }
};

while (!cts.IsCancellationRequested)
{
   Console.Write("Enter chat message: ");
   var content = Console.ReadLine();
   if (string.IsNullOrWhiteSpace(content)) break;

   var request = new ChatStreamingCompletionRequest
   {
      Model = settings.ModelName,
      Messages =
      [
         new ChatMessage { Role = "user", Content = content }
      ],
      Stream = true,
   };

   try
   {
      using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
      {
         Content = JsonContent.Create(request, options: jsonSerializerOptions)
      };

      using var httpResponse = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead, cts.Token);
      if (!httpResponse.IsSuccessStatusCode)
      {
         var body = await httpResponse.Content.ReadAsStringAsync(cts.Token);
         throw new HttpRequestException($"Mistral API {(int)httpResponse.StatusCode}: {body}", null, httpResponse.StatusCode);
      }

      await using var stream = await httpResponse.Content.ReadAsStreamAsync(cts.Token);
      using var reader = new StreamReader(stream);

      TokenUsage? finalUsage = null;
      string? finishReason = null;

      string? line;
      // Mistral emits single-line `data:` chunks, so per-line dispatch is sufficient (no multi-line SSE concatenation required).
      while ((line = await reader.ReadLineAsync(cts.Token)) is not null)
      {
         // SSE framing: blank line separates events, lines without "data:" are ignored (e.g. ":" keep-alive comments, "event:" / "id:" / "retry:" fields).
         if (string.IsNullOrEmpty(line)) continue;
         if (!line.StartsWith("data:", StringComparison.Ordinal)) continue;

         var payload = line.AsSpan(5).TrimStart();
         if (payload.SequenceEqual("[DONE]")) break;

         var chunk = JsonSerializer.Deserialize<ChatCompletionChunk>(payload, jsonSerializerOptions);
         if (chunk is null) continue;

         foreach (var choice in chunk.Choices)
         {
            if (!string.IsNullOrEmpty(choice.Delta.Content))
            {
               Console.Write(choice.Delta.Content);
            }
            if (choice.FinishReason is not null)
            {
               finishReason = choice.FinishReason;
            }
         }

         if (chunk.Usage is not null)
         {
            finalUsage = chunk.Usage;
         }
      }

      Console.WriteLine();
      Console.WriteLine();
      if (finalUsage is not null)
      {
         Console.WriteLine($"Prompt tokens: {finalUsage.PromptTokens}");
         Console.WriteLine($"Completion tokens: {finalUsage.CompletionTokens}");
         Console.WriteLine($"Total tokens: {finalUsage.TotalTokens}");
      }
      if (finishReason is not null)
      {
         Console.WriteLine($"Finish reason: {finishReason}");
      }
      Console.WriteLine();
   }
   catch (OperationCanceledException) when (cts.IsCancellationRequested)
   {
      Console.WriteLine();
      Console.WriteLine("Request cancelled.");
      // Reset the CTS so the next prompt iteration is cancellable again.
      cts.TryReset();
   }
   catch (HttpRequestException ex)
   {
      Console.WriteLine($"Request failed: {(int?)ex.StatusCode ?? 0} {ex.Message}");
   }
   catch (TaskCanceledException)
   {
      Console.WriteLine("Request timed out.");
   }
   catch (JsonException ex)
   {
      Console.WriteLine($"Failed to parse response: {ex.Message}");
   }
   catch (IOException ex)
   {
      Console.WriteLine($"Stream error: {ex.Message}");
   }
   catch (Exception ex)
   {
      Console.WriteLine($"Unexpected error: {ex.GetType().Name}: {ex.Message}");
   }
}

Uses HttpCompletionOption.ResponseHeadersRead to begin processing data as soon as headers are received.

public sealed class ChatStreamingCompletionRequest
{
   [JsonPropertyName("model")] public required string Model { get; init; }
   [JsonPropertyName("messages")] public required List<ChatMessage> Messages { get; init; }
   [JsonPropertyName("temperature")] public double? Temperature { get; init; }
   [JsonPropertyName("max_tokens")] public int? MaxTokens { get; init; }
   [JsonPropertyName("stream")] public bool? Stream { get; init; } 
   [JsonPropertyName("response_format")] public ResponseFormat? ResponseFormat { get; init; }
}

public sealed class ChatMessage
{
   [JsonPropertyName("role")] public required string Role { get; init; }
   [JsonPropertyName("content")] public required string Content { get; init; }
}

public sealed class ResponseFormat
{
   [JsonPropertyName("type")] public required string Type { get; init; }
}

public sealed class ChatCompletionChunk
{
   [JsonPropertyName("id")] public required string Id { get; init; }
   [JsonPropertyName("choices")] public required List<ChatCompletionDelta> Choices { get; init; }
   [JsonPropertyName("usage")] public TokenUsage? Usage { get; init; }
}

public sealed class ChatCompletionDelta
{
   [JsonPropertyName("index")] public int Index { get; init; }
   [JsonPropertyName("delta")] public required ChatMessageDelta Delta { get; init; }
   [JsonPropertyName("finish_reason")] public string? FinishReason { get; init; }
}

public sealed class ChatMessageDelta
{
   [JsonPropertyName("role")] public string? Role { get; init; }
   [JsonPropertyName("content")] public string? Content { get; init; }
}

public sealed class TokenUsage
{
   [JsonPropertyName("prompt_tokens")] public int? PromptTokens { get; init; }
   [JsonPropertyName("completion_tokens")] public int? CompletionTokens { get; init; }
   [JsonPropertyName("total_tokens")] public int? TotalTokens { get; init; }
}

When available, token usage statistics are captured from the final response chunk and displayed after generation completes.

Figured I might as well get Anthropic’s Claude to review my code.

Code Review: MistralBasicStreamingCLI

Scope: files in MistralBasicStreamingCLI/ only (Program.cs, Model.cs, ApplicationSettings.cs, appsettings.json, .csproj).

Summary

Small, single-file-ish PoC CLI that streams Mistral chat completions over SSE. Overall solid for its stated purpose (the file header calls it “horrible” — it’s actually reasonably clean). Config/secrets handling, cancellation plumbing, and exception granularity are all good practice. Findings below are ranked roughly by severity.

Findings

1. Ctrl+C behavior contradicts its own comment (Medium)

Program.cs:43 says:

> Ctrl+C cancels the current request and returns to the prompt; second Ctrl+C exits the process.

That holds only if Ctrl+C is pressed while a request is in flight — in that case the catch (OperationCanceledException) block runs and calls cts.TryReset() (line 141), so the loop continues.

But if Ctrl+C is pressed while sitting at the Console.ReadLine() prompt (before any request is sent), cts.Cancel() fires, Console.ReadLine() returns (typically null), and content fails the IsNullOrWhiteSpace check on line 58, so the loop hits break directly — no TryReset() call, no “second Ctrl+C” needed. The program exits on the first Ctrl+C in this case.

Net effect: whether Ctrl+C exits immediately or returns you to the prompt depends on exact timing (mid-request vs. at-prompt), which will look like inconsistent/buggy behavior to a user. If the two-stage cancel is intended everywhere, the prompt-read would need to be cancellable too (e.g. via a Task.Run wrapping Console.ReadLine() racing the token, or Console.KeyAvailable polling).

2. Streaming Usage block may never populate (Worth verifying)

Program.cs:116-119 and 126-128 only print token counts if chunk.Usage is not null. For OpenAI-compatible streaming APIs, the final chunk generally only carries usage when the request explicitly opts in (OpenAI requires "stream_options": {"include_usage": true}). ChatStreamingCompletionRequest (Model.cs:5-13) has no StreamOptions/equivalent property, and the request never sends one.

If Mistral’s API follows the same opt-in convention, finalUsage will always stay null and the “Prompt tokens / Completion tokens / Total tokens” lines will silently never print. If Mistral always includes usage on the last chunk regardless (some of their docs suggest this), then it’s fine as-is. Worth a quick manual test against the real API to confirm which is true — if it’s the former, add a stream_options field to the request model.

3. Redundant double-dispose of the response stream (Low)

await using var stream = await httpResponse.Content.ReadAsStreamAsync(cts.Token);
using var reader = new StreamReader(stream);

reader’s (synchronous) Dispose() runs first at scope exit and disposes the underlying stream (default leaveOpen: false). The outer await using then calls DisposeAsync() on the same, already-disposed stream. This is harmless (Stream.Dispose/DisposeAsync are idempotent) but is dead code — either drop await using in favor of a plain using, or construct StreamReader with leaveOpen: true and let the outer await using be the one real disposal.

4. No conversation history — every turn is stateless (Design note)

Each loop iteration sends a single ChatMessage { Role = "user", Content = content } (Program.cs:60-68) with no prior turns or system prompt included. So the “chat loop” is really a sequence of independent one-shot completions, not a multi-turn conversation. Likely intentional for a minimal PoC, but worth calling out in case a real chat history was expected.

5. Unused request fields (Low / cosmetic)

Temperature, MaxTokens, and ResponseFormat on ChatStreamingCompletionRequest (Model.cs:9,10,12) are never set anywhere — every request sends null for all three (and they’re omitted from the JSON payload thanks to WhenWritingNull). Not a bug, just currently-dead surface area on the model; fine to leave if they’re there for future use, otherwise consider trimming until needed.

6. appsettings.json ships an empty ApiKey (Informational, not a defect)

appsettings.json:3 has "ApiKey": "", with the real key presumably supplied via dotnet user-secrets (UserSecretsId is wired up in the .csproj). This is the correct pattern — flagging only so it stays that way and nobody “temporarily” pastes a real key into this file for a quick test and commits it.

Positives worth keeping

  • Secrets handling: AddUserSecrets() + empty placeholder in appsettings.json is the right pattern for a local PoC.
  • Startup validation: ArgumentException.ThrowIfNullOrWhiteSpace on required settings fails fast with a clear message instead of a confusing null-ref later.
  • HttpClient.Timeout = Timeout.InfiniteTimeSpan paired with a CancellationToken is the correct way to handle long-lived SSE connections — avoids the common bug of a wall-clock timeout killing a slow-but-healthy stream.
  • BaseAddress/relative-URI combination (https://api.mistral.ai/v1/ + chat/completions) is done correctly (trailing slash on base, no leading slash on relative) — a very common source of bugs elsewhere.
  • SSE line parsing correctly ignores blank lines and non-data: fields (comments, event:, id:, retry:), and checks for [DONE] before attempting to parse JSON.
  • Exception granularity: catching OperationCanceledException, HttpRequestException, TaskCanceledException, JsonException, IOException separately before a generic fallback gives useful, distinct messages per failure mode rather than one catch-all.

Suggested priority

  1. Decide/fix the Ctrl+C-at-prompt vs. Ctrl+C-mid-request inconsistency (#1) if consistent behavior matters for this PoC’s usability.
  2. Confirm whether Usage actually arrives from Mistral’s streaming endpoint without stream_options (#2) — quick manual test settles it.
  3. Everything else is cosmetic/cleanup, safe to defer.

Using strongly typed request and response Data Transfer Objects(DTOs) improved maintainability but without manual “tweaking” a request/response could fail.(esp. required & nullable). This implementation assumes that the Mistral Chat Completion API returns single-line JavaScript Object Notation(JSON) payloads in each SSE data: event, which simplified the parser implementation.

Mistral Chat Completions

Building a lightweight Codestral Chat Completion Command-Line Interface(CLI) is one of the fastest ways to understand how modern Large Language Model(LLM) Application Programming Interfaces (API) work in the real world. While everyone is talking about “agentic” programming (I now spend a lot of time reviewing code, as more agents = more reviews) and “token maxing” (difficult conversations with finance) this series of posts is about the plumbing.

I’m starting from the bottom and working my way up the stack. A raw Hypertext Transfer Protocol(HTTP) contract: an HTTP POST, a model name, a messages array, and a response object you have to parse yourself. With an HTTP proxy (Telerik Fiddler) I confirmed the Mistral Chat API endpoint Uniform Resource Locator(URL) and my API Key worked.

Before writing or generating typed Data Transfer Objects(DTO), or any kind of strongly‑typed client, streaming the response JSON into a jsonDocument was a good way to visualise the shape of the responses. I could enumerate properties, check for missing or inconsistent fields, validate casing, and confirm whether optional objects appear only in certain scenarios. Any undocumented polymorphic shapes, and response constructs can be impossible to model cleanly, and “hand-rolled” serialisation can be fragile.

//...
Console.Write("Enter chat message: ");
var prompt = Console.ReadLine();

// Anonymous type for the request body which feels bit "hinky" but, it works and is concise. Alternatively, could define a class for request body for better type safety and maintainability.
var requestObject = new
{
   model = settings.ModelName,
   messages = new[]
   {
      new { role = "user", content = prompt }
   }
};

// Alternatively, a JsonObject and JsonArray for more control over the JSON structure
var requestJson = new JsonObject()
{
   ["model"] = settings.ModelName,
   ["messages"] = new JsonArray
   {
      new JsonObject
      {
         ["role"] = "user",
         ["content"] = prompt
      }
   }
};

// Create HttpClient with required headers. Note that HttpClient should ideally be reused, but for simplicity we're creating a new instance here.
HttpClient httpClient = new()
{
   DefaultRequestHeaders =
   {
      Accept = { new MediaTypeWithQualityHeaderValue("application/json") },
      Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey)
   },
   BaseAddress = new Uri(settings.BaseUrl)
};

using var httpResponse = await httpClient.PostAsync("chat/completions", new StringContent(JsonSerializer.Serialize(requestObject), Encoding.UTF8, "application/json"));

httpResponse.EnsureSuccessStatusCode();

using var stream = await httpResponse.Content.ReadAsStreamAsync();
using var responseDocument = await JsonDocument.ParseAsync(stream);

var content = responseDocument.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString();

Console.WriteLine(content);
Console.WriteLine();

var usage = responseDocument.RootElement.GetProperty("usage");
Console.WriteLine($"Prompt tokens: {usage.GetProperty("prompt_tokens").GetInt32()}");
Console.WriteLine($"Completion tokens: {usage.GetProperty("completion_tokens").GetInt32()}");
Console.WriteLine($"Total tokens: {usage.GetProperty("total_tokens").GetInt32()}");
Console.WriteLine();

Console.WriteLine("Press <Enter> to exit...");
Console.ReadLine();

Reading the response string and parsing it with a jsonDocument is straightforward but can be inefficient for large responses because it loads the entire response into memory.

I built the typed interface by inspecting real JSON responses especially structures like polymorphic message, and cross‑checking them against the Mistral API docs. This highlighted which fields are genuinely optional, which only appear for tool calls, and which vary by finish reason. It also highlighted that I would have to introduce polymorphic message classes so the interface can cleanly represent text messages, tool‑call messages, and whatever new variants the API adds later.

// Create HttpClient with required headers. Note that HttpClient should ideally be reused, but for simplicity we're creating a new instance here..
using HttpClient httpClient = new()
{
   DefaultRequestHeaders =
   {
      Accept = { new MediaTypeWithQualityHeaderValue("application/json") },
      Authorization = new AuthenticationHeaderValue("Bearer", settings.ApiKey)
   },
   BaseAddress = new Uri(settings.BaseUrl)
};

var jsonSerializerOptions = new JsonSerializerOptions()
{
   // PropertyNamingPolicy removed - [JsonPropertyName] attributes on model handle wire names
   WriteIndented           = false,
   DefaultIgnoreCondition  = JsonIgnoreCondition.WhenWritingNull,
   AllowTrailingCommas     = false,
   ReadCommentHandling     = JsonCommentHandling.Disallow,
   UnmappedMemberHandling  = JsonUnmappedMemberHandling.Skip,
};

Console.Write("Enter chat message: ");
var content = Console.ReadLine();

while (!string.IsNullOrWhiteSpace(content))
{
   var request = new ChatCompletionRequest
   {
      Model = settings.ModelName,
      Messages =
      [
         new ChatMessage { Role = "user", Content = content }
      ],
   };

   try
   {
      using var httpResponse = await httpClient.PostAsJsonAsync("chat/completions", request, jsonSerializerOptions);
      httpResponse.EnsureSuccessStatusCode();

      ChatCompletionResponse? chatCompletionResponse = await httpResponse.Content.ReadFromJsonAsync<ChatCompletionResponse>(jsonSerializerOptions);

      if (chatCompletionResponse != null)
      {
         foreach (var choice in chatCompletionResponse.Choices)
         {
            Console.WriteLine(choice.Message.Content);
         }

         Console.WriteLine();
         if (chatCompletionResponse.Usage != null)
         {
            Console.WriteLine($"Prompt tokens: {chatCompletionResponse.Usage.PromptTokens}");
            Console.WriteLine($"Completion tokens: {chatCompletionResponse.Usage.CompletionTokens}");
            Console.WriteLine($"Total tokens: {chatCompletionResponse.Usage.TotalTokens}");
         }
         Console.WriteLine();
      }
   }
   catch (HttpRequestException ex)
   {
      Console.WriteLine($"Request failed: {(int?)ex.StatusCode} {ex.Message}");
   }
   catch (TaskCanceledException)
   {
      Console.WriteLine("Request timed out.");
   }
   catch (JsonException ex)
   {
      Console.WriteLine($"Failed to parse response: {ex.Message}");
   }

   Console.Write("Enter chat message: ");
   content = Console.ReadLine();
}
public sealed class ChatCompletionRequest
{
   [JsonPropertyName("model")] public required string Model { get; init; }
   [JsonPropertyName("messages")] public required List<ChatMessage> Messages { get; init; }
   [JsonPropertyName("temperature")] public double? Temperature { get; init; }
   [JsonPropertyName("max_tokens")] public int? MaxTokens { get; init; }
   [JsonPropertyName("stream")] public bool? Stream { get; init; }
   [JsonPropertyName("response_format")] public ResponseFormat? ResponseFormat { get; init; }
}

public sealed class ChatMessage
{
   [JsonPropertyName("role")] public required string Role { get; init; }
   [JsonPropertyName("content")] public required string Content { get; init; }
}

public sealed class ResponseFormat
{
   [JsonPropertyName("type")] public required string Type { get; init; }
}

public sealed class ChatCompletionResponse
{
   [JsonPropertyName("id")] public required string Id { get; init; }
   [JsonPropertyName("choices")] public required List<ChatCompletionChoice> Choices { get; init; }
   [JsonPropertyName("usage")] public TokenUsage? Usage { get; init; }
}

public sealed class TokenUsage
{
   [JsonPropertyName("prompt_tokens")] public int? PromptTokens { get; init; }
   [JsonPropertyName("completion_tokens")] public int? CompletionTokens { get; init; }
   [JsonPropertyName("total_tokens")] public int? TotalTokens { get; init; }
}

public sealed class ChatCompletionChoice
{
   [JsonPropertyName("index")] public int Index { get; init; }
   [JsonPropertyName("message")] public required ChatMessage Message { get; init; }
   [JsonPropertyName("finish_reason")] public string? FinishReason { get; init; }
}

I had to capture the application output in two screenshots as the response text was longer.

The non-deterministic nature of LLMs resulted in different response messages, with the longest one consuming significantly more tokens, 530 vs. 799 (future posts will cover the use of Random_seed)

Mistral AI ASP Net CORE MinimalAPI Experiment

Over the last couple of months, I’ve been experimenting with a range of AI coding tools starting with GitHub Copilot, then Anthropic Claude, and more recently, Mistral (I was looking for an on-prem solution). Mistral is a French company so is covered by the General Data Protect Regulation(GDPR) rules of the European Union(EU) which are much better than the regulations other providers have to comply with.

Like many .NET developers, I started with Copilot as a natural extension of my workflow, expecting it to streamline repetitive tasks and accelerate development. When I started using Building Edge AI with Github Copilot- Security Camera HTTP(Jan 2025) the experience wasn’t great. Especially when I was using it for the “niche” areas I work-in it was pretty hopeless (sometimes even referred me to my own blog posts).

After a while I started trialing the other tools in my workflow and though they were better, sometimes F2-Replace or intellisense were faster and used a lot less tokens. I would also get the tools to review the code of the others, and I especially liked the Claude “Irony stack” when using it review Co-Pilot generated code.

While the other tools certainly helped (especially after adding custom skills files), I often found myself spending as much time going “down rabbit holes”(not the tool’s problem, though I hopefully learnt some useful stuff) and correcting or restructuring or debugging generated code that I could have written faster from scratch.

That’s what made my “out of box” experience with Mistral stand out. With a relatively simple prompt, it produced code that was not only concise but surprisingly accurate with just a single compile time error and no warnings on the first pass.

NOTE: This was using the webby interface, but I now have a paid for subscription.

The instructions which included .NET 8 (bit retro) and “dotnet add package”(pretty good) meant the code compiled on second attempt. The issue was a syntax error initialising OpenTelemetry which was quickly fixed, somewhat ironically with GitHub Copilot.

.ConfigureResource(resourceBuilder) rather than .ConfigureResource(rb => rb = resourceBuilder)

//dotnet add package OpenTelemetry 
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package OpenTelemetry.Exporter.Console
//dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
//
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: builder.Environment.ApplicationName);

// Add OpenTelemetry Tracing
builder.Services.AddOpenTelemetry()
    //.ConfigureResource(resourceBuilder) /**** This was the only compile time issue
    .ConfigureResource(rb => rb = resourceBuilder)
    .WithTracing(tracerProviderBuilder =>
    {
       tracerProviderBuilder
           .AddSource("MinimalApiSample")
           .AddAspNetCoreInstrumentation(options =>
           {
              options.RecordException = true;
           })
           .AddHttpClientInstrumentation()
           .AddConsoleExporter(); // For demo: export to console
           //.AddOtlpExporter(); // Uncomment to export to OpenTelemetry Collector

    })
    .WithMetrics(metricsProviderBuilder =>
    {
       metricsProviderBuilder
           .AddAspNetCoreInstrumentation()
           .AddHttpClientInstrumentation()
           .AddConsoleExporter(); // For demo: export to console
           //.AddOtlpExporter(); // Uncomment to export to OpenTelemetry Collector
    });

var app = builder.Build();

// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");

app.MapGet("/", () =>
{
   using var activity = activitySource.StartActivity("RootEndpoint");
   activity?.SetTag("custom.tag", "Hello, OpenTelemetry!");
   return Results.Ok("Hello, OpenTelemetry!");
});

app.MapGet("/metrics", () =>
{
   // This endpoint is just for demo; metrics are exported automatically
   return Results.Ok("Metrics are being collected in the background.");
});

app.Run();
//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter; 
//using OpenTelemetry; 
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: builder.Environment.ApplicationName)
    .AddTelemetrySdk();

// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
    //.ConfigureResource(resourceBuilder)
    .ConfigureResource(rb => rb = resourceBuilder) //*****
    .WithTracing(tracerProviderBuilder =>
    {
       tracerProviderBuilder
           .AddSource("MinimalApiSample")
           .AddAspNetCoreInstrumentation(options =>
           {
              options.RecordException = true;
           })
           .AddHttpClientInstrumentation()
           .AddAzureMonitorTraceExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    })
    .WithMetrics(metricsProviderBuilder =>
    {
       metricsProviderBuilder
           .AddAspNetCoreInstrumentation()
           .AddHttpClientInstrumentation()
           .AddAzureMonitorMetricExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    });

var app = builder.Build();

// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");

app.MapGet("/", () =>
{
   using var activity = activitySource.StartActivity("RootEndpoint");
   activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
   return Results.Ok("Hello, Azure Application Insights!");
});

app.MapGet("/metrics", () =>
{
   return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});

app.Run();

Using Application Insights metrics the Kestral.active_connections graphs to shows some of the additional telemetry emitted by the application.

//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;  
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;

var builder = WebApplication.CreateBuilder(args);

// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: builder.Environment.ApplicationName)
    .AddTelemetrySdk();

// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");

// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
    //.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
    .ConfigureResource(rb=>rb =  resourceBuilder)
    .WithTracing(tracerProviderBuilder =>
    {
       tracerProviderBuilder
           .AddSource("MinimalApiSample")
           .AddAspNetCoreInstrumentation(options =>
           {
              options.RecordException = true;
           })
           .AddHttpClientInstrumentation()
           .AddAzureMonitorTraceExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    })
    .WithMetrics(metricsProviderBuilder =>
    {
       metricsProviderBuilder
           .AddAspNetCoreInstrumentation()
           .AddHttpClientInstrumentation()
           .AddMeter("MinimalApiSample.Metrics") // Add your custom meter
           .AddAzureMonitorMetricExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    });

var app = builder.Build();

// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");

app.MapGet("/", () =>
{
   using var activity = activitySource.StartActivity("RootEndpoint");
   activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
   return Results.Ok("Hello, Azure Application Insights!");
});

app.MapGet("/metrics", () =>
{
   // Increment custom metric on each access
   metricsCounter.Add(1);
   return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});

app.Run();

I by pleasantly surprised by suggestion of a counter for each endpoint which was my original intent.

Couldn’t think of a better name “scirtem” is “metrics” backwards. The way Meter and CreateCount are global would not be a good idea in a more complex system but this is fine for a hacky PoC.

//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;  //***** Unnecessary with OpenTelemetry.Extensions.Hosting
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;

var builder = WebApplication.CreateBuilder(args);

// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: builder.Environment.ApplicationName)
    .AddTelemetrySdk();

// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");
var scirtemCounter = meter.CreateCounter<int>("ScirtemEndpointAccessCount");

// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
    //.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
    .ConfigureResource(rb=>rb =  resourceBuilder)
    .WithTracing(tracerProviderBuilder =>
    {
       tracerProviderBuilder
           .AddSource("MinimalApiSample")
           .AddAspNetCoreInstrumentation(options =>
           {
              options.RecordException = true;
           })
           .AddHttpClientInstrumentation()
           .AddAzureMonitorTraceExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    })
    .WithMetrics(metricsProviderBuilder =>
    {
       metricsProviderBuilder
           .AddAspNetCoreInstrumentation()
           .AddHttpClientInstrumentation()
           .AddMeter("MinimalApiSample.Metrics") // Add your custom meter
           .AddAzureMonitorMetricExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    });

var app = builder.Build();

// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");

app.MapGet("/", () =>
{
   using var activity = activitySource.StartActivity("RootEndpoint");
   activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
   return Results.Ok("Hello, Azure Application Insights!");
});

app.MapGet("/metrics", () =>
{
   // Increment custom metric on each access
   metricsCounter.Add(1);
   return Results.Ok("Metrics and traces are being sent to Azure Application Insights.");
});

app.MapGet("/scirtem", () =>
{
   scirtemCounter.Add(1);
   return Results.Ok("Scirtem endpoint accessed.");
});

app.Run();

Using Application Insights metrics the MetricsEndPointAccesCount, and ScirtemEndPointAccesCount, plots to show the OLTP telemetry emitted by the application.

Mistral generated the code for the endpoint latency histogram without any prompting.

//dotnet add package OpenTelemetry
//dotnet add package OpenTelemetry.Extensions.Hosting
//dotnet add package OpenTelemetry.Instrumentation.AspNetCore
//dotnet add package OpenTelemetry.Instrumentation.Http
//dotnet add package Azure.Monitor.OpenTelemetry.Exporter
//
using Azure.Monitor.OpenTelemetry.Exporter;
//using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;
using System.Diagnostics.Metrics;

var builder = WebApplication.CreateBuilder(args);

// Configure OpenTelemetry with a resource (service name)
var resourceBuilder = ResourceBuilder.CreateDefault()
    .AddService(serviceName: builder.Environment.ApplicationName)
    .AddTelemetrySdk();

// Create a meter for custom metrics
var meter = new Meter("MinimalApiSample.Metrics");
var metricsCounter = meter.CreateCounter<int>("MetricsEndpointAccessCount");
var scirtemCounter = meter.CreateCounter<int>("ScirtemEndpointAccessCount");
var histogram = meter.CreateHistogram<double>("HistogramEndpointLatencyMs");

// Add OpenTelemetry Tracing and Metrics for Azure Application Insights
builder.Services.AddOpenTelemetry()
    //.ConfigureResource(resourceBuilder) /**** This is the only compile time issue
    .ConfigureResource(rb=>rb =  resourceBuilder)
    .WithTracing(tracerProviderBuilder =>
    {
       tracerProviderBuilder
           .AddSource("MinimalApiSample")
           .AddAspNetCoreInstrumentation(options =>
           {
              options.RecordException = true;
           })
           .AddHttpClientInstrumentation()
           .AddAzureMonitorTraceExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    })
    .WithMetrics(metricsProviderBuilder =>
    {
       metricsProviderBuilder
           .AddAspNetCoreInstrumentation()
           .AddHttpClientInstrumentation()
           .AddMeter("MinimalApiSample.Metrics") // Register your custom meter
           .AddAzureMonitorMetricExporter(options =>
           {
              options.ConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"];
           });
    });

var app = builder.Build();

// Example of a custom activity for tracing
var activitySource = new ActivitySource("MinimalApiSample");

app.MapGet("/", () =>
{
   using var activity = activitySource.StartActivity("RootEndpoint");
   activity?.SetTag("custom.tag", "Hello, Azure Application Insights!");
   return Results.Ok("Hello, Azure Application Insights!");
});

app.MapGet("/metrics", () =>
{
   metricsCounter.Add(1);
   return Results.Ok("Metrics endpoint accessed.");
});

app.MapGet("/scirtem", () =>
{
   scirtemCounter.Add(1);
   return Results.Ok("Scirtem endpoint accessed.");
});

app.MapGet("/histogram", async () =>
{
   // Simulate some work
   var startTime = Stopwatch.GetTimestamp();
   await Task.Delay(Random.Shared.Next(50, 200)); // Random delay between 50-200ms
   var endTime = Stopwatch.GetTimestamp();

   // Calculate latency in milliseconds
   var latencyMs = (endTime - startTime) * 1000.0 / Stopwatch.Frequency;
   histogram.Record(latencyMs);

   return Results.Ok($"Histogram endpoint accessed. Latency: {latencyMs:F2}ms");
});

app.Run();

Using Application Insights metrics the OpenTelemetry.HistogramEndpointLatencyMs plot to show the OLTP telemetry emitted by the application.

Even with my relatively trivial OTLP learning applications, Mistral consistently produced clean and usable code with my simple prompts (maybe, I have got better and prompting). The generated code was straightforward, required only minor fixes, and avoided much of the over-complexity I’d seen in earlier experiments with other tools (looking at you mid/late 2025 Copilot). For my simple OTLP observability learning scenarios, that translated into faster iteration and less time spent refactoring and debugging generated code.