Skip to main content
This tutorial walks through building a pi extension that manages Vers VMs and transparently routes the agent’s tools to remote machines. By the end, you’ll have an extension that creates VMs, runs commands over SSH, and overrides the built-in bash tool — all in ~80 lines.

What You’ll Learn

  • Registering custom tools that the LLM can call
  • Calling the Vers API from an extension
  • SSH command execution over TLS
  • Overriding built-in tools to route execution to VMs

Prerequisites

  • Pi coding agent installed
  • Vers account with VERS_API_KEY environment variable set
  • ssh and openssl on your PATH

Step 1: Create the Extension

Create ~/.pi/agent/extensions/my-vers.ts:
// ~/.pi/agent/extensions/my-vers.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";

export default function myVersExtension(pi: ExtensionAPI) {
  const API_KEY = process.env.VERS_API_KEY || "";
  const BASE = "https://api.vers.sh/api/v1";
  let activeVmId: string | undefined;

  async function versApi(method: string, path: string, body?: unknown) {
    const res = await fetch(`${BASE}${path}`, {
      method,
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
      body: body ? JSON.stringify(body) : undefined,
    });
    if (!res.ok) throw new Error(`Vers API ${res.status}: ${await res.text()}`);
    return res.headers.get("content-type")?.includes("json") ? res.json() : undefined;
  }

  // We'll add tools in the next steps
}
Test it loads: run pi -e ~/.pi/agent/extensions/my-vers.ts. Pi should start with no errors.

Step 2: Add VM Creation

Add this inside the myVersExtension function:
  pi.registerTool({
    name: "create_vm",
    label: "Create VM",
    description: "Create a new Vers VM and wait for it to boot",
    parameters: Type.Object({}),
    async execute() {
      const r = await versApi("POST", "/vm/new_root?wait_boot=true", {
        vm_config: { mem_size_mib: 4096, fs_size_mib: 8192 },
      });
      return { content: [{ type: "text", text: `VM created: ${r.vm_id}` }] };
    },
  });
Reload pi (/reload in the TUI). Ask the agent to create a VM — it calls your tool and returns a VM ID.

Step 3: Add SSH Execution

Vers VMs use SSH over TLS on port 443. Add the SSH helper and a tool:
  async function getKeyPath(vmId: string): Promise<string> {
    const info = await versApi("GET", `/vm/${vmId}/ssh_key`);
    const path = `/tmp/vers-${vmId.slice(0, 12)}.pem`;
    await writeFile(path, info.ssh_private_key, { mode: 0o600 });
    return path;
  }

  function sshExec(keyPath: string, vmId: string, command: string): Promise<string> {
    return new Promise((resolve, reject) => {
      execFile("ssh", [
        "-i", keyPath,
        "-o", "StrictHostKeyChecking=no",
        "-o", "UserKnownHostsFile=/dev/null",
        "-o", "LogLevel=ERROR",
        "-o", `ProxyCommand=openssl s_client -connect %h:443 -servername %h -quiet 2>/dev/null`,
        `root@${vmId}.vm.vers.sh`,
        command,
      ], (err, stdout, stderr) => {
        if (err) reject(new Error(stderr || err.message));
        else resolve(stdout);
      });
    });
  }

  pi.registerTool({
    name: "vm_exec",
    label: "Run on VM",
    description: "Execute a command on a Vers VM via SSH",
    parameters: Type.Object({
      vmId: Type.String({ description: "VM ID" }),
      command: Type.String({ description: "Shell command" }),
    }),
    async execute(_id, params) {
      const key = await getKeyPath(params.vmId);
      const out = await sshExec(key, params.vmId, params.command);
      return { content: [{ type: "text", text: out || "(no output)" }] };
    },
  });
Reload. Ask the agent to create a VM then run uname -a on it. You’ll see the VM’s kernel info.
The SSH connection uses openssl s_client as a ProxyCommand to tunnel through TLS on port 443. This is how Vers exposes SSH without opening additional ports.

Step 4: Override bash for Transparent Routing

This is the key pattern. Instead of making the LLM use vm_exec, override the built-in bash so normal commands route to the VM automatically:
  pi.registerTool({
    name: "vm_use",
    label: "Use VM",
    description: "Route all bash commands to this VM. Use vm_local to switch back.",
    parameters: Type.Object({ vmId: Type.String({ description: "VM ID" }) }),
    async execute(_id, params, _s, _u, ctx) {
      activeVmId = params.vmId;
      ctx.ui.setStatus("vers", `vers: ${params.vmId.slice(0, 12)}`);
      return { content: [{ type: "text", text: `Routing to ${params.vmId}` }] };
    },
  });

  pi.registerTool({
    name: "vm_local",
    label: "Use Local",
    description: "Stop routing to VM, run commands locally",
    parameters: Type.Object({}),
    async execute(_id, _p, _s, _u, ctx) {
      activeVmId = undefined;
      ctx.ui.setStatus("vers", undefined);
      return { content: [{ type: "text", text: "Back to local" }] };
    },
  });

  pi.registerTool({
    name: "bash",
    label: "bash",
    description: "Execute a bash command. Routes to active VM if set, otherwise runs locally.",
    parameters: Type.Object({
      command: Type.String({ description: "Command to execute" }),
      timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })),
    }),
    async execute(_id, params) {
      if (!activeVmId) {
        return new Promise((resolve) => {
          execFile("bash", ["-c", params.command], (err, stdout, stderr) => {
            const out = (stdout || "") + (stderr || "");
            if (err) resolve({ content: [{ type: "text", text: `${out}\nExit: ${err.code}` }] });
            else resolve({ content: [{ type: "text", text: out || "(no output)" }] });
          });
        });
      }
      const key = await getKeyPath(activeVmId);
      const out = await sshExec(key, activeVmId, params.command);
      return { content: [{ type: "text", text: out || "(no output)" }] };
    },
  });
Reload. Now:
  1. Ask the agent to create a VM
  2. Ask it to vm_use that VM
  3. Ask it to install Node.js — it runs apt-get install nodejs over SSH without the LLM knowing
The LLM calls bash as usual. Your extension decides where it runs.

Step 5: Add Status on Startup

Show the VM count in the footer when pi starts:
  pi.on("session_start", async (_ev, ctx) => {
    try {
      const vms = await versApi("GET", "/vms");
      ctx.ui.setStatus("vers", `vers: ${vms.length} VM(s)`);
    } catch {
      ctx.ui.setStatus("vers", "vers: offline");
    }
  });
Reload. You’ll see “vers: N VM(s)” in the footer.

The Complete Extension

The full file is ~80 lines of logic. Copy it from the assembled steps above, or see the production version with all four tool overrides (bash, read, edit, write) plus branching, commits, and SCP at hdresearch/pi-v.

Key Patterns

Tool override routing

LLM calls bash → extension checks activeVmId → SSH or local
The LLM doesn’t need to know about routing. It calls the same tools regardless. This pattern works for any remote execution target — containers, cloud VMs, SSH hosts.

API client as closure

The versApi function, activeVmId state, and SSH helpers all live in the extension’s closure. Multiple tools share them. No global state, no classes — just functions and variables scoped to the extension.

Status feedback

ctx.ui.setStatus shows persistent info in the footer. ctx.ui.setWidget shows multi-line info above the editor. Use these to keep the user informed without interrupting the agent.

Next Steps

  • Override read, edit, and write the same way — the full extension shows how
  • Add vers_vm_commit and vers_vm_restore for snapshots
  • Add vers_vm_branch for cloning running VMs
  • Try the agent swarms tutorial to orchestrate multiple agents across branched VMs