> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vers.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Building pi extensions

> Give a coding agent the ability to create Vers VMs and transparently route every bash call to a remote machine. ~80 lines of TypeScript.

Build a pi extension that gives the agent VM-management tools and transparently routes every `bash` call to a Vers VM — so the agent acts on a remote machine without knowing it.

## What you'll build

* An extension that registers `create_vm`, `vm_exec`, `vm_use`, `vm_local`, and an overridden `bash` tool
* A **transparent routing** pattern that generalizes to containers, cloud VMs, SSH hosts, or any remote execution target
* **Time:** \~20 minutes — mostly reading, \~80 lines to write

## Prerequisites

* [pi coding agent](https://github.com/badlogic/pi-mono) installed
* Vers account with `VERS_API_KEY` set in your environment
* `ssh` and `openssl` on your `PATH`

## Step 1: Create the Extension

Create `~/.pi/agent/extensions/my-vers.ts`:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
// ~/.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:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
  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:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
  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.

<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.
</Info>

## 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:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
  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:

```typescript theme={"theme":{"light":"min-light","dark":"min-dark"}}
  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](https://github.com/hdresearch/pi-v/blob/main/extensions/vers-vm.ts).

## 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.

## What's next

<CardGroup cols={2}>
  <Card title="Agent swarms tutorial" icon="robot" href="/tutorials/agent-swarms">
    Orchestrate many agents across branched VMs — the pattern this extension makes ergonomic.
  </Card>

  <Card title="Full pi-v extension" icon="github" href="https://github.com/hdresearch/pi-v/blob/main/extensions/vers-vm.ts">
    Production version: `read`/`edit`/`write` overrides, commits, branches, SCP.
  </Card>

  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Every endpoint your extension can call.
  </Card>

  <Card title="Architecture" icon="sitemap" href="/architecture">
    What the extension is actually talking to on the other end of the API.
  </Card>
</CardGroup>
