The scripting API.
The complete ot surface: every event you can listen to, every action you can take, their exact signatures, and a runnable example for each. Your scripts reach the ot object and nothing else. Anything with a capability is refused until you grant it, and the console tells you when that happens.
The event object
You subscribe with ot.on(name, handler, opts?). Your handler receives one event object (also readable at ot.event during a dispatch):
interface OtDispatchedEvent {
type: string; // e.g. 'alert.redemption'
payload: unknown; // the event data; fields depend on type
params: unknown; // data for a companion-trigger dispatch
isTest: boolean; // true for a Test-fire from the dashboard
occurredAt: string; // ISO timestamp
}name is a registry event name (below), a companion trigger like hotkey, or any future event string. opts.skipTest makes the handler ignore Test-fires from the dashboard.
ot.on('alert.follower', (e) => {
ot.log('New follower:', e.payload.username);
});
// Ignore dashboard Test-fires for this one
ot.on('alert.subscriber', (e) => { /* ... */ }, { skipTest: true });Events you can listen to
If it happens on your stream, you can trigger on it. Every event your channel emits is bindable, 54 of them across 17 categories. Search or filter the full catalog below (this list is generated straight from the platform's event registry, so it is always complete), and the editor's autocomplete offers the same set the moment you type ot.on('.
No events match
Try a different search, or clear the filters to see all events.
Use the dashboard's Test-fire (or the editor's Run and Dry run) to trigger a handler without waiting for a real event. Reach for opts.skipTest only when a handler should stay quiet during those tests.
Trigger without a stream event
Two subscriptions do not wait on Twitch at all. onHotkey needs the hotkey capability, onSchedule the schedule capability.
ot.onHotkey(combo: string, handler): void // a global keyboard shortcut
ot.onSchedule(spec: string, handler): void // a cron-like timer, e.g. '@every 5m'ot.onHotkey('ctrl+alt+d', () => ot.obs.setScene('BRB'));
ot.onSchedule('@every 5m', () => ot.chat.send('Remember to hydrate!'));A handler is cut off if it burns more than ~2 seconds of actual work. Time spent asleep in ot.wait / ot.sleep does not count and is exempt up to 60 seconds, so the flash-a-scene, wait, restore-it pattern is fine. Time inside other calls (ot.fetch, ot.obs.*) does count, so keep those quick.
Core: data, timing & state
The core surface needs no capability and no prompt. It never leaves the sandbox.
ot.log(...args): void
ot.now(): number // ms since the Unix epoch
ot.wait(ms): Promise<void> // (ot.sleep is an alias)
ot.store.get<T>(key): Promise<T | undefined> // persisted per script
ot.store.set(key, value): Promise<void>
ot.store.delete(key): Promise<void>
ot.store.sadd(key, member): Promise<boolean> // add to a set, true if new
ot.store.sismember(key, member): Promise<boolean> // is it in the set?
ot.store.scard(key): Promise<number> // set size
ot.state.media: OtMediaSnapshot // now-playing, queue, volume// Greet each viewer's first message, using a persisted set
ot.on('chat.message', async (e) => {
const user = String(e.payload.username);
if (await ot.store.sismember('greeted', user)) return;
await ot.store.sadd('greeted', user);
ot.chat.send('Welcome, ' + user + '!');
});Each script gets its own key space on your machine. It is for small state (counts, seen-sets, a timestamp), not a database, and it does not sync to the cloud.
Input: keyboard & mouse
The input capability lets a script drive your keyboard and mouse. On macOS it also needs the one-time system Accessibility permission (see Permissions & security).
ot.key(combo: string): void // ONE chord, held together
ot.type(text: string): void // type literal text
ot.hotkey(combos: string[]): void // a SEQUENCE of combos (a macro)
ot.mouse.move(x: number, y: number): void
ot.mouse.click(button?: 'left' | 'right' | 'middle'): void+ joins keys into a chord held together; an array fires elements one after another. ot.key('ctrl+shift+f4') presses all three together, ot.hotkey(['a', 'b']) presses A then B, and ot.hotkey(['ctrl+c', 'ctrl+v']) does copy then paste. To hold one chord through the array form, pass a single element: ot.hotkey(['cmd+space']).
// A redemption presses your OBS replay-save hotkey
ot.on('alert.redemption', (e) => {
if (e.payload.reward_title !== 'Save That') return;
ot.key('cmd+shift+f4'); // 👉 EDIT: your own hotkey
});Process: run & open
The process capability runs a local program or opens a URL or app. run takes the command and its arguments separately (never a single shell string), which keeps viewer text out of a shell.
ot.run(command: string, args?: string[]): Promise<{ code: number; stdout: string; stderr: string }>
ot.open(target: string): void // open a URL, file, or appot.onHotkey('ctrl+alt+s', async () => {
const r = await ot.run('say', ['stream starting']);
ot.log('exit', r.code);
});Sound
The sound capability plays an audio file on the machine running the Companion. path is an absolute path to a file on that machine (mp3, wav, ogg, or flac).
ot.playSound(path: string, opts?: { volume?: number }): void // volume 0..1ot.on('alert.cheer', (e) => {
if (e.payload.bits >= 1000) ot.playSound('/Users/you/Sounds/airhorn.mp3', { volume: 0.6 });
});Picking a sound. You do not have to type the path. In the script editor click the Sound button (or type ot.playSound( and take the "Pick a sound file" autocomplete) to open a native file picker. The chosen file is inserted as an absolute path and shown as a chip: press ▶ to preview it (press again to stop), the replace icon or the filename to choose a different file, and ✕ to remove the call. The chip appears on any ot.playSound path, including one the AI builder writes into a const. Paths reference the file in place, so moving or renaming the original breaks the reference, and a path from one machine will not resolve on another.
Network: fetch
The network capability makes a single, injection-safe web request. Put dynamic values in query or json, never concatenated into the URL: the runtime percent-encodes them so a value cannot break out of a parameter.
ot.fetch(url: string, init?: OtFetchInit): Promise<OtFetchResponse>
interface OtFetchInit {
method?: string; // defaults to 'GET'
query?: Record<string, string | number>; // percent-encoded for you
headers?: Record<string, string>;
json?: unknown; // JSON-serialized request body
}
interface OtFetchResponse {
status: number; // 0 on a transport failure
ok: boolean; // true for a 2xx
json?: unknown; // parsed body when JSON
text: string;
headers: Record<string, string>;
error?: string; // present only on a transport failure
}ot.on('chat.message', async (e) => {
if (e.payload.message_text !== '!weather') return;
const r = await ot.fetch('https://wttr.in/London', { query: { format: '3' } });
if (r.ok) ot.chat.send(r.text);
});OBS
The obs capability drives your local OBS Studio over the OBS WebSocket (the same one Remote Control uses; enable it in OBS under Tools, then WebSocket Server Settings). The named helpers cover the common cases; call sends any raw obs-websocket request.
ot.obs.setScene(sceneName: string): Promise<void>
ot.obs.setInputMute(inputName: string, muted?: boolean): Promise<void>
ot.obs.toggleSource(inputName: string): Promise<void>
ot.obs.call<T>(requestType: string, requestData?: unknown): Promise<T>ot.onHotkey('ctrl+alt+b', async () => {
await ot.obs.setInputMute('Mic', true);
await ot.obs.setScene('BRB'); // 👉 EDIT: your scene names
});Server: chat & flows
The server capability lets a script speak as your bot or fire a Flow event. reply prepends @user from the event you pass it, falling back to a plain send when there is no user.
ot.chat.send(text: string): Promise<void>
ot.chat.reply(e: { user?: string }, text: string): Promise<void>
ot.flow.emit(name: string, payload?: unknown): Promise<void>// Reply 'splat' whenever someone says 'whee'
ot.on('chat.message', (e) => {
const text = String(e.payload.message_text ?? '').toLowerCase();
if (/\bwhee\b/.test(text)) {
ot.chat.reply({ user: e.payload.username }, 'splat');
}
});ot.run, ot.key, ot.type and the ot.fetch URL are injection sinks. Never build them from a viewer's chat text or a redemption message. It is fine to compare or branch on that text, log it, or send it back as a chat message; just do not paste it into a command, a keystroke, or a URL. For a dynamic web request, put the value in fetch's query instead.
Capabilities at a glance
| Capability | Unlocks | Also needs |
|---|---|---|
| Core (always on) | ot.on, ot.log, ot.store, ot.wait, ot.now, ot.state | Nothing |
| Input | ot.key, ot.type, ot.hotkey, ot.mouse | macOS Accessibility |
| Process | ot.run, ot.open | |
| Sound | ot.playSound | |
| Network | ot.fetch | |
| OBS | ot.obs.* | OBS WebSocket on |
| Server | ot.chat.send, ot.chat.reply, ot.flow.emit | |
| Hotkey | ot.onHotkey | |
| Schedule | ot.onSchedule |
Coming soon
Meld Studio control (ot.meld.showScene and friends) and a few other actions are on the way. The editor's autocomplete and the AI builder only ever offer actions that actually work today, so a generated script never reaches for something that is not there yet.