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
stringonly. 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
| Place | Slot |
|---|---|
AgentBase<T>.SystemPrompt rendered through PromptTemplate per call. | {{input}} is filled automatically. Other slots need an override. |
RagAgent's system prompt | injects retrieved chunks before delegating to the base render. |
GroupChatAdmin's selection prompt | builds the candidate list internally. |
| Reflexion's revise prompt | injects 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.