The Python SDK for building extensions for Rune.
pip install rune-sdkRequires Python 3.11 or newer. The optional TUI adapters (Beyond extensions) are extras:
pip install "rune-sdk[rich]" # RichAdapter
pip install "rune-sdk[textual]" # TextualAdapterAn extension is a standalone program that Rune launches as a child process and connects to over a local unix socket. The SDK handles the handshake for you:
- You describe your extension with
Metadata: its id, version, and thePermissions it needs.serve_workspace_extensionwrites that to Rune over stdout and reads back the connection config from stdin. - Your setup coroutine receives a
Workspace, whose accessors (storage(),editor(),file_system(),window_manager(),notifications(),lsp(),llm(),debugger(), and more) are gRPC clients into the host that share onegrpc.aiochannel. Every call is gated on aPermissionyou declared, so an undeclared capability is rejected. - You wire capabilities, register commands and event handlers, and return.
serve_workspace_extensionowns the lifetime and serves until SIGINT/SIGTERM.
The example below is the wiring half of examples/snippets,
a complete extension that adds a snippets command for storing and reusing
text. main.py does only metadata and setup. The logic lives in a Snippets
handler typed against SDK clients so it stays testable.
# main.py
import logging
import sys
from examples.snippets.handler import extend
from rune_sdk.extension import Metadata, Permission, serve_workspace_extension
META = Metadata(
developer_id="rune-sdk-examples",
developer_email="your@email.com",
developer_key="1234",
extension_id="snippets",
extension_name="Snippets",
extension_version="0.1.0",
permissions=frozenset(
{
Permission.STORAGE,
Permission.EDITOR,
Permission.COMMANDS,
Permission.FILE_SYSTEM,
Permission.BROWSER_RESOURCE_OPENER,
Permission.BROWSER_WINDOW_MANAGER,
Permission.NOTIFICATIONS,
}
),
)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, stream=sys.stderr)
serve_workspace_extension(extend, META)# handler.py (the setup body: wire and register, then return)
import asyncio
from typing import Any
from rune_sdk.api import text
from rune_sdk.extension import Workspace
async def extend(w: Workspace, _config: dict[str, Any]) -> None:
s = Snippets(w) # holds w.storage(), w.editor(), w.file_system(), etc.
# React to editor events in a background task.
sub = await w.editor().subscribe_events(
text.EventType.SELECTION,
text.EventType.FLUSH,
text.EventType.CLOSE,
)
asyncio.create_task(s.handle_events(sub))
manual = text.CommandManual(
name="snippets",
summary="Insert, edit, copy, or delete reusable text snippets.",
synopsis="[insert|edit|copy|delete] <name>",
)
await w.register_command(manual, s.handle_command, s.complete)The Snippets handler (handler.py) drives the clients: snippets insert
writes a stored body at the cursor, snippets edit/copy open a scratch buffer
that is persisted when saved or closed, and snippets delete removes one, all
with tab completion over stored names. See the
examples/snippets README for the full
walkthrough and the extension SDK guide
for the complete authoring guide.
For a complete extension you can clone and adapt, see the Theme Builder extension. It provides a practical starting point for implementing your own Rune extension.
rune_sdk.extension handles the handshake and hands you a Workspace. Its
accessors return rune_sdk.api gRPC clients that share one channel:
| Package | Capability | Accessor |
|---|---|---|
rune_sdk.extension |
Handshake, Workspace, command registration |
(none) |
api.text |
Editor and text operations, editor events, commands | editor(), commands(), register_command() |
api.storage |
Document storage and persistence | storage() |
api.browser |
Windows, tabs, resource opening, notifications, interrupts | window_manager(), resource_opener(), notifications(), interrupter() |
api.workspace |
URI resolution, file operations, watchers, processes, ptys | file_system(), executor(), terminal() |
api.semantic |
Language-server operations (definition, references, rename, diagnostics, and more) | lsp() |
api.llm |
LLM access (models, token counting, messages) | llm() |
api.debug |
Debug-adapter (DAP) control | debugger() |
api.syntax |
Tree-sitter structural search and queries | parser() |
api.config |
Workspace configuration | config() |
Every accessor is gated on the matching Permission you declared in
Metadata, so an undeclared capability is rejected by the host. Each API
package ships a testing module with fake-service harnesses; the vendored
.proto definitions live in proto/ and the generated stubs in
rune_sdk._pb.
Rune launches an extension as a child process and connects to it over a private
local socket, so you never run your script directly during development. Start it
in a workspace from the Rune console
with the extensions command, pointing it at your interpreter and entry script:
extensions start snippets python3 /path/to/examples/snippets/main.py --config '{"key":"value"}'
extensions start <id> <cmdAndArgs> [--config <json>] runs your program as the
extension id, so you can iterate without publishing a package. The rest of the
lifecycle is managed from the same console command:
extensions statuslists every workspace extension with its status, pid, and uptime.extensions info <id>shows detailed state, including restart counts.extensions logs <id> [--tail <N>]prints what your extension wrote to stderr, where startup failures and crashes show up. Configureloggingto write there.extensions restart <id>relaunches with the current config, the fastest way to pick up a change.extensions stop <id>stops it.
Permissions are enforced at two layers: a call into a capability you did not
declare in Metadata.permissions is rejected outright, and the first time your
extension uses a declared capability Rune prompts the user to allow or deny it.
If a capability seems unreachable, check authorizer list for a lingering
deny always decision and clear it with authorizer revoke <permission>. See
the extensions guide for the full
workflow.
You distribute an extension as a Rune package: a gzipped tar archive that Rune
extracts into the user's data directory and installs with pkg install. A
package holds three kinds of content:
- Executables. Any file with the execute bit set (by convention under
bin/) is copied onto thePATHby its base name. An executable can be a compiled binary or a script with a shebang, so a launcher script that runs your entry module works here. - Payload. Any other files stay in the extracted package directory, reachable
at
$RUNE_DATADIR/lib/$RUNE_PKG_ID. Bundle your Python sources (and, if you vendor them, dependencies) here. - A config overlay. A top-level
config.yamlis deep-merged into the user's config at install time. This is how your extension registers itself: add anextensions.<id>entry with apathto your launcher and optionalconfigdefaults.
# config.yaml
extensions:
snippets:
path: '/bin/snippets'
config:
# defaults surfaced in the user's config, editable like any other settingRune launches the executable named by path and passes the config block to
your extend coroutine as its config map. Because a Python extension needs an
interpreter, ship a small launcher script (with a shebang) under bin/ that
runs your entry module from the payload, and rely on a Python present on the
target or a bundled interpreter. A portable script can ship as a single archive;
anything native (a bundled interpreter) needs one archive per OS and
architecture. See the packages guide
for the full format and how to publish so pkg install snippets resolves.
Extensions are the richest way to add new functionality to Rune, but if all you need is simple one-shot functionality that can be packaged as a TUI, you can also use this SDK for that.
An extension can serve cell-grid text UIs into Rune windows over a
bidirectional handler stream. Window content is drawn by handlers (defined in
rune_sdk.tui):
- Component: a basic UI element that can be drawn and resized.
- Handler: a component that can also handle keyboard and mouse events and manage the cursor/selection.
- Handler stream: the host drives installed handlers over a bidirectional gRPC stream (resize, draw, handle, cursor, selection, close).
serve_handler installs a handler in a window created according to a placement
(SplitPlacement, TabPlacement, FloatingPlacement, BarPlacement,
WindowContentPlacement) and serves it until the host closes it.
Instead of a widget library, the SDK adapts the Python TUI ecosystem
(rune_sdk.adapt):
RichAdapterhosts any Rich renderable (tables, markdown, syntax, tracebacks) as a Component. Render-only: swap content withset_renderable, which fireson_dirtyso the embedder can publish a redraw interrupt.TextualAdapterhosts a full interactive Textual app headless as a Handler, translating term events to Textual key/mouse events and signaling repaints throughon_dirty.
See examples/hello_rich for a live-updating
Rich table served into a window split, and
examples/textual_app for an interactive
Textual repl (Input + Button + DataTable) served as a browser tab.
If you only need a scripted, one-shot integration rather than a full extension,
runectl is the fast path. It is Rune's command-line companion: a single
executable that reaches into a running workspace to drive the editor, windows,
storage, language servers, and more from a shell script, a Makefile, or an
editor hook. It works regardless of the language you write extensions in.
Install it from the Rune console:
pkg install runectl
Once installed, runectl is on your PATH inside Rune's terminals and targets
the current workspace automatically. It is also the fastest way to sample the
data Rune returns for each API while you build an extension: run the equivalent
runectl command with --format json to see the exact response shape before
writing code against it. See the
runectl guide for the full command
reference.
Common Make targets:
make generate # Regenerate protobuf/gRPC code
make lint # ruff check, ruff format --check, mypy --strict
make test # pytest
make format # ruff format + autofixRun the full pre-commit validation before committing:
make generate && make lint && make testTests follow a table-driven approach with pytest.mark.parametrize.
Handlers are tested with the rune_sdk.tui.testing harness
(draw_handler, run_handler_sequence) and extensions end to end against
in-process fake hosts (see tests/e2e).
Apache License 2.0. See LICENSE.