Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 6 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

[![Web & API & SQL CI/CD](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-web-api-sql.yml/badge.svg)](https://github.com/goodtocode/agent-framework-quick-start/actions/workflows/gtc-agent-standalone-web-api-sql.yml)

Microsoft Agent Framework Quick-start is a 100% Microsoft, enterprise-ready starter kit for building modern, agentic applications with C#, Blazor (Fluent UI), and ASP.NET Core Web API. This solution demonstrates how to use the Microsoft Agent Framework to create a Copilot-style chat client, fully integrated with SQL Server for persistent storage of authors, chat sessions, and messages—all orchestrated through a clean architecture pattern.
Microsoft Agent Framework Quick-start is a enterprise-ready starter kit for building modern, agentic applications with C#, Blazor (Fluent UI), and ASP.NET Core Web API. This solution demonstrates how to use the Microsoft Agent Framework to create a Copilot-style chat client, fully integrated with SQL Server for persistent storage of authors, chat sessions, and messages—all orchestrated through a clean architecture pattern.

With built-in tools (plugins) for querying and managing your own data, automated Azure infrastructure (Bicep), and seamless CI/CD (GitHub Actions), this repo provides everything you need to build, deploy, and extend real-world AI-powered apps on a traditional .NET stack—no JavaScript, no raw HTML, just pure Blazor and Fluent UI. Perfect for teams looking to modernize with AI while leveraging familiar, pragmatic enterprise patterns.

Expand Down Expand Up @@ -257,26 +257,15 @@ dotnet user-secrets set "ConnectionStrings:DefaultConnection" "YOUR_SQL_CONNECTI

1. Open Windows Terminal in Powershell or Cmd mode
2. cd to root of repository
3. (Optional) If you have an existing database, scaffold current entities into your project

```
dotnet ef dbcontext scaffold "Data Source=localhost;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -t WeatherForecastView -c WeatherChannelContext -f -o WebApi
```

4. Create an initial migration
```
dotnet ef migrations add InitialCreate --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext
```

5. Develop new entities and configurations
6. When ready to deploy new entities and configurations
3. Deploy new entities and configurations to database

```
dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30"
```
7. When an entity changes, is created or deleted, create a new migration. Suggest doing this each new version.
4. When an entity changes, is created or deleted, create a new migration. Suggest doing this each new version.
```
dotnet ef migrations add v1.1.1 --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext
dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructure.SqlServer.csproj --startup-project .\src\Presentation.WebApi\Presentation.WebApi.csproj --context AgentFrameworkContext --connection "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=AgentFramework;Min Pool Size=3;MultipleActiveResultSets=True;Trusted_Connection=Yes;TrustServerCertificate=True;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30"
```

# Running the Application
Expand All @@ -286,9 +275,9 @@ Right-click Presentation.WebApi and select Set as Default Project
dotnet run --project src/Presentation.WebApi/Presentation.WebApi.csproj
```

## Open http://localhost:7777/swagger/index.html
## Open http://localhost:7175/swagger/index.html
Open Microsoft Edge or modern browser
Navigate to: http://localhost:7777/swagger/index.html in your browser to the Swagger API Interface
Navigate to: http://localhost:7175/swagger/index.html in your browser to the Swagger API Interface

# Github Actions for Azure IaC and CI/CD
## GitHub Actions (.github folder)
Expand Down
2 changes: 2 additions & 0 deletions data/Chat/Actor.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Select top 1000 *
From Actors
2 changes: 2 additions & 0 deletions data/Chat/ChatSessions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Select Top 1000 *
From ChatSessions
4 changes: 4 additions & 0 deletions src/.github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Copilot Instructions

## General Guidelines
- Use custom .NotEmpty("message") validation convention instead of .NotEmpty().WithMessage("message") in FluentValidation validators. The Goodtocode.Validation library extends FluentValidation with custom syntax where validation methods accept message as a parameter: .NotEmpty("message"), .NotEqual(value, "message"), etc. Do not use .WithMessage() - pass the message directly as a parameter to the validation method.
7 changes: 7 additions & 0 deletions src/Core.Application/Abstractions/ICurrentUserContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

public interface ICurrentUserContext
{
Guid OwnerId { get; }
Guid TenantId { get; }
}
20 changes: 20 additions & 0 deletions src/Core.Application/Abstractions/IRequiresUserContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Goodtocode.AgentFramework.Core.Domain.Auth;

namespace Goodtocode.AgentFramework.Core.Application.Abstractions;

/// <summary>
/// Marker interface for requests that require user context to be injected via pipeline behavior.
/// </summary>
/// <remarks>This interface is used to identify requests that need authenticated user information.
/// When a request implements this interface, the UserInfoBehavior pipeline will automatically
/// populate the <see cref="UserContext"/> property with the current user's context before
/// the request handler executes.</remarks>
public interface IRequiresUserContext
{
/// <summary>
/// Gets or sets the authenticated user's context.
/// </summary>
/// <remarks>This property is automatically populated by the pipeline behavior
/// before the request handler is invoked.</remarks>
IUserContext? UserContext { get; set; }
}
16 changes: 0 additions & 16 deletions src/Core.Application/Abstractions/IUserInfoRequest.cs

This file was deleted.

4 changes: 2 additions & 2 deletions src/Core.Application/Actor/CreateActorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class CreateActorCommand : IRequest<ActorDto>
public Guid TenantId { get; set; }
}

public class CreateAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<CreateActorCommand, ActorDto>
public class CreateActorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<CreateActorCommand, ActorDto>
{
private readonly IAgentFrameworkContext _context = context;

Expand All @@ -23,7 +23,7 @@ public async Task<ActorDto> Handle(CreateActorCommand request, CancellationToken
GuardAgainstEmptyOwnerId(request?.OwnerId);
GuardAgainstIdExists(_context.Actors, request!.Id);

var Actor = ActorEntity.Create(request!.Id == Guid.Empty ? Guid.NewGuid() : request!.Id, request.OwnerId, request.TenantId, request.FirstName, request.LastName, request.Email);
var Actor = ActorEntity.Create(request!.Id == Guid.Empty ? Guid.NewGuid() : request!.Id, request.FirstName, request.LastName, request.Email);
_context.Actors.Add(Actor);
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class DeleteActorByOwnerIdCommand : IRequest
public Guid OwnerId { get; set; }
}

public class DeleteAuthorByOwnerIdCommandHandler(IAgentFrameworkContext context) : IRequestHandler<DeleteActorByOwnerIdCommand>
public class DeleteActorByOwnerIdCommandHandler(IAgentFrameworkContext context) : IRequestHandler<DeleteActorByOwnerIdCommand>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/DeleteActorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class DeleteActorCommand : IRequest
public Guid Id { get; set; }
}

public class DeleteAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<DeleteActorCommand>
public class DeleteActorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<DeleteActorCommand>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/GetActorChatSessionQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class GetActorChatSessionQuery : IRequest<ChatSessionDto>
public Guid ChatSessionId { get; set; }
}

public class GetAuthorChatSessionQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionQuery, ChatSessionDto>
public class GetActorChatSessionQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionQuery, ChatSessionDto>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class GetActorChatSessionsPaginatedQuery : IRequest<PaginatedList<ChatSes
public int PageSize { get; init; } = 10;
}

public class GetAuthorChatSessionsPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionsPaginatedQuery, PaginatedList<ChatSessionDto>>
public class GetActorChatSessionsPaginatedQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionsPaginatedQuery, PaginatedList<ChatSessionDto>>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/GetActorChatSessionsQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class GetActorChatSessionsQuery : IRequest<ICollection<ChatSessionDto>>
public DateTime? EndDate { get; set; }
}

public class GetAuthorChatSessionsQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionsQuery, ICollection<ChatSessionDto>>
public class GetActorChatSessionsQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorChatSessionsQuery, ICollection<ChatSessionDto>>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/GetActorQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public class GetActorQuery : IRequest<ActorDto>
public Guid ActorId { get; set; }
}

public class GetAuthorQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorQuery, ActorDto>
public class GetActorQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetActorQuery, ActorDto>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down
8 changes: 4 additions & 4 deletions src/Core.Application/Actor/GetMyActorQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,18 @@

namespace Goodtocode.AgentFramework.Core.Application.Actor;

public class GetMyActorQuery : IRequest<ActorDto>, IUserInfoRequest
public class GetMyActorQuery : IRequest<ActorDto>, IRequiresUserContext
{
public IUserEntity? UserInfo { get; set; }
public IUserContext? UserContext { get; set; }
}

public class GetAuthorByOwnerIdQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetMyActorQuery, ActorDto>
public class GetActorByOwnerIdQueryHandler(IAgentFrameworkContext context) : IRequestHandler<GetMyActorQuery, ActorDto>
{
private readonly IAgentFrameworkContext _context = context;

public async Task<ActorDto> Handle(GetMyActorQuery request, CancellationToken cancellationToken)
{
var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserInfo!.OwnerId).FirstOrDefaultAsync(cancellationToken: cancellationToken);
var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserContext!.OwnerId).FirstOrDefaultAsync(cancellationToken: cancellationToken);
GuardAgainstNotFound(actor);

return ActorDto.CreateFrom(actor);
Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/GetMyActorQueryValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ public class GetMyActorQueryValidator : Validator<GetMyActorQuery>
{
public GetMyActorQueryValidator()
{
RuleFor(x => x.UserInfo).NotEmpty();
RuleFor(x => x.UserContext).NotEmpty();
}
}
10 changes: 5 additions & 5 deletions src/Core.Application/Actor/SaveMyActorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,32 @@

namespace Goodtocode.AgentFramework.Core.Application.Actor;

public class SaveMyActorCommand : IRequest<ActorDto>, IUserInfoRequest
public class SaveMyActorCommand : IRequest<ActorDto>, IRequiresUserContext
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Email { get; set; }
public Guid TenantId { get; set; }
public IUserEntity? UserInfo { get; set; }
public IUserContext? UserContext { get; set; }
}

public class SaveAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<SaveMyActorCommand, ActorDto>
public class SaveActorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<SaveMyActorCommand, ActorDto>
{
private readonly IAgentFrameworkContext _context = context;

public async Task<ActorDto> Handle(SaveMyActorCommand request, CancellationToken cancellationToken)
{
GuardAgainstEmptyTenantId(request?.TenantId);

var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserInfo!.OwnerId && x.TenantId == request.TenantId).FirstOrDefaultAsync(cancellationToken);
var actor = await _context.Actors.Where(x => x.OwnerId == request!.UserContext!.OwnerId && x.TenantId == request.TenantId).FirstOrDefaultAsync(cancellationToken);
if (actor is not null)
{
actor.Update(request?.FirstName, request?.LastName ?? actor.LastName, request?.Email);
_context.Actors.Update(actor!);
}
else
{
actor = ActorEntity.Create(Guid.NewGuid(), request!.UserInfo!.OwnerId, request.TenantId, request.FirstName, request.LastName, request.Email);
actor = ActorEntity.Create(Guid.NewGuid(), request?.FirstName, request?.LastName, request?.Email);
_context.Actors.Add(actor);
}

Expand Down
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/SaveMyActorCommandValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public class SaveMyActorCommandValidator : Validator<SaveMyActorCommand>
{
public SaveMyActorCommandValidator()
{
RuleFor(x => x.UserInfo).NotEmpty();
RuleFor(x => x.UserContext).NotEmpty();
RuleFor(x => x.TenantId).NotEmpty();
}
}
2 changes: 1 addition & 1 deletion src/Core.Application/Actor/UpdateActorCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class UpdateActorCommand : IRequest
public string Name { get; set; } = string.Empty;
}

public class UpdateAuthorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<UpdateActorCommand>
public class UpdateActorCommandHandler(IAgentFrameworkContext context) : IRequestHandler<UpdateActorCommand>
{
private readonly IAgentFrameworkContext _context = context;

Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,28 @@

namespace Goodtocode.AgentFramework.Core.Application.ChatCompletion;

public class CreateChatMessageCommand : IRequest<ChatMessageDto>, IUserInfoRequest
public class CreateMyChatMessageCommand : IRequest<ChatMessageDto>, IRequiresUserContext
{
public Guid Id { get; set; }
public Guid ChatSessionId { get; set; }
public string? Message { get; set; }
public IUserEntity? UserInfo { get; set; }
public IUserContext? UserContext { get; set; }
}

public class CreateChatMessageCommandHandler(AIAgent agent, IAgentFrameworkContext context) : IRequestHandler<CreateChatMessageCommand, ChatMessageDto>
public class CreateChatMessageCommandHandler(AIAgent agent, IAgentFrameworkContext context) : IRequestHandler<CreateMyChatMessageCommand, ChatMessageDto>
{
private readonly AIAgent _agent = agent;
private readonly IAgentFrameworkContext _context = context;

public async Task<ChatMessageDto> Handle(CreateChatMessageCommand request, CancellationToken cancellationToken)
public async Task<ChatMessageDto> Handle(CreateMyChatMessageCommand request, CancellationToken cancellationToken)
{
GuardAgainstSessionNotFound(_context.ChatSessions, request!.ChatSessionId);
GuardAgainstEmptyMessage(request?.Message);
GuardAgainstIdExists(_context.ChatMessages, request!.Id);
GuardAgainstEmptyUser(request?.UserInfo);
GuardAgainstUnauthorizedUser(_context.ChatSessions, request!.UserInfo!);
GuardAgainstEmptyUser(request?.UserContext);

var chatSession = _context.ChatSessions.Find(request.ChatSessionId);
var chatSession = _context.ChatSessions.Find(request!.ChatSessionId);
GuardAgainstSessionNotFound(chatSession);
GuardAgainstUnauthorizedUser(chatSession!, request.UserContext!);

var chatHistory = new List<ChatMessage>();
foreach (ChatMessageEntity message in chatSession!.Messages)
Expand Down Expand Up @@ -69,13 +69,10 @@ public async Task<ChatMessageDto> Handle(CreateChatMessageCommand request, Cance
return ChatMessageDto.CreateFrom(chatMessage);
}

private static void GuardAgainstSessionNotFound(DbSet<ChatSessionEntity> dbSet, Guid sessionId)
private static void GuardAgainstSessionNotFound(ChatSessionEntity? chatSession)
{
if (sessionId != Guid.Empty && !dbSet.Any(x => x.Id == sessionId))
throw new CustomValidationException(
[
new("ChatSessionId", "Chat Session does not exist")
]);
if (chatSession == null)
throw new CustomNotFoundException("Chat Session Not Found");
}

private static void GuardAgainstEmptyMessage(string? message)
Expand All @@ -93,23 +90,19 @@ private static void GuardAgainstIdExists(DbSet<ChatMessageEntity> dbSet, Guid id
throw new CustomConflictException("Id already exists");
}

private static void GuardAgainstEmptyUser(IUserEntity? userInfo)
private static void GuardAgainstEmptyUser(IUserContext? userContext)
{
if (userInfo == null || userInfo.OwnerId == Guid.Empty || userInfo.TenantId == Guid.Empty)
if (userContext == null || userContext.OwnerId == Guid.Empty || userContext.TenantId == Guid.Empty)
throw new CustomValidationException(
[
new("UserInfo", "User information is required to create a chat message")
new("UserContext", "User information is required to create a chat message")
]);
}

private static void GuardAgainstUnauthorizedUser(DbSet<ChatSessionEntity> dbSet, IUserEntity userInfo)
private static void GuardAgainstUnauthorizedUser(ChatSessionEntity chatSession, IUserContext userContext)
{
bool isAuthorized = dbSet.Any(x => x.Actor != null && x.Actor.OwnerId == userInfo.OwnerId);
if (!isAuthorized)
throw new CustomValidationException(
[
new("UserInfo", "User is not authorized to create a chat message in this session")
]);
if (chatSession.OwnerId != userContext.OwnerId)
throw new CustomForbiddenAccessException("ChatSession", chatSession.Id);
}

private static void GuardAgainstNullAgentResponse(ChatMessage? response)
Expand Down
Loading
Loading