Templating & macros

Every prompt Marginalia sends is rendered from a template: the master template, the user prompt, lorebook entries and the summary prompt. Templates are Handlebars with SillyTavern-compatible macros on top. This page explains how that is implemented and how to add variables and macros. What users can write is documented in the user guide: Prompt templates and the Macro reference.

The code is in domain/templates/ (data objects, context, variables), domain/templates/macros/ (translator, helpers, macro registry) and domain/service/impl/TemplateServiceImpl.java.

Rendering in one picture

flowchart LR
    T["template text<br/>(SillyTavern macros<br/>+ Handlebars)"] --> MT["MacroTranslator"]
    MT --> HB["pure Handlebars source<br/>+ literal table"]
    HB --> C["Handlebars.compileInline<br/>(cached)"]
    C --> R["apply(Context)"]
    D["TemplateData<br/>+ TemplateContext"] --> R
    H["MacroHelpers<br/>st_* helpers"] --> R
    R --> P["postProcess<br/>({{trim}})"]
    P --> O["prompt text"]

TemplateService.processTemplate(template, name, data):

  1. Translate the SillyTavern syntax into plain Handlebars (MacroTranslator.translate).

  2. Compile the result with Handlebars. Compiled templates are cached by template text (Caffeine, 500 entries, 24 h after last access), so a lorebook entry is parsed once, not on every generation.

  3. Render it against a Handlebars Context built from the TemplateData (MacroHelpers.createContext).

  4. Post-process the output: {{trim}} markers are removed together with the newlines around them.

Handlebars runs with EscapingStrategy.NOOP - the output is a prompt, not HTML, so quotes, & and < stay as they are.

isValidTemplate(template, name) only does steps 1-2, so it catches syntax errors. isValidTemplate(template, name, dataClass) also walks the compiled template (Template.collect of variables and sections, in all branches) and reports names that would end in helperMissing (see Template data) in ValidationResult.unknownNames(). They are warnings, isValid() stays true; the UI shows them as the field's helper text (TemplateHints.showWarnings). Names relative to the current context (this, ., ../x, @index) and parameters ({{#if name}}) are not checked.

Where templates are rendered

Template Rendered in Data class Its own variables
User prompt PrepareConstantsStep UserPromptData povCharacter, sceneSetting, presentCharacters, instructions
Lorebook entry payload ProcessLorebookStep (one data object for all active entries) LorebookTemplateData the four above + narrativePov, narrativeTense, style
Master template PrepareContentStep (twice: token estimate on a fork, then for real) MasterTemplateData backgroundLore, narrativePov, narrativeTense, style, summaries
Summary prompt SummaryServiceImpl.createSummaryPayload SummaryTemplateData backgroundLore, text (the parts to summarize)
Meta summary prompt SummaryServiceImpl.createMetaSummaryPayload MetaSummaryTemplateData backgroundLore, summaryBlocks (the summaries to merge, oldest first)

The order of the generation pipeline matters: the user prompt is rendered first, then the lorebook entries, then the master template - a variable set in the user prompt is visible in lore, and lore can set variables the master template reads.

POV, tense and style are values, not templates: they are passed into the templates as variables but not rendered themselves.

Template data

Each template gets an instance of a TemplateData subclass. Its bean properties (getters declared in the subclass - TemplateData's own methods are excluded) are the template's variables. A field annotated @LocalizedTemplateDescription(loc = L.DESC_TEMPLATE_...) is listed, with that description, in the hints popover of the prompt fields (TemplateHints), followed by the shared context properties the class doesn't declare.

Names are resolved by MacroHelpers.TemplateDataValueResolver, in this order:

  1. a property of the data object - null renders as an empty string,

  2. a property of the shared TemplateContext (povCharacter, presentCharacters, sceneSetting, instructions, narrativePov, narrativeTense, style, manuscriptName, manuscriptDescription) - that is why these work in every template,

  3. an argument-less macro used as a value, so {{#if user}} works like {{if user}},

  4. otherwise the name is missing and Handlebars' helperMissing hook renders the word Error, so typos are visible in the prompt (MacroHelpers.resolves is the same check, used by validation).

Reflection into anything else is blocked: the resolver returns nothing for other names instead of letting the default resolvers call methods of TemplateData (there is deliberately no getTemplateContext() getter).

The template context

TemplateContext is the environment macros read: the turn fields, POV / tense / style, book name and description, model name and token limits, the generation type, the story so far (storyMessages), the last instructions and time, the latest summary, the variables, a clock, a random generator and the seed for {{pick}}.

One context is created per generation (GenerationStepBase.getTemplateContext, stored as GenerationProperties.TEMPLATE_CONTEXT) and set into every data object of that generation, so all templates see the same variables. fork() makes a copy with copied variables for renders whose side effects must not count - the token estimate of the master template uses one.

Summaries and meta summaries build their own context (SummaryServiceImpl.createTemplateContext) from the part being summarized (for a meta summary: the part it is stored on); the variable changes made by a summary prompt are not stored.

How macros are translated

MacroTranslator scans the template, recognizes SillyTavern constructs, and rewrites only those into calls of helpers named st_*. Everything else - {{variable}}, {{#section}}...{{/section}}, {{#if}}, {{#each}}, {{.}}, {{!-- comments --}}, triple mustaches - is left for Handlebars untouched.

Real translator output:

Template Handlebars source Literal table
Hello {{user}} Hello {{st_user 0}}  
{{getvar::{{char}}_mood}} {{st_getvar 1 (st_concat 3 (st_char 2) (st_lit 0))}} _mood
{{#sceneSetting}}Scene: {{.}}{{/sceneSetting}} unchanged  
{{if .mood == sad}}cry{{else}}smile{{/if}} {{#st_if 0 (st_var 1 "local" "mood" "==" (st_lit 0)) false}}cry{{else}}smile{{/st_if}} sad
{{.turn++}} {{st_var 0 "local" "turn" "++"}}  
{{$count += 2}} {{#st_var 1 "global" "count" "+="}}2{{/st_var}}  
{{random::a::b::c}} {{st_random 2 (st_lit 0) (st_lit 1) (st_lit 2)}} a, b, c
{{setvar name}}Bob{{/setvar}} {{#st_setvar 0 (st_lit 0)}}Bob{{/st_setvar}} name
{{// note}} removed  
\{\{x\}\} {{st_lit 1}}x{{st_lit 2}} {{, }}
<USER> {{st_user 1}}  
{{unknownmacro::x}} {{st_lit 3}} {{unknownmacro::x}}

What the translator handles:

  • Names are case-insensitive ({{User}}, {{USER}}); template variables are not.
  • Arguments in all SillyTavern forms: {{macro arg}}, {{macro::a::b}}, legacy {{macro:arg}}.
  • Nesting: macros inside arguments are evaluated first and joined with st_concat.
  • Scoped macros: a macro that gets fewer arguments than it needs becomes a block - {{setvar name}}...{{/setvar}}
    • and the block content is its last argument. # ({{#setvar ...}}) keeps the block's whitespace.
  • Conditionals {{if cond}}...{{else}}...{{/if}} and {{if !cond}}; the condition can be a macro, a variable shorthand or a template variable (st_cond decides which).
  • Variable shorthands {{.local}}, {{$global}} with the operators =, ++, --, +=, -=, ||, ??, ||=, ??=, ==, !=, >, >=, <, <= (st_var).
  • Comments {{// ...}} and blocks {{//}}...{{///}} are removed.
  • Escapes \{\{...\}\} produce literal braces; Handlebars' own \{{ still works.
  • Legacy markers <USER>, <BOT>, <CHAR>, <GROUP>, <CHARIFNOTGROUP>.
  • Unknown macros with :: arguments are output literally, as SillyTavern does; other unknown names are passed to Handlebars as variables.

User text never becomes Handlebars source. Every argument and literal goes into the literal table and is referenced by index through st_lit, so a lorebook entry containing }} or {{#each}} in an argument can't break or inject into the generated template.

The first parameter of every st_* call is the site - the index of the call in the template. {{pick}} uses it to make each position pick independently but stably.

Helpers

MacroHelpers.register registers on the Handlebars instance:

Helper Does
st_<macro> One per macro in Macros; unpacks the arguments (plus the block content for block calls) into a MacroCall and calls the macro's function on the TemplateData.
st_lit Returns a literal from the table.
st_concat Joins its arguments (nested macros in arguments).
st_prop Resolves a template property by name.
st_cond Evaluates an if condition: argument-less macro, template property, or the text itself (non-empty text is true).
st_if The conditional itself, block or inline.
st_var Variable shorthands.
helperMissing Renders Error for unknown names.

Truthiness (TemplateData.isTruthy): null, empty collections and the strings "", false, 0, off, no (case-insensitive) are false; everything else is true.

The macro registry

Macros (in domain/templates/macros/) is a static registry of MacroDefinitions - name, signature for the UI, minimum and maximum number of arguments, description key and the function:

define("{{roll::1d20}}", 1, 1, L.DESC_MACRO_ROLL, (d, c) -> d.roll(c.arg(0)), "roll");
  • The first name is canonical; further names are aliases (char, charIfNotGroup).
  • Definitions with a description appear in the hints popover (Available Macros).
  • ignored(...) registers SillyTavern macros that have no meaning in Marginalia (instruct sequences, swipes, author's notes, character card fields...): they render as an empty string (or false), so imported world info doesn't leak raw macros into prompts.

The macro logic lives in TemplateData (user(), lastMessage(), time(offset), setVar(...), pick(...)...), using the TemplateContext. A few mappings worth knowing, since Marginalia has no chat:

SillyTavern In Marginalia
{{user}}, {{char}} the POV character
{{group}} the present characters (or the POV character)
{{notChar}} present characters except the POV character
{{description}} the book's description
{{scenario}} the scene setting
{{input}} the instructions of this turn
{{lastMessage}} the text of the last part of the branch
{{lastUserMessage}} the instructions of the last part
{{summary}} the latest summary on the branch

Variables

TemplateVariables stores the variables of getvar / setvar and the shorthands, as strings:

Scope Macros Stored in Loaded from
local {{getvar}}, {{setvar}}... {{.name}} the generated part's attributes the last part of the branch the new part continues
global {{getglobalvar}}... {{$name}} the book's attributes the book

Both are kept under the attribute key templateVariables and saved by GenerateNewMessageStep after the prompt is rendered (see Domain model). Because local variables are read from the part being continued, regenerating or branching doesn't apply a turn's changes twice. Numbers are handled with BigDecimal (addVar, ++, +=); non-numeric add concatenates.

Randomness and time

  • {{random}} and {{roll}} use the context's Random - a new result on every render.

  • {{pick}} is stable: the choice is derived from SHA-256 of the book id (pickSeed), the template key (a hash of the template text), the call site and the options. The same template in the same book picks the same option every time; editing the template or moving the macro can change the pick.

  • Time macros ({{time}}, {{date}}, {{weekday}}, {{datetimeformat}}...) use the context's clock, which is the server's default time zone - UTC in the Docker image - unless an offset is given ({{time::UTC+2}}). MomentFormat converts moment.js patterns (SillyTavern's) for {{datetimeformat}}.

  • {{trim}} outputs a marker - the word trim wrapped in the private-use character U+E000, so normal text can't contain it - which postProcess removes together with the surrounding newlines.

Adding a template variable

  1. Add a field with getter and setter to the data class (e.g. MasterTemplateData) and annotate the field with @LocalizedTemplateDescription(loc = L.DESC_TEMPLATE_X).

  2. Add the key to L and the text to LocalizationEN - it is shown in the hints.

  3. Set the value where the data object is filled (the generation step or SummaryServiceImpl).

  4. If it should be available in every template, add it to TemplateContext (field, fork(), property(name)) and fill it in GenerationStepBase.createTemplateContext instead.

  5. Check that the name isn't also a macro name - the translator turns known macro names into macro calls, which would hide the variable.

Adding a macro

  1. Implement the logic as a public method of TemplateData using templateContext() and variables().

  2. Register it in the static block of Macros with define(signature, minArgs, maxArgs, L.DESC_MACRO_X, function, names...). maxArgs -1 means unlimited; a macro with minArgs > 0 called with fewer arguments becomes a block whose content is the last argument.

  3. Add the description to L / LocalizationEN.

  4. Document it in the user guide's Macro reference.

  5. Add rendering tests to templates/MacroRenderingTest.

No changes to the translator or the helpers are needed - helpers are registered for every definition.

Tests

Test Covers
templates/MacroRenderingTest Macros, shorthands, conditionals, escaping, nesting - rendered end to end.
templates/TemplateServiceTest Property resolution, no HTML escaping, Error for unknown names, sections, validation, macro arguments can't inject Handlebars, variables shared through the data / context, forks.
templates/DefaultTemplatesTest The built-in templates from Defaults render.
templates/MacroLorebookFixtureTest A SillyTavern-style lorebook full of macros (src/test/resources/macro-test.json).
domain/templates/TemplateHelpersTest Helper-level details.

TemplateTestBase provides a template service and a LorebookTemplateData with a fresh context whose clock is fixed (2026-03-15T14:30:45Z, UTC) and whose random generator is seeded. Run them with mvn test -Dtest='Macro*Test,Template*Test'.