Semantic Kernel’s function system relies on parameter schemas to bridge natural language intent and typed execution. This semantic kernel function parameter schemas tutorial walks through defining schemas that the planner can reason about, the kernel can validate, and your native code can consume without runtime surprises. We’ll cover JSON Schema construction, kernel argument binding, type mapping quirks, and the failure modes that show up in production.
Understanding the schema contract
Every Semantic Kernel function — whether a native C# method, a Python function, or a prompt template — exposes a parameter schema that describes what the function accepts. The planner uses this schema to decide which function to invoke and how to populate arguments. The kernel uses it to validate incoming KernelArguments before dispatch. If the schema is wrong, the planner hallucinates parameters, validation passes bad data, or your handler receives null where it expects a string.
The schema lives in FunctionMetadata.Parameters, a collection of ParameterMetadata objects. Each parameter carries a name, description, type, default value, and a Schema property that holds a JSON Schema fragment. This fragment is what the planner actually reads.
var function = kernel.CreateFunctionFromMethod(
method: static (string city, int days = 3) =>
$"Weather forecast for {city} over {days} days",
functionName: "GetForecast",
parameters: new[]
{
new ParameterMetadata("city")
{
Description = "City name",
Schema = JsonSchema.Parse("{\"type\": \"string\"}")
},
new ParameterMetadata("days")
{
Description = "Number of days",
DefaultValue = 3,
Schema = JsonSchema.Parse("{\"type\": \"integer\", \"minimum\": 1, \"maximum\": 14}")
}
}
);
Notice the JsonSchema.Parse call. Semantic Kernel uses System.Text.Json.Nodes.JsonNode under the hood, but the JsonSchema helper parses a JSON Schema string into the internal representation. You can also build schemas programmatically using JsonSchemaBuilder, but the string form is often clearer for complex constraints.
Mapping CLR types to JSON Schema
The kernel does not automatically infer JSON Schema from your method signature when you use CreateFunctionFromMethod with explicit parameters. You own the schema. This is a frequent source of bugs: the C# signature says int, the schema says string, and the planner sends "7" which binds to int via System.Text.Json coercion — until it sends "seven" and the invocation fails.
Prefer explicit schemas that match your handler’s deserialization expectations. The following table shows the safe mappings:
| CLR type | JSON Schema type | Notes |
|---|---|---|
string |
"string" |
Add minLength, maxLength, pattern for validation |
int, long |
"integer" |
Use minimum/maximum bounds |
double, float, decimal |
"number" |
decimal serializes as number; watch precision |
bool |
"boolean" |
Planner may send "true" string; kernel coerces |
DateTime |
"string", "format": "date-time" |
ISO 8601; kernel binds to DateTime |
Guid |
"string", "format": "uuid" |
Kernel binds to Guid |
enum |
"string", "enum": [...] |
List all values; planner picks one |
T[] / List<T> |
"array", "items": {...} |
Items schema matches element type |
Dictionary<string, T> |
"object", "additionalProperties": {...} |
Keys are strings; values follow schema |
| Complex POCO | "object", "properties": {...}, "required": [...] |
Each property gets its own schema |
For complex objects, define the schema inline or reference a shared definition. The kernel does not support $ref resolution across the function metadata collection, so duplicate the schema or build it programmatically once and reuse the JsonNode.
var addressSchema = JsonSchema.Parse("""
{
"type": "object",
"properties": {
"street": { "type": "string", "maxLength": 100 },
"city": { "type": "string", "maxLength": 50 },
"postalCode": { "type": "string", "pattern": "^\\d{5}(-\\d{4})?$" },
"country": { "type": "string", "enum": ["US", "CA", "MX"] }
},
"required": ["street", "city", "postalCode", "country"],
"additionalProperties": false
}
""");
var function = kernel.CreateFunctionFromMethod(
method: static (Address address) => ShipOrder(address),
functionName: "ShipOrder",
parameters: new[]
{
new ParameterMetadata("address")
{
Description = "Shipping address",
Schema = addressSchema
}
}
);
Setting additionalProperties: false prevents the planner from inventing extra fields that your handler would ignore — or worse, that a downstream validator would reject.
KernelArguments binding behavior
When the planner invokes a function, it constructs a KernelArguments dictionary from its reasoning trace. The kernel then binds these arguments to your function’s parameters using System.Text.Json deserialization. Understanding the binding rules prevents “works in test, fails in production” scenarios.
- Name matching is case-insensitive but preserves the original casing in the schema. The planner emits lowercase keys; your schema uses PascalCase. This works, but keep names consistent to avoid confusion.
- Missing optional parameters receive their
DefaultValuefromParameterMetadata. If no default exists and the parameter is not marked required in the schema, the handler receivesdefault(T)—nullfor reference types, zero for value types. - Extra arguments in
KernelArgumentsthat don’t match any parameter are ignored. They do not cause validation errors. - Type coercion follows
JsonSerializerrules with the defaultJsonSerializerOptions(camelCase property naming, case-insensitive matching). Numbers in JSON bind toint,long,double,decimal. Strings bind toGuid,DateTime,enumif the format matches. - Null handling: if the planner explicitly sends
nullfor a non-nullable value type, binding throws. Mark the schema property as nullable with"type": ["integer", "null"]if you need to distinguish missing from explicit null.
// Schema that accepts null for an optional integer
var nullableDaysSchema = JsonSchema.Parse("""
{
"type": ["integer", "null"],
"minimum": 1,
"maximum": 14
}
""");
Prompt template functions and schema inference
Prompt template functions (kernel.CreateFunctionFromPrompt) infer parameter schemas from the template’s {{$variable}} placeholders. The inference is basic: every placeholder becomes a string parameter with no constraints. This is rarely sufficient for production.
// Inferrred schema: both parameters are unconstrained strings
var prompt = """
Summarize the following text in {{$language}} using at most {{$maxWords}} words.
Text: {{$input}}
""";
var summarize = kernel.CreateFunctionFromPrompt(prompt,
functionName: "Summarize",
Override the inferred schema by passing PromptTemplateConfig with explicit InputVariables:
var config = new PromptTemplateConfig
{
Template = prompt,
Name = "Summarize",
Description = "Summarizes text with constraints",
InputVariables = new[]
{
new InputVariable("language")
{
Description = "Target language",
IsRequired = true,
JsonSchema = "{\"type\": \"string\", \"enum\": [\"en\", \"es\", \"fr\", \"de\"]}"
},
new InputVariable("maxWords")
{
Description = "Maximum word count",
IsRequired = false,
Default = "100",
JsonSchema = "{\"type\": \"integer\", \"minimum\": 10, \"maximum\": 500}"
},
new InputVariable("input")
{
Description = "Text to summarize",
IsRequired = true,
JsonSchema = "{\"type\": \"string\", \"minLength\": 1, \"maxLength\": 10000}"
}
}
};
var summarize = kernel.CreateFunctionFromPrompt(config);
Now the planner knows language must be one of four codes, maxWords is an integer with bounds, and input has length limits. The prompt template still receives strings — the kernel binds validated arguments and passes them as strings to the renderer — but invalid invocations are rejected before the model sees them.
Validation pipeline and error handling
Schema validation occurs in two places: the planner’s function selection (client-side, best-effort) and the kernel’s InvokeAsync (server-side, enforced). The kernel validates KernelArguments against each parameter’s schema before calling your handler. Validation failures throw KernelException with a ValidationError code.
try
{
var result = await kernel.InvokeAsync(function, arguments);
}
catch (KernelException ex) when (ex.ErrorCode == "ValidationError")
{
// ex.Message contains details like:
// "Parameter 'days' failed validation: value '0' is less than minimum 1"
logger.LogWarning(ex, "Planner sent invalid arguments");
return Results.BadRequest(new { error = "Invalid parameters", details = ex.Message });
}
The validation uses System.Text.Json.Schema (the experimental JSON Schema validator). It supports Draft 2020-12 core keywords: type, enum, const, minimum/maximum, exclusiveMinimum/exclusiveMaximum, minLength/maxLength, pattern, minItems/maxItems, uniqueItems, required, properties, additionalProperties, items, allOf/anyOf/oneOf/not. It does not support format validation beyond type coercion — format: "email" is accepted but not enforced.
If you need stricter validation (email format, custom regex, cross-field constraints), validate in your handler after binding:
var function = kernel.CreateFunctionFromMethod(
method: static (KernelArguments args) =>
{
var email = args.GetValue<string>("email");
if (!EmailRegex().IsMatch(email))
throw new ArgumentException("Invalid email format", nameof(email));
var start = args.GetValue<DateTime>("startDate");
var end = args.GetValue<DateTime>("endDate");
if (end <= start)
throw new ArgumentException("endDate must be after startDate");
return ProcessBooking(email, start, end);
},
functionName: "BookAppointment",
parameters: new[]
{
new ParameterMetadata("email") { Schema = JsonSchema.Parse("{\"type\": \"string\"}") },
new ParameterMetadata("startDate") { Schema = JsonSchema.Parse("{\"type\": \"string\", \"format\": \"date-time\"}") },
new ParameterMetadata("endDate") { Schema = JsonSchema.Parse("{\"type\": \"string\", \"format\": \"date-time\"}") }
}
);
Common pitfalls
Pitfall: planner ignores required fields. The planner’s function calling logic is probabilistic. Even with "required": true in the schema, the planner may omit a parameter. Always handle missing required parameters in your handler or use a kernel filter to enforce presence before invocation.
Pitfall: enum case sensitivity. JSON Schema enums are case-sensitive. If your schema lists ["Pending", "Approved", "Rejected"] and the planner sends "approved", validation fails. Either normalize in the handler or use lowercase enum values in the schema.
Pitfall: array vs single value. The planner sometimes wraps a single value in an array when the schema says "type": "array". Your handler receives string[] with one element. Accept both by checking args.TryGetValue for both array and scalar, or use a custom binder.
Pitfall: schema drift between planner and kernel. If you update the function schema but the planner caches an older version (common with long-running planner sessions), you’ll see validation errors for parameters that “should” be valid. Version your function names (GetForecast_v2) or restart the planner when schemas change.
Pitfall: large object schemas bloat the prompt. The planner includes the full schema in its system prompt. A function with a 50-property object schema consumes significant context. Split large functions, use additionalProperties: false to keep schemas tight, or reference a separate validation endpoint.
Testing schemas in isolation
Unit test your schemas without spinning up the kernel. The JsonSchema class exposes Validate(JsonNode instance) returning a ValidationResults collection.
[Test]
public void ForecastSchema_RejectsInvalidDays()
{
var schema = JsonSchema.Parse("{\"type\": \"integer\", \"minimum\": 1, \"maximum\": 14}");
var results = schema.Validate(JsonValue.Create(0));
Assert.That(results.Any(r => r.Keyword == "minimum"), Is.True);
results = schema.Validate(JsonValue.Create(15));
Assert.That(results.Any(r => r.Keyword == "maximum"), Is.True);
results = schema.Validate(JsonValue.Create("7"));
Assert.That(results.Any(r => r.Keyword == "type"), Is.True);
}
[Test]
public void AddressSchema_RequiresAllFields()
{
var schema = addressSchema; // from earlier example
var incomplete = JsonNode.Parse("{\"street\": \"123 Main St\", \"city\": \"Seattle\"}");
var results = schema.Validate(incomplete);
Assert.That(results.Any(r => r.Keyword == "required"), Is.True);
}
These tests catch schema regressions before they reach the planner.
When to use native functions vs prompt templates
Native functions (C#/Python methods) give you full control over schema, validation, and execution. Prompt templates are useful when the function is a prompt — summarization, classification, extraction — and you want the model to do the work. The schema still matters: it constrains what the planner can pass to the template.
A practical rule: if the function contains business logic, data access, or external API calls, make it native. If it’s purely “ask the model to transform text,” a prompt template is fine — but still define an explicit schema in PromptTemplateConfig rather than relying on inference.
Putting it together: a complete example
Here’s a production-ready function with schema, validation, and error handling:
public static class OrderFunctions
{
private static readonly JsonNode CreateOrderSchema = JsonSchema.Parse("""
{
"type": "object",
"properties": {
"customerId": { "type": "string", "format": "uuid" },
"items": {
"type": "array",
"minItems": 1,
"maxItems": 50,
"items": {
"type": "object",
"properties": {
"sku": { "type": "string", "pattern": "^[A-Z]{3}-\\d{4}$" },
"quantity": { "type": "integer", "minimum": 1, "maximum": 100 }
},
"required": ["sku", "quantity"],
"additionalProperties": false
}
},
"shippingAddress": {
"type": "object",
"properties": {
"street": { "type": "string", "maxLength": 100 },
"city": { "type": "string", "maxLength": 50 },
"postalCode": { "type": "string", "pattern": "^\\d{5}(-\\d{4})?$" },
"country": { "type": "string", "enum": ["US", "CA"] }
},
"required": ["street", "city", "postalCode", "country"],
"additionalProperties": false
},
"idempotencyKey": { "type": "string", "maxLength": 64 }
},
"required": ["customerId", "items", "shippingAddress"],
"additionalProperties": false
}
""");
public static KernelFunction CreateOrder(IOrderService orders) =>
KernelFunctionFactory.CreateFromMethod(
method: async (KernelArguments args, CancellationToken ct) =>
{
var customerId = args.GetValue<Guid>("customerId");
var items = args.GetValue<JsonArray>("items");
var address = args.GetValue<JsonObject>("shippingAddress");
var idempotencyKey = args.GetValue<string>("idempotencyKey");
// Additional business validation not expressible in JSON Schema
var skus = items.Select(i => i["sku"]!.GetValue<string>()).ToList();
if (skus.Distinct().Count() != skus.Count)
throw new ArgumentException("Duplicate SKUs in order");
return await orders.PlaceAsync(customerId, items, address, idempotencyKey, ct);
},
functionName: "CreateOrder",
parameters: new[]
{
new ParameterMetadata("order") { Schema = CreateOrderSchema }
}
);
The planner sees a single parameter order of type object. It must construct the entire payload in one call. This reduces planner errors compared to multiple flat parameters, and the schema validates the complete structure before your code runs.
Schema evolution strategy
As your functions evolve, treat schemas like API contracts:
- Add optional fields with defaults — never break existing planners.
- Deprecate fields by keeping them in the schema but ignoring them in the handler. Remove after planner migration.
- Version function names for breaking changes:
CreateOrder_v2with a new schema. Update the planner’s function registry atomically. - Publish schemas alongside your kernel deployment so consumers (and your own integration tests) can validate offline.
The semantic kernel function parameter schemas tutorial approach — explicit schemas, defensive validation, unit-tested constraints — keeps the planner honest and your handlers safe. The planner will still hallucinate occasionally, but it will hallucinate within the boundaries you defined.