skills/pi-tool-routing/SKILL.md
Route pi tools through custom backends (VMs, sandboxes) with state persistence
npx skillsauth add jcsaaddupuy/badrobots pi-tool-routingInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Building pi extensions that wrap or route tools requires patterns that don't appear in the basic extension documentation. This skill covers the advanced patterns needed to:
/reload without losing long-lived resourcesWhen you want to route pi's built-in tools (read, write, edit, bash) through a custom backend, you're doing tool wrapping. Common scenarios:
| Scenario | Backend | Benefit | |----------|---------|---------| | Secure code execution | Gondolin VM | Isolation from host filesystem/network | | OS-level sandboxing | bubblewrap/sandbox-exec | Restrict file and network access | | Remote execution | SSH/Docker | Run on remote machines | | Permission gates | Custom logic | Confirm destructive operations | | Filesystem isolation | Virtual mounts | Hide host paths from LLM |
/reload?By default, /reload destroys all extension state. For long-lived resources (VMs, SSH connections, database pools), you need cross-reload persistence:
/reload creates a new extension instance; local variables are lostglobalThis (JavaScript global scope) which survives reloadsThe foundation of tool routing is this pattern:
// Register ONCE at extension load time
pi.registerTool({
...localBash, // Spread the original tool's metadata
async execute(id, params, signal, onUpdate, ctx) {
// Route DYNAMICALLY at execute time
if (!attachedVm) {
return localBash.execute(id, params, signal, onUpdate, ctx);
}
// Use custom operations
const customTool = createBashTool(localCwd, {
operations: createVmBashOps(attachedVm, localCwd),
});
return customTool.execute(id, params, signal, onUpdate, ctx);
},
});
Why this works:
attachedVm might have changed)Here's a minimal example wrapping bash commands through a Gondolin VM:
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { createBashTool, type BashOperations } from "@mariozechner/pi-coding-agent";
import { VM } from "@earendil-works/gondolin";
let attachedVm: VM | null = null;
function createVmBashOps(vm: VM, localCwd: string): BashOperations {
return {
exec: async (command, cwd, { onData, signal, timeout, env: _env }) => {
// Don't pass host env - rely on VM's env set at creation
const proc = vm.exec(["/bin/bash", "-lc", command], {
cwd,
signal,
stdout: "pipe",
stderr: "pipe",
});
for await (const chunk of proc.output()) {
onData(chunk.data);
}
const r = await proc;
return { exitCode: r.exitCode };
},
};
}
export default function (pi: ExtensionAPI) {
const localCwd = process.cwd();
const localBash = createBashTool(localCwd);
pi.registerTool({
...localBash,
async execute(id, params, signal, onUpdate, ctx) {
if (!attachedVm) {
return localBash.execute(id, params, signal, onUpdate, ctx);
}
const tool = createBashTool(localCwd, {
operations: createVmBashOps(attachedVm, localCwd),
});
return tool.execute(id, params, signal, onUpdate, ctx);
},
});
pi.registerCommand("attach", {
description: "Attach to VM",
handler: async (args, ctx) => {
attachedVm = await VM.create();
ctx.ui.notify("Attached to VM", "success");
},
});
pi.registerCommand("detach", {
description: "Detach from VM",
handler: async (_args, ctx) => {
if (attachedVm) {
await attachedVm.close();
}
attachedVm = null;
ctx.ui.notify("Detached from VM", "info");
},
});
pi.on("session_shutdown", async () => {
if (attachedVm) {
await attachedVm.close();
}
attachedVm = null;
});
}
See linked references for comprehensive coverage:
globalThis for cross-reload persistence, loading state on startup, cleanup on shutdownGoal: Run bash commands in isolated VM
Pattern: Register once, route at execute time via attachedVm
Key files: See tool-wrapping.md
Gotcha: Don't pass host environment variables to VM; Gondolin handles network mediation:
// ❌ WRONG: Passes host proxy vars
const proc = vm.exec(cmd, { env: process.env });
// ✅ RIGHT: Let VM's env at creation time handle everything
const proc = vm.exec(cmd); // No env parameter
Goal: LLM sees /root/workspace instead of host path
Pattern: Modify system prompt before agent starts
Implementation:
pi.on("before_agent_start", (event, ctx) => {
if (!attachedVm) return;
const hostPath = process.cwd();
const guestPath = "/root/workspace";
event.messages[0].content = event.messages[0].content.replace(
hostPath,
guestPath
);
});
/reloadGoal: VMs stay alive when user runs /reload
Pattern: Store VM references in globalThis, reload on startup
Implementation: See state-persistence.md
! commands)Goal: Route ! curl and !! npm test through VM
Pattern: Handle user_bash event, return custom operations
Implementation:
pi.on("user_bash", (event, ctx) => {
if (!attachedVm) return;
return { operations: createVmBashOps(attachedVm, localCwd) };
});
attachedVm when tool runs/reloadsession_startsession_shutdownfor await (const chunk of proc.output())result.exitCode...originalTool - Metadata is needed for routing to workSymptom: Tool always uses local operations, never routes to VM
Check: Is attachedVm being set? Log in execute:
console.error(`[DEBUG] attachedVm=${attachedVm ? "yes" : "no"}`);
/reloadSymptom: VMs closed, attachment cleared after /reload
Check: Are you storing in globalThis? Register should only add to global registry:
globalThis.vmRegistry = globalThis.vmRegistry || new Map();
globalThis.vmRegistry.set(name, vm); // Survives reload
Symptom: $PATH or $HOME empty in VM commands
Check: Did you pass env to vm.create()? Set explicitly:
const vm = await VM.create({
env: {
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
HOME: "/root",
}
});
Symptom: VM curl fails with "Connection reset by peer"
Root cause: Host HTTP_PROXY passed to VM confuses Gondolin's httpHooks
Fix: Don't pass proxy vars to VM:
// ❌ WRONG
vm.exec(cmd, { env: process.env }); // Includes HTTP_PROXY
// ✅ RIGHT - Empty env, let Gondolin handle
vm.exec(cmd); // No env parameter
Register once, route at runtime: Tools are registered with stable metadata at extension load time. Routing decisions (local vs custom backend) happen at execute time by checking state variables like attachedVm.
Persist state in globalThis: Long-lived resources (VMs, connections) survive /reload by storing in JavaScript's global scope. Load from global registry on startup, cleanup on shutdown.
Map filesystems explicitly: When using isolated environments, convert host paths to guest paths consistently to hide host filesystem from LLM and prevent escapes.
Handle environment carefully: Don't assume host environment applies to isolated backends; configure VM/sandbox environment explicitly. Filter out proxy variables to prevent conflicts with network mediation layers.
development
DuckDB patterns for JSON/JSONL analysis, array unnesting, and common gotchas. Use when querying JSON files, nested data, or encountering "UNNEST not supported here" errors.
development
Mealie recipe manager API: recipes, shopping lists, meal plans. Requires MEALIE_BASE_URL and MEALIE_API_KEY.
business
TimeWarrior time tracking: start/stop intervals, query durations by tag or issue, compute totals for issue tracker time reporting
development
Bookmark manager for saving, searching, and annotating web content. Use when: (1) saving a webpage for later reference, (2) searching previously saved bookmarks, (3) adding highlights/annotations to saved content, (4) user asks to 'bookmark this' or 'save this article'. Requires READECK_BASE_URL and READECK_API_KEY environment variables.