n4nAI

Semantic Kernel planner tutorial: handlebars planner basics

Learn to build agents with Semantic Kernel's Handlebars planner through a complete, runnable tutorial covering setup, prompt templates, and execution loops.

n4n Team3 min read699 words

Audio narration

Coming soon — every post will get a voice note here.

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.

Tagssemantic-kernelplannerhandlebars-plannertutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All semantic kernel planners & agents posts →