Plugin tools
Add your own Python tools, global-only by design
Drop a file with a module-level TOOLS list in ~/.visvoai/tools/*.py and
restart — that's the entire integration surface.
# ~/.visvoai/tools/mytools.py
"""Plugin tools — drop this file in ~/.visvoai/tools/ and restart the CLI."""
import asyncio
import subprocess
from visvoai.cli.toolkit import make_cli_tool
def git_authors(days: int = 30) -> str:
"""Who committed to this repo recently, with commit counts."""
out = subprocess.run(
["git", "shortlog", "-sn", f"--since={days} days ago", "HEAD"],
capture_output=True, text=True, timeout=10)
return out.stdout or out.stderr
def tag_release(version: str, message: str = "") -> str:
"""Create an annotated git tag for a release (asks you first)."""
out = subprocess.run(
["git", "tag", "-a", version, "-m", message or version],
capture_output=True, text=True, timeout=10)
return out.stderr or f"tagged {version}"
async def wait_for_port(port: int, seconds: int = 15) -> str:
"""Wait until a local TCP port accepts connections (e.g. a dev server)."""
for _ in range(seconds * 2):
try:
_, w = await asyncio.open_connection("127.0.0.1", port)
w.close()
return f"port {port} is up"
except OSError:
await asyncio.sleep(0.5)
return f"ERROR: port {port} not up after {seconds}s"
TOOLS = [
make_cli_tool(git_authors, gate=None), # read → runs silently
make_cli_tool(tag_release, gate="approve"), # mutates → you confirm
make_cli_tool(wait_for_port, gate=None), # async just works
]That one file shows all three tool shapes the contract supports: a sync
read, a sync mutation, and an async def — awaited directly on the UI
event loop, no threads required.
The contract (make_cli_tool)
- Schema from type hints.
create_schema_from_functionbuilds the args schema straight from your function signature — no schema class to hand-write. - Description from the docstring,
inspect.cleandoc'd — the model reads it verbatim, so write it for the model, not for yourself. - Output is capped (1000 lines by default, override with
cap=) — one runaway tool call can't flood the context window. - Exceptions become
"ERROR: …"text, never a raised exception — a broken plugin degrades to a bad tool result, it can't crash a turn. - Gate is declared, not inferred:
gate=None— free; pure reads that never prompt.gate="approve"(default) — wrapped in the same user-confirmation gate every built-in mutating tool goes through.gate="self"— the function handles its own confirmation internally and is passed through ungated.
The gate is stored as tool metadata (tool.metadata["gate"]); graph
builders read that metadata to decide whether to wrap a tool in the
approval gate — there's no separate list of "which tools are dangerous" to
keep in sync elsewhere.
Global-only, on purpose
~/.visvoai/tools/ is scanned; <project>/.visvoai/tools/ is not. Loading
a .py file is code execution inside the CLI process — so only files you
put on disk are ever imported. A cloned repo has no path to inject Python
into your session. Projects that need declarative, repo-shareable
capability use MCP servers or [permissions] config instead.
A broken or malformed plugin file is logged and skipped, never fatal to startup.