Interfaces & bindings

July 12, 2026 · View on GitHub

Interfaces are xbin's dependency-injection and capability system in one mechanism: a component requests typed slots in its manifest, tiles (or xbind builtins) provide them, and the owner binds each request to a provider. The binding is the authorization — unbound means no capability, and nothing a component writes about itself can change that. One model covers network egress, service APIs, raw TCP dependencies, inbound router links, and (as a third direction) publishing to the outside world.

Related: 12-egress.md (the net family in depth) · 13-ingress.md (exposes and ingress sources) · 06-authorization.md (grants this composes with) · 14-lifecycle.md (the @archive pseudo-slot) · reference: /docs/elements.md, /docs/protocol.md · design: plans/interfaces.md, plans/ingress.md.

Why typed slots instead of hard-coded paths

A component that hard-codes fetch("/api/apps/llm-gw/v1/chat") has welded itself to one provider, invented its own authorization story, and hidden a dependency where no one can see it. The interface model inverts all three (decision IFACE-1):

  • Declared, not discovered. Dependencies live in the manifest, where the owner, the admin UI, bx iface, and other agents can read them.
  • Swappable behind a contract. A slot names a service contract ({kind:"http", service:"openai"}), not a path. Swap Ollama for a proxy tile by re-binding; the requester never changes.
  • The binding is the authorization. Only the owner/admin creates bindings; an agent can declare wants but can never satisfy them itself. Default-deny is preserved: an unbound slot grants nothing — no egress, no endpoint, no reachability.

Each kind is a family with its own wiring mechanism and its own admin UX (IFACE-2); new families plug into the same request/provide/bind spine.

     component manifest                     workspace manifest (owner-only)
  ┌────────────────────────┐               ┌──────────────────────────────┐
  │ "interfaces": {        │   bx bind /   │ "bindings": {                │
  │   "llm": {kind:http,   │  admin tile   │   "apps/chat": {             │
  │           service:...} │ ────────────▶ │     "llm": "apps/llm-gw" }}  │
  │ }                      │               └──────────────┬───────────────┘
  └────────────────────────┘                              │ resolved at spawn

  ┌────────────────────────┐                XBIN_IFACE_LLM_URL=…  (backend)
  │ provider manifest      │                xbin.iface("llm").url (frontend)
  │ "provides": {          │                + the call is GRANTED at the
  │   "openai": {kind:http,│                  provider's declared role
  │     service:"openai",  │
  │     role:"writer"}}    │
  └────────────────────────┘

The three directions

sectiondeclaresoffers tobound by
interfacesslots this component NEEDS— (it's a request)owner → a provider
providesslots it OFFERSother tiles(requesters bind it)
exposesendpoints it OFFERSthe outside worldowner → an ingress source

exposes is the ingress plane's mirror of this model — same manifest-declares / owner-binds / unbound-is-unreachable shape, with the binding carrying the public route. It gets its own chapter: 13-ingress.md.

The kind families

kindsidewhat a binding deliversdetail
netrequest + provideL3 egress: the netns's default route, through a builtin or a provider tile12-egress.md
httprequest + providea service URL injected into the requester; the binding is also the call grantbelow
streamrequesta raw TCP address for a sibling's exposed portbelow
lan-ingressrequest + providean inbound link leg into a router tile's subnet (ING-6)below + 13-ingress.md
ingressprovide onlymarks a tile as an HTTP ingress terminator13-ingress.md
gpu, resourcedeclared in the model, still delivered as gpu:* / res:* grants today (IFACE-4/5); they fold into bindings later06-authorization.md, 10-resources.md

net — L3 egress

"interfaces": { "net": { "kind": "net" } }, bound to the internet / host / lan:<cidr> builtins or to a tile that provides {kind:"net"} (a firewall, VPN, or router — the middlebox pattern). No binding = an empty network namespace = zero IP egress. A multi net slot is rejected at bind time (it would mean multiple default routes). The whole family — relay, splice, provider tiles, cap:net-admin — is 12-egress.md.

http — service contracts

The workhorse family. Requester:

"interfaces": { "llm": { "kind": "http", "service": "openai" } }

Provider (the llm-gw builtin):

"provides": {
  "openai":  { "kind": "http", "service": "openai",     "role": "writer" },
  "metrics": { "kind": "http", "service": "prometheus", "role": "reader" }
}
  • Service contracts are conventions on the service string — reuse standard names (openai, prometheus, mcp for Model Context Protocol servers, …) so components stay interchangeable; invent a new one only for a genuinely new API shape. A provider may offer several http provides; a binding selects the provide matching the slot's service, deterministically — so a multi-provide tile like llm-gw grants writer through its openai provide and reader through metrics, never a coin-flip.
  • Binding-as-grant. The provide's role (default reader) is what a bound requester holds on the provider — no separate uses entry, no second approval. One owner action wires the dependency and authorizes the call.
  • No network involved: http bindings ride the gateway socket and the normal RBAC spine (05-identity.md) — they work for tiles with zero egress.

Multi-input slots (multi: true, http-only — IFACE-6): the requester explicitly declares it accepts a set — e.g. the chat builtin's llm and mcp slots take any number of model providers and MCP servers. The owner's control becomes a multiselect; the injection becomes a JSON list (below); binding order is preserved.

Provider instances (instances: true on a provide — IFACE-7): for providers whose outputs are runtime config, not manifest data (one IMAP tile serving three accounts). The provider registers a plain map[id]pathPrefix at runtime:

PUT /api/xbin/iface-instances   { "instances": { "abc": "/accounts/abc" } }

Self-scoped (a provider registers only its own; admin may pass component); prefixes are provider-relative — xbind composes /api/<provider><prefix>, and absolute /api/… registrations are rejected so install paths never leak into persisted state. Each instance binds as provider#id and appears in pickers as a first-class option implementing the base contract — an instance-unaware requester connects to one unchanged. Registration replaces the whole map and re-wires bound requesters; a vanished instance leaves its binding visibly unresolved, never silently rewired.

stream — a raw TCP dependency on a sibling

"interfaces": { "db": { "kind": "stream" } }        // + bx bind apps/app db=apps/postgres#pg

Binds to a sibling's exposed stream slot (provider#expose-slot; the target must declare exposes: { pg: {kind:"stream", port:…} } — tcp only). The requester gets an ordinary dialable address (XBIN_IFACE_DB_ADDR=10.0.2.2:20000) that xbind splices into the provider's in-sandbox port — no ports opened on the host, no shared netns, and the binding is the authorization, exactly like http. Policy mayCall ceilings apply to the target (06-authorization.md).

lan-ingress — an inbound leg from a router tile

"interfaces": { "vpn": { "kind": "lan-ingress" } }   // bound to a tile providing lan-ingress

Gives a service tile a second, addressed link into a net-provider tile's subnet (ING-6): the tile learns its stable address (XBIN_IFACE_VPN_IP, e.g. 10.43.0.2), the provider learns the client map (XBIN_LAN_INGRESS), and inbound traffic the provider terminates (a VPN, say) routes straight to the tile. It is an L3 link — the provider can reach all of the tile's ports and is trusted to filter. Details ride with the ingress plane: 13-ingress.md.

Binding mechanics

Storage. Bindings live in the workspace xbin.json (machine-managed, owner-plane): bindings[component][slot] = ref | [refs…]. A ref is "provider[#instance]". Since the ingress work, a ref may carry route config — {ref, host|zone, listen} — for exposed endpoints; a config-free ref still marshals as the plain string, so every pre-existing manifest parses and round-trips unchanged.

Who binds. Owner/admin only — the API (POST /api/xbin/bindings) is admin-gated, and AGENTS.md restates the rule for agents: declare interfaces/provides/exposes, leave binding to the owner. bx grammar:

bx bind <comp> <slot>=<ref>            # replace (bind or rebind)
bx bind <comp> <slot>+=<ref>           # add to a multi slot's set
bx bind <comp> <slot>-=<ref>           # remove from the set
bx bind --unset <comp> <slot>          # clear (back to default-deny)
bx iface                               # providers, requests, current wiring

Validation at bind time (the friendly half — resolution re-checks everything at evaluation, so a hand-edited manifest can't out-run policy):

ruleapplies to
slot must exist in the component's interfaces (or exposes, or be an @ pseudo-slot)all
multi-ref sets only on {kind:http, multi:true} slots; no duplicate refsall
a component can never be its own providerall
provider must offer a matching provide (net ⇒ provides net or a builtin id; http ⇒ same service; lan-ingress ⇒ provides lan-ingress)by kind
#instance refs only against instances-provides with that id registered; bare refs rejected where the provide declares instances; net/lan-ingress take no #instancehttp/stream
stream refs must name provider#expose-slot, target tcp; policy mayCall must cover the providerstream
policy ceiling: a net deny refuses net/lan-ingress binds; an ingress deny refuses expose binds — with the blocking row namednet, exposes
expose slots: hostname authority, listen address, and conflict rules13-ingress.md

Rebinding restarts. Interface wiring is materialized at spawn (env vars, TUNs, splices), so a bind/unbind/rebind restarts the requester's backend — and the old and new providers too when their client roster changed. Instance re-registration likewise restarts bound requesters. This is deliberate: wiring changes are loud, atomic events, not something a running backend half-observes.

Pending binds — the bind-on-install prompt. Every requested slot with no binding (and every unbound exposed endpoint, flagged expose:true) surfaces in GET /api/xbin/bindings as pending, each with its candidate options: the builtins for its kind plus every tile whose provides match (service-filtered; instances expanded to provider#id entries). The shell's bx-bindings prompt and the admin tile's interfaces tab render exactly this list — importing a tile that requests net and openai immediately asks the owner two concrete questions. (The net picker offers internet and host; lan:<cidr> is accepted as a typed ref.)

What the component sees

Injection is exact and captured at spawn. <SLOT> is the slot name uppercased, non-alphanumerics → _.

bindingbackend envfrontend (xbin.iface(slot))
http, singleXBIN_IFACE_<SLOT>_URL=http://xbin/api/<provider><prefix> (+ XBIN_IFACE_<SLOT>_INSTANCE=<id> when instanced){url:"/api/…", service, instance?}
http, multiXBIN_IFACE_<SLOT>= JSON [{provider, instance?, url, service}]{service, multi:true, endpoints:[…]}
streamXBIN_IFACE_<SLOT>_ADDR=10.0.2.2:<port> — dial it like any TCP address
lan-ingressXBIN_IFACE_<SLOT>_IP=<own address>; the provider side gets XBIN_LAN_INGRESS= JSON client map
netno variable — the network namespace itself is the delivery
provide ingressXBIN_INGRESS_FORWARD_URL=http://10.0.2.2:8642 (the terminator's hand-back door)

http://xbin is the gateway pseudo-host (backends dial the unix socket; the SDK's client handles it); frontends get same-origin /api/… paths and fetch them directly. Resolution never guesses: a ref whose provider vanished or whose instance was unregistered simply yields no endpoint and shows as broken in the wiring UI.

The @archive pseudo-slot

Backup and offload need "which archiver tile stores this component's snapshots" — an owner decision with the exact shape of a binding, minus a manifest declaration. @ pseudo-slots fill that: bindings["*"]["@archive"] is the workspace default, bindings["<comp>"]["@archive"] a per-component override; single-valued, owner-set through the same API. The backup engine resolves override-then-default. See 14-lifecycle.md.

Chains, the graph, and trust

A provider is itself a requester — of its own egress, its own resources. Paths through the binding graph therefore compose: a tile bound to a firewall tile bound to a VPN tile bound to internet is a three-hop chain that nobody declared as a "chain" (IFACE-3 — chaining is emergent from the graph). The graph must stay a DAG; binding a component to itself is rejected outright, and longer cycles are the owner's to avoid when wiring (there is no transitive cycle check today).

The trust framing to keep in mind when binding:

  • A net provider is a privileged tap — it sees and can rewrite every packet of its bound clients. The binding is the explicit, owner-made statement that this is wanted.
  • Providers are themselves sandboxed tiles (08-sandbox.md): a compromised provider is contained to its client links plus whatever it was bound to.
  • http providers see plaintext only for calls made to them; they can't read TLS of traffic they merely carry (a CA-trust binding for DPI is deliberately unbuilt).

The unifying rule, one last time, because everything above is an instance of it: a manifest can only ever ask; the owner's binding is the only thing that gives.