In the AI and natural language processing world, building a solid chat interface can be tricky. When we started with Azure's Conversational Language Understanding (CLU), we wanted to make a system that could really get what users were saying and respond well. We did this by linking the predicted "TopIntent" directly to certain handler methods. This way, we made it easier to figure out what users wanted and do the right thing, making the interaction smooth.
public async Task ProcessIntentAsync(ITurnContext turnContext, CancellationToken cancellationToken)
{
var query = turnContext.Activity.Text;
var userId = turnContext.Activity.From.Id;
var tenantId = await _tenantResolver.GetTenantIdForUser(userId);
var cluResponse = await _conversationsClient.AnalyzeConversationAsync(query);
if (!await _permissionChecker.HasPermissionForIntent(userId, tenantId, cluResponse.TopIntent))
{
await turnContext.SendActivityAsync($"You don't have permission to access {prediction.TopIntent}.");
return;
}
var intentProcessor = _intentFactory.GetProcessor(cluResponse.TopIntent);
if (intentProcessor != null)
{
await intentProcessor.ProcessAsync(turnContext, cluResponse.Entities);
}
else
{
await turnContext.SendActivityAsync("I'm not sure how to help with that.");
}
}
Azure CLU Limitations: Why We Needed More
While this implementation worked, we quickly encountered several limitations:
We had to define explicit entities for each parameter and handle missing entities manually:
private async Task HandleAgentPerformanceIntent(ITurnContext turnContext, List<Entity> entities)
{
var agentEntity = entities.FirstOrDefault(e => e.Category == "AgentName");
if (agentEntity == null)
{
await turnContext.SendActivityAsync("Please specify an agent name.");
return;
}
var dateRangeEntity = entities.FirstOrDefault(e => e.Category == "DateRange");
if (dateRangeEntity == null)
{
await turnContext.SendActivityAsync("Please specify a time period.");
return;
}
}
Maintenance Burden
Each new capability required extensive changes across multiple systems:
Creating and training a new CLU intent with dozens of examples
Adding entity definitions for any parameters
Adding a new case to our switch statement
Implementing a new handler method with parameter extraction
Adding permission checks specific to that intent
Contextual Amnesia
The bot couldn't maintain conversation context or understand follow-up questions:
First query: "What was my busiest queue last month?"
Bot processes GetBusiestQueue intent successfully
Follow-up query: "And what about December?"
CLU has no context from previous query, so this likely fails or matches a different intent entirely
Semantic Kernel Approach: Dynamic Function Selection
With Semantic Kernel, we eliminated hard-coded intent matching in favor of AI-powered function selection:
public class QueueAnalyticsPlugin
{
[KernelFunction, Description("Gets statistics for the busiest queue within a specified time period.")]
[Parameter("dateRange", "The date range to analyze (e.g., 'last month', 'yesterday', etc.)")]
public async Task<string> GetBusiestQueue(string dateRange, KernelArguments arguments)
{
}
[KernelFunction, Description("Compares call volumes between two queues over a time period.")]
[Parameter("queue1", "The first queue to compare")]
[Parameter("queue2", "The second queue to compare")]
[Parameter("dateRange", "The date range to analyze")]
public async Task<string> CompareQueues(string queue1, string queue2, string dateRange, KernelArguments arguments)
{
}
}
This approach allows the LLM to understand the user's intent and select the most appropriate function, even with varied phrasing and follow-up questions, while maintaining the same security guarantees.
The Semantic Kernel Plugin Architecture
Semantic Kernel allowed us to create a more powerful bot by organizing functionality into plugins that the LLM can intelligently select based on user intent.
public class SemanticKernelBot
{
private readonly Kernel _kernel;
private readonly IPermissionService _permissionService;
public SemanticKernelBot(Kernel kernel, IPermissionService permissionService)
{
_kernel = kernel;
_permissionService = permissionService;
RegisterPlugins();
}
private void RegisterPlugins()
{
_kernel.Plugins.AddFromObject(new QueueAnalyticsPlugin(_permissionService), "QueueAnalytics");
_kernel.Plugins.AddFromObject(new AgentPerformancePlugin(_permissionService), "AgentPerformance");
_kernel.Plugins.AddFromObject(new CallStatisticsPlugin(_permissionService), "CallStatistics");
}
}
Plugin Implementation with Security Checks
Each plugin function automatically enforces security checks before accessing any data:
public class QueueAnalyticsPlugin
{
private readonly IMediator _mediator;
private readonly IPermissionService _permissionService;
public QueueAnalyticsPlugin(IMediator mediator, IPermissionService permissionService)
{
_mediator = mediator;
_permissionService = permissionService;
}
[KernelFunction, Description("Gets statistics for the busiest queue within a specified time period.")]
[Parameter("dateRange", "The date range to analyze (e.g., 'last month', 'yesterday', etc.)")]
public async Task<string> GetBusiestQueue(string dateRange, KernelArguments arguments)
{
var tenantId = arguments["tenantId"] as string;
var userId = arguments["userId"] as string;
if (!await _permissionService.HasPermission(userId, tenantId, Permission.ViewQueueAnalytics))
{
return "You don't have permission to view queue analytics. Please contact your administrator.";
}
var (startDate, endDate) = ParseDateRange(dateRange);
var result = await _mediator.Send(new GetBusiestQueueQuery(tenantId, startDate, endDate));
return $"The busiest queue between {startDate:d} and {endDate:d} was {result.QueueName} with {result.CallCount} calls.";
}
}
Reusing Existing Business Logic
A key advantage is that each plugin simply calls our existing mediator-based business logic:
var result = await _mediator.Send(new GetBusiestQueueQuery(tenantId, startDate, endDate));
This means:
No duplication of business logic
Existing security checks remain in place
All tenant isolation guarantees are maintained
Natural Language Understanding with Context
Semantic Kernel enables the bot to maintain context across the conversation:
public async Task HandleMessageAsync(ITurnContext turnContext)
{
var userQuery = turnContext.Activity.Text;
var userId = turnContext.Activity.From.Id;
var chatHistory = await _chatHistoryRepository.GetForUser(userId);
var kernelArguments = new KernelArguments
{
["userId"] = userId,
["tenantId"] = await _tenantResolver.GetTenantIdForUser(userId),
["history"] = chatHistory.ToString()
};
var result = await _kernel.InvokePromptAsync(userQuery, kernelArguments);
await turnContext.SendActivityAsync(result.ToString());
}
This allows natural follow-up questions:
User: "What was my busiest queue last month?"
Bot: "The busiest queue in January was Support with 1,245 calls."
User: "What about December?"
Bot: "In December, the busiest queue was Sales with 982 calls."
The Best of Both Worlds: Hybrid Approach
While Semantic Kernel excels at conversation, CLU is still better for specific structured outputs like Adaptive Cards:
public async Task HandleMessageAsync(ITurnContext turnContext)
{
var userQuery = turnContext.Activity.Text;
var cluResult = await _languageClient.PredictAsync(userQuery);
if (cluResult.TopIntent == "MostBusyAgent" && cluResult.TopScore > 0.7)
{
var card = await _cardGenerator.CreateMostBusyAgentAdaptiveCard(cluResult.Entities);
await turnContext.SendActivityAsync(MessageFactory.Attachment(card));
return;
}
await HandleWithSemanticKernel(turnContext);
}
Multi-tenancy and Security: Never Compromised
Every function implements permission checks before accessing any data:
[KernelFunction]
public async Task<string> GetAgentPerformance(string agentName, string dateRange, KernelArguments arguments)
{
var tenantId = arguments["tenantId"] as string;
var userId = arguments["userId"] as string;
if (!await _permissionService.HasPermission(userId, tenantId, Permission.ViewAgentData))
{
return "You don't have permission to view agent performance data.";
}
if (!await _agentRepository.BelongsToTenant(agentName, tenantId))
{
return $"No agent named '{agentName}' was found in your organization.";
}
}
These checks ensure:
Users can only access data they're authorized to view
No cross-tenant data leakage can occur
All requests are properly scoped to the user's tenant
Conclusion
Transitioning to Semantic Kernel transformed the Clobba Teams Bot into a more powerful, context-aware assistant while maintaining strict security guarantees.
The key advantages:
Powerful natural language understanding that handles variations in phrasing
Contextual awareness that maintains conversation state
Security-first approach with permission checks in every function
Tenant isolation guarantees to prevent data leakage
Simplified development through plugin architecture
By combining the strengths of Semantic Kernel and Azure CLU, we've created a bot that's both more powerful and more secure—giving users a modern AI experience without compromising on security.