| name | surface |
| description | How a downstream app consumes the shared @kolu/surface stack (@kolu/surface · surface-app · surface-remote · surface-mcp) — declaring a typed reactive surface, serving it, consuming it (SolidJS hooks or a CLI), and mirroring a remote surface over ssh. Grounded in the real consumers: kolu, drishti, odu, and the TUIs. Load when wiring a surface server/client/mirror, or reaching for makeSession / a link / the `.use()` hooks. CHANGING the framework is gated separately — `.claude/rules/surface.md` (a paired, CI-green drishti PR pinned to final kolu HEAD). |
Using @kolu/surface (downstream consumer guide)
Declare a typed reactive surface once; the framework derives the Effect RPC group, wires the
server, and binds the Solid client hooks. This is the consumer guide — changing the
framework needs a paired drishti PR (.claude/rules/surface.md).
Who uses it — match the closest consumer, don't hand-roll
| Consumer | Shape | Notable |
|---|
kolu (client+server) | one browser ⇄ one Node server, ONE ws | single-tier; two sibling surfaces; uses surface+surface-app only (not -remote) |
drishti (srid/drishti) | browser ⇄ Node ⇄ ssh agent mirror | the canonical twin; 3 workspaces (common/agent/app) |
odu (juspay/odu) | CI runner: stdio lanes → unix-socket fan-in → CLI/MCP | serve+consume+mirror over every transport at once; surface-mcp projection |
kolu-cli (kolu ls/create/send/wait/…) | one-shot CLI verbs, no browser | the primary CLI consumer: a PURE padi client — transport-blind {client,dispose}, unix-socket local, ssh remote (--host); no .use() hooks. padi-tui/kaval-tui are the same shape, retiring |
The spine (real import paths)
- Define —
defineSurface({cells,collections,streams,events,procedures}) (@kolu/surface/define). Many surfaces over one transport: composeSurfaceContracts(map) + sibling clients, never merged. Several flat GROUPS onto one wire (a sibling bundle beside a keyed map, hand-written root procedures, an unprefixed root surface): mergeDisjointGroups({label: group}) — the one counted merge, which names the tag and both halves on a collision. Never a bare RpcGroup.merge (last-writer-wins, silent).
- Serve —
implementSurface(surface, deps) / implementSurfaces(map, base, perKeyDeps) (@kolu/surface/server) returns a supervised runtime { router, ctx, done, close }. router is the FINAL top-level router — serve it directly (no implement(surface.contract).router(...) re-wrap; a second finalize double-prefixes /surface/surface/… and 404s). Observe done (owned-fault channel) and own close at teardown. Serve on a SHARED caller-owned publisher via implementSurfaceOnPublisher / implementSurfacesOnPublisher. A ROOTED bundle whose sibling set MOVES while it is served is implementRootedSurfaces(core, base, coreDeps) — mount(key, surface, deps) returns a MountedSurface {key, surface, ctx, drop()} (the registration carries its own undo), group/handlers are LIVE reads (read them as a pair in one turn), composition is INCREMENTAL (survivors keep handler identity, stores and sources), and a drop refuses at a record an open connection already captured — in-flight subscriptions included (SurfaceSiblingDropped). Live-ness is a property of the ROOTED door because a rootless wire's reserved probes target the FIRST sibling, which a moving roster can drop.
- Consume (SolidJS) —
surfaceClient(surface, link) / surfaceClients(link, map) (@kolu/surface/solid) → client.cells.X.use({authority,initial,onError}), .collections.X.use({keys,onError}) then .byKey(id)?.() / .keys(), .streams.X.use(inputFn,{onError}) with .pending()/.error(), .
Links (transport, swappable)
websocketLink(ws) (/links/websocket) · stdioLink (/links/stdio) · unixSocketLink({socketPath}) (/links/unix-socket) · directLink(router) (/links/direct, in-process identity, for tests). Serve side: serveOverStdio (/peer-server), serveOverUnixSocket (/unix-socket), and for browsers serveSurfaceApp (@kolu/surface-app/serve) — don't hand-roll a listener: it owns the whole order (origin gate → upgrade → stale-tab check → heartbeat enrolment → serve) plus the shell layers, the bind and the scope-registered teardown, and it reads the inbound frame cap from RPC_MAX_FRAME_BYTES so a consumer cannot undercut the wire. TLS (tls), an HTTP middleware, an app's own routes and a dist-less dev shell (clientDist is optional) are all options — kolu's own listener rides it. Reach past it to the granular acceptSurfaceSocket + serveSurfaceSocket only when the app picks WHICH runtime serves each upgrade — ?host= per-host dispatch (drishti), the one decision the accept seam leaves at the call site. CLIs keep ONE transport-blind Connection = {client, dispose} so every command is written once across local vs ssh. serveSurfaceApp and serveOverUnixSocket take an optional per-face expose (@kolu/surface/expose: exposeFace(surface, map) / exposeFaces for a bundle / exposeRootedFaces for a root beside a bundle, the same ExposeMap serveSurfaceAsMcp takes and the same classifyExpose grammar), so a verb can be reachable over the unix socket and not to browsers. WHICH faces take a map, which gate themselves, and what a key grants are stated once — in that module's header. Read it there rather than from memory.
Mirror a remote surface (drishti / pulam-web / odu)
- Dial the host —
makeSession({connectOnce: sshConnector<contract>({host, binary, resolveDrvPath})}) (@kolu/surface-remote): long-lived, nix copys the agent closure, runs <bin> --stdio over ssh, reconnects. buildRemotePool fans out N hosts; one-shot CLIs use dialAgentOnce instead.
- Mirror inward —
pumpRemoteSurface({source, session, makeSink, …}) (-remote) folds the remote agent's frames into a local implementSurface re-serve via a SurfaceSink (makeSink, @kolu/surface/mirror). The parent implements the same surface; a remotely-unobservable cell (e.g. connection state) is parent-authoritative.
- Re-serve — the local re-served surface (a FINAL
implementSurface router) served on /rpc/ws, accepted via acceptSurfaceSocket (@kolu/surface-app/server). Browsers connect with connectSurface (@kolu/surface-app/solid), which bundles socket + websocketLink + surfaceClient + a default-on liveness heartbeat. connectSurfaces is the multi-surface twin, and its core: {surface, name} slot carries an UNPREFIXED root beside the siblings — typed client, health enrolment under name, and the reserved probes addressed to the root's bare surface/system/*. A rooted app has no reason to hand-assemble the seam. A SIBLING ROSTER CHANGE IS A REDIAL, not an update — Effect RPC bakes the RpcGroup into the client at openWireLink, so new tags need a new wire at both ends; conn.redial(surfaces) owns it, re-using this connection's own options, keeping the root, and dialling the replacement BEFORE releasing the old wire.
Gotchas (hard-won, all real)
- Procedures call off the FULL link, not the scoped client —
surfaceClients per-key .rpc is typed unknown; reach the root link for raw procedures.
- Raw streaming —
unenrolledStreamCall(client.X, input, {signal, onRetry}) (@kolu/surface/client) carries the reconnect (STREAM_RETRY) context; a bare client.X(…) silently loses it. There is no stream namespace (.claude/rules/streaming.md is stale on that point).
- Consume streams fine-grained — value-bearing →
.streams.use() (replace-each-frame); delta-accumulate → mirrorRemoteSurface / createSubscription+reduce. Never coarse-read-and-copy: same-shape frames coalesce and the view freezes.
- Snapshot-then-deltas + fail-fast — a cell always opens with a snapshot;
firstFrameOrThrow (@kolu/surface/first-frame) treats an empty stream as a link failure, never a silent empty.
- Liveness is on by construction — framework-reserved
surface.system.live; connectSurface / HostSession / createServerLifecycle default their watchdog to it (probeSurfaceLive). Don't nominate your own probe unless you mean to.
- Version skew — gate on
isContractVersionCompatible (major.minor), never a string ==.
- Nix-baked deps — odu declares no
@kolu/* in package.json; they're symlinked at Nix build (the bake-in-via-Nix convention). A bare pnpm install won't resolve them.
Reference
Runnable end-to-end: packages/surface/example/ (its mini-ci stdio example is odu's seed). Full API + rationale: each package's README.md. Match the closest consumer above; don't reinvent a primitive the table already shows how to use.