Scripting
JavaScript event handlers that make scenarios react and adapt — the full chat, character, and event API.
Voxta supports scripting in scenarios, events, and actions. Scripts are standard JavaScript modules running in Voxta's sandbox — modern ES syntax, import/export, the whole deal. Scripts can:
- Generate replies (any message type)
- Read and write flags and chat variables
- Fire app triggers at the host app
- React to chat events (messages, transcription, generation, custom)
- Define actions dynamically
A script is just a .js file with a trigger export, or an init script that registers event listeners.
Scripts live for the duration of the chat instance. Variables you set with plain let/const reset when you exit the chat. Use chat.setFlags() and chat.variables to persist data across sessions.
A minimal trigger script
import { chat } from "@voxta";
export function trigger(e) {
// Your code runs when the event/action fires
}@voxta is the package that exposes Voxta's chat state. Import it once at the top of every script.
Sending messages
Every message type is callable from scripts:
export function trigger(e) {
chat.instructions('Instructions visible only to you');
chat.note('A silent note both sides see');
chat.secret('Visible to the character only');
chat.event('A narrated event');
chat.story('A long-form generated narration');
// Speak as the user
chat.userMessage('Hey, how are you?');
// Speak as the (main) character
chat.characterMessage('Hello!');
// Speak as a specific scenario role
chat.roleMessage('detective', 'How are you doing today?');
// Fully custom
chat.customMessage({
role: 'Secret',
text: 'Generated by the story writer',
useStoryWriter: true,
maxNewTokens: 10,
maxSentences: 5,
triggerReply: false,
narrate: false,
});
}The chat object
Characters
// The main character (first role)
const main = e.character;
// A specific role
const guard = chat.roles.guard;
// All characters in the scene
chat.characters.forEach(c => console.log(c.name));Each character has:
id— UUIDname— display namescenarioRole— the role this character is assigned toassets— asset collection (see App triggers / Asset helpers)
User
const userName = e.user.name;Action arguments
Available on actions where the provider declared arguments:
export function trigger(e) {
const speed = e.arguments.speed;
const direction = e.arguments['direction'];
}Variables (stateful)
// Set
chat.variables.score = 0;
chat.set('score', 0); // equivalent
// Get
const score1 = chat.variables.score; // may be undefined
const score2 = chat.get('score', 0); // with default
// Update
chat.variables.score += 5;Variables persist for the duration of the chat (including across save/reload). Use the scenario init script to initialize them so they exist before any action runs:
import { chat } from "@voxta";
chat.addEventListener('start', () => {
chat.variables.score = 0;
});Flags
chat.setFlag('wears_hat');
chat.setFlag('!wears_hat'); // unset
chat.unsetFlag('wears_hat'); // equivalent unset
chat.setFlag('pose.sitting'); // enum, replaces other pose.*
chat.setFlags('a', 'b.enum', '!c'); // multiple at once
if (chat.hasFlag('wears_hat')) { /* ... */ }Expiring flags:
chat.setFlag('cooldown', { messages: 10 }); // 10 conversation messages
chat.setFlag('rush_hour', { seconds: 120 }); // 2 minutesSee Flags for the data model.
Role enable/disable
Hide or show characters mid-chat:
chat.setRoleEnabled('guard', false); // remove from active chat
chat.setRoleEnabled('guard', true); // bring back inApp triggers
Drive the host app:
chat.appTrigger('Emote', '🌳', '#00ff00');
// Queued — fires after the current TTS completes
chat.queueAppTrigger('PlayScenarioAudio', 'Sounds/ping.mp3', 'sfx');See App triggers.
setContext
Set a context entry dynamically from a script (alternative to declaring it in the Contexts tab):
// Activate "char is wearing a crown" tied to flag my_flag
chat.setContext('crown', '{{ char }} is wearing a crown.', 'my_flag');
// Clear it
chat.setContext('crown');chat.time
Elapsed seconds since the chat started:
const elapsed = chat.time;The e object
e is the event object passed to the trigger function and event listeners. The available properties depend on what triggered the script.
Message
export function trigger(e) {
const role = e.message.role; // User, Assistant, Event, Story, ...
const text = e.message.text;
}Also available: e.message.id, e.message.senderId, e.message.index, e.message.conversationIndex (user/character messages only).
Character info (when triggered by a character message)
const name = e.character.name;
const role = e.character.scenarioRole; // e.g. 'main'After speech
Run code after the character finishes the TTS playback for their current line:
e.afterSpeech(() => {
console.log('Speech complete');
});Force the next reply
Override Voxta's default turn-taking:
e.chatFlow(chat.roles.main); // next reply comes from main
e.chatFlow(chat.user); // user gets to go nextEvaluate the next event
Normally one event fires per evaluation pass. To allow the next-evaluating event to also fire (useful for coordinated event chains):
e.evaluateNextEvent();Event listeners
Register listeners on chat to react to lifecycle events. Most belong in the scenario init script.
init
When the script first loads. Initialize module-scope state here.
chat.addEventListener('init', () => {
console.log('Initializing...');
});start
Chat begins, before any messages are sent.
chat.addEventListener('start', (e) => {
console.log('Chat started');
// e.hasBootstrapMessages tells you whether bootstrap messages are queued
});resume
Existing chat is loaded mid-session.
chat.addEventListener('resume', (e) => {
console.log(`Resumed; last message: ${e.message.text}`);
});transcriptionStarted / transcriptionFinished
User started/finished talking (STT).
chat.addEventListener('transcriptionStarted', () => console.log('User started talking'));
chat.addEventListener('transcriptionFinished', (e) => {
if (e.text) console.log(`User said: ${e.text}`);
else console.log('User stopped talking');
});userMessageReceived
User sent a message (typed or transcribed). You can rewrite the message before it reaches the AI:
chat.addEventListener('userMessageReceived', (e) => {
e.rewriteUserMessage(e.message.text.replace(/John/, 'Jane'));
});generating / generatingComplete
Reply generation lifecycle.
chat.addEventListener('generating', (e) => console.log(`Generating reply from ${e.character.name}`));
chat.addEventListener('generatingComplete', (e) => console.log(`Reply: ${e.message.text}`));typingStart / typingEnd
User started/stopped typing in the text input (parallel to transcription events but for keyboard input).
app:*
Custom events fired by the host app. Event name is app-specific.
chat.addEventListener('app:hover', (e) => {
console.log(`User hovered over ${e.arguments.limbName}`);
});action:*
Fired when action inference selects an action. Use this to handle the effect of dynamically-defined actions.
chat.addEventListener('action:my_action', (e) => {
console.log(`Action ${e.action} called on layer ${e.layer}, character ${e.character.name}, arg ${e.arguments.myValue}`);
});Dynamically defined actions
Define actions from scripts (rather than the Actions tab):
import { chat } from '@voxta';
chat.setActions('my_context_key', {
name: 'adjust_desk_height',
layer: 'desk_control',
timing: 'AfterAssistantMessage',
description: 'Adjust the desk height between sitting (0) and standing (5)',
arguments: [
{ name: 'height', type: 'integer', description: 'Desk height 0–5' },
],
});
chat.addEventListener('action:adjust_desk_height', (e) => {
const newHeight = e.arguments.height;
// ...
});Useful for actions that depend on runtime state (e.g. "available actions" change based on what the character is currently holding).
Imports and shared code
Scenario scripts can import from sibling files in the same scenario:
// 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 this round! Score: ${score}`);
}
// scenario/action_handler.js
import { winRound } from './lib';
export function trigger(e) {
winRound(e, 10);
}For parent scenario scripts, use ../base/:
import { somethingShared } from "../base/file_name";You can export let myVar = 5; from a module, but you cannot update a re-exported scalar (module exports are bindings, not references). For mutable shared state, wrap in an object: export let counter = { value: 0 };, then update as counter.value += 1. For state that should persist, prefer chat.variables instead.
Patterns
Initialize state on chat start
import { chat } from "@voxta";
chat.addEventListener('start', () => {
chat.variables.score = 0;
chat.variables.tries = 3;
});Track an action streak
import { chat } from "@voxta";
export function trigger(e) {
const streak = chat.get('streak', 0) + 1;
chat.set('streak', streak);
if (streak >= 3) {
chat.note(`${e.character.name} is on a roll — ${streak} in a row!`);
}
}Branching on user input
import { chat } from "@voxta";
export function trigger(e) {
const text = e.message.text.toLowerCase();
if (text.includes('left')) {
chat.setFlags('went_left', 'direction_chosen');
} else if (text.includes('right')) {
chat.setFlags('went_right', 'direction_chosen');
}
}Play SFX after speech
import { chat } from "@voxta";
export function trigger(e) {
e.afterSpeech(() => {
chat.appTrigger('PlaySound', chat.scenario.assets.get('door_close.wav'));
});
}