RemoteAdapter over a Transport
A RemoteAdapter turns a remote SPOKE connect peer into a drop-in async BaselinePorts surface: you supply a message-oriented Transport, the adapter dials and completes the signed-hello handshake through it, and you then call the same port methods — getKnowledgeEntry, putRelation, listTimelineEvents, and the rest — as if the peer were local. orchestrateUpsert(adapter, req) and the other orchestrate* calls run unchanged on the caller.
The adapter ships in two packages: the TypeScript @42ch/spoke-connect/remote subpath and the Rust remote-adapter cargo feature of spoke-connect. Both enforce protocol version 2 envelope authentication internally (see Envelope authentication in the Connect architecture).
1. The Transport seam
A Transport is a consumer-implemented seam that carries connect envelopes between the adapter and the remote peer. It is message-oriented: one call moves exactly one connect envelope.
| Method | Contract |
|---|---|
send(envelope) | Accepts exactly one connect envelope's bytes |
recv() | Returns the next inbound envelope; blocks until one arrives or the connection closes |
close() | Releases resources; idempotent |
One envelope per call — a byte-stream carrier applies length-prefix (or equivalent) delimiting before handing envelopes to the adapter. The packages ship an in-memory loopback pair for tests: loopbackTransportPair() (TypeScript) / loopback_transport_pair() (Rust) return the client and server ends of one connection, and closing either end fails the peer's pending recv exactly like a real connection drop. The loopback pair is test-only; WebSocket and other carriers are consumer-side implementations of the same three methods.
2. TypeScript — @42ch/spoke-connect/remote
pnpm add @42ch/[email protected]connectRemoteAdapter dials through your transport — signed hello exchange, allowlist check, session snapshot verification — and resolves to an established adapter:
import { derivePeerIdFromEd25519Pubkey, getPublicKeyEd25519 } from "@42ch/spoke-connect";
import { connectRemoteAdapter } from "@42ch/spoke-connect/remote";
const adapter = await connectRemoteAdapter({
transport, // your Transport implementation
localIdentity: { seed }, // 32-byte Ed25519 seed
localManifest, // your HostCapabilityManifest
remotePubkey, // the remote peer's 32-byte Ed25519 public key
allowlist: [derivePeerIdFromEd25519Pubkey(remotePubkey)],
invokeTimeoutMs: 5000, // optional; bounds the handshake and each invoke
});| Option | Meaning |
|---|---|
transport | Your Transport implementation; the adapter sends and receives envelopes through it |
localIdentity.seed | 32-byte Ed25519 seed for the local connect identity |
localManifest | Your HostCapabilityManifest, advertised in the signed hello |
remotePubkey | The remote peer's 32-byte Ed25519 public key; the remote peer_id is derived from it and must be on the allowlist (fail-closed) |
allowlist | Peer ids this adapter accepts; the remote peer_id must be listed |
invokeTimeoutMs | Optional per-invoke timeout; on elapse only that call fails and the session stays usable (default 5000) |
The established adapter exposes read-only session info — state, sessionId, remotePeerId, remoteManifest — and close():
adapter.state; // "Established"
adapter.sessionId; // the remote-assigned session id
adapter.remotePeerId; // the authenticated remote peer_id
adapter.remoteManifest; // the remote peer's HostCapabilityManifest
adapter.close(); // releases the session; idempotentA dial failure — configuration error, handshake rejection, or dial timeout — rejects the connectRemoteAdapter promise; no adapter instance exists.
3. Rust — the remote-adapter feature
cargo add spoke-connect --features remote-adapter
cargo add async-traitThe Transport trait mirrors the TypeScript interface (async fn methods, Send + Sync; close defaults to a no-op):
use async_trait::async_trait;
use spoke_connect::remote::{Transport, TransportError};
#[async_trait]
impl Transport for MyTransport {
async fn send(&self, envelope: &[u8]) -> Result<(), TransportError> {
// deliver exactly one envelope's bytes to the peer
Ok(())
}
async fn recv(&self) -> Result<Vec<u8>, TransportError> {
// return the next inbound envelope, or Err(TransportError::Closed)
// when the connection closes
Ok(Vec::new())
}
async fn close(&self) -> Result<(), TransportError> {
// release resources; idempotent
Ok(())
}
}connect_remote_adapter performs the dial and returns Arc<RemoteAdapter>:
use std::sync::Arc;
use spoke_connect::remote::{
connect_remote_adapter, RemoteAdapterOptions, RemoteIdentity,
};
let adapter = connect_remote_adapter(RemoteAdapterOptions {
transport: Arc::new(my_transport),
local_identity: RemoteIdentity { seed: client_seed }, // 32-byte Ed25519 seed
local_manifest: client_manifest, // your HostCapabilityManifest
remote_pubkey: host_pubkey, // remote peer's 32-byte Ed25519 public key
allowlist: vec![peer_id_host.into()],
invoke_timeout_ms: None, // None uses the default (5000 ms)
capability_token: None, // optional capability-token proof attached as `auth`
})?;Session info comes back as Options (populated once the session establishes); close() is synchronous:
adapter.state(); // RemoteAdapterState::Established
adapter.session_id(); // Option<String>
adapter.remote_peer_id(); // Option<String>
adapter.remote_manifest(); // Option<HostCapabilityManifest>
adapter.close();4. Call the BaselinePorts methods
The adapter implements the same async BaselinePorts six families as a local adapter — knowledge entries, relations, scope queries, findings, rules, and the host manifest views — so orchestrate* calls run unchanged on the caller:
import { orchestrateUpsert } from "@42ch/spoke-operations";
const response = await orchestrateUpsert(adapter, upsertRequest);use spoke_operations::{orchestrate_upsert, SpokeResult};
// SpokeResult is a plain Ok/Reject enum — there is no `?` support, so match
// it explicitly (the same pattern the crate's own tests use).
let response = match orchestrate_upsert(adapter.as_ref(), upsert_request).await {
SpokeResult::Ok(response) => response,
SpokeResult::Reject(reject) => return Err(reject), // your error path
};You can also call the port methods directly:
const put = await adapter.putKnowledgeEntry(entry, null);
const got = await adapter.getKnowledgeEntry(entry.entry_id);Each port method maps to a reserved port.* product op (port.knowledge.put, port.relation.get, …) carried as the invoke op; the mapping is internal to the adapter. See Port-method ops in the wire reference.
Optional port families
Beyond the baseline six families, the adapter ships the optional l2-computable (project / compute) and l5-fork (listForkTimelineEvents) faces. They are plain port methods on the same established session — the demo client drives all three round-trips over a real WebSocket (examples/connect-demo/client/src/main.ts):
const projectedResult = requireOk(
await adapter.project({
session_id: COMPUTABLE_SESSION_ID,
entry_id: COMPUTABLE_ENTRY_ID,
state: { ...PROJECT_STATE },
}),
);
const computedResult = requireOk(
await adapter.compute({
session_id: COMPUTABLE_SESSION_ID,
entry_id: COMPUTABLE_ENTRY_ID,
computable: { ...COMPUTE_DELTA },
settle: true,
}),
);
forkEvents = requireOk(
await adapter.listForkTimelineEvents({
scope_id: DEMO_SCOPE_ID,
fork_id: DEMO_STORM_FORK_ID,
}),
);The PROJECT_STATE and COMPUTE_DELTA constants the snippet spreads are defined in the demo client source (examples/connect-demo/client/src/main.ts): the static state the session projects and the delta compute merges.
The family must be negotiated: both manifests declare it in capabilities[], so the session's negotiated_capabilities contains it. The demo gates its optional steps on its own manifest's declarations — the negotiated set is the intersection, so a server that did not declare a family denies loudly instead of being skipped. Denials map through the shared dispatch-deny row: wire op_unsupported / capability_missing → CAPABILITY_PORT_MISSING reject with details.wire_code preserved (section 6 below).
The Rust adapter exposes the same faces as project / compute / list_fork_timeline_events; over FFI the same methods live on RemoteAdapterFFI (per-language casing in the symbol map). Serving the families on the responder side — the library ports option, the Rust RemoteServePorts seam, and the foreign-callback PortsHandler — is documented in Optional port families.
5. Remote extraction and the ownership gate
Two further surfaces ride the same established session, each behind its own capability flag: the extract core op delegates a whole extraction to the peer, and the ownership gate conditions the Scope-bearing ops on a reader viewpoint. Declare each flag in both peers' HostCapabilityManifest.capabilities[] — negotiated_capabilities is the both-hello intersection, so a flag both sides declared is negotiated and the invoke is served, and a flag present on one side only answers the responder's deny branch.
Remote extraction (ke-extraction)
extract is a core op served as a whole-operation service face: the adapter delegates the whole extraction and decodes the peer's wire ExtractResponse. The request carries source references, the serving host owns source loading and extraction, and the loader value stays host-local inside its own machinery:
const result = await adapter.extract(request); // request: ExtractRequestrequest is { run_id, sources, entry_types?, extensions? }: a non-empty correlation id plus a non-empty SourceAnchor list, each anchor's optional span narrowing the referenced artifact. The success branch is { candidates, run } — an empty candidates array is a successful zero-result run, every returned candidate carries status: "provisional", and run.run_id echoes the request verbatim. The Rust reference drives the same op with adapter.extract(request).await, and over FFI the method is RemoteAdapterFFI.extract(extract_request_json).
Serving extraction is a connect-owned service face: the TypeScript ports provider adds RemoteExtractService.extract(request), the Rust reference injects a RemoteExtractService (composing a mixed host with RemoteServePortsComposite::with_extract), and the FFI responder serves it through PortsHandler.extract(extract_request_json). The manifest declares capabilities and roles independently: the offering extract host announces the input-source role as descriptive metadata about that host, while ke-extraction is the capability flag that gates dispatch of the op.
The ownership gate (ke-ownership)
The three Scope-bearing port ops — port.scope.list_knowledge_entries, port.scope.list_timeline_events, and port.fork.list_timeline_events — additionally require ke-ownership when the request's Scope carries a non-empty viewpoint string. The gate reads payload.scope.viewpoint as supplied and treats any non-empty string as ownership-bearing, so a Scope whose viewpoint is empty or unset keeps serving under the op's row capability alone. The method call is unchanged — the existing scope query carries the gate. The Rust reference drives the same witness as adapter.list_knowledge_entries(&scope).await, and over FFI it is RemoteAdapterFFI.list_knowledge_entries(scope_json):
const listed = await adapter.listKnowledgeEntries({
scope_id: DEMO_SCOPE_ID,
viewpoint: holderId,
});The two flags are independent: a host can serve extraction, the ownership gate, both, or neither, and each is declared on both sides of the hello exchange. Refusals stay on the existing error vocabulary — the dispatch deny, the missing service face, a serving callback's own refusal, and the router's terminal reject are catalogued in Refusal surfaces.
The demo drives both surfaces end to end in examples/connect-demo/client/src/main.ts, against a host that declares both flags in examples/connect-demo/server/src/adapter/mock-adapter.ts.
6. Concurrency and errors
Concurrent port calls on one established session are allowed: outbound sequence is allocated at send time, responses demultiplex on request_id, and completions may arrive out of order. Each pending invoke carries an adapter-owned timeout; on elapse only that call fails and the session stays usable.
Port calls settle to SpokeResult; invoke-path failures surface as rejects:
| Failure | Surface |
|---|---|
| Transport I/O | INTERNAL_ERROR reject with details.kind = "transport" |
| Session closed / connection loss | INTERNAL_ERROR reject with details.kind = "session_closed"; the adapter transitions to Closed and every pending invoke fails |
| Invoke timeout | INTERNAL_ERROR reject with details.kind = "timeout" (that waiter only) |
| Correlation mismatch | INTERNAL_ERROR reject with details.kind = "correlation_mismatch" |
| Sequence exhaustion | INTERNAL_ERROR reject with details.kind = "sequence_exhausted"; the session closes — open a new one |
| Envelope-auth rejection | INTERNAL_ERROR reject with details.kind ∈ {envelope_auth_missing, envelope_auth_invalid, envelope_auth_session_unbound} (that waiter only; the session stays usable) |
Dispatch deny (op_unsupported / capability_missing wire codes) | CAPABILITY_PORT_MISSING reject with details.wire_code |
| Unknown wire codes | INVALID_INPUT reject with details.wire_code |
Dial / hello / allowlist / nonce failures happen before an adapter exists: connectRemoteAdapter rejects (TypeScript) or returns Err(RemoteAdapterError) with Config / Handshake / ProtocolVersionMismatch / Timeout variants (Rust).
7. Envelope authentication
The adapter enforces protocol version 2 per-envelope authentication internally on every post-hello envelope, with nothing to configure. Connect architecture explains the property; the wire reference holds the algorithm ids, the signed field sets, the verify rules, and the per-envelope enforcement detail.
8. Loopback smoke
The in-repo loopback pair gives you the whole flow with no network: the server end is served by the repository's test loopback host (tests/remote/loopback-host.ts — test-only), the client end is dialed by connectRemoteAdapter:
import { derivePeerIdFromEd25519Pubkey, getPublicKeyEd25519 } from "@42ch/spoke-connect";
import { connectRemoteAdapter, loopbackTransportPair } from "@42ch/spoke-connect/remote";
// startLoopbackHost is an in-repo test fixture, NOT a package export —
// consumers write their own host (or copy this one from the linked file).
import { startLoopbackHost } from "<repo>/packages/spoke-connect-ts/tests/remote/loopback-host.ts";
const clientSeed = /* your 32-byte Ed25519 seed */;
const hostSeed = /* the remote peer's 32-byte Ed25519 seed */;
const pair = loopbackTransportPair();
// Server end (test-only): the repository's loopback host serves a local
// async BaselinePorts adapter over the server end of the pair.
const host = await startLoopbackHost({
transport: pair.server,
seed: hostSeed,
clientPubkey: getPublicKeyEd25519(clientSeed),
allowlist: [derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(clientSeed))],
adapter: toyWorldAdapter,
hostManifest,
});
// Client end — the shipped consumer surface.
const adapter = await connectRemoteAdapter({
transport: pair.client,
localIdentity: { seed: clientSeed },
localManifest,
remotePubkey: getPublicKeyEd25519(hostSeed),
allowlist: [derivePeerIdFromEd25519Pubkey(getPublicKeyEd25519(hostSeed))],
});
const put = await adapter.putKnowledgeEntry(entry, null);
const got = await adapter.getKnowledgeEntry(entry.entry_id);
adapter.close();
host.close();The same flow is the journey's terminal step: RemoteAdapter from native bindings drives the identical handshake over FFI with a foreign-callback Transport.
Next steps
- Integrate a RemoteAdapter against a live host — the step-by-step learning path through the same contract against the demo mock inference host.
- Expose and invoke remote tools — advertise tools in the manifest, register handlers on the dial, and let the host discover and reverse-invoke them.
- Route across multiple peers — a multi-peer router composes N registered adapters behind the same
BaselinePortssurface. - RemoteAdapter from native bindings — the same adapter lifecycle as a synchronous FFI surface from C#, Go, Kotlin, Python, or Swift.
- Connect from the TypeScript client — the language-native client surface, including the
./remoteentry point. - Connect wire reference — envelope field tables, envelope authentication, and the port-method ops catalogue.
- Connect architecture — session lifecycle, envelope authentication, and capability routing.