Skip to article
Browse chapters

Wire services

How one plugin reaches another's behaviour, in process by type and across processes by key, method and schema.

Two rendezvous, one idea

A plugin may not depend on another plugin (ADR-0001). What it may do is look up a service: a live object its owner registered under a string key.

In process, the rendezvous is by type. Owner and consumer import one contract crate and meet at the same TypeId — the lookup downcasts to the concrete type both of them named:

pub fn service<T: Any + Send + Sync>(&self, key: &str) -> Option<Arc<T>>;

A TypeId cannot cross a process. So across one, a service is met by the same trio the tool contract already rides — a string key, a method name, and a JSON schema (ADR-0031). Across a process boundary the contract has to take a form its consumers can read, and that form is a schema, not a type.

The one new trait

#[async_trait]
pub trait WireService: Send + Sync {
    async fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError>;
}

That is the whole of it, and it is the only trait this design mints. What a particular service means lives in its own api crate, never in the sdk, because the kernel keeps no feature nouns.

A method the service does not speak is refused with the set it does speak. A ServiceError carries words and nothing to branch on: a consumer that needs kinds is talking to a typed trait in an api crate, not to this face.

Crossing is the owner’s choice

A contribution carries both faces of one live object:

Contribution::Service {
    key: "acme.linter".into(),
    value: handle.clone(),   // what host.service::<T>(key) downcasts to
    wire: Some(adapter),     // what a process's service/call reaches
}

Without a wire face, the service does not exist to a process at all. Registering one is a deliberate act, and the adapter that turns the typed handle into the wire face is mechanical — the typed trait is the source, the adapter is derived, and neither side hand-writes the contract twice.

An external service — one nothing knew about until its handshake answered — enters the registry through HostApi::open_service, which builds both faces from the one object. A consumer reaches it with host.service::<ServiceHandle>(key) and calls it by method; N external services are N of those, differing by the process behind them and never by type. Both service_wire and open_service default to refusing in words, so a host that keeps no services says so rather than failing quietly.

Declaring one from outside

An external plugin declares its services in the handshake:

{
  "services": {
    "acme.linter": {
      "methods": {
        "lint": { "type": "object", "properties": { "path": { "type": "string" } } }
      }
    }
  }
}

The schema is for whoever writes the caller; nothing validates against it. The method names, though, are the host’s: a method the declaration never named is refused before it crosses.

A plugin that needs a service says so in requires:

requires: ["service:acme.linter"]

A missing requirement disables the plugin with a notice. It never crashes the host.

One method, both directions

{"jsonrpc":"2.0","id":7,"method":"service/call",
 "params":{"key":"acme.linter","method":"lint","params":{"path":"src/lib.rs"}}}

The same line serves both ways. Host to process, it drives an external implementation of a service. Process to host, it lets an external consumer call anybody’s — the connection grows reverse requests for exactly this. External to external routes through the host: the registry is the router, and there are no process-to-process pipes.

Neither side reads params. It is the service’s own contract, and the bridge is a pipe for it. A service with nothing to say answers null, which is an answer; a service that could not answer fails the call.

Two external plugins can therefore pair on a service with no Rust written by anyone. And a Rust consumer of an external service programs against its api crate’s trait and cannot tell the implementation moved out of process.

Services carry no kernel authority

A service call is code-to-code between plugins the person installed. The permission gate is not involved, and the schema’s own words say a service call is the plugin’s own act. Nothing about a service can loosen what a tool may do.

The host’s own two doors

The bridge registers one reserved service, bingo.host, so a process can reach the host through the same lane it reaches any service (ADR-0033). Its methods are the only things a plugin process may ask of the host, and there are two of them.

ask {call, question} → {answer} puts a question to the person.

{"key":"bingo.host","method":"ask",
 "params":{"call":"<the callId from tool/call>","question":{ … }}}

The call is the whole of the grant. The bridge already tracks running calls for progress and cancellation, and that liveness is what authorises the question: an ended call is not there to ask on, and another connection’s call is not this caller’s to reach. Both are refused in words. The question is the sdk’s own InteractionKind and rides the same asking machinery an in-process tool’s question does; there is no second question path. Nothing is minted for it.

notice {level, message} → {} says one line for the person, under the plugin’s own name, at any time. It is the one door scoped by nothing: it spends nothing but a line. The level is the kernel’s own three, and an omitted one is the quietest.

What a door is, and what it is not

The taxonomy behind this is worth stating, because it is what keeps the surface small. What is visible and attributable — spawning, posting — crosses as an ordinary plugin-owned service. What only observes rides the hook observation lane. What spends — money, the person’s attention, or their private data — crosses as an allowance: minted where a crossing begins, metered, and dead when the crossing ends. Nothing is ambient and nothing is renewed.

A grant is a spend, never a permission. No grant is Allow-shaped, and the verdict plane is untouched by all of it. A future capability enters as one more method on bingo.host plus one scoping fact, each behind its own decision naming its scope and its accounting — and a door mints only where its scoping fact exists nowhere else. What is not a method does not exist across the line.