Script API
Reference for the Voxta script API: every method, event and object on chat, from messages and variables to flags and listeners.
The reference half of Scripting, which covers how scripts are written and when they run.
Everything below hangs off chat, imported once at the top of a script:
import { chat } from "@voxta";Messages
Every type is a method on chat. See Messages.
chat.instructions('Only you see this');
chat.note('Both sides see this, no reply');
chat.secret('Only the character sees this');
chat.event('A door slams'); // narrated, triggers a reply
chat.story('Describe the storm'); // story writer expands it
chat.userMessage('Where are we going?');
chat.characterMessage('To the garden');
chat.roleMessage('detective', 'Right behind you'); // false if no one has that role; '' makes them replynote, secret and instructions take { expiresAfterTurns } to drop out of the prompt after N turns:
chat.secret('Standing by the window.', { expiresAfterTurns: 2 });event, story and customMessage return a promise that settles when the generated message finishes (its speech played out, it was written silently, or it was interrupted), so you can sequence around it:
export async function trigger(e) {
await chat.story('The lights go out.');
chat.appTrigger('PlaySound', chat.scenario.assets.get('breaker.wav'));
}
// Or pass a callback instead
chat.story('The lights go out.', () => chat.setFlag('blackout'));customMessage
chat.customMessage({
role: 'Secret', // Assistant, User, Event, Note, Secret, Instructions
text: 'Then something incredible happens',
useStoryWriter: true,
maxNewTokens: 200,
maxSentences: 2,
allowMultipleLines: false,
includeChatHistory: false,
triggerReply: false,
narrate: false,
expiresAfterTurns: 5,
});State
Variables
Persist for the life of the chat, including across resume. Strings, numbers, booleans, null, and objects or arrays of those, up to 20 levels deep.
chat.variables.score = 0;
chat.set('score', 0); // same thing
const s = chat.get('score', 0); // with a default
chat.variables.score += 5;Reading a variable gives you a copy. Changing an object or array in place does nothing until you assign it back:
const inventory = chat.variables.inventory;
inventory.push('key');
chat.variables.inventory = inventory; // without this, the key is lostFlags
chat.setFlag('wears_hat');
chat.setFlag('!wears_hat'); // unset
chat.unsetFlag('wears_hat');
chat.setFlag('wears_hat', isWearing); // false or 0 unsets
chat.setFlag('pose.sitting'); // enum — clears other pose.*
chat.setFlags('a', 'b.enum', '!c');
chat.hasFlag('wears_hat');
chat.setFlag('cooldown', { messages: 10 }); // expires after 10 messages
chat.setFlag('rush_hour', { seconds: 120 }); // expires after 2 min chat time
chat.setFlag('wears_hat', false); // false or 0 unsetsSee Flags.
Contexts
chat.setContext('crown', '{{ char }} is wearing a crown.', 'my_flag'); // third arg is an optional flag filter
chat.setContext('crown'); // clearServices
chat.hasService(type) says whether this chat has a service of that type, so a scenario can branch on what it can do.
if (chat.hasService('ImageGen')) chat.imagine();
else chat.note('The painter is out today.');Types with an answer: TextGen, ActionInference, Decisions, Summarization, TextToSpeech, SpeechToText, ComputerVision, ImageGen, Animations, VideoGen, PortraitAnimation, Memory. Case doesn't matter. Any other service type returns false; a name that is not a service type throws.
Characters
chat.character // main character
chat.roles.guard // by scenario role
chat.characters // every character in the scene
chat.user
chat.narrator // { id: 'narrator' }, nothing else
chat.scenario // id, assets, appConfigurationCharacters and the user have id, name, scenarioRole (none for the user), assets, appConfiguration. chat.roles only holds the roles the scenario actually defines, so chat.roles.main exists only if a role is named main.
chat.setRoleEnabled('guard', false); // remove from the chat
chat.setCharacterCanSpeak(false); // main character: present, but silent
chat.setCharacterCanSpeak(false, 'guard'); // throws if no character has that roleServices
chat.hasService(type) tells a script whether the chat has a service of that type, so a scenario can offer a feature only where it works:
if (chat.hasService('ImageGen')) {
chat.setButtons('camera', [
{ name: 'Take a photo', description: 'Snap a picture', effect: { setFlags: ['wants_photo'] } },
]);
}Types: TextGen, ActionInference, Summarization, TextToSpeech, SpeechToText, ComputerVision, ImageGen, VideoGen, Animations, PortraitAnimation, Memory, and the rest of the service types. An unknown name throws, so a typo fails loudly rather than reading as "no service".
Assets
On chat.scenario.assets and each character's assets. Patterns are case-insensitive regexes; a /re/ literal works, but without flags.
chat.scenario.assets.get('sounds/door.wav'); // exact path, throws if missing
e.character.assets.oneOf('emote_'); // random match, throws if none
e.character.assets.oneOrNoneOf('rare_'); // random match, undefined if none
chat.scenario.assets.matchFiles('^bg/.*png$'); // all matching pathsget, oneOf and oneOrNoneOf return an asset link (a string) ready to hand to an app trigger. matchFiles returns plain relative paths; pass one through get() to turn it into a link.
Also import { oneOf } from "@voxta/utils" for picking at random: oneOf(['a', 'b']) or oneOf('a', 'b', 'c').
App triggers
chat.appTrigger('Emote', '🌳', '#00ff00');
chat.setBackground(chat.scenario.assets.get('room.jpg'), 1); // path, layer?, volume?
chat.setBackground(chat.scenario.assets.get('fireplace.webm'), 1, 70); // video at 70% volume
chat.setBackground({ path: chat.scenario.assets.get('map.png'), layer: 10, blur: false });
// Queued: runs after the current speech finishes
chat.queue.appTrigger('PlaySound', chat.scenario.assets.get('ping.mp3'));
chat.queue.roleMessage('guard', 'Halt.');
chat.queue.roleEnabled('guard', true);
chat.queue.setFlag('alarm');
chat.queue.setFlags('a', '!b');See App triggers and HUD & stage effects.
Timers
On chat time: a timer pauses when the chat does, and ends with the session. Max 32 active per chat.
chat.* timers take seconds, which must be positive:
const t = chat.setTimeout(() => chat.setFlag('late'), 4);
const h = chat.setInterval(() => chat.setFlag('tick'), 30);
chat.clearTimeout(t);
chat.clearInterval(h);
chat.time; // seconds of chat time since the chat startedThe global setTimeout, setInterval, clearTimeout and clearInterval work too, in milliseconds like in a browser, on the same chat clock. Extra arguments go to the callback, and a delay of 0 is allowed. The callback must be a function, not a code string.
setTimeout((name) => chat.note(`${name} is late.`), 1500, 'The guard');Awaiting a timer in a trigger suspends it, the same as awaiting a message:
export async function trigger(e) {
chat.appTrigger('PlaySound', chat.scenario.assets.get('knock.mp3'));
await new Promise((resolve) => setTimeout(resolve, 2000));
chat.event('Nobody answers.');
}Timers only last for the chat session. Reopening a chat does not bring them back, so start them from an init listener, which runs on both a new chat and a resumed one:
chat.addEventListener('init', () => {
chat.setInterval(() => chat.set('hunger', chat.get('hunger', 0) + 1), 60);
});A timer is queued, not sample-accurate. To land a sound inside a spoken line, use a tool with the "insert" audio method.
Generation
// generateImage(prompt?, character?, options?): the prompt goes straight to the image generator
chat.generateImage('A candlelit tavern at night');
chat.generateImage('A neon street', { analyze: true }); // character sees the result
chat.generateImage('A portrait', e.character); // target a character
chat.generateImage('A portrait', 'guard'); // or a scenario role
chat.generateImage('Her at the beach', e.character, { reference: true }); // keep her look
chat.imagine(); // LLM writes the prompt from context
chat.imagine('the room she just walked into');
chat.imagine({ prompt: 'her childhood home', analyze: true });
chat.imagine({ character: 'guard' }); // picture of someone else
chat.imagine({ params: { avatar: true } }); // use as the avatar
chat.imagine({ params: { background: true, layer: 1 } }); // use as a background
chat.generateAnimation({ prompt: 'A slow curtsy', duration: 4, seed: 12345 });
chat.generateAnimation('A slow curtsy', { character: 'guard' });
chat.perform('Throw both arms up and jump once'); // the character acts it out
chat.perform({ prompt: 'A victory dance', duration: 6, character: e.character });
chat.interrupt(); // stop speech / cancel the replyWithout a character, a picture or motion is about whoever spoke last. character takes a character object or a scenario role name.
generateImage sends the prompt straight to the image generator. reference: true anchors it to the character the way imagine does, by sending their reference image and their last generated picture along with it.
imagine chains from the chat's previous picture. For the very first one, firstReference picks what it starts from: the character's thumbnail by default, a character asset path, or "none" to start from the text alone.
params is passed to the app as is; params.layer follows the background layer zones.
infer
Ask the model for text. Async.
export async function trigger(e) {
const line = await chat.infer('Write a short, cryptic fortune.', { maxSentences: 1 });
chat.event(line);
}Pass a schema and you get a parsed object instead of a string. Fields are "string", "number", "boolean", or an array of allowed values.
const verdict = await chat.infer('Did the user agree to the deal?', {
schema: { agreed: 'boolean', tone: ['friendly', 'hostile', 'neutral'] },
});
if (verdict.agreed) chat.setFlag('deal_struck');| Option | Notes |
|---|---|
maxNewTokens | Token cap. |
maxSentences | Sentence cap (text mode only). |
allowMultipleLines | Allow line breaks (text mode only). |
includeChatHistory | Include the transcript in the prompt. |
schema | Switches to structured mode. |
infer only works from an async trigger or listener. The promise rejects when inference fails or takes over 30 seconds, and in structured mode when the reply still doesn't fit the schema after a retry. Catch it when a fallback makes sense:
try {
const verdict = await chat.infer('Did the user agree?', { schema: { agreed: 'boolean' } });
} catch {
chat.instructions('Could not tell. Assuming no deal.');
}The e object
Passed to trigger. Which fields are set depends on what fired it.
| Field | Notes |
|---|---|
e.message | id, senderId, index, conversationIndex, chatTime, role, text. All strings, so convert before doing maths: Number(e.message.index). |
e.character | The character behind the event |
e.user | Always available |
e.arguments | Actions, tools, and app:* events, typed as declared (an integer is a number). Empty otherwise, never null. |
e.afterSpeech(fn) | Run fn once the resulting speech finishes |
e.chatFlow(who) | Force who replies next: chat.character, chat.roles.guard, chat.user |
e.evaluateNextEvent() | Let the next event also fire this pass |
What trigger returns answers the call when its action was called as a tool.
Event listeners
Register on chat, usually from the scenario init script. chat.on is an alias for addEventListener. An unknown event name throws.
A listener gets its own object, with only the fields below; character and message are the same shapes as on e.
| Event | Fires when | Fields |
|---|---|---|
init | The chat opens, new or resumed | — |
start | A new chat begins | hasBootstrapMessages |
resume | An existing chat is reopened | — |
userMessageReceived | The user sent a message | message, rewriteUserMessage(text) |
generating | A reply starts generating | character, message |
generatingComplete | A reply finished | character, message |
speechStart | TTS playback starts | character, message, startIndex, isNarrator |
speechComplete | TTS playback ends | character, message |
transcriptionStarted | The user starts talking | — |
transcriptionFinished | The user stops talking | text (empty if nothing was said) |
imageGenerated | An image finished generating | message |
buttonPressed | The user pressed a button | button (its name) |
controlChanged | The user moved a control | contextKey, variable, value |
beforeSelectActionInference | Just before an action layer is chosen | character, message, layer, timing (lowercase, e.g. "afterassistantmessage"), actions, setActions(names) |
action:<name> | Action inference or a tool call picked it | character, message, arguments, action, layer, contextKey |
app:<name> | The host app raised a custom event | arguments |
chat.addEventListener('userMessageReceived', (e) => {
e.rewriteUserMessage(e.message.text.replace(/John/g, 'Jane'));
});
chat.addEventListener('start', () => {
chat.variables.score = 0;
});
// Narrow the candidate actions before the inference pass runs
chat.addEventListener('beforeSelectActionInference', (e) => {
if (e.layer === 'movement' && chat.hasFlag('tied_up')) e.setActions([]);
});Answering a tool
When the character calls an action as a tool, what the action's script or an action:<name> listener returns goes back to it as the result, before it finishes the sentence it was writing.
chat.addEventListener('action:roll_dice', (e) => {
return { result: `You rolled a ${1 + Math.floor(Math.random() * 20)}.` };
});
// In the action's own script: anything that isn't a string is sent as JSON
export function trigger(e) {
return { result: { room: chat.get('room'), exits: ['kitchen', 'study'] } };
}- A bare value works too, and means the same:
return 'Rolled a 12.'. A string is sent as written; anything else as JSON. - Return nothing and there is nothing to say; the character is only told the action ran.
- An
asyncscript or listener answers once its promise settles, so it canawait chat.infer. One that awaits a message or a timer first can't answer: the call does not wait that long. - If several listeners answer, the first one wins and the rest are logged as a warning.
- Outside a tool call nothing is waiting, and the value is ignored.
export async function trigger(e) {
const weather = await chat.infer('Describe the weather in one word.', { maxSentences: 1 });
return { result: { weather, temperature: 18 } };
}Don't answer with chat.note: it arrives a turn late, and the call waiting on it gets nothing.
Defining things at runtime
Each of these is keyed by a context key: calling it again with the same key replaces the set, and an empty array clears it.
Actions
chat.setActions('desk', [
{
name: 'adjust_desk_height',
description: 'Adjust the desk between sitting (0) and standing (5)',
layer: 'desk_control',
timing: 'AfterAssistantMessage',
invocation: 'ActionInference', // or 'ToolCalling'
arguments: [
{ name: 'height', type: 'integer', description: 'Desk height 0-5', required: true },
],
},
]);
chat.addEventListener('action:adjust_desk_height', (e) => {
chat.set('desk_height', e.arguments.height);
});| Field | Notes |
|---|---|
name, description | Required. |
shortDescription | Shown to the character itself. |
layer | Groups mutually exclusive actions. |
timing | AfterUserMessage, BeforeAssistantMessage, AfterAssistantMessage, AfterAnyMessage, Manual. |
invocation | ActionInference (default) or ToolCalling. |
arguments | { name, type, description?, required?, choices? }. type is required: string, integer, double, boolean, array, void. |
flagsFilter, matchFilter (array), roleFilter | Conditions. See Actions. |
once, disabled, cancelReply, finalLayer | Behaviour switches. |
activates | Actions this one unlocks. |
setFlags, note, secret, instructions, event, story, trigger, maxTokens, maxSentences | Effects, set directly on the action, not inside an effect object. For logic, use an action:<name> listener. |
Unlike buttons, runtime actions read their effects from the action itself. An effect: { note: … } object is ignored, and one with a script throws.
chat.setActions('door', [
{ name: 'open_door', description: 'Open the front door', setFlags: ['door_open'], note: 'The door creaks open.' },
]);Buttons and controls
chat.setButtons('doors', [
{ name: 'Left door', description: 'Open the left door', effect: { setFlags: ['chose_left'] } },
{ name: 'Right door', description: 'Open the right door', effect: { setFlags: ['chose_right'] } },
]);
chat.setControls('settings', {
title: 'Settings',
controls: [
{ type: 'toggle', variable: 'lights_on', label: 'Lights' },
{ type: 'checkbox', variable: 'safe_mode', label: 'Safe mode' },
{ type: 'slider', variable: 'volume', label: 'Volume', min: 0, max: 100, step: 5, live: true },
],
});Buttons take name, description, flagsFilter, roleFilter, once, disabled and an effect object; they cannot run a script. Controls take variable and type (required), label, flagsFilter, disabled, and for sliders min/max/step (default 0/100/1) and live. A bare array works in place of { title, controls }. Both are keyed like actions. See Buttons & controls.