The Handlebars planner in Semantic Kernel lets you define planning logic as declarative templates rather than imperative code. This tutorial walks through building a working agent that breaks down goals into steps, selects functions, and executes them — all driven by a Handlebars template you can version and test independently.
Prerequisites
- .NET 8 SDK or later
- An OpenAI-compatible API key (OpenAI, Azure OpenAI, or a gateway like n4n.ai)
- Basic familiarity with Semantic Kernel concepts: kernels, functions, and planners
Create a new console project and add the required packages:
dotnet new console -n HandlebarsPlannerDemo
cd HandlebarsPlannerDemo
dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Planners.Handlebars
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
Create an appsettings.json file (copy to output directory) for configuration:
{
"OpenAI": {
"ModelId": "gpt-4o-mini",
"ApiKey": "your-api-key-here",
"Endpoint": "https://api.openai.com/v1"
}
}
Set Copy to Output Directory to Copy if newer in the file properties.
The planning template
The Handlebars planner uses a template that receives the goal, available functions, and conversation history. It outputs a JSON plan. Create PlannerTemplates/handlebars-planner.hbs:
{{!-- Handlebars planner template for goal decomposition --}}
{
"goal": "{{goal}}",
"plan": [
{{#each steps}}
{
"function": "{{this.function}}",
"arguments": {{json this.arguments}},
"description": "{{this.description}}"
}{{#unless @last}},{{/unless}}
{{/each}}
]
}
This template expects the planner to populate steps with an array of function calls. The json helper serializes arguments properly.
Define the functions
Create a Plugins folder with a simple math plugin and a text plugin. These are the tools the planner can invoke.
// Plugins/MathPlugin.cs
using Microsoft.SemanticKernel;
namespace HandlebarsPlannerDemo.Plugins;
public class MathPlugin
{
[KernelFunction("add")]
[Description("Adds two numbers together")]
public double Add(
[Description("First number")] double a,
[Description("Second number")] double b) => a + b;
[KernelFunction("multiply")]
[Description("Multiplies two numbers")]
public double Multiply(
[Description("First number")] double a,
[Description("Second number")] double b) => a * b;
[KernelFunction("divide")]
[Description("Divides first number by second")]
public double Divide(
[Description("Dividend")] double a,
[Description("Divisor")] double b) => b != 0 ? a / b : double.NaN;
}
// Plugins/TextPlugin.cs
using Microsoft.SemanticKernel;
namespace HandlebarsPlannerDemo.Plugins;
public class TextPlugin
{
[KernelFunction("uppercase")]
[Description("Converts text to uppercase")]
public string Uppercase([Description("Text to convert")] string text) => text.ToUpperInvariant();
[KernelFunction("reverse")]
[Description("Reverses a string")]
public string Reverse([Description("Text to reverse")] string text) => new string(text.Reverse().ToArray());
[KernelFunction("word_count")]
[Description("Counts words in text")]
public int WordCount([Description("Text to count")] string text) => text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
}
Build the kernel and register plugins
In Program.cs, configure the kernel with your provider and import the plugins:
// Program.cs
using Microsoft.Extensions.Configuration;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Planners.Handlebars;
using HandlebarsPlannerDemo.Plugins;
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.Build();
var modelId = config["OpenAI:ModelId"]!;
var apiKey = config["OpenAI:ApiKey"]!;
var endpoint = config["OpenAI:Endpoint"]!;
var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion(modelId, apiKey, endpoint: endpoint);
builder.Plugins.AddFromType<MathPlugin>("Math");
builder.Plugins.AddFromType<TextPlugin>("Text");
var kernel = builder.Build();
Load the Handlebars planner
The planner needs the template loaded from the file system. Add a helper method:
static HandlebarsPlanner CreatePlanner(Kernel kernel, string templatePath)
{
var template = File.ReadAllText(templatePath);
var options = new HandlebarsPlannerOptions
{
Template = template,
AllowMissingFunctions = false
};
return new HandlebarsPlanner(options);
}
Execute a planning loop
Now wire the planner into a loop that: creates a plan from a goal, executes each step, and prints results.
async Task RunPlannerAsync(Kernel kernel, HandlebarsPlanner planner, string goal)
{
Console.WriteLine($"\n=== Goal: {goal} ===\n");
// Create the plan
var plan = await planner.CreatePlanAsync(kernel, goal);
Console.WriteLine("Generated plan:");
Console.WriteLine(plan);
Console.WriteLine();
// Execute the plan
var result = await planner.ExecutePlanAsync(kernel, plan);
Console.WriteLine("Final result:");
Console.WriteLine(result);
}
Call it from Main:
var templatePath = Path.Combine(AppContext.BaseDirectory, "PlannerTemplates", "handlebars-planner.hbs");
var planner = CreatePlanner(kernel, templatePath);
await RunPlannerAsync(kernel, planner, "Calculate (15 + 25) * 4, then convert the result to uppercase text");
await RunPlannerAsync(kernel, planner, "Reverse the word 'semantic' and count its characters");
Expected output at first checkpoint
Run the project:
dotnet run
You should see something like:
=== Goal: Calculate (15 + 25) * 4, then convert the result to uppercase text ===
Generated plan:
{
"goal": "Calculate (15 + 25) * 4, then convert the result to uppercase text",
"plan": [
{
"function": "Math-add",
"arguments": {"a": 15, "b": 25},
"description": "Add 15 and 25"
},
{
"function": "Math-multiply",
"arguments": {"a": 40, "b": 4},
"description": "Multiply the sum by 4"
},
{
"function": "Text-uppercase",
"arguments": {"text": "160"},
"description": "Convert result to uppercase"
}
]
}
Final result:
160
The planner correctly decomposed the goal, chained the math operations, and passed the numeric result to the text function. Note that uppercase on a number string returns the same string — this reveals a type mismatch the planner didn’t catch.
Fix the type handling
The planner treats all arguments as strings. Add a conversion step in the template or handle it in the function. The cleaner approach: make functions accept strings and parse internally.
Update MathPlugin.cs:
[KernelFunction("add")]
[Description("Adds two numbers together")]
public double Add(
[Description("First number")] string a,
[Description("Second number")] string b) => double.Parse(a) + double.Parse(b);
// Apply same pattern to Multiply and Divide
Re-run and the plan executes cleanly.
Add conversation history support
Real agents need context. The Handlebars planner accepts chatHistory in the template context. Update the template to reference prior turns:
{{!-- PlannerTemplates/handlebars-planner-v2.hbs --}}
{{#if chatHistory}}
Previous conversation:
{{#each chatHistory}}
- {{this.role}}: {{this.content}}
{{/each}}
{{/if}}
Current goal: {{goal}}
Available functions:
{{#each functions}}
- {{this.name}}: {{this.description}}
Parameters: {{json this.parameters}}
{{/each}}
Output a JSON plan with steps array.
Update the planner creation to use the new template and pass history:
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage("My favorite number is 42");
chatHistory.AddAssistantMessage("Noted. 42 is a great number.");
var plan = await planner.CreatePlanAsync(kernel, goal, chatHistory);
The planner can now reference “my favorite number” in subsequent goals.
Handle missing functions gracefully
Set AllowMissingFunctions = true in options when you want the planner to degrade gracefully. It will emit a noop step for unknown functions rather than throwing.
var options = new HandlebarsPlannerOptions
{
Template = template,
AllowMissingFunctions = true
};
Test with a goal referencing a non-existent function:
await RunPlannerAsync(kernel, planner, "Translate 'hello' to French using the translate function");
Output shows a noop step with an error message you can catch and handle in your execution loop.
Customize the execution loop
The default ExecutePlanAsync runs steps sequentially. For production, you’ll want custom logic: retries, timeouts, parallel execution where steps are independent, and structured logging.
async Task<FunctionResult> ExecuteWithRetryAsync(Kernel kernel, string functionName, KernelArguments args, int maxRetries = 3)
{
for (int attempt = 1; attempt <= maxRetries; attempt++)
{
try
{
var function = kernel.Plugins.GetFunction(functionName);
return await kernel.InvokeAsync(function, args);
}
catch (Exception ex) when (attempt < maxRetries)
{
Console.WriteLine($"Attempt {attempt} failed: {ex.Message}. Retrying...");
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
}
}
throw new InvalidOperationException($"Function {functionName} failed after {maxRetries} attempts");
}
Replace the planner’s execution with your own loop that parses the JSON plan and invokes functions with your policies.
Version and test templates independently
Because the planning logic lives in a Handlebars file, you can unit-test it without spinning up a kernel. Render the template with mock data:
[Test]
public void Template_RendersValidJson_ForSampleGoal()
{
var template = File.ReadAllText("PlannerTemplates/handlebars-planner.hbs");
var handlebars = HandlebarsDotNet.Handlebars.Compile(template);
var context = new
{
goal = "Add 2 and 3",
steps = new[]
{
new { function = "Math-add", arguments = new { a = 2, b = 3 }, description = "Add numbers" }
}
};
var output = handlebars(context);
var parsed = JsonNode.Parse(output);
Assert.That(parsed?["plan"]?[0]?["function"]?.GetValue<string>(), Is.EqualTo("Math-add"));
}
This lets you validate template changes in CI without API calls.
When to choose Handlebars over other planners
- Handlebars planner: Planning logic as data. Good when you need version control, A/B testing, or non-developers to modify planning behavior.
- Function calling planner: Relies on the model’s native function calling. Simpler, less control over structure.
- Stepwise planner: Iterative, model-driven reasoning. Better for open-ended exploration, harder to constrain.
The Handlebars planner shines when the planning structure is stable but the available functions change frequently — you update the function registry without touching the template.
Clean up and next steps
You now have a working Handlebars planner setup with:
- Declarative planning templates you can version
- Typed plugins the planner discovers automatically
- Conversation history awareness
- Custom execution policies
From here, consider:
- Adding a validator that checks plan schema before execution
- Implementing a plan cache for repeated goals
- Building a template registry that selects templates by domain
- Integrating with a provider gateway for model fallback and usage metering
The complete project structure:
HandlebarsPlannerDemo/
├── appsettings.json
├── PlannerTemplates/
│ ├── handlebars-planner.hbs
│ └── handlebars-planner-v2.hbs
├── Plugins/
│ ├── MathPlugin.cs
│ └── TextPlugin.cs
└── Program.cs
This pattern scales to production agents where planning logic is auditable, testable, and separable from the orchestration code.