SK plugins are powerful but have sharp edges. Here is everything the quickstart tutorials skip.
Why Plugins Trip People Up
Semantic Kernel's plugin system is elegant in principle: annotate a Python class with decorators and SK automatically exposes its methods as AI-callable tools. In practice, there are four common failure modes that tutorials do not cover: missing return type annotations, ambiguous descriptions, incorrect parameter types, and async handling.
This guide covers the full plugin authoring pattern, the failure modes, and how to test plugins in isolation before wiring them to an agent.
The Basic Plugin Pattern
from semantic_kernel.functions import kernel_function
from semantic_kernel.functions.kernel_parameter_metadata import KernelParameterMetadata
class OrderPlugin:
"""Provides functions for looking up and managing customer orders."""
@kernel_function(
name="get_order_status",
description="Get the current status of a customer order by order ID. "
"Returns the status (pending, shipped, delivered, cancelled) "
"and estimated delivery date.",
)
async def get_order_status(
self,
order_id: str, # type annotation required -- SK uses this for schema
) -> str: # return type annotation required
"""Retrieve order status from the database."""
# Your implementation here
order = await db.get_order(order_id)
return f"Order {order_id}: {order.status}, estimated delivery: {order.eta}"
@kernel_function(
name="cancel_order",
description="Cancel a customer order. Only works for orders in 'pending' status. "
"Returns a confirmation message or an error if cancellation is not possible.",
)
async def cancel_order(
self,
order_id: str,
reason: str, # all parameters need type annotations
) -> str:
result = await db.cancel_order(order_id, reason)
msg = 'cancelled' if result.ok else 'could not be cancelled: ' + result.error
return f"Order {order_id}: {msg}" Failure Mode 1: Missing Type Annotations
Semantic Kernel generates the function schema (which is sent to the LLM) from Python type annotations. If a parameter has no annotation, SK cannot generate a schema for it, and the function may not be exposed or may error at call time.
# BROKEN: no type annotations -- SK cannot infer schema
@kernel_function(name="search", description="Search the knowledge base")
async def search(self, query, max_results):
...
# FIXED: explicit type annotations
@kernel_function(name="search", description="Search the knowledge base")
async def search(
self,
query: str,
max_results: int = 5, # default values are fine
) -> str:
...Always annotate parameters with the most specific type possible: str, int, float, bool, list[str]. Avoid Any, Optional without a concrete type, or dict without typing. The LLM uses the schema to decide what values to pass.Failure Mode 2: Vague Descriptions
The description in @kernel_function is what the LLM reads when deciding which function to call and what arguments to pass. Vague descriptions cause the LLM to call the wrong function, pass wrong arguments, or skip the function entirely.
# VAGUE: LLM has no idea when to call this or what to pass
@kernel_function(
name="lookup",
description="Look up something",
)
async def lookup(self, query: str) -> str: ...
# SPECIFIC: LLM knows exactly when and how to use this
@kernel_function(
name="lookup_customer_by_email",
description="Look up a customer account using their email address. "
"Returns customer ID, account status, and subscription tier. "
"Use this when the user provides or mentions an email address.",
)
async def lookup_customer_by_email(self, email: str) -> str: ...Failure Mode 3: Returning Non-String Types
SK functions can return complex types, but the safest and most reliable return type is str. When you return a dict, list, or Pydantic model, SK serialises it for you -- but the LLM sees a JSON string that it may or may not parse correctly. For reliability, serialise explicitly.
import json
# RISKY: returns a dict -- SK serialises it, but the format may surprise you
@kernel_function(name="get_products", description="...")
async def get_products(self, category: str) -> dict:
return {"products": [...], "count": 5}
# BETTER: return a clean, human-readable string the LLM can reason about
@kernel_function(name="get_products", description="Get products in a category. "
"Returns a formatted list of product names and prices.")
async def get_products(self, category: str) -> str:
products = await db.get_products(category)
lines = [f"- {p.name}: ${p.price:.2f}" for p in products]
return f"Found {len(products)} products in {category}:\n" + "\n".join(lines)Failure Mode 4: Sync Functions in Async Agents
Semantic Kernel's agent runtime is async. If your plugin function is synchronous and does blocking I/O (database calls, HTTP requests, file reads), it blocks the event loop and causes performance problems or timeouts. Always make plugin functions async.
import asyncio
# BLOCKING: will stall the async event loop
@kernel_function(name="fetch_data", description="...")
def fetch_data(self, url: str) -> str:
import requests
return requests.get(url).text # blocking HTTP call
# CORRECT: async with non-blocking HTTP
@kernel_function(name="fetch_data", description="...")
async def fetch_data(self, url: str) -> str:
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.textRegistering and Testing Plugins
import asyncio
from semantic_kernel import Kernel
from semantic_kernel.connectors.ai.open_ai import OpenAIChatCompletion
kernel = Kernel()
# Add LLM service
kernel.add_service(OpenAIChatCompletion(
service_id="gpt4o",
ai_model_id="gpt-4o",
api_key="your-key",
))
# Register your plugin
kernel.add_plugin(OrderPlugin(), plugin_name="orders")
# Test a function directly (without the LLM) -- always do this first
async def test_plugin():
result = await kernel.invoke(
plugin_name="orders",
function_name="get_order_status",
order_id="ORD-12345",
)
print(f"Direct invocation result: {result}")
asyncio.run(test_plugin())Always test plugin functions via kernel.invoke() before testing them through the agent. This verifies the function works correctly in isolation, without the complexity of LLM function selection layered on top.Quick Reference
- Annotate ALL parameters with explicit Python types (str, int, bool, list[str])
- Write descriptions as instructions to the LLM: what it does, when to use it, what it returns
- Return str from plugin functions for maximum reliability -- serialise complex data manually
- Make all plugin functions async to avoid blocking the event loop
- Test functions directly with kernel.invoke() before testing through the agent
- Use specific function names (lookup_customer_by_email not lookup) to reduce LLM ambiguity