ROLECALLContent
Content

State Machine

Everything you create and cast

State Machine

A preset can do more than tell the AI how to write. It can remember things.

The state machine is the part of the Macro Engine that turns tags the AI writes into stored values. The AI writes [NEXT-DIRECTOR: SCORIA] somewhere in its reply, the tag disappears before you ever see it, and from that turn on your preset can read back SCORIA and change how the next prompt is built.

That is the whole idea. Everything below is the vocabulary for saying which tags matter and what they should do.


What it is good for

Anything the story needs to carry forward that the AI cannot reliably hold in its head:

  • Whose turn it is in a rotating cast.
  • Which arc beat you are on, and a counter that ticks toward the next one.
  • A running ledger of what happened, appended one line per turn.
  • A mood, a location, a threat level, a relationship score.
  • Flags that switch whole sections of your prompt on and off.

Without it, the usual workaround is a stack of regex rules feeding variable macros, plus a second pass to hide the scaffolding from the reader. The state machine replaces all of that with a short declaration.


Where you configure it

Open a preset in the preset editor and find the Macro Engine panel. It holds three things:

SectionWhat it does
Hooks & EventsThe state machine itself. A YAML editor for everyone, plus a form view for Understudies.
Config BlocksOther advanced preset carriers. Understudies only.
Macro LintLive warnings across all your prompts and this config.

The panel header shows a live rule count and any error or warning totals, so you can tell at a glance whether the engine accepted what you wrote.


Your first hook

Say you want the AI to keep a notebook, and you want it hidden from the reader.

Step 1. Tell the AI to emit the tag. Put this in one of your system prompts:

When something happens that your character would privately note,
add a line at the end of your reply in this exact format:
[DIRECTOR NOTE: the thing you noticed]

Step 2. Declare the hook. In the Macro Engine panel:

hooks:
  - id: director-notebook
    trigger: '\[DIRECTOR NOTE:\s*(.+?)\]'
    action:
      type: push
      key: 'character:notebook'
      value: '$1'
    strip: true

Step 3. Read it back. Anywhere in your prompt stack:

Your private notes so far:
{{state.show::character:notebook}}

That is a complete loop. The AI writes the tag, the hook catches it, $1 captures the text inside, push appends it to a list, strip: true removes the tag from the visible reply, and the next prompt prints the whole list back.


Hooks

A hook is a regular expression plus one or more actions. Use hooks when the thing you are matching is shaped loosely, or when you need capture groups.

FieldRequiredDefaultWhat it means
idyesA name for this rule. It shows up in the inspector, so make it readable.
triggeryesA regular expression. Capture groups become $1, $2, and so on. Named groups become $name.
flagsnogsRegex flags. Global matching is always on.
actionyesOne action, or a list of them.
stripnotrueRemove the matched text from the reply. Tags are not prose.
placementnoAI replyHooks currently run on the AI's reply only.

If your regex does not compile, that hook is skipped rather than breaking your scene. It will simply never fire, which is exactly what the inspector's Health view is built to show you.

Action types

TypeUses valueWhat it does
setyesOverwrite the key.
unsetnoDelete the key.
pushyesAppend to a list.
popnoRemove the last list item.
shiftnoRemove the first list item.
appendyesAdd text to the end of a string.
incrementoptional stepAdd to a number. Defaults to 1.
decrementoptional stepSubtract from a number. Defaults to 1.

Values are read intelligently. 12 becomes the number twelve, true becomes a boolean, anything shaped like a JSON list or object becomes that, and everything else stays text.

A hook can carry several actions, and they run in order against the running result, so this works:

hooks:
  - id: beat-advance
    trigger: '\[BEAT COMPLETE\]'
    action:
      - { type: increment, key: 'arc:beat' }
      - { type: unset, key: 'scene:tension' }

Events

An event is the tidier form for a tag with a fixed shape. Instead of writing a regular expression, you write the tag itself and mark the parts you want to capture with $name.

events:
  next_director:
    pattern: '[NEXT-DIRECTOR: $name]'
    do: 'set session:pending_director = $name'
    strip: true

  director_leave:
    pattern: '[DIRECTOR-LEAVE]'
    do:
      - 'unset session:callsign'
      - 'unset session:active_genre'
    strip: true

Patterns are forgiving about spacing. [PHASE: $phase] matches [PHASE:rising] and [PHASE: rising ] identically. A captured value cannot contain square brackets or span a line break.

The do mini-language

Each line is one instruction:

<verb> <scope:key> [= <value>]
VerbAliasesEffect
setOverwrite.
unsetdeleteRemove.
push / pop / shiftList operations.
appendAdd to the end of a string.
incrementincAdd.
decrementdecSubtract.

Captures work in both halves, so set session:$slot = $value is legal. A line the engine cannot read is skipped, which is one of the things Macro Lint exists to warn you about.

Run order

Events run before hooks. Within events, anything named in event_order runs first, in the order you listed:

event_order:
  - director_leave
  - next_director

This matters when two rules touch the same key. In the example above, clearing the outgoing Director must happen before assigning the next one, or the new assignment gets wiped.

Hooks always run after every event, in the order you wrote them.


Scopes

Every key belongs to a scope, written as a prefix:

ScopeWritten asLives for
sessionsession:key or just keyThis chat.
charactercharacter:keyThis chat, kept separate per character.
arcarc:keyThis chat, meant for long-running story arcs.
scenescene:keyThis chat, meant for the current scene.
globalglobal:keyEvery chat you have, forever.

A bare key with no prefix is a session key, so older presets keep working unchanged. Any prefix that is not one of the five above is treated as part of the name, not as a scope.

Keys can reach into objects with dots:

{{state.set::global:preferences.font::serif}}

Be deliberate about global:. It follows you into every chat with every character.


Reading state in your prompts

MacroWhat it does
{{state.get::session:callsign}}Prints the value.
{{state.show::character:notebook}}Pretty-prints a list or object. Add ::json for JSON instead of YAML.
{{state.set::session:mood::tense}}Sets a value and prints nothing.
{{state.unset::session:mood}}Deletes it.
{{has::session:callsign}}true when set and not empty.
{{is_empty::session:callsign}}The opposite.
{{silent::...}}Runs everything inside for its effects and prints nothing.
{{show::content}}Forces literal output.

Combine them with conditionals to branch your prompt:

{{#if {{has::session:callsign}}}}
The current Director is {{state.get::session:callsign}}.
{{/if}}

Values set inside historical messages do not re-fire. When RoleCall replays your chat history to build a prompt, all the setting macros in those old messages go quiet, so a counter set on turn 3 does not tick again on turn 40.


The status card

Raw variable dumps are hard to read at a glance. Declare a dashboard and the values you actually care about get pinned to the top of the State tab:

dashboard:
  - label: 'Director'
    value: '{{state.get::session:callsign}}'
  - label: 'Beat'
    value: '{{state.get::arc:beat}} of 5'
  - label: 'Countdown'
    value: '{{calc::10 - {{state.get::arc:beat}}}} turns to the reveal'
    show_if: '{{has::arc:beat}}'

Each row's value and show_if are ordinary macro templates, so you can compute things. They are evaluated read-only, so a dashboard row can never accidentally change your state. A row disappears when its show_if comes out empty, 0, or false.

The status card is an Understudies feature.


Asking the AI to decide something

Sometimes you do not want to wait for the AI to volunteer a tag. You want to ask it a direct question and store the answer.

In the reply (free)

{{pick_in_reply::Pick the next Director::HEARTTHROB::SCORIA::into=session:callsign}}
{{ask_in_reply::Summarize her mood in one word::into=session:mood}}
{{pick_in_reply_or_keep::Pick a Director::HEARTTHROB::SCORIA::into=session:callsign}}

These render an instruction into your prompt asking the AI to open its reply with a machine-read line, then capture that line and remove it. There is no extra cost, because the answer rides along with the reply you were already paying for.

The tradeoff: the answer arrives with the reply, so it cannot change the prompt that produced it. You can use it from the next turn onward. The _or_keep variant renders nothing at all when the target already has a value, which is how you ask once and never again.

If the AI does not answer, the question stays pending and is asked again next turn.

Before the prompt is built (costs a call)

{{pick$::Which genre fits this scene?::noir::comedy::horror}}
{{pick_or_keep$::session:genre::Which genre?::noir::comedy}}
{{pick_when$::{{has::arc:crisis}}::How bad is it?::mild::severe}}
{{ask_model$::Name this chapter in three words}}

The $ is a cost marker. Each of these makes a separate model call while your prompt is being assembled, which is what lets the answer shape the very prompt being built. That is the only thing in-reply asks cannot do.

Guardrails worth knowing:

  • Answers are cached per chat, so the same question is not re-asked every turn. Add ! ({{pick$!::...}}) to force a fresh answer.
  • pick_or_keep$ skips the call entirely when the variable is already set. pick_when$ skips when its condition is false. Use them.
  • Macro Lint counts these for you and tells you the worst case per render.
  • Add into=scope:key to any of them to store the answer as well as print it.

Controlling evaluation order

Prompts are evaluated in stack order, so a prompt near the top that reads a variable set by a prompt near the bottom sees the old value. Phase macros fix that:

MacroRuns
{{phase::pre_render::...}}Before any prompt is rendered.
{{phase::post_render::...}}After every prompt has rendered.
{{defer::...}}Same as post_render. The usual choice for a summary line that must see final state.

A common pattern is to compute setup in pre_render and print a status footer in defer, so the footer reflects everything the rest of the stack did.


Watching it work

The State tab

In a scene, open the Context wing, choose Active Lore (the last-send view), then pick the State tab. The tab appears once you have sent a message since opening the scene; before that, Active Lore shows only a prompt to send one. You get the current value of every variable, grouped by scope and searchable, plus the list of changes from the most recent prompt build and what caused each one. Your dashboard card sits at the top.

This is the right surface for "what is the value right now."

The state machine inspector

For "why did it stop changing," there is a full page inspector. Open a scene and add /state-machine to the end of the URL:

/scene/<character>/<chat>/state-machine

It has three views:

Health. Every hook you declared, scored by how often it actually fires, quietest first. A hook sitting at zero is the single most useful thing this tool shows you: it means the tag never reached the engine, either because the AI stopped writing it or because your pattern stopped matching what the AI actually writes. It also flags rules that fired but are no longer in your config, which usually means you edited the preset mid-chat.

Variables. Current values, with how many turns ago each one last changed. A value that has not moved in twenty runs while everything around it kept moving is a stuck rule.

Timeline. The change history, regrouped into the turns the changes came from, so you can see which rules fired together. Changes that rewrote a key with the value it already had are counted separately as no-ops, which is a distinct and very common failure.

The inspector is an Understudies feature.

The playground

The Macro Engine panel links to a playground where you can paste a sample reply and watch your rules run against it. It is entirely local. No model call, no cost, no chat touched. Use it before wiring a new hook into a real scene.


Cleaning up

The inspector has maintenance actions for when a chat's state has gone wrong. All of them affect only the chat you are looking at. None of them touch global: values, which are shared across all your chats.

ActionWhat it clears
Clean event logThe change history only. Your actual state is untouched.
Dedupe ledgersRemoves exactly repeated entries from /// separated lists, keeping the first of each.
Clear stagingDeletes variables whose names start with staging_.
Clear all variablesDeletes every variable in this chat. Your story keeps its messages, but the machine starts from nothing.

The last one is not reversible. Take a look at the Variables view first.


Macro Lint

The lint panel runs continuously over your prompts and your config, and reports:

  • Macros that do not exist, with a suggestion when the name is close to a real one.
  • {{if}} blocks that are missing their closing tag, which otherwise render as visible literal text.
  • References to prompts, templates, or blocks that do not exist.
  • Variables you read but nothing ever sets, and variables set only by a prompt that is currently switched off.
  • How many off-band $ calls your preset makes per render.

Lint knows about your hooks and events, so a variable that only a hook writes is not reported as dangling.


Troubleshooting

What you seeWhat it usually means
The tag appears in the visible replyEither strip is off, or the rule never matched. An unmatched tag is not removed. Health tells you which.
A value stopped updatingOpen Health. If the hook's fire rate is near zero, the AI stopped writing the tag or your pattern no longer matches it. Read a recent raw reply and compare.
A hook fires but nothing changesCheck the run's no-op count in Timeline. The usual cause is increment on a key holding text rather than a number, which reads the base as zero and writes the same result every time.
The same line is in a list three timesOne staged value was committed more than once. Run Dedupe ledgers, then look for two rules writing the same key.
An in-reply question is never answeredConfirm the AI is actually starting its reply with the [SET TAG: ...] line, and that the tag matches the one derived from your into= key.
Everything in Health says "no longer declared"The inspector could not read your config. Check that the Macro Engine panel is showing a rule count and no parse error.
A push wiped a valuepush expects a list. Pushing onto a key that currently holds plain text discards the text and starts a new list.

Limits worth knowing

  • Rules run on the AI's reply, after it finishes generating. They do not run on what you type.
  • Rules fire once per generation. Re-reading old messages never re-fires them. Regenerating a reply is a genuinely new generation, so it does fire again.
  • A broken rule is skipped, not fatal. An invalid pattern or a malformed action never breaks your scene. It just quietly does nothing, which is why Health and Lint matter.
  • Stripped tags never reach your stored messages. The cleaned text is what gets saved.
  • The change history is not kept forever and the inspector reads a recent window of it. Long-running chats will show recent turns rather than the entire history.
  • {{after::prompt_id::...}} currently runs at the end of the render pass rather than immediately after the named prompt. The value it reads is correct; the prompt name is not yet used for precise placement.

A worked example

A rotating narrator with a beat counter, a hidden notebook, and a status card:

hooks:
  - id: notebook
    trigger: '\[NOTE:\s*(.+?)\]'
    action: { type: push, key: 'character:notebook', value: '$1' }
    strip: true

  - id: tension
    trigger: '\[TENSION:\s*(low|medium|high)\]'
    action: { type: set, key: 'scene:tension', value: '$1' }
    strip: true

events:
  hand_off:
    pattern: '[HAND-OFF]'
    do:
      - 'unset session:narrator'
      - 'increment arc:beat'
    strip: true

  take_over:
    pattern: '[NARRATOR: $name]'
    do: 'set session:narrator = $name'
    strip: true

event_order:
  - hand_off
  - take_over

dashboard:
  - label: 'Narrator'
    value: '{{state.get::session:narrator}}'
    show_if: '{{has::session:narrator}}'
  - label: 'Beat'
    value: '{{state.get::arc:beat}}'
  - label: 'Tension'
    value: '{{state.get::scene:tension}}'
    show_if: '{{has::scene:tension}}'

Paired with a prompt that reads it back:

{{#if {{has::session:narrator}}}}
You are narrating as {{state.get::session:narrator}}.
{{/if}}

Current tension: {{state.get::scene:tension}}

Notes you have taken:
{{state.show::character:notebook}}

When you want to pass narration to another voice, end your reply with
[HAND-OFF] followed by [NARRATOR: name].
Mark tension with [TENSION: low|medium|high].
Record private observations with [NOTE: ...].

hand_off is listed first in event_order so the beat advances and the old narrator clears before take_over assigns the new one. Without that ordering, the hand-off would erase the narrator that had just been set.