The host tier

The host tier runs machine work for a server plugin. It can read a repository, start a process, and watch files.

A host role is a private typed contract. Only the same plugin's server artifact can use it.

The server artifact still provides the public service implementation. Other plugins use that service and do not see its host role.

This page builds the status and watch parts of the bb.git provider. It implements bb.vcs through a private Git role.

Use this when

  • Run a machine tool. Put Git, a compiler, or another local process in a host role.
  • Use the machine that owns a repository. Select a host target before you call the role.
  • Watch machine files. Emit a typed signal when a repository changes.
  • Keep a public service independent of placement. Let consumers use one server service for local and remote work.
  • Set process limits. Declare memory, request, response, start, stop, and idle limits for each role.

What you build

File Purpose
bb.plugin.jsonc Declares the artifacts, the bb.vcs provider claim, and the private role.
src/contracts.ts Imports the public bb.vcs types and defines the private Git role token.
src/host.ts Runs Git and emits repository change signals.
src/server.ts Claims bb.vcs and adapts its generic methods to the Git host role.
src/host.test.ts Tests the role through the host boundary.

One capability has two records

bb.vcs is a public, replaceable, single server service. The bb.vcs owner declares bb.git as its default provider.

The bb.git plugin claims bb.vcs. It does not own or redeclare the generic contract.

The example role has the private ID bb.git.host.git. The manifest name git forms the final ID part.

The role never enters the global winner picker. A replacement service can use a different private implementation.

Step Record or action
1 A consumer declares a required edge to bb.vcs.
2 The winner picker selects the public bb.vcs service provider.
3 That provider calls its private bb.git.host.git role.
4 The role starts Git on the selected host.

The inspector joins both records. Generic consumers only declare an edge to bb.vcs.

Steps

1. Declare both artifacts, the claim, and the role

The manifest declares static facts. The build generates contract.json from this file and the exported TypeScript contracts.

// bb.plugin.jsonc
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "bb.git",
  "version": "1.0.0",
  "name": "Git",
  "description": "Provides repository data and Git operations.",
  "engines": {
    "bb": "^2.0.0",
    "sdk": "^2.0.0"
  },
  "artifacts": {
    "server": "./dist/server.mjs",
    "host": "./dist/host.mjs"
  },
  "claims": [
    { "service": "bb.vcs", "version": "^1.0.0" }
  ],
  "hostRoles": [
    {
      "name": "git",
      "contract": "./src/contracts.ts#gitHostRole",
      "scope": "host",
      "launch": { "kind": "module", "export": "default" },
      "limits": {
        "startTimeoutMs": 10000,
        "stopGraceMs": 5000,
        "idleMs": 300000,
        "maxRssBytes": 1073741824,
        "maxRequestBytes": 33554432,
        "maxResponseBytes": 8388608
      }
    }
  ]
}

The bb.vcs owner declares the service and its default provider. This manifest only claims that service.

The hostRoles row is private. Do not add a public service claim for bb.git.host.git.

2. Use the public service types and define the private role

The public bb.vcs service accepts a generic repository target. The server resolves an environment target before a host call.

The private role accepts only a host ID and a repository path. It never receives an unresolved environment ID.

// src/contracts.ts
import { defineHostRole } from "@get-bb/plugin/host";
import type {
  VcsRepositoryRef,
  VcsRepositoryStatus,
} from "@bb/vcs/contracts";

export interface GitHostMethods {
  status: {
    input: { repository: VcsRepositoryRef };
    output: VcsRepositoryStatus;
  };
  startWatch: {
    input: { repository: VcsRepositoryRef };
    output: { watchId: string };
  };
  stopWatch: {
    input: { watchId: string };
    output: void;
  };
}

export interface GitHostSignals {
  repositoryChanged: {
    watchId: string;
    reason: "head" | "index" | "worktree" | "refs" | "unknown";
  };
  watchLost: {
    watchId: string;
    reason: "overflow" | "reconnect";
  };
}

export const gitHostRole = defineHostRole<GitHostMethods, GitHostSignals>(
  "bb.git.host.git",
  "1.0",
);

The full bb.vcs contract also has changed files, commits, heads, history, and diffs. Each operation follows this same split.

The bb.git plugin exports Git-only staging, worktree, and stash services through separate bb.git.* contracts.

3. Fulfill the role in the host artifact

The host factory receives a typed api object. Each registration belongs to its factory scope.

Use an argument array when you start Git. Do not make a shell command string from input.

// src/host.ts
import { createHash, randomUUID } from "node:crypto";
import { defineHostPlugin } from "@get-bb/plugin/host";
import { gitHostRole } from "./contracts.js";

type Stop = () => void | Promise<void>;

export default defineHostPlugin((api) => {
  const watches = new Map<string, Stop>();

  api.roles.provide("git", gitHostRole, {
    async status({ repository }, context) {
      const result = await context.exec.run(
        ["git", "-C", repository.rootPath, "status", "--porcelain=v1", "--branch"],
        { cwd: repository.rootPath, signal: context.signal },
      );

      if (result.exitCode !== 0) {
        throw new Error(result.stderr || "git status failed");
      }

      const lines = result.stdout.trimEnd().split("\n");
      const header = lines.shift() ?? "";
      const branch = header.startsWith("## ")
        ? header.slice(3).split("...")[0] || null
        : null;

      return {
        repository,
        head: {
          name: branch,
          kind: branch ? "branch" : null,
          target: null,
          detached: false,
        },
        upstream: null,
        clean: lines.every((line) => line.length === 0),
        conflicted: false,
        changedFileCount: lines.filter((line) => line.length > 0).length,
        revision: createHash("sha256").update(result.stdout).digest("hex"),
      };
    },

    async startWatch({ repository }, context) {
      const watchId = randomUUID();
      const release = context.retain();
      const stopWatch = context.watch(
        [repository.rootPath],
        {},
        () => {
          context.emitSignal("repositoryChanged", {
            watchId,
            reason: "unknown",
          });
        },
      );

      watches.set(watchId, async () => {
        await stopWatch();
        await release();
      });
      return { watchId };
    },

    async stopWatch({ watchId }) {
      const stop = watches.get(watchId);
      watches.delete(watchId);
      await stop?.();
    },
  });

  api.onDispose(async () => {
    await Promise.all([...watches.values()].map((stop) => stop()));
    watches.clear();
  });
});

The context limits file, process, and watch access to the selected target scope. The host role is not a security sandbox.

4. Adapt the public service in the server artifact

The server owns service rules. It resolves repository targets, checks revisions, and applies output limits.

This example uses a small resolveRepository helper. The first-party implementation resolves environment targets through the bb.workspace service.

// src/server.ts
import {
  defineServerPlugin,
  type ServerPluginApi,
} from "@get-bb/plugin/server";
import {
  bbVcs,
  type VcsRepositoryEvent,
  type VcsRepositoryRef,
  type VcsRepositoryTarget,
} from "@bb/vcs/contracts";
import {
  gitHostRole,
} from "./contracts.js";
import { resolveRepository } from "./resolve-repository.js";

export default defineServerPlugin((api) => {
  api.services.provide(bbVcs, {
    async status({ repository, signal }) {
      const resolved = await resolveRepository(repository, signal);
      const host = await api.host.use("git", gitHostRole, {
        kind: "host",
        hostId: resolved.hostId,
      });

      try {
        return await host.call(
          "status",
          { repository: resolved },
          { signal, timeoutMs: 30000 },
        );
      } finally {
        await host.dispose();
      }
    },

    async watch({ repository, signal }, listener) {
      const resolved = await resolveRepository(repository, signal);
      return startRepositoryWatch(api, resolved, signal, listener);
    },
  });
});

async function startRepositoryWatch(
  api: ServerPluginApi,
  repository: VcsRepositoryRef,
  signal: AbortSignal | undefined,
  listener: (event: VcsRepositoryEvent) => void,
): Promise<() => Promise<void>> {
  const host = await api.host.use("git", gitHostRole, {
    kind: "host",
    hostId: repository.hostId,
  });
  const { watchId } = await host.call("startWatch", { repository }, { signal });
  const off = host.onSignal("repositoryChanged", (event) => {
    if (event.watchId !== watchId) return;

    void host.call("status", { repository }).then((status) => {
      listener({ kind: "changed", status });
    });
  });

  return async () => {
    off();
    await host.call("stopWatch", { watchId }).catch(() => undefined);
    await host.dispose();
  };
}

// The helper returns a resolved host ID and validated paths.
export type ResolveRepository = (
  target: VcsRepositoryTarget,
  signal?: AbortSignal,
) => Promise<VcsRepositoryRef>;

api.services.provide() registers the public provider. api.host.use() opens only this plugin's private role.

The factory scope removes the service registration automatically. The returned watch disposer owns its client and role lease.

5. Test the role boundary

The host harness checks method types, JSON data, signals, cancellation, leases, and cleanup.

// src/host.test.ts
import { createHostHarness } from "@get-bb/plugin/testing";
import hostPlugin from "./host.js";
import { gitHostRole } from "./contracts.js";

const host = createHostHarness(hostPlugin, gitHostRole, {
  paths: { dataDir: "/tmp/bb-git-test", tempDir: "/tmp/bb-git-test/tmp" },
  ports: {
    exec: {
      run: async () => ({
        stdout: "## main\n",
        stderr: "",
        exitCode: 0,
      }),
    },
  },
});

const repository = {
  repositoryId: "repo-1",
  environmentId: null,
  hostId: "host-1",
  rootPath: "/repo",
  provider: {
    pluginId: "bb.git",
    kind: "git",
    generation: 1,
  },
};

const status = await host.call("status", { repository });
if (!status.clean || status.head.name !== "main") {
  throw new Error("unexpected status");
}

await host.dispose();

Use a temporary directory in a real test. Do not use a user repository as test data.

What happens at runtime

  1. The loader verifies bb.plugin.json, contract.json, and the host artifact digest.
  2. The loader stages the bb.git claim for bb.vcs and its private role before one atomic commit.
  3. The server resolves the repository to a host ID and validated paths.
  4. api.host.use() selects that host and starts the verified role generation when necessary.
  5. host.call() validates the input, deadline, output, and cancellation state.
  6. A role signal crosses the same typed boundary and reaches onSignal().
  7. The daemon stops an idle role after its declared limit expires.
  8. A failed role does not restart by itself. The next required call starts a new generation.

The kernel keeps host transport and seven stable ports. These ports are bb.storage, bb.secrets, bb.preferences, bb.realtime, bb.http, bb.rpc, and bb.plugins.

The bb.vcs domain owns generic version-control verbs and data. The bb.git provider adapts those verbs to Git.

Pitfalls

  • Do not expose the role as a service. Generic consumers require only the public bb.vcs service.
  • Resolve an environment first. A host role must receive a host ID and validated machine paths.
  • Use typed verbs. Do not send a general shell command through a host role.
  • Use argument arrays. Never join user input into a shell command string.
  • Set a call deadline. Pass timeoutMs for work that must finish within a fixed time.
  • Release watches and leases. Return a disposer and register factory cleanup for remaining resources.
  • Keep role state private. A winner change can select a provider with a different role implementation.
  • Expect process failure. The next call can start a fresh role, but it cannot restore in-memory state.
  • Treat limits as controls, not isolation. Plugins remain trusted code.

See also

  • Design 01, “Host daemon and host roles,” for identity, targets, context, protocol, limits, and diagnostics.
  • Design 08, “bb.vcs, bb.vcs-ui, and bb.git,” for the generic contract and the default Git provider.
  • Design section 13, “The server, the host, and service reach,” for the one-capability rule.
  • Guide 11 for environment roles.
  • Guide 13 for server, app, and host test helpers.