Environment providers

An environment provider prepares the workspace where a thread runs. The headless bb.workspace service owns environment data and lifecycle state. A provider claims one key on the replaceable bb.workspace.envProvider service. Its server artifact owns the public service. Its host artifact creates and removes files on the selected host.

This page builds acme.repo-worktree/repo-worktree. It creates a managed Git worktree for one environment.

Use this when

  • Create an isolated worktree. Give each environment its own branch and root.
  • Create a personal workspace. Put a managed directory below the provider data directory.
  • Use an existing directory. Return an unmanaged workspace after strict path checks.
  • Run on a remote host. Let bb.workspace select the host and route each host call.
  • Replace one provider implementation. Claim the same provider key and join the global winner picker.

The provider model

bb.workspace declares two server contracts.

Contract Kind Purpose
bb.workspace single Owns projects, environments, hosts, terminals, and routes.
bb.workspace.envProvider keyed Supplies one provider implementation for each provider key.

The provider key has the form <pluginId>/<name>. The slash separates parts of a key. The contract ID remains bb.workspace.envProvider.

Each key has one global winner. A failure returns the key to its default claimant. The bb.workspace service watches this keyed contract and updates its provider catalog.

The provider has two parts:

  1. The server artifact claims and provides bb.workspace.envProvider.
  2. The host artifact fulfills one private bb.workspace.envProvider.host role.

Consumers call bb.workspace.environments. They do not call the private host role.

What you build

File Purpose
bb.plugin.jsonc Declares the keyed claim, the required edge, the host role, and both artifacts.
src/contracts.ts Names the private role for this provider.
src/host.ts Creates, reconnects, summarizes, and destroys a worktree.
src/server.ts Provides the public keyed service and forwards host work.
contract.json Records the checked service and host schemas. The build creates this file.

1. Declare the claim and role

Use one claim key in the manifest. Use the same role name in the host and server files.

// bb.plugin.jsonc
{
  "$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
  "schemaVersion": 2,
  "id": "acme.repo-worktree",
  "version": "2.0.0",
  "name": "Repository worktree",
  "description": "Create one managed Git worktree for each environment.",
  "engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },

  "claims": [
    {
      "service": "bb.workspace.envProvider",
      "key": "acme.repo-worktree/repo-worktree",
      "version": "^1.0.0"
    }
  ],

  "requires": [
    { "service": "bb.workspace", "range": "^1.0.0" }
  ],

  "hostRoles": [
    {
      "name": "repo-worktree",
      "contract": "./src/contracts.ts#repoWorktreeHostRole",
      "scope": "host",
      "launch": { "kind": "module", "export": "repoWorktree" }
    }
  ],

  "artifacts": {
    "server": "./dist/server.js",
    "host": "./dist/host.js"
  }
}

The build checks this manifest and creates contract.json. Do not edit that generated file.

The requires edge starts this plugin after the current bb.workspace winner becomes ready. A workspace winner change restarts this server factory.

2. Name the private host role

The workspace host package owns the common role contract. Give that contract the provider role name.

// src/contracts.ts
import { environmentProviderHostRole } from "@bb/workspace/host";

export const repoWorktreeHostRole =
  environmentProviderHostRole.named("repo-worktree");

The role stays private to this plugin. It does not join the global service winner store.

3. Implement the host provider

defineEnvironmentProvider checks the options through a Standard Schema. It gives each method an EnvironmentProviderContext.

The context has a lifecycle signal, progress output, private paths, and a contained workspace factory. Use the workspace factory for all repository work.

// src/host.ts
import { z } from "zod";
import { defineHostPlugin } from "@get-bb/plugin/host";
import { defineEnvironmentProvider } from "@bb/workspace/host";
import type {
  EnvironmentHandle,
  EnvironmentSummary,
} from "@bb/workspace/contracts";
import { repoWorktreeHostRole } from "./contracts.js";

const optionsSchema = z.strictObject({
  branch: z.string().min(1).optional(),
});

type Options = z.infer<typeof optionsSchema>;

function readPath(handle: EnvironmentHandle, environmentId: string): string {
  if (handle.kind !== "local-path") {
    throw new Error("The provider requires a local-path handle.");
  }

  if (handle.persisted.environmentId !== environmentId) {
    throw new Error("The handle belongs to a different environment.");
  }

  if (handle.persisted.path !== handle.root) {
    throw new Error("The saved path does not match the handle root.");
  }

  return handle.root;
}

const provider = defineEnvironmentProvider<Options>({
  name: "repo-worktree",
  label: "Repository worktree",
  ownsRoot: true,
  options: optionsSchema,

  async provision(request, context): Promise<EnvironmentHandle> {
    if (request.source === null) {
      throw new Error("This provider requires a project source on the host.");
    }

    context.progress.step("worktree", "Create the Git worktree", "started");

    const workspace = await context.workspace.provision({
      mode: "managed-worktree",
      sourcePath: request.source.path,
      environmentId: request.environmentId,
      branch: request.options.branch,
    });

    const extraRoots = await workspace.getAdditionalWorkspaceWriteRoots();
    context.progress.step("worktree", "The Git worktree is ready", "completed");

    return {
      kind: "local-path",
      root: workspace.path,
      writeRoots: [workspace.path, ...extraRoots],
      persisted: {
        environmentId: request.environmentId,
        path: workspace.path,
      },
    };
  },

  async reconnect(request, context): Promise<EnvironmentHandle> {
    const path = readPath(request.handle, request.environmentId);

    const workspace = await context.workspace.provision({
      mode: "reconnect-managed-worktree",
      environmentId: request.environmentId,
      path,
    });

    const extraRoots = await workspace.getAdditionalWorkspaceWriteRoots();
    return {
      ...request.handle,
      root: workspace.path,
      writeRoots: [workspace.path, ...extraRoots],
    };
  },

  async destroy(request, context): Promise<void> {
    const path = readPath(request.handle, request.environmentId);
    context.progress.step("destroy", "Remove the Git worktree", "started");

    const workspace = await context.workspace.provision({
      mode: "reconnect-managed-worktree",
      environmentId: request.environmentId,
      path,
    });

    await workspace.destroy();
    context.progress.step("destroy", "The Git worktree is removed", "completed");
  },

  async summarize(handle, context): Promise<EnvironmentSummary> {
    if (handle.kind !== "local-path") {
      return {
        label: "Worktree unavailable",
        path: null,
        isRepo: false,
        isWorktree: false,
        branch: null,
        baseBranch: null,
        defaultBranch: null,
      };
    }

    const environmentId = String(handle.persisted.environmentId ?? "");
    const path = readPath(handle, environmentId);
    const workspace = await context.workspace.provision({
      mode: "reconnect-managed-worktree",
      environmentId,
      path,
    });
    const branch = await workspace.getCurrentBranch();

    return {
      label: branch ?? "Detached worktree",
      path: workspace.path,
      isRepo: workspace.isGitRepo,
      isWorktree: workspace.isWorktree,
      branch,
      baseBranch: null,
      defaultBranch: await workspace.getDefaultBranch(),
    };
  },
});

export const repoWorktree = defineHostPlugin((api) => {
  api.roles.provide("repo-worktree", repoWorktreeHostRole, provider);
});

The factory receives a typed api object. The runtime removes the role when the factory stops. Register extra cleanup with api.onDispose().

The workspace helper derives managed paths from the environment ID. It contains Git changes and cleanup within that root.

4. Provide the keyed server service

The server artifact owns the public claim. It forwards each method to its private role.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import {
  environmentProvider,
  workspaceService,
} from "@bb/workspace/contracts";
import { repoWorktreeHostRole } from "./contracts.js";

const providerId = "acme.repo-worktree/repo-worktree" as const;

export default defineServerPlugin(async (api) => {
  const workspace = await api.services.use(workspaceService);
  const host = api.hostRoles.client(repoWorktreeHostRole);

  api.services.provide(environmentProvider.key(providerId), {
    describe: () => host.call("describe", {}),
    provision: (request, options) =>
      host.stream("provision", request, options),
    reconnect: (request) => host.call("reconnect", request),
    destroy: (request, options) =>
      host.call("destroy", request, options),
    summarize: (handle) => host.call("summarize", { handle }),
  });

  return workspace.environments.watch({}, (event) => {
    api.log.debug(`environment ${event.environmentId} changed`);
  });
});

api.services.provide() stages the implementation before it changes the winner. The factory scope removes the claim and watcher during cleanup.

The server never receives a filesystem port. The host role does all filesystem and Git work.

5. Provision an environment

Consumers use the headless workspace service. They select the provider by its key.

import { defineServerPlugin } from "@get-bb/plugin/server";
import { workspaceService } from "@bb/workspace/contracts";

export default defineServerPlugin(async (api) => {
  const workspace = await api.services.use(workspaceService);

  for await (const event of workspace.environments.provision({
    projectId: "proj_123",
    hostId: "host_123",
    providerId: "acme.repo-worktree/repo-worktree",
    options: { branch: "spike/provider-api" },
  })) {
    if (event.kind === "output") {
      api.log.info(event.line);
    }

    if (event.kind === "ready") {
      api.log.info(`ready at ${event.environment.summary?.path ?? "unknown"}`);
    }
  }
});

The provision stream can contain progress, output, and ready events. An abort signal cancels the operation.

What happens at runtime

  1. The loader validates bb.plugin.jsonc and the generated contract.json.
  2. The server factory claims one key on bb.workspace.envProvider.
  3. The kernel applies the global winner rule for that key.
  4. bb.workspace lists the winning provider for each online host.
  5. A provision call selects the host and starts the private role there.
  6. The host provider creates a contained worktree and returns a handle.
  7. bb.workspace stores the handle and publishes the ready environment.
  8. A host restart calls reconnect with the stored handle.
  9. Environment removal calls destroy and then updates lifecycle state.

Handle rules

Treat every saved handle as untrusted input.

  • Check handle.kind before you read root.
  • Store the environment ID and path in persisted.
  • Check both values on reconnect and destroy.
  • Let the workspace helper validate a managed path.
  • Return every required write root in writeRoots.
  • Make destroy safe for a retry after a lost response.

Return a local-path handle when a thread must run in a normal directory. A custom handle has no root. Use it only when another execution system understands its saved data.

Progress and cancellation

Use stable progress keys such as worktree and destroy. A caller can then update one step instead of adding duplicate rows.

Use context.signal for the current call. Use context.lifecycle.signal for work that must stop with the host role.

Do not hide a cancellation error. Let it cross the role boundary so bb.workspace can set the correct lifecycle state.

Provider replacement

bb.workspace.envProvider is a replaceable keyed service. Replacement happens per provider key.

A replacement plugin can claim acme.repo-worktree/repo-worktree. The winner picker selects one implementation for that key. Other provider keys do not change.

The public server service and its private host role move as one staged generation. A failed host candidate cannot remove the current winner.

Pitfalls

  • Do not put UI in the provider. The headless bb.workspace service owns the data contract.
  • Do not expose the host role. Consumers must use bb.workspace.environments.
  • Do not trust a saved path. Check the environment ID, handle kind, and contained root.
  • Do not remove a foreign directory. Reject a handle before any destructive operation.
  • Do not omit write roots. A thread can write only within the returned roots.
  • Do not keep a stale service handle. A required winner change restarts the server factory.
  • Do not edit contract.json. Run the plugin build after each contract change.

See also

  • Design 06, bb.workspace and bb.workspace-ui.
  • Guide 10, the host tier.
  • The @bb/workspace/host export for contained workspace helpers.
  • The @get-bb/plugin/testing/host export for private role tests.