diff --git a/dotnet/devin/sample-agent/.gitignore b/dotnet/devin/sample-agent/.gitignore
new file mode 100644
index 00000000..93793595
--- /dev/null
+++ b/dotnet/devin/sample-agent/.gitignore
@@ -0,0 +1,38 @@
+# Agent secrets and local overrides
+appsettings.Development.json
+appsettings.Local.json
+
+# .NET build output
+bin/
+obj/
+publish/
+
+# User secrets
+secrets.json
+
+# IDE
+.vs/
+*.user
+*.suo
+
+# A365 CLI generated files (keep a365.config.json as template)
+a365.generated.config.json
+scope-update*.json
+required-access.json
+
+# Deployment artifacts
+deploy.zip
+*.zip
+web.config
+
+# Logs
+*.log
+log.txt
+logs/
+
+# Dev tools
+devTools/
+
+# Environment files
+.env
+.env.*
diff --git a/dotnet/devin/sample-agent/Agent/MyAgent.cs b/dotnet/devin/sample-agent/Agent/MyAgent.cs
new file mode 100644
index 00000000..cebd5ee5
--- /dev/null
+++ b/dotnet/devin/sample-agent/Agent/MyAgent.cs
@@ -0,0 +1,159 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Agent365DevinSampleAgent.Client;
+using Agent365DevinSampleAgent.telemetry;
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Builder.App;
+using Microsoft.Agents.Builder.State;
+using Microsoft.Agents.Core.Models;
+using System.Diagnostics;
+
+namespace Agent365DevinSampleAgent.Agent;
+
+///
+/// Devin Sample Agent — routes messages to the Devin API and returns responses.
+/// Handles installation, notifications, typing indicators, and observability.
+///
+public class MyAgent : AgentApplication
+{
+ private const string AgentHireMessage = "Thank you for hiring me! Looking forward to assisting you in your professional journey!";
+ private const string AgentFarewellMessage = "Thank you for your time, I enjoyed working with you.";
+
+ private readonly DevinClient _devinClient;
+ private readonly ILogger _logger;
+
+ public MyAgent(
+ AgentApplicationOptions options,
+ DevinClient devinClient,
+ ILogger logger)
+ : base(options)
+ {
+ _devinClient = devinClient;
+ _logger = logger;
+
+ // Register message handler for agentic requests
+ OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: true);
+
+ // Register message handler for non-agentic requests (Playground / WebChat)
+ OnActivity(ActivityTypes.Message, OnMessageAsync, isAgenticOnly: false);
+
+ // Register installation handler for agentic requests
+ OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: true);
+
+ // Register installation handler for non-agentic requests (Playground)
+ OnActivity(ActivityTypes.InstallationUpdate, OnInstallationUpdateAsync, isAgenticOnly: false);
+
+ // Register conversation update handler
+ OnConversationUpdate(ConversationUpdateEvents.MembersAdded, OnMembersAddedAsync);
+ }
+
+ private async Task OnMembersAddedAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
+ {
+ foreach (var member in turnContext.Activity.MembersAdded ?? [])
+ {
+ if (member.Id != turnContext.Activity.Recipient?.Id)
+ {
+ await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
+ }
+ }
+ }
+
+ private async Task OnInstallationUpdateAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
+ {
+ using var activity = AgentMetrics.InitializeMessageHandlingActivity("InstallationUpdate", turnContext);
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add)
+ {
+ _logger.LogInformation("Agent installed");
+ await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
+ }
+ else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove)
+ {
+ _logger.LogInformation("Agent uninstalled");
+ await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken);
+ }
+
+ AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error handling installation update");
+ AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, false);
+ throw;
+ }
+ }
+
+ private async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken)
+ {
+ using var activity = AgentMetrics.InitializeMessageHandlingActivity("MessageHandler", turnContext);
+ var stopwatch = Stopwatch.StartNew();
+
+ try
+ {
+ // Log user identity
+ var fromAccount = turnContext.Activity.From;
+ _logger.LogDebug(
+ "Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
+ fromAccount?.Name ?? "(unknown)",
+ fromAccount?.Id ?? "(unknown)",
+ fromAccount?.AadObjectId ?? "(none)");
+
+ var userMessage = turnContext.Activity.Text?.Trim();
+ if (string.IsNullOrEmpty(userMessage))
+ {
+ await turnContext.SendActivityAsync(
+ MessageFactory.Text("Please send me a message and I'll help you!"), cancellationToken);
+ return;
+ }
+
+ // Send immediate acknowledgment (discrete Teams message)
+ await turnContext.SendActivityAsync(
+ MessageFactory.Text("Got it — working on it…"), cancellationToken);
+
+ // Typing indicator loop — refreshes every ~4s for long-running operations
+ using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var typingTask = Task.Run(async () =>
+ {
+ try
+ {
+ while (!typingCts.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token);
+ await turnContext.SendActivityAsync(
+ new Microsoft.Agents.Core.Models.Activity { Type = ActivityTypes.Typing }, typingCts.Token);
+ }
+ }
+ catch (OperationCanceledException) { /* expected on cancel */ }
+ }, typingCts.Token);
+
+ try
+ {
+ // Invoke Devin
+ var response = await _devinClient.InvokeAgentAsync(userMessage, cancellationToken);
+
+ // Send the Devin response as a discrete message
+ await turnContext.SendActivityAsync(
+ MessageFactory.Text(response), cancellationToken);
+ }
+ finally
+ {
+ // Stop typing indicator
+ await typingCts.CancelAsync();
+ try { await typingTask; } catch (OperationCanceledException) { }
+ }
+
+ AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error processing message");
+ AgentMetrics.FinalizeMessageHandlingActivity(activity, turnContext, stopwatch.ElapsedMilliseconds, false);
+ await turnContext.SendActivityAsync(
+ MessageFactory.Text("There was an error processing your request."), cancellationToken);
+ }
+ }
+}
diff --git a/dotnet/devin/sample-agent/AspNetExtensions.cs b/dotnet/devin/sample-agent/AspNetExtensions.cs
new file mode 100644
index 00000000..cfd5e201
--- /dev/null
+++ b/dotnet/devin/sample-agent/AspNetExtensions.cs
@@ -0,0 +1,161 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.Authentication;
+using Microsoft.Agents.Core;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.IdentityModel.Tokens;
+using Microsoft.IdentityModel.Validators;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.IdentityModel.Tokens.Jwt;
+
+namespace Agent365DevinSampleAgent;
+
+public static class AspNetExtensions
+{
+ private static readonly ConcurrentDictionary> _openIdMetadataCache = new();
+
+ ///
+ /// Adds token validation typical for ABS/SMBA and Bot-to-bot.
+ /// Default to Azure Public Cloud.
+ ///
+ public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation")
+ {
+ IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName);
+
+ if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true))
+ {
+ System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled");
+ return;
+ }
+
+ services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!);
+ }
+
+ public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions)
+ {
+ AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions));
+
+ if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0)
+ {
+ throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId");
+ }
+
+ foreach (var audience in validationOptions.Audiences)
+ {
+ if (!Guid.TryParse(audience, out _))
+ {
+ throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID");
+ }
+ }
+
+ if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0)
+ {
+ validationOptions.ValidIssuers =
+ [
+ "https://api.botframework.com",
+ "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/",
+ "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0",
+ "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/",
+ "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0",
+ "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/",
+ "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0",
+ ];
+
+ if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _))
+ {
+ validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId));
+ validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId));
+ }
+ }
+
+ if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl))
+ {
+ validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl;
+ }
+
+ if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl))
+ {
+ validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl;
+ }
+
+ var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval;
+
+ _ = services.AddAuthentication(options =>
+ {
+ options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
+ })
+ .AddJwtBearer(options =>
+ {
+ options.SaveToken = true;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateLifetime = true,
+ ClockSkew = TimeSpan.FromMinutes(5),
+ ValidIssuers = validationOptions.ValidIssuers,
+ ValidAudiences = validationOptions.Audiences,
+ IssuerSigningKeyResolver = (token, securityToken, kid, parameters) =>
+ {
+ return GetSigningKeys(validationOptions, openIdMetadataRefresh, kid);
+ },
+ };
+ options.Authority = string.Format(CultureInfo.InvariantCulture, "https://login.microsoftonline.com/{0}", validationOptions.TenantId ?? "common");
+ });
+
+ services.AddAuthorization();
+ }
+
+ private static IEnumerable GetSigningKeys(TokenValidationOptions validationOptions, TimeSpan openIdMetadataRefresh, string kid)
+ {
+ var allKeys = new List();
+
+ var openIdUrls = new[]
+ {
+ validationOptions.OpenIdMetadataUrl!,
+ validationOptions.AzureBotServiceOpenIdMetadataUrl!,
+ };
+
+ foreach (var url in openIdUrls)
+ {
+ var configManager = _openIdMetadataCache.GetOrAdd(
+ url,
+ key => new ConfigurationManager(
+ key,
+ new OpenIdConnectConfigurationRetriever(),
+ new HttpDocumentRetriever())
+ {
+ AutomaticRefreshInterval = openIdMetadataRefresh
+ });
+
+ try
+ {
+ var openIdConfig = configManager.GetConfigurationAsync(CancellationToken.None).GetAwaiter().GetResult();
+ allKeys.AddRange(openIdConfig.SigningKeys);
+ }
+ catch (Exception)
+ {
+ // If one metadata endpoint fails, continue with others
+ }
+ }
+
+ return allKeys;
+ }
+}
+
+public class TokenValidationOptions
+{
+ public bool Enabled { get; set; } = true;
+ public List? Audiences { get; set; }
+ public string? TenantId { get; set; }
+ public List? ValidIssuers { get; set; }
+ public string? OpenIdMetadataUrl { get; set; }
+ public string? AzureBotServiceOpenIdMetadataUrl { get; set; }
+ public TimeSpan? OpenIdMetadataRefresh { get; set; }
+ public bool IsGov { get; set; }
+}
diff --git a/dotnet/devin/sample-agent/Client/DevinClient.cs b/dotnet/devin/sample-agent/Client/DevinClient.cs
new file mode 100644
index 00000000..bf7bfa32
--- /dev/null
+++ b/dotnet/devin/sample-agent/Client/DevinClient.cs
@@ -0,0 +1,181 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Agent365DevinSampleAgent.Client;
+
+///
+/// Client for interacting with the Devin API.
+/// Creates sessions, sends messages, and polls for responses.
+///
+public class DevinClient
+{
+ private readonly HttpClient _httpClient;
+ private readonly ILogger _logger;
+ private readonly string _baseUrl;
+ private readonly int _pollingIntervalSeconds;
+ private readonly int _timeoutSeconds;
+ private string? _currentSessionId;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ PropertyNameCaseInsensitive = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ public DevinClient(HttpClient httpClient, IConfiguration configuration, ILogger logger)
+ {
+ _httpClient = httpClient;
+ _logger = logger;
+
+ _baseUrl = configuration["Devin:BaseUrl"]
+ ?? throw new InvalidOperationException("Devin:BaseUrl configuration is required");
+
+ var apiKey = configuration["Devin:ApiKey"]
+ ?? throw new InvalidOperationException("Devin:ApiKey configuration is required");
+
+ _pollingIntervalSeconds = configuration.GetValue("Devin:PollingIntervalSeconds", 10);
+ _timeoutSeconds = configuration.GetValue("Devin:TimeoutSeconds", 300);
+
+ _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
+ _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
+ }
+
+ ///
+ /// Sends a prompt to Devin and returns the response.
+ /// Maintains session across turns for multi-turn conversations.
+ ///
+ public async Task InvokeAgentAsync(string prompt, CancellationToken cancellationToken = default)
+ {
+ _currentSessionId = await PromptDevinAsync(prompt, _currentSessionId, cancellationToken);
+ return await PollForResponseAsync(_currentSessionId, cancellationToken);
+ }
+
+ private async Task PromptDevinAsync(string prompt, string? sessionId, CancellationToken cancellationToken)
+ {
+ string requestUrl;
+ object requestBody;
+
+ if (sessionId != null)
+ {
+ requestUrl = $"{_baseUrl}/sessions/{sessionId}/message";
+ requestBody = new { message = prompt };
+ }
+ else
+ {
+ requestUrl = $"{_baseUrl}/sessions";
+ requestBody = new { prompt };
+ }
+
+ var json = JsonSerializer.Serialize(requestBody, JsonOptions);
+ using var content = new StringContent(json, Encoding.UTF8, "application/json");
+
+ _logger.LogDebug("Sending request to Devin: {Url}", requestUrl);
+ var response = await _httpClient.PostAsync(requestUrl, content, cancellationToken);
+ response.EnsureSuccessStatusCode();
+
+ var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
+ var data = JsonSerializer.Deserialize(responseJson, JsonOptions);
+
+ var rawSessionId = data?.SessionId ?? "";
+ var resolvedSessionId = sessionId ?? rawSessionId.Replace("devin-", "");
+
+ _logger.LogDebug("Devin session ID: {SessionId}", resolvedSessionId);
+ return resolvedSessionId;
+ }
+
+ private async Task PollForResponseAsync(string sessionId, CancellationToken cancellationToken)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(_timeoutSeconds);
+ var sentMessages = new HashSet();
+
+ _logger.LogDebug("Starting poll for Devin's reply (session: {SessionId})", sessionId);
+
+ while (DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_pollingIntervalSeconds), cancellationToken);
+
+ var requestUrl = $"{_baseUrl}/sessions/{sessionId}";
+ var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("Devin API call failed with status {StatusCode}", response.StatusCode);
+ return "There was an error processing your request, please try again.";
+ }
+
+ var responseJson = await response.Content.ReadAsStringAsync(cancellationToken);
+ _logger.LogInformation("Devin poll response (status check): {Response}",
+ responseJson.Length > 500 ? responseJson[..500] + "..." : responseJson);
+
+ var data = JsonSerializer.Deserialize(responseJson, JsonOptions);
+
+ if (data == null) continue;
+
+ _logger.LogInformation("Current Devin session status: {Status}, Messages count: {Count}",
+ data.Status, data.Messages?.Count ?? 0);
+
+ // Check the last message for a devin_message response
+ var latestMessage = data.Messages?.LastOrDefault();
+ if (latestMessage != null)
+ {
+ _logger.LogInformation("Latest message — Type: '{Type}', EventId: '{EventId}', Message: '{Message}'",
+ latestMessage.Type, latestMessage.EventId,
+ latestMessage.Message?.Length > 100 ? latestMessage.Message[..100] + "..." : latestMessage.Message);
+ }
+
+ if (latestMessage?.Type == "devin_message" && !sentMessages.Contains(latestMessage.EventId))
+ {
+ sentMessages.Add(latestMessage.EventId);
+ _logger.LogInformation("Received Devin response: {Message}", latestMessage.Message);
+ return latestMessage.Message ?? "No response from Devin.";
+ }
+
+ // If status is no longer active, stop polling
+ if (data.Status != "new" && data.Status != "claimed" && data.Status != "running")
+ {
+ _logger.LogWarning("Devin session ended with status: {Status}", data.Status);
+ break;
+ }
+ }
+
+ _logger.LogWarning("Timed out waiting for Devin response");
+ return "I'm still working on this. Please try again in a moment.";
+ }
+}
+
+#region Models
+
+public class DevinCreateSessionResponse
+{
+ [JsonPropertyName("session_id")]
+ public string? SessionId { get; set; }
+}
+
+public class DevinSessionResponse
+{
+ [JsonPropertyName("status")]
+ public string Status { get; set; } = "";
+
+ [JsonPropertyName("messages")]
+ public List? Messages { get; set; }
+}
+
+public class DevinMessage
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "";
+
+ [JsonPropertyName("message")]
+ public string? Message { get; set; }
+
+ [JsonPropertyName("event_id")]
+ public string EventId { get; set; } = "";
+}
+
+#endregion
diff --git a/dotnet/devin/sample-agent/DevinSampleAgent.csproj b/dotnet/devin/sample-agent/DevinSampleAgent.csproj
new file mode 100644
index 00000000..d8d1e01a
--- /dev/null
+++ b/dotnet/devin/sample-agent/DevinSampleAgent.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ b2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/devin/sample-agent/Program.cs b/dotnet/devin/sample-agent/Program.cs
new file mode 100644
index 00000000..1d4eb202
--- /dev/null
+++ b/dotnet/devin/sample-agent/Program.cs
@@ -0,0 +1,92 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Agent365DevinSampleAgent;
+using Agent365DevinSampleAgent.Agent;
+using Agent365DevinSampleAgent.Client;
+using Agent365DevinSampleAgent.telemetry;
+using Microsoft.Agents.A365.Observability;
+using Microsoft.Agents.A365.Observability.Extensions.AgentFramework;
+using Microsoft.Agents.A365.Observability.Runtime;
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Core;
+using Microsoft.Agents.Hosting.AspNetCore;
+using Microsoft.Agents.Storage;
+using System.Reflection;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Setup OpenTelemetry, Service Discovery, Resilience, and Health Checks
+builder.ConfigureOpenTelemetry();
+
+builder.Configuration.AddUserSecrets(Assembly.GetExecutingAssembly());
+builder.Services.AddControllers();
+builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600));
+builder.Services.AddHttpContextAccessor();
+builder.Logging.AddConsole();
+
+// ********** Configure A365 Services **********
+// Configure observability.
+builder.Services.AddAgenticTracingExporter(clusterCategory: "production");
+
+// Add A365 tracing with Agent Framework integration
+builder.AddA365Tracing(config =>
+{
+ config.WithAgentFramework();
+});
+// ********** END Configure A365 Services **********
+
+// Add AspNet token validation
+builder.Services.AddAgentAspNetAuthentication(builder.Configuration);
+
+// Register IStorage. For development, MemoryStorage is suitable.
+builder.Services.AddSingleton();
+
+// Register the Devin HTTP client
+builder.Services.AddHttpClient(client =>
+{
+ client.Timeout = TimeSpan.FromSeconds(600);
+});
+
+// Add AgentApplicationOptions from config.
+builder.AddAgentApplicationOptions();
+
+// Add the agent (which is transient)
+builder.AddAgent();
+
+var app = builder.Build();
+
+if (app.Environment.IsDevelopment())
+{
+ app.UseDeveloperExceptionPage();
+}
+
+app.UseRouting();
+app.UseAuthentication();
+app.UseAuthorization();
+
+// Map the /api/messages endpoint to the AgentApplication
+app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) =>
+{
+ await AgentMetrics.InvokeObservedHttpOperation("agent.process_message", async () =>
+ {
+ await adapter.ProcessAsync(request, response, agent, cancellationToken);
+ }).ConfigureAwait(false);
+});
+
+// Health check endpoint
+app.MapGet("/api/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }));
+
+if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground")
+{
+ app.MapGet("/", () => "Devin Sample Agent");
+ app.UseDeveloperExceptionPage();
+ app.MapControllers().AllowAnonymous();
+ app.Urls.Add($"http://localhost:3978");
+}
+else
+{
+ app.MapControllers();
+}
+
+app.Run();
diff --git a/dotnet/devin/sample-agent/Properties/launchSettings.json b/dotnet/devin/sample-agent/Properties/launchSettings.json
new file mode 100644
index 00000000..a5df99e0
--- /dev/null
+++ b/dotnet/devin/sample-agent/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "profiles": {
+ "Sample Agent": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development",
+ "SKIP_TOOLING_ON_ERRORS": "true"
+ },
+ "applicationUrl": "http://localhost:3978"
+ },
+ "Sample Agent (Playground)": {
+ "commandName": "Project",
+ "launchBrowser": false,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Playground",
+ "BEARER_TOKEN": "",
+ "SKIP_TOOLING_ON_ERRORS": "true"
+ },
+ "applicationUrl": "http://localhost:3978"
+ }
+ }
+}
diff --git a/dotnet/devin/sample-agent/README.md b/dotnet/devin/sample-agent/README.md
new file mode 100644
index 00000000..95fc358c
--- /dev/null
+++ b/dotnet/devin/sample-agent/README.md
@@ -0,0 +1,321 @@
+# Devin (Cognition AI) Sample Agent
+
+## Overview
+
+This sample demonstrates how to use [Devin](https://devin.ai) (by Cognition AI) as the AI backbone in an agent using the **Microsoft Agent 365 SDK** and **Microsoft 365 Agents SDK**. It enables enterprise developers to connect Devin's autonomous coding agent capabilities into Agent 365 managed environments.
+
+It covers:
+
+- **Devin API Integration**: Forward messages to Devin and poll for responses
+- **Observability**: End-to-end tracing, caching, and monitoring for agent applications
+- **Notifications**: Services and models for managing user notifications
+- **Multi-turn Sessions**: Maintain Devin session across conversation turns
+- **Hosting Patterns**: Hosting with Microsoft 365 Agents SDK
+
+This sample uses the [Devin REST API](https://docs.devin.ai) and the [Microsoft Agent 365 SDK for .NET](https://github.com/microsoft/Agent365-dotnet).
+
+For comprehensive documentation and guidance on building agents with the Microsoft Agent 365 SDK, including how to add tooling, observability, and notifications, visit the [Microsoft Agent 365 Developer Documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/).
+
+## Architecture
+
+```
+┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐
+│ Agent 365 SDK │ │ This Sample │ │ Devin API │
+│ (Notifications)│─────>│ (MyAgent) │─────>│ (Cognition AI) │
+└─────────────────┘ └──────────────────┘ └─────────────────────┘
+ │ │ │
+ │ Incoming message │ POST /sessions │
+ │───────────────────────>│─────────────────────────>│
+ │ │ │
+ │ │ Poll GET /sessions/{id}│
+ │ │─────────────────────────>│
+ │ │ │
+ │ │ devin_message response │
+ │ Response │<─────────────────────────│
+ │<───────────────────────│ │
+```
+
+The agent does **not** call an LLM directly. Instead, it delegates all reasoning to Devin's hosted API (which uses Claude under the hood).
+
+## Prerequisites
+
+- [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) or later
+- Microsoft Agent 365 SDK
+- A [Devin](https://devin.ai) API key ([app.devin.ai](https://app.devin.ai))
+- Agent 365 blueprint credentials (ClientId, ClientSecret, TenantId)
+
+## Launch Profiles
+
+This sample includes two launch profiles in `Properties/launchSettings.json`:
+
+### Sample Agent
+
+Uses Agentic Users with Client Credentials or Managed Identity. Use this for production or when testing with full Azure Bot Service configuration.
+
+### Sample Agent (Playground)
+
+Simplified profile for early local development using bearer token authentication.
+
+**Quick setup:**
+1. Add required permissions using the a365 CLI:
+ ```bash
+ a365 develop add-permissions
+ ```
+ This grants the necessary scopes for agent access.
+
+2. Get a bearer token:
+ ```bash
+ a365 develop get-token
+ ```
+ The CLI will either automatically add the token to your `launchSettings.json` or provide it for you to copy/paste.
+
+3. Select the "Sample Agent (Playground)" launch profile in Visual Studio
+4. Run the agent
+
+> **Note**: Bearer tokens are for development only and expire regularly. Refresh with `a365 develop get-token`.
+
+## Configuration
+
+### Devin API Settings
+
+Get your API key from the [Devin dashboard](https://app.devin.ai).
+
+In `appsettings.json`:
+```json
+{
+ "Devin": {
+ "BaseUrl": "https://api.devin.ai/v1",
+ "ApiKey": "",
+ "PollingIntervalSeconds": 10,
+ "TimeoutSeconds": 300
+ }
+}
+```
+
+### Authentication
+
+Configure the blueprint connection in `appsettings.json` under `Connections` > `ServiceConnection`:
+
+```json
+{
+ "Connections": {
+ "ServiceConnection": {
+ "Settings": {
+ "AuthType": "ClientSecret",
+ "ClientId": "",
+ "ClientSecret": "",
+ "AuthorityEndpoint": "https://login.microsoftonline.com/",
+ "Scopes": ["5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default"]
+ }
+ }
+ }
+}
+```
+
+## Working with User Identity
+
+On every incoming message, the A365 platform populates `Activity.From` with basic user information — always available with no API calls or token acquisition:
+
+| Field | Description |
+|---|---|
+| `Activity.From.Id` | Channel-specific user ID (e.g., `29:1AbcXyz...` in Teams) |
+| `Activity.From.Name` | Display name as known to the channel |
+| `Activity.From.AadObjectId` | Azure AD Object ID — use this to call Microsoft Graph |
+
+The sample logs these fields at the start of every message turn in `OnMessageAsync` ([MyAgent.cs](Agent/MyAgent.cs)):
+
+```csharp
+var fromAccount = turnContext.Activity.From;
+_logger.LogDebug(
+ "Turn received from user — DisplayName: '{Name}', UserId: '{Id}', AadObjectId: '{AadObjectId}'",
+ fromAccount?.Name ?? "(unknown)",
+ fromAccount?.Id ?? "(unknown)",
+ fromAccount?.AadObjectId ?? "(none)");
+```
+
+## Handling Agent Install and Uninstall
+
+When a user installs (hires) or uninstalls (removes) the agent, the A365 platform sends an `InstallationUpdate` activity — also referred to as the `agentInstanceCreated` event. The sample handles this in `OnInstallationUpdateAsync` ([MyAgent.cs](Agent/MyAgent.cs)):
+
+| Action | Description |
+|---|---|
+| `add` | Agent was installed — send a welcome message |
+| `remove` | Agent was uninstalled — send a farewell message |
+
+```csharp
+if (turnContext.Activity.Action == InstallationUpdateActionTypes.Add)
+{
+ _logger.LogInformation("Agent installed");
+ await turnContext.SendActivityAsync(MessageFactory.Text(AgentHireMessage), cancellationToken);
+}
+else if (turnContext.Activity.Action == InstallationUpdateActionTypes.Remove)
+{
+ _logger.LogInformation("Agent uninstalled");
+ await turnContext.SendActivityAsync(MessageFactory.Text(AgentFarewellMessage), cancellationToken);
+}
+```
+
+The handler is registered twice in the constructor — once for agentic (A365 production) requests and once for non-agentic (Agents Playground / WebChat) requests, enabling local testing without a full A365 deployment.
+
+To test with Agents Playground, use **Mock an Activity → Install application** to send a simulated `installationUpdate` activity.
+
+## Sending Multiple Messages in Teams
+
+Agent365 agents can send multiple discrete messages in response to a single user prompt in Teams. This is achieved by calling `SendActivityAsync` multiple times within a single turn.
+
+> **Important**: Streaming responses are not supported for agentic identities in Teams. The SDK detects agentic identity and buffers the stream into a single message. Use `SendActivityAsync` directly to send immediate, discrete messages to the user.
+
+The sample demonstrates this in `OnMessageAsync` ([MyAgent.cs](Agent/MyAgent.cs)) by sending an immediate acknowledgment before the Devin response:
+
+```csharp
+// Message 1: immediate ack — reaches the user right away
+await turnContext.SendActivityAsync(MessageFactory.Text("Got it — working on it…"), cancellationToken);
+
+// ... Devin API polling ...
+
+// Message 2: the Devin response
+await turnContext.SendActivityAsync(MessageFactory.Text(response), cancellationToken);
+```
+
+Each `SendActivityAsync` call produces a separate Teams message. You can call it as many times as needed to send progress updates, partial results, or a final answer.
+
+### Typing Indicators
+
+For long-running operations (Devin typically takes 10-60s), the agent sends typing indicators every ~4 seconds to show a "..." progress animation:
+
+```csharp
+// Typing indicator loop — refreshes every ~4s for long-running operations.
+using var typingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+var typingTask = Task.Run(async () =>
+{
+ try
+ {
+ while (!typingCts.IsCancellationRequested)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(4), typingCts.Token);
+ await turnContext.SendActivityAsync(
+ new Activity { Type = ActivityTypes.Typing }, typingCts.Token);
+ }
+ }
+ catch (OperationCanceledException) { /* expected on cancel */ }
+}, typingCts.Token);
+
+try { /* ... invoke Devin ... */ }
+finally
+{
+ await typingCts.CancelAsync();
+ try { await typingTask; } catch (OperationCanceledException) { }
+}
+```
+
+> **Note**: Typing indicators are only visible in 1:1 chats and small group chats — not in channels.
+
+## Running the Agent
+
+To set up and test this agent, refer to the [Configure Agent Testing](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/testing?tabs=dotnet) guide for complete instructions.
+
+## Project Structure
+
+```
+sample-agent/
+├── Agent/
+│ └── MyAgent.cs # Main agent — routes messages to Devin
+├── Client/
+│ └── DevinClient.cs # Devin API client (create session, poll response)
+├── telemetry/
+│ ├── AgentOTELExtensions.cs # OpenTelemetry setup
+│ ├── AgentMetrics.cs # Custom metrics and tracing
+│ └── A365OtelWrapper.cs # A365 observability wrapper
+├── manifest/
+│ ├── manifest.json # Teams app manifest
+│ └── agenticUserTemplateManifest.json
+├── Program.cs # Entry point and DI configuration
+├── AspNetExtensions.cs # JWT token validation
+├── appsettings.json # Production configuration (fill in your values)
+├── appsettings.Playground.json # Local development / Playground configuration
+├── a365.config.json # A365 CLI configuration
+└── DevinSampleAgent.csproj
+```
+
+## Key Dependencies
+
+| Package | Purpose |
+|---------|---------|
+| `Microsoft.Agents.Hosting.AspNetCore` | Agent 365 hosting infrastructure |
+| `Microsoft.Agents.Authentication.Msal` | MSAL-based authentication |
+| `Microsoft.Agents.A365.Notifications` | A365 notification support |
+| `Microsoft.Agents.A365.Observability.Extensions.AgentFramework` | A365 observability |
+| `OpenTelemetry.Exporter.OpenTelemetryProtocol` | OpenTelemetry OTLP exporter |
+| `Microsoft.Extensions.Http.Resilience` | HTTP resilience policies |
+
+## Devin API Reference
+
+| Endpoint | Purpose |
+|----------|---------|
+| `POST /sessions` | Create a new Devin session with a prompt |
+| `POST /sessions/{id}/message` | Send a follow-up message to existing session |
+| `GET /sessions/{id}` | Poll session for `devin_message` responses |
+
+For full API documentation, see [Devin API docs](https://docs.devin.ai).
+
+## Troubleshooting
+
+### Timeout waiting for Devin response
+
+**Symptom:** Agent responds with "I'm still working on this..."
+
+**Solution:**
+- Increase `Devin:TimeoutSeconds` in appsettings.json (default: 300s)
+- Decrease `Devin:PollingIntervalSeconds` for faster detection (default: 10s)
+- Complex tasks may take longer — Devin runs autonomously
+
+### 401/403 from Devin API
+
+**Solution:**
+- Verify your `Devin:ApiKey` is correct and not expired
+- Check that your Devin account has API access enabled
+- Regenerate the key from the [Devin dashboard](https://app.devin.ai) if needed
+
+### AADSTS65001 Consent Error
+
+**Symptom:** Authentication fails with consent-related error during agent setup.
+
+**Solution:**
+- Ensure `aiTeammate: true` is set in `a365.config.json`
+- Re-run `a365 setup blueprint` — the CLI handles consent automatically for AI teammates
+- Verify the `--m365` flag is used when registering the endpoint
+
+## Support
+
+For issues, questions, or feedback:
+
+- **Issues**: Please file issues in the [GitHub Issues](https://github.com/microsoft/Agent365-dotnet/issues) section
+- **Documentation**: See the [Microsoft Agents 365 Developer documentation](https://learn.microsoft.com/en-us/microsoft-agent-365/developer/)
+- **Devin**: See [Devin documentation](https://docs.devin.ai)
+- **Security**: For security issues, please see [SECURITY.md](SECURITY.md)
+
+## Contributing
+
+This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit .
+
+When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.
+
+This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
+
+## Additional Resources
+
+- [Microsoft Agent 365 SDK - .NET repository](https://github.com/microsoft/Agent365-dotnet)
+- [Microsoft 365 Agents SDK - .NET repository](https://github.com/Microsoft/Agents-for-net)
+- [Devin API documentation](https://docs.devin.ai)
+- [Devin dashboard](https://app.devin.ai)
+- [.NET API documentation](https://learn.microsoft.com/dotnet/api/?view=m365-agents-sdk&preserve-view=true)
+
+## Trademarks
+
+*Microsoft, Windows, Microsoft Azure and/or other Microsoft products and services referenced in the documentation may be either trademarks or registered trademarks of Microsoft in the United States and/or other countries. The licenses for this project do not grant you rights to use any Microsoft names, logos, or trademarks. Microsoft's general trademark guidelines can be found at http://go.microsoft.com/fwlink/?LinkID=254653.*
+
+## License
+
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the MIT License - see the [LICENSE](../../../LICENSE.md) file for details.
diff --git a/dotnet/devin/sample-agent/appsettings.Playground.json b/dotnet/devin/sample-agent/appsettings.Playground.json
new file mode 100644
index 00000000..2295ae6b
--- /dev/null
+++ b/dotnet/devin/sample-agent/appsettings.Playground.json
@@ -0,0 +1,28 @@
+{
+ "TokenValidation": {
+ "Enabled": false,
+ "Audiences": [
+ ""
+ ],
+ "TenantId": ""
+ },
+ "Devin": {
+ "BaseUrl": "https://api.devin.ai/v1",
+ "ApiKey": "",
+ "PollingIntervalSeconds": 10,
+ "TimeoutSeconds": 300
+ },
+ "Connections": {
+ "ServiceConnection": {
+ "Settings": {
+ "AuthType": "ClientSecret",
+ "ClientId": "",
+ "ClientSecret": "",
+ "AuthorityEndpoint": "https://login.microsoftonline.com/",
+ "Scopes": [
+ "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default"
+ ]
+ }
+ }
+ }
+}
diff --git a/dotnet/devin/sample-agent/appsettings.json b/dotnet/devin/sample-agent/appsettings.json
new file mode 100644
index 00000000..9cbc2075
--- /dev/null
+++ b/dotnet/devin/sample-agent/appsettings.json
@@ -0,0 +1,71 @@
+{
+ "AgentApplication": {
+ "StartTypingTimer": false,
+ "RemoveRecipientMention": false,
+ "NormalizeMentions": false,
+ "UserAuthorization": {
+ "AutoSignin": false,
+ "Handlers": {
+ "agentic": {
+ "Type": "AgenticUserAuthorization",
+ "Settings": {
+ "AlternateBlueprintConnectionName": "ServiceConnection",
+ "Scopes": [
+ "https://graph.microsoft.com/.default"
+ ]
+ }
+ }
+ }
+ }
+ },
+ "TokenValidation": {
+ "Enabled": false,
+ "Audiences": [
+ ""
+ ],
+ "TenantId": ""
+ },
+ "Devin": {
+ "BaseUrl": "https://api.devin.ai/v1",
+ "ApiKey": "",
+ "PollingIntervalSeconds": 10,
+ "TimeoutSeconds": 300
+ },
+ "Connections": {
+ "ServiceConnection": {
+ "Settings": {
+ "AuthType": "ClientSecret",
+ "ClientId": "",
+ "ClientSecret": "",
+ "AuthorityEndpoint": "https://login.microsoftonline.com/",
+ "Scopes": [
+ "5a807f24-c9de-44ee-a3a7-329e88a00ffc/.default"
+ ],
+ "AgentId": ""
+ }
+ }
+ },
+ "ConnectionsMap": [
+ {
+ "ServiceUrl": "*",
+ "Connection": "ServiceConnection"
+ }
+ ],
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.Agents": "Information"
+ }
+ },
+ "EnableAgent365Exporter": true,
+ "Agent365Observability": {
+ "AgentId": "",
+ "AgentName": "",
+ "AgentDescription": "",
+ "TenantId": "",
+ "AgentBlueprintId": "",
+ "ClientId": "",
+ "ClientSecret": ""
+ }
+}
\ No newline at end of file
diff --git a/dotnet/devin/sample-agent/telemetry/A365OtelWrapper.cs b/dotnet/devin/sample-agent/telemetry/A365OtelWrapper.cs
new file mode 100644
index 00000000..364f96c5
--- /dev/null
+++ b/dotnet/devin/sample-agent/telemetry/A365OtelWrapper.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.Observability.Caching;
+using Microsoft.Agents.A365.Observability.Runtime.Common;
+using Microsoft.Agents.A365.Runtime.Utils;
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Builder.App.UserAuth;
+using Microsoft.Agents.Builder.State;
+
+namespace Agent365DevinSampleAgent.telemetry;
+
+public static class A365OtelWrapper
+{
+ public static async Task InvokeObservedAgentOperation(
+ string operationName,
+ ITurnContext turnContext,
+ ITurnState turnState,
+ IExporterTokenCache? agentTokenCache,
+ UserAuthorization authSystem,
+ string authHandlerName,
+ ILogger? logger,
+ Func func
+ )
+ {
+ await AgentMetrics.InvokeObservedAgentOperation(
+ operationName,
+ turnContext,
+ async () =>
+ {
+ (string agentId, string tenantId) = await ResolveTenantAndAgentId(turnContext, authSystem, authHandlerName);
+
+ using var baggageScope = new BaggageBuilder()
+ .TenantId(tenantId)
+ .AgentId(agentId)
+ .Build();
+
+ try
+ {
+ agentTokenCache?.RegisterObservability(agentId, tenantId, new AgenticTokenStruct
+ {
+ UserAuthorization = authSystem,
+ TurnContext = turnContext,
+ AuthHandlerName = authHandlerName
+ }, EnvironmentUtils.GetObservabilityAuthenticationScope());
+ }
+ catch (Exception ex)
+ {
+ logger?.LogWarning($"There was an error registering for observability: {ex.Message}");
+ }
+
+ await func().ConfigureAwait(false);
+ }).ConfigureAwait(false);
+ }
+
+ private static async Task<(string agentId, string tenantId)> ResolveTenantAndAgentId(ITurnContext turnContext, UserAuthorization authSystem, string authHandlerName)
+ {
+ string agentId = "";
+ if (turnContext.Activity.IsAgenticRequest())
+ {
+ agentId = turnContext.Activity.GetAgenticInstanceId();
+ }
+ else
+ {
+ if (authSystem != null && !string.IsNullOrEmpty(authHandlerName))
+ agentId = Utility.ResolveAgentIdentity(turnContext, await authSystem.GetTurnTokenAsync(turnContext, authHandlerName));
+ }
+ agentId = agentId ?? Guid.Empty.ToString();
+ string? tempTenantId = turnContext?.Activity?.Conversation?.TenantId ?? turnContext?.Activity?.Recipient?.TenantId;
+ string tenantId = tempTenantId ?? Guid.Empty.ToString();
+
+ return (agentId, tenantId);
+ }
+}
diff --git a/dotnet/devin/sample-agent/telemetry/AgentMetrics.cs b/dotnet/devin/sample-agent/telemetry/AgentMetrics.cs
new file mode 100644
index 00000000..24249971
--- /dev/null
+++ b/dotnet/devin/sample-agent/telemetry/AgentMetrics.cs
@@ -0,0 +1,133 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.Builder;
+using Microsoft.Agents.Core;
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace Agent365DevinSampleAgent.telemetry;
+
+public static class AgentMetrics
+{
+ public static readonly string SourceName = "A365.Devin";
+
+ public static readonly ActivitySource ActivitySource = new(SourceName);
+
+ private static readonly Meter Meter = new("A365.Devin", "1.0.0");
+
+ public static readonly Counter MessageProcessedCounter = Meter.CreateCounter(
+ "agent.messages.processed",
+ "messages",
+ "Number of messages processed by the agent");
+
+ public static readonly Counter RouteExecutedCounter = Meter.CreateCounter(
+ "agent.routes.executed",
+ "routes",
+ "Number of routes executed by the agent");
+
+ public static readonly Histogram MessageProcessingDuration = Meter.CreateHistogram(
+ "agent.message.processing.duration",
+ "ms",
+ "Duration of message processing in milliseconds");
+
+ public static readonly Histogram RouteExecutionDuration = Meter.CreateHistogram(
+ "agent.route.execution.duration",
+ "ms",
+ "Duration of route execution in milliseconds");
+
+ public static readonly UpDownCounter ActiveConversations = Meter.CreateUpDownCounter(
+ "agent.conversations.active",
+ "conversations",
+ "Number of active conversations");
+
+ public static Activity InitializeMessageHandlingActivity(string handlerName, ITurnContext context)
+ {
+ var activity = ActivitySource.StartActivity(handlerName);
+ activity?.SetTag("Activity.Type", context.Activity.Type.ToString());
+ activity?.SetTag("Agent.IsAgentic", context.IsAgenticRequest());
+ activity?.SetTag("Caller.Id", context.Activity.From?.Id);
+ activity?.SetTag("Conversation.Id", context.Activity.Conversation?.Id);
+ activity?.SetTag("Channel.Id", context.Activity.ChannelId?.ToString());
+ activity?.SetTag("Message.Text.Length", context.Activity.Text?.Length ?? 0);
+
+ activity?.AddEvent(new ActivityEvent("Message.Processed", DateTimeOffset.UtcNow, new()
+ {
+ ["Agent.IsAgentic"] = context.IsAgenticRequest(),
+ ["Caller.Id"] = context.Activity.From?.Id,
+ ["Channel.Id"] = context.Activity.ChannelId?.ToString(),
+ ["Message.Id"] = context.Activity.Id,
+ ["Message.Text"] = context.Activity.Text
+ }));
+ return activity!;
+ }
+
+ public static void FinalizeMessageHandlingActivity(Activity activity, ITurnContext context, long duration, bool success)
+ {
+ MessageProcessingDuration.Record(duration,
+ new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown"),
+ new("Channel.Id", context.Activity.ChannelId?.ToString() ?? "unknown"));
+
+ RouteExecutedCounter.Add(1,
+ new("Route.Type", "message_handler"),
+ new("Conversation.Id", context.Activity.Conversation?.Id ?? "unknown"));
+
+ if (success)
+ {
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ }
+ else
+ {
+ activity?.SetStatus(ActivityStatusCode.Error);
+ }
+ activity?.Stop();
+ activity?.Dispose();
+ }
+
+ public static Task InvokeObservedHttpOperation(string operationName, Func func)
+ {
+ using var activity = ActivitySource.StartActivity(operationName);
+ try
+ {
+ return func();
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new()
+ {
+ ["exception.type"] = ex.GetType().FullName,
+ ["exception.message"] = ex.Message,
+ ["exception.stacktrace"] = ex.StackTrace
+ }));
+ throw;
+ }
+ }
+
+ public static Task InvokeObservedAgentOperation(string operationName, ITurnContext context, Func func)
+ {
+ MessageProcessedCounter.Add(1);
+ var activity = InitializeMessageHandlingActivity(operationName, context);
+ var routeStopwatch = Stopwatch.StartNew();
+ try
+ {
+ return func();
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
+ activity?.AddEvent(new ActivityEvent("exception", DateTimeOffset.UtcNow, new()
+ {
+ ["exception.type"] = ex.GetType().FullName,
+ ["exception.message"] = ex.Message,
+ ["exception.stacktrace"] = ex.StackTrace
+ }));
+ throw;
+ }
+ finally
+ {
+ routeStopwatch.Stop();
+ FinalizeMessageHandlingActivity(activity, context, routeStopwatch.ElapsedMilliseconds, true);
+ }
+ }
+}
diff --git a/dotnet/devin/sample-agent/telemetry/AgentOTELExtensions.cs b/dotnet/devin/sample-agent/telemetry/AgentOTELExtensions.cs
new file mode 100644
index 00000000..6b108407
--- /dev/null
+++ b/dotnet/devin/sample-agent/telemetry/AgentOTELExtensions.cs
@@ -0,0 +1,126 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+
+namespace Agent365DevinSampleAgent.telemetry;
+
+public static class AgentOTELExtensions
+{
+ private const string HealthEndpointPath = "/health";
+ private const string AlivenessEndpointPath = "/alive";
+
+ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ builder.Services.AddOpenTelemetry()
+ .ConfigureResource(r => r
+ .Clear()
+ .AddService(
+ serviceName: "A365.Devin",
+ serviceVersion: "1.0.0",
+ serviceInstanceId: Environment.MachineName)
+ .AddAttributes(new Dictionary
+ {
+ ["deployment.environment"] = builder.Environment.EnvironmentName,
+ ["service.namespace"] = "Microsoft.Agents"
+ }))
+ .WithMetrics(metrics =>
+ {
+ metrics.AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddRuntimeInstrumentation()
+ .AddMeter("agent.messages.processed",
+ "agent.routes.executed",
+ "agent.conversations.active",
+ "agent.route.execution.duration",
+ "agent.message.processing.duration");
+ })
+ .WithTracing(tracing =>
+ {
+ tracing.AddSource(builder.Environment.ApplicationName)
+ .AddSource(
+ "A365.Devin",
+ "Microsoft.Agents.Builder",
+ "Microsoft.Agents.Hosting",
+ "A365.Devin.MyAgent",
+ "Microsoft.AspNetCore",
+ "System.Net.Http"
+ )
+ .AddAspNetCoreInstrumentation(tracing =>
+ {
+ tracing.Filter = context =>
+ !context.Request.Path.StartsWithSegments(HealthEndpointPath)
+ && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath);
+ tracing.RecordException = true;
+ tracing.EnrichWithHttpRequest = (activity, request) =>
+ {
+ activity.SetTag("http.request.body.size", request.ContentLength);
+ activity.SetTag("user_agent", request.Headers.UserAgent);
+ };
+ tracing.EnrichWithHttpResponse = (activity, response) =>
+ {
+ activity.SetTag("http.response.body.size", response.ContentLength);
+ };
+ })
+ .AddHttpClientInstrumentation(o =>
+ {
+ o.RecordException = true;
+ o.EnrichWithHttpRequestMessage = (activity, request) =>
+ {
+ activity.SetTag("http.request.method", request.Method);
+ activity.SetTag("http.request.host", request.RequestUri?.Host);
+ activity.SetTag("http.request.useragent", request.Headers?.UserAgent);
+ };
+ o.EnrichWithHttpResponseMessage = (activity, response) =>
+ {
+ activity.SetTag("http.response.status_code", (int)response.StatusCode);
+ var headerList = response.Content?.Headers?
+ .Select(h => $"{h.Key}={string.Join(",", h.Value)}")
+ .ToArray();
+
+ if (headerList is { Length: > 0 })
+ {
+ activity.SetTag("http.response.headers", headerList);
+ }
+ };
+ o.FilterHttpRequestMessage = request =>
+ !request.RequestUri?.AbsolutePath.Contains("health", StringComparison.OrdinalIgnoreCase) ?? true;
+ });
+ });
+
+ return builder;
+ }
+
+ public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder
+ {
+ builder.Services.AddHealthChecks()
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ public static WebApplication MapDefaultEndpoints(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapHealthChecks(HealthEndpointPath);
+
+ app.MapHealthChecks(AlivenessEndpointPath, new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
+ {
+ Predicate = r => r.Tags.Contains("live")
+ });
+ }
+
+ return app;
+ }
+}