kolu
Docs

How to mirror a surface over ssh

An agent serves its surface on the machine it runs on. A browser can’t reach that process. This guide fronts the remote surface with a local mirror: you dial the host, keep a live local copy of everything it serves, and re-serve that copy to browsers over the same contract. The browser only ever talks to the local copy; the ssh pipe swaps invisibly behind it.

Mirroring a surface over ssh in three hopsA remote agent serves its surface over stdio. The parent dials it over ssh (hop one), pumps its frames into a local implementSurface copy (hop two), and re-serves that copy over a direct or WebSocket link (hop three). The browser only ever faces the local copy; only the ssh pipe behind it swaps.remote agentserves its surfaceover stdiothe source of truthparent process② pumppumpRemoteSurfacefolds frames intoimplementSurfacethe LOCAL copya live mirror ofeverything served③ re-servedirectLink / wsbrowserfaces thelocal copy① dialssh · makeSession+ sshConnectorthis pipe swapsfolds inthe browser always faces the LOCAL copyonly the pipe behind it swaps
The remote agent serves over stdio; the parent ① dials it over ssh, ② pumps its frames into a local implementSurface, and ③ re-serves that copy to the browser. The browser only ever talks to the local copy — only the ssh pipe behind it swaps.

You need the remote agent’s defineSurface(…) contract and its binary’s .drv (a Nix derivation).

1. Dial the host

A session over ssh is makeSession (the transport-agnostic reconnect loop) plugged with sshConnector (the ssh transport). You own the session — key your own map and tear it down yourself; there is no shared pool.

// `AgentClient` is the structural member face — there is no contract type to be
// generic over. The connector takes the SURFACE as a VALUE: Effect RPC builds
// its client from `surface.group`, and the face is re-nested from
// `surface.spec` — neither is recoverable from a type alone, and passing the
// surface is what makes the dialled face and the served group provably the same
// tag set.
const session: Session<AgentClient, SshProv> = makeSession({
  initialConnection: "probing", // an ssh session provisions before it connects
  connectOnce: sshConnector({
    surface: base,
    host: "[email protected]", // any ssh target; "localhost" short-circuits
    binary: "my-agent", // exe name inside the realised closure
    // Policy-free: YOU (the consumer) compose the localhost arm's spawn env, keeping only
    // the keys that are SET (an empty HOME/PATH would misdirect lookups). kolu uses
    // kolu-pty's `composeSpawnEnv`. Never the caller's ambient `process.env`; unused for ssh.
    localEnv: Object.fromEntries(
      (["HOME", "PATH"] as const)
        .map((k): [string, string | undefined] => [k, process.env[k]])
        .filter((e): e is [string, string] => e[1] !== undefined),
    ),
    resolveDrvPath: () => resolveDrv("bob.example"), // deferred — see the caution
  }),
});

2. Pump its frames inward

Implement the mirror surface locally, then run pumpRemoteSurface to fold the agent’s frames into it. A standalone re-serve serves the agent’s base surface verbatim.

// Re-serve the agent's base surface verbatim. (SR9: per-host connection health is a
// host-map concept — a `@kolu/surface-map` map's `entries` channel carries the fine
// connection payload, produced by `serveHostMap`; a standalone re-serve like this
// carries no `connection` cell.)
const source = base;
const runtime = implementSurface(source, {
  cells: { load: { store: loadStore } },
  collections: {
    processes: {
      readAll: () => processes,
      upsert: (k, v) => {
        processes.set(k, v);
      },
      remove: (k) => {
        processes.delete(k);
      },
    },
  },
});

void pumpRemoteSurface({
  source,
  session,
  makeSink: ({ seq: _seq }) => ({
    // built per spawn — per-client state resets
    cells: { load: (v) => runtime.ctx.cells.load.set(v) },
    collections: {
      processes: {
        upsert: (k, v) => runtime.ctx.collections.processes.upsert(k, v),
        remove: (k) => runtime.ctx.collections.processes.remove(k),
      },
    },
  }),
});

makeSink is a factory: it takes { seq } (the spawn counter, not a client) and the pump rebuilds it on every spawn, so no per-client fold survives a reconnect.

3. Re-serve the same surface locally

The mirror runtime’s { group, handlers } is what any transport takes — there is nothing to finalize and nothing to re-prefix, because a tag carries its own route. A directDispatch over the runtime is the in-process consumer; a browser reaches the same pair over a WebSocket, with the app layer’s acceptSurfaceSocket gating each upgrade and serveSurfaceSocket serving it (see @kolu/surface-app). The browser now consumes the local copy exactly as if the agent were in-process.

// The mirror runtime's `{ group, handlers }` is what any transport takes; a
// browser consumes the local copy exactly as if the agent were in-process.
const dispatch = directDispatch(runtime);

Connection health is a host-map concept

Connection health is state the remote agent cannot report — it can’t observe the link to itself. When you serve many hosts as a host map (serve a map), that health rides each entry as a fine connection payload — serveHostMap produces the coarse dot and the fine word from the same session frame in one projection, the ONE authority both derive from (see @kolu/surface-remote). A standalone re-serve like the one above carries no per-host connection state; if a real one ever needs it, the mechanism graduates from the host map with that consumer in hand. For the model behind this pump, see The server half.