Skip to content

Plugin Development Basics

Luker's plugin system is built on SillyTavern's extension architecture with additional enhancements. This document is intended for developers who want to build third-party plugins for Luker, covering file structure, lifecycle, event system, UI integration, and debugging tips.

Terminology

In Luker, "plugin" and "extension" refer to the same concept. Built-in extensions are located in the public/scripts/extensions/ directory, while third-party plugins are installed to public/scripts/extensions/third-party/.

Plugin File Structure

A standard Luker plugin contains the following files:

third-party/my-plugin/
├── manifest.json      # Plugin metadata (required)
├── index.js           # Entry script (required)
├── style.css          # Stylesheet (optional)
├── settings.html      # Settings panel HTML (optional)
└── assets/            # Static assets (optional)

manifest.json

manifest.json is the plugin's metadata file that defines its basic information and loading behavior:

json
{
  "display_name": "My Plugin",
  "loading_order": 100,
  "requires": [],
  "optional": [],
  "js": "index.js",
  "css": "style.css",
  "author": "Your Name",
  "version": "1.0.0",
  "homePage": "https://github.com/your-repo",
  "auto_update": true
}
FieldTypeDescription
display_namestringThe name displayed in the UI
loading_ordernumberLoading order; lower numbers load first
requiresstring[]List of required dependency extensions
optionalstring[]List of optional dependency extensions
jsstringPath to the entry JavaScript file
cssstringPath to the stylesheet
authorstringAuthor information
versionstringVersion number
homePagestringProject homepage URL
auto_updatebooleanWhether auto-update is supported

index.js

The entry script is the core file of a plugin. Luker uses ES Module dynamic imports to load plugins, so the entry file should use import/export syntax.

A minimal entry script structure:

js
import { extension_settings, getContext } from '../../../extensions.js';
import { eventSource, event_types } from '../../../../script.js';

const EXTENSION_NAME = 'my-plugin';

// Default values for plugin settings
const defaultSettings = {
  enabled: true,
  someOption: 'default',
};

// Load settings
function loadSettings() {
  extension_settings[EXTENSION_NAME] = extension_settings[EXTENSION_NAME] || {};
  Object.assign(extension_settings[EXTENSION_NAME], {
    ...defaultSettings,
    ...extension_settings[EXTENSION_NAME],
  });
}

// Plugin entry point
jQuery(async () => {
  loadSettings();

  // Load settings panel HTML
  const settingsHtml = await $.get(`${extensionFolderPath}/settings.html`);
  $('#extensions_settings').append(settingsHtml);

  // Bindievents
  eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
});

style.css

The stylesheet is loaded automatically. It is recommended to use CSS class names prefixed with your plugin name to avoid conflicts with other plugins or Luker's core styles:

css
.my-plugin-container {
  padding: 10px;
}

.my-plugin-container .status {
  color: var(--SmartThemeBodyColor);
}

settings.html

An HTML fragment for the settings panel, which is injected into the extension settings area. Use the data-extension-name attribute to identify the owning plugin:

html
<div class="my-plugin-settings" data-extension-name="my-plugin">
  <div class="inline-drawer">
    <div class="inline-drawer-toggle inline-drawer-header">
      <b>My Plugin</b>
      <div class="inline-drawer-icon fa-solid fa-circle-chevron-down"></div>
    </div>
    <div class="inline-drawer-content">
      <label>
        <input type="checkbox" id="my_plugin_enabled" />
        Enable Plugin
      </label>
    </div>
  </div>
</div>

Global Objects

Luker.getContext()

Luker.getContext() is the primary interface for plugins to interact with Luker. It returns a context object with a rich set of APIs:

js
const context = Luker.getContext();

NOTE

SillyTavern.getContext() and st.getContext() are compatibility aliases. New plugins should use Luker.getContext().

The context object includes APIs in the following major categories:

  • Chat Datacontext.chat, context.characters, context.groups, etc.
  • MessagesaddMessages(), updateMessages(), deleteMessages(), getMessage(), getMessageCount()
  • Chat PersistencesaveChatMetadata(), etc. (low-level appendChatMessages(), patchChatMessages() are deprecated — use the Messages API)
  • Chat StategetChatState(), updateChatState(), etc.
  • Presetscontext.presets.list(), context.presets.resolve(), etc.
  • Prompt / World Info AssemblybuildPresetAwarePromptMessages(), simulateWorldInfoActivation(), etc.
  • Event Systemcontext.eventSource, context.eventTypes
  • Character StategetCharacterState(), setCharacterState()
  • Inter-Extension CommunicationregisterExtensionApi()

For the complete API list, see the Extension API Reference.

Event System

Luker's event system is the core mechanism for plugin development. Plugins respond to user actions and system state changes by listening to events.

Basic Usage

js
const context = Luker.getContext();

// Listen to an event
context.eventSource.on(context.eventTypes.CHAT_CHANGED, (chatId) => {
  console.log('Chat changed:', chatId);
});

// Listen with priority
context.eventSource.on(context.eventTypes.MESSAGE_RECEIVED, handler, { priority: 10 });

// Ensure a handler runs first or last
context.eventSource.makeFirst(context.eventTypes.CHAT_CHANGED, handler);
context.eventSource.makeLast(context.eventTypes.CHAT_CHANGED, handler);

Listener Execution Order

Luker's event listeners execute serially in the following priority order (each listener is awaited):

  1. Explicit plugin ordering (pluginOrder) — configured via eventSource.setOrderConfig() or the built-in Hook Order extension
  2. Listener priority (priority) — the third argument to eventSource.on(); higher numbers execute first
  3. Registration order — listeners registered first execute first

Chat Lifecycle Events

EventTrigger TimingCallback Arguments
CHAT_CHANGEDChat switch completed(chatId)
CHAT_CREATEDNew chat created(chatId)
GROUP_CHAT_CREATEDGroup chat created(chatId)
CHAT_BRANCH_CREATEDChat branch created(payload) — see below

Message Events

EventTrigger TimingCallback Arguments
USER_MESSAGE_RENDEREDUser message rendering completed(messageId)
CHARACTER_MESSAGE_RENDEREDCharacter message rendering completed(messageId)
MESSAGE_SENTUser message sent(messageId)
MESSAGE_RECEIVEDAI reply received(messageId, type?)
MESSAGE_EDITEDMessage edited(messageId, meta?)
MESSAGE_UPDATEDMessage block refreshed(messageId)
MESSAGE_DELETEDMessage deleted(chatLength, meta?)
MESSAGE_SWIPEDMessage swiped(messageId, meta?)
MESSAGE_SWIPE_DELETEDSwipe option deleted({ messageId, swipeId, newSwipeId })

System Events

EventTrigger TimingCallback Arguments
APP_READYApplication initialization completed(void)
SETTINGS_UPDATEDSettings saved(void)
CHARACTER_FIRST_MESSAGE_SELECTEDFirst message selected(payload)
WORLDINFO_UPDATEDWorld Info updated(payload)

Event Payload Details

MESSAGE_DELETED meta

ts
(
  chatLength: number,
  meta?: {
    kind: 'delete',
    deletedPlayableSeqFrom: number | null,
    deletedPlayableSeqTo: number | null,
    deletedAssistantSeqFrom: number | null,
    deletedAssistantSeqTo: number | null,
  },
)

MESSAGE_SWIPED meta

ts
(
  messageId: number,
  meta?: {
    pendingGeneration: boolean,
    previousSwipeId: number | null,
    nextSwipeId: number | null,
  },
})

CHAT_BRANCH_CREATED payload

ts
{
  mesId: number,                  // Message index at the branch point
  branchName: string,             // New branch chat ID / filename
  assistantMessageCount: number,  // Number of assistant messages in the branch
  sourceTarget: {
    is_group: boolean,
    id?: string,
    avatar_url?: string,
    file_name?: string,
  },
  targetTarget: {
    is_group: boolean,
    id?: string,
    avatar_url?: string,
    file_name?: string,
  },
}

CHAT_BRANCH_CREATED fires after the new branch chat file is saved but before the UI switches to the new branch. If your plugin stores state data bound to a chat, you should copy or truncate that state to the new branch in this event handler.

Generation Hook Events

Generation hooks are the key mechanism for plugins to intervene in the AI generation pipeline. They fire in the following order:

GENERATION_STARTED

GENERATION_CONTEXT_READY        ← Adjust coreChat and maxContext

GENERATION_BEFORE_WORLD_INFO_SCAN  ← Influence World Info scanning

GENERATION_AFTER_WORLD_INFO_SCAN   ← World Info scan completed

GENERATION_WORLD_INFO_FINALIZED    ← World Info finalized

GENERATION_BEFORE_API_REQUEST      ← Final request inspection

(API request sent)

GENERATION_ENDED / GENERATION_STOPPED

Hook Selection Guide

HookGood ForNot Suitable For
GENERATION_CONTEXT_READYTrimming/replacing coreChat, adjusting context limitsLogic that depends on World Info output
GENERATION_BEFORE_WORLD_INFO_SCANTemporary modifications that influence World Info scanningFinal request body editing
GENERATION_WORLD_INFO_FINALIZEDReading final World Info results, depth injectionRebuilding pre-scan chat slice assumptions
GENERATION_BEFORE_API_REQUESTFinal request inspection, tool injection, provider-specific additionsModifications that need to affect World Info activation

GENERATION_CONTEXT_READY payload

ts
{
  type: string,           // normal/continue/regenerate/swipe/...
  dryRun: boolean,
  isContinue: boolean,
  isImpersonate: boolean,
  coreChat: ChatMessage[],
  maxContext: number,
}

APP_READY Event

APP_READY is a special event with an auto-trigger behavior. If a listener is registered after the application has already started, it will immediately receive the arguments from the last APP_READY emission. This ensures that lazily loaded plugins can still initialize correctly.

Chat State

Luker provides a chat state mechanism that allows plugins to bind data to a specific chat, rather than stuffing it into chat_metadata.

Basic Usage

js
const context = Luker.getContext();
const NAMESPACE = 'my-plugin';

// Read state
const state = await context.getChatState(NAMESPACE);

// Update state (recommended approach)
await context.updateChatState(NAMESPACE, (current = {}) => ({
  ...current,
  lastUpdated: Date.now(),
  counter: (current.counter || 0) + 1,
}));

// Delete state
await context.deleteChatState(NAMESPACE);

Best Practices

  • Use updateChatState() for read-modify-write operations instead of manually chaining getChatState() + patchChatState()
  • Keep namespace payloads as plain JSON-serializable objects
  • For large plugin data, prefer chat state over chat_metadata
  • Handle ok: false return values to keep your plugin UI resilient

Handling State During Branching

js
context.eventSource.on(context.eventTypes.CHAT_BRANCH_CREATED, async (payload) => {
  const sourceState = await context.getChatState(NAMESPACE, {
    target: payload.sourceTarget,
  });
  if (!sourceState) return;

  await context.updateChatState(NAMESPACE, () => ({
    ...sourceState,
    branch: {
      sourceMesId: payload.mesId,
      branchName: payload.branchName,
      copiedAt: Date.now(),
    },
  }), {
    target: payload.targetTarget,
  });
});

UI Integration

Settings Panel

A plugin's settings panel is injected into the extension settings area via an HTML fragment. Use the inline-drawer class to create a collapsible settings panel:

js
jQuery(async () => {
  const settingsHtml = await $.get(`${extensionFolderPath}/settings.html`);
  $('#extensions_settings').append(settingsHtml);

  // Bind settings control events
  $('#my_plugin_enabled').on('change', function () {
    extension_settings[EXTENSION_NAME].enabled = $(this).prop('checked');
    saveSettingsDebounced();
  });

  // Initialize control states
  $('#my_plugin_enabled').prop('checked', extension_settings[EXTENSION_NAME].enabled);
});

Luker provides popup APIs such as callGenericPopup for displaying custom dialogs:

js
import { callGenericPopup, POPUP_TYPE } from '../../../popup.js';

const result = await callGenericPopup(
  '<div>Custom content</div>',
  POPUP_TYPE.CONFIRM,
  'Title',
);

Slash Commands

Plugins can register custom slash commands:

js
import { registerSlashCommand } from '../../../slash-commands.js';

registerSlashCommand(
  'myplugin',
  async (args, value) => {
    // Command logic
    return 'Command execution result';
  },
  [],
  'Execute My Plugin action',
);

Inter-Plugin Communication

registerExtensionApi

Plugins can register named APIs via registerExtensionApi, allowing other plugins to discover and call them:

js
// Provider side
const context = Luker.getContext();
context.registerExtensionApi('my-plugin', {
  doSomething: () => { /* ... */ },
  getData: () => myData,
});

// Consumer side
const myPluginApi = context.getExtensionApi('my-plugin');
if (myPluginApi) {
  myPluginApi.doSomething();
}

Debugging Tips

Browser Developer Tools

  • Use Luker.getContext() in the browser console to directly inspect the context object
  • Use context.eventSource.getListenersMeta(eventName) to view all listener information for a given event
  • Plugin identity is inferred from the extension path during ordering/debugging, including third-party extensions (third-party/<name>)

Frontend Log Manager

Luker includes a built-in frontend log manager that intercepts console output and fetch requests. Use getFrontendLogsSnapshot() to obtain log snapshots, with support for filtering by time range and ID. See Logging System for details.

Common Troubleshooting

IssueTroubleshooting Direction
Plugin not loadingCheck that manifest.json is properly formatted and the js path exists
Settings not savingEnsure saveSettingsDebounced() is being called
Event not firingUse getListenersMeta() to confirm the listener is registered
Style conflictsUse CSS class names prefixed with your plugin name
State lostVerify the correct namespace is used and check that the chat state was written successfully

Hot Reload

During development, you can trigger a reload by disabling/enabling the plugin through the extension management panel. After modifying style.css, a full page refresh is usually required.

Built upon SillyTavern