When I first started building Semantic Kernel plugins, I learned quickly that writing good plugins isn't just about correct C# syntax. The LLM needs to understand when and how to call your functions, and that means following Semantic Kernel plugin best practices is critical. Your function description isn't just documentation -- it's the API contract that the LLM uses to decide whether to invoke your tool. A vague description means your plugin won't get called at the right time. Poor error handling breaks the entire agent loop. In this article, I'm sharing the patterns and best practices I've developed for production Semantic Kernel plugin development that ensure your plugins work reliably with LLM-driven orchestration.
Semantic Kernel Plugin Best Practices: Write Descriptions That Actually Work
The most important Semantic Kernel plugin best practice is writing clear, specific descriptions. When you use the [Description] attribute on functions and parameters, you're not writing comments for human developers -- you're writing instructions for the LLM. The LLM reads these descriptions to decide when to call your function and what arguments to pass. If your description is vague or missing, the LLM won't know when your tool is appropriate, and your plugin will sit unused.
I see this mistake constantly: developers write descriptions like "Gets product" or "Sends email" and wonder why the LLM never calls their functions. The LLM needs context about what the function does, what data it returns, and when it should be used.
Here's what bad versus good descriptions look like in practice:
// Bad vs good descriptions
public class ProductPlugin
{
// ❌ Bad: vague description -- LLM won't know when to call this
[KernelFunction("get_product")]
[Description("Gets product")]
public string GetProductBad(string id) => "...";
// ✅ Good: specific description helps LLM call correctly
[KernelFunction("get_product_details")]
[Description("Retrieves complete product information including name, price, stock level, and description for a given product ID")]
public async Task<string> GetProductDetailsAsync(
[Description("The unique product identifier (SKU), e.g. 'WIDGET-001'")] string productId,
[Description("If true, includes inventory data from all warehouses. Defaults to false.")] bool includeInventory = false)
{
// Implementation
return $"Product {productId}: Widget Pro, $29.99, In Stock: 142 units";
}
}
Notice how the good example describes what information the function returns and provides an example format for the productId parameter. The LLM uses these details to construct valid function calls. Parameter descriptions are equally important -- the LLM needs to know what format and type of data to provide.
When writing descriptions, I ask myself: "If I were the LLM, would I know exactly when to call this function and what arguments to pass?" If the answer is no, I rewrite the description with more specifics.
Best Practice 2: One Responsibility Per Plugin Class
A common mistake I see is cramming unrelated functions into a single plugin class. Developers create a "UtilityPlugin" that does weather lookups, sends emails, and calculates taxes. This violates the single responsibility principle and makes your plugin harder for the LLM to understand.
Each plugin class should represent a cohesive capability group. When creating custom Semantic Kernel plugins, I follow the "plugin equals capability group" rule. If you're building email functionality, create an EmailPlugin with functions like SendEmail, GetInbox, and MarkAsRead. If you're building product search, create a ProductPlugin with SearchProducts, GetProductDetails, and CheckInventory.
This organization helps the LLM understand the scope of each plugin. When the kernel loads your plugins, each plugin name becomes part of the tool schema the LLM sees. A well-named, focused plugin tells the LLM "this is the email toolset" or "this is the product information toolset." These semantic kernel plugin best practices for organizing code make a real difference in production systems.
My naming convention is straightforward: the plugin class name should describe the capability domain, and function names should be action-oriented verbs. ProductPlugin contains product-related functions. OrderPlugin contains order management functions. This makes the plugin's purpose immediately clear to both the LLM and human developers.
Keeping plugins focused also makes testing easier. A plugin with a single responsibility has fewer dependencies and clearer boundaries. You can test the plugin in isolation without needing to mock ten different services.
Best Practice 3: Return Serializable, Readable Strings
This is one of the most counterintuitive Semantic Kernel plugin best practices for traditional .NET developers. In typical C# development, we return strongly-typed objects. But in Semantic Kernel plugin development, your return value needs to be readable by the LLM -- and LLMs read text, not object graphs.
When your plugin function completes, the LLM receives the return value as part of the function calling result. If you return a complex object, it gets serialized to JSON, and the LLM has to parse that structure. It's better to return a clear string that describes the result in natural language or structured text.
Instead of returning Product objects, return strings like "Product WIDGET-001: Widget Pro, $29.99, In Stock: 142 units." Instead of returning an order object, return "Order #12345 placed successfully. Total: $199.99. Estimated delivery: March 15, 2026."
This doesn't mean you can't use structured data internally. Your plugin can query databases, call APIs, and work with complex objects. Just serialize the final result into a readable string format before returning.
If you need to return multiple items, format them as a readable list or table format. The LLM can parse structured text like numbered lists or CSV-style data. The key is making the return value immediately useful to the LLM without requiring additional parsing logic.
Best Practice 4: Handle Errors Gracefully
Here's a critical rule: never throw exceptions from plugin functions. When a plugin function throws an exception, it breaks the LLM's tool-calling loop. The agent can't recover gracefully because it receives an error message instead of a result it can reason about.
Instead of throwing exceptions, return error strings that the LLM can read and respond to. This allows the LLM to adjust its strategy, try a different approach, or ask the user for clarification. The LLM is surprisingly good at handling errors if you give it clear error descriptions.
Here's the pattern I use consistently:
// Error handling -- never throw, always return error string
[KernelFunction("send_email")]
[Description("Sends an email to a recipient. Returns 'success' or an error description.")]
public async Task<string> SendEmailAsync(
[Description("Recipient email address")] string to,
[Description("Email subject line")] string subject,
[Description("Email body text")] string body)
{
// Validate inputs -- LLMs can hallucinate invalid values
if (string.IsNullOrWhiteSpace(to) || !to.Contains('@'))
return $"Error: '{to}' is not a valid email address. Please provide a valid email.";
if (string.IsNullOrWhiteSpace(subject))
return "Error: Email subject cannot be empty.";
try
{
await _emailService.SendAsync(to, subject, body);
return $"Email successfully sent to {to}";
}
catch (Exception ex)
{
// Log the real error, return a friendly message
_logger.LogError(ex, "Failed to send email to {Recipient}", to);
return $"Error: Failed to send email to {to}. Please try again or use a different method.";
}
}
Notice how every error condition returns a descriptive string. The LLM can read these error messages and decide how to proceed. Maybe it tries a different email address, or maybe it informs the user that email isn't available.
I still log the actual exceptions for debugging purposes, but I return user-friendly error messages. This pattern keeps the agent loop running smoothly and gives the LLM the information it needs to recover.
Best Practice 5: Keep Functions Idempotent Where Possible
LLMs sometimes call the same function multiple times, either because they're uncertain about the result or because they're retrying after an error. If your plugin functions have side effects that can't be safely repeated, this can cause problems. Making functions idempotent -- safe to call multiple times with the same result -- is an important Semantic Kernel plugin best practice.
Idempotent operations produce the same result regardless of how many times they're called. Reading data is naturally idempotent. Setting a value to a specific state can be idempotent. Adding an item to a set can be idempotent if duplicates are ignored.
Non-idempotent operations include incrementing counters, appending to lists without checking for duplicates, or sending notifications each time the function is called. These operations behave differently on repeated calls.
Here's an example of an idempotent plugin function:
// Idempotent plugin function
[KernelFunction("add_tag")]
[Description("Adds a tag to a document. Safe to call multiple times -- adding an existing tag has no effect.")]
public async Task<string> AddTagAsync(
[Description("The document ID to tag")] string documentId,
[Description("The tag to add, e.g. 'urgent', 'reviewed', 'archived'")] string tag)
{
var document = await _repository.GetByIdAsync(documentId);
if (document is null)
return $"Error: Document '{documentId}' not found";
if (document.Tags.Contains(tag, StringComparer.OrdinalIgnoreCase))
return $"Tag '{tag}' already exists on document '{documentId}' -- no change made";
document.Tags.Add(tag);
await _repository.SaveAsync(document);
return $"Successfully added tag '{tag}' to document '{documentId}'";
}
The function checks if the tag already exists before adding it. Calling this function multiple times with the same parameters is safe -- the tag gets added once, and subsequent calls report that it already exists.
When you can't make a function idempotent, document this clearly in the function description. Let the LLM know that repeated calls will have repeated effects. This helps the LLM make better decisions about when to call the function.
Best Practice 6: Validate Inputs
LLMs are powerful, but they occasionally hallucinate or construct invalid parameter values. Your plugin functions should validate inputs before using them. This is defensive programming applied to AI-driven tool calling.
I've seen LLMs pass email addresses without the @ symbol, provide negative quantities, or construct malformed URLs. Sometimes the LLM misunderstands the expected format. Sometimes it extracts incorrect values from user input. Either way, your plugin should validate parameters and return clear error messages when validation fails.
Input validation serves two purposes: it prevents errors in your code, and it teaches the LLM what valid input looks like. When your function returns "Error: quantity must be a positive number," the LLM learns the constraint and adjusts future calls.
Look at the email example earlier -- we validate that the email address contains an @ symbol and isn't empty. For numeric parameters, validate ranges. For IDs, check format requirements. For optional parameters, handle null or default values appropriately.
The validation logic should be straightforward and return clear error messages. Don't just return "invalid input" -- tell the LLM exactly what's wrong and what format you expect. This improves the LLM's ability to self-correct on subsequent calls.
Best Practice 7: Log Plugin Invocations
Observability is crucial for production AI applications. You need to know when your plugins are called, what parameters were passed, and what results were returned. Structured logging with ILogger gives you this visibility.
When you're using dependency injection with IServiceCollection, you can inject ILogger into your plugin classes just like any other service. This follows standard .NET patterns and integrates with your existing logging infrastructure.
Here's how I structure logging in plugins:
// Logging with DI
public class AuditedSearchPlugin
{
private readonly ISearchService _search;
private readonly ILogger<AuditedSearchPlugin> _logger;
public AuditedSearchPlugin(ISearchService search, ILogger<AuditedSearchPlugin> logger)
{
_search = search;
_logger = logger;
}
[KernelFunction("search_documents")]
[Description("Searches documents using semantic similarity. Returns the top matching document titles and excerpts.")]
public async Task<string> SearchDocumentsAsync(
[Description("The natural language search query")] string query,
[Description("Maximum number of results to return (1-10)")] int maxResults = 5)
{
_logger.LogInformation("Plugin invoked: search_documents with query={Query}, maxResults={Max}", query, maxResults);
maxResults = Math.Clamp(maxResults, 1, 10); // Validate and clamp
var results = await _search.SearchAsync(query, maxResults);
_logger.LogInformation("Plugin returned {Count} results for query={Query}", results.Count, query);
return results.Count == 0
? $"No documents found matching '{query}'"
: string.Join("
", results.Select((r, i) => $"{i+1}. {r.Title}: {r.Excerpt}"));
}
}
I log at the entry point to record the invocation and parameters, and I log at the exit point to record the result. This creates an audit trail of plugin activity. If something goes wrong, I can trace exactly what the LLM requested and what my plugin returned.
For sensitive operations like data modification or external API calls, consider logging at the Information level. For high-frequency read operations, Debug level might be more appropriate. The key is having visibility into plugin behavior in production.
Structured logging with named parameters (like query={Query}) makes log analysis easier. You can query your logs to find all invocations with specific parameters or identify patterns in how the LLM uses your plugins.
Plugin Naming Conventions
Every name in your plugin becomes part of the tool schema the LLM sees. The plugin class name, function names, and parameter names all contribute to how the LLM understands and uses your tools. Following consistent naming conventions is an essential Semantic Kernel plugin best practice.
For plugin class names, I use descriptive nouns followed by "Plugin": ProductPlugin, EmailPlugin, OrderPlugin. This makes the purpose immediately clear. The class name should describe the domain or capability the plugin provides. Following semantic kernel plugin best practices and patterns for naming ensures consistency across your codebase.
For function names, I use clear, action-oriented verbs: SearchProducts, SendEmail, GetOrderStatus, UpdateInventory. The function name should describe what action the function performs. Avoid generic names like "Process" or "Handle" -- be specific.
For parameter names, use full words rather than abbreviations. Write "emailAddress" instead of "addr" and "maxResults" instead of "max." The parameter name is part of the tool schema, and clear names help the LLM construct valid calls.
I use PascalCase for function names (following C# conventions) and camelCase for parameter names. This is standard C# naming, and it translates cleanly to the JSON tool schema that most LLM APIs expect.
Consistency matters more than the specific convention you choose. If your entire codebase uses a different naming pattern, follow that pattern. The LLM adapts to your naming conventions as long as they're consistent and descriptive.
Testing Plugins in Isolation
One advantage of the plugin architecture pattern in Semantic Kernel is that plugins are just C# classes. You can test them without the kernel, without the LLM, and without the entire AI orchestration stack. This makes unit testing straightforward and fast.
When I write tests for plugins, I instantiate the plugin class directly and call its functions. I mock the dependencies using standard .NET testing patterns. If a plugin depends on IEmailService, I mock that service and verify the plugin calls it correctly.
Testing plugins in isolation validates your business logic independent of the AI layer. You can test that error handling works correctly, that validation catches invalid inputs, and that success cases return the expected strings. You can test edge cases and boundary conditions without involving an LLM.
For integration testing, you can register your plugins with a Kernel instance and test the full orchestration. But start with unit tests that focus on individual plugin functions. This gives you fast feedback and clear failure messages when something breaks.
I structure my plugin tests around three scenarios: success cases, validation failures, and error conditions. For each function, I test that valid inputs produce expected output, invalid inputs return clear error messages, and exceptions from dependencies are caught and returned as error strings.
This testing approach catches bugs early and ensures your plugins behave correctly before you involve the LLM. It's standard .NET testing applied to AI tooling.
FAQ
Should I use async or sync functions in plugins?
If your plugin does any IO -- database calls, API requests, file operations -- use async functions. Semantic Kernel handles both sync and async functions, but async prevents blocking threads during IO operations. Most production plugins use async.
Can I return JSON from plugin functions?
Yes, but make sure it's formatted clearly. The LLM can parse JSON, but natural language descriptions often work better. If you need structured data, format it consistently and mention the format in your function description.
How do I handle optional parameters?
Use C# default parameters and describe the default behavior in the parameter description. For example: [Description("If true, includes archived items. Defaults to false.")] bool includeArchived = false. The LLM will omit optional parameters when they're not needed.
Should I put validation in the plugin or in the service layer?
Both. Validate parameters in the plugin to give the LLM immediate feedback. Your service layer should also validate as defense in depth. The plugin validation focuses on teaching the LLM correct usage.
How do I test plugins that use ILogger?
Create a mock ILogger or use a testing logger implementation. Many testing frameworks provide logger implementations that capture log messages for assertion. You can verify that your plugin logs the expected information.
Conclusion
Following Semantic Kernel plugin best practices transforms your AI applications from experimental prototypes into production-ready systems. Writing clear descriptions ensures the LLM calls your functions at the right time. Handling errors gracefully keeps the agent loop running smoothly. Validating inputs protects against hallucinated parameters. Logging provides visibility into plugin behavior.
These patterns come from building real production systems with Semantic Kernel in C#. They prevent common pitfalls and make your plugins reliable tools for LLM orchestration. Your plugin isn't just code -- it's an API for an AI agent, and that requires a different mindset than traditional C# development.
Start with descriptions. Make sure every function and parameter has a clear, specific description. Then focus on error handling and validation. These three practices -- descriptions, error handling, and validation -- cover the majority of plugin reliability issues. Add logging for observability, keep plugins focused on single responsibilities, and test them like any other C# code.
The result is plugins that work reliably with LLMs, fail gracefully when problems occur, and provide the visibility you need to debug and improve your AI applications. These semantic kernel plugin best practices and patterns for C# developers turn your AI experiments into production systems.

