BlaiseBlaise
Documentation
Runner and PlugsHow-to guides

Write a host plug

Give the agent and the plugs after yours a machine of your own, such as a cloud sandbox.

A host plug passes a host to next(). This guide builds one for a service that can run a command and exposes an SSH port. For a service that has only an HTTP API, replace step 2.

1. Describe the machine, lazily

createHost takes a description and a connect function. The connection is made the first time something uses the host, and at most once.

import { z } from "zod";
import { type PlugContext, definePlug } from "@blaiseagent/runner";
import { createHost, sshConnect, tcpStream } from "@blaiseagent/runner/host";

const inputs = z.object({ name: z.string(), hostKey: z.string() });
type DevboxContext = PlugContext<z.infer<typeof inputs>, undefined>;

function devboxHost(ctx: DevboxContext) {
  ctx.agent.cwd = "/home/dev/app";
  return createHost({
    info: {
      kind: "devbox",
      os: "linux",
      home: "/home/dev",
      tmpdir: "/tmp",
      workspace: "/home/dev/app",
      persistent: true,
    },
    connect: async () => {
      const ssh = await sshConnect(await tcpStream(`${ctx.inputs.name}.devbox.test`, 22), {
        username: "dev",
        privateKey: await ctx.runner.fs.promises.readFile("/keys/devbox", "utf8"),
        hostKey: ctx.inputs.hostKey,
      });
      return {
        transport: ssh.transport,
        fs: ssh.sftp(),
        forward: (spec) => ssh.forward(spec),
        close: async () => ssh[Symbol.asyncDispose](),
      };
    },
  });
}

export default definePlug({
  name: "acme/devbox",
  inputs,

  async onSession(ctx, next) {
    await next({ host: devboxHost(ctx) });
  },

  async onTeardown(ctx, next) {
    await next({ host: devboxHost(ctx) });
    // Destroy the machine here, if the plug created it.
  },
});

The runner closes the connection when the next() that introduced the host resolves. Do not close it yourself.

2. Supply only what you have

A connection needs one thing: transport.spawn, which starts a process and returns byte streams. The rest is derived.

You provideThe host gives you
transport onlyexec, spawn, and a file system built from POSIX commands
plus fs (a provider)a native file system, such as SFTP
plus forwardhost.forward for ports
plus syncthe synchronous fs methods

For a service with an API instead of SSH, build spawn from its exec call and return the streams as ReadableStream and WritableStream values. If it only offers a WebSocket, webSocketStream turns it into a byte stream that sshConnect can use.

3. Set persistent honestly

persistent: true means the machine outlives the session. The runner then starts the agent under a watchdog that ends it when its session process goes away. Leave it false for machines that are destroyed with the session.

4. Handle teardown

Offer the host in onTeardown too, with the same description. Because hosts are lazy, nothing connects unless an inner plug has teardown work to do on the machine.

async onTeardown(ctx, next) {
  await next({ host: devboxHost(ctx) });
  // Destroy the machine here, if the plug created it.
},

5. Test it

Test the plug with the harness, as in Test a plug. The harness reports the host the plug offered, which is how this plug is tested without connecting:

import { describe, expect, test } from "vitest";
import { createPlugHarness } from "@blaiseagent/runner/testing";
import devbox from "./devbox";

describe("devbox", () => {
  test("offers a lazy host without connecting", async () => {
    const harness = createPlugHarness(devbox, {
      inputs: { name: "dev1", hostKey: "SHA256:abc" },
    });
    const { switchedHost } = await harness.session();
    expect(switchedHost?.info).toMatchObject({
      kind: "devbox",
      persistent: true,
    });
  });

  test("offers the same host to the plugs of a teardown", async () => {
    const harness = createPlugHarness(devbox, {
      inputs: { name: "dev1", hostKey: "SHA256:abc" },
    });
    const { switchedHost } = await harness.teardown();
    expect(switchedHost?.info.kind).toBe("devbox");
  });
});

For the SSH parts, startSshServer from @blaiseagent/runner/testing starts a real SSH server on a loopback port that runs commands and serves SFTP over the local machine.

On this page