Building a Simple Semantic Kernel Agent in C#

Introduction

Microsoft’s Semantic Kernel is a powerful framework that enables developers to integrate large language models (LLMs) into their applications seamlessly. Whether you’re building chatbots, content generators, or intelligent automation tools, Semantic Kernel provides the building blocks to create sophisticated AI-powered agents.

In this post, we’ll explore how to build a simple yet effective Semantic Kernel agent in C# that can understand user requests, plan actions, and execute tasks autonomously.

What is Semantic Kernel?

Semantic Kernel is an open-source SDK that allows developers to:

  • Integrate AI services like OpenAI GPT, Azure OpenAI, and other language models
  • Create plugins that extend AI capabilities with custom functions
  • Build AI agents that can plan and execute multi-step tasks
  • Combine traditional programming with AI-powered natural language processing

Think of it as a bridge between your application logic and AI services, providing a structured way to build intelligent applications.

Why Do You Need an Agent?

Traditional AI integrations often involve simple request-response patterns. However, agents take this a step further by:

  • Autonomous Decision Making: Agents can analyze user requests and determine the best course of action
  • Multi-step Planning: They can break down complex tasks into smaller, manageable steps
  • Tool Integration: Agents can use various tools and APIs to accomplish goals
  • Context Awareness: They maintain conversation context and can reference previous interactions

Building a Simple Semantic Kernel Agent

Let’s create a basic agent that can help with file operations and web searches. Here’s a minimal working example:

Step 1: Install Required Packages

First, install the necessary NuGet packages:

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Plugins.Core

Step 2: Create the Agent

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using System.ComponentModel;

public class SimpleSemanticKernelAgent
{
    private readonly Kernel _kernel;
    private readonly IChatCompletionService _chatService;
    private readonly ChatHistory _chatHistory;

    public SimpleSemanticKernelAgent(string apiKey, string model = "gpt-3.5-turbo")
    {
        // Create kernel builder
        var builder = Kernel.CreateBuilder();

        // Add OpenAI chat completion service
        builder.AddOpenAIChatCompletion(model, apiKey);

        // Add plugins
        builder.Plugins.AddFromType<FileOperationsPlugin>();
        builder.Plugins.AddFromType<WebSearchPlugin>();

        // Build kernel
        _kernel = builder.Build();

        // Get chat completion service
        _chatService = _kernel.GetRequiredService<IChatCompletionService>();

        // Initialize chat history
        _chatHistory = new ChatHistory();
        _chatHistory.AddSystemMessage(
            "You are a helpful assistant that can perform file operations and web searches. " +
            "When users ask for help, analyze their request and use the available tools to assist them.");
    }

    public async Task<string> ProcessUserRequestAsync(string userInput)
    {
        try
        {
            // Add user message to history
            _chatHistory.AddUserMessage(userInput);

            // Configure execution settings
            var executionSettings = new OpenAIPromptExecutionSettings
            {
                ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions,
                MaxTokens = 1000,
                Temperature = 0.7
            };

            // Get response from the agent
            var response = await _chatService.GetChatMessageContentAsync(
                _chatHistory, 
                executionSettings, 
                _kernel);

            // Add assistant response to history
            _chatHistory.AddAssistantMessage(response.Content ?? "");

            return response.Content ?? "I'm sorry, I couldn't process your request.";
        }
        catch (Exception ex)
        {
            return $"An error occurred: {ex.Message}";
        }
    }
}

// Example plugin for file operations
public class FileOperationsPlugin
{
    [KernelFunction, Description("Read content from a text file")]
    public async Task<string> ReadFileAsync(
        [Description("Path to the file to read")] string filePath)
    {
        try
        {
            if (!File.Exists(filePath))
                return "File not found.";

            return await File.ReadAllTextAsync(filePath);
        }
        catch (Exception ex)
        {
            return $"Error reading file: {ex.Message}";
        }
    }

    [KernelFunction, Description("Write content to a text file")]
    public async Task<string> WriteFileAsync(
        [Description("Path to the file to write")] string filePath,
        [Description("Content to write to the file")] string content)
    {
        try
        {
            await File.WriteAllTextAsync(filePath, content);
            return "File written successfully.";
        }
        catch (Exception ex)
        {
            return $"Error writing file: {ex.Message}";
        }
    }
}

// Example plugin for web search (simplified)
public class WebSearchPlugin
{
    [KernelFunction, Description("Search the web for information")]
    public async Task<string> SearchWebAsync(
        [Description("Search query")] string query)
    {
        // In a real implementation, you would integrate with a search API
        // like Bing Search API, Google Custom Search, etc.
        await Task.Delay(1000); // Simulate API call

        return $"Search results for '{query}': [This is a simplified example. " +
               "In a real implementation, you would return actual search results.]";;
    }
}

Step 3: Using the Agent

class Program
{
    static async Task Main(string[] args)
    {
        // Initialize the agent with your OpenAI API key
        var agent = new SimpleSemanticKernelAgent("your-openai-api-key-here");

        Console.WriteLine("Semantic Kernel Agent initialized. Type 'exit' to quit.");

        while (true)
        {
            Console.Write("\nYou: ");
            var input = Console.ReadLine();

            if (input?.ToLower() == "exit")
                break;

            if (string.IsNullOrWhiteSpace(input))
                continue;

            Console.Write("Agent: ");
            var response = await agent.ProcessUserRequestAsync(input);
            Console.WriteLine(response);
        }
    }
}

Important Tips for Success

When building Semantic Kernel agents, keep these best practices in mind:

1. Design Clear Function Descriptions

  • Use descriptive function names and detailed descriptions
  • Provide clear parameter descriptions
  • Include examples in your documentation

2. Handle Errors Gracefully

  • Always wrap plugin functions in try-catch blocks
  • Return meaningful error messages
  • Log errors for debugging purposes

3. Optimize Performance

  • Use appropriate token limits to control costs
  • Implement caching for frequently used data
  • Consider using streaming responses for long operations

4. Security Considerations

  • Validate all inputs to your plugins
  • Implement proper authentication and authorization
  • Be cautious with file system access and external API calls
  • Never expose sensitive information in function descriptions

5. Testing and Monitoring

  • Test your agent with various input scenarios
  • Monitor token usage and API costs
  • Implement logging to track agent behavior
  • Use A/B testing to improve agent responses

Summary

Semantic Kernel agents represent a powerful way to build intelligent applications that can understand natural language, plan actions, and execute tasks autonomously. The example we’ve built demonstrates the core concepts:

  • Kernel Configuration: Setting up the AI service and plugins
  • Plugin Development: Creating custom functions the agent can use
  • Conversation Management: Maintaining context across interactions
  • Error Handling: Gracefully managing failures and edge cases

With these foundations, you can extend the agent to support more complex scenarios, integrate with additional APIs, and create sophisticated AI-powered applications that truly understand and assist your users.

The future of software development increasingly involves AI collaboration, and Semantic Kernel provides an excellent framework for building these intelligent partnerships. Start simple, iterate quickly, and gradually add more capabilities as your understanding and requirements grow.

Use TOTP for securing API Requests

Did you know that if APIs are left unprotected, anyone can use them, potentially resulting in numerous calls that can bring down the API (DoS/DDoS Attack) or even update the data without the user’s consent?

Let’s look at this from the perspective of a curious developer. Sometimes, I only want to trace the network request in the browser by launching the Dev Tools (commonly by pressing the F12 key) and looking at the Network Tab.

In the past, WebApps were directly linked with Sessions. Based on whether a session is valid, the request would go through. If no request is performed in a given time frame, it would simply exhaust the session in 20 minutes (default in some servers unless configured). Now, we build WebApps purely on the client side, allowing them to consume Rest-based APIs. Our services are strictly API-first because it will enable us to scale them quickly and efficiently. Modern WebApps are built using frameworks like ReactJS, NextJS, Angular, and VueJS, resulting in single-page applications (SPA) that are purely client-side.

Let’s look at this technically: HTTP is a Stateless Protocol. This means the server doesn’t need to remember anything between requests when using HTTP. It just receives a URL and some headers and sends back data.

We use attributes like [Authorize] in our Asp.Net Core-based Web APIs to authorize them securely. This involves generating JWT (JSON Web Tokens) and sending them along with the Login Response to be stored on the Client Side. Any future requests include the JWT Token, which gets automatically validated. JWTs are an open, industry-standard RFC 7519 method for representing claims securely between two parties and can be used with various technologies beyond Asp.Net Core, such as Spring.

When you send the JWT back to the server, it’s typically sent using the Header in subsequent requests. The server generally looks for the Authorization Header values, which comprise the keyword Bearer, followed by the JWT Token.

<code>Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFudWJoYXYgUmFuamFuIiwiaWF0IjoxNTE2MjM5MDIyfQ.ALU4G8LdHbt6FCqxtr2hgfJw1RR7nMken2x0SC_hZ3g</code>

The above is an example of the token being sent. You can copy the JWT Token and visit https://jwt.io to check its values.

If I missed something about JWT, feel free to comment.

JWT Decoded

This is one of the reasons why I thought of protecting my APIs. I stumbled upon features like Remember Me when developing a WebApp using Asp.Net Core WebAPI as the backend and ReactJS as the front end. Although a simple feature, Remember Me could be implemented in various ways. Initially, I thought, why complicate things with cookies? I will be building mobile apps for the site anyway! However, it always bugged me that my JWT was stored in LocalStorage. The reason is simple: I can have users for this website ranging from someone who has zero knowledge of how it works to someone like me or, at worst, a potential hacker. A simple attack vector is impersonation if my JWT token is accessed. Any JavaScript can easily access this token stored in Local Storage. Due to this, I thought, yes, we can save the JWT in a Cookie sent from the Server, but it needs properties like HttpOnly, etc. But what if my API is used from a Mobile App? Considering the Cookie, it’s not a norm to extract the said token from a Cookie (although it is doable). Thus, I started looking into TOTP.

Now, let’s explore TOTP (Time-based One-Time Password) and its role in securing API requests. TOTP authenticates users based on a shared secret key and the current time. It generates a unique, short-lived password that changes every 30 seconds.

Have you heard of TOTP before? It’s the same thing when you use your Microsoft Authenticator or Google Authenticator Apps to provide a 6-digit code for Login using 2FA (2-Factor Authentication).

Why Use TOTP for API Security?

While JWTs provide a robust mechanism for user authentication and session management, they are not immune to attacks, especially if the tokens are stored insecurely or intercepted during transmission. TOTP adds an extra layer of security by requiring a time-based token in addition to the JWT. This ensures that even if a JWT is compromised, the attacker still needs the TOTP to authenticate, significantly reducing the risk of unauthorized access.

Implementing TOTP in APIs

Here’s a high-level overview of how to implement TOTP for API requests:

1. Generate a Shared Secret: When a user registers or logs in, generate a TOTP secret key dynamically, hidden from any storage. This key is used to create TOTP tokens.

2. TOTP Token Generation: Use libraries to generate TOTP tokens based on the shared secret and the current time.

3. API Request Validation: On the server side, validate the incoming JWT as usual. Additionally, the TOTP token is required in the request header or body. Validate the TOTP token using the same shared secret and the current time.

<code>// Example code snippet for validating TOTP in Node.js

const speakeasy = require('speakeasy');

// Secret stored on the server
const secret = 'KZXW6YPBOI======';

function validateTOTP(token) {
  const verified = speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: token,
  });
  return verified;
}

// On receiving an API request
const tokenFromClient = '123456'; // TOTP token from client
if (validateTOTP(tokenFromClient)) {
  console.log('TOTP token is valid!');
} else {
  console.log('Invalid TOTP token!');
}
</code>

Using TOTP ensures that even if the JWT is compromised, unauthorized access is prevented because the TOTP token, which changes every 30 seconds, is required.

I have published two libraries for Authentication using TOTP on NuGet and NPM:

Deconstructing TV Shows Reminder

TV Shows Reminder - Get Reminders for your Favorite TV Shows
TV Shows Reminder - Get Reminders for your Favorite TV Shows

In this post, we’ll dive deep into how TV Shows Reminder is architected, exploring everything from the frontend to backend, infrastructure choices, and integrations that make the WebApp perform smoothly.

Introduction

TV Shows Reminder is designed to help users effortlessly keep track of their favorite TV shows, receiving timely notifications about upcoming episodes. The architecture behind this app blends modern frontend technologies, robust backend services, and cloud infrastructure, ensuring scalability, performance, and security.

Architecture Overview

At a high level, TV Shows Reminder employs a microservices-inspired architecture. The frontend uses ReactJS with Redux for state management, the backend relies on a .NET WebAPI, and Strapi is utilized as a separate content management service, all orchestrated seamlessly through Cloudflare’s infrastructure and various Azure services.

Frontend: ReactJS, Redux, TailwindCSS

The frontend is built with ReactJS, providing a responsive and dynamic user experience. TypeScript ensures type safety and robustness, minimizing bugs at compile-time. TailwindCSS offers a highly maintainable styling solution.

State management is streamlined with Redux, offering predictable state transitions. Data fetched from APIs and search results are cached in local storage, significantly enhancing response times.

The app leverages Cloudflare Pages for hosting, combined with Cloudflare Workers and Workers KV for serving static data such as show details, seasons, and episodes, minimizing backend hits and ensuring rapid content delivery.

Backend and Data Management

The backend services are powered by ASP.NET WebAPI (.NET 8), hosted on Ubuntu servers. Crucial data is cached using Redis, dramatically improving response times and minimizing database latency.

Strapi acts as a separate microservice, managing user-related information and homepage content. This modular approach helps maintain separation of concerns, easy updates, and better security by abstracting unnecessary details away from the frontend.

Firebase Authentication simplifies user credential management, eliminating the overhead of storing sensitive data on internal servers.

Image Handling and Optimization

Images are managed via Imbo, an image server deployed on DigitalOcean Spaces. Imbo offers real-time image resizing and manipulation capabilities, ensuring optimal image delivery speed and size.

Metadata for image lifecycle management is stored in Azure Table Storage, where image identifiers track images older than 60 days, enabling timely cleanup. Meanwhile, Imbo maintains duplicate copies of these images in DigitalOcean Spaces until deletion, ensuring consistency and availability.

Search and External Integrations

Search functionality integrates directly with TMDB’s robust search engine. Results are combined with optimized images from Imbo, ensuring accuracy and visual appeal.

When searches occur, resulting show IDs are queued into Azure Service Bus. This action triggers a .NET-based Worker Service deployed on a separate Ubuntu server. This service fetches detailed show data and updated images through imgcdn.in, communicating with both Imbo and the primary .NET WebAPI.

Notification Management

Notifications are orchestrated via Azure Logic Apps, triggered every 12 hours, and run on Azure App Services. This service processes upcoming shows and user subscriptions to generate personalized email notifications.

To prevent duplication and ensure robustness, notification data is first stored in MongoDB. Emails are dispatched primarily through SendGrid, with AWS Simple Email Service (SES) serving as a reliable backup.

Furthermore, OneSignal enables browser-based push notifications, extending user engagement beyond emails.

Security and Performance

Security is integral to the system architecture. JWT tokens and TOTP codes protect API endpoints, preventing replay attacks and ensuring authenticated access.

Redis caching dramatically reduces latency, enabling faster response times from backend services. Cloudflare Workers play an additional crucial role, managing caching and API security efficiently, offering protection against common web vulnerabilities.

Lessons Learned and Future Improvements

Building TV Shows Reminder provided several key insights:

  • Separation of frontend and backend services significantly eases development and maintenance.
  • Leveraging dedicated microservices like Strapi can significantly simplify content and user-data management.
  • Caching and image optimization considerably enhance performance and scalability.

Looking ahead, there is potential for:

  • Enhanced automation and refinement in image lifecycle management.
  • Integration of machine learning for personalized user recommendations.
  • Continuous improvements to the notification engine to deliver even more targeted and timely alerts.

Conclusion

TV Shows Reminder exemplifies a well-thought-out, scalable architecture using modern frontend frameworks, robust backend services, and strategic cloud integrations. The blend of best practices ensures an optimal user experience and a maintainable codebase poised for future growth and enhancements.