Skip to content

Macros & Variables

APIs for registering custom macros, evaluating macros in text, and reading/writing chat-scoped or global variables.

Macros

Luker exposes a macro system through the macros namespace, plus the legacy MacrosParser for backwards compatibility. Built-in macros like {{user}}, {{char}}, {{lastMessage}}, and {{getvar::name}} are registered by core; plugins can add their own through macros.register().

macros.register

ts
macros.register(name: string, options: {
    handler: (ctx: MacroExecutionContext) => string,
    aliases?: { alias: string, visible?: boolean }[],
    category?: string,
    unnamedArgs?: number | UnnamedArgDef[],
    list?: boolean | { min: number, max?: number },
    strictArgs?: boolean,
    description?: string,
    returns?: string,
    returnType?: 'string' | 'integer' | 'number' | 'boolean',
    displayOverride?: string,
    exampleUsage?: string | string[],
    delayArgResolution?: boolean,
}): MacroDefinition | null

Registers a macro. Returns the registered definition, or null if validation failed.

OptionDescription
handlerMacro body. Receives an execution context with parsed args
aliasesAlternate names. Each { alias, visible } registers the same handler
categoryGrouping for autocomplete (e.g. 'utility', 'character', 'time')
unnamedArgsEither a count (all required) or an array of arg defs
listWhether the macro accepts a variadic list of args
strictArgsWhen false, arity/type mismatches log warnings instead of throwing
delayArgResolutionWhen true, nested macros in args are NOT pre-resolved — the handler must call ctx.resolve(text) itself. Use only for control-flow macros

Handler context

The handler receives a MacroExecutionContext with:

FieldDescription
nameMacro name as invoked
argsNamed arg values
unnamedArgsUnnamed positional args
listVariadic list args
envMacro evaluation environment (chat, character, persona, etc.)
normalize(value)Coerce a value to the macro's return type
trimContent(content, opts?)Trim a multi-line block
resolve(text, opts?)Resolve nested macros in text
warn(message, error?)Log a warning attributed to this macro
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', optional: false, type: 'string', description: 'Person to greet' },
    ],
    handler: (mctx) => `Hello, ${mctx.unnamedArgs[0]}!`,
});

After registration, both {{myStatus}} and {{greet::Bob}} work.

macros.registry

The underlying registry. Use it for unregistration and inspection:

js
ctx.macros.registry.unregisterMacro('myStatus');
ctx.macros.registry.hasMacro('greet');
const def = ctx.macros.registry.getMacro('greet');

Built-in macro reference

A non-exhaustive list of macros registered by core. See the source under public/scripts/macros/definitions/ for full coverage.

MacroReturns
{{user}}Current user / persona name
{{char}}Current character name
{{persona}}Current persona description
{{charDescription}} / {{charPersonality}} / {{charScenario}}Card fields
{{charDepthPrompt}} / {{charCreatorNotes}} / {{charFirstMessage}} / {{charVersion}}Card fields
{{mesExamples}} / {{mesExamplesRaw}}Dialogue examples
{{group}} / {{groupNotMuted}}Group member names
{{lastMessage}} / {{lastMessageId}} / {{lastUserMessage}} / {{lastCharMessage}}Recent chat content
{{firstIncludedMessageId}} / {{firstDisplayedMessageId}}Visibility window
{{lastSwipeId}} / {{currentSwipeId}}Swipe state
{{model}}Active model identifier
{{maxPrompt}} / {{maxContext}} / {{maxResponse}}Token budgets
{{time}} / {{date}} / {{weekday}} / {{isotime}} / {{isodate}}Local clock
{{datetimeformat::FORMAT}}moment.format(FORMAT)
{{idleDuration}} / {{timeDiff}}Time deltas
{{getvar::name}} / {{setvar::name::value}} / {{addvar::name::value}}Local variables
{{incvar::name}} / {{decvar::name}} / {{hasvar::name}} / {{deletevar::name}}Local variables
{{pushvar::name::value}} / {{popvar::name}}Local variables — array push / pop. Auto-creates [] when missing; supports dotted paths
{{getglobalvar::name}} / {{setglobalvar::name::value}} / ...Global variables
{{if::cond::then::else}} / {{else::...}} / {{each::...}}Control flow
{{trim}} / {{newline}} / {{space}} / {{noop}}Whitespace helpers
{{roll::XdY}} / {{random::a,b,c}} / {{pick::a,b,c}}Randomness
{{//comment}}Comment (ignored output)
{{outlet::name}}Custom WI outlet content
{{isMobile}} / {{hasExtension::name}}Environment checks
{{lastGenerationType}} / {{systemPrompt}}Pipeline state

MacrosParser (deprecated)

ts
MacrosParser.registerMacro(key: string, value: string | (nonce) => string, description?: string): void
MacrosParser.unregisterMacro(key: string): void

Older API for registering simple string-substitution macros. Logs a deprecation warning. Migrate to macros.register({ handler }) for full feature support, or pass dynamicMacros to substituteParams for one-off invocation.

substituteParams

ts
substituteParams(content: string, options?: {
    name1Override?: string,
    name2Override?: string,
    original?: string,
    groupOverride?: string,
    replaceCharacterCard?: boolean,
    dynamicMacros?: Record<string, string | (() => string)>,
    postProcessFn?: (text: string) => string,
}): string

Resolves all macros in content. Use dynamicMacros to inject ad-hoc 0-arg macros for a single call:

js
const result = ctx.substituteParams('Hello, {{user}}! Today is {{date}}.');

substituteParamsExtended

ts
substituteParamsExtended(
    content: string,
    additionalMacros?: Record<string, string | (() => string)>,
    postProcessFn?: (text: string) => string,
): string

Convenience wrapper around substituteParams that adds additionalMacros for the current call only:

js
const result = ctx.substituteParamsExtended(
    'Query: {{queryText}}',
    { queryText: userInput },
);

The additionalMacros are not registered globally — they exist only for this single substitution.

Variables

Two scopes are available: local (per-chat, persisted in chat_metadata.variables) and global (cross-chat, persisted in extension_settings.variables.global).

Local Variables

ts
context.variables.local.get(name: string, args?: object): string | number
context.variables.local.set(name: string, value: any, args?: object): any
context.variables.local.add(name: string, value: any): any
context.variables.local.inc(name: string): any
context.variables.local.dec(name: string): any
context.variables.local.del(name: string): ''
context.variables.local.has(name: string): boolean
context.variables.local.push(name: string, value: any): string | undefined
context.variables.local.pop(name: string): string | undefined
MethodDescription
getReads the variable. Numeric strings are auto-coerced to numbers. Returns '' for missing
setWrites the variable. Returns the value
addIf both are numeric, performs numeric addition. If existing is a JSON array, pushes. Otherwise concatenates as strings
inc / decShortcuts for add(name, ±1)
delRemoves the variable. Returns ''
hasBoolean existence check
pushPushes value onto the JSON array at name. Auto-creates [] when missing. The macro form is {{pushvar::name::value}}
popPops the last element from the JSON array at name. No-op when empty or missing. The macro form is {{popvar::name}}

name on every method above accepts a dotted path (e.g. roster.alice.hp) to read or write a leaf inside a structured variable. The write methods mutate chat_metadata.variables[root] in place, the same way the macro-side {{setvar::roster.alice.hp::value}} does; intermediate nodes are created on demand.

The optional args parameter on get / set supports:

  • args.key — alternative variable name (overrides name)
  • args.index — index/key into a JSON list/dict stored in the variable
  • args.as (set only) — coerce to 'string' / 'number' / 'boolean' for indexed writes

Global Variables

ts
context.variables.global.get / set / add / inc / dec / del / has

Same surface as local. Persisted across chats.

Usage Example

js
const ctx = Luker.getContext();

// Read a local var with default
const turns = Number(ctx.variables.local.get('turns_taken')) || 0;

// Increment
ctx.variables.local.inc('turns_taken');

// Check + initialize a global config var
if (!ctx.variables.global.has('api_endpoint')) {
    ctx.variables.global.set('api_endpoint', 'https://api.example.com');
}
const endpoint = ctx.variables.global.get('api_endpoint');

// Indexed write into a JSON list stored in a local var
ctx.variables.local.set('inventory', 'sword', { index: 0, as: 'string' });
ctx.variables.local.set('inventory', 'shield', { index: 1, as: 'string' });

Local vs Global

LocalGlobal
ScopeSingle chatAll chats
Storagechat_metadata.variablesextension_settings.variables.global
Save triggersaveMetadataDebouncedsaveSettingsDebounced
Use forChat-specific counters, in-progress statePlugin config, cross-chat data

Floor-bound writes

Beyond the seven-method local / global CRUD, luker also exports a top-level setVariable that can bind a single write to a specific floor — the code-side equivalent of writing {{setvar::name::value}} inside that floor's text.

ts
context.setVariable(
    name: string,
    value: any,
    options?: { floor?: number },
): Promise<any>
Call formEffect
await ctx.setVariable(k, v)Writes chat_metadata.variables[k] = v directly. Same bucket as ctx.variables.local.set(k, v); useful when you want an awaitable shortcut
await ctx.setVariable(k, v, { floor: N })Appends a setvar op to floor N's extra.var_ops, bound to that floor's current swipe. On swipe switches / message deletions / branch creates, the variable-op-log replays in lockstep with the floors

Details when floor is set:

  • The value is coerced to a stringextra.var_ops entries only carry strings (since {{getvar}} also returns strings). For structured state, use createFloorState instead (see floor-bound structured state).
  • It's a replay, not an overwrite — on swipe / delete / branch, the rebuilder walks every surviving var_op in write order and replays the keys those ops touch onto chat_metadata.variables. Keys that no surviving var_op writes (world-info side effects, slash commands, third-party extension writes) are left untouched. A floor write doesn't mutate storage directly; it contributes one instruction to every future replay.
  • floor must be a valid floor index (0 <= floor < chat.length); out-of-range throws.
  • Dotted names work the same way for floor-bound writessetVariable('roster.alice.hp', 50, { floor }) splits on the first . and forwards path into the op record. op.key stays as the top-level variable name, so replay treats the whole structure as one unit.
js
const ctx = Luker.getContext();

// Immediate write — same bucket as ctx.variables.local.set
await ctx.setVariable('quest_stage', 'intro');

// Bound to the last floor; replays alongside swipe / delete / branch
await ctx.setVariable('hp', 42, { floor: ctx.chat.length - 1 });

Floor writes vs createFloorState

Floor writecreateFloorState
Data shapeScalar (string / number)Structured object
BucketShares chat_metadata.variables with {{getvar::k}}Independent namespace, never reaches macros
Commit logvariable-op-log (floor extra.var_ops)Floor structured commit log (__floor_log)
Use forRollback-friendly scalars co-owned with AI-written {{setvar}}Plugin- or CardApp-managed rollback-friendly structured state

The two mechanisms have independent commit logs. Don't write the same key from both — replay order isn't guaranteed across them, and you'll see one clobber the other.

Built upon SillyTavern