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):
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¶
"apiVersion"must be1(the current host version)."entry"must be"python"— nothing else routes to this backend."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)."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, useorg.example..menus[]must contain at least one entry whosetitlematches the leftmost segment of every menu path you use inmain.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); declarepyRequireswhen 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¶
register(ctx)MUST be a top-level function. The static probe requires a line beginning withdef register(at column 0. If you nest it inside a class, decorate it at module scope, or make it anasync def, the plugin is silently rejected at discovery time.- Import from
mtewhen you subscribe to events — the typed dataclasses (mte.DocumentSaved, etc.) come from that package.import mteat the top is safe even if you don't use it directly; the# noqa: F401above prevents lint noise. - Never
print(). The worker's stdout is the RPC channel; writing to it corrupts the protocol. Usectx.log.info / warning / error. - Never
importanything you didn't declare. Beyond the standard library andmte, every third-party import must appear inplugin.json'spyRequires(the editor pip-installs it into the plugin's venv on first launch) or be vendored inside the plugin folder. An undeclared import raisesModuleNotFoundErroron the user's machine. - Command / event handlers may block briefly — they run on the
worker's thread pool, so sync calls into
ctx.editor/ctx.settings/ctx.data_dirare 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 intoctx.editor.*, cache-only. Build indexes elsewhere (e.g. ondocument.opened) and answer from a dict. - Do not import Qt, PySide, PyQt, or Scintilla. They are not available in the worker.
- 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. - Menu paths must start with a top-level menu you also declared in
plugin.json'smenus[]. 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¶
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¶
Fire-and-forget. Lines are prefixed [python:<plugin.id>] in the
host's diagnostics log.
ctx.status¶
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)¶
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 throughctx.settings(same store asctx.settings.set).on_click="<cmd.id>"— Button only; invokes a registered command.on_change=<handler>— allocate viactx.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.
{
"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 }
]
}
"""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¶
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")
Menu separator¶
Multiple related commands¶
@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¶
print() for diagnostics¶
Wrong:
Right:
register inside a class¶
Wrong:
Right:
async def register¶
Wrong:
Right:
Third-party imports without shipping the wheel¶
Wrong:
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:
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")
Menu path whose top segment isn't in plugin.json¶
Wrong:
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:
- Launch the editor from a shell where a Python 3.9+ interpreter is on
PATH(orMTE_PYTHONpoints at one). - Look for
PluginHost: Python plugin backend enabled; interpreter=…in the log. If missing, jump to Troubleshooting. - Look for
[python:<your.id>] register() ran(if you followed thewordcountexample) — provesregisterexecuted. - If the menu item is missing, check the log for
PythonPluginBackend: skipping '<id>': …explanations. - 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>.mtepluginand 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).
- zip the folder's contents as
- Restart the editor.
- Set
MTE_PYTHONif 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.