For migrating plugins from API v2.0 to v3.0, see the Migration Guide. For Reference documentation and type definitions, see the DTS file.
Welcome to the RisuTanium Plugin Development Guide! This guide will help you create powerful, secure plugins for RisuTanium using API v3.0
- Getting Started
- Plugin Structure
- API v3.0 Architecture
- Core Concepts
- Working with the DOM
- Plugin UI
- Data Storage
- Advanced Features
- Best Practices
- Examples
- Troubleshooting
RisuTanium plugins are JavaScript extensions that can add new features, customize behavior, and integrate with external services. Plugins run in a secure, sandboxed environment to protect user data and privacy.
We recommend starting with our Typescript plugin template for best practices and type safety.
You can download Typescript template from RisuTanium app -> Plugin Settings -> </> Menu -> Download plugin template.
If you are using IDE like Visual Studio Code, you can open the template folder directly and start coding with IntelliSense support.
You can import your plugin script directly in RisuTanium app via Plugin Settings -> Import Plugin.
if your browser support local file access, we recommend using Hot Reload feature for faster development cycle. to use Hot Reload, import the plugin via Plugin Settings -> </> Menu -> Import plugin with hot reload
Every plugin starts with metadata comments and a main script:
//@name My Awesome Plugin
//@display-name My Awesome Plugin
//@api 3.0
//@arg api_key string Your API key
//@arg max_retries int Maximum retry attempts
//@link https://github.com/yourname/plugin Documentation
// Your plugin code here
(async () => {
try {
console.log('Plugin initialized');
// Your initialization code
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();Metadata comments define your plugin's identity and configuration: It must be placed at the very top of your plugin script.
-
//@name- Internal plugin name (must be unique)//@name my_pluginWe do not recommend changing this after publishing, as it may break existing installations.
-
//@api- API version (use3.0for new plugins)//@api 3.0
-
//@display-name- User-friendly display name//@display-name My Awesome PluginUnlike
//@name, this can be changed freely without breaking installations. -
//@arg- Define plugin arguments//@arg setting_name string Description of the setting //@arg max_items int Maximum number of items
Supported types:
string,intSyntax://@arg <name> <type> <description and optional metadata> -
//@link- Add custom links//@link https://example.com/docs Documentation //@link https://example.com/support Get Support
The links will appear in the plugin settings UI.
-
//@update-url- URL to check for updates//@update-url https://raw.githubusercontent.com/username/repo/branch/plugin.jsPut your plugin's latest raw js file URL here for automatic update checks. the server must support CORS and Range requests. We recommend hosting on GitHub repo and referencing the raw file URL. (e.g.
https://raw.githubusercontent.com/username/repo/branch/plugin.js). -
//@version- Version of your plugin//@version 1.0.0Required for update checks. Although other version formats are supported, we recommend using Semantic Versioning (e.g.
1.0.0,2.1.3). This should be updated manually by you whenever you release a new version. unlike other metadata, this metadata requires to be high on the file, ideally right below the//@nameand//@apimetadata, so that the update checker can read it easily.
API v3.0 plugins run inside a sandboxed iframe for security. This architecture provides:
- Isolation: Each plugin runs in its own isolated context
- Security: Limited access to the main application prevents data leaks
- Custom UI: The iframe can display custom interfaces
- Structured Communication: Data is safely passed using structured cloning
+=====================================+
| Main RisuTanium Application |
| |
| +===============================+ |
| | Plugin Iframe (Hidden) | |
| | | |
| | - Your Plugin Code | |
| | - Custom UI (optional) | |
| | - Risuai API access | |
| +===============================+ |
| |
| Safe DOM Access via getRootDocument()
+=====================================+
CRITICAL: All API methods in v3.0 return Promises, even if they appear synchronous in the code. Always use await or .then() when calling API methods.
// L WRONG - Will not work as expected
const character = Risuai.getCharacter();
// CORRECT - Always use await
const character = await Risuai.getCharacter();
// ALSO CORRECT - Using .then()
Risuai.getCharacter().then(character => {
// Work with character
});This applies to ALL Risuai API methods, including:
- Data access (
getCharacter,getDatabase, etc.) - DOM operations via
getRootDocument() - Storage operations
- All other API methods
All API v3.0 functionality is available through the global Risuai object:
// Get character data
const character = await Risuai.getCharacter();
// Access the main document
const rootDoc = await Risuai.getRootDocument();The Risuai global object is your gateway to all plugin functionality:
// Version information
console.log(Risuai.apiVersion); // "3.0"
console.log(Risuai.apiVersionCompatibleWith); // ["3.0"]
// Logging
console.log('This appears as: [RisuAI Plugin: PluginName] This...');
// Container management
await Risuai.showContainer('fullscreen'); // Show your iframe UI
await Risuai.hideContainer(); // Hide your iframe UI
// DOM access
const doc = await Risuai.getRootDocument(); // Access main document safelyYour plugin has access to two separate DOM contexts:
-
Your iframe's DOM (standard
documentobject)- Full access using standard DOM APIs
- Hidden by default
- Use for your plugin's custom UI
- Sandboxed from the main application, no additional security restrictions and breaking changes expected
-
Main application DOM (via
getRootDocument())- Restricted access through
SafeDocument/SafeElementwrappers - Use to interact with RisuTanium's interface
- Security restrictions prevent malicious behavior
- Additional restrictions might be added in the future for user safety, including breaking changes.
- Restricted access through
We recommend using your iframe's DOM for custom UI whenever possible, and only access the main document when absolutely necessary.
Your plugin's iframe has full access to the standard DOM API:
// Create elements
const container = document.createElement('div');
const button = document.createElement('button');
// Set content
button.textContent = 'Click Me!';
container.appendChild(button);
// Add to iframe body
document.body.appendChild(container);Remember: Use await because all API methods are async!
// Get the root document
const rootDoc = await Risuai.getRootDocument();
// Create elements
const container = await rootDoc.createElement('div');
const button = await rootDoc.createElement('button');
// Set content
await button.setTextContent('Click Me!');
await container.appendChild(button);
// Query existing elements
const chatArea = await rootDoc.querySelector('.chat-container');
if (chatArea) {
await chatArea.appendChild(container);
}The SafeElement wrapper provides secure DOM manipulation with these methods:
// Adding and removing children
await element.appendChild(childElement);
await element.removeChild(childElement);
await element.prepend(childElement);
await element.remove();
// Replacing elements
await element.replaceChild(newChild, oldChild);
await element.replaceWith(newElement);
// Cloning
const copy = await element.cloneNode(true); // deep clone// Getting text (remember: async!)
const text = await element.innerText();
const content = await element.textContent();
// Setting text
await element.setTextContent('Hello World');
await element.setInnerText('Hello World');All HTML is automatically sanitized with DOMPurify to prevent XSS attacks:
// Set HTML (safe - scripts are removed)
await element.setInnerHTML('<div class="message">Hello!</div>');
// This will be sanitized - script tag removed
await element.setInnerHTML('<script>alert("XSS")</script>');
// Get HTML
const html = await element.getInnerHTML();
const outerHtml = await element.getOuterHTML();
// Set outer HTML (replaces the element itself, also sanitized)
await element.setOuterHTML('<div class="replaced">Replaced!</div>');For security reasons, only x- prefixed custom attributes can be directly accessed:
// Allowed - custom attributes
await element.setAttribute('x-plugin-id', 'my-id');
const id = await element.getAttribute('x-plugin-id');
// L Not allowed - will throw error
await element.setAttribute('onclick', 'alert()'); // Error!
await element.setAttribute('href', 'javascript:...'); // Error!Use dedicated methods for standard attributes:
// For links, use createAnchorElement
const link = rootDoc.createAnchorElement('https://example.com');
// For styles, use style methods
await element.setStyle('color', 'red');// Individual style properties
await element.setStyle('color', 'blue');
await element.setStyle('fontSize', '16px');
const color = await element.getStyle('color');
// Style attribute as string
await element.setStyleAttribute('color: red; font-size: 14px;');
const styleStr = await element.getStyleAttribute();
// CSS classes
await element.addClass('active');
await element.removeClass('inactive');
await element.setClassName('container active');
const className = await element.getClassName();
const isActive = await element.hasClass('active');// Query descendants
const buttons = await element.querySelectorAll('.button');
const firstButton = await element.querySelector('.button');
const byId = await element.getElementById('submit-btn');
const byClass = await element.getElementsByClassName('message');
// Navigation
const children = await element.getChildren();
const parent = await element.getParent();
// Matching
const matches = await element.matches('.selected');const height = await element.clientHeight();
const width = await element.clientWidth();
const top = await element.clientTop();
const left = await element.clientLeft();
const rect = await element.getBoundingClientRect();
const rects = await element.getClientRects();Event listeners have security restrictions and return unique IDs:
// Add event listener - returns ID for later removal
const listenerId = await element.addEventListener('click', async (event) => {
console.log('Element clicked!');
// Do something with the event
const target = event.target;
}, { capture: false });
// Remove event listener using the ID
await element.removeEventListener('click', listenerId);Allowed Events (Unlimited):
- Mouse:
click,dblclick,contextmenu,mousedown,mouseup,mousemove,mouseover,mouseleave - Pointer:
pointercancel,pointerdown,pointerenter,pointerleave,pointermove,pointerout,pointerover,pointerup - Scroll:
scroll,scrollend
Allowed Events (Random Delay for Anti-Fingerprinting):
- Keyboard:
keydown,keyup,keypress(delayed randomly to prevent timing attacks)
Keyboard events while an alert is shown:
While a confirm, notice or progress dialog covers the page, document key listeners (V2 and V3 plugins alike) still receive keys, as they always have. In the app's own document, held-key repeats of Enter or Space on a button, link or other control are cancelled before any listener sees them; a plugin iframe has its own document and is not affected.
Blocked Events: All other event types are blocked for security reasons.
await element.focus();// Get the tag name (e.g., "DIV", "BUTTON")
const tag = await element.nodeName();
// Get the node type (1 = ELEMENT_NODE)
const type = await element.nodeType();// Scroll element into viewport
await element.scrollIntoView();
// With options
await element.scrollIntoView({ behavior: 'smooth', block: 'center' });const doc = await Risuai.getRootDocument();
// Create regular elements (limited to whitelist)
const div = await doc.createElement('div');
const span = await doc.createElement('span');
const button = await doc.createElement('button');
// Non-whitelisted tags become <div>
const unknown = await doc.createElement('custom-element'); // Creates <div>
// Create validated anchor links
const link = await doc.createAnchorElement('https://example.com');
// Only http/https allowed
const badLink = await doc.createAnchorElement('javascript:alert()'); // href becomes '#'Use SafeMutationObserver to watch for changes:
// Create observer
// The callback receives a SafeClassArray<SafeMutationRecord>
const observer = await Risuai.createMutationObserver(async (mutations) => {
const mutationArray = await Risuai.unwarpSafeArray(mutations);
for (const mutation of mutationArray) {
// SafeMutationRecord uses async getter methods
console.log(`Type: ${await mutation.getType()}`);
// getTarget() returns a SafeElement
const target = await mutation.getTarget();
// getAddedNodes() returns SafeClassArray<SafeElement>
const addedNodes = await Risuai.unwarpSafeArray(await mutation.getAddedNodes());
for (const node of addedNodes) {
console.log(`Node added: ${await node.nodeName()}`);
}
}
});
// Start observing
const rootDoc = await Risuai.getRootDocument();
const body = await rootDoc.querySelector('body');
await observer.observe(body, {
childList: true,
subtree: true,
attributes: true
});SafeClassArray<T> is a secure array-like container used in several APIs (e.g., mutation observer callbacks, getAddedNodes()). Use unwarpSafeArray() to convert it to a standard JavaScript array:
// Recommended: use unwarpSafeArray for easy iteration
const items = await Risuai.unwarpSafeArray(safeArray);
for (const item of items) {
console.log(item);
}
// Or use SafeClassArray methods directly
const length = await safeArray.length();
for (let i = 0; i < length; i++) {
const item = await safeArray.at(i);
console.log(item);
}
// Push a new item
await safeArray.push(newItem);Your plugin's iframe is hidden by default. You can show it to display custom interfaces:
Unlike getRootDocument(), your iframe's document is the standard DOM API without restrictions.
// Build your UI in the iframe's document
async function showPluginUI() {
// Access your iframe's document (standard DOM API)
const myDoc = document;
myDoc.body.innerHTML = `
<div style="padding: 20px; background: #1e1e1e; color: white;">
<h1>My Plugin Settings</h1>
<button id="save-btn">Save</button>
<button id="close-btn">Close</button>
</div>
`;
// Add event listeners (standard DOM)
myDoc.getElementById('save-btn').addEventListener('click', async () => {
await saveSettings();
});
myDoc.getElementById('close-btn').addEventListener('click', async () => {
await Risuai.hideContainer();
});
// Show the iframe in fullscreen
await Risuai.showContainer('fullscreen');
}When shown in fullscreen mode, your iframe:
- Is moved to
document.body - Positioned fixed at (0, 0)
- Sized to 100% width and height
- Has z-index of 1000
- Border removed
Add buttons and menu items to RisuTanium's interface:
Risuai.registerSetting(
'My Plugin Settings',
async () => {
// Called when user clicks the menu item
await Risuai.showContainer('fullscreen');
},
'<svg width="24" height="24">...</svg>', // Optional icon
'html' // Icon type: 'html', 'img', or 'none'
);Risuai.registerButton({
name: 'Quick Action',
icon: 'https://example.com/icon.png', // Optional icon URL
iconType: 'img', // Icon type: 'html', 'img', or 'none'
location: 'action' //you can also use 'chat' or 'hamburger' for chat or hamburger menu
}, async () => {
// Called when user clicks the button
const char = await Risuai.getCharacter();
await console.log(`Current character: ${char.name}`);
});Icon Types:
'html'- Raw HTML (SVG, emoji, etc.)'img'- Image URL'none'- No icon (text only)
Both registerSetting and registerButton return a UIPartResponse with an id. Use unregisterUIPart to remove a registered element:
const btn = await Risuai.registerButton({
name: 'My Button',
icon: '🔥',
iconType: 'html',
location: 'action'
}, async () => { /* ... */ });
// Later, remove it
await Risuai.unregisterUIPart(btn.id);Use arguments for user-configurable settings:
// Define in metadata
//@arg api_key string Your API key
//@arg max_retries int Maximum retry attempts
// Access in code (remember: async!)
const apiKey = await Risuai.getArgument('api_key');
const maxRetries = await Risuai.getArgument('max_retries');
// Update values
await Risuai.setArgument('max_retries', 5);pluginStorage is save-file specific: it travels with the save file itself, for example through a .bin backup and restore, not with the device:
// All operations are synchronous (wrapper around sync storage)
await Risuai.pluginStorage.setItem('user_preference', 'dark_mode');
await Risuai.pluginStorage.setItem('last_sync', Date.now().toString());
const preference = await Risuai.pluginStorage.getItem('user_preference');
const allKeys = await Risuai.pluginStorage.keys();
const count = await Risuai.pluginStorage.length();
await Risuai.pluginStorage.removeItem('last_sync');
await Risuai.pluginStorage.clear(); // Remove all itemsUse pluginStorage when:
- You want data to travel with the save file, not stay tied to one device
- Data is specific to a save file
- Storing user preferences or plugin state
safeLocalStorage is device-specific and shared between plugins:
// Same API as pluginStorage
await Risuai.safeLocalStorage.setItem('device_id', 'unique-id');
const deviceId = await Risuai.safeLocalStorage.getItem('device_id');Use safeLocalStorage when:
- Data should stay on one device
- Storing device-specific settings
- Sharing data between plugins
getLocalPluginStorage() returns a SafeLocalPluginStorage instance: device-local storage that supports any JSON-serializable value (unlike safeLocalStorage which is strings-only), with generic type support:
const storage = await Risuai.getLocalPluginStorage();
// Store any JSON-serializable value
await storage.setItem('config', { theme: 'dark', fontSize: 14 });
// Retrieve with type inference
const config = await storage.getItem('config');
// List all keys
const keys = await storage.keys();
// Remove an item or clear all
await storage.removeItem('config');
await storage.clear();Use getLocalPluginStorage() when:
- You need to store structured objects (not just strings)
- Data should stay on one device
Access RisuTanium's database for characters, personas, and more:
// Get database (remember: async!)
const db = await Risuai.getDatabase();
// Request only specific keys for better performance
const db = await Risuai.getDatabase(['characters', 'personas']);
// Access allowed properties
console.log(db.characters);
console.log(db.personas);
console.log(db.modules);
// Update database
db.characters.push(newCharacter);
await Risuai.setDatabase(db); // Full save
// Or use lite version (faster)
await Risuai.setDatabaseLite(db);getDatabase() returns null if the user has not granted database access consent.
Fork-specific note (not upstream RisuAI): a character can be archived. As upstream does, getDatabase() returns a placeholder for an archived character, not its data. A placeholder has coldstorage set (truthy). Every placeholder carries its real name, image and chaId, coldstorage, and one empty dummy chat. Placeholders built by this fork also carry lastInteraction and trashTime when they are set, a shortened description in creatorNotes, the real chat count in coldChatCount, a real type, a group's characters, and for a character its tags and creator (from placeholder version 3; an older placeholder may carry empty tags and creator from defaults, so empty does not mean the character has none). Placeholders made by upstream RisuAI, which a save brought over from upstream may contain, carry only the first set, plus internal fields (chatPage, firstMsgIndex, coldStoragedChats), and their type is 'character', even for a group, until the app rewrites the placeholder at startup (on a host that supports startup archiving, whether or not it is turned on). Do not rely on the fork-only fields being present. Treat any element with coldstorage set as a placeholder, and call getCharacterFromIndex() for its full data:
const db = await Risuai.getDatabase(['characters']);
if (!db) return;
for (let i = 0; i < db.characters.length; i++) {
const entry = db.characters[i];
let full = entry;
if (entry.coldstorage) {
// Indices can shift between awaits, so check what you got back.
full = await Risuai.getCharacterFromIndex(i);
if (!full || full.chaId !== entry.chaId) continue;
}
// ...
}Fork-specific note (not upstream RisuAI): setDatabase() and setDatabaseLite() do not let a placeholder replace a character. If characters holds a placeholder that may not take the place, the call does not reject. It puts back the live character with the same chaId (matched by chaId, not by index), or leaves the placeholder out when no character in the list has that chaId, and logs a console warning. A placeholder is refused when the character with the same chaId is loaded in full, is archived under a different unit, or does not exist. A placeholder you read from getDatabase() and hand back unchanged is accepted while the live character is still archived under that unit, and so is a full character in place of a placeholder.
Allowed database keys:
charactersmodulesenabledModulesmoduleIntergrationpluginV2personaspluginspluginCustomStoragetemperatureaskRemovalmaxContextmaxResponsefrequencyPenaltyPresensePenaltythemetextThemelineHeightseperateModelsForAxModelsseperateModelscustomCSSguiHTMLcolorSchemeNamecharacterOrderselectedPersona
Fork-specific note (not upstream RisuAI): setDatabase() and setDatabaseLite() merge a plugins value into the installed plugin list by name. They never replace the list. This differs from upstream RisuAI in these ways:
- A plugin left out of the list stays installed. An entry whose
scriptequals the installed script is ignored, whatever its other fields hold. A snapshot fromgetDatabase()written back unchanged therefore changes nothing, and a plugin cannot change another plugin's saved values or on/off state through the list. A console warning is logged when an ignored entry differs in another field. UsesetArgumentfor your own arguments. - An entry with an installed name and a different script is an update only when the
//@versionin the new script is newer than the installed version. The same, an older or a missing version is ignored with a console warning, and a stale copy of the list cannot silently revert an update. - An update or a new plugin is read from the
//@header of itsscript, not from the other fields of the entry. The header//@namemust equal the entry'sname, and a new plugin must declare//@api 3.0(2.0 and 2.1 are refused). The user is asked once per update or install, in list order, and the prompt names the plugins and, for an update, the versions. - An update keeps the saved argument values for arguments the new header still declares with the same type, and keeps the on/off state. A new plugin starts switched on with the header's default values, whatever
realArgandenabledyou sent. The new script runs after the nextloadPlugins(). - If any change is declined, is refused (a header that does not parse, names another plugin or is not 3.0), or cannot be applied once the prompts are answered, no change to the list is applied. The other keys of the call are still saved, and the call rejects with an
Errorwhose message names the plugin and the reason. Apluginsvalue that is not an array is handled the same way. Your own entry with a different script that is not newer than the installed one rejects with "not newer than installed", so a self-updater can tell that its update did not happen. setDatabaseLite()prompts for these changes too. It saves the call's data before it returns when nothing needs asking, and after the prompts otherwise.- The Update button, re-importing a plugin and
//@update-urlupdates keep the saved values and the on/off state in the same way. Hot reload keeps the saved values and switches the plugin on. installPlugin()returns the plugins the user accepted and writes nothing. It only offers plugins whose name is not installed, and its header must name the entry.
// Self-update: the host asks once, keeps the saved settings the new version still declares, and rejects if the user declines.
const db = await Risuai.getDatabase(['plugins']);
const mine = db.plugins.find((p) => p.name === 'My Plugin');
mine.script = newScript; // its //@version must be newer than the installed one
try {
await Risuai.setDatabase({ plugins: db.plugins });
} catch (e) {
console.warn('Update not applied:', e.message);
}Convenient methods for working with the current character:
// Get current character (async!)
const character = await Risuai.getCharacter();
console.log(character.name);
console.log(character.description);
// Modify character
character.customField = 'new value';
// Save changes
await Risuai.setCharacter(character);Legacy names (still work, but prefer new names):
Risuai.getChar(): UseRisuai.getCharacter()Risuai.setChar(): UseRisuai.setCharacter()
Access characters and chats by their position in the database, and get the currently selected indices:
// Get the index of the currently selected character and chat
const charIndex = await Risuai.getCurrentCharacterIndex();
const chatIndex = await Risuai.getCurrentChatIndex();
// Read a character by index
const character = await Risuai.getCharacterFromIndex(0);
if (character) {
console.log(character.name);
}
// Save a modified character back by index
character.description = 'Updated description';
await Risuai.setCharacterToIndex(0, character);
// Read a specific chat for a character
const chat = await Risuai.getChatFromIndex(charIndex, chatIndex);
// Save a modified chat back
await Risuai.setChatToIndex(charIndex, chatIndex, chat);chat.id and a character's chaId must be unique across the database, so a copy of a chat or character passed to any of the APIs above must drop or regenerate them rather than carry over the original's. Keep the original chat.id or chaId when you are replacing that same chat or character; an object installed without one gets a fresh id, so it is treated as a different chat or character from anything that already existed.
Fork-specific note (not upstream RisuAI): these calls treat an archived character (one whose entry in getDatabase() has coldstorage set) differently from upstream. A character can be archived.
getCharacterFromIndex()andgetChatFromIndex()return the full data, read from the archive as an independent copy. The character is not loaded into the app. Upstream returns the placeholder: forgetChatFromIndex(), its own dummy chat for chat index 0 andnullfor any other index. If the archive is missing, unreadable, or does not hold that character, the call rejects and the user sees one alert naming the character.setChatToIndex()loads the character first, then writes to it. If the archive is missing, unreadable, or does not hold that character, nothing is written, the call rejects, and the user sees one alert naming the character. It also rejects if the character is no longer in the list or can no longer be loaded. As upstream does, an out-of-range chat index does nothing, so a call that resolves does not guarantee the write.setCharacter()(and the legacysetChar()) andsetCharacterToIndex()reject, and write nothing, when you pass a placeholder (coldstorageset) that may not take the place. A placeholder is refused when the character with the samechaIdis loaded in full, is archived under a different unit, or does not exist. The same placeholder you read fromgetDatabase()is accepted while the live character is still archived under that unit, and so is a full character. An out-of-range index passed tosetCharacterToIndex()does nothing and does not reject.
Fork-specific note (not upstream RisuAI): a write that would leave the page with data that cannot be saved is refused. setDatabase(), setDatabaseLite(), setCharacter() (and the legacy setChar()), setCharacterToIndex() and an assignment to characters or modules on the database object a V2 plugin receives throw (the two database setters reject) with an Error, and write nothing, when:
charactersormodulesis not an array, or an entry ofcharactersis not an object (a number,nullor an array, for example);- a character's
chaIdis present but cannot be saved: it is one ofroot,config,preset,modules,loadouts,plugins,pluginStorageor__proto__, is longer than 255 bytes in UTF-8, is not well-formed text, or is not a string or a finite number.
A missing or empty chaId is still accepted and given a new id. An id that cannot be saved is accepted only where the character list already holds the same id (so setDatabase(getDatabase()) and setCharacter(getCharacter()) keep working while saving waits on such a character), never as an additional holder of it. setCharacter() (and setChar()) and setCharacterToIndex() also never move it onto a different character, because they check the one slot they replace. setDatabase() and setDatabaseLite() check only how many entries of the list carry each such id, so a held id may move between entries of a whole list.
Uses Risuai's fetch with CORS handling and proxy support:
const response = await Risuai.nativeFetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await Risuai.getArgument('api_key')}`
},
body: JSON.stringify({ query: 'hello' })
});
const data = await response.json();
console.log(`Received: ${JSON.stringify(data)}`);Direct browser fetch (may have CORS issues):
const response = await Risuai.nativeFetch('https://api.example.com/data');
const data = await response.json();Modify content at different processing stages:
// Add handler for display output
Risuai.addRisuScriptHandler('display', async (content) => {
// Modify content before display
return content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
});
// Add handler for user input
Risuai.addRisuScriptHandler('input', async (content) => {
// Process user input before sending
return content.trim();
});
// Remove handler
Risuai.removeRisuScriptHandler('display', handlerFunction);Available modes:
'display'- Modify content before showing to user'output'- Modify AI output'process'- Modify content during processing'input'- Modify user input
Replace or modify message arrays:
// Add replacer before sending to AI
Risuai.addRisuReplacer('beforeRequest', async (messages, type) => {
// Add system message
return [
{ role: 'system', content: 'You are a helpful assistant.' },
...messages
];
});
// Add replacer after receiving from AI
Risuai.addRisuReplacer('afterRequest', async (content, type) => {
// Modify response text
return content.toUpperCase();
});
// Remove replacer
Risuai.removeRisuReplacer('beforeRequest', replacerFunction);// Read image assets
const imageData = await Risuai.readImage('asset-id');
// Save assets
const savedPath = await Risuai.saveAsset(assetData);Inlay assets are files the user attaches to a chat message (e.g. drag-and-dropped images). Raw message text contains a placeholder of the form {{inlayed::<uuid>}} referencing the asset.
// In a custom provider handler, recover the user-attached image:
const charIdx = await Risuai.getCurrentCharacterIndex();
const chatIdx = await Risuai.getCurrentChatIndex();
const chat = await Risuai.getChatFromIndex(charIdx, chatIdx);
const lastMsg = chat.message[chat.message.length - 1];
const m = lastMsg.data.match(/\{\{inlayed::([a-f0-9-]+)\}\}/i);
if (m) {
const inlay = await Risuai.readInlay(m[1]);
// inlay.data === "data:image/png;base64,iVBORw0..."
// inlay.ext === "png"
// inlay.type === "image"
// ... forward to external multi-modal API (Anthropic / OpenAI / etc.)
}readInlay(id) returns null if no asset exists for the given UUID. Supported type values: 'image' | 'video' | 'audio' | 'signature'.
Change the application color scheme at runtime:
// Switch to a preset color scheme
// Available presets: 'default', 'dark', 'light', 'cherry', 'galaxy',
// 'nature', 'realblack', 'monokai-light', 'monokai-black'
await Risuai.changeColorScheme('dark');
// Get the current scheme name and values
const { name, scheme } = await Risuai.getColorScheme();
console.log(name); // e.g., 'dark'
console.log(scheme.bgcolor);
// Apply a fully custom color scheme
await Risuai.setColorScheme({
bgcolor: '#1a1a2e',
darkbg: '#16213e',
borderc: '#0f3460',
selected: '#e94560',
draculared: '#e94560',
textcolor: '#ffffff',
textcolor2: '#a8a8b3',
darkBorderc: '#0a2040',
darkbutton: '#0f3460',
type: 'dark'
});Control chat text colors:
// Switch to a preset text theme: 'standard' | 'highcontrast'
await Risuai.changeTextTheme('highcontrast');
// Get current theme
const { name, customTheme } = await Risuai.getTextTheme();
// Apply a custom text theme
await Risuai.setCustomTextTheme({
FontColorStandard: '#ffffff',
FontColorBold: '#ffdd57',
FontColorItalic: '#aabbff',
FontColorItalicBold: '#ffaaff',
FontColorQuote1: '#88ffaa',
FontColorQuote2: '#ffaa88'
});RisuTanium's plugin API lets you intercept Text-to-Speech just before synthesis and just before playback.
Runs before TTS synthesis for every message, across every provider (including webspeech). The hook receives the text that is about to be spoken along with the active provider's name and the character id, and may return a replacement text or abort playback.
//@name simple-tts-prefix
//@api 3.0
(async () => {
await Risuai.addTTSPreprocessor(async (ctx) => {
return { text: '[narrator] ' + ctx.text };
});
})();Runs after the provider returns its audio, just before it is decoded and played. The hook receives the raw encoded bytes (mp3, wav, etc.) plus the MIME type. Return a replacement { audio, mimeType } to swap the audio, { skip: true } to suppress playback, or undefined / void to pass through unchanged.
Does not run for the webspeech provider (the browser synthesizes and plays internally, so there is no audio buffer to intercept) or the vits provider (uses its own playback path).
//@name volume-normalize-tts
//@api 3.0
(async () => {
await Risuai.addTTSPostprocessor(async (ctx) => {
// Decode to PCM inside the plugin iframe.
const audioCtx = new AudioContext();
const decoded = await audioCtx.decodeAudioData(ctx.audio);
// ... analyse peak amplitude, produce a normalised Float32Array,
// re-encode to wav bytes as `newBytes` ...
return { audio: newBytes, mimeType: 'audio/wav' };
});
})();- Sequential. Hooks run in registration order; each receives the previous hook's output.
- Error isolation. If a hook throws, its result is discarded and the next hook runs. TTS playback itself is not interrupted.
- No enforced timeout. Matches the trust model of
addRisuScriptHandlerandaddRisuReplacer. A hook that hangs will stall TTS playback for that message. Plugins that call slow services (auxiliary LLMs, remote audio processors) should implement their ownAbortController+ timer for cancellation. - Short-circuit. A hook that returns
{ skip: true }stops the pipeline and aborts the TTS for that message. Later hooks are not called. - No permission prompt. These hooks are in the same trust category as
addRisuScriptHandler. They transform text or audio that the plugin could already transmit vianativeFetch. - Auto-cleanup. Hooks registered by a plugin are removed automatically when the plugin unloads.
Register a plugin as a custom AI provider that RisuTanium can use for generation:
await Risuai.addProvider(
'MyProvider',
async (args, abortSignal) => {
const response = await Risuai.nativeFetch('https://api.example.com/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: args.prompt_chat,
temperature: args.temperature / 100,
max_tokens: args.max_tokens
}),
signal: abortSignal
});
if (!response.ok) {
return { success: false, content: 'Request failed' };
}
const data = await response.json();
return {
success: true,
content: data.choices[0].message.content
// content can also be a ReadableStream<string> for streaming
};
},
{
tokenizer: 'gpt-4' // optional: tokenizer to use for context counting
}
);ProviderArguments fields:
prompt_chat— OpenAI-format message arraytemperature,max_tokens,frequency_penalty,presence_penaltymin_p,repetition_penalty,top_k,top_pmode— generation mode string
Register a custom Model Context Protocol module to expose tools the AI can call:
await Risuai.registerMCP(
{
identifier: 'plugin:my-tools', // must start with 'plugin:'
name: 'My Tools',
version: '1.0.0',
description: 'Custom tools provided by my plugin'
},
// getToolList — return available tools
async () => [
{
name: 'get_weather',
description: 'Gets current weather for a city',
inputSchema: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name' }
},
required: ['city']
}
}
],
// callTool — handle tool invocations
async (toolName, content) => {
if (toolName === 'get_weather') {
const weather = await fetchWeather(content.city);
return [{ type: 'text', text: weather }];
}
return [{ type: 'text', text: 'Unknown tool' }];
}
);
// Later, unregister the MCP module
await Risuai.unregisterMCP('plugin:my-tools');Tool call content types (MCPToolCallContent):
{ type: 'text', text: string }— plain text result{ type: 'image' | 'audio', data: string, mimeType: string }— base64 media{ type: 'resource', resource: { uri, mimeType, text } }— resource reference
Intercept and modify HTTP request bodies sent to LLM APIs (sensitive fields like API keys are stripped before your callback):
const interceptor = await Risuai.registerBodyIntercepter(async (body, type) => {
// Modify request body before it's sent
body.temperature = 0.7;
body.stream = true;
return body;
});
// Returns null if user denies permission
if (interceptor) {
console.log('Interceptor registered:', interceptor.id);
// Later, unregister
await Risuai.unregisterBodyIntercepter(interceptor.id);
}Register a function to run when the plugin is unloaded (e.g., when the user disables it or reloads plugins):
await Risuai.onUnload(async () => {
// Clean up resources, remove event listeners, etc.
console.log('Plugin unloading...');
});Force a reload of all plugins (use sparingly):
await Risuai.loadPlugins();Some APIs require explicit user consent. Use requestPluginPermission to prompt the user:
// Request a specific permission
const granted = await Risuai.requestPluginPermission('fetchLogs');
if (granted) {
const logs = await Risuai.getFetchLogs();
}Available permissions: 'fetchLogs', 'db', 'mainDom', 'replacer'
Access a log of recent LLM HTTP requests (requires 'fetchLogs' permission):
const granted = await Risuai.requestPluginPermission('fetchLogs');
if (!granted) {
console.log('Permission denied');
return;
}
const logs = await Risuai.getFetchLogs();
// logs is null if consent was not given
if (logs) {
for (const entry of logs) {
console.log(`[${new Date(entry.timestamp).toISOString()}] ${entry.url}`);
console.log(`Status: ${entry.status}`);
}
}const info = await Risuai.getRuntimeInfo();
console.log(info.apiVersion); // e.g., '3.0'
console.log(info.platform); // 'node', 'tauri' or 'web'
console.log(info.saveMethod); // 'tauri' or 'local'Convert any SafeClassArray<T> to a plain JavaScript array:
const array = await Risuai.unwarpSafeArray(safeArray);Search or retrieve entries from the LLM translation cache:
// Search by partial key
const results = await Risuai.searchTranslationCache('hello');
for (const { key, value } of results) {
console.log(`${key} => ${value}`);
}
// Exact key lookup
const translation = await Risuai.getTranslationCache('hello world');
if (translation) {
console.log('Cached:', translation);
}All Risuai API methods are async - never forget await:
// L WRONG
const char = Risuai.getCharacter();
console.log(char.name); // undefined or Promise
// CORRECT
const char = await Risuai.getCharacter();
console.log(char.name); // Works!Always handle errors gracefully:
(async () => {
try {
const data = await Risuai.getDatabase();
// Process data
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();Prefer pluginStorage over safeLocalStorage for data that should travel with the save file:
// Good - travels with the save file
Risuai.pluginStorage.setItem('settings', JSON.stringify(settings));
// Device-specific only
Risuai.safeLocalStorage.setItem('device_id', id);Remove event listeners when done:
const listeners = [];
// Store listener IDs
listeners.push(await element.addEventListener('click', handler));
// Clean up
for (const id of listeners) {
await element.removeEventListener('click', id);
}Prefer new API names over deprecated ones:
// Modern
await Risuai.getCharacter()
await Risuai.setCharacter(char)
await Risuai.getArgument(key)
// L Deprecated (still work but avoid)
await Risuai.getChar()
await Risuai.setChar(char)
await Risuai.getArg(key)Don't try to break out of the iframe or access restricted APIs. The sandbox is for user security.
Add clear comments and metadata:
//@name my_plugin
//@display-name My Awesome Plugin
//@api 3.0
//@arg api_key string Get your key at https://example.com
//@link https://github.com/user/plugin Documentation
//@link https://github.com/user/plugin/issues Report Issues//@name settings_example
//@display-name Settings Example
//@api 3.0
//@arg theme string Color theme (light/dark)
(async () => {
try {
// Register settings button
Risuai.registerSetting(
'Theme Settings',
async () => {
const theme = await Risuai.getArgument('theme');
document.body.innerHTML = `
<div style="padding: 20px; background: #2d2d2d; color: white; font-family: sans-serif;">
<h1>Theme Settings</h1>
<p>Current theme: <strong>${theme}</strong></p>
<button id="light-btn">Light Theme</button>
<button id="dark-btn">Dark Theme</button>
<button id="close-btn">Close</button>
</div>
`;
document.getElementById('light-btn').addEventListener('click', async () => {
await Risuai.setArgument('theme', 'light');
console.log('Theme set to light');
});
document.getElementById('dark-btn').addEventListener('click', async () => {
await Risuai.setArgument('theme', 'dark');
console.log('Theme set to dark');
});
document.getElementById('close-btn').addEventListener('click', () => {
Risuai.hideContainer();
});
Risuai.showContainer('fullscreen');
},
'https://example.com/icon_src_here.png',
'img'
);
console.log('Settings panel registered');
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();//@name char_info
//@display-name Character Info Display
//@api 3.0
(async () => {
try {
Risuai.registerButton({
name: 'Show Character Info',
icon: '🛈',
iconType: 'html',
location: 'action',
}, async () => {
const char = await Risuai.getCharacter();
const rootDoc = Risuai.getRootDocument();
const body = rootDoc.querySelector('body');
const infoBox = rootDoc.createElement('div');
await infoBox.setStyle('position', 'fixed');
await infoBox.setStyle('top', '50%');
await infoBox.setStyle('left', '50%');
await infoBox.setStyle('transform', 'translate(-50%, -50%)');
await infoBox.setStyle('background', 'white');
await infoBox.setStyle('padding', '20px');
await infoBox.setStyle('border', '2px solid black');
await infoBox.setStyle('zIndex', '9999');
await infoBox.setInnerHTML(`
<h2>${char.name}</h2>
<p><strong>Description:</strong> ${char.description || 'No description'}</p>
<button id="close-info">Close</button>
`);
await body.appendChild(infoBox);
const closeBtn = await infoBox.querySelector('#close-info');
if (closeBtn) {
await closeBtn.addEventListener('click', async () => {
await infoBox.remove();
});
}
}
);
console.log('Character info button registered');
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();//@name dom_monitor
//@display-name DOM Monitor
//@api 3.0
(async () => {
try {
const rootDoc = Risuai.getRootDocument();
// Add a status indicator
const indicator = rootDoc.createElement('div');
await indicator.setStyle('position', 'fixed');
await indicator.setStyle('bottom', '10px');
await indicator.setStyle('right', '10px');
await indicator.setStyle('padding', '10px');
await indicator.setStyle('background', '#4CAF50');
await indicator.setStyle('color', 'white');
await indicator.setStyle('borderRadius', '5px');
await indicator.setTextContent('Plugin Active');
const body = rootDoc.querySelector('body');
if (body) {
await body.appendChild(indicator);
}
// Monitor DOM changes
let changeCount = 0;
const observer = Risuai.createMutationObserver(async (mutations) => {
changeCount += mutations.length;
await indicator.setTextContent(`Changes: ${changeCount}`);
});
if (body) {
observer.observe(body, {
childList: true,
subtree: true,
attributes: false
});
}
console.log('DOM monitoring started');
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();//@name markdown_processor
//@display-name Markdown Processor
//@api 3.0
(async () => {
try {
// Process AI output to convert markdown-style bold
Risuai.addRisuScriptHandler('output', async (content) => {
// **bold** <strong>bold</strong>
content = content.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// *italic* <em>italic</em>
content = content.replace(/\*(.+?)\*/g, '<em>$1</em>');
return content;
});
// Process user input to add timestamps
Risuai.addRisuScriptHandler('input', async (content) => {
const timestamp = new Date().toLocaleTimeString();
Risuai.pluginStorage.setItem('last_input_time', timestamp);
return content;
});
console.log('Markdown processor registered');
} catch (error) {
console.log(`Error: ${error.message}`);
}
})();Problem: Not using await on async methods.
// L Wrong
const char = Risuai.getCharacter();
console.log(char); // Promise or undefined
// Correct
const char = await Risuai.getCharacter();
console.log(char); // Actual character objectProblem: Trying to set non-x- prefixed attributes.
// L Wrong - throws error
await element.setAttribute('onclick', 'alert()');
// Correct - use x- prefix for custom attributes
await element.setAttribute('x-custom-id', 'my-id');
// Or use dedicated methods
await element.setStyle('color', 'red');
await element.setInnerHTML('<div>Safe content</div>');Problem: Using standard addEventListener syntax or not storing the ID.
// L Wrong - need await and ID storage
element.addEventListener('click', handler);
// Correct
const listenerId = await element.addEventListener('click', async (e) => {
// Handle event
});
// Later, remove with ID
await element.removeEventListener('click', listenerId);Problem: Confusing pluginStorage with safeLocalStorage.
pluginStorage: Save-file specific, travels with the save filesafeLocalStorage: Device-specific, shared between plugins
// For user preferences (travels with the save file)
Risuai.pluginStorage.setItem('preference', 'value');
// For device-specific data
Risuai.safeLocalStorage.setItem('device_id', 'uuid');Problem: This is intentional! All HTML is sanitized with DOMPurify.
// Scripts are removed for security
await element.setInnerHTML('<script>alert("XSS")</script>');
// Result: empty element (script removed)
// Use event listeners instead
const button = rootDoc.createElement('button');
await button.setTextContent('Click Me');
await button.addEventListener('click', async () => {
console.log('Button clicked!');
});Problem: Trying to access your iframe's document from root or vice versa.
// L Wrong - these are separate contexts
const rootDoc = Risuai.getRootDocument();
rootDoc.querySelector('#my-iframe-element'); // Won't find it
// Correct - access each separately
// Your iframe's DOM:
document.getElementById('my-iframe-element');
// RisuTanium's main DOM:
const rootDoc = Risuai.getRootDocument();
await rootDoc.querySelector('.Risuai-element');Problem: Forgetting to call setDatabase() or setDatabaseLite().
// L Wrong - changes not saved
const db = await Risuai.getDatabase();
db.characters.push(newChar);
// Correct - save changes
const db = await Risuai.getDatabase();
db.characters.push(newChar);
await Risuai.setDatabase(db); // Or setDatabaseLite(db)If you're updating an older plugin, see the Migration Guide for detailed migration instructions from API v2.1 to v3.0.
Key differences:
- All APIs are now async (use
await) - Access through
Risuaiobject instead of global functions - Use
getRootDocument()instead ofdocument - SafeElement methods instead of standard HTMLElement
- Event listeners return IDs instead of using function references
- Iframe-based isolation instead of shared document context
If you want to generate a plugin using an LLM like we recommend including only DTS file instead of including full documentation in the prompt, unless you are migrating from v2.0 to v3.0 in which case you should include the Migration Guide in the prompt too.