Skip to main content

Prompt templates

PromptTemplate is the slot-substitution engine that backs every agent's SystemPrompt. It's deliberately tiny: replace {{slotName}} with a value, no logic, no expressions, no escapes. You'll rarely instantiate it yourself — it's used internally — but you'll meet it when you override RenderSystemPromptAsync.

API

public sealed class PromptTemplate
{
public PromptTemplate(string template);

public string Render(IDictionary<string, string> slots);

public string Render(
string input,
IDictionary<string, string>? extraSlots = null);

public override string ToString(); // returns the raw template
}

The two-argument Render(input, extraSlots) overload pre-fills {{input}} for you. That's the slot the framework substitutes automatically when you don't override RenderSystemPromptAsync.

Built-in {{input}} slot

Every agent's system prompt is rendered through PromptTemplate before being sent. {{input}} is replaced with the user's input string:

IAgent agent = new Agent<string>(
"Echo",
"Echoes the input.",
"Repeat back the user's input verbatim. Input was: {{input}}",
llm);

await agent.RunAsync("hello", new AgentContext());

Custom slots

Anything else needs to be filled in via RenderSystemPromptAsync — the framework can't know what your slots mean.

public sealed class GreeterAgent : AgentBase<string>
{
public override string Name => "Greeter";
public override string Description => "Greets users by name.";
protected override LlmClientBase LlmClient { get; }

private readonly string _userName;
private readonly string _teamName;

protected override string SystemPrompt =>
"You are greeting {{userName}} from the {{teamName}} team.";

public GreeterAgent(LlmClientBase llm, string userName, string teamName)
{
LlmClient = llm;
_userName = userName;
_teamName = teamName;
}

protected override Task<string> RenderSystemPromptAsync(
string input, AgentContext ctx, CancellationToken ct)
{
var template = new PromptTemplate(SystemPrompt);
var rendered = template.Render(input, new Dictionary<string, string>
{
["userName"] = _userName,
["teamName"] = _teamName,
});
return Task.FromResult(rendered);
}
}

Inject the slot values through the agent's constructor — they're captured once and reused on every run. For values that vary per run (a user-provided question, a fetched profile, retrieved RAG chunks), do the lookup inside RenderSystemPromptAsync itself.

What PromptTemplate doesn't do

  • No conditionals. No {{#if}}…{{/if}}. Decide in C# code which template to use, or build the substitution map.
  • No loops. No {{#each}}. Stringify your list before substituting.
  • No escapes. {{ always starts a slot. If you need a literal {{, build the string differently.
  • No type coercion. Slots are string only. Format numbers / dates yourself before substituting.

This is intentional — system prompts are short and the engine should be a one-line read of "this slot becomes that string." For richer templating, use any external engine (Scriban, Handlebars.NET) and pass the rendered string to PromptTemplate as a no-op final step.

Where templates live in the framework

PlaceSlot
AgentBase<T>.SystemPrompt rendered through PromptTemplate per call.{{input}} is filled automatically. Other slots need an override.
RagAgent's system promptinjects retrieved chunks before delegating to the base render.
GroupChatAdmin's selection promptbuilds the candidate list internally.
Reflexion's revise promptinjects the critic's feedback into the actor's next call.

You don't see those internally; they're documented here for completeness.

When to override RenderSystemPrompt (sync) vs RenderSystemPromptAsync

  • Override the sync version when the slot fill-in is fast and in-memory (constructor-captured value, computed string).
  • Override the async version when you need to do I/O — RAG retrieval, profile lookup, vector-store search. The framework awaits it before calling the LLM.

If you override only RenderSystemPrompt, the async version delegates to it automatically.