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
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 | nullRegisters a macro. Returns the registered definition, or null if validation failed.
| Option | Description |
|---|---|
handler | Macro body. Receives an execution context with parsed args |
aliases | Alternate names. Each { alias, visible } registers the same handler |
category | Grouping for autocomplete (e.g. 'utility', 'character', 'time') |
unnamedArgs | Either a count (all required) or an array of arg defs |
list | Whether the macro accepts a variadic list of args |
strictArgs | When false, arity/type mismatches log warnings instead of throwing |
delayArgResolution | When 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:
| Field | Description |
|---|---|
name | Macro name as invoked |
args | Named arg values |
unnamedArgs | Unnamed positional args |
list | Variadic list args |
env | Macro 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 |
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:
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.
| Macro | Returns |
|---|---|
{{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)
MacrosParser.registerMacro(key: string, value: string | (nonce) => string, description?: string): void
MacrosParser.unregisterMacro(key: string): voidOlder 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
substituteParams(content: string, options?: {
name1Override?: string,
name2Override?: string,
original?: string,
groupOverride?: string,
replaceCharacterCard?: boolean,
dynamicMacros?: Record<string, string | (() => string)>,
postProcessFn?: (text: string) => string,
}): stringResolves all macros in content. Use dynamicMacros to inject ad-hoc 0-arg macros for a single call:
const result = ctx.substituteParams('Hello, {{user}}! Today is {{date}}.');substituteParamsExtended
substituteParamsExtended(
content: string,
additionalMacros?: Record<string, string | (() => string)>,
postProcessFn?: (text: string) => string,
): stringConvenience wrapper around substituteParams that adds additionalMacros for the current call only:
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
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| Method | Description |
|---|---|
get | Reads the variable. Numeric strings are auto-coerced to numbers. Returns '' for missing |
set | Writes the variable. Returns the value |
add | If both are numeric, performs numeric addition. If existing is a JSON array, pushes. Otherwise concatenates as strings |
inc / dec | Shortcuts for add(name, ±1) |
del | Removes the variable. Returns '' |
has | Boolean existence check |
push | Pushes value onto the JSON array at name. Auto-creates [] when missing. The macro form is {{pushvar::name::value}} |
pop | Pops 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 (overridesname)args.index— index/key into a JSON list/dict stored in the variableargs.as(set only) — coerce to'string'/'number'/'boolean'for indexed writes
Global Variables
context.variables.global.get / set / add / inc / dec / del / hasSame surface as local. Persisted across chats.
Usage Example
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
| Local | Global | |
|---|---|---|
| Scope | Single chat | All chats |
| Storage | chat_metadata.variables | extension_settings.variables.global |
| Save trigger | saveMetadataDebounced | saveSettingsDebounced |
| Use for | Chat-specific counters, in-progress state | Plugin 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.
context.setVariable(
name: string,
value: any,
options?: { floor?: number },
): Promise<any>| Call form | Effect |
|---|---|
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 string —
extra.var_opsentries only carry strings (since{{getvar}}also returns strings). For structured state, usecreateFloorStateinstead (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. floormust be a valid floor index (0 <= floor < chat.length); out-of-range throws.- Dotted names work the same way for floor-bound writes —
setVariable('roster.alice.hp', 50, { floor })splits on the first.and forwardspathinto the op record.op.keystays as the top-level variable name, so replay treats the whole structure as one unit.
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 write | createFloorState | |
|---|---|---|
| Data shape | Scalar (string / number) | Structured object |
| Bucket | Shares chat_metadata.variables with {{getvar::k}} | Independent namespace, never reaches macros |
| Commit log | variable-op-log (floor extra.var_ops) | Floor structured commit log (__floor_log) |
| Use for | Rollback-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.