vitruvian-tool init <folder> starts a new tool from a copy of this one. The folder runs as it is: add it to Vitruvian.
This is samples/hello-python of the SDK, as it is. The conformance script runs it in the SDK’s tests.
vitruvian-tool.json:
{
"id": "dev.vitruvian.samples.hello-python",
"name": { "en": "Hello, in Python" },
"description": { "en": "Says hello, and copies the time." },
"version": "0.1.0",
"protocol": "1",
"publisher": "VitruvianSoftware",
"license": "MIT",
"icon": "terminal",
"uses": ["notify", "clipboard.write"],
"contributes": {
"commands": [
{
"id": "hello",
"title": { "en": "Say hello from Python" },
"icon": "hand.wave",
"surfaces": ["commandBar", "radial", "quickPanel", "shortcut"]
},
{
"id": "copyTime",
"title": { "en": "Copy the time" },
"icon": "clock",
"surfaces": ["commandBar"]
}
]
},
"activation": ["onCommand"],
"runtime": { "kind": "external", "exec": "bin/tool.py" }
}
bin/tool.py:
#!/usr/bin/env python3
# Copyright (c) 2026 VitruvianSoftware
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""A tool in plain Python, with nothing but the standard library.
It has two commands: `hello` asks the app to show a short message, and
`copyTime` asks the app to put the time on the clipboard. The protocol is one
JSON message per line: the app's on standard input, the tool's on standard
output. Anything else the tool has to say goes to standard error, which is its
log. `vitruvian-tool init` starts a new tool from this file.
"""
import datetime
import json
import sys
VERSION = "0.1.0"
# What the app sent while a command waited for an answer. It is handled once
# that command has been answered.
kept = []
asked = 0
class Refused(Exception):
"""The app answered a request with a failure."""
def __init__(self, error):
super().__init__(error.get("message", "refused"))
self.error = error
def send(message):
sys.stdout.write(json.dumps(message, separators=(",", ":")) + "\n")
sys.stdout.flush()
def read():
"""The app's next message; None once the app has closed the link."""
for line in sys.stdin:
if line.strip():
return json.loads(line)
return None
def ask(method, params):
"""Asks the app for something, and waits for its answer."""
global asked
asked += 1
send({"jsonrpc": "2.0", "id": asked, "method": method, "params": params})
while True:
message = read()
if message is None or message.get("method") == "shutdown":
sys.exit(0)
if "method" not in message and message.get("id") == asked:
if "error" in message:
raise Refused(message["error"])
return message["result"]
kept.append(message)
def hello():
ask("notify/hud", {"icon": "hand.wave", "message": "Hello from a Python tool"})
def copy_time():
now = datetime.datetime.now().strftime("%H:%M:%S")
ask("clipboard/writeText", {"text": now})
ask("notify/hud", {"icon": "clock", "message": "Copied " + now})
COMMANDS = {"hello": hello, "copyTime": copy_time}
def handle(message):
method, request = message.get("method"), message.get("id")
if method == "initialize":
if "1" not in message["params"]["protocolVersions"]:
error = {"code": -32602, "message": "this tool speaks protocol 1 only"}
return send({"jsonrpc": "2.0", "id": request, "error": error})
result = {"protocolVersion": "1", "toolVersion": VERSION}
return send({"jsonrpc": "2.0", "id": request, "result": result})
if method == "command/run":
command = COMMANDS.get(message["params"]["command"])
if command is None:
error = {"code": -32602, "message": "no such command"}
return send({"jsonrpc": "2.0", "id": request, "error": error})
try:
command()
except Refused as refused:
error = {
"code": -32010,
"message": "command refused",
"data": refused.error,
}
return send({"jsonrpc": "2.0", "id": request, "error": error})
return send({"jsonrpc": "2.0", "id": request, "result": {}})
if method == "shutdown":
sys.exit(0)
if method is not None and request is not None:
error = {"code": -32601, "message": "method not found"}
return send({"jsonrpc": "2.0", "id": request, "error": error})
# A notice this tool has no use for, or an answer nobody waits for.
return None
def main():
while True:
message = kept.pop(0) if kept else read()
if message is None:
return 0
handle(message)
if __name__ == "__main__":
sys.exit(main())