@Extendable hooks
Most of what a plugin does starts with a decorator: an object whose onMethodEnter and onMethodLeave run
before and after a method of an application class. Any method of a class annotated @Extendable can be decorated,
private ones included, and the decorator can read the method's arguments and local variables and change what
the method returns. This page explains how that works and how to use it. The signature of every such method is declared in the source with
@PublicAPI
Instrumentation
flowchart LR
Init["RuntimeInstrumentationInitializer<br/>(Spring bean, at startup)"] -->|ByteBuddyAgent.install| Agent["agent"]
Agent -->|"classes annotated @Extendable<br/>in com.github.enerccio.*"| Vis["ExtendableMethodVisitor<br/>rewrites each method"]
Vis --> Code["instrumented method"]
Code -->|enter / leave| Holder["ExtensionServiceHolder"] --> ES["ExtensionServiceImpl<br/>decorators by class + method name"]RuntimeInstrumentationInitializer installs a ByteBuddy agent into the running JVM when the Spring context starts.
The agent transforms every class annotated @Extendable whose name starts with com.github.enerccio - classes
loaded later when they are loaded, classes already loaded by retransformation. Only method bodies change; the class
keeps its fields and methods (disableClassFormatChanges), which is what makes retransformation possible.
ExtendableMethodVisitor rewrites every method that is not a constructor, not static and not synthetic (lambda
bodies are synthetic). In pseudo-code, an instrumented method looks like this:
ReturnType method(A a, B b) {
ExtensionService service = ExtensionServiceHolder.getInstance();
ExtendableMethodContext context = service.createContextHolder(this);
context.registerMethodArgument("a", a, A.class); // declared parameter types
context.registerMethodArgument("b", b, B.class);
service.onExtendableMethodEnter(DeclaringClass.class, this, context, "method");
a = (A) <argument "a" from context>; // decorators may have replaced it
b = (B) <argument "b" from context>;
try {
... original body; after every store into an object local variable:
context.registerLocalVariable("name", value, value.getClass());
... at every return of a value (not in a void method):
context.registerReturnValue(result, ReturnType.class); // declared return type, the original value
service.onExtendableMethodLeave(DeclaringClass.class, this, context, "method", null);
return (ReturnType) context.resolveReturnValue(); // decorators may have replaced it
... at a return in a void method:
service.onExtendableMethodLeave(DeclaringClass.class, this, context, "method", null);
return;
} catch (Throwable t) {
service.onExtendableMethodLeave(DeclaringClass.class, this, context, "method", t);
throw t;
}
}
This runs on every call, whether a decorator is registered or not. That's why @Extendable is on UI classes,
which run at human speed, and not on services or entities.
Names come from the class file: parameter names from the MethodParameters attribute (the application is compiled
with -parameters), local variable names from the local variable table (preserveAllLocals). If either is
missing, arguments are called arg0, arg1... and locals var<slot>.
The public API
Everything a decorator can name - the class, the method, its arguments, its local variables - is a promise the
application makes to the plugins. Every instrumented method of an @Extendable class (not a constructor, not static,
not synthetic, with a body - the methods that can be decorated) therefore declares its signature with @PublicAPI
(domain.traits):
@PublicAPI(from = "1.1.0",
returns = @APIReturn(type = MenuItem.class),
parameters = {@APIParameter(name = "bar", type = MenuBar.class)},
locals = {@APILocals(name = "cogs", type = MenuItem.class)})
private MenuItem createCogsMenuItem(MenuBar bar) {
The methods that describe a class to the help assistant (getLLMState, getLLMChildren, getInteraction, getLLMProblems) are public API
like the others where a class overrides them, and a plugin can decorate them to add to what the assistant knows, see
Extending the UI.
Types are class literals, so a refactoring that renames or moves a class in the IDE updates them too. They are erased
(List.class for List<String>).
What counts as a change. Renaming or retyping an argument or a local variable, adding or removing one, changing the return type, renaming or removing the method, or moving it to another class (a decorator registered for the old class and name is never called) can break a plugin. Adding a method does not, and neither does changing the body in a way that keeps the arguments, the locals and what they hold. For each change that can be seen:
- update the annotation (see below) and set
fromto the current version of the project (pom.xml); - search
marginalia/plugins/for the class, method and variable names and fix the plugins; - mention it in the release notes.
A new method starts with the current version in from. The methods that existed in 1.1.0 (the same class, name and
argument names) were given "1.1.0" when the annotation was introduced; the local variables were not compared with 1.1.0.
It is tested. PublicAPISignatureTest (instruct package) reads the compiled @Extendable classes with
ByteBuddy - the type pool, so nothing is loaded - and the local variable table, and fails when
-
an instrumented method has no
@PublicAPI; -
the annotation differs from the compiled method (name or type of an argument, return type, a local that is missing, extra or of another type);
-
a class is compiled without parameter names (
-parameters), so the names would bearg0...; -
fromis notmajor.minor.patch, or the annotation is on a method that is not instrumented (a constructor, a static or abstract method) or in a class that is not@Extendable.
The message names the method and prints the annotation to write, so after changing a method you run the test, paste
what it says and review the difference - that difference is exactly the change a plugin would see. Methods that
AspectJ adds to the @Configurable classes (readResolve()) have no source and are skipped.
@PublicAPI describes methods only. The fields a plugin reads with getReflectiveFieldValue (such as sidebarList
or this$0) are not declared, so check marginalia/plugins/ before renaming or retyping a field of an @Extendable
class.
Registering a decorator
ExtensionDecorator decorator = new ExtensionDecorator() {
@Override
public void onMethodEnter(Object instrumented, ExtendableMethodContext context) throws Exception {
// before the body: arguments are known, no locals yet
}
@Override
public void onMethodLeave(Object instrumented, ExtendableMethodContext context, Throwable throwing) throws Exception {
// after the body: arguments, locals, fields; throwing != null if the method is throwing
}
};
extensionService.registerDecorator(decorator,
"com.github.enerccio.marginalia.ui.dialogs.manuscript.ManuscriptStoryPart", "createSidebarButton");
// in onExtensionUnload
extensionService.unregisterDecorator(decorator); // removes it from every method it was registered for
-
instrumentedis the object whose method runs (this). -
Class name is the binary name of the class that declares the method: nested classes use
$(ManuscriptStoryPart$ChatMessageCard). A method inherited from a superclass is reported under the superclass, for all subclasses; a subclass's own methods under the subclass. The class itself must be@Extendable- the annotation is not inherited, and nested classes need their own. -
Method name only: all overloads of a method share its decorators. Check the arguments (
hasMethodArgument(name, type)) if you need to tell overloads apart. -
Several decorators on the same method run in the order they were registered, for enter and for leave.
-
A decorator registered for a class or method that doesn't exist is accepted and never called - check names carefully.
Verification refuses to load an extension that does this. -
Decorators are global: they run for every user and every session. Use the arguments and fields to decide whether to act. The current user is the session-scoped
Userbean, injected as in services (The current user) - on the UI thread it resolves to the session's user.
The method context
Each call gets its own ExtendableMethodContext (calls don't share them, recursive and concurrent calls neither).
Type checks are against the registered type: for arguments the declared parameter type, for locals the runtime
class of the value (Object when the value was null), for fields and methods the declared type. The requested type
must be the same or a supertype.
Local variables, in detail:
-
Only object locals are recorded (
ASTORE):int,booleanand other primitive locals are not. -
A local is recorded each time it's assigned, so you get its last value. Locals in different scopes that reuse the same slot keep their own names.
-
Locals of lambdas (event listeners) belong to the synthetic lambda method, which is not instrumented.
Changing the return value
A decorator of a method that returns a value can replace the value in onMethodLeave. The decorators of a method form
a chain in the order they were registered, sharing one current value:
@Override
public void onMethodLeave(Object instrumented, ExtendableMethodContext context, Throwable throwing) {
if (!context.hasReturn()) {
return; // void method, or it threw
}
String original = context.getOriginalReturn(String.class); // what the body returned
String current = context.getCurrentReturn(String.class); // original, or the previous decorator's result
context.setCurrentReturn(current + " (edited)"); // the next decorator and the caller get this
}
-
getOriginalReturnis always what the body returned,getCurrentReturnstarts as the same value and is whatever the lastsetCurrentReturnset. A decorator that only wants to add to the result reads the current value, so it keeps what the decorators before it did; one that wants to start over reads the original. -
When the last decorator is done, the caller of the method receives the current value. If nobody set one, the method returns what its body returned.
-
The value must fit the declared return type of the method (a subtype is fine,
nullis fine for objects); for a primitive return type pass the matching wrapper (setCurrentReturn(5)forint), notnull. A value that does not fit is refused with aModCompatibilityException(a warning in the log, as for the othererrors ), the current value stays and the method is not affected. -
Type checks of the getters are against the declared return type:
getOriginalReturn(CharSequence.class)works for a method that returnsString,Integer.classdoes not work forint. -
The return value exists only after the body returned. In
onMethodEnter, for avoidmethod and when the method throws,hasReturn()isfalseand the getters and the setter are refused. A decorator cannot swallow an exception or turn a return into one, and the body always runs (What a decorator can't do ). -
The mutable objects a method returns (a component, a list) can be changed without replacing the value; replace it when you need a different object, or the value is immutable (
String, numbers,Optional).
The return value is part of the contract
What a method returns is read by its caller, and every plugin that replaces it is now a part of that. A change of the
declared return type of an @Extendable method is a change of its
Errors
The getters and the reflective calls throw a ModCompatibilityException (a private RuntimeException subclass of
ExtensionServiceImpl) when a name doesn't exist or the type doesn't match. ExtensionServiceImpl catches everything a decorator throws, so a failing decorator
never breaks the decorated method or the other decorators:
-
in
onMethodLeave, aModCompatibilityExceptionis logged as a warning:Mod '<decorator class>' skipped on <class>.<method>: Required component variable 'sidebarBtn' was not found ...; -
anything else, and anything in
onMethodEnter, is logged as an error with the stack trace.
That makes a renamed variable a log line, not a crash - but also easy to miss. When you work on a plugin, watch the log.
What a decorator can't do
-
Skip the method. The body always runs, with its arguments as they are after
onMethodEnter. What it returns can bereplaced afterwards, and so can what it built (a local variable or field) or saved. -
Replace the result of a method that throws, or throw instead of a return: a decorator can read the exception in
onMethodLeavebut it is always rethrown. -
Decorate constructors, static methods or lambda bodies. Decorate the method the constructor calls (for example the constructor of
ChatMessageCardcallscreateHamburgerMenu(), the items come later fromcreateMenuItems()), or the method that registers the listener. -
Decorate classes that aren't
@Extendable, plugin classes, or library classes. -
Read primitive locals. Read the fields or objects they end up in.
Extension points
The @Extendable classes are listed in User interface → Extension points. To find a
hook for what you want to do:
-
Find the component in the UI and the class that builds it (User interface maps screens to classes).
-
Find the method that builds or refreshes it - preferably one that runs every time the component is (re)built, so that your change is applied again.
-
Find how to reach the component: a local variable of that method (
getLocalVariable), a field (getReflectiveFieldValue), or the method's arguments. -
Decide on enter or leave: almost always
onMethodLeave, when the component exists.onMethodEnteris for reading state the method is about to overwrite, or replacing arguments - the bundled plugins save their settings inUserPart.save's enter, so that the application's ownsettingService.save(userSetting)in the body stores them.
Methods the bundled plugins use, as examples:
Inner classes reach their outer instance through the synthetic field this$0.
The phone twins
On a phone the book opens in PhoneManuscriptDialog, whose tabs are separate classes (not subclasses) in
ui.dialogs.phone.manuscript, so a decorator of the desktop class does not run there. PhoneManuscriptStoryPart and its
PhoneManuscriptStoryPart$ChatMessageCard have the same decorable methods, fields and locals as the desktop ones
(createSidebarButton, renderStoryContent, populateMenuBar, createCogsMenuItem, createMenuItems,
refreshSummaryMenuItems, autosaveAndSwapToMarkdown; fields sidebarList, leftBar, currentManuscript,
hamburgerMenu, message, this$0), so a plugin supports the phone by registering the same decorator a second time
under the other class name. The Reviewer, Chapter Marker, Author's Note, Arc Outline, Character Tracker and Converter do (the Converter also closes the drawer, an AppLayout found with leftBar.findAncestor, before it opens newTurnPopover); Side Query, Lore Generator and Lorebook VCS don't.
Two differences: the card has no right-hand column (the metadata is in a popover), and leftBar is the tab sheet in
the drawer of the story (a TabDock that does not detach, like the desktop one); on both, leftBar is created once and not rebuilt by renderStoryContent() (check for your
tab first, as Author's Note does). Reach the outer part through this$0 as an Object, since its type depends on the dialog.
Verification before loading
Every name a decorator uses is unchecked at compile time, so Marginalia checks them before it starts an extension:
ExtensionService.verifyExtension(Bundle bundle, File jar) (ExtensionVerifier in instruct.verify). It doesn't run
the extension, it reads its bytecode. The classes are listed by the JAR file and read from the installed bundle
(Bundle.getEntry), and everything they ask for is compared with the real classes of the running application.
What is followed. An ASM data-flow analysis (ContextInterpreter) walks every method of every class of the
extension and tracks constants, new objects and the objects that come out of the context:
-
every
registerDecorator(decorator, className, methodName)call. The class and method name must be constants (static final Stringconstants are inlined by the compiler, so they are). The decorator is the class created withnewon the way to the call, directly, through a local variable or through a field of the extension that was assigned earlier - the usualdecorator = new ExtensionDecorator() {...}; registerDecorator(decorator, ...); -
in the decorator class - all its methods, so lambdas and private helpers of the decorator count too - every call on an
ExtendableMethodContext.
What is checked, against the application's classes loaded without being initialized, the way
The object of a reflective access is followed: instrumented (of onMethodEnter / onMethodLeave) is the decorated
class; the result of getReflectiveFieldValue / callReflectiveMethod (also through a cast and a local variable) is
an object of the field's type / the method's return type; an object from getMethodArgument / getLocalVariable is
of the requested type. So the usual way to reach the outer instance of an inner class is verified in both steps:
ManuscriptStoryPart parent = context.getReflectiveFieldValue(instrumented, "this$0", ManuscriptStoryPart.class);
context.getReflectiveFieldValue(parent, "sidebarList", VerticalLayout.class); // a field of ManuscriptStoryPart
Only the decorators themselves are scanned
Nothing is followed into another class. If a decorator passes its context (or instrumented, or an object taken out of
the context) to a method of another class, or builds names at run time, what that code asks for is not checked -
checking it is up to the developer of the extension. Keep the context calls in the decorator class if you want them
verified.
The report. The result is written next to the JAR, named after it: chaptermarker.jar gets
chaptermarker.valid or chaptermarker.invalid (the one of the opposite result is deleted). It is plain text: the
extension, the bundle, the time, Result: VALID / INVALID, the errors, the warnings and the decorators that were
checked:
Extension: chaptermarker.jar
Bundle: chaptermarker 1.5.0
Verified: 2026-10-10T18:18:31.781673Z
Result: INVALID
Errors (1):
- ChapterMarkingExtension$1.onMethodLeave line 55 (ManuscriptStoryPart.createSidebarButton): com...ManuscriptStoryPart has no field 'sidebarLst'
Checked decorators (2):
- ManuscriptStoryPart.createSidebarButton <- ChapterMarkingExtension$1
- ManuscriptStoryPart$ChatMessageCard.autosaveAndSwapToMarkdown <- ChapterMarkingExtension$2
When it runs. OsgiServiceImpl verifies a bundle before starting it, at startup and when it is uploaded:
-
A report is reused only while it is newer than the JAR and newer than the application (the later of the creation and modification time of the WAR; of the folder it is unpacked to or of the classes when it runs unpacked or from the IDE). An older report is deleted and the extension is verified again: a new version of Marginalia can break extensions that were fine, and this catches them all at the first start. Both reports at once are treated as none.
-
.valid: the extension is started..invalid: it is not - at startup the bundle stays installed, not started (state Installed or Resolved), the report is logged as an error and Admin → Extensions shows an Invalid button that opens it. An upload that is invalid is rejected like one that fails to start: its JAR is deleted, the replaced version is put back and the report is shown to the administrator. A verification that can't be done (an unreadable JAR) fails the extension too. -
Unloading an extension deletes its reports with the JAR.
Verification can't know about everything: a warning is not a failure, and an extension with no errors can still break at run time (for example through a context handed to another class). Watch the log of the decorated methods as before.
Keeping hooks working
Everything a decorator uses is a name: the class, the method, the argument, the local, the field. None of it is checked at compile time. To keep the damage small:
-
Prefer arguments and fields over locals, and locals over walking the component tree.
-
Use
hasLocalVariable/hasMethodArgumentand fall back gracefully, as Chapter Marker does whensidebarBtnisn't found (it takes the last button ofsidebarList). -
Keep all names in constants in one place, so a rename in the application is a one-line change in the plugin (the compiler inlines
static final Stringconstants, soverification still sees them). -
Use the application's public methods where they exist (
LorebookView.getCurrentLorebook()) instead of reading the field.
When you change the application, the other side applies: renaming or restructuring an @Extendable method can break
plugins. PublicAPISignatureTest fails until the @PublicAPImarginalia/plugins/ (a search for the method or variable name is usually
enough) and to mention the change in the release notes. Do not make the test pass by editing the annotation alone.