The host tier

The core process never touches a workspace. It has no fs, no exec, no git; those live on a host machine (the core's own machine or an enrolled remote host), behind kernel-host. A plugin that must read a repo, run a tool, or watch a file ships a host tier: a host.ts entry that runs in a child process on the host, answers typed commands from the plugin's server half, and pushes signals back. This page builds a repo-tools plugin with one rpc role that lints a workspace, reads a config file on demand, and tails a log file into the server.

Use this when

  • Run a linter over the workspace and report. The server half calls a host command, the role spawns eslint in the workspace, and the result comes back typed.
  • Read a config file from the repo on demand. A service method answers bb repo config <root> .bbrc from the host that owns the directory.
  • Tail a log file and push lines to the server. The role emits a log-line signal per line; the server half receives it through onSignal.
  • Run a long job on a remote machine's host. The same command with { hostId } runs on an enrolled host; timeoutMs sets the deadline.
  • Run a workspace-local build tool. pnpm exec tsc in the repo's own node_modules, which only the host has.

What you build

File Tier Contains
package.json#bb manifest host, contributes.hostRoles[] (rpc / tools), hostCommands, hostSignals (§6.8)
src/contracts.ts contracts defineHostCommands (the schemas both ends validate) and the service the server exposes (§2.6)
src/lint.ts shared the one function the role runs and the server falls back to (§3.9)
src/host.ts host defineHostEntry + defineHostRole({ role: "rpc" }) + signalEmitter (§6.1§6.3)
src/server.ts server ctx.hostClient(contract, "tools"): call, onSignal, onExit (§3.9)

Steps

1. Declare the role and its names in the manifest

The manifest carries names only; schemas are code. launch defaults to { kind: "module", export: "tools" }, so a defineHostEntry default export needs no named export. idleMs: 0 keeps the role alive between tail lines (see Pitfalls).

{
  "name": "@get-bb/plugin-repo-tools", "version": "1.0.0", "type": "module",
  "bb": {
    "id": "repo-tools", "name": "Repo tools", "description": "Lint, read, and tail a workspace on its host",
    "category": "developer",
    "server": "./src/server.ts", "host": "./src/host.ts", "contracts": "./src/contracts.ts",
    "provides": { "repo-tools/tools": { "version": "1.0.0" } },
    "contributes": {
      "cli": { "commands": ["repo"] },
      "hostRoles": [{ "role": "rpc", "name": "tools", "limits": { "idleMs": 0 } }],
      "hostCommands": ["lint", "read-config", "tail-start", "tail-stop"],
      "hostSignals": ["log-line"]
    }
  },
  "dependencies": { "@get-bb/plugin-sdk": "workspace:*", "zod": "4.3.6" }
}

2. Write the contract once

defineHostCommands checks the plugin id and kebab-case names; wire methods are repo-tools/<command>. The service below is what users and agents reach (HTTP, SDK, bb repo lint).

// src/contracts.ts — schemas only
import { defineHostCommands, defineService, method } from "@get-bb/plugin-sdk/contracts";
import { z } from "zod";

const root = z.string().min(1).describe("Absolute workspace root on the host");
export const lintResultSchema = z.strictObject({
  findings: z.array(z.strictObject({ file: z.string(), line: z.number().int(), message: z.string() })),
  exitCode: z.number().int().nullable(),
});
export type LintResult = z.infer<typeof lintResultSchema>;

export const repoToolsHost = defineHostCommands({
  id: "repo-tools",
  commands: {
    lint: { input: z.strictObject({ root }), output: lintResultSchema },
    "read-config": { input: z.strictObject({ root, path: z.string().min(1) }), output: z.strictObject({ text: z.string() }) },
    "tail-start": { input: z.strictObject({ root, file: z.string().min(1) }), output: z.strictObject({ tailId: z.string() }) },
    "tail-stop": { input: z.strictObject({ tailId: z.string().min(1) }), output: z.strictObject({}) },
  },
  signals: { "log-line": { payload: z.strictObject({ tailId: z.string(), line: z.string() }) } },
});

export const tools = defineService({
  id: "repo-tools/tools", version: "1.0.0", summary: "Run repo checks on the workspace's host",
  cli: { group: ["repo"] },
  methods: {
    lint: method({
      kind: "query", summary: "Lint a workspace on its host",
      input: z.strictObject({ root, hostId: z.string().nullable().default(null).describe("null = the core's host") }),
      output: lintResultSchema,
      cli: { fields: { root: { positional: 0 }, hostId: { flag: "host" } } },
      renderText: (r) => r.findings.map((f) => `${f.file}:${f.line} ${f.message}`).join("\n") || "clean",
    }),
  },
});

3. Put the work in one shared function

The role runs it on the host; the server runs the same function in-process when the role is unreachable and the root is on the core's machine (the §3.9 fallback pattern). The linter's output is a boundary: parse it, never cast it.

// src/lint.ts
import { spawn } from "node:child_process";
import { z } from "zod";
import type { LintResult } from "./contracts.js";

const eslintJson = z.array(z.object({ filePath: z.string(), messages: z.array(z.object({ line: z.number(), message: z.string() })) }));

export function lintWorkspace(root: string, signal: AbortSignal): Promise<LintResult> {
  return new Promise((resolve, reject) => {
    const child = spawn("eslint", ["--format", "json", "."], { cwd: root, signal, stdio: ["ignore", "pipe", "inherit"] });
    let out = "";
    child.stdout.on("data", (c: Buffer) => (out += c.toString("utf8")));
    child.on("error", reject);
    child.on("close", (exitCode) => {
      const files = eslintJson.parse(JSON.parse(out || "[]"));
      resolve({ findings: files.flatMap((f) => f.messages.map((m) => ({ file: f.filePath, line: m.line, message: m.message }))), exitCode });
    });
  });
}

4. Write the host entry

defineHostRole({ role: "rpc" }) wraps the handlers with implementHostCommands: input is validated before a handler runs, output before the line leaves. signalEmitter(contract, ctx) validates a signal payload the same way. The RoleContext here is the narrowed, scope-bound one of §6.4; note what the workspace code uses and what it does not (see Pitfalls).

// src/host.ts
import { open, readFile, stat } from "node:fs/promises";
import path from "node:path";
import { KernelError } from "@get-bb/plugin-sdk/contracts";
import { defineHostEntry, defineHostRole, signalEmitter } from "@get-bb/plugin-sdk/host";
import { repoToolsHost } from "./contracts.js";
import { lintWorkspace } from "./lint.js";

/** A caller-supplied relative path is input: it must stay under the root it names. */
const inside = (root: string, rel: string): string => {
  const abs = path.resolve(root, rel);
  if (abs !== root && !abs.startsWith(root + path.sep))
    throw new KernelError({ code: "invalid_input", message: `${rel} escapes ${root}`, data: { root, rel } });
  return abs;
};

/** Polls `abs` once a second and hands every new line to `onLine`; returns the stop function. */
async function tailFile(abs: string, onLine: (line: string) => Promise<void>): Promise<() => void> {
  let offset = (await stat(abs)).size;
  const timer = setInterval(async () => {
    const { size } = await stat(abs);
    if (size <= offset) return;
    const fh = await open(abs);
    const buf = Buffer.alloc(size - offset);
    await fh.read(buf, 0, buf.length, offset);
    await fh.close();
    offset = size;
    for (const line of buf.toString("utf8").split("\n").filter((l) => l.length > 0)) await onLine(line);
  }, 1_000);
  return () => clearInterval(timer);
}

const tails = new Map<string, () => void>();

export default defineHostEntry({
  roles: [
    defineHostRole({
      role: "rpc", name: "tools", contract: repoToolsHost,
      commands: {
        lint: ({ root }, ctx) => lintWorkspace(root, ctx.lifecycle.signal),
        "read-config": async ({ root, path: rel }) => ({ text: await readFile(inside(root, rel), "utf8") }),
        "tail-start": async ({ root, file }, ctx) => {
          const emit = signalEmitter(repoToolsHost, ctx);
          const tailId = `${ctx.name}-${process.pid}-${tails.size + 1}`;
          tails.set(tailId, await tailFile(inside(root, file), (line) => emit("log-line", { tailId, line })));
          ctx.log.info(`tail ${tailId} started`);
          return { tailId };
        },
        "tail-stop": async ({ tailId }) => {
          tails.get(tailId)?.();
          tails.delete(tailId);
          return {};
        },
      },
    }),
  ],
  signals: repoToolsHost.signals,
  dispose: () => { for (const stop of tails.values()) stop(); },
});

5. Call it from the server half

ctx.hostClient(contract, name) is wired at dc07292bf (§0.7, §3.9): call validates input before the frame leaves core and output after it arrives; onSignal delivers only payloads that pass the signal schema and logs the rest; onExit sees every role exit. Keep the fallback for a host that is offline.

// src/server.ts
import { definePlugin, KernelError } from "@get-bb/plugin-sdk";
import { repoToolsHost, tools } from "./contracts.js";
import { lintWorkspace } from "./lint.js";

export default definePlugin({
  async activate(ctx) {
    const host = ctx.hostClient(repoToolsHost, "tools");
    host.onSignal("log-line", ({ tailId, line }) => ctx.log.info("tail", { tailId, line }));
    host.onExit((exit) => ctx.log.warn("tools role exited", { roleId: exit.roleId, exitCode: exit.exitCode, signal: exit.signal }));
    let warned = false;
    await ctx.provide(tools, {
      lint: async ({ root, hostId }) => {
        try {
          return await host.call("lint", { root }, { hostId, timeoutMs: 120_000 });
        } catch (err) {
          if (!(err instanceof KernelError) || err.code !== "service_unavailable" || hostId !== null) throw err;
          if (!warned) { warned = true; ctx.log.warn("tools role unreachable; linting in-process", { code: err.code }); }
          return lintWorkspace(root, ctx.signal);          // the same function the role runs
        }
      },
    });
  },
});

6. Test both halves without a host

createTestPlugin runs the entry's rpc role over a fake RoleContext and wires ctx.hostClient to it (§9.2, §9.5); fakeRoleContext records signals and logs for a direct handler test.

import { createTestPlugin, fakeRoleContext } from "@get-bb/plugin-sdk/testing";
const plugin = await createTestPlugin({ manifest, server, host, contracts: [tools] });
await (await plugin.inject(tools)).lint({ root: repoDir });            // crosses to the fake role
const role = fakeRoleContext({ pluginId: "repo-tools", role: "rpc", name: "tools" });
const handler: unknown = host.commands["read-config"];                  // HostEntry.commands is erased (§6.1)
if (typeof handler !== "function") throw new Error("no read-config");
await handler({ root: repoDir, path: "../etc/passwd" }, role.context); // rejects: invalid_input

What happens at runtime

  1. bb plugin build writes dist/host.mjs; core publishes host.mjs + meta.json as artifacts/<sha256>.tgz on first use and every host verifies that digest (packages/core/src/host-client.ts).
  2. The first host.call("lint", …) builds a RoleSpec for (repo-tools, generation, rpc, tools) with scope: { kind: "none" } and env: {}, and asks the host's RolesPort to start it: bin/bb-host-role.mjslocal/bootstrap.ts, umask 077, chdir to the temp dir, import the entry, select default.roles.rpc.tools, print role/ready. { hostId } picks an enrolled host's live session instead of the core's worker.
  3. Core sends rpc.req repo-tools/lint as one JSON-RPC line; the table validates the params, runs the handler, validates the result, answers. Each call holds a host-side lease for its duration.
  4. emit("log-line", …) is a role/signal notification; the loader subscribes every name in contributes.hostSignals and relays it to hostClient.onSignal.
  5. With no lease and no line in either direction for idleMs, the supervisor stops the process (SIGTERM → group → SIGKILL after stopGraceMs). It never restarts it: the next call starts a fresh one, and onExit fires on every exit. SIGTERM runs dispose(); ctx.lifecycle.signal aborts on stdin EOF.

Pitfalls

  • Scope none binds ctx.fs / ctx.exec / ctx.vcs to the plugin root (ctx.paths.dataDir), not to the workspace (§6.4; core/src/host-client.ts starts rpc roles with scope: none). Workspace paths come in as input; use node:fs / node:child_process on them, as env-local does, and contain them yourself (inside above). exec.run with a cwd outside the scope fails.
  • ctx.watch is a no-op and openLane is absent (D14). Tail with your own timer, as above; roleStorage(ctx).watch works only under the role's data dir.
  • ctx.retain() is a no-op in the child (D14); leases are held host-side during a call. A tail with no traffic for idleMs (default 300 000 ms) is stopped, so set limits.idleMs: 0 on the row or keep calling. Any line in either direction resets the clock.
  • Limits (§6.7): 256 active calls per (pluginId, generation)service_unavailable; 32 MiB in-flight input and 8 MiB per result → host/too_large; 1 MiB per stdout line → SIGKILL; role/ready within 10 s or host/role_start_failed. The reference lists 30 s as the default per call; as built, rpc/role-client.ts runs no timer when timeoutMs is null, so pass timeoutMs from the server when you want a deadline.
  • Process env (§6.7): the inherited env minus every BB_*, NODE_ENV, npm_config_*; the login-shell PATH; then BB_CLI, BB_HOST_ID, BB_SERVER_URL. A role is not a thread: no BB_THREAD_ID, and RoleSpec.env may carry no BB_* key. ctx.hostClient passes env: {}.
  • Names: hostCommands are kebab-case (read-config, never read.config); no build or load step compares the manifest lists with the entry (§6.3) — an undeclared command is METHOD_NOT_FOUND at call time, an undeclared signal is never relayed.
  • Signals are validated on both ends: signalEmitter rejects a bad payload with invalid_input; onSignal logs hostClient.onSignal: payload rejected by the signal schema and delivers nothing (§3.9).
  • Keep the fallback (§3.9): D15's service_unavailable is superseded at dc07292bf, but a missing host worker or an unready host tier still answers it (core/src/core.ts fail), and an enrolled host with no live session answers host/offline (kernel-host/src/hosts/registry.ts forHost). Neither error sets retryable; host/disconnected is the one retryable host code (port/errors.ts), so do not rely on retryable.
  • Other command roles (ai-service, skill-discovery, terminal-backend) are served by the same bootstrap but only rpc rows feed entry.commands; a HostEntryInput has no commands field (§6.1, §6.2).

See also

  • Reference §6 (host tier), §3.9 (host client), §2.6 (defineHostCommands), §9.2 and §9.5 (test harness), §0.7 (dc07292bf wiring), D14 and D15.
  • examples/plugins/hello-slot/src/{contracts,host,server}.ts: the echo role and hostGreet.
  • plugins/env-local/src/host.ts: the repo role (inspect-repo) and the work-status-changed signal beside three environment providers.
  • Guide 11 (environment providers) for the other host role, 12 (provider plugins) for provider-bridge.