Skip to content

Macros

Macros are {{name}} placeholders that get replaced with dynamic content at the moment a prompt is built. They show up in presets, world info, character cards, chat messages, slash commands, regex replacements — anywhere prompt text is assembled. By the time a request reaches the AI, every macro has been resolved to its final string.

Macros 2.0 / experimental engine

The features on this page run on the chevrotain-based macro engine — the same one SillyTavern introduced as Macros 2.0 (the "Experimental Macro Engine"). It's on by default in Luker. Toggle it under User Settings → Chat/Message Handling → Experimental Macro Engine.

With the experimental engine off, macros still resolve but the following fall back to the legacy regex pipeline and stop working:

FeatureRequires experimental engine
{{if}} / {{else}} / {{each}} control flow
Scoped macros like {{setvar::k}}body{{/setvar}}
Variable shorthand ({{.var}}, {{$var}}, expressions)
Flags (#, /, …)
Nested macros inside macro arguments
Leading-whitespace preservation inside scoped bodies
Stable substitution order across passes
Plain {{user}}, {{setvar::name::value}}, {{time}}, …works either way

If a feature on this page seems not to work, check this setting first.

Syntax

A macro looks like {{name}}, {{name::arg}}, or {{name::arg1::arg2}}. Macro names are case-insensitive{{user}}, {{User}}, and {{USER}} all resolve to the same macro. Whitespace between the braces and the name is allowed: {{ user }} works the same as {{user}}.

Macro identifiers must match ^[a-zA-Z][\w-_]*$ — start with a letter, then word characters, underscores, or hyphens. The only exception is the comment macro {{//}}.

Arguments

Separate positional arguments with :::

text
{{setvar::hp::50}}
{{datetimeformat::YYYY-MM-DD HH:mm:ss}}
{{roll::3d6+4}}

A single : is accepted as a fallback ({{roll: 1d20}}) but :: is the preferred form because real content frequently contains single colons.

For comma-style list macros ({{random}}, {{pick}}), either form is accepted:

text
{{random::red::green::blue}}
{{random::red,green,blue}}

If you need a literal comma inside a list item, escape it as \,.

Nested macros

Macros nest. Inner macros are resolved first, and the result becomes the argument of the outer macro:

text
{{setvar::greeting::Hello, {{user}}!}}
{{if {{getvar::showHeader}}}}# Header{{/if}}
{{each::{{getvar::roster}}}}- {{loop_value::name}}{{/each}}

There is no nesting depth limit beyond the recursion budget of the parser, but very deep trees are a sign the macro is doing too much — break it up with intermediate variables.

Scoped macros

Some macros ({{if}}, {{each}}, {{trim}}, {{//}}, and most user-registered macros that accept arguments) accept a body of content between an opening and a closing tag. The body becomes the last positional argument:

text
{{if .ready}}
The character is ready.
{{/if}}

{{each::npcs}}
- {{loop_key}}: {{loop_value::hp}} HP
{{/each}}

By default, scoped bodies are auto-trimmed and dedented — leading and trailing whitespace is removed, and the common leading indent of the first non-empty line is stripped from every line. To preserve everything verbatim, add the # flag:

text
{{#trim}}
   exact   spaces   preserved
{{/trim}}

Escaping

To emit a literal pair of braces, prefix with a backslash:

text
\{{not a macro}}

The backslash is stripped during post-processing and the raw {{...}} reaches the AI unchanged. This is the supported way to teach the model macro syntax inside a prompt without accidentally executing the example on every assembly.

Legacy tags

Five non-curly tags from very old SillyTavern character cards are auto-rewritten to their macro equivalents before resolution:

LegacyModern equivalent
<USER>{{user}}
<BOT> / <CHAR>{{char}}
<GROUP> / <CHARIFNOTGROUP>{{group}} / {{charIfNotGroup}}

Use the curly form in new content. The legacy tags exist for backward compatibility.

The form {{time_UTC+N}} is also rewritten to {{time::UTC+N}}.

Variable shorthand

In addition to the verbose {{getvar::name}} / {{setvar::name::value}} macros, Luker provides a compact variable expression syntax that reads like an assignment:

FormMeaningReturns
{{.name}}Read local variableCurrent value
{{$name}}Read global variableCurrent value
{{.name = 5}}Set'' (empty)
{{.name += 1}}Add (numeric add or string append)''
{{.name -= 1}}Subtract (numeric only; warns otherwise)''
{{.name++}}Increment''
{{.name--}}Decrement''
{{.name ?? "default"}}Value, or default if undefinedValue or default
{{.name &#124;&#124; "default"}}Value, or default if falsyValue or default
{{.name ??= "x"}}Set only if undefinedNew value
{{.name &#124;&#124;= "x"}}Set only if falsyNew value
{{.name == 5}}String equality"true" / "false"
{{.name != 5}}String inequality"true" / "false"
{{.name > 5}}Numeric >"true" / "false"
{{.name >= 5}}Numeric >="true" / "false"
{{.name < 5}}Numeric <"true" / "false"
{{.name <= 5}}Numeric <="true" / "false"

. always means a chat-local variable, $ always means a global variable. The two scopes don't share names.

Variable expressions compose with control flow:

text
{{if .hp <= 0}}
You die.
{{else}}
You have {{.hp}} HP left.
{{/if}}

Names in variable expressions accept hyphens and underscores but must end with a word character: my-var is valid, my- is not (that would clash with the -- decrement operator).

Lazy fallback evaluation

The ?? and || operators only evaluate the fallback expression when the variable is missing / falsy. Use this to skip expensive defaults: {{.cachedSummary ?? {{summary}}}} only calls {{summary}} on a cache miss.

Control flow

Conditionals — {{if}} / {{else}}

text
{{if condition}}then-content{{/if}}
{{if condition}}then{{else}}otherwise{{/if}}
{{if !condition}}negated{{/if}}

The condition can be:

  • A literal value — empty string, false, off, 0 are falsy; anything else is truthy.
  • A registered macro name without braces — {{if description}}# Description{{/if}} resolves description first.
  • A nested macro — {{if {{getvar::showHeader}}}}...{{/if}}.
  • A variable shorthand — {{if .ready}}, {{if $debugFlag}}.
  • A variable expression — {{if .hp > 0}}.
  • A !-prefixed inversion of any of the above — {{if !.dead}}.

Lazy branch evaluation

Only the chosen branch resolves its nested macros. {{if .casting}}{{.mana -= 10}}{{/if}} will not subtract mana when .casting is false. {{if}} is safe to wrap around expensive or side-effectful inner macros.

Iteration — {{each}}

text
{{each::collection}}
{{loop_key}}: {{loop_value}}
{{/each}}

collection accepts three forms:

  1. An inline JSON literal — {{each::["sword","shield"]}} or {{each::{"a":1,"b":2}}}.
  2. A variable name — {{each::npcs}} reads the local variable npcs (and falls back to global) and parses its JSON.
  3. A nested macro that resolves to a collection — {{each::{{getglobalvar::roster}}}}.

Inside the body:

MacroMeaning
{{loop_key}}Current key (or array index as a string)
{{loop_value}}Whole value (objects auto-JSON-stringify)
{{loop_value::field}}Drill into the value, same dotted-path semantics as {{getvar}}

Nested {{each}} blocks naturally shadow loop_key / loop_value for the inner scope. Object iteration order is JavaScript native — insertion order for string keys, ascending for integer-like keys.

Empty or non-iterable collections render to an empty string. There is no built-in iteration cap — a body that re-enters {{each}} on the same collection is your responsibility.

Comments — {{//}}

Inline:

text
{{// this line is ignored}}

Scoped:

text
{{//}}
Multi-line comment.
Free to contain {{macros}} and \{escapes\} — none of it runs.
{{///}}

Comments resolve to an empty string and consume their content verbatim. Useful for annotating prompt entries that the AI shouldn't see.

Variables

Variables come in two scopes:

  • Local — keyed per chat, stored in chat_metadata.variables. Use for chat-specific state (HP, current quest, turn count).
  • Global — shared across all chats, stored in extension settings. Use for cross-chat counters or plugin config.

Reading

MacroVariable shorthandPurpose
{{getvar::name}}{{.name}}Read local
{{getglobalvar::name}}{{$name}}Read global
{{hasvar::name}} (alias varexists)"true" / "false"
{{hasglobalvar::name}} (alias globalvarexists)"true" / "false"

Missing variables produce the empty string.

Writing

MacroVariable shorthandPurpose
{{setvar::name::value}}{{.name = value}}Set local
{{addvar::name::value}}{{.name += value}}Add (numeric) / append (string) / push (JSON array)
{{incvar::name}}{{.name++}}+1
{{decvar::name}}{{.name--}}-1
{{deletevar::name}} (alias flushvar)Remove

setglobalvar / addglobalvar / incglobalvar / decglobalvar / deleteglobalvar are the global-scope equivalents.

addvar is overloaded: when both sides parse as numbers, it does numeric addition; when the existing value is a JSON array, it pushes; otherwise it concatenates as strings.

Anchor whitespace with {{noop}}

String concat, scoped bodies, += " text" and similar contexts often have leading or trailing whitespace stripped by the engine's auto-trim or argument trimming. Insert a {{noop}} (resolves to the empty string) next to the whitespace you need to keep — e.g. {{addvar::story::{{noop}} This is the new paragraph.}}.

Dotted paths for structured values

A variable can hold any JSON-serializable value. When a variable's value is a JSON-stringified object or array, dotted-path reads work out of the box:

text
{{setvar::npcs::{"alice":{"hp":40},"bob":{"hp":30}}}}
{{getvar::npcs.alice.hp}}    → 40
{{getvar::npcs.alice}}        → {"hp":40}
{{getvar::list.0}}            → first element of `list`

Path lookups against non-JSON values fall back to a literal flat-key lookup, so a variable literally named a.b still works.

This pairs naturally with {{each}}: an NPC roster, an inventory dict, or a quest journal can live in a single variable and be rendered into the prompt or a world book entry on each pass.

text
{{each::npcs}}
- {{loop_key}}: {{loop_value::hp}} HP
{{/each}}

Per-message (floor) variables

In stock SillyTavern, side-effect macros like {{setvar::hp::50}} only run when they appear in a prompt template — preset, world info, or the very first message. When the AI writes the same literal in its reply, it does nothing and shows up verbatim in the chat.

Luker fixes this with per-message variable extraction. When a message (AI reply, user message, swipe, continue) is saved, Luker:

  1. Scans the text for {{setvar}}, {{addvar}}, {{incvar}}, {{decvar}}, {{deletevar}}.
  2. Resolves any nested display macros ({{user}}, {{getvar::other}}, {{time}}, …) against the current state.
  3. Applies the op to chat_metadata.variables immediately.
  4. Records a structured op on message.extra.var_ops.
  5. Strips the literal from the visible text.

When you delete a message, switch swipes, regenerate, or edit, Luker replays the surviving op log so your variables stay consistent with the visible timeline.

This is what the Per-Message Variables UI surfaces — a flask icon on every message with extracted ops, opening an editor where you can inspect, edit, delete, or add ops. The result is that the AI can own and mutate state directly through its replies, and that state survives all the chat-structure operations users routinely perform.

See Per-Message Variables for the full feature page (replay semantics, swipe lifecycle, the op editor, and recommended authoring patterns).

Global variables are not extracted

{{setglobalvar}} and the rest of the global family are not on the per-message extraction list — global state is cross-chat and is not tied to a specific message in the log. They keep stock SillyTavern semantics.

Built-in catalog

/? macros opens an in-app browser with the same descriptions, search, and live signatures.

Names & participants

MacroReturns
{{user}}Current persona name
{{char}}Current character name
{{group}} (alias charIfNotGroup)Comma-separated group members, or char name in solo
{{groupNotMuted}}Same as group minus muted members
{{notChar}}All participants except the current speaker

Character card fields

MacroReturns
{{charDescription}} (alias description)Description field
{{charPersonality}} (alias personality)Personality field
{{charScenario}} (alias scenario)Scenario field
{{persona}}Current persona description
{{charPrompt}}Main Prompt override
{{charInstruction}}Post-History Instructions override
{{charDepthPrompt}}@ Depth Note
{{charCreatorNotes}} (alias creatorNotes)Creator notes
{{charVersion}}Card version string
{{charFirstMessage}} (alias greeting)Main greeting (index 0)
{{greeting::N}}Greeting at index N — 0 is the main, 1+ are alt greetings
{{mesExamples}}Dialogue examples, formatted for instruct mode when enabled
{{mesExamplesRaw}}Dialogue examples, raw
{{original}}Original prompt content inside a character-level override. One-shot per pass — second call in the same substitution returns "". Meaningful inside charPrompt / charInstruction.

Chat state

MacroReturns
{{lastMessage}}Text of the last message
{{lastMessageId}}Index of the last message
{{lastUserMessage}}Text of the last user message
{{lastCharMessage}}Text of the last character message
{{firstIncludedMessageId}}Index of the first message in the current context window
{{firstDisplayedMessageId}}Index of the first visible message in the chat scroll area
{{lastSwipeId}}1-based count of swipes on the last message
{{currentSwipeId}}1-based index of the active swipe
{{allChatRange}}Range string like 0-12, or empty if the chat is empty
{{idleDuration}} (alias idle_duration)Human-readable time since the last user message
{{lastGenerationType}}normal / impersonate / regenerate / quiet / swipe / continue

Time & date

MacroReturns
{{time}}Local time (locale-formatted short time, e.g. 3:45 PM)
{{time::UTC+2}}Local time at a UTC offset
{{date}}Local date (locale-formatted long date)
{{weekday}}Day name (Monday, …)
{{isotime}}HH:mm
{{isodate}}YYYY-MM-DD
{{datetimeformat::FORMAT}}Current time using a moment.js format string
{{timeDiff::A::B}}Human-readable absolute difference between two times

Variables

See the Variables section above for the full list.

Control flow & utilities

MacroReturns
{{if cond}}…{{/if}}Conditional content
{{else}}Else branch marker inside {{if}}
{{each::col}}…{{/each}}Iteration
{{loop_key}} / {{loop_value}} / {{loop_value::path}}Inside {{each}}
{{trim}}Inline: trims surrounding newlines in post-processing. Scoped: returns trimmed content
{{newline}} / {{newline::N}}Insert one or N newlines
{{space}} / {{space::N}}Insert one or N spaces
{{noop}}Empty string. Useful as a whitespace anchor in concat / scoped contexts
{{reverse::text}}Reversed string
{{//comment}} (alias comment)Comment (empty output)

Randomness

MacroReturns
{{roll::1d20}}Dice roll using droll syntax (1d6, 3d6+4, …). A plain integer N is treated as 1dN. Re-rolls on every render.
{{random::red::green::blue}}Random element. Re-rolls on every render.
{{pick::red::green::blue}}Random element, stable per chat + macro position. Seed = chat hash + content hash + position + reroll seed. Reset with /reroll-pick.

Environment & API

MacroReturns
{{model}}Active model identifier
{{maxPrompt}} (alias maxPromptTokens)Max prompt context tokens
{{maxContext}} (alias maxContextTokens)Max context tokens
{{maxResponse}} (alias maxResponseTokens)Max response tokens
{{isMobile}}"true" on mobile clients
{{hasExtension::name}}"true" if a given extension is loaded and enabled
{{input}}Current contents of the send textarea
{{outlet::key}}World info outlet content for the given key
{{banned::word}}Adds a banned word for Text Completion backends; returns empty

Author's note & summary

MacroReturns
{{authorsNote}}Effective author's note text for the current chat
{{charAuthorsNote}}Character-scoped author's note
{{defaultAuthorsNote}}Configured default author's note
{{summary}}Chat summary — only registered when the Summarize extension is loaded

Reasoning template

MacroReturns
{{reasoningPrefix}}Reasoning section prefix
{{reasoningSuffix}}Reasoning section suffix
{{reasoningSeparator}}Separator between reasoning and answer

Instruct & system prompts

These macros only return content when the relevant instruct / system-prompt features are enabled.

MacroReturns
{{systemPrompt}}Active system prompt. Switches to {{charPrompt}} when Prefer Character Prompt is on.
{{defaultSystemPrompt}} (aliases instructSystem, instructSystemPrompt)Configured default system prompt
{{instructStoryStringPrefix}} / {{instructStoryStringSuffix}}Story string wrappers
{{instructUserPrefix}} (alias instructInput) / {{instructUserSuffix}}User turn sequences
{{instructAssistantPrefix}} (alias instructOutput) / {{instructAssistantSuffix}} (alias instructSeparator)Assistant turn sequences
{{instructFirstAssistantPrefix}} (alias instructFirstOutputPrefix) / {{instructLastAssistantPrefix}} (alias instructLastOutputPrefix)First / last variants
{{instructFirstUserPrefix}} (alias instructFirstInput) / {{instructLastUserPrefix}} (alias instructLastInput)First / last variants
{{instructSystemPrefix}} / {{instructSystemSuffix}}System turn sequences
{{instructSystemInstructionPrefix}}Last-system sequence
{{instructStop}}Stop sequence
{{instructUserFiller}}Alignment filler
{{exampleSeparator}} (alias chatSeparator) / {{chatStart}}Context template markers

Extension-provided

Only present when the named extension is installed and enabled:

MacroSourceReturns
{{charPrefix}}Stable DiffusionPositive image-gen prompt prefix
{{charNegativePrefix}}Stable DiffusionNegative image-gen prompt prefix
{{summary}}SummarizeStored chat summary

Third-party extensions may register additional macros; the macro browser flags each one's source.

Flags

Flags are single characters placed between the opening {{ and the macro name. They modify how the macro is parsed or resolved.

/ — closing block

Marks a tag as the closing half of a scoped/block macro. Pairs with the opening tag of the same identifier and consumes the content in between:

text
{{if .ready}} ...body... {{/if}}
{{each::npcs}} ...body... {{/each}}
{{setvar::longText}} ...body... {{/setvar}}

A closing tag never takes its own arguments — everything between opening and closing is folded into the opening macro's last positional argument.

# — preserve whitespace

Only meaningful on scoped/block macros. By default the engine auto-trims and dedents the body before handing it to the handler (strips leading and trailing whitespace, then removes the common leading indent of the first non-empty line from every line). The # flag suppresses that behavior so the body is passed through verbatim:

text
{{#if .verbose}}
    every space, including this 4-space indent,
    is preserved exactly as written.
{{/if}}

The flag also doubles as backward compatibility for the old Handlebars-style {{#if …}} writing — that syntax still works today and is equivalent to {{if …}} with auto-trim disabled.

On non-scoped macros the flag is accepted but has no behavioral effect.

! ? ~ > — reserved

FlagIntended meaningStatus
!Resolve before other macros in the same textParsed only
?Resolve after other macrosParsed only
~Mark for re-evaluationParsed only
>Treat | as an output-filter pipeParsed only (see Pipe below)

These tokens are recognized by the parser today but no runtime hook consumes them, so they have no effect on output. The lone ! in {{if !.dead}} is a separate construct — it's condition negation inside {{if}}, not the flag.

Flags can be combined and whitespace between flag and name is allowed: {{ #each ::list}} … {{/each}}.

| — pipe (argument terminator)

The pipe character is special inside macro arguments even without the > flag. The lexer transitions out of argument mode when it sees \|, so:

text
{{getvar::name|filter}}

…parses as the macro getvar with the single argument name followed by a "filter" identifier filter. The filter handler isn't wired up yet, so the filter name is discarded and the macro behaves as {{getvar::name}}. The practical implication is that a literal \| inside an argument terminates that argument — to keep \| as part of the value, escape it as \|:

text
{{setvar::menu::sword \| shield \| bow}}

Pipe is reserved

Today, writing {{macro|uppercase}} does not uppercase anything — it just parses without error, drops the filter name, and runs the macro on the args before the pipe. If you need string transforms, register a custom macro or use a regex extension. The pipe-filter chain itself is reserved for a future engine version.

Slash command pipes — {{pipe}}, {{var::name}}

Inside a slash command closure (STscript — /command1 | /command2 | … chains, Quick Replies, and similar), two extra macros are bound by the slash-command scope:

MacroScopeMeaning
{{pipe}}Slash command closureThe output of the previous command in the pipe chain
{{var::name}}Slash command closureA closure-scope variable created with /let / /var. Different from chat variables (which are accessed via {{getvar::name}})

Both only exist inside the slash command parser. Outside an STscript context they render literally (no closure to bind them to).

The \| character is also the slash command pipe operator at the STscript level — that's a feature of the command parser, not the macro engine. Inside a single macro's args, \| follows the macro pipe rule described above.

Resolution semantics

The engine walks each text fragment once and resolves macros left to right.

  • Nested macros resolve before their parent is called. The parent sees fully-resolved string arguments unless the macro definition opts into delayArgResolution (currently {{if}} and {{each}} only).
  • Side-effect macros ({{setvar}}, {{addvar}}, {{incvar}}, {{decvar}}, {{deletevar}}) apply immediately, so a later macro in the same pass sees the new value.
  • Per-message extraction (see Per-Message Variables) happens at message save time, not at prompt-build time.
  • Unknown macros render as the raw {{...}} text (with nested arguments still resolved). No exception, no warning by default.
  • Argument arity / type mismatches with the default strictArgs: true log a runtime warning and emit the raw macro text; with strictArgs: false they emit a warning but the handler still runs.
  • Result normalization — every handler return is normalized: null / undefined'', Date → ISO string, arrays / objects → JSON.stringify(...), everything else → String(...). That's why {{loop_value}} on an object renders as JSON.
  • Comment stripping — orphan {{trim}} markers and stray else sentinels left behind by partial parses are cleaned up in a post-processing pass.

Custom & plugin-registered macros

Extensions can register their own macros:

js
const ctx = Luker.getContext();

ctx.macros.register('myStatus', {
    description: 'Returns the plugin status string.',
    category: 'utility',
    handler: () => 'My plugin is active.',
});

ctx.macros.register('greet', {
    description: 'Greets a name.',
    unnamedArgs: [
        { name: 'name', type: 'string', description: 'Person to greet' },
    ],
    handler: (mctx) => `Hello, ${mctx.unnamedArgs[0]}!`,
});

After registration both {{myStatus}} and {{greet::Bob}} are available everywhere a macro is, including world info, presets, and chat messages.

The full registration surface — argument typing, aliases, scoped macros, lazy resolution, dynamicMacros for one-off injection — is in Extension API › Macros & Variables.

Discovery & debugging

  • Type {{ in any field that resolves macros to trigger autocomplete. Press Ctrl+Space to invoke it anywhere.
  • Enable autocomplete in all fields under Settings → AutoComplete Settings → Show in all macro fields.
  • Run /? macros (or /? macro / /? 4) to open the Macro Browser — a searchable popup listing every registered macro, its category, aliases, arguments, return type, examples, and source (core / extension / third-party).
  • Run /reroll-pick to reset all {{pick}} outcomes in the current chat. Pass a specific seed (/reroll-pick mySeed) for reproducibility.

Where macros are resolved

Every text field that flows through the prompt pipeline:

  • Character card fields (description, personality, scenario, first message, alt greetings, dialogue examples, @ Depth Note, character prompts)
  • World info entry content, keys, and headers
  • Preset prompt entries
  • Author's note
  • Chat messages (user and AI), with per-message variable extraction
  • Slash command arguments and Quick Reply scripts
  • Regex extension replacements
  • Macro arguments themselves (nesting)

A field that takes literal text and never reaches the model — a connection profile's API URL, for instance — does not resolve macros. When in doubt, drop in {{date}}: if it renders as literal {{date}}, the field doesn't run macros.

Common patterns

Conditional sections in a preset

text
{{if .difficulty == "hard"}}
The world is unforgiving. Failure is permanent.
{{else}}
The world is forgiving. Death is a setback, not an end.
{{/if}}

Counter that survives swipes and deletes

In a character's first_mes or in an alt greeting, seed the variable:

text
{{setvar::turn::0}}

In the AI's reply (set up via a world book entry that instructs the model), let the AI increment it:

text
{{incvar::turn}}

Because per-message extraction strips the literal and records the op, the user sees clean narrative and the variable still increments. Deleting that message reverses the op on replay.

Stable random choice for a chat

text
{{pick::a tavern brawl::a quiet evening::an unexpected guest}}

Once rolled, the choice stays fixed for that chat at that macro position — useful for "today's mood" style randomness that should not flicker on re-render.

Render a structured collection

text
# Active quests
{{each::quests}}{{if {{loop_value::status}} == "active"}}
- {{loop_value::name}}
{{/if}}{{/each}}

The AI maintains quests with {{setvar::quests::…}} in its replies; the world book entry above lays them out on each turn.

Author's note that adapts to a flag

text
{{if $debug_verbose}}
[narrative voice: be especially explicit about reasoning]
{{/if}}

A global flag toggled from a Quick Reply or slash command becomes an opt-in instruction shipped only when wanted.

Reference

Built upon SillyTavern