Typed contracts and contract.json
A plugin contract has a dot-form ID, a semantic version, and a type shape. The build turns source contract sketches into contract.json.
The kernel reads this file before it runs plugin code. It uses the file for install checks, graph plans, inspection, and winner selection.
Use this when
- Publish a typed service or surface. Put its public shape in
src/contracts.tsand declare it inbb.plugin.jsonc. - Use one contract from two package copies. The kernel matches the contract ID and version, not a JavaScript object.
- Review the exact public shape. Read the generated
dist/contract.jsonfile. - Change a stable contract. Use semantic version rules and keep a deprecation period.
- Try an unsettled contract. Mark it as
experimentaland expect source changes after any release. - Find an install failure. Compare claims and service edges with the declarations in
contract.json.
What you build
This page adds two contracts to a small counter plugin.
The plugin owns the acme.counter.service service. It also owns the acme.counter.status surface.
The example shows the source sketches, the manifest declarations, and the generated contract file.
1. Write the contract sketches
Keep public types and data-only tokens in src/contracts.ts. Do not put handlers or runtime state in this file.
// src/contracts.ts
import { defineService } from "@get-bb/plugin";
export interface CounterValue {
value: number;
}
export interface CounterService {
get(input: Record<string, never>): Promise<CounterValue>;
increment(input: { by: number }): Promise<CounterValue>;
}
export interface CounterStatusProps {
value: number;
reset(): Promise<void>;
}
export const counterService = defineService<CounterService>(
"acme.counter.service",
"1.0.0",
);The service token gives TypeScript a useful handle. The surface needs only its exported props type.
These declarations are source sketches. The generated JSON file supplies the reviewable wire shape.
2. Connect each sketch to the manifest
The manifest holds short static rows. Each declaration points to one exported source name.
// bb.plugin.jsonc
{
"$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
"schemaVersion": 2,
"id": "acme.counter",
"version": "2.0.0",
"name": "Counter",
"description": "A typed counter example.",
"engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },
"claims": [
{ "service": "acme.counter.service", "version": "^1.0.0" },
{ "surface": "acme.counter.status", "version": "^1.0.0" }
],
"services": [
{
"id": "acme.counter.service",
"version": "1.0.0",
"kind": "single",
"replaceable": true,
"defaultProvider": "acme.counter",
"contract": "./src/contracts.ts#counterService",
"stability": "stable"
}
],
"surfaces": [
{
"id": "acme.counter.status",
"version": "1.0.0",
"kind": "single",
"replaceable": true,
"defaultClaimant": "acme.counter",
"props": "./src/contracts.ts#CounterStatusProps",
"stability": "experimental"
}
],
"exports": {
"./contracts": "./dist/contracts.mjs"
},
"artifacts": {
"app": "./dist/app.mjs",
"server": "./dist/server.mjs"
}
}The plugin owns both IDs because they start with acme.counter. A first-party owner uses IDs such as bb.thread-ui.composer.
The kind field selects single, list, keyed, or chain. Both example contracts use single.
Each replaceable contract joins the global winner picker. The default fields give new installs an active implementation.
A winning single surface receives the typed default component as Original. A service winner never receives an Original handle.
3. Generate contract.json
Run the plugin build after each contract change.
bb plugin build .The build writes dist/contract.json. Authors do not edit this file.
{
"$schema": "https://getbb.app/schemas/contract-v2.schema.json",
"schemaVersion": 2,
"plugin": { "id": "acme.counter", "version": "2.0.0" },
"manifestDigest": "sha256-...",
"declarations": [
{
"id": "acme.counter.service",
"owner": "acme.counter",
"tier": "server",
"type": "service",
"version": "1.0.0",
"kind": "single",
"replaceable": true,
"defaultProvider": "acme.counter",
"stability": "stable",
"methods": {
"increment": {
"input": { "$ref": "#/$defs/IncrementInput" },
"output": { "$ref": "#/$defs/CounterValue" }
}
}
},
{
"id": "acme.counter.status",
"owner": "acme.counter",
"tier": "app",
"type": "surface",
"version": "1.0.0",
"kind": "single",
"replaceable": true,
"defaultClaimant": "acme.counter",
"stability": "experimental",
"props": { "$ref": "#/$defs/CounterStatusProps" }
}
],
"$defs": {
"IncrementInput": { "type": "object" },
"CounterValue": { "type": "object" },
"CounterStatusProps": { "type": "object" }
}
}This excerpt omits some generated schema details. The real file contains the full method, props, and host-role schemas.
The build keeps TypeScript declaration names for diagnostics. It also copies each contract's version, kind, and stability tier.
The manifest gives the short graph. contract.json gives the complete checked shape.
4. Understand contract identity
The kernel identifies a contract by its ID and semantic version compatibility. It never compares JavaScript objects.
Two plugins can bundle separate copies of counterService. Both copies still name acme.counter.service version 1.0.0.
The loader resolves both copies to the same compatible contract. Duplicate package copies do not create duplicate contracts.
The ID alone is not sufficient. A consumer range of ^1.0.0 does not accept contract version 2.0.0.
The token alone is not sufficient. A token cannot override a different ID or an incompatible version in contract.json.
5. Choose a stability tier
Every declared service and surface has one stability tier.
| Tier | Manifest value | Change rule |
|---|---|---|
| Stable | "stable" |
Follow semantic version rules and provide a deprecation period. |
| Experimental | "experimental" |
The owner can change the contract in any release. |
Add optional fields in a compatible stable release. Use a new major version for an incompatible stable shape.
Keep the old major available during the deprecation period. This rule gives consumers time to change their ranges and code.
Use the experimental tier while the contract can still change quickly. The generated file keeps the marker visible to tools.
An experimental SDK member also starts with experimental_. A contract always uses the manifest stability marker.
6. Check compatibility at install
Install the local plugin after a successful build.
bb plugin install path:.The kernel reads bb.plugin.json, contract.json, and artifact.json as data. It does not import plugin code for this check.
The kernel checks these facts before activation:
- Each declaration ID starts with the owner plugin ID.
- Each declaration has one version, one type, and one kind.
- Each claim matches a declared ID, version, type, and keyed form.
- Each required, optional, or watched edge names a compatible service range.
- Each default claimant or provider implements the declared contract.
- Each exported schema reference stays inside the plugin artifact.
- Each stable contract follows semantic version rules.
- Each experimental contract has the explicit stability marker.
An engine or contract range mismatch gives the plugin the incompatible status. Invalid manifest and schema pairs give an invalid_contract error.
No plugin factory runs after these failures. The current installed generation stays available when an update fails.
What happens at runtime
The loader has already indexed each declaration and claim before it starts a factory.
The factory receives a typed api object from @get-bb/plugin/app, /server, or /host. API registrations have automatic cleanup.
The kernel resolves compatible contract IDs, selects global winners, and stages all implementations. It commits one plugin generation as one unit.
contract.json supports checks and inspection. The contracts module still supplies TypeScript inference and runtime token values.
Pitfalls
- Do not edit
dist/contract.json. Change the source contract or manifest, and run the build again. - Use dot-form contract IDs. For example, use
acme.counter.service. - Do not use a token object as persistent identity. Store the contract ID and a version range.
- Do not change a stable shape without a suitable semantic version change.
- Do not remove the stability marker from an experimental contract to make an install pass.
- Do not put runtime effects in
src/contracts.ts. Keep the module safe for every runtime tier.
See also
- Guide 2, Services and service edges, for provider and consumer factories.
- Guide 7, Surfaces and kinds, for the four kinds and
Original. - Guide 13, Testing, for
assertContractand loader harnesses. - Guide 14, Composition, forks, and distribution, for parent-owned contract claims.
- Design 01, Kernel, for the full manifest and
contract.jsonrules.