Example: a Notes Plugin

A complete small Plugin with a Python half that reads a Project file over RPC and a web half that shows it as a Console channel.

This Plugin shows the lines of the Project's notes.txt as a channel in the Console. It is small, but it uses each part of a Plugin: a manifest, a Python half that registers an RPC method, a web half that registers into another Plugin's slot, and tests for both halves. It was written from the Plugin development pages alone, and Studio's own test suite loads it, so the code on this page runs against the current API.

Files#

witwin_example_notes/
  witwin.toml        the manifest
  __init__.py        Python half: activate(ctx) registers one RPC method
  web/viewport.js    web half: a Console channel through the console.channels slot

The manifest#

id = "witwin.example_notes"
name = "Example Notes"
version = "0.1.0"
description = "Shows the Project's notes.txt as a Console channel."
icon = "notebook"
dependencies = []
# The web half registers into a slot through the proposed `slots` service,
# so it works only while loaded from a local path.
enabledApiProposals = ["slots"]
 
# Optional: without the Console this Plugin still activates; its channel
# waits in the slot and appears when the Console is enabled.
[[pluginDependencies]]
id = "studio.console"
version = "^1"
optional = true
 
[engines]
witwin = "^2.0.0"
 
[web]
root = "web"
 
[contributes.viewport.host]
entry = "viewport.js"
sdkVersion = "2.0.0"
 
[permissions]
frontend = true
project_files = "read"
 
[agent]
summary = "Shows the Project's notes.txt in the Console."

Three choices in this file shape the rest of the Plugin:

  • studio.console is an optional dependency. The Console is a system Plugin that a Project can turn off; this Plugin still activates without it, and its channel attaches when the Console is on.
  • enabledApiProposals = ["slots"] asks for the proposed slots service. Proposed services work only for a Plugin loaded from a local path, so this Plugin has to move to stable services before it can be released.
  • [permissions] discloses what the Plugin uses: a web half and read access to Project files. Studio shows this list before an installed copy of the Plugin runs; while you load it from a local path, it runs without that step.

Every key is explained in The manifest.

The Python half#

from __future__ import annotations
 
from pydantic import BaseModel
 
from witwin_server.api import PluginContext, ProjectFs, ProjectPathError, RpcError, get_logger
 
logger = get_logger("ExampleNotes")
 
# Third-party RPC names live under "<plugin id>.".
READ_METHOD = "witwin.example_notes.read"
 
 
class ReadParams(BaseModel):
    # A Project path (forward slashes, relative to the Project root), never an OS path.
    path: str = "notes.txt"
 
 
class Notes(BaseModel):
    lines: list[str]
 
 
def read_notes(fs: ProjectFs, params: ReadParams) -> Notes:
    """The lines of ``params.path``; no file means no lines, a bad path is ``invalid_params``."""
    try:
        text = fs.read_text(params.path) if fs.is_file(params.path) else ""
    except ProjectPathError as error:
        raise RpcError("invalid_params", str(error)) from error
    return Notes(lines=text.splitlines())
 
 
def activate(ctx: PluginContext) -> None:
    # The registry reads the pydantic models from these annotations.
    def read(_rpc_ctx, params: ReadParams) -> Notes:
        return read_notes(ctx.fs, params)
 
    # lock="none": reading a text file does not need to wait for Scene writes.
    ctx.rpc.register(READ_METHOD, read, scope="project.read", lock="none", idempotent=True)
    logger.debug("registered %s", READ_METHOD)

The Python half runs in the ProjectServer. It imports only witwin_server.api, the stable layer, which is what the boundary check enforces. It reads the file through ctx.fs with a Project path rather than an OS path, so the same code works for a local Project and a cloud Project. See Python API and Files and the network.

The web half#

export const sdkVersion = '2.0.0';
 
const PLUGIN_ID = 'witwin.example_notes';
const READ_METHOD = `${PLUGIN_ID}.read`;
 
/** @type {import('@witwin/host').PluginDefinition} */
const plugin = {
  id: PLUGIN_ID,
  inject: ['rpc', 'slots', 'commands'],
  activate(host) {
    const rpc = host.services.get('rpc');
    const listeners = new Set();
    /** @type {import('@witwin/workbench').ConsoleEntry[]} */
    let entries = [];
 
    const reload = async () => {
      const { lines } = await rpc.call(READ_METHOD, {});
      const now = new Date().toISOString();
      entries = lines.map((text, index) => ({ id: `note-${index}`, timestamp: now, level: 'info', source: 'notes', text }));
      listeners.forEach((listener) => listener());
    };
 
    host.services.get('slots').register('console.channels', {
      channel: { id: PLUGIN_ID, label: 'Notes' },
      entries: () => entries,
      subscribe: (listener) => {
        listeners.add(listener);
        return () => listeners.delete(listener);
      },
    });
    host.registerCommand({ id: `${PLUGIN_ID}.reload`, title: 'Reload notes', category: 'Notes' }, reload);
    // The first load must not fail activation when the server is still starting.
    return reload().catch(() => undefined);
  },
};
 
export default plugin;

The web half is a plain ES module: it bundles no React and imports nothing from Studio. It calls the Python half through the stable rpc service and adds its entry to console.channels, a slot the Console declares. Because the dependency on studio.console is optional, the entry waits while the Console is off. See Web API and Slots, events and URIs.

Load it in Studio#

  1. Add the folder as a local Plugin

    On the Plugins page, open the + Add menu, choose Add Local Plugin Path and pick the witwin_example_notes folder.

  2. Add a notes file

    Create notes.txt in the Project root, then run the Reload notes command. The Console shows a Notes channel with the file's lines.

Test it#

The Plugin has one test of each kind that Testing a Plugin describes. The Python tests load it with Studio's real loader through witwin_server.api.testing:

from pathlib import Path
 
from witwin_server.api import testing
 
PLUGIN_ROOT = Path(__file__).resolve().parent.parent  # the Plugin folder
READ_METHOD = "witwin.example_notes.read"
 
 
def test_example_manifest_declares_the_optional_console_dependency():
    manifest = testing.read_manifest(PLUGIN_ROOT / "witwin.toml")
    assert manifest.id == "witwin.example_notes"
    console = [dep for dep in manifest.plugin_dependencies if dep.get("id") == "studio.console"]
    assert console and console[0].get("optional") is True
 
 
def test_example_plugin_activates(monkeypatch):
    monkeypatch.syspath_prepend(str(PLUGIN_ROOT.parent))
    with testing.load_plugin(PLUGIN_ROOT) as plugin:
        assert plugin.error is None
        assert plugin.activated

Next steps#