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):
-
Translate the SillyTavern syntax into plain Handlebars (
MacroTranslator.translate). -
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.
-
Render it against a Handlebars
Contextbuilt from theTemplateData(MacroHelpers.createContext). -
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 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
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:
-
a property of the data object -
nullrenders as an empty string, -
a property of the shared
TemplateContext(povCharacter,presentCharacters,sceneSetting,instructions,narrativePov,narrativeTense,style,manuscriptName,manuscriptDescription) - that is why these work in every template, -
an argument-less macro used as a value, so
{{#if user}}works like{{if user}}, -
otherwise the name is missing and Handlebars' helperMissing hook renders the word
Error, so typos are visible in the prompt (MacroHelpers.resolvesis 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:
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.
- and the block content is its last argument.
- Conditionals
{{if cond}}...{{else}}...{{/if}}and{{if !cond}}; the condition can be a macro, a variable shorthand or a template variable (st_conddecides 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:
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 (orfalse), 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:
Variables
TemplateVariables stores the variables of getvar / setvar and the shorthands, as strings:
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'sRandom- 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}}).MomentFormatconverts moment.js patterns (SillyTavern's) for{{datetimeformat}}. -
{{trim}}outputs a marker - the wordtrimwrapped in the private-use character U+E000, so normal text can't contain it - whichpostProcessremoves together with the surrounding newlines.
Adding a template variable
-
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). -
Add the key to
Land the text toLocalizationEN- it is shown in the hints. -
Set the value where the data object is filled (the generation step or
SummaryServiceImpl). -
If it should be available in every template, add it to
TemplateContext(field,fork(),property(name)) and fill it inGenerationStepBase.createTemplateContextinstead. -
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
-
Implement the logic as a public method of
TemplateDatausingtemplateContext()andvariables(). -
Register it in the static block of
Macroswithdefine(signature, minArgs, maxArgs, L.DESC_MACRO_X, function, names...).maxArgs-1 means unlimited; a macro withminArgs > 0called with fewer arguments becomes a block whose content is the last argument. -
Add the description to
L/LocalizationEN. -
Document it in the user guide's Macro reference.
-
Add rendering tests to
templates/MacroRenderingTest.
No changes to the translator or the helpers are needed - helpers are registered for every definition.
Tests
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'.