VisvoAI CLI Docs
Customizing

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_function builds 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.

On this page