Skip to main content
Version: 1.1

AI Templates

A Liquid-based template engine for managing, rendering, and composing AI system prompts from multiple sources.

Quick Start​

builder.Services.AddCoreAITemplating();
info

You rarely need to call this directly — AddCoreAIServices() chains it automatically.

Problem & Solution​

Hard-coding system prompts in C# makes them difficult to maintain, localize, and customize. The template system:

  • Stores prompts as markdown files with front-matter metadata
  • Renders them with Liquid syntax for dynamic content
  • Discovers templates from multiple sources (embedded resources, file system, code)
  • Supports merging multiple templates into a single prompt

Services Registered by AddCoreAITemplating()​

AddCoreAITemplating() builds on the lower-level AddTemplating() registration and also adds the built-in AI template source metadata for SystemPrompt and Profile templates.

ServiceImplementationLifetimePurpose
ITemplateParserDefaultMarkdownTemplateParserSingletonParses markdown front-matter templates
ITemplateEngineFluidTemplateEngineSingletonRenders Liquid templates
ITemplateServiceDefaultTemplateServiceScopedUnified template discovery and rendering
OptionsTemplateProvider—SingletonTemplates registered via code
FileSystemTemplateProvider—SingletonGeneric templates discovered directly from Templates/
PromptsFileSystemTemplateProvider—SingletonSystem prompt templates discovered from Templates/Prompts/

Key Interfaces​

ITemplateService​

The main service for working with templates.

public interface ITemplateService
{
Task<IReadOnlyList<Template>> ListAsync();
Task<Template> GetAsync(string id);
Task<string> RenderAsync(string id, IDictionary<string, object> arguments = null);
Task<string> MergeAsync(
IEnumerable<string> ids,
IDictionary<string, object> arguments = null,
string separator = "\n\n");
}

ITemplateEngine​

Renders Liquid templates. You can replace this with a custom engine.

public interface ITemplateEngine
{
Task<string> RenderAsync(string template, IDictionary<string, object> arguments);
bool TryValidate(string template, out IReadOnlyList<string> errors);
}

ITemplateProvider​

Implement to supply templates from a custom source (database, API, etc.).

public interface ITemplateProvider
{
Task<IReadOnlyList<Template>> GetTemplatesAsync();
}

Template File Format​

Templates are markdown files with YAML front matter. You can either:

  • store generic templates in Templates/ and set Kind explicitly
  • store system prompt templates in Templates/Prompts/, which defaults Kind to SystemPrompt

Example generic template:

---
Title: Helpful Assistant
Kind: SystemPrompt
Description: A general-purpose helpful assistant prompt
Category: General
IsListable: true
---
You are a helpful assistant. Today's date is {{ "now" | date: "%Y-%m-%d" }}.

{% if user_name %}
You are assisting {{ user_name }}.
{% endif %}

The same content can live under Templates/Prompts/ without repeating Kind:

---
Title: Helpful Assistant
Description: A general-purpose helpful assistant prompt
Category: General
IsListable: true
---
You are a helpful assistant. Today's date is {{ "now" | date: "%Y-%m-%d" }}.

{% if user_name %}
You are assisting {{ user_name }}.
{% endif %}

Front Matter Fields​

FieldTypeDescription
TitlestringDisplay name
DescriptionstringHuman-readable description
CategorystringGrouping category
IsListableboolWhether the template appears in listing APIs
KindstringSemantic template kind such as SystemPrompt or Profile

Registering Templates​

From Embedded Resources​

Store .md files as embedded resources under Templates/ in your assembly. Files under Templates/Prompts/ default to SystemPrompt; files elsewhere should set Kind in front matter:

builder.Services.AddTemplatesFromAssembly(typeof(MyClass).Assembly, source: "MyApp");

From Code​

builder.Services.AddTemplating(options =>
{
options.AddTemplate("my-template", "You are {{ role }}.", metadata =>
{
metadata.Title = "Role Template";
});
});

From File System​

builder.Services.AddTemplating(options =>
{
options.AddDiscoveryPath("/app/templates");
});

With that discovery path:

  • /app/templates/Templates/*.md is treated as the generic template root and should declare Kind
  • /app/templates/Templates/Prompts/*.md is treated as prompt-only and defaults Kind to SystemPrompt

Generic Templates/ discovery is intentionally not recursive. Subfolders can be reserved for other provider-specific conventions without being double-loaded.

Configuration​

TemplateOptions​

services.Configure<TemplateOptions>(options =>
{
options.AddDiscoveryPath("/path/to/templates");
options.AddTemplate(new Template { /* ... */ });
});

Template Examples​

Example 1: Customer Support Assistant​

Templates/Prompts/customer-support.md
---
Title: Customer Support Assistant
Description: Prompt for a customer-facing support chatbot
Category: Support
IsListable: true
---
You are a customer support assistant for {{ company_name | default: "our company" }}.

## Your Responsibilities
- Answer product questions accurately and concisely.
- Help customers troubleshoot common issues.
- Escalate to a human agent when you cannot resolve an issue.

## Guidelines
- Always be polite and professional.
- Never share internal pricing or unreleased product details.
- Today's date is {{ "now" | date: "%B %d, %Y" }}.

{% if support_hours %}
Our support hours are {{ support_hours }}. If the customer is contacting us outside
these hours, let them know when support will be available.
{% endif %}

{% if knowledge_base_context %}
## Relevant Knowledge Base Articles
{{ knowledge_base_context }}
{% endif %}

Example 2: Content Writer with Tone Control​

Templates/Prompts/content-writer.md
---
Title: Content Writer
Description: Generates content with configurable tone and style
Category: Content
IsListable: true
---
You are a professional content writer.

**Tone**: {{ tone | default: "professional" }}
**Target audience**: {{ audience | default: "general" }}
**Max length**: {{ max_words | default: "500" }} words

{% case tone %}
{% when "casual" %}
Write in a friendly, conversational style. Use contractions and simple language.
{% when "formal" %}
Write in a formal, authoritative style. Avoid contractions and colloquialisms.
{% when "technical" %}
Write with precision. Use domain-specific terminology and cite sources where possible.
{% endcase %}

Example 3: RAG-Augmented Assistant​

Templates/Prompts/rag-assistant.md
---
Title: RAG Assistant
Description: Assistant that uses retrieved documents for grounded answers
Category: RAG
IsListable: false
---
You are a knowledgeable assistant. Answer questions using ONLY the provided context.

## Rules
- If the context does not contain enough information, say so honestly.
- Always cite which document or section your answer comes from.
- Do not make up information that is not in the provided context.

{% if retrieved_documents %}
## Retrieved Context
{% for doc in retrieved_documents %}
### Document: {{ doc.title }}
{{ doc.content }}
{% endfor %}
{% endif %}

Liquid Reference​

The template engine uses the Fluid Liquid implementation. Here are the most commonly used filters and tags in templates:

Filters​

FilterExampleOutput
date{{ "now" | date: "%Y-%m-%d" }}2025-01-15
default{{ name | default: "User" }}User (if name is nil)
upcase{{ "hello" | upcase }}HELLO
downcase{{ "HELLO" | downcase }}hello
truncate{{ text | truncate: 100 }}First 100 characters
strip_html{{ html_content | strip_html }}Plain text
escape{{ user_input | escape }}HTML-escaped string
size{{ items | size }}Number of items
join{{ tags | join: ", " }}Comma-separated string

Tags​

{% comment %} Conditional content {% endcomment %}
{% if variable %}...{% elsif other %}...{% else %}...{% endif %}

{% comment %} Loops {% endcomment %}
{% for item in collection %}{{ item.name }}{% endfor %}

{% comment %} Switch/case {% endcomment %}
{% case variable %}{% when "value1" %}...{% when "value2" %}...{% endcase %}

{% comment %} Variable assignment {% endcomment %}
{% assign greeting = "Hello, " | append: user_name %}

Template Composition​

For complex prompts, compose templates using the MergeAsync method instead of creating one monolithic template:

// Render multiple templates and merge into a single prompt
var systemPrompt = await templateService.MergeAsync(
["base-personality", "safety-rules", "rag-instructions"],
arguments: new Dictionary<string, object>
{
["user_name"] = currentUser.DisplayName,
["company"] = tenant.Name,
},
separator: "\n\n---\n\n"
);

Best practices for template composition:

  • Base templates — Define shared personality, tone, and general rules.
  • Feature templates — Add instructions for specific capabilities (RAG, tool use, safety).
  • Context templates — Inject dynamic, session-specific content (user info, retrieved docs).
tip

Keep each template focused on a single concern. Compose them at runtime rather than duplicating instructions across templates. This makes it easy to update one aspect (e.g., safety rules) without touching every template.

Testing Templates​

Validate Liquid Syntax​

Use ITemplateEngine.TryValidate() to check for syntax errors before saving a template:

var engine = serviceProvider.GetRequiredService<ITemplateEngine>();

var template = "Hello {{ user_name }}, today is {{ 'now' | date: '%A' }}.";

if (engine.TryValidate(template, out var errors))
{
Console.WriteLine("Template is valid.");
}
else
{
foreach (var error in errors)
{
Console.WriteLine($"Error: {error}");
}
}

Render with Test Arguments​

Use ITemplateService.RenderAsync() to preview the final output:

var result = await templateService.RenderAsync("customer-support", new Dictionary<string, object>
{
["company_name"] = "Contoso",
["support_hours"] = "9 AM – 5 PM EST",
["knowledge_base_context"] = "Article: How to reset your password...",
});

// Inspect the rendered output
Console.WriteLine(result);

Unit Testing Templates​

public sealed class TemplateRenderingTests
{
[Fact]
public async Task CustomerSupportTemplate_ShouldIncludeCompanyName()
{
// Arrange
var engine = new FluidTemplateEngine();
var template = "You are a support agent for {{ company_name }}.";
var arguments = new Dictionary<string, object>
{
["company_name"] = "TestCorp",
};

// Act
var result = await engine.RenderAsync(template, arguments);

// Assert
Assert.Contains("TestCorp", result);
}

[Fact]
public void InvalidTemplate_ShouldReturnErrors()
{
var engine = new FluidTemplateEngine();
var invalid = "Hello {{ user_name | nonexistent_filter }}";

var isValid = engine.TryValidate(invalid, out var errors);

Assert.False(isValid);
Assert.NotEmpty(errors);
}
}

Custom Template Provider​

Implement ITemplateProvider to load templates from a custom source (e.g., a database or remote API):

public sealed class DatabaseAITemplateProvider(
ISession session,
ITemplateParser parser) : ITemplateProvider
{
public async Task<IReadOnlyList<Template>> GetTemplatesAsync()
{
var records = await session
.Query<PromptTemplateRecord, PromptTemplateIndex>()
.ListAsync();

var templates = new List<Template>();

foreach (var record in records)
{
// Parse the markdown content (with YAML front-matter) into an Template
if (parser.TryParse(record.Content, out var template))
{
template.Id = record.TemplateId;
template.Source = "Database";
templates.Add(template);
}
}

return templates;
}
}

Register the provider:

builder.Services.AddSingleton<ITemplateProvider, DatabaseAITemplateProvider>();
info

All registered ITemplateProvider instances are queried by ITemplateService. Templates from multiple providers are merged into a single collection. If two providers return templates with the same Id, the first discovered template wins and later duplicates are ignored.