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
eslintin 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> .bbrcfrom the host that owns the directory. - Tail a log file and push lines to the server. The role emits a
log-linesignal per line; the server half receives it throughonSignal. - Run a long job on a remote machine's host. The same command with
{ hostId }runs on an enrolled host;timeoutMssets the deadline. - Run a workspace-local build tool.
pnpm exec tscin the repo's ownnode_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_inputWhat happens at runtime
bb plugin buildwritesdist/host.mjs; core publisheshost.mjs+meta.jsonasartifacts/<sha256>.tgzon first use and every host verifies that digest (packages/core/src/host-client.ts).- The first
host.call("lint", …)builds aRoleSpecfor(repo-tools, generation, rpc, tools)withscope: { kind: "none" }andenv: {}, and asks the host'sRolesPortto start it:bin/bb-host-role.mjs→local/bootstrap.ts,umask 077,chdirto the temp dir, import the entry, selectdefault.roles.rpc.tools, printrole/ready.{ hostId }picks an enrolled host's live session instead of the core's worker. - Core sends
rpc.req repo-tools/lintas 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. emit("log-line", …)is arole/signalnotification; the loader subscribes every name incontributes.hostSignalsand relays it tohostClient.onSignal.- With no lease and no line in either direction for
idleMs, the supervisor stops the process (SIGTERM → group → SIGKILL afterstopGraceMs). It never restarts it: the next call starts a fresh one, andonExitfires on every exit. SIGTERM runsdispose();ctx.lifecycle.signalaborts on stdin EOF.
Pitfalls
- Scope
nonebindsctx.fs/ctx.exec/ctx.vcsto the plugin root (ctx.paths.dataDir), not to the workspace (§6.4;core/src/host-client.tsstartsrpcroles withscope: none). Workspace paths come in as input; usenode:fs/node:child_processon them, asenv-localdoes, and contain them yourself (insideabove).exec.runwith acwdoutside the scope fails. ctx.watchis a no-op andopenLaneis absent (D14). Tail with your own timer, as above;roleStorage(ctx).watchworks 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 foridleMs(default 300 000 ms) is stopped, so setlimits.idleMs: 0on 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/readywithin 10 s orhost/role_start_failed. The reference lists 30 s as the default per call; as built,rpc/role-client.tsruns no timer whentimeoutMsisnull, so passtimeoutMsfrom the server when you want a deadline. - Process env (§6.7): the inherited env minus every
BB_*,NODE_ENV,npm_config_*; the login-shellPATH; thenBB_CLI,BB_HOST_ID,BB_SERVER_URL. A role is not a thread: noBB_THREAD_ID, andRoleSpec.envmay carry noBB_*key.ctx.hostClientpassesenv: {}. - Names:
hostCommandsare kebab-case (read-config, neverread.config); no build or load step compares the manifest lists with the entry (§6.3) — an undeclared command isMETHOD_NOT_FOUNDat call time, an undeclared signal is never relayed. - Signals are validated on both ends:
signalEmitterrejects a bad payload withinvalid_input;onSignallogshostClient.onSignal: payload rejected by the signal schemaand delivers nothing (§3.9). - Keep the fallback (§3.9): D15's
service_unavailableis superseded atdc07292bf, but a missing host worker or an unready host tier still answers it (core/src/core.tsfail), and an enrolled host with no live session answershost/offline(kernel-host/src/hosts/registry.tsforHost). Neither error setsretryable;host/disconnectedis the one retryable host code (port/errors.ts), so do not rely onretryable. - Other command roles (
ai-service,skill-discovery,terminal-backend) are served by the same bootstrap but onlyrpcrows feedentry.commands; aHostEntryInputhas nocommandsfield (§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 (dc07292bfwiring), D14 and D15. examples/plugins/hello-slot/src/{contracts,host,server}.ts: theechorole andhostGreet.plugins/env-local/src/host.ts: thereporole (inspect-repo) and thework-status-changedsignal beside three environment providers.- Guide 11 (environment providers) for the other host role, 12 (provider plugins) for
provider-bridge.