Foreword — From Script to Structure
Most Blender add-ons don’t begin as add-ons. They begin as small Python scripts — little helpers written to remove friction from repetitive tasks. That was certainly my path. I wrote small functions, pasted them into Blender’s Script Editor, ran them manually, and moved on. They worked, and that was enough.
Eventually, though, the thought appeared: “I wish this were just a button.”
That is usually the turning point. You open the official Blender add-on
tutorial. It starts simply, but very quickly you find yourself reading about
bpy.types.Operator, registration blocks, panels, properties, and execution
flow. It’s not wrong — it’s thorough and powerful — but it can feel like
stepping into a formal contract with Blender’s runtime. I glanced at it more
than once and quietly closed the tab. There’s a moment when you think, “Not
today.”
Most people do not learn add-on architecture because they are curious. They learn it because they hit a productivity barrier hard enough that they have no choice. That was my experience. And learning it was not smooth. There were small issues everywhere — registration mistakes, panels not appearing, properties not updating, reload quirks. Each issue was minor. Together they were exhausting.
That experience led to a simple question: what if building a Blender add-on felt more like assembling Lego blocks?
In Lego, you don’t begin by designing the entire structure. You start with a block. Then another. The connectors are predictable. Pieces snap together cleanly. Complexity emerges from composition, not from rewriting foundations every time.
What if each Python function were a block? What if shared inputs were the connectors? What if larger systems were assembled from smaller, reusable pieces?
QuickAddon was built from that frustration. Not to replace Blender’s architecture. Not to hide it. But to make add-on development composable — to let you start with a function that works and snap structure around it.
This book follows that same progression. We begin with small helper functions. We turn them into tools. We assemble them into systems. Only later do we peel back the structure underneath. If you already understand Blender’s internals, you will recognize what is happening. If you don’t, you won’t need to — at least not immediately.
The goal is simple: build tools like Lego. Snap them together. Let structure grow when you need it.
And yes, we will still link the official Blender tutorial — not as a threat, but as a rite of passage.
You will need it too as I need it everyday, as it will demystify everything. You will also find the reference material here.
The Official Blender Add-on Tutorial
Allow me to introduce the QuickAddon next.
Introduction
QuickAddon
QuickAddon turns plain Python functions into fully working Blender add-ons.
You write the business logic.
QuickAddon generates the Blender-facing structure:
- Operators
- Panels
- Shared-property UI
- A standalone host wrapper
- An embeddable plugin module
No boilerplate. No Blender ceremony. No manual class wiring.
We’ll walk through a small example so you get the idea. In Chapter 3 we will do this for real; for now, treat this as an illustration.
60-Second Example
Assume you already have a proven Python function. We’ll use a tiny hello.py.
File: hello.py
def say_hello(name: str):
print(f"Hello {name}")
Now run:
quickaddon doctor hello.py
You’ll see that quickaddon has added a small header/preamble at the top of your file.
That preamble is intentional. Among other things, it makes the decorator available as op without you needing to import anything inside your script.
Now add the decorator to your function:
@op(
label="Say Hello",
space="SEQUENCE_EDITOR",
category="My Tools",
shared={
"name": "project.name",
},
)
def say_hello(name: str):
print(f"Hello {name}")
Then run:
quickaddon build hello.py --out my_addon_folder
The add-on hello has been created in my_addon_folder.
Install the generated addon in Blender.
Open:
Video Sequencer → N Panel → My Tools
You will see:
- A text field for
name - A button labeled Say Hello
Click it.
You just built a Blender add-on.
No classes. No register/unregister boilerplate. No property definitions. No UI layout code.
Just a function.
If you’re tempted to try this right now, go ahead. But if you want the smoothest learning path, wait until Chapter 3—we’ll do this exact workflow there, step by step. For now, treat this as a preview.
What Just Happened?
QuickAddon did three things:
- Wrapped your function in a Blender Operator.
- Generated shared properties for
project.name. - Created a standalone host wrapper that injects routing logic.
You didn’t see any of it.
That’s the point.
The Philosophy
Plugins declare what they need. Hosts decide where data lives. Shared keys form contracts.
QuickAddon sits in the middle and wires everything together.
You get:
- Clean business logic
- Reusable components
- Embeddable tools
- No fragile path strings
- No copy-paste boilerplate across add-ons
What This Means in Practice
Typical Blender add-ons accumulate:
- Boilerplate
PropertyGroupdefinitionsPointerPropertywiring- UI layout code
- Register/unregister juggling
QuickAddon removes that scaffolding from your day-to-day work.
You focus on:
def your_logic(...):
...
…and let the generator handle the structure.
Next
Now that you’ve seen the shape of it, we’ll explain the mental model.
Mental Model
QuickAddon exists for two kinds of developers:
- Those who want to build Blender tools without writing boilerplate.
- Those whose existing add-ons are growing and need structural separation.
This chapter explains the system in one pass.
If it feels abstract at first, that is intentional. Each concept becomes concrete in later chapters.
The Problem It Solves
Traditional Blender add-ons tend to mix:
- Tool logic
- UI layout
- Property definitions
- Registration plumbing
- Storage routing
in the same file.
As an add-on grows, this coupling becomes difficult to reason about.
QuickAddon enforces separation between these concerns.
That separation is the foundation of scalability.
Core Separation
QuickAddon separates three concerns:
- Tool Logic
- Shared Values
- Storage Ownership
Understanding these three layers is enough to understand the entire system.
Tool Logic
You write decorated Python functions.
The generator produces:
- Operators
- Panels
- Property definitions
- Registration code
Your source file contains behavior. The generated code contains structure.
You do not manually define Blender classes.
You do not write register() blocks.
You do not manually route properties.
Runtime context (such as context, scene, etc.) is supplied explicitly through the Injection Contract.
QuickAddon can then surface those generated operators in more than one way.
The default path is panel-first:
- QuickAddon generates panel UI
- QuickAddon lists the operator there
- The tool feels like a standard add-on button
But the system is intentionally broader than that.
Generated operators may also be:
- Drawn by a host add-on instead of the default generated panel
- Invoked from menus
- Bound to keymaps
- Triggered by other add-on code as helper operators
This is the purpose of the panel decorator flag.
panel=Truemeans QuickAddon should auto-surface the operator in its generated panel UIpanel=Falsemeans generate the operator normally, but let some other UI surface own how it is exposed
That distinction matters because QuickAddon is not only a panel generator. It is also an operator generator and composition system.
Shared Values
Shared parameters are declared in the decorator:
shared={"audio_path": "project.audio_path"}
A shared key:
- Is a stable string identifier
- Is not a Blender property path
- Is an opaque routing key
Multiple tools may reference the same shared key.
The key does not define where data lives. It defines a contract.
Storage Ownership
A host is any add-on that provides storage routing and panel integration for one or more plugins.
In standalone mode, the generated add-on acts as its own host.
In embedded mode, another add-on becomes the host.
Hosts control:
- Where shared values are stored
- How shared UI is drawn
- How plugin instances are scoped
- Which UI surfaces expose which operators
Plugins declare intent. Hosts enforce routing.
Instance Isolation (Later)
As systems grow, hosts may need multiple independent instances of the same plugin.
That isolation model is introduced later in the book after the v1 routing model is established.
Structural Overview
Plugin Function
↓
Generated Operator
↓
HostAPI (Routing)
↓
Host Storage
Each layer has one responsibility.
No layer reaches into another layer’s storage directly.
Design Constraints
QuickAddon enforces:
- Deterministic host injection
- Centralized shared routing
- Stateless plugin behavior
- No implicit global state
These constraints favor predictability over flexibility.
In large add-on systems, predictability scales better than cleverness.
Summary
You write Python functions.
QuickAddon generates structure.
Hosts control storage and lifecycle.
That is the entire model.
First Plugin
First Plugin
Since you are intending to write an add-on, it is useful to know where Blender expects add-ons to live.
Open Blender.
Go to the Scripting workspace and locate the Python Console.
Type:
>>> import addon_utils
>>> addon_utils.paths()
[
'/Applications/Blender-454.app/Contents/Resources/4.5/scripts/addons_core',
'/Users/user/Library/Application Support/Blender/4.5/scripts/addons'
]
On my system, user-installed add-ons live in:
/Users/user/Library/Application Support/Blender/4.5/scripts/addons
Your path may differ depending on OS and Blender version.
It is helpful to store this location in an environment variable so you don’t have to type it repeatedly.
Add it to your shell configuration file (for example, ~/.zshrc or ~/.bashrc).
In this book, we will assume the variable is named:
BLENDER_ADDON
From now on, we will use $BLENDER_ADDON whenever we refer to your add-on directory.
From Script Editor to Button
Assume this is your starting point:
You wrote a function. You pasted it into Blender’s Script Editor. You ran it. It works.
Example:
from pathlib import Path
def setup_from_audio(
audio_path: Path,
bpm: int = 120,
):
print("Audio:", audio_path)
print("BPM:", bpm)
Here, audio_path: Path does two important things:
- QuickAddon gives Blender a file-path style UI for the field
- your function receives a real
Pathobject at runtime
If you want file-path UI but still want a plain string at runtime, use a str
annotation and add a subtype hint later with param_subtypes.
You tested it manually. Now you want a button inside Blender that runs it.
You do not want to learn add-on boilerplate.
Step 1 – Save It to a File
Save the script as:
audiodeck.py
Step 2 – Insert the Required Header
Run:
quickaddon doctor audiodeck.py
This inserts the canonical header block.
Do not edit the header.
Your file now contains something like:
# QUICKADDON_HEADER_BEGIN <hash>
...
# QUICKADDON_HEADER_END <hash>
from pathlib import Path
def setup_from_audio(
audio_path: Path,
bpm: int = 120,
):
print("Audio:", audio_path)
print("BPM:", bpm)
The header enables QuickAddon to process your file correctly. It must remain intact.
Step 3 – Add the @op Decorator
Add metadata above your function:
@op(
label="Setup From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
)
def setup_from_audio(
audio_path: Path,
bpm: int = 120,
):
print("Audio:", audio_path)
print("BPM:", bpm)
That is the only structural change.
You did not convert it into a class.
You did not define a Blender Operator manually.
You did not import bpy.
Step 4 – Build the Add-on
Now build it into the Blender add-on directory we identified earlier:
quickaddon build audiodeck.py \
--out $BLENDER_ADDON \
--force
Open Blender.
Go to Edit → Preferences → Add-ons.
Search for Setup From Audio and enable it.
From now on, Blender will load this add-on automatically.
Open:
SEQUENCE_EDITOR → N-panel → QuickAddon
You now have:
- A button labeled Setup From Audio
- A popup dialog for
audio_pathandbpm
Click Run.
Your original function executes.
That’s it.
Quick note:
Pathmeans your function receives aPathstrmeans your function receives astr- UI hints like file pickers are separate from runtime type intent
For example, this keeps a string while still using Blender’s filepath picker UI:
@op(
label="Setup From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
param_subtypes={"audio_path": "FILE_PATH"},
)
def setup_from_audio(
audio_path: str = "",
bpm: int = 120,
):
print(type(audio_path), audio_path)
print("BPM:", bpm)
Step 5 – Add More Functions
Let’s add a second function to the same file.
@op(
label="Setup From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
)
def setup_from_audio(
audio_path: Path,
bpm: int = 120,
):
print("Setup:", audio_path, bpm)
@op(
label="Render From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
)
def render_from_audio(
audio_path: Path,
):
print("Render:", audio_path)
Rebuild and re-enable.
What you will see:
- Two buttons
- Two popup dialogs
- Each function asks for
audio_pathindependently
That is perfectly valid if the two audio paths should be separate.
But if both tools are meant to operate on the same audio file, repeatedly entering the path becomes unnecessary.
This is where shared values come in.
Now Make It Shared
Modify both decorators:
@op(
label="Setup From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
shared={
"audio_path": "project.audio_path",
},
)
def setup_from_audio(
audio_path: Path,
bpm: int = 120,
):
print("Setup:", audio_path, bpm)
@op(
label="Render From Audio",
space="SEQUENCE_EDITOR",
category="QuickAddon",
shared={
"audio_path": "project.audio_path",
},
)
def render_from_audio(
audio_path: Path,
):
print("Render:", audio_path)
Rebuild.
Now you will see:
audio_pathappears once in the panel.- It no longer appears in popup dialogs.
- Both functions use the same stored value.
- The value persists across runs.
You enter the path once.
Both functions use it.
That is what “shared” means.
You did not write storage code. You only declared that both functions reference the same shared key.
Persistence is handled for you.
If this feels clean and predictable, that is intentional.
In the next chapter, we will examine how the generated host makes this possible.
Standalone Add-on
So far, you wrote a function and built an add-on.
Now let’s look at what was actually generated.
What Actually Gets Generated
Build into a temporary folder so we can inspect the output:
quickaddon build audiodeck.py \
--out ./dist \
--force
Now inspect the generated directory:
ls -R ./dist
You should see:
dist/
└── audiodeck/
├── __init__.py
├── generated_ops.py
└── user_code.py
user_code.py is simply a copy of your original Python file.
Nothing special happens there. From this point forward, we will treat it as implicit and focus on the other two files.
Those two files define the pattern.
File 1 – generated_ops.py
Open:
dist/audiodeck/generated_ops.py
This file is substantial — but you did not have to write it.
Inside you will find:
- Generated Operator classes
- Shared parameter definitions
- UI draw helpers
- Registration hooks
Example (trimmed):
import bpy
# Auto-generated by quickaddon
# Source addon: Audiogen
_SHARED_PTR_NAME = "qa_shared__audiogen"
_HOST_API = None # injected by host at register time
class _FallbackHostAPI:
...
class QA_SHARED_ROOT(bpy.types.PropertyGroup):
project__audio_path: bpy.props.StringProperty(name="audio_path", default='', subtype="FILE_PATH")
class QA_OT_SETUP_FROM_AUDIO(bpy.types.Operator):
...
class QA_OT_RENDER_FROM_AUDIO(bpy.types.Operator):
...
...
# Panel -> ops mapping, used by host integration hooks
PANEL_OPS = {
"QuickAddon": [
("Setup From Audio", "qa_audiogen.setup_from_audio"),
("Render From Audio", "qa_audiogen.render_from_audio"),
],
}
def draw_shared(layout, context):
...
def draw_tools(layout, context, *, category=None):
...
def draw(layout, context, *, category=None, with_shared=True):
"""
Host integration hook:
- with_shared=True draws shared inputs first.
- category controls which tool group(s) to draw.
"""
if with_shared:
draw_shared(layout, context)
layout.separator()
draw_tools(layout, context, category=category)
class QA_PT_SEQUENCE_EDITOR_QUICKADDON(bpy.types.Panel):
...
def draw(self, context):
layout = self.layout
draw(layout, context, category="QuickAddon", with_shared=True)
_BASE_CLASSES = [
QA_SHARED_ROOT,
QA_OT_SETUP_FROM_AUDIO,
QA_OT_RENDER_FROM_AUDIO,
]
_PANEL_CLASSES = [
QA_PT_SEQUENCE_EDITOR_QUICKADDON,
]
# Prevent double-register in nested/plugin scenarios
_IS_REGISTERED = False
_REGISTERED_MODE = None
def register(*, mode="plugin", host_api=None):
...
for c in _BASE_CLASSES:
bpy.utils.register_class(c)
# namespaced shared ptr
setattr(
bpy.types.Scene,
_SHARED_PTR_NAME,
bpy.props.PointerProperty(type=QA_SHARED_ROOT),
)
...
def unregister():
...
if hasattr(bpy.types.Scene, _SHARED_PTR_NAME):
delattr(bpy.types.Scene, _SHARED_PTR_NAME)
for c in reversed(_BASE_CLASSES):
bpy.utils.unregister_class(c)
...
This file represents your Python functions transformed into Blender operators.
It:
- Defines operator classes.
- Defines shared properties.
- Exposes drawing helpers.
- Registers base classes.
It does not decide where shared values are ultimately stored when embedded in a larger system. It describes behavior and inputs.
That distinction becomes important later.
File 2 – __init__.py
Open:
dist/audiodeck/__init__.py
This file is lightweight.
You will see:
- Add-on metadata (
bl_info) - Registration logic
- A host wrapper
- Standalone panel wiring
Example (trimmed):
bl_info = {
"name": "Audiogen",
...
}
from . import generated_ops
class _StandaloneHostAPI:
...
def register():
generated_ops.register(
mode="plugin",
host_api=_StandaloneHostAPI(),
)
def unregister():
generated_ops.unregister()
This file connects the generated add-on to Blender.
It provides:
- Default shared storage
- HostAPI injection
- Add-on entry points
In standalone mode, this wrapper acts as the host.
The Visual Pattern
When you enable the add-on, the layers look like this:
Your Functions
↓ used by
generated_ops.py (behavior + operators)
↓ used by
__init__.py (standalone host wrapper)
↓ used by
Blender UI + Storage
Two files. Two responsibilities.
You wrote one function. QuickAddon generated the layers and wiring around it.
You now recognize the shape of a standalone QuickAddon add-on.
In the next chapter, we will see how this same structure enables embedding inside another host.
Embedding Plugins
This is where we start assembling systems from our Lego pieces.
In the previous chapter, we generated and used audiodeck.
Now consider this situation:
- audiodeck was generated first.
- You enabled it. It works.
- Later, you generated audio_encode.
- It also works standalone.
Their functionality is related.
Now you want:
audio_encode to appear inside audiodeck’s panel without enabling audio_encode separately.
We are staying entirely inside the QuickAddon ecosystem. Let’s walk through the process.
Step 1 – Start from Two Generated Add-ons
After building both, your add-ons directory looks like:
.../scripts/addons/
├── audiodeck/
│ ├── __init__.py
│ └── generated_ops.py
└── audio_encode/
├── __init__.py
└── generated_ops.py
Each add-on works independently in standalone mode.
Step 2 – Embed audio_encode Inside audiodeck
We will embed audio_encode physically inside audiodeck.
Final layout:
.../scripts/addons/
└── audiodeck/
├── __init__.py
├── generated_ops.py
└── audio_encode/
├── __init__.py
└── generated_ops.py
What changed?
- We moved
audio_encode/insideaudiodeck/. - Blender now sees only
audiodeckas an add-on to enable.
audio_encode becomes a module inside the host.
Step 3 – Modify audiodeck
Open:
audiodeck/__init__.py
Add the import
from .audio_encode import generated_ops as audio_encode_ops
Update register()
Extend register():
def register():
# audiodeck registration (already generated)
...
# Register audio_encode in plugin mode and mount one named instance
audio_encode_ops.register(mode="plugin", host_api=_HOST_API)
audio_encode_ops.mount_instance("encode")
Update unregister() accordingly:
def unregister():
audio_encode_ops.unregister()
# audiodeck unregister logic
...
Important:
We use mode="plugin" and then explicitly mount a named instance.
This means:
audio_encoderegisters its operators.audio_encodeprepares its hosted runtime.audio_encodedoes not create its own panel.audio_encodedoes not create a hidden default instance.
Even in the standalone case in the previous chapter, we used mode="plugin" - this is the default and most common mode.
The other modes exist purely for QuickAddon author’s debugging convenience.
Update audiodeck’s Panel draw()
Inside audiodeck’s panel class:
def draw(self, context):
layout = self.layout
_HOST_API.draw_registered(layout, context)
That is the entire integration layer.
No changes were made to audio_encode itself.
Step 4 – Enable Only audiodeck
In Blender:
- Disable
audio_encode(if it was previously enabled). - Enable
audiodeck.
Result:
- Only one panel appears (
audiodeck). audio_encodebuttons appear inside it.- Shared inputs render correctly.
audio_encodefunctions execute normally.
What Just Happened
Standalone audio_encode:
audio_encode __init__.py
└── creates its own panel
Embedded audio_encode:
audiodeck __init__.py
├── registers audio_encode (plugin mode)
├── mounts a named instance
└── lets HostAPI render registered plugin UI
audio_encode still contains:
- Its operators
- Its shared keys
- Its generated plumbing
Only audiodeck now controls:
- Panel ownership
- UI composition
- Instance naming
What Has NOT Changed
- The
audio_encodetool script - Its shared key declarations
- Its operator logic
- Its build process
Embedding modifies only the host (audiodeck).
The plugin remains untouched.
Where Are Shared Values Stored?
At this stage:
audio_encodestill uses its generated fallback storage.audiodeckdoes not yet ownaudio_encode’s shared values.
In the next chapter, we will transfer ownership.
That is where the HostAPI contract becomes visible.
You now understand:
- How two generated add-ons relate
- How one becomes the host
- How a plugin is physically embedded
- How registration is redirected
- How UI is delegated
Next, we will make audiodeck own audio_encode’s shared values.
Host Ownership of Shared Values
In the previous chapter:
audiodeckbecame the host.audio_encodewas embedded inside it.- The UI was unified.
- Only
audiodeckwas enabled.
However, one important thing has not changed yet.
audio_encode is still storing its shared values in fallback storage.
Current State
Right now, shared values for audio_encode live in:
context.scene.qa_shared
So even though:
audiodeckcontrols the panelaudio_encoderenders inside it
The data is still owned by generated fallback storage.
audiodeckcontrols the UI, but not the data.
Why This Matters
Suppose audiodeck already defines its own property group:
class AudioDeckProps(bpy.types.PropertyGroup):
audio_path: bpy.props.StringProperty(
name="Audio Path",
subtype="FILE_PATH"
)
And suppose it is registered as:
bpy.types.Scene.audiodeck_props = bpy.props.PointerProperty(
type=AudioDeckProps
)
Now we have two places storing the same concept:
context.scene.audiodeck_props.audio_path
context.scene.qa_shared.project__audio_path
Two values. One meaning.
That duplication is unnecessary.
If audiodeck is the host, it should own the data.
The Mechanism: HostAPI
When audio_encode reads or draws a shared value, it does not directly access storage.
Instead, it calls:
host_api.get(...)
host_api.draw(...)
Until now, we were relying on the default fallback host.
Now we will provide our own.
Step 1 – Implement a Minimal HostAPI in audiodeck
Open:
audiodeck/__init__.py
Add the following class:
class AudioDeckHostAPI:
def get(self, context, key, fallback_prop):
if key == "project.audio_path":
return context.scene.audiodeck_props.audio_path
# fallback for unknown keys
return getattr(context.scene.qa_shared, fallback_prop)
def draw(self, layout, context, key, fallback_prop, label=None):
if key == "project.audio_path":
layout.prop(
context.scene.audiodeck_props,
"audio_path",
text=label
)
else:
layout.prop(
context.scene.qa_shared,
fallback_prop,
text=label
)
This class does two things:
- Routes
"project.audio_path"intoaudiodeck_props. - Falls back to generated storage for all other shared keys.
The plugin does not change. Only the host’s routing logic changes.
Step 2 – Inject the HostAPI
Modify the registration of audio_encode inside audiodeck.register():
audio_encode_ops.register(
mode="plugin",
host_api=AudioDeckHostAPI()
)
That is the entire ownership transfer.
What Changed?
Before:
audio_encode
└── qa_shared
After:
audio_encode
└── HostAPI
└── audiodeck_props
Now:
audio_encodereads fromaudiodeckstorage.audio_encodedrawsaudiodeckproperties.- Fallback storage remains available for other shared keys.
What Did NOT Change
audio_encodetool scriptaudio_encodeshared decoratoraudio_encodeoperator logicaudio_encodebuild process
Only audiodeck changed.
The plugin remains generated, reusable, and composable.
What You Have Now
At this point:
- UI ownership belongs to
audiodeck. - Data ownership belongs to
audiodeck. audio_encoderemains independent and reusable.- The relationship is defined entirely through the HostAPI contract.
In the next chapter, we will make this routing scalable — so you do not have to write if key == ... for every shared value.
HostAPI – The Routing Contract
In the previous chapter, we transferred shared value ownership from fallback storage into audiodeck.
We did this by injecting a HostAPI implementation.
Now we formalize what that means.
This chapter defines the HostAPI contract.
Why HostAPI Exists
A generated plugin never directly accesses storage.
It does not read from:
context.scene.qa_shared
It does not read from:
context.scene.audiodeck_props
Instead, whenever a shared value is:
- Read
- Drawn in the UI
The plugin calls:
host_api.get(context, key, fallback_prop)
host_api.draw(layout, context, key, fallback_prop, label=...)
The plugin speaks only in shared keys.
The host decides where those keys live.
That separation is the purpose of HostAPI.
Core Terms
Shared Key
A stable identifier defined in the plugin decorator:
shared={"audio_path": "project.audio_path"}
The plugin only knows:
"project.audio_path"
It does not know how that key is stored.
Fallback Property
A generated property name derived from the shared key:
project__audio_path
This exists so the plugin can function in standalone mode.
If the host does not serve a key, fallback storage is used.
Required Methods
A HostAPI implementation must define:
class HostAPI:
def get(self, context, key: str, fallback_prop: str):
"""
Return the value for shared key `key`.
- `key` is the shared key (e.g. "project.audio_path")
- `fallback_prop` is the generated fallback property name
"""
...
def draw(self, layout, context, key: str, fallback_prop: str, *, label=None):
"""
Draw the UI control for shared key `key`.
Must draw something:
- host-owned property
- or fallback property
"""
...
These two methods form the entire routing contract.
Nothing else is required.
Fallback Behavior
If a host does not serve a shared key:
return getattr(context.scene.qa_shared, fallback_prop)
and
layout.prop(context.scene.qa_shared, fallback_prop, text=label)
This ensures:
- Plugins remain functional without a host.
- Hosts can adopt routing incrementally.
- Shared ownership is optional, not mandatory.
Fallback is not a special case — it is part of the design.
Injection Model
When embedding a plugin, the host injects its HostAPI:
audio_encode_ops.register(
mode="plugin",
host_api=AudioDeckHostAPI()
)
The plugin never creates the HostAPI itself.
The host always controls:
- Whether a HostAPI is provided
- Which implementation is used
Control remains centralized.
Design Discipline
HostAPI is:
- A routing layer
- Not a transformation layer
- Not a validation framework
- Not a business logic container
It should:
- Map keys to storage
- Draw the correct property
- Preserve fallback behavior
Nothing more.
What HostAPI Is Not
HostAPI does not:
- Know which plugin requested a key
- Store plugin identity
- Mutate shared keys
- Enforce global state
It is intentionally minimal.
Where We Are
At this point, you understand:
- Plugins declare shared keys.
- Plugins never access storage directly.
- Hosts inject HostAPI.
- HostAPI routes shared keys to storage.
In the next chapter, we will implement a clean and scalable routing pattern inside audiodeck.
Property Ownership – A Clean Routing Pattern
In the previous chapter, we formalized the HostAPI contract.
We saw that:
- Plugins speak in shared keys.
- Hosts decide where those keys live.
- Routing happens through
get()anddraw().
Earlier, we implemented routing using:
if key == "project.audio_path":
...
That works for one key.
It does not scale.
This chapter introduces a clean, declarative routing pattern.
The Problem with if Chains
Suppose audio_encode defines three shared keys:
shared={
"audio_path": "project.audio_path",
"bpm": "project.bpm",
"output_dir": "project.output_dir",
}
Writing:
if key == ...
elif key == ...
elif key == ...
Becomes repetitive and fragile.
As the number of shared keys grows, branching logic becomes harder to reason about and maintain.
We want:
- Explicit mapping
- Easy extension
- No branching logic explosion
- Clear ownership declaration
Step 1 – Define a KEYMAP
Inside audiodeck/__init__.py:
KEYMAP = {
"project.audio_path": ("audiodeck_props", "audio_path"),
"project.bpm": ("audiodeck_props", "bpm"),
"project.output_dir": ("audiodeck_props", "output_dir"),
}
Each entry maps:
shared_key → (property_group_name, attribute_name)
This makes ownership explicit and declarative.
The host now clearly states:
“These shared keys belong to these properties.”
Step 2 – Refactor HostAPI to Use KEYMAP
Replace the earlier implementation with:
class AudioDeckHostAPI:
def get(self, context, key, fallback_prop):
if key in KEYMAP:
group_name, attr = KEYMAP[key]
group = getattr(context.scene, group_name)
return getattr(group, attr)
return getattr(context.scene.qa_shared, fallback_prop)
def draw(self, layout, context, key, fallback_prop, label=None):
if key in KEYMAP:
group_name, attr = KEYMAP[key]
group = getattr(context.scene, group_name)
layout.prop(group, attr, text=label)
else:
layout.prop(
context.scene.qa_shared,
fallback_prop,
text=label
)
Routing is now centralized.
No condition chains. No duplication. No hidden logic.
Adding a New Shared Key
To route a new key, modify only the KEYMAP:
KEYMAP["project.sample_rate"] = ("audiodeck_props", "sample_rate")
No other code changes are required.
Growth becomes mechanical.
Fallback Still Works
If a shared key is not listed in KEYMAP:
- It automatically uses fallback storage.
- The plugin continues to function.
- Ownership transfer can be incremental.
This makes adoption safe and predictable.
Discipline Guidelines
To keep routing reliable:
- Shared keys must match exactly.
- Host property types must match plugin expectations.
- Avoid transforming values inside HostAPI.
- Keep KEYMAP static and explicit.
HostAPI is a routing layer. It should remain simple.
What You Now Have
At this point:
- UI ownership belongs to
audiodeck. - Data ownership belongs to
audiodeck. - Routing is scalable.
- Fallback behavior is preserved.
- Plugins remain reusable.
In the next chapter, we extend this model to multiple embedded plugins.
Multi-Plugin Hosts and Generated Routing
At this point, you have built a proper host.
Inside audiodeck:
- Shared keys are centralized in
KEYMAP. HostAPIroutes through that map.- Fallback storage remains intact.
- Plugins remain unaware of storage details.
The system is clean. It scales. It is generic.
You now have a host that can serve any embedded plugin.
Serving Multiple Plugins
Suppose you embed a second plugin audio_render:
audiodeck/
├── audio_encode/
└── audio_render/
Registration:
audio_encode_ops.register(
mode="plugin",
host_api=AudioDeckHostAPI()
)
audio_encode_ops.mount_instance("encode")
audio_render_ops.register(
mode="plugin",
host_api=AudioDeckHostAPI()
)
audio_render_ops.mount_instance("render")
Both plugins speak in shared keys.
Both are routed through the same KEYMAP. Update the KEYMAP to include audio_render's routing.
Routing is based on keys — not plugin identity.
Your host is now a system boundary.
The Routing Layer Is Generic
Notice what your HostAPI does not do:
- It does not know which plugin requested a key.
- It does not track plugin identity.
- It does not contain business logic.
Everything flows through:
Plugin → HostAPI → KEYMAP → Storage
This is composability.
Plugins snap into the host. Shared keys connect through the map. Ownership remains centralized.
Look at What You Built
You wrote:
- A declarative
KEYMAP - A mechanical
HostAPI - A predictable registration + mounting pattern
There is no dynamic behavior in this routing layer. No heuristics. No introspection.
It is entirely structural.
A Natural Question
If this structure is predictable…
If every host follows the same pattern…
If shared keys are already declared in decorators…
Why are we writing this by hand?
Let’s pause on the idea of generated keymap routing.
With this idea:
- Shared keys declared in the host are collected.
- Embedded plugin shared keys are collected.
- A
KEYMAPis generated. - A default
HostAPIis emitted. - Fallback behavior is preserved.
The structure you just implemented manually could, in principle, be generated automatically.
Build Manually, Then Automate
You now understand:
- The contract
- The ownership model
- The routing boundary
Automation without understanding creates dependency. Understanding before automation creates control.
That is why we built the routing layer manually first.
We will revisit automation much later in the book.
Before that, we need to cover a few more structural foundations.
Where We Stand
At this stage:
- Hosts own UI.
- Hosts own shared data.
- Multiple plugins are supported.
- Routing is centralized.
- Plugin instances are explicit and named.
- Generation removes boilerplate.
But how does this fit into the existing add-on ecosystem?
In Chapter 11, we will step outside the generated world and refactor a hand-written add-on into a host.
Next, we address an important issue for add-on developers — and how the generator proposes a disciplined solution.
Cooperative Long-Running Tasks
Now that we have built an add-on and executed our functions, we must address an important concern for serious add-on development.
Up to this point, every plugin function executes immediately.
That works for:
- Small tasks
- Quick transformations
- Lightweight operations
But real tools often perform:
- Frame-by-frame processing
- Batch encoding
- File iteration
- Heavy analysis
If executed in a single operator call, Blender freezes.
Operators run on the main thread.
If a task takes 10 minutes, the UI blocks for 10 minutes.
That is not acceptable for serious tools.
The Cooperative Model
Instead of blocking execution, we introduce:
Cooperative long-running tasks.
The idea is simple:
- A task performs a small unit of work.
- It yields control back to Blender.
- The host schedules the next step.
- Progress is tracked.
- The user can cancel.
This is similar to how rendering progresses frame by frame.
Blender stays responsive.
Declaring a Long Task
A plugin may declare:
@op(
label="Process Frames",
long_task=True,
)
def process_frames(...):
...
If long_task=True, the function must follow a contract.
That contract defines:
- How incremental work is performed
- How progress is reported
- How cancellation is respected
- How cleanup is handled
Plugins do not implement scheduling.
The host owns execution control.
Cancellation Model
Cancellation must be explicit and predictable.
QuickAddon long tasks must provide a visible cancellation mechanism similar to Blender’s “Render Animation” operator.
The host must provide:
- A progress indicator
- A visible cancel control (e.g., button or “X”)
- Proper cleanup on cancellation
ESC behavior:
ESC may cancel the task only when the long-task modal operator actively owns execution focus, similar to Blender rendering.
ESC must not:
- Cancel unrelated background work
- Interfere with normal Blender interaction
- Trigger cancellation unintentionally while the user performs other actions
The recommended model mirrors Blender’s native rendering workflow:
User starts task
→ Progress appears
→ Cancel button is visible
→ ESC cancels only while task is modal-owner
Cancellation is a host responsibility. Plugins must tolerate interruption.
Why This Matters
As tools grow:
- Processing time increases
- Users expect responsiveness
- Cancellation becomes critical
- Stability becomes non-negotiable
A disciplined long-task model allows:
- Non-blocking execution
- Progress feedback
- Safe cancellation
- Predictable lifecycle management
We will define the full long-task contract and implementation details in Chapter 13.
For now, understand the boundary:
Plugins may declare long tasks. Hosts own scheduling and execution control.
In the next chapter, we step outside the generated ecosystem and refactor an existing add-on into a host.
Refactoring an Existing Add-on into a Host
So far, we have stayed inside the generated ecosystem.
Now we step outside it.
Suppose you already have a working Blender add-on.
It was written manually. It grew over time. It works. But it feels heavier than it should.
We will refactor it into a proper host.
The Starting Point
Consider a simplified existing add-on:
class AUDIO_OT_encode(bpy.types.Operator):
bl_idname = "audio.encode"
bl_label = "Encode Audio"
def execute(self, context):
path = context.scene.audio_path
bitrate = context.scene.bitrate
print("Encoding:", path, bitrate)
return {'FINISHED'}
class AUDIO_OT_render(bpy.types.Operator):
bl_idname = "audio.render"
bl_label = "Render Audio"
def execute(self, context):
path = context.scene.audio_path
print("Rendering:", path)
return {'FINISHED'}
class AUDIO_PT_panel(bpy.types.Panel):
bl_label = "Audio Tools"
bl_space_type = 'SEQUENCE_EDITOR'
bl_region_type = 'UI'
bl_category = "Audio"
def draw(self, context):
layout = self.layout
layout.prop(context.scene, "audio_path")
layout.prop(context.scene, "bitrate")
layout.operator("audio.encode")
layout.operator("audio.render")
This works.
But notice:
- Shared data is accessed directly from
context.scene. - Operators assume a specific storage location.
- Logic and UI are tightly coupled.
- Extending this structure requires repetition.
There is no clear ownership boundary.
Step 1 – Separate Ownership
First, formalize storage ownership.
Define a property group:
class AudioHostProps(bpy.types.PropertyGroup):
audio_path: bpy.props.StringProperty(subtype="FILE_PATH")
bitrate: bpy.props.IntProperty(default=192)
Register it:
bpy.types.Scene.audio_host = bpy.props.PointerProperty(
type=AudioHostProps
)
Now storage is explicit.
Instead of scattering values across context.scene, we centralize them.
Step 2 – Extract Tool Logic
Instead of embedding logic directly inside operators, extract the functional core:
def encode_audio(audio_path, bitrate):
print("Encoding:", audio_path, bitrate)
def render_audio(audio_path):
print("Rendering:", audio_path)
Now logic is reusable and independent of Blender.
Operators become thin wrappers.
Step 3 – Convert Tools into Plugins
Now these functions can become QuickAddon plugins.
They declare shared keys:
@op(
shared={
"audio_path": "project.audio_path",
"bitrate": "project.bitrate",
}
)
def encode_audio(audio_path: Path, bitrate: int):
...
And:
@op(
shared={
"audio_path": "project.audio_path",
}
)
def render_audio(audio_path: Path):
...
Important contract:
audio_path: Pathmeans the generated wrapper passes a realPathobject to plugin code- if a host/plugin author wants Blender filepath UI but needs a plain string,
they should use
audio_path: strplusparam_subtypes={"audio_path": "FILE_PATH"}
Generate them as plugins.
These plugins:
- Do not know where storage lives.
- Do not assume
context.scene. - Declare only their shared contracts.
Step 4 – Turn the Existing Add-on into a Host
Your original add-on becomes:
- UI owner
- Property owner
- Orchestrator
It embeds the generated plugins.
It defines:
KEYMAPHostAPI- Optional orchestration logic
The structure becomes:
audio_host/
├── __init__.py
├── encode_plugin/
└── render_plugin/
The host controls routing.
The plugins remain modular.
What Changed?
Before:
- Operators directly accessed scene properties.
- Storage was implicit.
- Logic and UI were tightly coupled.
After:
- Host owns storage.
- Plugins declare shared keys.
- Routing is centralized.
- Logic is modular.
- The system becomes composable.
What Did Not Change?
- The core encode logic.
- The core render logic.
- The UI intent.
- The user workflow.
We changed structure, not behavior.
The Result
Your existing add-on:
- Becomes cleaner.
- Gains composability.
- Gains embeddable plugins.
- Gains routing discipline.
- Gains orchestration capability.
And it did not require rewriting everything from scratch.
In the next chapter, we go the other direction:
Refactoring an existing add-on into a reusable plugin.
Refactoring Existing Addon into Plugin
In the previous chapter, we refactored an existing add-on into a host.
Now we take the opposite direction.
Instead of turning a legacy add-on into a host, we turn it into a reusable plugin.
The goal is composability.
The Starting Point
Consider a typical legacy operator:
class AUDIO_OT_normalize(bpy.types.Operator):
bl_idname = "audio.normalize"
bl_label = "Normalize Audio"
def execute(self, context):
audio_path = context.scene.audio_path
threshold = context.scene.threshold
print("Normalizing:", audio_path, threshold)
return {'FINISHED'}
This works.
But it assumes:
- Properties live directly on
context.scene. - Storage location is fixed.
- UI and logic are tightly coupled.
It cannot be embedded cleanly into another host.
Step 1 – Extract the Core Logic
Separate execution from Blender context:
def normalize_audio(audio_path, threshold):
print("Normalizing:", audio_path, threshold)
Now the function is pure.
It no longer depends on context.
The Blender-specific layer becomes a wrapper, not the core.
Step 2 – Declare Shared Keys
Instead of directly accessing scene properties, declare shared inputs explicitly:
@op(
label="Normalize Audio",
space="SEQUENCE_EDITOR",
category="Audio",
shared={
"audio_path": "project.audio_path",
"threshold": "project.threshold",
},
)
def normalize_audio(
audio_path: Path,
threshold: float = -1.0,
):
print("Normalizing:", audio_path, threshold)
This does three things:
- Removes direct scene coupling.
- Declares the input contract explicitly.
- Makes the tool host-agnostic.
The function now speaks in shared keys, not storage paths.
Step 3 – Generate the Plugin
Run:
quickaddon build normalize.py
You now have:
normalize_plugin/
├── __init__.py
└── generated_ops.py
This plugin:
- Declares shared keys.
- Supports fallback storage.
- Can run standalone.
- Can be embedded into any host.
No assumptions about storage are embedded in it.
What Changed?
Before:
- The operator assumed a storage location.
- It could not be embedded cleanly.
- Logic was tied to a specific add-on.
After:
- Logic is pure.
- Inputs are declarative.
- Storage is routed by the host.
- The plugin is reusable.
What Did Not Change?
- The normalization logic.
- The UI label.
- The intended workflow.
- The operator behavior.
Only the structure changed.
The Composability Test
If a tool can:
- Declare shared keys.
- Avoid direct context access.
- Avoid owning storage.
Then it is a proper plugin.
Plugins should not:
- Decide where data lives.
- Perform orchestration.
- Assume execution order.
Those responsibilities belong to the host.
The Architectural Symmetry
Chapter 11:
Existing add-on → Host
Chapter 12:
Existing add-on → Plugin
Together, these chapters define the system boundary.
Hosts own:
- UI composition
- Shared storage
- Orchestration
- Constraint validation
Plugins own:
- Tool logic
- Input declaration
- Stateless execution
That separation is the foundation of composability.
Where You Are Now
You have seen:
- Scripts turned into tools
- Tools turned into plugins
- Plugins embedded into hosts
- Hosts managing routing
- Hosts coordinating execution
- Legacy add-ons refactored in both directions
You now understand the full architectural loop.
The rest is composition.
The Long Task Contract
In Chapter 10, we introduced cooperative long-running tasks.
In this chapter, we define the contract precisely.
This is not an implementation detail. It is an execution model.
Why the Contract Exists
Blender operators execute on the main thread.
If a function performs long-running work in a single call:
- The UI freezes
- The user cannot cancel
- Progress is invisible
- Blender appears unstable
The long task contract solves this by enforcing cooperative execution.
Declaring a Long Task
A plugin declares:
@op(
label="Process Frames",
long_task=True,
)
def process_frames(...):
...
If long_task=True, the function must follow the long task contract.
Strict Conformance
If long_task=True is declared:
- The function must be a python generator.
- The generator must yield at least once.
If the function:
- Does not contain
yield - Returns normally without yielding
- Raises
StopIterationimmediately
The build must fail.
QuickAddon enforces this at build time.
Long tasks are not optional behavior. They are a contract.
Strict conformance guarantees:
- Non-blocking execution
- Predictable scheduling
- Safe cancellation
- Stable host control
The Core Rule
A long task must:
Perform incremental work and yield control back to the host after each step.
It must not block until completion.
Required Execution Model
A long task function must behave as a generator.
Conceptually:
def process_frames(...):
total = compute_total()
for i in range(total):
do_one_unit_of_work(i)
yield {
"progress": i,
"total": total,
}
The function:
- Executes a small unit of work
- Yields a progress payload
- Allows the host to resume later
It does not run to completion in a single call.
Yield Payload
Each yield must return a dictionary.
Minimum required fields:
progress
total
Optional fields:
message
Example:
yield {
"progress": i,
"total": total,
"message": f"Processing frame {i}",
}
The host uses this data to:
- Update the progress bar
- Display status messages
- Determine completion
Completion
When the generator finishes:
- The task is considered complete.
- The host performs cleanup.
- Progress UI closes.
- Execution returns
FINISHED.
Cancellation
The host must provide an explicit cancellation mechanism.
Recommended UI model:
- Status bar progress indicator
- Visible cancel button (“X”)
- ESC key support only while modal operator owns execution
When cancellation is triggered:
- Host stops calling
next() - Generator is discarded
- Progress UI closes
- Operator returns
CANCELLED
Cancellation must be:
- Explicit
- Predictable
- Scope-aware (v2)
- Safe
Plugins must not rely on guaranteed completion.
They must tolerate interruption at any yield boundary.
Error Handling
If a long task generator raises an exception:
- The host must stop execution.
- Progress UI must close.
- The error must be reported via Blender operator report.
If a yield payload:
- Is not a dictionary
- Omits required fields
- Contains invalid values
The host must:
- Cancel execution
- Report a contract violation
The long task contract is enforced at runtime as well as at build time.
Host Responsibilities
The host must:
- Detect
long_task=True - Wrap execution in a modal operator
- Schedule incremental
next()calls - Update progress display
- Provide cancellation
- Ensure scope isolation (if scoped)
The host owns scheduling.
Plugins must not implement modal logic directly.
Interaction with Scoped Instances
If HostAPI v2 is used:
- Each scope maintains its own long-task state.
- Progress and cancellation apply only to that scope.
- Multiple scoped instances may run independently.
Isolation must be enforced at the host level.
Forbidden Patterns
A long task must not:
- Execute blocking loops without yielding
- Spawn uncontrolled threads
- Access scene storage directly
- Assume uninterrupted execution
- Manipulate modal handlers directly
The contract enforces discipline.
Short Tasks vs Long Tasks
Short task:
@op(...)
def simple_task(...):
perform_all_work()
Long task:
@op(long_task=True)
def incremental_task(...):
yield ...
The decorator determines the execution strategy.
Why This Matters
The long task contract provides:
- Non-blocking UI
- Visible progress
- Safe cancellation
- Predictable lifecycle
- Stable host-controlled execution
It allows complex processing without destabilizing Blender. In chapter 15 we will expand further we examine how the generator implements this model internally.
In the next chapter, we will examine how the HostAPI paradigm can be used for more complex need.
Scoped Instances and Nested Composition
Up to this point, every embedded plugin instance has assumed a single storage domain.
That works for:
- Simple add-ons
- Single-instance tools
- Linear workflows
But it limits composition.
What happens if:
- You embed the same plugin twice?
- You want two encoders with different settings?
- A host embeds another host?
- You build nested systems?
Static storage resolution breaks down.
We need instance isolation.
Earlier, in Chapter 9, we hinted at a more scalable routing model. We now extend that idea into scoped composition.
The Limitation of Static Storage
In the single-instance model:
shared_key → KEYMAP → Scene.some_pointer.some_attr
All plugin instances share the same storage location.
This prevents:
- Multiple independent instances
- Nested hosts
- Scoped isolation
To unlock composition, storage must become scoped.
Introducing Scope
A scope represents one logical instance of a plugin inside a host.
Instead of resolving shared values globally:
key → value
We now resolve:
(scope, key) → value
Each mounted plugin instance receives its own scope.
Scope is allocated and owned by the host.
Binding a Scope for a Mounted Instance
HostAPI v2 introduces:
host_api.bind(context, instance_hint=None)
During hosted runtime execution for a mounted instance:
- The host/runtime allocates or selects a scope.
- A new HostAPI bound to that scope is returned.
- All shared resolution occurs within that scope.
Plugins do not manage scope.
Hosts do.
What Changes for Plugins?
Nothing in the tool source.
The generator ensures:
- Shared keys remain declarative.
get()anddraw()operate through the bound API.- Plugins remain unaware of storage paths.
The change is architectural, not behavioral.
Scoped KEYMAP
Routing must now consider scope.
Conceptually:
(scope, key) → host-owned property
If no host-owned mapping exists:
- Scoped fallback storage is used.
Each plugin instance is isolated unless the host explicitly routes keys together.
HostAPI v2 enables the scalable routing model we anticipated earlier.
Nested Composition
Scopes make nested composition possible.
Example:
- Host A embeds Host B.
- Host A mounts a root instance.
- Host-owned storage remains the ground.
- Child systems may later mount child instances on top of the same scoped model.
Each layer isolates its instances.
No collisions. No implicit sharing. No accidental overwrites.
Composition becomes predictable.
Storage Backend
Scoped storage must not rely on dynamic RNA property creation.
Recommended fallback backend:
scene["qa_scope:<scope_id>:<fallback_prop>"]
Hosts may still route specific keys to typed PropertyGroups.
Fallback storage, however, must remain dynamic and scope-aware.
Multi-Instance Example
Imagine embedding the same plugin twice:
audiodeck/
├── encoder_A
└── encoder_B
Each registration calls bind().
Each receives a unique scope.
Each has independent values for:
project.audio_pathproject.bitrateproject.output_dir
They do not interfere with one another.
Responsibility Boundaries
Plugins:
- Declare shared keys
- Remain stateless across scopes
- Do not access scene storage directly
Hosts:
- Allocate scopes
- Own scoped storage
- Maintain scope-aware routing
- Decide when scopes intentionally share values
Isolation is a host responsibility.
When Should You Use Scoped Instances?
Scoped instances are necessary when:
- A plugin may appear multiple times
- Nested composition is required
- Isolation is critical
- Complex systems are being built
Simple add-ons may not need it.
QuickAddon supports both models.
Scoped instances transform QuickAddon from:
an add-on generator
into
a composable UI architecture framework.
At this point, you may want to review the formal specification: HostAPI V2 Spec.
Internal Execution Model
Up to this point, you have seen:
- How plugins are declared.
- How shared keys are routed.
- How long tasks are enforced.
- How HostAPI v1 and v2 separate storage ownership.
Now we step inside the system.
This chapter explains what actually happens when a user clicks a button.
The Three Phases of Execution
Every QuickAddon tool goes through three distinct phases:
- Build-time compilation
- Registration-time binding
- Runtime execution
Understanding these phases clarifies every design decision.
Phase 1 – Build-Time Compilation
When you run:
quickaddon build my_tool.py
QuickAddon performs:
- Module loading
- Decorator extraction
- Structural validation
- Contract validation (QA10)
- Render model construction
- Template emission
The source file is not executed inside Blender.
It is compiled into a structural add-on module.
At this stage:
- Shared keys are registered.
- Types are verified.
- long_task contracts are enforced.
- No Blender API calls occur.
Build-time is deterministic.
If it succeeds, the add-on structure is valid.
Phase 2 – Registration-Time Binding
When Blender enables the add-on:
- Classes are registered.
- PropertyGroups are created.
- HostAPI is injected.
In v1:
- A fallback HostAPI is used if none is injected.
In v2:
- A named instance is mounted explicitly.
- The runtime binds a scope for that mounted instance.
- A bound HostAPI instance is used internally.
- All shared routing resolves through the bound API.
Registration does not execute tool logic.
It prepares execution boundaries.
Phase 3 – Runtime Execution
When a user clicks a tool button:
Blender invokes the generated Operator.
The operator:
- Collects local parameters (if any)
- Resolves shared values via HostAPI
- Calls the user function
At this point execution diverges:
- Short task
- Long task
Short Task Execution Path
For short tasks:
fn(...)
return {'FINISHED'}
The function runs immediately.
If an exception occurs:
- The operator reports the error.
- Execution returns
CANCELLED.
Short tasks are synchronous and simple.
Long Task Execution Path
If long_task=True:
Execution becomes cooperative.
The operator:
- Calls the user function.
- Verifies that a generator was returned (QA20).
- Starts a modal timer.
- Schedules incremental execution.
Instead of running to completion:
User Click
↓
Operator.invoke()
↓
Start generator
↓
Modal loop
↓
next(generator)
↓
Yield progress
↓
Repeat until StopIteration
Each yield returns control to Blender.
The UI remains responsive.
Modal Scheduler Model
The long task operator owns:
- Timer creation
- Modal handler registration
- Progress bar lifecycle
- Generator advancement
- Cleanup on completion or cancellation
The plugin never touches:
- Timers
- Modal handlers
- Progress API
This separation guarantees stability.
Runtime Contract Enforcement (QA20)
During modal execution:
Each yield payload is validated:
- Must be dict
- Must contain
progress - Must contain
total
If validation fails:
- Execution stops
- Progress UI closes
- Error is reported
- Operator returns
CANCELLED
Contracts are enforced at runtime to prevent silent corruption.
Cancellation Behavior
If the user cancels:
- The modal loop stops.
- The generator is discarded.
- Progress UI closes.
- Operator returns
CANCELLED.
The generator must tolerate interruption.
Completion is not guaranteed.
Native Blender Parity
QuickAddon long tasks intentionally mirror Blender’s “Render Animation” execution model.
Characteristics:
- Modal timer-driven execution
- Progress displayed in status area
- Visible cancel control
- ESC cancels only while render is active
- UI remains stable
This alignment ensures:
- Native user expectations
- Predictable cancellation
- Familiar interaction patterns
- Professional integration into Blender workflows
Scoped Execution (v2)
In v2:
Each plugin instance is bound to a scope.
All shared value resolution happens through:
(scope, key) → value
Long tasks remain isolated per scope.
Multiple instances may run independently.
Isolation is enforced at the HostAPI level.
No Threads, No Background Workers
QuickAddon intentionally does not use:
- Threads
- Background tasks
- Async execution
Why?
Blender’s Python environment is single-threaded for UI safety.
Cooperative scheduling preserves:
- Stability
- Predictability
- Debuggability
The generator model is sufficient.
Determinism Guarantees
The execution model guarantees:
- Build-time structural correctness
- Registration-time binding clarity
- Runtime contract enforcement
- No hidden shared state
- No dynamic introspection
Execution is explicit at every layer.
Why This Model Exists
The internal execution model enforces:
- Separation of structure and behavior
- Host-controlled lifecycle
- Plugin-declared intent
- Deterministic routing
- Safe incremental processing
Without this model:
- Long tasks freeze Blender
- Nested composition breaks
- State leaks between instances
- Bugs become nondeterministic
The model is strict because Blender is strict.
What You Now Understand
When you click a button:
You are not running a script.
You are entering a controlled execution pipeline:
- Validated
- Routed
- Bound
- Scheduled
- Enforced
QuickAddon is not merely generating operators.
It is generating execution boundaries.
Composition Over Convenience
You began with a simple problem. How do I turn a Python function into a Blender add-on? Along the way, you built something larger. You learned:
- How to declare tools instead of writing operators by hand.
- How to separate UI from logic.
- How to separate storage ownership from tool behavior.
- How to route shared data through a contract.
- How to compose multiple plugins into a host.
- How to isolate instances with scopes.
- How to execute long-running tasks without freezing Blender.
- How to enforce architectural discipline at build time.
QuickAddon is not a macro generator. It is not a shortcut. It is a structure. The structure enforces:
- Explicit ownership
- Declarative inputs
- Predictable routing
- Controlled execution
- Safe composition
You may never need multi-instance scoped hosts. You may never embed a host inside another host. You may never declare a long task. That is fine. The architecture scales down as cleanly as it scales up. The point is not complexity. The point is boundaries. When boundaries are clear:
- Systems remain stable.
- Growth remains controlled.
- Refactoring becomes possible.
- Composition becomes natural.
You now understand the full loop:
Script
→ Tool
→ Plugin
→ Host
→ System
Everything beyond this point is composition. Build what you need. Refactor when structure demands it. Generate when discipline permits it.
Own your boundaries.
Decorator Reference
This page documents the user-facing @op(...) contract.
It is a reference page, not a teaching chapter.
Use it when you need the current supported decorator surface in one place.
Basic Shape
@op(
label="Setup From Audio",
category="QuickAddon",
space="SEQUENCE_EDITOR",
region="UI",
panel=True,
long_task=False,
shared={"audio_path": "project.audio_path"},
param_labels={"audio_path": "Audio File"},
param_order={"audio_path": 100, "bpm": 10},
param_subtypes={"cache_dir": "DIR_PATH"},
inject={"ctx": "ctx"},
)
def setup_from_audio(audio_path: Path, bpm: int = 120, cache_dir: str = "", ctx: Any = None):
...
Core Decorator Fields
| Field | Meaning |
|---|---|
label | UI label for the generated operator |
idname | Optional Blender operator idname override |
description | Optional operator description |
category | Panel tab/category |
space | Blender editor space |
region | Blender region |
panel | Whether to include the op in generated panel UI |
long_task | Enables the long-task generator contract |
shared | Declares shared key routing by parameter |
param_labels | Overrides generated UI labels |
param_order | Controls display order; higher values render first |
param_subtypes | Overrides generated Blender property subtypes |
inject | Explicit runtime injection mapping |
v1 and v2 both use the same decorator surface.
Track differences apply to generated runtime behavior, not to the decorator syntax itself.
Supported Parameter Types
QuickAddon currently supports:
strintfloatboolpathlib.PathLiteral["a", "b", ...]
These generate:
StringPropertyIntPropertyFloatPropertyBoolPropertyStringProperty(subtype="FILE_PATH")EnumProperty
Runtime contract:
pathlib.Pathparameters are passed to user code asPathobjectsstrparameters are passed to user code as plain stringsparam_subtypesaffects Blender UI only, not runtime conversion
Not supported:
*args**kwargs
Unsupported parameter types fail build with QA10 diagnostics.
Shared Keys
Shared values are declared by parameter name:
@op(
shared={
"audio_path": "project.audio_path",
}
)
def setup_from_audio(audio_path: Path):
...
Rules:
- Shared keys are stable opaque identifiers.
- The same shared key must keep one consistent type across all ops.
- Shared parameters do not appear as local popup dialog properties.
- Shared values are drawn in the shared inputs group instead.
If multiple ops use the same shared key, they reference the same logical shared value.
Panel
panel controls whether QuickAddon includes the operator in its auto-generated
panel UI.
panel=True- Generate and register the operator
- Include it in the generated panel tool list
panel=False- Generate and register the operator
- Do not include it in the generated panel tool list
This is a UI composition flag only.
It does not:
- remove the operator
- disable the operator
- remove parameters
- prevent invocation from other UI or code
An operator with panel=False still exists as a normal Blender operator and can be
invoked by bl_idname from menus, custom panels, keymaps, scripts, or host add-ons.
Why This Exists
Most QuickAddon examples start with the default generated panel because that is the lowest-friction path.
But QuickAddon supports a broader set of UI patterns:
- standard panel-first tools
- menu-driven operators
- host-composed tools drawn by another add-on
- hidden helper operators used for workflow plumbing
panel is what lets one generated operator model support all of those cases.
Menu Behavior
panel=False is a good fit for menu-only operators.
If the operator has local parameters:
- invoking it from a menu still opens Blender’s floating properties dialog
- the user can enter values before execution
If the operator has no local parameters:
- it runs immediately when invoked
Shared parameters are different:
- shared values do not appear in the local popup dialog
- shared values are expected to be drawn by host/shared UI instead
Interaction With shared
panel=False can be used with shared, but only when some other UI is responsible
for exposing those shared values.
Good fit:
- a host add-on panel already draws the shared inputs
- a custom UI calls generated draw helpers
- shared values are owned by a host integration layer
Awkward fit:
- the generated QuickAddon panel was the only place users could edit those shared values
Practical rule:
panel=False + sharedis useful when another UI surface owns the shared statepanel=False + sharedis confusing when no other UI exists for those shared inputs
Mental Model
panel answers one question:
Should QuickAddon auto-surface this operator in its default panel UI?
UI Labels
Use param_labels to override display names:
@op(
param_labels={
"audio_path": "Audio File",
"start_frame": "Start Frame",
}
)
This applies to:
- Generated local operator properties
- Shared input draw labels
If you do not supply param_labels, QuickAddon uses the parameter name.
Label Collision Rule
Resolved UI labels must be unique within each displayed group.
That means:
- Local properties within one operator dialog must not collide
- Shared inputs within the shared inputs group must not collide
Duplicate labels fail build with QA10 diagnostics.
UI Ordering
Use param_order to control property order:
@op(
param_order={
"audio_path": 100,
"bpm": 10,
"debug": 0,
}
)
Rules:
- Higher order values appear first
- Parameters with the same order are sorted deterministically by UI label, then name
- Applies to both local properties and shared inputs
If param_order is omitted, the default order value is 0.
param_subtypes
Use param_subtypes when you want filepath-style Blender UI without changing the
runtime Python type:
@op(
param_subtypes={
"json_path": "FILE_PATH",
"cache_dir": "DIR_PATH",
}
)
def export_transforms(json_path: str = "", cache_dir: str = ""):
...
Rules:
- Supported values are currently
"FILE_PATH"and"DIR_PATH". - Valid only for
strandPathparameters. - For
Path, QuickAddon still passes aPathobject at runtime. - For
str, QuickAddon still passes a plain string at runtime.
Long Tasks
If long_task=True:
- The function must be a generator
- Async generators are not allowed
- Runtime yield payloads must include
progressandtotal
For the full contract, see Diagnostics and Contract Enforcement.
Injection
Injection is explicit and separate from shared routing.
Use:
@op(
inject={
"ctx": "ctx",
"scene": "scene",
}
)
Injected parameters:
- Do not generate UI fields
- Are supplied at execution time
- Must be declared explicitly
For the full injection contract, see Injection Contract v1.
Strictness Notes
QuickAddon does not silently guess through decorator ambiguity.
Examples of build failures:
- Unknown parameter referenced in
shared,param_labels,param_order, orinject - Shared key type mismatch across ops
- Duplicate resolved labels within one UI group
- Invalid
long_task=Truecontract
Reference pages for related contracts:
- Injection Contract v1
- QuickAddon HostAPI v2 Spec
- v1 to v2 migration
- Diagnostics and Contract Enforcement
Injection Contract v1
The Injection Contract defines how runtime values (such as Blender context) are passed into plugin functions.
This system is separate from HostAPI and shared key routing.
HostAPI handles shared state. Injection handles runtime wiring.
Core Principle
Injection is explicit.
Nothing is auto-wired.
A parameter is injected only if it is declared in:
@op(inject={ ... })
Basic Usage
from typing import Any
@op(
label="Example",
inject={
"ctx": "ctx",
"scene": "scene",
},
)
def example(ctx: Any, scene: Any, threshold: float = 0.5):
...
Parameters listed in inject:
- Do not generate UI fields.
- Are passed at operator execution time.
- Must match function parameter names.
Injection Mapping
Injection is defined as:
inject={
"parameter_name": "expression_or_alias",
}
The right-hand side may be:
1) Full Expression
Evaluated relative to execute(self, context) scope.
Example:
inject={
"ctx": "context",
"active_strip": "context.scene.sequence_editor.active_strip",
}
This generates static wiring:
ctx=context
active_strip=context.scene.sequence_editor.active_strip
No dynamic eval is used at runtime.
2) Built-in Aliases
These aliases expand to common context values:
| Alias | Expands To |
|---|---|
ctx | context |
scene | context.scene |
wm | context.window_manager |
area | context.area |
region | context.region |
space | context.space_data |
Example:
inject={
"ctx": "ctx",
}
Expands to:
ctx=context
Strictness Rules
1) Injection Must Be Explicit
There is no automatic context injection.
Even though operators receive context,
it must be declared if used.
2) Any Requires Injection
If a parameter:
- Is annotated as
Any - Has no default value
- Is not declared in
inject
Build fails.
Example error:
Parameter 'ctx' is annotated as Any but not declared in @op(inject=...).
Injection must be explicit.
This prevents accidental UI generation or ambiguous wiring.
3) Unknown Alias Is an Error
If injection value is:
- Not a built-in alias
- Not a valid expression
Build fails.
No guessing. No silent fallback.
Runtime Behavior
If an injected expression evaluates to None,
the function receives None.
Example:
context.areamay beNonecontext.scene.sequence_editormay beNone
Handling None is the responsibility of the plugin author.
Design Philosophy
Injection exists to support:
- Context-aware operators
- Refactoring existing addons
- Future extension beyond Blender context
It does not:
- Replace shared keys
- Replace HostAPI
- Imply ownership of runtime state
Relationship to HostAPI
HostAPI:
- Routes shared keys.
- Manages persistent state.
Injection:
- Supplies runtime values.
- Has no persistence semantics.
The two systems are orthogonal by design.
QuickAddon HostAPI v2 Spec
HostAPI v2 introduces scoped named instances and enables:
- Multi-instance plugins
- Nested composition (host-of-host)
- Isolated storage per plugin instance
- Hierarchical system construction
HostAPI v2 replaces the single-instance model of v1.
v1 and v2 are separate build tracks.
Core Concepts
Scope
A scope represents one logical instance of a plugin within a host.
Each mounted plugin instance receives a unique scope.
All shared key routing happens inside that scope.
Shared keys are no longer resolved globally. They are resolved as:
(scope, key)
Scope identity is:
- Host-defined
- Opaque to plugins
- Stable across file save/load
- Unique within a scene
Plugins must not assume scope format.
Bound HostAPI
HostAPI v2 introduces bind().
bind() allocates (or selects) a scope and returns a HostAPI instance bound to that scope.
After binding, all get() and draw() calls operate within that scope.
Scope allocation may be:
- Eager (during bind)
- Lazy (during first get/draw call)
Blender registration does not always provide reliable context, so hosts may defer scope materialization until runtime.
Scope identity remains host-defined and opaque.
Required Methods
class HostAPI:
def bind(
self,
context=None,
instance_hint: str | None = None,
) -> "HostAPI":
"""
Allocate (or select) a scope for this plugin instance.
- `instance_hint` may be used to request deterministic naming
(e.g., "encode_A").
- `context` may be None; hosts may allocate lazily.
- Returns a HostAPI bound to that scope.
"""
...
def get(self, context, key: str, fallback_prop: str):
"""
Return the value for shared key within this scope.
`fallback_prop` remains the plugin-generated property
name for fallback storage.
"""
...
def draw(
self,
layout,
context,
key: str,
fallback_prop: str,
*,
label: str | None = None,
):
"""
Draw UI control for shared key within this scope.
Must draw either:
- a host-owned property
- or a scoped fallback property
"""
...
These methods define the routing contract for v2.
bind() is a host/runtime mechanic.
Generated plugin users should think in terms of explicit named instances via
mount_instance("name"), not direct bind() calls.
Public v2 Plugin Pattern
Generated v2 plugins expose a small explicit public pattern:
plugin.register(host_api=host_api)
plugin.mount_instance("main")
plugin.unmount_instance("main")
plugin.unregister()
Rules:
register()prepares the hosted runtime only.register()does not create a hidden default instance.- Every usable instance is explicit and named.
- Internal instance keys remain opaque runtime details.
- Hosts should not call generated draw helpers directly.
Scope Allocation Rules
- Each call to
bind()allocates or selects one scope. - Scope identifiers must be unique within a scene.
- Scope lifetime is tied to scene data.
- Scope identity must remain stable across file save/load.
Hosts may choose:
- Deterministic scope naming (recommended when the mounted instance has a stable name)
- Opaque auto-generated identifiers
Plugins must treat scope identity as opaque.
Storage Model
HostAPI v2 does not mandate a specific storage backend.
Recommended fallback backend:
scene["qa_scope:<scope_id>:<fallback_prop>"]
IDProperties are recommended for:
- Arbitrary shared keys
- Multi-instance support
- Nested composition
- Dynamic routing
Hosts may route specific (scope, key) pairs to typed PropertyGroups via KEYMAP.
KEYMAP v2 Structure
KEYMAP must become scope-aware.
Hosts may implement either:
Global Routing
KEYMAP_GLOBAL = {
"project.audio_path": ("audiodeck_props", "audio_path"),
}
Scoped Override
KEYMAP_SCOPED = {
"<scope_id>": {
"project.bpm": ("audiodeck_props", "bpm"),
}
}
Resolution order for get():
- Scoped override
(scope_id, key) - Global routing
key - Scoped fallback storage
Isolation is enforced by design.
Nested Composition
HostAPI v2 enables nested systems.
Example:
- Host A embeds Host B.
- Host A mounts a root plugin instance.
- Host-owned storage remains the ground for that tree.
- Parent nodes may later mount child nodes on top of the same scoped storage model.
Scopes may be hierarchical internally, but hierarchy is an implementation detail of the host.
Plugins remain unaware of nesting.
Plugin Responsibilities
Plugins in v2:
- Must not access
context.scenedirectly for shared values - Must use HostAPI for all shared key resolution
- May declare
long_task=True(separate contract) - Must remain stateless across scopes
Plugins declare intent. Hosts enforce isolation.
Host Responsibilities
Hosts in v2:
- Allocate scopes during registration or lazily at runtime
- Own scoped storage lifecycle
- Maintain scope-aware routing
- Enforce isolation between instances
- Optionally expose instance management UI
The host owns identity, routing, and lifecycle.
Multi-Instance Behavior
The same plugin module may expose multiple mounted named instances.
Each mounted instance receives a distinct scope.
Shared keys are isolated per scope unless explicitly routed by the host.
Isolation is the default.
Instance Identity
Host-facing identity is instance-oriented.
Use explicit instance names when you need deterministic labels or grouping in host UI.
Plugin identity remains internal to generated artifacts and is opaque to host APIs.
Upgrade Model
v1 and v2 are separate build tracks.
Upgrade path:
- Regenerate the add-on with the v2 CLI option
- Plugins remain unchanged at the source level
- Decorator additions (if any) are minimal
- No runtime compatibility layer is provided
The tracks do not mix.
Design Principles
HostAPI v2 is based on:
- Explicit scoping
- Explicit instance mounting
- Centralized routing
- Host-owned execution control
- Composability over convenience
- Stability over magic
HostAPI v2 enables QuickAddon to function as a composable UI architecture framework — not merely an add-on generator.
v1 to v2 Migration
QuickAddon does not provide a runtime compatibility layer between tracks. Migration is regeneration-based.
Core Rule
v1andv2are parallel tracks.- To move to
v2, regenerate with--track v2.
Migration Steps
- Keep your tool source functions and
@opdeclarations. - Ensure filename stem follows canonical policy:
^[a-z_][a-z0-9_]*$. - Rebuild with v2:
quickaddon build my_tool.py --out "$BLENDER_ADDON" --track v2 --force
- Host integration uses scoped API:
- register plugin in plugin mode with unbound host API
- mount explicit named instances with
mount_instance("...")
What Changes Operationally
- Shared resolution becomes scoped (
scope + key). - Host controls scope lifecycle and routing.
- Multiple instances of the same plugin become first-class.
register()prepares the runtime; it does not create an instance automatically.
What Stays Stable
- Tool function source logic.
- Shared key declarations.
- Injection contract model (
inject={...}). - Long-task contract (
long_task=Truegenerator semantics).
Common Migration Errors
- Invalid source filename stem:
QA10-FILENAME-INVALID
- Attempting
--nameoverride:QA10-FILENAME-OVERRIDE-DISALLOWED
- Shared key type mismatch across ops:
QA10-SHARED-KEY-TYPEMISMATCH
Diagnostics and Contract Enforcement
QuickAddon enforces architectural contracts.
Strictness is intentional.
When a rule is violated, the system does not guess. It fails early and clearly.
Each diagnostic includes:
- A stable error code
- File and function location (when applicable)
- A concise explanation
- A direct fix suggestion
Error codes follow this format:
QA<layer>-<area>-<rule>
Where:
QA10→ Build-time validationQA20→ Runtime enforcementarea→LONGTASK,SHARED,HOSTAPI, etc.
Build-Time Validation
Build-time errors prevent invalid add-ons from being generated.
These diagnostics enforce structural correctness before Blender ever loads the add-on.
They are the most critical safeguards.
QA10-LONGTASK-NOTGEN
Triggered when:
long_task=Trueis declared- The function contains no
yieldoryield from
Example:
[QA10-LONGTASK-NOTGEN] my_tool.py:42 in process_frames
long_task=True requires a generator function (use 'yield' or 'yield from').
Fix:
- Add incremental
yieldsteps - Or remove
long_task=True
QA10-LONGTASK-ASYNC
Triggered when:
async defis used withlong_task=True
Async generators are not supported.
Fix:
- Use a standard generator (
def, notasync def)
QA10-SHARED-KEY-TYPEMISMATCH
Triggered when:
- The same shared key is declared with incompatible types across functions
Fix:
- Ensure consistent type usage for the shared key
Shared keys must have stable type semantics across the system.
Runtime Contract Enforcement
Runtime errors occur inside Blender during execution.
They indicate a contract violation that passed build validation but failed during execution.
QA20-LONGTASK-RETURNED-NONGEN
Triggered when:
- A
long_task=Truefunction returns a non-generator object at runtime
Example:
[QA20-LONGTASK-RETURNED-NONGEN] process_frames
long_task function did not return a generator.
Fix:
- Ensure the function contains
yield - Ensure execution does not return early before yielding
QA20-LONGTASK-YIELD-NONDICT
Triggered when:
- The generator yields something other than a dictionary
Fix:
- Yield a dictionary containing
progressandtotal
QA20-LONGTASK-YIELD-MISSING-FIELDS
Triggered when:
- The yield payload omits required fields
Minimum required fields:
progress
total
Optional:
message
Fix:
- Include required fields in every yield
The host relies on these fields for scheduling and progress reporting.
QA20-LONGTASK-EXCEPTION
Triggered when:
- The generator raises an exception
Behavior:
- Execution stops
- Progress UI closes
- The error is reported through Blender’s operator report system
Fix:
- Correct the underlying exception
Unhandled exceptions terminate the task immediately.
Design Philosophy
QuickAddon diagnostics are:
- Deterministic
- Searchable
- Stable across versions
Strictness is not punishment.
It guarantees:
- Non-blocking execution
- Predictable routing
- Isolation between instances
- Stable nested composition
Architectural contracts are enforced so systems remain reliable at scale.
QA Scaffold CLI Reference
qa-scaffold is a convention-first CLI for generating and operating QuickAddon QA scaffold projects.
The workflow is Python-only and designed to hide shell orchestration behind stable subcommands.
Command Surface
qa-scaffold init <path>
qa-scaffold build [--config <path>]
qa-scaffold sync [--config <path>]
Standard Flow
- Initialize scaffold:
qa-scaffold init ./qa_scaffolding
- Enter scaffold project and sync its environment:
cd qa_scaffolding
uv sync
- Build generated addon:
uv run qa-scaffold build
- Build and copy to sync target:
uv run qa-scaffold sync
Config Contract
Default config file in scaffold root:
qa_scaffold.toml
Typical defaults:
plugin_script = "src/sample_plugin.py"
out_dir = "build/addons"
sync_dir = "build/synced"
track = "v2"
force = true
doctor = true
blender = "4.0"
Rules:
- Config paths are resolved relative to the config file location.
trackmust bev1orv2.syncrunsbuildfirst, then copies output tosync_dir.
Convention Policy
- Users should not run shell scripts directly for scaffold operations.
- Canonical execution path is
uv run qa-scaffold <subcommand>. - Plugin identity still follows QuickAddon filename-stem policy.
Output Layout
After build/sync, expected structure is:
<scaffold>/build/addons/<plugin_stem>/...
<scaffold>/build/synced/<plugin_stem>/...