A flexible and expressive template engine for Large Language Model (LLM) prompts and structured message generation in C#.
LLT is designed to make prompt engineering and content generation as powerful and maintainable as regular C# code.
- Razor-inspired DSL with
@if,@foreach, expressions, metadata, and inline variables - Full expression evaluator β arithmetic, logic, ternary, null-coalescing (
??), property checks (?:), safe navigation (?.), array/object literals, and method calls - Message-oriented syntax for LLM role structures (system, user, assistant, tool)
- Powerful metadata filtering β select the best template by language, model, or custom qualifiers
- Metadata fallback schemes β hierarchical language fallback (
en-USβenβ sibling β default) - Composable templates β reuse and render nested templates inside others, with optional context override (
@render 'name' with ctx) - Extensible function system β built-in functions plus custom ones registered from C#
- Deterministic formatting β indentation is normalized during parsing, inner spacing is preserved, output is predictable
- Library-driven workflow β import templates from assemblies, files, or strings
- Body parsing β parse template bodies without the
@templatewrapper viaParseTextTemplate/ParseMessagesTemplate
Install the package from NuGet:
dotnet add package LLTSharp
Or via the Package Manager Console:
Install-Package LLTSharp
var parser = new LLTParser();
var templateStr = """
@template GreetingTemplate
{
Greetings, @name!
@if age > 18
{
You are an adult.
}
else
{
You are too young!
}
Have a nice day.
}
""";
var template = parser.Parse(templateStr).First();
var adult = new { name = "Andrew", age = 20 };
var young = new { name = "Alice", age = 15 };
Console.WriteLine(template.Render(adult));
Console.WriteLine(template.Render(young));Output:
Greetings, Andrew!
You are an adult.
Have a nice day.
Greetings, Alice!
You are too young!
Have a nice day.
Multiple templates can be stored, versioned, and retrieved by language or model ID:
var lib = new TemplateLibrary();
lib.ImportFromString("""
@template greeting
{
@metadata { lang: 'en' }
Hello!
}
@template greeting
{
@metadata { lang: 'en', model: 'gpt-4' }
Hello GPT-4!
}
@template greeting
{
@metadata { lang: 'es' }
Hola!
}
""");
var template = lib.Retrieve("greeting", new LanguageMetadata("en"), new TargetModelMetadata("gpt-4"));
Console.WriteLine(template.Render()); // Hello GPT-4!Templates are resolved by metadata specificity, similar to CSS selector priority. If no template matches exactly, use fallback retrieval:
// en-US β en β any sibling of the same language root β default language
lib.SetLanguageFallbackScheme(new HierarchicalLanguageFallbackScheme("en"));
var fallback = lib.RetrieveWithFallback("greeting", new LanguageMetadata("en-US"));LLT supports special @messages syntax for structured chat prompts:
@messages template ChatBot
{
@metadata { language: 'en', version: 1 }
@system message {
You are a helpful assistant.
Here is your instructions:
@foreach instruction in instructions {
Instruction: @instruction
}
}
@foreach name in names {
@message {
@role 'user'
Hello, I am @name!
}
}
}
Rendered result: a sequence of chat messages with proper roles (system, user, assistant, tool).
Roles can be declared statically (@system message, @user message, @assistant message, @tool message)
or dynamically via @message { @role <expression> ... }.
| Literal | Examples |
|---|---|
| Numbers | 42, 3.14, -7 |
| Strings | 'single', "double" |
| Booleans | true, false |
| Null | null |
| Arrays | [1, 2, 3], ['a', ctx?.value], [] (trailing commas allowed) |
| Objects | { name: 'Fish', qty: 5 }, { 'key with spaces': 1, [expr]: 2 } |
| Syntax | Meaning |
|---|---|
ctx |
The root of the rendering context |
name |
Property of the root context (same as ctx.name) |
?name |
Safe property access β returns null instead of throwing when missing |
a.b / a.b.c |
Property access (chained) |
a?.b |
Safe navigation β null if a is null or lacks b |
a[expr] |
Index access (arrays, dictionaries, strings) |
a?[expr] |
Safe index access |
a.method(args) |
Method call (any public .NET method) |
a?.method(args) |
Safe method call |
func(args) |
Global template function (see Functions) |
| Operator | Meaning | Example |
|---|---|---|
- |
Negation | @(-x) |
! |
Logical NOT | @(!flag) |
# |
Length (string / array / dictionary) | @(#name), @if #items > 0 |
+ |
No-op (kept for symmetry) | @(+x) |
| Precedence | Operators | Meaning |
|---|---|---|
| 1 | * / % |
Multiplication, division, modulus |
| 2 | + - |
Addition, subtraction, string concatenation |
| 3 | < <= > >= |
Relational |
| 4 | ?: |
Has operator β checks whether the left operand has the right property |
| 5 | == != |
Equality |
| 6 | && |
Logical AND |
| 7 | || |
Logical OR |
| 8 | ?? |
Null-coalescing β right operand when the left is null or missing |
| 9 | ? : |
Ternary conditional |
@template t {
1: @(?value ?? 'No value')
2: @(ctx ?: 'value' ? value ?? 'Null' : 'No value')
}
| Render context | Output |
|---|---|
new { value = "Hello" } |
1: Hello / 2: Hello |
new { value = (string?)null } |
1: No value / 2: Null β the property exists but is null |
new { } |
1: No value / 2: No value β the property is missing |
??treats missing andnullthe same way.?:(has) distinguishes them: it only checks existence, so it works great as the condition when you need different fallbacks fornullvs. missing:
@(user ?: 'name' ? user.name : 'Anonymous')
@(user?.name ?? 'Anonymous') @/ Safe navigation + coalescing
Tip
@-statements in plain text accept simple expressions (unary + member access).
Wrap binary expressions in parentheses: @(a + b) β inside @if, @while etc. full expressions are allowed.
An expression can be followed by : and a format string:
Price: @price:'0.00'
| Statement | Syntax | Description |
|---|---|---|
| If | @if cond { ... } |
Conditional block |
| Else | else { ... }, else if cond { ... } or @else { ... } |
Optional @ before else is allowed |
| Foreach | @foreach item in items { ... } |
Iteration; loop variable is scoped to the block |
| While | @while cond { ... } |
Conditional loop |
| Let | @let x = expr |
Declares a new variable (lexically scoped) |
| Assign | @x = expr |
Assigns to an existing variable |
| Render | @render 'name', @render 'name' with expr |
Renders another template, optionally with a new context |
| Output | @expr, @(expr), @expr:'format' |
Prints an expression value |
| Comment | @/ line comment |
C#-style line comment β to the end of the line |
| Comment | @* block comment *@ |
Razor-style block comment |
Comments are skipped by the parser and can be placed wherever whitespace is allowed, including inside template bodies.
Escapes: @@ renders a literal @, {{ renders {, }} renders }.
Multi-line raw text can be wrapped in five backticks to avoid escaping:
@template code_sample
{
`````
@if isNotParsed { this is raw text, not a statement }
`````
}
Attach metadata to a template with the @metadata block (only constant values are allowed):
@template greeting
{
@metadata { lang: 'en', model: 'gpt-4', version: 2 }
Hello!
}
Built-in metadata keys (parsed by the corresponding factories):
| Key | Metadata type |
|---|---|
lang |
LanguageMetadata |
model |
TargetModelMetadata |
model_family |
TargetModelFamilyMetadata |
version |
VersionMetadata |
Pass factories to Parse so the keys are recognized; unknown keys become AdditionalMetadata:
var parser = new LLTParser();
var templates = parser.Parse(src, new MetadataFactory[]
{
new LanguageMetadataFactory(),
new TargetModelMetadataFactory(),
new VersionMetadataFactory(),
});public class MyMetadata : IMetadata
{
public MyMetadata(string value) => Value = value;
public string Value { get; }
}
public class MyMetadataFactory : MetadataFactory
{
public override bool TryCreateMetadata(string key, TemplateDataAccessor value, out IMetadata metadata)
{
if (key == "my_key")
{
metadata = new MyMetadata(value.ToString());
return true;
}
metadata = null;
return false;
}
}lib.SetLanguageFallbackScheme(new HierarchicalLanguageFallbackScheme("en"))β walks up the language hierarchy (en-USβen), then tries siblings, then the default language.lib.SetLanguageFallbackScheme(new MajorLanguageFallbackScheme())β falls back to the major language group.lib.SetFallbackScheme(typeof(LanguageMetadata), scheme)β register a customMetadataFallbackScheme<T>for any metadata type.
Retrieval API: Retrieve, TryRetrieve, TryRetrieveBest, RetrieveWithFallback, RetrieveAll β
each with optional identifier and metadata arguments.
Built-in functions (TemplateFunctions.All):
| Function | Description | Example |
|---|---|---|
type(x) |
Type name of the value (string, number, boolean, object, array, null, ...) |
@type(item) |
length(x) |
Length of a string, array or dictionary | @length(items) |
strcat(a, b, ...) |
Concatenates values into a string | @strcat('Hi, ', name) |
substr(s, start, len) |
Substring | @substr(name, 0, 3) |
Register custom functions and pass them to Render:
var functions = new TemplateFunctionSet(includeDefault: true,
new TemplateFunction("shout", (self, args) => args[0].ToString().ToUpperInvariant()));
var rendered = template.Render(new { name = "Andrew" }, functions);Hello, @shout(name)! @/ β Hello, ANDREW!
- Leading indentation is normalized during parsing: every line loses up to
depth Γ TabSizeleading whitespace, wheredepthis the block nesting level. You can write templates with comfortable indentation β the common base indent is stripped and the output stays clean - Inner whitespace, line breaks and blank lines are preserved as written
- Leading/trailing blank lines of blocks are trimmed; indentation before complex statements
(
@if,@foreach,@while) is removed - Lines containing only non-rendering constructs β
@/and@* *@comments,@letdeclarations, variable assignments β are removed entirely: the surrounding line breaks are trimmed so they leave no blank lines in the output TabSize(default4) onLLTParsercontrols how many columns one indent level takes during refinement@letvariables are lexically scoped- Loop variables do not leak outside their block
- Nested
@ifand@foreachblocks behave predictably, matching C#βlike logical semantics
- LLTParser β parses source text into ASTs (
TemplateNode,TemplateExpressionNode, etc.); supports full-file parsing (Parse) and body-only parsing (ParseTextTemplate,ParseMessagesTemplate) - Template β runtime object capable of rendering with dynamic context (
TextTemplate,MessagesTemplate) - TemplateLibrary β registry and loader for collections of templates with filtering and fallbacks
- Metadata System β extensible mechanism for attaching attributes such as language, model, or custom version
- Data Accessors β reflection / dictionary / array wrappers (
DataAccessorFactory) withPropertiesToLowerCase,KeysToLowerCaseandSnapshotoptions - Function System β
TemplateFunctionSet/TemplateFunctionfor extending template logic - ChatMessage Model β unified representation for structured message templates (
system,user,assistant,tool)
MIT License Β© 2025