Voxta docs

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

MomentWhat runs
New chat opensInit scripts, then the init and start listeners
Existing chat reopenedInit scripts, then the init and resume listeners
An interaction firesThat interaction's handler
Something happens in the chatAny listener registered for it
Later, on purposeTimer 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 / assert write to the script log.
  • setTimeout / setInterval / clearTimeout / clearInterval are 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.

What's next

On this page