Scripting
How Voxta scenario scripts work: handlers versus init scripts, when each one runs during a chat, and what state they can read and change.
Scripts are how a scenario does something no field expresses — a delay, a computation, a decision that depends on state.
They are standard ES modules: import/export, async/await, promises. Every method they can call is in the Script API; this page is about how they fit together.
Two shapes
A handler exports trigger, and runs when the thing it is attached to fires — an event, a button, an action, a tool.
import { chat } from "@voxta";
export function trigger(e) {
chat.note(`${e.character.name} did something.`);
}An init script runs once when the chat starts and registers listeners instead. The scenario's index script is the usual home for this.
import { chat } from "@voxta";
chat.addEventListener('start', () => {
chat.variables.score = 0;
});
chat.addEventListener('userMessageReceived', (e) => {
if (e.message.text.includes('password')) chat.setFlag('said_password');
});A scenario usually has both: an init script setting up state and listeners, and a handler on each interaction that needs one.
When a handler's action is called as a tool, what trigger returns is the answer. See Answering a tool.
When they run
| Moment | What runs |
|---|---|
| New chat opens | Init scripts, then the init and start listeners |
| Existing chat reopened | Init scripts, then the init and resume listeners |
| An interaction fires | That interaction's handler |
| Something happens in the chat | Any listener registered for it |
| Later, on purpose | Timer callbacks, and chat.queue.* after the current speech |
The full listener list is in the Script API.
What survives
Module state resets when you leave the chat. A let at the top of a file is gone on reload.
Use chat.variables for anything that must persist — they are stored with the chat and survive resume. Use flags for on/off state other things are gated on.
Timers don't survive either. Start them from an init listener, which runs again when the chat is reopened. See Timers.
The distinction that matters: a variable holds a value, a flag is something conditions can filter on. A number belongs in a variable; whether the door is open belongs in a flag. Conditions can read variables too, with $name.
Sharing code
Scenario scripts import siblings by name, and parent-scenario scripts through ../base/:
// scenario/lib.js
import { chat } from "@voxta";
export function winRound(e, points) {
const score = chat.set("score", chat.get("score", 0) + points);
chat.note(`${e.character.name} won! Score: ${score}`);
}
// scenario/handler.js
import { winRound } from './lib';
import { somethingShared } from '../base/shared';
export function trigger(e) {
winRound(e, 10);
}Module exports are bindings, not references — you cannot reassign an exported scalar from another module. Wrap mutable state in an object (export let counter = { value: 0 }), or use chat.variables when it should persist.
The sandbox
- No host interop, no
eval, no network, no filesystem. Only@voxta,@voxta/utils, and sibling scenario files. - Strict mode, recursion capped at 128, 10M statements per entry, 2s regex timeout, 30s promise timeout.
console.log/debug/info/warn/error/assertwrite to the script log.setTimeout/setInterval/clearTimeout/clearIntervalare globals, in milliseconds, on chat time. See Timers.
A runaway script ends as a script error rather than taking the server down with it.
Debugging
The Script section of the chat inspector carries the log, and a browser for code snippets. Flags and Contexts next to it answer most "why didn't that fire?" questions faster than a console.log does.