Skip to main content

Under the Hood: What the Plugin Loader Actually Does

⚠️ Critical Security Warning

Tools, Functions, Pipes, Filters, and Actions execute arbitrary Python code on your server. Function creation is restricted to administrators only, and Workspace Tool creation is gated by the workspace.tools permission. Granting that permission is equivalent to giving the user shell access to the server. Only install from trusted sources, review code before importing, and restrict creation to trusted administrators. A malicious plugin could access your file system, exfiltrate data, or compromise your entire system. For full details, see the Plugin Security Warning.

Open WebUI's plugins (Tools, Functions = Filters / Pipes / Actions) are not sandboxed scripts running in some restricted runtime. They are Python modules executed inside your Open WebUI process, with full access to the standard library, any pip package, the entire open_webui codebase, the live FastAPI app, and the database. The documented hooks (inlet, outlet, stream, pipe, action) are one way to use that access. They are not the only way.

This page documents what the loader really does and what that opens up, so you can build (or audit) plugins beyond the patterns shown on the per-type pages. It also lists the footguns that come with the territory.


How a plugin is loaded

A single loader in backend/open_webui/utils/plugin.py handles every plugin type:

  1. The plugin's Python source is read from the database.
  2. A fresh types.ModuleType is created and registered in sys.modules as function_{id} (or tool_{id}).
  3. The source is fed to exec(content, module.__dict__). Anything at module top level runs at this point.
  4. The loader looks for one entry-point class: Tools, Pipe, Filter, Action or Event. That class becomes the handle Open WebUI calls into.
  5. The module stays in sys.modules for the life of the process. Any side effect of step 3 (imports, monkey-patches, background tasks, route registrations) is now installed in the live application.

The entry-point class is the only thing the rest of Open WebUI cares about. Everything else in the file is yours.

When the module is re-executed

Inlet/outlet hooks pass load_from_db=True. The loader still serves from cache if the source has not changed, but it consults the database on every call to decide that. Stream hooks pass load_from_db=False and read straight from cache.

HookDB check per call?Module re-exec'd when?
inlet / outlet (Filter)yessource change between calls
stream (Filter)noonly when another hook re-loads it
Tools, Pipes, Actionsyes on dispatchsource change between calls

Practical consequences:

  • Editing a Filter via the editor takes effect on the next chat for inlet/outlet. Stream picks it up the next time an inlet or outlet triggers a reload.
  • Re-execution is not per-request, so module-top-level work is paid for once per content version, not once per chat. Top-level imports, patches, and singletons are fine.
  • Disabling or deleting a plugin removes it from the active set. It does not undo anything its module top level did. The module stays in sys.modules and any monkey-patches it installed in other modules stay applied until the process restarts.

What you actually have access to

From any hook (and from module top level):

  • The full open_webui.* package. Examples: from open_webui.models.chats import Chats, from open_webui.utils.middleware import process_chat_payload, from open_webui.models.config import Config.
  • The live FastAPI Request via __request__, which carries __request__.app (the FastAPI app), __request__.app.state (config, caches, handlers), and __request__.state (per-request scratch).
  • The reserved dunder args documented in Reserved Arguments: __user__, __metadata__, __model__, __request__, __event_emitter__, __event_call__, __features__, __body__, __id__, __oauth_token__, plus stream-only and per-hook extras.
  • Events documented in Events: emit anything to the frontend, or solicit a response from the user with event_call.
  • Any pip package via requirements: frontmatter, installed at load time (gated by ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS).
  • The Python stdlib, plus everything pip-installed in the container.

There is no sandbox, no allowlist, no capability system. The execution model is "this is Python, you are inside the server process".


Patterns

1. Mutate the per-request model dict from inlet

The __model__ you receive is the same dict object the rest of the request reads. Changing its keys from inlet changes how the rest of the pipeline behaves on this request. Example (the reasoning-content fix for DeepSeek / Kimi / MiMo):

class Filter:
    async def inlet(self, body: dict, __model__: dict = None) -> dict:
        # Flip the per-request model to the code path that emits
        # reasoning_content as a top-level field on assistant messages
        # during the native tool-call loop.
        if __model__ and __model__.get("provider") not in ("ollama", "llama.cpp"):
            __model__["provider"] = "llama.cpp"
        return body

Same trick works for any other field the middleware reads from the model dict: params, meta, custom keys you put there yourself and then read from another hook.

2. Monkey-patch a backend function

Because the plugin module can import open_webui.* and rebind module attributes:

import open_webui.utils.middleware as _mw

_original = _mw.process_chat_payload

async def _patched(request, form_data, user, metadata, model):
    # ...your wrapping logic, then delegate...
    return await _original(request, form_data, user, metadata, model)

_mw.process_chat_payload = _patched

Runs at module load (once per source version). The patch persists in sys.modules for the life of the process. Deleting or disabling the plugin does not revert the patch. The only clean rollback is a process restart.

Use sparingly. Cross-plugin interference is a real risk: if two plugins patch the same function the result depends on load order, which is not deterministic.

3. Serve a page or asset from a route

from fastapi.responses import HTMLResponse, Response

def _ensure_routes(app):
    if any(getattr(r, "path", None) == "/my/tool" for r in app.routes):
        return  # idempotency guard: the loader may re-exec on edits

    async def page():
        return HTMLResponse(
            "<html><head><link rel=stylesheet href=/my/tool.css></head>"
            "<body><div id=app></div><script src=/my/tool.js></script></body></html>"
        )

    async def script():
        return Response(MY_JS, media_type="application/javascript")

    async def style():
        return Response(MY_CSS, media_type="text/css")

    app.add_api_route("/my/tool", page, methods=["GET"])
    app.add_api_route("/my/tool.js", script, methods=["GET"])
    app.add_api_route("/my/tool.css", style, methods=["GET"])

Call from the first hook (or a system.startup.completed event) that has __request__.app / __app__. The idempotency guard matters: the loader may re-execute on edits, and add_api_route registers the same path twice without complaint.

__app__ is the real FastAPI app, so a route can return anything HTTP can carry: JSON, HTMLResponse, JavaScript, CSS, images, even a whole bundled SPA. You can host an entire standalone tool at its own URL, shipped as a plugin, with no frontend rebuild. This is how a plugin serves a ToS/consent page, an approval screen, an OAuth callback, a status dashboard, or a one-click-installer UI.

The line to not blur: a route is your own page. Your page can reference your own /my/tool.js and /my/tool.css and they load fine. It does not inject into the Open WebUI chat UI. Registering /my/tool.js does not make Open WebUI's own index.html load it; the user only gets your asset by navigating to your route.

To put your JS/CSS into the actual Open WebUI SPA (a button on real messages, a theme over the real app, a first-run tour over the real interface), the frontend has to reference your asset. That means writing into the custom-CSS / custom-JS loader seam at startup, which is the indirect, fragile path. A dedicated asset-injection primitive is what would formalize that seam so multiple plugins can do it safely. Route registration alone gets you pages, not injection.

warning

A registered route runs on the real application and is not authenticated unless you add an auth dependency yourself. Anything you expose at a path is reachable by anyone who can reach the server, so gate sensitive routes (admin checks, signed tokens) explicitly.

4. Spawn a background task

import asyncio

async def _loop(app):
    while True:
        # ...periodic work...
        await asyncio.sleep(60)

def _start_once(app):
    if getattr(app.state, "_my_plugin_started", False):
        return
    app.state._my_plugin_started = True
    asyncio.create_task(_loop(app))

The app.state flag makes it "once per process" rather than "once per source version". On a clean restart it starts fresh.

5. Stash state in app.state

async def inlet(self, body, __request__):
    cache = __request__.app.state.__dict__.setdefault("my_cache", {})
    # ...read/write cache...
    return body

Shared across requests and across plugins in the same process. There is no namespacing: pick a unique key.

6. Use event_emitter for arbitrary side effects in the UI

event_emitter accepts any event shape the frontend handles: status banners, source citations, file attachments, chat-message updates, toasts. You are not restricted to the events documented on the per-type pages. See Events for the full catalogue.

7. Prompt the user mid-handler with event_call

event_call is event_emitter that awaits a response. Show a form, a confirmation, an input dialog, and block until the user answers. Useful inside Tool methods that need a human in the loop, or Action handlers that confirm before executing.

8. Pipes as full provider replacements

A Pipe replaces the entire LLM call. Open WebUI hands you the request and asks for a response back. Nothing in the middleware constrains what you put in that response, so:

  • wrap an external API (any provider, any protocol),
  • route between providers based on request shape,
  • run an entire agent inside pipe() and stream the agent's output back,
  • skip any model entirely and return canned content.

A Pipe is the most powerful entry point precisely because the middleware steps out of the way.

9. Tools that do more than their docstring says

A Tools class's methods are exposed to the model as callable tools (their docstrings become JSON schema). The method body can do anything: call external APIs, emit UI events with __event_emitter__, stash data in app.state, monkey-patch on first call. The docstring is purely how the tool advertises itself to the model. The implementation is unconstrained.

10. Actions as arbitrary one-shot operations

Action renders a button on an assistant message. The handler runs server-side against the chat that the message belongs to, with a narrower dunder surface than Filters and Tools: __model__, __id__, __event_emitter__, __event_call__, __request__ and __user__, and nothing else. Use for "approve this", "re-run with...", "send to external system", or any one-off operation a user should be able to trigger from a specific message.


Concurrency and multiple instances

Open WebUI does not serialize your plugin code and gives you no exactly-once guarantee. Hooks and handlers run inside an async event loop and can overlap; in a multi-replica deployment the same logical work can run on several workers at once. Coordinating side effects that must happen at most once is your responsibility, not the platform's.

Two distinct levels:

  • Within one process. Async hooks interleave. If two requests hit the same hook concurrently and it does a read-modify-write on shared state (a module global, an app.state cache, a file), they can race. An asyncio.Lock (for coroutines) or threading.Lock (for the offloaded thread pool) serializes them — but only inside that one process.
  • Across replicas. Each replica is a separate process with its own sys.modules and its own app.state. A module global or in-process lock coordinates nothing between them. Two workers can run the same handler for the same event at the same moment. Anything that must fire once globally (sending an email, provisioning a resource, writing one audit row, calling a non-idempotent external API) will fire once per replica unless you guard it with shared state.

The shared-state primitive Open WebUI itself uses is Redis. In a correctly configured multi-instance deployment REDIS_URL is already set (it has to be, for websockets and shared caches to work across replicas). Use it to grab a distributed lock or to enforce an idempotency key:

from open_webui.utils.redis import get_redis_connection
from open_webui.env import REDIS_URL, REDIS_KEY_PREFIX

# decode_responses=True so values come back as str, not bytes.
_redis = get_redis_connection(REDIS_URL, decode_responses=True) if REDIS_URL else None


def run_once(key: str, ttl: int = 300) -> bool:
    """True for exactly one caller across all replicas within `ttl` seconds.

    Backed by Redis `SET key value NX EX ttl` — an atomic "set if not exists"
    with expiry, so exactly one worker gets the key and the rest get nothing.
    """
    if _redis is None:
        # No Redis means a single-process deployment: there is no other
        # replica to coordinate with, so this caller is the only one.
        return True
    return bool(_redis.set(f"{REDIS_KEY_PREFIX}:plugin:{key}", "1", nx=True, ex=ttl))

Then gate the side effect:

if run_once(f"signup-log:{event['actor']['id']}"):
    await log_signup(event)   # runs on exactly one replica

Pick the ttl and the key deliberately. The key is what "the same work" means (a user id, an event id, a date bucket); the ttl is how long that claim is remembered, so a retry or a slow duplicate within the window is suppressed but the same key can legitimately recur later.

For work that takes longer than a single atomic claim, Open WebUI ships a renewable lock helper, RedisLock in backend/open_webui/socket/utils.py (aquire_lock() / renew_lock() / release_lock()), which is what the websocket layer uses for its cleanup jobs. It is convenient, but it is an internal API and subject to the stability caveat in the Footguns below — the raw set(..., nx=True, ex=...) call above is plain redis-py and will not move.


Footguns

  • No sandboxing. Tools and Functions execute Python in your backend process as the backend user. The security policy (Rule 10) treats this as intended behaviour: granting Tool or Function creation permission is equivalent to granting shell access on the host. Treat plugin authors as administrators.
  • Stream hooks use a stale cache. Edits to a stream method only take effect after another hook (or a process restart) refreshes the module. If you edit a stream filter and the change does not seem to apply, trigger an inlet/outlet reload or restart.
  • Cross-plugin interference is not detected. Two plugins patching the same function, registering the same route, or writing to the same app.state key will collide. Load order is not deterministic. Prefer additive patterns (your own namespaces, wrappers that delegate) over destructive ones.
  • Disabling does not unload. The module stays in sys.modules and any module-level side effects stay installed. Restart the process to fully revert.
  • No concurrency control, no exactly-once. Open WebUI does not serialize your handlers and does not deduplicate work across replicas. Async hooks interleave within a process, and in a multi-replica deployment the same handler can run on every worker at once — a side effect that must fire once globally fires once per replica unless you coordinate it yourself. See Concurrency and multiple instances.
  • requirements: runs pip install on every replica at load. In multi-replica deployments set ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS=False and pre-install dependencies in your image; runtime installs race across workers and crash. See Scaling → Function/Tool Dependency Installation Crashes.
  • Internal APIs are not a stable public surface. open_webui.utils.*, the internal model classes, middleware helpers, and pretty much everything outside the documented dunder args and event types can rename, move, or change signatures between releases. If your monkey-patch breaks after an upgrade, that is on you to repair.
  • The Pipelines server is out of scope here. This page is about in-process plugins (Tools / Functions). The separate Pipelines server runs out-of-process and does not share sys.modules with Open WebUI: it cannot monkey-patch the main app, but it also is not constrained by it.

When this is the wrong tool

For anything you can express through the documented hooks (filters that mutate body, tools that call APIs and return results, actions that emit events), stay in the documented hooks. The patterns above are powerful, but their durability is shallow: cross-plugin interaction, upgrade compatibility, and rollback all degrade the moment you start patching module internals.

If your plugin needs an interface that does not exist yet, an upstream PR is more durable than a monkey-patch.

If you file a bug report against a code path that your plugin is monkey-patching, expect it to be closed. Reports must reproduce against an unmodified Open WebUI (Rule 6).

This content is for informational purposes only and does not constitute a warranty, guarantee, or contractual commitment. Open WebUI is provided "as is." See your license for applicable terms.