Skip to content

Authoring a Modular Text Editor Python plugin with Claude

Audience: an AI assistant (Claude / ChatGPT / etc.). This page is a self-contained instruction set: hand it to the assistant along with the task ("please write me a plugin that does X"), and the assistant should produce a complete, correct, working plugin folder without needing to read the rest of the docs.

If you are a human trying to write a plugin by hand, start here instead — this page is optimized for AI consumption and skips the friendly examples.


1. What you are producing

A folder with exactly two files (plus optional extras):

<plugin-id>/
├── plugin.json      # required
└── main.py          # required

Return the folder as a code block per file. The user either drops the folder into the editor's plugins directory, or zips the folder and renames the archive <name>.mteplugin, then installs it via Preferences → Plugins → Install…. Both paths need a restart to load.

The two files are described exhaustively below. Follow the rules literally — the host performs a static probe and rejects plugins that break them.


2. plugin.json — canonical template

{
  "id":          "org.example.<short-name>",
  "name":        "<Human Display Name>",
  "version":     "1.0.0",
  "vendor":      "<Author or Team>",
  "description": "<One sentence about what it does.>",
  "apiVersion":  1,
  "entry":       "python",
  "module":      "main",
  "order":       900,
  "menus": [
    { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
  ]
}

Hard rules

  1. "apiVersion" must be 1 (the current host version).
  2. "entry" must be "python" — nothing else routes to this backend.
  3. "module" must be "main" unless there is an explicit reason to name the module differently — and never a dotted path (Phase 1 supports flat module names only).
  4. "id" must be reverse-DNS style (org.<vendor>.<name>) and globally unique across the user's installed plugins. If the user did not specify a vendor, use org.example..
  5. menus[] must contain at least one entry whose title matches the leftmost segment of every menu path you use in main.py. Prefer "Plugins" for anything user-facing that doesn't obviously belong in a core menu.

Optional fields

  • "pyRequires": [] — an array of pip requirement strings (e.g. ["requests>=2.31"]). When non-empty, the editor provisions a per-plugin venv on first launch (pip install — needs network that one time) and runs the plugin under it. Prefer the standard library when it does the job (no provisioning delay, works offline); declare pyRequires when the task genuinely needs a third-party package, and tell the user the first launch will pause to install it.
  • "permissions": [] — coarse capability declarations (e.g. ["network"]), shown read-only in the Plugins page. Declare "network" when the plugin makes outbound calls; it is honest labelling for the user, not an enforcement.

Values you should NOT invent

  • Any field not listed above. The host ignores unknown keys; adding them is a maintenance risk.

3. main.py — canonical skeleton

"""<Short description of what this plugin does.>"""
import mte  # noqa: F401  -- pulls in dataclasses for event payloads


def register(ctx):
    # Register commands, menu items, and event subscriptions here.
    # Everything below is illustrative -- delete what you don't need.

    @ctx.commands.command(id="<id>.<verb>",
                          title="<Human Label>",
                          shortcut="Ctrl+Alt+<letter>",   # optional
                          category="<Group>")             # optional
    def _handler():
        ctx.status.show("hello", timeout_ms=2000)
        ctx.log.info("<id>: handler ran")

    ctx.menus.add_item("Plugins/<Human Label>/<Item>", "<id>.<verb>")

Hard rules

  1. register(ctx) MUST be a top-level function. The static probe requires a line beginning with def register( at column 0. If you nest it inside a class, decorate it at module scope, or make it an async def, the plugin is silently rejected at discovery time.
  2. Import from mte when you subscribe to events — the typed dataclasses (mte.DocumentSaved, etc.) come from that package. import mte at the top is safe even if you don't use it directly; the # noqa: F401 above prevents lint noise.
  3. Never print(). The worker's stdout is the RPC channel; writing to it corrupts the protocol. Use ctx.log.info / warning / error.
  4. Never import anything you didn't declare. Beyond the standard library and mte, every third-party import must appear in plugin.json's pyRequires (the editor pip-installs it into the plugin's venv on first launch) or be vendored inside the plugin folder. An undeclared import raises ModuleNotFoundError on the user's machine.
  5. Command / event handlers may block briefly — they run on the worker's thread pool, so sync calls into ctx.editor / ctx.settings / ctx.data_dir are legal. But completion provider callbacks (@ctx.completions.provider(...)) MUST NOT block: they run on the event loop with a ~30 ms deadline. No I/O, no calls into ctx.editor.*, cache-only. Build indexes elsewhere (e.g. on document.opened) and answer from a dict.
  6. Do not import Qt, PySide, PyQt, or Scintilla. They are not available in the worker.
  7. Command ids must be prefixed with your plugin's namespace to avoid collisions with core / other plugins. <id>.<verb> above; e.g. wordcount.count, hello.sayHi.
  8. Menu paths must start with a top-level menu you also declared in plugin.json's menus[]. Convention: "Plugins/<Plugin Name>/<Verb>".

4. Complete API — everything on ctx

You have twelve services (Phases 1 + 2 + 3). Anything not listed here does not exist — do not invent services. (Per-plugin venvs are declared via pyRequires, not a ctx service; the streaming/cancel protocol substrate has no plugin-facing API yet.)

ctx.commands

@ctx.commands.command(*, id, title, shortcut="", category="")
def _handler(): ...

Decorator. Registers a command and binds _handler (a nullary function returning None). Exceptions in _handler are caught by the worker and written to stderr; they do not crash the editor.

ctx.menus

ctx.menus.add_item(path, command_id)                # -> handler_id (str)
ctx.menus.add_separator(path)                       # -> handler_id (str)
ctx.menus.set_item_checked(handler_id, checked)     # -> None

path is "Top/Sub/…/Label". Intermediate submenus are created on-demand.

set_item_checked turns the item into a checkable menu entry and sets its mark. Use it for persistent on/off toggles. First call promotes; subsequent calls just flip. Silent no-op on unknown ids.

ctx.events

Six topics, each with a sugar decorator and a raw form.

Sugar Raw topic Payload
@ctx.events.on_document_opened document.opened mte.DocumentOpened(document: int, path: str)
@ctx.events.on_document_closed document.closed mte.DocumentClosed(document: int)
@ctx.events.on_document_saved document.saved mte.DocumentSaved(document: int)
@ctx.events.on_selection_changed selection.changed mte.SelectionChanged(document: int)
@ctx.events.on_active_document_changed document.activeChanged mte.ActiveDocumentChanged(document: int)
@ctx.events.on_app_about_to_quit app.aboutToQuit mte.AppAboutToQuit()

Raw form:

handler_id = ctx.events.subscribe("<topic>", callback)
ctx.events.unsubscribe(handler_id)  # to unbind

Do not invent new topics — the raw form validates them and raises ValueError on anything not in the table.

ctx.log

ctx.log.trace  (str)
ctx.log.debug  (str)
ctx.log.info   (str)
ctx.log.warning(str)
ctx.log.error  (str)

Fire-and-forget. Lines are prefixed [python:<plugin.id>] in the host's diagnostics log.

ctx.status

ctx.status.show(message: str, timeout_ms: int = 0, level: str = "info")

level must be "info" / "warning" / "error". timeout_ms=0 means the host picks a default.

ctx.editor (Phase 2)

Every method is synchronous: it blocks the calling thread. Fine from a def command / event callback (the worker dispatches sync handlers on a thread pool); illegal from async def handlers and from ctx.completions providers (which run on the event loop directly).

Documents are referenced by opaque int ids (0 = no document).

doc     = ctx.editor.active_document()
docs    = ctx.editor.open_documents()
ctx.editor.set_active_document(doc)
path    = ctx.editor.file_path(doc)                 # str or None

text    = ctx.editor.text(doc)
slice   = ctx.editor.text_range(doc, start, end)    # [start, end) bytes
ctx.editor.set_text(doc, "new text")
n       = ctx.editor.length(doc)

sel     = ctx.editor.selection_range(doc)           # mte.MatchRange
s       = ctx.editor.selected_text(doc)
ctx.editor.replace_selection(doc, "hi")
ctx.editor.set_selection(doc, mte.MatchRange(4, 9))

pos     = ctx.editor.caret(doc)                     # mte.EditorPosition
ctx.editor.set_caret(doc, mte.EditorPosition(line=5, column=0))

new_doc = ctx.editor.open_file("/path/to/file")     # 0 on failure
ok      = ctx.editor.save_document(doc)
dirty   = ctx.editor.is_modified(doc)

hit     = ctx.editor.find_in_range(doc, "TODO", 0,
             ctx.editor.length(doc),
             mte.SearchOptions(match_case=True))
new_end = ctx.editor.replace_target(doc, hit, "DONE",
             mte.SearchOptions())

style   = ctx.editor.indentation(doc)               # IndentationStyle
ctx.editor.set_indentation(doc,
    mte.IndentationStyle(use_tabs=False, width=2))
lang    = ctx.editor.colorizer_id(doc)              # "python", "cpp", ...

mte.SearchOptions.mode: "normal" / "extended" / "regex". mte.SearchOptions.direction: "forward" / "backward".

ctx.settings / ctx.app_settings (Phase 2)

Typed key/value store scoped per plugin (ctx.settings) plus a read-only view of app-wide settings (ctx.app_settings). Type is inferred from the default's Python type.

ctx.settings.set("wrap", True)
ctx.settings.set("count", 42)
ctx.settings.set("theme", "dark")

wrap  = ctx.settings.get("wrap",   False)     # bool
count = ctx.settings.get("count",  0)         # int
theme = ctx.settings.get("theme",  "light")   # str

ctx.settings.contains("wrap")
ctx.settings.remove("stale")

# App-wide (read-only)
tw = ctx.app_settings.get("editor.tabWidth", 4)

ctx.app_settings.set/remove/sync raise RuntimeError. Bool must be checked before int in your own logic when reading (Python isinstance(True, int) == True).

ctx.completions (Phase 2)

@ctx.completions.provider(languages=["python"])
def _complete(req: mte.CompletionRequest):
    return [mte.CompletionItem(text=w, kind=mte.KIND_KEYWORD)
            for w in ("hello", "hello_world")
            if w.startswith(req.prefix)]

req fields: document, prefix, position, language_id, manual. Kinds: KIND_KEYWORD / KIND_WORD / KIND_SYMBOL / KIND_SNIPPET / KIND_UNKNOWN. Return list[CompletionItem], plain strings, or dicts. The callback MUST return within ~30 ms or the host drops your results for that keystroke — no I/O, no ctx.editor.text(...), cache-only. Build indexes elsewhere (e.g. on document.opened).

ctx.data_dir() / ctx.diagnostics_dir() (Phase 2)

p = ctx.data_dir()   # pathlib.Path -- writable, host-created

Use data_dir() for caches and indexes. diagnostics_dir() is read-only (the crash reporter writes there).

ctx.docks — declarative dock panels (Phase 3)

from mte.ui import Column, Row, Label, LineEdit, Checkbox, Button

def _on_needle_changed(value: str) -> None:
    ctx.log.info(f"needle -> {value!r}")

needle_handler = ctx.docks.on_change(_on_needle_changed)

panel_id = ctx.docks.add_panel(
    id="myplugin.panel",     # returned; use it for ctx.ui.get / ctx.ui.set
    title="My Plugin",
    area="right",            # "left" / "right" / "top" / "bottom"
    form=Column([
        Label(text="Search:"),
        LineEdit(id="needle", placeholder="text...",
                 binds="settings:lastNeedle",
                 on_change=needle_handler),
        Checkbox(id="cs", label="Case sensitive",
                 binds="settings:caseSensitive"),
        Row([Button(text="Run", on_click="myplugin.run")]),
    ]),
)

Build the form spec with the mte.ui helpers. The control vocabulary is fixed: Label / Button / Checkbox / LineEdit / NumberSpin / ComboBox / List inside Column / Row / Group.

  • binds="settings:<key>" — the control's value auto-persists through ctx.settings (same store as ctx.settings.set).
  • on_click="<cmd.id>" — Button only; invokes a registered command.
  • on_change=<handler> — allocate via ctx.docks.on_change(callback); fires on user edits.

on_change callbacks run on the worker's event loop (NOT the thread pool). Keep them cheap — no editor calls, no long work. Mutate a small in-memory state, log, or trigger a command; do heavy work from the command.

ctx.settings_page — declarative Preferences pages (Phase 3)

from mte.ui import Column, Checkbox, NumberSpin

ctx.settings_page.register(
    category="Plugins/My Plugin",     # slash-separated tree path
    title="My Plugin",
    form=Column([
        Checkbox(id="verbose", label="Verbose logging",
                 binds="settings:verbose"),
        NumberSpin(id="cap", default=100, min=1, max=10_000,
                   binds="settings:cap"),
    ]),
)

Same builders, same binds semantics. Two controls sharing a binds="settings:<key>" mirror each other in real time — the dock panel and the Preferences page can both hold a checkbox for the same setting, and toggling one flips the other.

Phase 3 caveat

Values persist eagerly on every user edit — Cancel does not roll back what the user changed. This will improve in a future refinement.

ctx.ui.get / ctx.ui.set — runtime access to panel controls (Phase 3)

# Read the current value; returns str or None.
current = ctx.ui.get(panel_id, "needle")

# Write. Returns bool (False when panel/control unknown, or when the
# value can't be coerced to the target type).
ctx.ui.set(panel_id, "needle", "hello")
ctx.ui.set(panel_id, "cs", True)          # bool -> "true"/"false"

get returns the primary value as a string:

  • Label / Button / LineEdit / ComboBox → text
  • Checkbox → "true" / "false"
  • NumberSpin → integer as decimal
  • List → currently selected item's text (empty string if none)

Host-driven set is silent: it does NOT re-fire the control's on_change, so you can update your own widgets from a command handler without infinite loops.


5. Full worked example — wordcount plugin

Copy this verbatim and change the id, name, and titles to fit your case. It exercises every Phase 1 service.

wordcount/plugin.json
{
  "id":          "org.example.wordcount",
  "name":        "Word Count",
  "version":     "1.0.0",
  "vendor":      "Example, Inc.",
  "description": "Counts words the user has typed since launch and logs saves.",
  "apiVersion":  1,
  "entry":       "python",
  "module":      "main",
  "order":       900,
  "menus": [
    { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
  ]
}
wordcount/main.py
"""Reports a session word-count total via a menu command and logs saves."""
import mte  # noqa: F401

# Module-level state is fine -- one worker per plugin, one register() call.
_state = {"saves": 0}


def register(ctx):

    ctx.log.info("wordcount: register() ran")

    @ctx.commands.command(id="wordcount.report",
                          title="Report save count",
                          shortcut="Ctrl+Alt+W",
                          category="Text")
    def _report():
        n = _state["saves"]
        ctx.status.show(f"{n} save(s) this session", timeout_ms=2500)
        ctx.log.info(f"wordcount: user asked for count; {n} saves so far")

    ctx.menus.add_item("Plugins/Word Count/Report save count",
                       "wordcount.report")

    @ctx.events.on_document_saved
    def _saved(ev: mte.DocumentSaved):
        _state["saves"] += 1
        ctx.log.info(f"wordcount: doc {ev.document} saved -- total now {_state['saves']}")

    @ctx.events.on_app_about_to_quit
    def _quit(_ev):
        ctx.log.info(f"wordcount: shutting down after {_state['saves']} saves")

Expected user flow: open the editor, save a file three times, then click Plugins → Word Count → Report save count. The status bar shows 3 save(s) this session. The log records every step.


6. Common tasks — copy-paste recipes

Each recipe is a body-only snippet: paste inside def register(ctx):.

Add a menu command

@ctx.commands.command(id="mine.hello", title="Say Hi", shortcut="Ctrl+Alt+Y")
def _hi():
    ctx.status.show("hi")

ctx.menus.add_item("Plugins/Mine/Say Hi", "mine.hello")

React to save

@ctx.events.on_document_saved
def _on_save(ev):
    ctx.log.info(f"saved doc {ev.document}")

Warn on quit

@ctx.events.on_app_about_to_quit
def _on_quit(_ev):
    ctx.log.warning("mine: last chance to persist state")

Log at a specific level

ctx.log.trace  ("very fine")
ctx.log.debug  ("dev")
ctx.log.info   ("routine")
ctx.log.warning("odd")
ctx.log.error  ("wrong")
ctx.menus.add_separator("Plugins/Mine")
@ctx.commands.command(id="mine.a", title="Do A")
def _a(): ...

@ctx.commands.command(id="mine.b", title="Do B")
def _b(): ...

ctx.menus.add_item("Plugins/Mine/Do A", "mine.a")
ctx.menus.add_item("Plugins/Mine/Do B", "mine.b")

Persistent on/off toggle with a visible check mark

KEY = "verbose"

item = ctx.menus.add_item("View/Verbose Logging", "mine.toggleVerbose")
# Seed the mark from the persisted state so it matches on launch.
ctx.menus.set_item_checked(item, ctx.settings.get(KEY, False))

@ctx.commands.command(id="mine.toggleVerbose", title="Verbose Logging")
def _toggle():
    on = not ctx.settings.get(KEY, False)
    ctx.settings.set(KEY, on)
    ctx.menus.set_item_checked(item, on)

Side panel with a text input, checkbox, and two buttons

from mte.ui import Column, Row, Label, LineEdit, Checkbox, Button

def _on_needle(value): ctx.log.info(f"needle={value!r}")
needle_h = ctx.docks.on_change(_on_needle)

panel = ctx.docks.add_panel(
    id="mine.panel", title="Mine", area="right",
    form=Column([
        Label(text="Search:"),
        LineEdit(id="needle", placeholder="text...",
                 binds="settings:needle", on_change=needle_h),
        Checkbox(id="cs", label="Case", binds="settings:cs"),
        Row([Button(text="Run",  on_click="mine.run"),
             Button(text="Clear", on_click="mine.clear")]),
    ]),
)

@ctx.commands.command(id="mine.run", title="Run")
def _run():
    needle = ctx.ui.get(panel, "needle") or ""
    cs = ctx.ui.get(panel, "cs") == "true"
    ctx.status.show(f"searching for {needle!r} (case={cs})")

@ctx.commands.command(id="mine.clear", title="Clear")
def _clear():
    ctx.ui.set(panel, "needle", "")

Preferences page mirroring a dock's settings

from mte.ui import Column, Checkbox

# Both controls bind to the SAME settings key -- toggling one
# flips the other in real time.
ctx.docks.add_panel(id="mine.panel", title="Mine", area="right",
    form=Column([Checkbox(id="cs", label="Case",
                          binds="settings:cs")]))

ctx.settings_page.register(category="Plugins/Mine", title="Mine",
    form=Column([Checkbox(id="cs", label="Case sensitive",
                          binds="settings:cs")]))

7. Anti-patterns — do NOT do these

Wrong:

def _handler():
    print("did the thing")   # <-- corrupts the RPC channel

Right:

def _handler():
    ctx.log.info("did the thing")

register inside a class

Wrong:

class Plugin:
    def register(self, ctx):   # <-- static probe rejects this
        ...

Right:

def register(ctx):
    ...

async def register

Wrong:

async def register(ctx):   # <-- probe accepts but runtime never awaits
    ...

Right:

def register(ctx):
    ...

Third-party imports without shipping the wheel

Wrong:

import requests   # <-- ImportError on most user machines

Right (Phase 1): stick to the standard library. If you truly need something third-party, ship the wheel inside the plugin folder and extend sys.path in the first lines of main.py; see Packaging.

Qt imports

Wrong:

from PySide6 import QtWidgets   # <-- not available; no Qt in the worker

Right: no Qt anywhere. All UI you can contribute is through ctx.menus, ctx.status, and (from Phase 3) declarative dock/settings panels. Never a raw widget.

Blocking in a handler

Wrong:

def _handler():
    time.sleep(30)   # <-- stalls the worker for 30s
    result = requests.get("https://slow.example")

Right (Phase 1): defer to a thread, communicate via ctx.log:

def _handler():
    import threading
    def _work():
        time.sleep(30)
        ctx.log.info("done")
    threading.Thread(target=_work, daemon=True).start()
    ctx.status.show("started")

Wrong:

"menus": [ { "title": "Plugins", "barPriority": 570 } ]
ctx.menus.add_item("Tools/Mine/Do", "mine.do")   # <-- Tools ≠ Plugins

Right: keep the top-level segment in the path aligned with menus[]. Use "Plugins/Mine/Do" above.

Storing ctx for later use

Wrong:

_ctx = None

def register(ctx):
    global _ctx
    _ctx = ctx        # <-- fine for the lifetime of this worker,
                      #     but confusing and unnecessary

def some_other_function():
    _ctx.log.info("...")   # <-- called from where?

Right: capture the services you need in the closure that uses them. register is where wiring happens; everything else is a handler that already has access via closure.


8. Debugging checklist for the user

Include this at the end of your response when you produce a plugin, so the user knows what to check if it doesn't work:

  1. Launch the editor from a shell where a Python 3.9+ interpreter is on PATH (or MTE_PYTHON points at one).
  2. Look for PluginHost: Python plugin backend enabled; interpreter=… in the log. If missing, jump to Troubleshooting.
  3. Look for [python:<your.id>] register() ran (if you followed the wordcount example) — proves register executed.
  4. If the menu item is missing, check the log for PythonPluginBackend: skipping '<id>': … explanations.
  5. If clicking the menu does nothing, check the log for [worker stderr] … lines — Python tracebacks land there.

9. Constraints to reiterate at the end of every response

When you produce a plugin, always tell the user:

  • Install it either way:
    • zip the folder's contents as <name>.mteplugin and use Preferences → Plugins → Install… (validates the package before touching anything), or
    • drop the folder into the editor's per-user plugins directory (%LOCALAPPDATA%/MTE/Plugins/ on Windows; ~/Library/Application Support/MTE/Plugins/ on macOS; ~/.local/share/MTE/Plugins/ on Linux).
  • Restart the editor.
  • Set MTE_PYTHON if the log says "no Python interpreter found".
  • Report back the [python:<id>] … log line so we can confirm it ran.

10. Source of truth

If any statement on this page conflicts with what the Python client package actually implements, the package wins. It ships next to the editor at <exe>/Python/mte/ — read that source before deciding a behaviour. The C++ side of the protocol is in Components/PluginHost/Src/PyServiceBridge.cpp and the wire spec is in Python/PROTOCOL.md.