Skip to main content
Version: Latest

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/
AIProfileSystemPromptTemplateProvider—ScopedSystemPrompt AI profile templates from the AI profile template store, identified by name

AIProfileSystemPromptTemplateProvider exposes each SystemPrompt AI profile template under its name, so a prompt selection on an AI profile or chat interaction can store the name. The earlier identifiers, ai-profile: followed by the item id or the name, are kept as aliases. When no AI profile template store is registered, the provider returns no templates.

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.

Templates that change while the application runs​

When users can add or edit the templates a provider returns, implement IDynamicTemplateProvider instead of ITemplateProvider. It adds no members; it tells the template service how to treat the provider:

  • DefaultTemplateService reads every other provider first, so a dynamic template never replaces a template with the same Id that the application ships with.
  • A template service that caches templates for longer than a scope overrides GetStaticTemplatesAsync and caches only what it returns. Dynamic providers are read again in every scope, so an edit applies to the next request.
public sealed class DatabaseAITemplateProvider(
ISession session,
ITemplateParser parser) : IDynamicTemplateProvider
{
// Same GetTemplatesAsync as above.
}

builder.Services.AddScoped<ITemplateProvider, DatabaseAITemplateProvider>();

A template can also list Aliases, identifiers it was stored under before. ITemplateService.GetAsync and RenderAsync match an Id first and an alias second.