8. Derived surfaces: HTTP, SDK, CLI, tool, docs
A plugin defines a method once; @bb/kernel-contract derives the HTTP route, the typed SDK call, the CLI word, the agent tool and the reference docs from that one MethodDef (03 §1; kernel-contract:README). A plugin registers nothing by hand: there is no route table, no CLI parser per plugin, and no tool list in the manifest. expectDerivedSurfaces(def, samples) from @get-bb/plugin-sdk/testing checks all five for every method and every suite calls it (03 §7.4, D20). Path shorthand: <pkg>:<file> is packages/<pkg>/src/<file>.
8.1 What one method yields
| Surface | Derived name | Gate | Source |
|---|---|---|---|
| HTTP | /api/v1/<pluginId>/<service>/<method>; verb by kind |
expose.http (a custom method is always mounted) |
kernel-contract:http/routes.ts, http/mount.ts |
| SDK | sdk.plugins.<pluginId>.<service>.<method>(input) or sdk.call(ref, method, input) |
expose.sdk: X-BB-Client.kind: "sdk" callers get unknown_method when false |
sdk:client.ts |
| UI | useService(def).<method> in the app tier |
expose.ui: kind: "app" callers get unknown_method when false |
D5 |
| CLI | bb <group…> <word>; group from the service, word kebab(method) |
expose.cli (manifest only; not enforced over HTTP, D5) |
kernel-contract:cli/tree.ts |
| Tool | tool.name, else <plugin_id>_<service>_<method> in snake_case |
expose.tool + tool.presentation |
kernel-contract:tool/spec.ts |
| Command | mutations only: command, else <pluginId>/<service>.<method> |
always for kind: "mutation" |
kernel-core:commands.ts |
| Docs | one MethodDoc per method with any of http/cli/tool/sdk exposed |
— | kernel-contract:docs/reference.ts |
For hello-slot's greeter (§2.1): greet is GET /api/v1/hello-slot/greeter/greet, sdk.plugins["hello-slot"].greeter.greet({ name }), bb hello greet <name> [--salutation <string>], tool hello_slot_greeter_greet; count is POST /api/v1/hello-slot/greeter/count, command hello-slot/greeter.count (deadline 60 s), bb hello count <name>, no tool. hostGreet mounts as bb hello host-greet <name> because the method sets cli.name.
8.2 HTTP
| kind | verb | input | response |
|---|---|---|---|
| query | GET |
flat query params when every field is scalar, else input=<json>; arrays from repeated params; null spelled null |
200 {ok: true, result} |
| mutation | POST |
JSON body (application/json; an Origin-bearing request with another type is 415) |
200 {ok: true, result} |
| stream | GET + Accept: text/event-stream |
as query | SSE: event: item / id: <seq> / data: {seq, value}; then event: end {ok: true} or event: error {ok: false, error}; : keep-alive comment first and every 15 s |
| custom | any (ANY) |
plugin-defined; the serializable pair or a Fetch Request |
plugin-defined; the only kind that owns sub-paths |
One listener (127.0.0.1:38886 by default); one wildcard on /api/v1/* (plus /internal/*, /local/* for kernel path methods) resolved from the live catalog on every request, so a reload swaps handlers without remounting (03 §3.1). Bodies are capped at 1 MiB (413). Every answer carries Bb-Call-Id, Bb-Generation, Bb-Deadline and Bb-Server-Id. The deadline budget is blocking.maxMs, else 60 s for a mutation, else 30 s (to the first SSE frame for streams; kernel-contract:invoke.ts). A {kind: "bytes", mimeType, base64} result is served raw on ?raw=1 or a matching Accept (03 §3.5). Bb-Render: text; width=; color=; tty= switches the answer to text/plain through renderText (or genericText), and SSE items to {seq, text} (03 §5.5). A wrong verb is 405 invalid_input; an unknown key in the input is 400 with issues[0].path = [key] (D4). target: "host" answers 503 today (D5). Resumable streams read Last-Event-ID into ctx.since.
Error codes, HTTP status and CLI exit (kernel-core:errors.ts; 03 §3.2 owns both columns):
| code | HTTP | exit | when |
|---|---|---|---|
invalid_input |
400 | 2 | schema validation, unknown key, bad query encoding, unknown flag |
unknown_method / unknown_command / unknown_event |
404 | 2 | not in the catalog; CLI "did you mean" |
not_found |
404 | 3 | the handler throws it |
unauthenticated |
401 | 4 | credential required, none presented |
forbidden / vetoed |
403 | 7 | origin or auth refused; actor kind excluded by actors; a before-interceptor refused (data.by) |
conflict / precondition |
409 | 6 | handler-raised; duplicate registration |
service_unavailable / needs_configuration |
503 | 4 | provider not bound, disabled, or target: "host" |
timeout |
504 | 5 | deadline reached (retryable) |
cancelled |
499 | 130 | client disconnected or interaction cancelled |
invalid_output, invalid_facts, invalid_contract, reserved_name, stale_handle, scope_disposed, dispatch_depth, activation_loop, plugin_error, internal |
500 | 1 | kernel or plugin bug; a thrown non-KernelError is plugin_error |
<pluginId>/<code> |
errors[code].status |
2 / 3 / 6 / 7 for 400 / 404 / 409 / 403, else 1 | declared on the method; an undeclared code becomes plugin_error with data.undeclaredCode |
host/<code> |
declared, else 502 | same rule | kernel-host codes pass through unchanged |
Auth (kernel-contract:http/auth.ts; 03 §9). The boundary derives the principal from transport evidence, top to bottom, first match wins; a client cannot assert its own actor:
| evidence | caller.via |
actor | default client kind |
|---|---|---|---|
Authorization: Bearer <hostKey> + X-BB-Thread-Id |
host-proxy |
agent:<threadId> |
cli |
Authorization: Bearer <hostKey> |
host |
system:host:<hostId> |
internal |
x-bb-gate-auth: machine (+ x-bb-gate-machine-id, with the tunnel secret) ± X-BB-Thread-Id |
gate-machine |
agent:<threadId> or system:host:<machineId> |
cli / internal |
x-bb-gate-auth: session |
gate-session |
human:local |
sdk |
Authorization: Bearer bbt_… (valid local token) |
token |
human:local; tokenScope may limit the plugin set |
sdk |
Origin on the allowlist (else forbidden) |
origin |
human:local; browser session from the cookie jar |
app |
loopback, no Origin, no bearer, x-bb-context.threadId |
loopback |
agent:<threadId> |
sdk |
| loopback, nothing else | loopback |
human:local |
sdk |
none of the above on an auth: "none" method |
anonymous |
human:local (03 §9.1 has no row; README owner decision) |
sdk |
bridge tool/call / plugin handle |
tool / plugin:<id> |
agent:<threadId> / inherited or plugin:<id> |
— |
auth: "operator" admits loopback, origin, gate-session, token, host-proxy, gate-machine and tool; "machine" adds host; "none" admits anything and turns an unauthenticated refusal into the anonymous row (a forbidden origin stays refused). plugin:* relays always pass. local: true additionally requires a loopback address, no gate, and an allowed Origin when one is present. Remote requests with no credential are unauthenticated. A missing X-BB-Client is the headless SDK client; a malformed one is invalid_input. x-bb-context ({threadId, projectId, environmentId, hostId, cwd}, strict) fills ambient input fields the caller omitted and is the agent evidence on loopback.
8.3 SDK
connect(options?) → BBSdk (sdk:client.ts; 03 §4.1 spelled it createBbClient(baseUrl), folded into connect). serverUrl defaults to BB_SERVER_URL, then http://127.0.0.1:38886; context defaults to the BB_* ambient reader, so an agent shell is attributed to its thread; client defaults to {kind: "sdk", surface: "headless"}; loopback callers are credential-less.
| Member | Signature (abridged) | What it does | Spec | As built |
|---|---|---|---|---|
call |
(ref: ServiceRef, method, input, {signal?}) => Promise<Output> |
GET for query, POST for mutation; ref is a ServiceDef (kind known) or a service id string (kind from the manifest). Errors are rebuilt as KernelError. |
03 §4.1 | sdk:client.ts |
stream |
(ref, method, input, {since?, signal?}) => AsyncIterable<Item> |
SSE through parseSse/sseItems; every frame validated; a body that closes before end is internal. |
03 §4.1 | same |
raw |
(path, init?) => Promise<Response> |
Custom methods; adds the SDK's headers. | 03 §4.1 | same |
subscribe |
(target: {name, key}, cb) => () => void |
One realtime socket at /api/v1/kernel/realtime; subscriptions sent on open and re-sent after a reconnect. |
03 §8.4 | same |
onReconnect |
(cb: ({downMs, stale}) => void) => () => void |
Fires on resumed; resume carries empty watermarks today. |
03 §8.4 | sdk:README |
plugins |
sdk.plugins.<pluginId>.<service>.<method>(input, opts?) |
A thenable-and-async-iterable proxy: await a query/mutation, for await a stream. Typed by the merged BbServices; string-indexable otherwise. |
03 §4.1 (services) |
one proxy named plugins |
manifest |
() => Promise<CliManifest> |
kernel/contract.manifest, one request, cached per Bb-Generation. |
03 §5.2 | same |
types |
(moduleName = "@bb/sdk") => Promise<string> |
The .d.ts text bb sdk types writes: one BbServices entry per service from JSON Schema. |
03 §4.1 | sdk:types.ts |
serverId / generation / close |
() => Promise<string> / () => number | null / () => void |
Bb-Server-Id; last Bb-Generation seen; closes the socket. |
03 §4.1 | same |
Typing comes from declaration merging on BbServices: a script runs bb sdk types > bb.d.ts and gets one "hello-slot/greeter": ServiceClient<typeof greeter>-shaped entry per service from the manifest's JSON Schemas, or an @bb/sdk consumer that can import a contracts module writes the merge itself. A plugin's contracts module does not: ServiceClient is exported by @bb/sdk (packages/sdk/src/client.ts), not by @get-bb/plugin-sdk, and a plugin may not import @bb/sdk (D19). ServiceClient gives (input, {signal?}) => Promise<O> per query/mutation and (input, {since?, signal?}) => AsyncIterable<O> per stream. Inside a plugin the same contract is reached without HTTP: ctx.inject(greeter) in the server tier, useService(greeter) in the app (03 §4.2).
const sdk = connect(); // BB_SERVER_URL + BB_THREAD_ID… from the shell
const g = await sdk.plugins["hello-slot"].greeter.greet({ name: "bb" }); // typed once the contracts module is merged
for await (const ev of sdk.stream("notes-fixture/notes", "follow", { limit: 3 })) console.log(ev.id);8.4 CLI
Tree (kernel-contract:cli/tree.ts; 03 §5.3). A service claims cli.group (default [kebab(serviceName)], at most two levels, service aliases such as [["machine"]]); each expose.cli method mounts cli.name (default kebab(method)) plus cli.aliases under that group, or under its own cli.group (a method may enter another plugin's group; [] is the top level). A cli.flagAlias {group, name, flag} makes bb <group> <name> --<flag> run this method; the parser resolves the triple before the word. Collisions are "earlier claimant wins": one owner per group path (aliases included), one method per (group, word), one per flag-alias triple, a flag alias that is not also a flag of the aliased word, and no plugin word in a kernel group unless it is extensible (plugin, secret, host, runtime as built). The later claimant is dropped and reported; the bb binary prints warning: and never fails (D6; 03 §5.3 said install-time error). Tool names are checked in the same pass.
Flags come from the input JSON Schema plus cli.fields (03 §5.4):
| schema / meta | CLI form |
|---|---|
string, number, integer |
--<flag> <value>; <field> positional when positional: n; -x when alias |
boolean |
--<flag> (no value) |
enum (or anyOf with one) |
--<flag> <a|b|c>; membership checked before the call |
array of scalars |
repeatable --<flag> v --<flag> v; [field...] when variadic (one positional array field) |
object, array of objects |
--<flag> '<json>' |
contentEncoding: "binary" |
--<flag> <path> (declared; multipart upload not implemented) |
ambient: threadId|projectId|environmentId|hostId|cwd |
filled from BB_* env when omitted; never required; help says "default: current thread" |
field timeoutMs (integer) |
emitted as --timeout <seconds>, scaled ×1000 |
description / default |
help text / "(default: …)" in the field's unit; the CLI never applies defaults |
hidden: true |
accepted, absent from help |
required |
required unless it has a default or is ambient |
Conditional flags exist only when the method can act on them: --yes (destructive), --host <id|name> (an ambient: "hostId" field), --self (an ambient: "threadId" field), --since <cursor> (resumable), --timeout <seconds> (blocking or stream without a timeoutMs field: a client-side deadline, exit 5), --quiet (blocking or stream). Global flags on every word: --json, --server <url>, --no-color, --help, --version. cli.hidden hides a word from bb --help and the skill but keeps it in the manifest, bb <group> --help and the docs. cli.offline declares a second handler in the plugin's cli entry that runs without a server (caller.via: "offline").
Output and exit (cli:call.ts; 03 §5.5–5.6, D6): human text is the default (Bb-Render: text, rendered by core through renderText or genericText: scalars printed, objects as key: value, arrays of objects as a six-column table); --json prints the bare result as pretty JSON, NDJSON for streams, and {ok: false, error} on failure (03 §5.5 said the envelope; the built CLI prints the bare result). A destructive word asks y/N on a TTY unless --yes, and exits 2 ("refusing without --yes") with no TTY. SIGINT closes the request and exits 130. Exit codes: 0 success; 1 internal or undeclared plugin error (also a declined confirm, D6); 2 usage or invalid_input; 3 not_found; 4 no runtime, unauthenticated, service_unavailable, needs_configuration; 5 timeout; 6 conflict/precondition; 7 forbidden/vetoed; 130 interrupted or cancelled. bb <group> alone is exit 2. Help is rendered from the manifest's MethodDoc offline; bb --help folds a group named like a top word into that word's line.
For greet the usage line is bb hello greet <name> [--salutation <string>]: name is positional 0 and required; salutation has a default so it is optional and shown as (default: Hello).
At dc07292bf, a variadic positional of structured items keeps a non-JSON word as the string (bb thread send <thread> <text…>); the method's schema decides what a string means (03 §5.4).
8.5 Agent tool
toolSpecOf (kernel-contract:tool/spec.ts; 03 §6.1) builds one ToolSpec {tool: DynamicTool, ref, method, pluginId, instructions, destructive, blocking} per expose.tool method. tool.name is the declared name or <plugin_id>_<service>_<method> (kebab → snake), [a-zA-Z0-9_-]+ (checked by defineService), unique across the composition (a collision is a buildCliTree problem, surfaced as degraded, D8). The 64-character ceiling is enforced only by expectDerivedSurfaces (§9.6), not at definition or plan time. tool.description is tool.description, else summary + description, plus "Destructive: confirm with the user first." when destructive. inputSchema is the input JSON Schema with ambient fields kept but made optional and described "default: the current thread/project/host". presentation is tool.presentation with suppress filled. instructions (≤ 4096 bytes) is appended to the session instructions while the tool is active. custom methods cannot be tools.
runTool (kernel-contract:tool/adapter.ts; 03 §6.2) runs the call as actor {kind: "agent", id: threadId}, caller.via: "tool", render: "text", It uses the bridge session and the session's ambient context; the kernel fills ambient fields the model omits. The result is 06 §4.4's ToolResult:
| field | value |
|---|---|
success |
false on any envelope error; the error is one text block <code>: <message> |
content |
[{type: "text", text}] from renderText, else compact JSON; an image block for a {kind: "bytes", mimeType: "image/*"} result; a stream tool collects its items, one rendered line each |
ui |
{renderer: tool.ui.renderer, payload: result} when tool.ui is declared (the item array for streams), else null; the timeline renders it through the plugin's slot and falls back to content |
destructive tools still go through the thread's permission mode; blocking tools park like HTTP. Tool versus CLI (03 §6.3): both stay callable; the per-session skill text replaces the CLI line of any method whose tool is active with "use the <tool> tool". Methods with expose.tool are reachable from a sandbox without network (03 §9.5).
8.6 Docs
methodDoc (kernel-contract:docs/reference.ts; 03 §7.1) is a pure function of the normalized method: {pluginId, service, method, kind, summary, description, input, output, cli: CliWord | null, tool: {name, description} | null, http: {verb, path} | null, examples, errors, destructive, blocking, resumable, target, auth, local, actors}. cli is the CliWord itself (03 §7.4 spelled a {usage, flags} projection; usageLine(doc.cli) and flagRows(doc.cli) render those on either end), actors is null when all four kinds are admitted, and a method appears when any of http/cli/tool/sdk is exposed. The same record rides the CLI manifest (kernel/contract.manifest) and kernel/contract.reference {pluginId} → ReferenceDoc {pluginId, generation, services: [{id, summary, methods}]}, so bb guide reference <plugin> works offline and the Extensions UI shows the same text. renderReference emits, per service and method:
## hello-slot/greeter — Greet by name and count greetings
### bb hello greet (query, tool: hello_slot_greeter_greet)
Greet someone by name
Answers on every surface: HTTP, SDK, CLI (`bb hello greet <name>`), and the agent tool.
Usage: bb hello greet <name> [--salutation <string>]
Flags: <name> Who to greet
--salutation <string> The word before the name (default: Hello)
HTTP: GET /api/v1/hello-slot/greeter/greet
Output: object
Examples: bb hello greet bb # prints `Hello, bb!` and the counterskillReference(docs, {activeTools, disabledPlugins}) (kernel-contract:docs/skill.ts; 03 §7.2) is the generated command index: groups sorted, one usage line and summary per visible (cli.hidden: false) word, the ambient-field note, "Destructive: confirm with the user first.", "(disabled)" for disabled plugins, and the §6.3 substitution line for active tools; it carries no prose beyond authors' summaries. The guide plugin assembles <dataDir>/skills-generated/bb/SKILL.md from its preamble, every plugin's contributes.guide fragment (composition row order) and this text; bb guide [chapter] renders the fragments offline (03 §7.3). contract.json's guide field is the fragment path (§2.9).
expectDerivedSurfaces(def, samples) (kernel-contract:testing/index.ts) asserts per method: a route path, input/output JSON Schemas, a CliWord in the MethodDoc whose usage starts with bb <group> <word> and whose help has no blank lines, a tool name within limits plus a complete presentation when expose.tool, and that renderText returns a string for the sample output (a declared renderText without a sample is a problem). It throws one Error listing every problem. The cli package's parser round-trip is a separate check (03 §7.4 asked for it here).