Skip to content

Runtime contract ​

Build against this contract, not against host code, whether you're writing an operator's own tooling or a port. It is what crosses the boundary between the host and the services in guests: the orchestrator's control plane and the host controller's API.

Each section names the contract/ module that is its reference implementation.

SideStatusDesign
host ↔ orchestratorcovered herePRDs 0087 and 0092 (design repo)
agent and gatewaystill to be addedPRD 0086 (design repo)

Trust domains and tokens ​

contract/auth.py, contract/hostctl.py

Callers present compact HS256 JWTs whose payload carries a role.

DomainKey holderRolesKey envToken env
control-plane (CONTROL_PLANE)orchestrator, host CLIcli, gatewayBOT_BOTTLE_ORCHESTRATOR_TOKENBOT_BOTTLE_ORCHESTRATOR_AUTH_JWT
host-control (HOST_CONTROL)host controller, host CLIcli, orchestrator, gatewayBOT_BOTTLE_HOSTCTL_KEYBOT_BOTTLE_HOSTCTL_JWT

A token is signed, not encrypted, and long-lived. The protection is that only the key holder can mint one.

  • The orchestrator never holds the host-control key. It gets an orchestrator-role token, so it can't mint itself a cli token.
  • The gateway gets a gateway token for each domain it calls, never a key.

Orchestrator control plane ​

contract/http.py, served by orchestrator/api.py

RuleDetail
AuthThe token goes in x-bot-bottle-orchestrator-auth (ORCHESTRATOR_AUTH_HEADER). Missing or invalid gets 401. A valid token with the wrong role gets 403.
LimitsRequest bodies are at most 1 MiB (MAX_BODY_BYTES) and must arrive within 10 s (REQUEST_BODY_TIMEOUT_SECONDS). Unknown body fields are ignored.
RolesEvery route is cli-only except GET /health (no auth) and the three gateway routes below.
RouteRoleDoes
GET /healthnoneliveness
GET /gatewayclithe gateway's status
GET /bottlesclithe registry (secrets redacted)
POST /bottlescliregister a bottle: source_ip, metadata, policy, tokens, env_var_secret. Returns bottle_id, identity_token.
PUT /bottles/{id}/policyclireplace a bottle's egress policy
POST /bottles/{id}/reprovision_gatewayclire-push a bottle's gateway state
POST /bottles/{id}/secretcliupdate one egress secret
DELETE /bottles/{id}clideregister
POST /reconcileclireap rows for bottles that aren't running (grace_seconds, default 120). 503 if the live set is unknown.
POST /attributeclithe bottle_id for a source_ip and identity_token. 403 if neither matches.
GET /supervise/proposalsclipending supervise proposals
POST /supervise/respondcliapprove or deny a proposal. 409 already answered if it has a decision.
POST /resolvegateway, cliresolve a source IP and identity token to a bottle's policy and tokens
POST /supervise/proposegateway, clifile a proposal
POST /supervise/pollgateway, cliwait for a decision
POST /launchesclirelay a launch to the host controller. Returns {"job"}.
GET /launches/{job}clithe launch job's state
GET /launches/{job}/eventsclithe job's events (NDJSON stream)
GET /bottles/{id}/attachcliWebSocket relay of the bottle's terminal

Launches ​

POST /launches takes agent, bottles, label, color, prompt, headless, image_policy and assume_yes.

  • Paths and secret values are never forwarded.
  • Without assume_yes, the job ends with {"type": "error", "code": "confirm"}.
  • The /launches and attach routes answer 503 when the orchestrator has no host controller.

Process inputs ​

sh
python -m bot_bottle.orchestrator --host H --port P --db PATH

The signing key goes in BOT_BOTTLE_ORCHESTRATOR_TOKEN. The orchestrator refuses to start without one. BOT_BOTTLE_LOG_LEVEL=debug turns on the access log.

Host controller (as the orchestrator sees it) ​

contract/hostctl.py, served by control/hostctl/server.py

An orchestrator the host controller brought up reaches it through these.

InputDetail
ProvisioningBOT_BOTTLE_HOSTCTL_URL (HOSTCTL_URL_ENV) and an orchestrator token in BOT_BOTTLE_HOSTCTL_JWT. An orchestrator started any other way gets neither.
AddressPort 8098 (GUEST_PORT) on the host's end of the orchestrator's network: the docker publish bridge, the macOS vmnet gateway or the firecracker orchestrator TAP. Never 0.0.0.0.
ListenerIt accepts only peers on that network, and only the orchestrator and gateway roles. It answers /bottles/live only for its own backend.
AuthAuthorization: Bearer <token>.
RouteRolesDoes
GET /statusnonebuild id and pid
POST /bottlescli, orchestratorstart a launch job (a LaunchRequest). Returns {"job"}. cwd, copy_cwd, secrets and host_keys are cli-only.
GET /bottlescli, orchestratorbottles this daemon holds
DELETE /bottles/{id}cli, orchestratorstart a teardown job
GET /bottles/livecli, orchestrator{"bottles": [{backend, slug, source_ip}], "errors": {backend: message}}, read from the backends
GET /sessions/{id}cli, orchestratorwhether the agent is running, and its exit code
GET /sessions/{id}/attachcli, orchestratorWebSocket. Terminal bytes travel as binary frames. resize, writable, exit and detached travel as JSON text frames. Viewers are read-only unless the agent's manifest allows writing.
GET /jobs, GET /jobs/{id}the job's ownerjob list and state
GET /jobs/{id}/eventsthe job's ownerNDJSON: log lines, then one result or error

A backend in errors means its list is unknown, not empty. Callers must not treat it as "nothing is running".

Internal environment ​

bot-bottle sets these BOT_BOTTLE_* variables for its own processes. None is a knob. The ones you can set are in install.md.

scripts/gen_env_docs.py generates the table from bot_bottle/env_vars.py.

<!-- env-vars:internal:begin -->

VariableDoesDefault
BOT_BOTTLE_ORCHESTRATOR_URLThe orchestrator's control-plane URL, for the gateway's daemons.—
BOT_BOTTLE_ORCHESTRATOR_TOKENThe control-plane signing key; the orchestrator refuses to start without it.—
BOT_BOTTLE_ORCHESTRATOR_AUTH_JWTThe gateway's gateway-role token for the control plane.—
BOT_BOTTLE_HOSTCTL_URLThe host controller's URL, for an orchestrator it brought up.—
BOT_BOTTLE_HOSTCTL_KEYThe host-control signing key.—
BOT_BOTTLE_HOSTCTL_JWTA host-control token: orchestrator-role for the orchestrator, gateway-role for the gateway.—
BOT_BOTTLE_GATEWAY_DAEMONSWhich gateway daemons run (egress, git-http, supervise).all
BOT_BOTTLE_GIT_GATE_READY_FILEWhen set, git-http waits for this file before it starts.—
BOT_BOTTLE_SENSITIVE_PREFIXESComma-separated env var name prefixes whose values egress DLP blocks as known secrets (the canary).—
BOT_BOTTLE_SOURCE_HASHmacos-container: the source hash the running orchestrator was built from, to spot a stale one.—
BOT_BOTTLE_CLAUDE_HOST_ACCESS_TOKENSecret name the gateway injects a forwarded Claude login under.—
BOT_BOTTLE_CODEX_HOST_ACCESS_TOKENSecret name the gateway injects a forwarded Codex login under.—
BOT_BOTTLE_VERSIONThe host bot-bottle version string, injected into infra containers at startup so /version can report it.—
BOT_BOTTLE_COMMITThe host bot-bottle git commit hash, injected into infra containers at startup so /version can report it.—
BOT_BOTTLE_PROTOCOLThe host bot-bottle protocol version (integer), injected into infra containers at startup so /version can report it.—
<!-- env-vars:internal:end -->

Changelog ​

  • 2026-10-22: first version. Control-plane routes and roles, the host controller's API as an orchestrator sees it, both trust domains, and the env each service reads (PRD 0087 chunk 7, design repo).
  • 2026-10-30: the internal environment table, generated from bot_bottle/env_vars.py. BOT_BOTTLE_ORCHESTRATOR_DEBUG is gone. Use BOT_BOTTLE_LOG_LEVEL=debug.

Apache 2.0