noggin-rpc
noggin-rpc is the wire protocol every split-process noggin host speaks. Today that means the desktop app and the VS Code extension; a future web host would speak it too.
The protocol is transport-agnostic: the same envelopes ride over Electron IPC, window.postMessage, or a future WebSocket. Picking a transport is a host-author concern. Speaking the protocol is a contract every noggin-rpc client and server upholds.
The reference implementation lives in @noggin/rpc. This page is the contract: anyone could build a noggin-rpc client or server in a different language against this spec.
Status: shipping. Phases 1–3 of the noggin-rpc plan — protocol types, server adapter, and the client-side
RemoteNoggin— are all in@noggin/rpc. The desktop app (Phase 4) and the VS Code extension's webview (Phase 5) both drive their engine overcreateNogginRpcServer; the same UI components render against aRemoteNogginin both hosts.
Architecture
A noggin-rpc connection always has two ends:
- Server. The trusted side. Holds the engine, provider registry,
and
HostServices(file pickers, input boxes, etc.). Runs in the desktop main process, the VS Code extension host, or a future server-side runtime. - Client. The UI side. Holds the React components, the
optimistic update layer (Phase 3), and a
RemoteNogginadapter that translates UI verb calls into RPC requests.
One server, one client, one transport: there is no broker. Multi-renderer hosts (Electron with multiple windows) pair one server per renderer.
Envelope
Every wire message is one of six discriminated shapes. JSON-serialisable and order-preserving over the transport.
type RpcMessage =
| { type: 'request'; id: string; method: string; params?: unknown }
| { type: 'response'; id: string; result?: unknown }
| { type: 'error'; id: string; error: RpcErrorPayload }
| { type: 'notification'; method: string; params?: unknown }
| { type: 'ping'; id: string }
| { type: 'pong'; id: string };
interface RpcErrorPayload {
code: string; // 'rpc.*' for framework errors, engine codes for verb errors
message: string; // human-readable, not stable
data?: unknown; // engine errors include { exitCode }
}
Implementations MUST:
- Echo
idverbatim on responses, errors, and pongs. - Preserve message order from the same peer.
- Treat a
requestwith no registered handler asrpc.method-not-found(server) or as an out-of-band error (client). - Reply to a
pingwith apongof the same id.
Implementations MUST NOT:
- Multiplex multiple logical channels over one connection (use multiple connections).
- Coalesce or batch notifications (each notification is one message).
- Drop messages silently on full buffers — they must surface as
rpc.disconnected.
Method surface
Five families of methods. The TypeScript shapes are normative — when this page and the types in rpc/src/protocol.ts disagree, the types win.
noggin.* — lifecycle and reads
| Method | Purpose |
|---|---|
noggin.open | Open a noggin by canonical location. Returns a per-connection sessionId and the full document snapshot. |
noggin.close | Release server resources for a session. |
noggin.snapshot | Re-request the full document. Used to recover after a missed noggin.changed (UI heard about an error and wants to resync). |
noggin.show | Server-side equivalent of the engine's verbs.show — returns the current tree view without mutating. |
noggin.subscribe | Begin streaming noggin.changed / noggin.errored notifications for the given session. Returns a subscriptionId. |
noggin.unsubscribe | Stop the stream. Idempotent; unknown ids are silently ignored. |
noggin.open
request: { location: string; opts?: Record<string, unknown> }
response: { sessionId: SessionId; snapshot: NogginDocument; describe: string }
location is a canonical URI the user/agent supplied (file:///abs/path.yaml, memory://x, localstorage://demo, …). The server picks a provider by scheme prefix; URIs without a scheme are rejected with no-scheme.
The snapshot is the complete document, the same shape as the on-disk YAML, ready for the UI to project into a tree. After noggin.open, the client typically issues noggin.subscribe to receive incremental changes; otherwise it would have to poll noggin.snapshot.
Errors:
no-provider— no provider registered for the scheme.no-scheme—locationlacks a<scheme>://prefix.no-location— emptylocation.- engine errors from the provider's
open()(e.g.lock-timeout).
noggin.subscribe / noggin.unsubscribe
// noggin.subscribe
request: { sessionId: SessionId }
response: { subscriptionId: SubscriptionId }
// noggin.unsubscribe
request: { subscriptionId: SubscriptionId }
response: { subscriptionId: SubscriptionId }
While subscribed, the server pushes noggin.changed and noggin.errored notifications. Both carry the originating subscriptionId so the client routes them to the right consumer.
Ordering: the server MUST deliver a noggin.changed for a mutation BEFORE the response to the verb that caused it. This is what lets the optimistic UI layer reconcile a write deterministically: the prediction landed, then the verb resolves with the same authoritative view.
verb.* — mutations
One method per engine verb. Same shape:
request: { sessionId: SessionId; opts: <VerbOptions> }
response: CurrentTreeView // except delete (DeleteResult) and copy (CopyResult)
| Method | Engine verb | Returns |
|---|---|---|
verb.push | verbs.push | CurrentTreeView |
verb.add | verbs.add | CurrentTreeView |
verb.move | verbs.move | CurrentTreeView |
verb.goto | verbs.goto | CurrentTreeView |
verb.done | verbs.done | CurrentTreeView |
verb.pop | verbs.pop | CurrentTreeView |
verb.edit | verbs.edit | CurrentTreeView |
verb.note | verbs.note | CurrentTreeView |
verb.delete | verbs.delete | DeleteResult |
verb.copy | verbs.copy | CopyResult |
verb.copy is the one two-noggin verb: its request carries both sourceSessionId and destSessionId. Both sessions must already be open on the same server.
Verbs MUST return errors using engine codes (no-active-item, path-not-found, cycle, etc.) so the client can pattern-match on them with the same code paths used in-process.
host.* — host services
These flow client → server even though they're called "host" services. The UI lives in the client process; the host runtime (Electron main, VS Code extension host) lives in the server process.
| Method | Purpose |
|---|---|
host.pickFile | Show a file-open dialog; returns selected paths (or [] on cancel). |
host.pickNewFile | Show a save-as dialog; returns the chosen path or null. |
host.showInputBox | Single-line text input modal. |
host.showQuickPick | List of options with optional filter. |
host.showConfirm | Yes/no modal. |
host.showError | Error toast / dialog. Always resolves { acknowledged: true }. |
host.openExternal | Hand a URL or path to the OS default handler. |
Servers that don't implement a host service (e.g. a headless test server) reject calls to it with rpc.method-not-found. Clients should degrade gracefully — these are UI conveniences, not core verbs.
provider.* — registry and discovery
| Method | Purpose |
|---|---|
provider.list | Enumerate registered providers (scheme + default flag + display name). |
provider.describe | Detailed info for one provider. |
provider.create | Drive the provider's user-facing flow for creating a new noggin (e.g. "Save As…"). Returns a location or null on cancel. |
provider.open | Drive the provider's user-facing flow for opening an existing noggin (e.g. file picker). Returns a location or null. |
provider.listInstances | Provider-specific catalog (recents, known cloud noggins, …). |
provider.create and provider.open are deliberately separate from host.pickFile: the provider gets to choose its own UX flow (it may use host.* building blocks internally, but the contract here is "give me a noggin location," not "give me a file path").
Notifications
Two server-to-client notification methods. Both are scoped to an active noggin.subscribe:
// noggin.changed
{
subscriptionId: SubscriptionId;
sessionId: SessionId;
changes: ItemChange[]; // same vocab as the engine's onDidChange
snapshot?: NogginDocument; // authoritative state AFTER the changes
}
// noggin.errored
{
subscriptionId: SubscriptionId;
sessionId: SessionId;
code: NogginErrorCode | string;
message: string;
exitCode?: number;
}
noggin.changed carries the authoritative document snapshot AFTER changes were applied. Clients use it to rebase optimistic predictions; without it the ItemChange shape alone isn't enough (it carries field-name lists for updated, not values). The field is optional only so a future bandwidth-constrained transport can negotiate diffs-only delivery — the reference server adapter always sends a snapshot.
noggin.errored covers errors that fire outside a verb call — file watcher detecting a malformed file, lock-acquisition timeout from a peer writer, etc. Per-verb errors are returned in the verb's error envelope, not via this notification.
Heartbeats
Optional. When enabled on a side:
- The side sends a
pingwhen idle forintervalMs. - The peer MUST answer with a
pongof the same id. - If the
pongdoesn't arrive withintimeoutMs, the side declares the connection dead, rejects pending requests withrpc.heartbeat-timeout, and firesonDisconnect.
Both client and server can enable heartbeats independently. The default is off (intervalMs: 0).
Error model
All errors arrive as RpcErrorPayload (in an error envelope or inside a thrown NogginRpcError on the client). Codes split into three buckets:
- Framework codes (
rpc.*) — produced by the RPC layer: -rpc.disconnected-rpc.disposed-rpc.timeout-rpc.method-not-found-rpc.heartbeat-timeout-rpc.handler-error(server handler threw a non-NogginError) - Engine codes — forwarded verbatim from the engine's
NogginError. See Response envelope for the canonical list. - Custom codes — anything else a server handler chooses to throw.
The wire data field is optional. For engine errors the server includes { exitCode } so the CLI's exit-code contract survives the round trip.
Subscription lifecycle, formally
client server
│ │
│── noggin.subscribe { sessionId } ─────────▶│
│◀──────────── { subscriptionId } ───────────│
│ │
│ (any verb mutates the noggin) │
│◀─── noggin.changed { subscriptionId, … } ──│
│◀─── noggin.changed { subscriptionId, … } ──│
│ │
│── noggin.unsubscribe { subscriptionId } ──▶│
│◀──────────── { subscriptionId } ───────────│
│ │
│ (no further notifications for this id) │
If the transport drops while a subscription is active, the client's RpcClient rejects all pending requests with rpc.disconnected and fires onDisconnect. The server's RpcServer cleans up its subscription registry. Re-establishing the connection requires the client to re-open the noggin and re-subscribe — subscription ids do not survive a disconnect.
Server adapter
Building a noggin-rpc server by hand means wiring 28 methods to the engine, provider registry, and host services. The createNogginRpcServer adapter does that for you:
import { createNogginRpcServer } from '@noggin/rpc';
import { createElectronIpcMainTransport } from '@noggin/rpc/transports/electron-ipc';
import { ipcMain } from 'electron';
const transport = createElectronIpcMainTransport(ipcMain, mainWindow.webContents);
createNogginRpcServer({
transport,
hostServices, // your HostServices implementation
providerFlows: { // optional: provider-level UX flows
create: (scheme) => /* run a Save As dialog, return location */,
pickToOpen: (scheme) => /* run an Open dialog, return location */,
},
});
// That's it. Every `noggin.*`, `verb.*`, `host.*`, `provider.*`
// method is now answered correctly.
The adapter manages:
- Session lifecycle. Per-connection
sessionIdkeyed to a realNogginreturned byopenNoggin(location, opts).noggin.closedisposes the noggin and clears any subscriptions still attached to it. Disconnect cascades through to dispose every open session. - Verb dispatch. Every
verb.*method routes to the matching function inverbs.*from@noggin/engine. Engine errors flow back through the wire with their original stable codes. - Subscriptions.
noggin.subscribereturns asubscriptionId, hooks the engine'sonDidChangeto pushnoggin.changednotifications, and hooksonDidErrorto pushnoggin.errored.noggin.unsubscribeis idempotent; unknown ids resolve silently. - Host services.
host.*calls go straight to the injectedHostServicesimplementation. Missing services reject withcode: 'not-implemented'. - Provider registry.
provider.listenumeratesproviders.list()from@noggin/engine.provider.create/provider.open/provider.listInstances/provider.describedelegate to the injectedproviderFlows; without them they reject withcode: 'not-implemented'.
HostServices
The interface a host implementation fulfills. Seven methods, one per host.* RPC. Cancellation is encoded in the response shape ({ value: null }, { paths: [] }, { confirmed: false }); throw only for actual failures.
interface HostServices {
pickFile(opts: HostPickFileRequest): Promise<HostPickFileResponse>;
pickNewFile(opts: HostPickNewFileRequest): Promise<HostPickNewFileResponse>;
showInputBox(opts: HostShowInputBoxRequest): Promise<HostShowInputBoxResponse>;
showQuickPick(opts: HostShowQuickPickRequest): Promise<HostShowQuickPickResponse>;
showConfirm(opts: HostShowConfirmRequest): Promise<HostShowConfirmResponse>;
showError(opts: HostShowErrorRequest): Promise<HostShowErrorResponse>;
openExternal(opts: HostOpenExternalRequest): Promise<HostOpenExternalResponse>;
}
The desktop app's main process implements this against Electron's dialog.* and shell.openExternal. The VS Code extension implements it against vscode.window.show* and vscode.env.openExternal. The RPC layer never sees those differences — the server-adapter routes through HostServices and the wire shape is identical.
Client-side optimistic application
@noggin/rpc ships both sides of the protocol. The client entry point is openRemoteNoggin({ client, location }) which does the noggin.open + noggin.subscribe handshake and returns a RemoteNoggin ready to drive.
import { openRemoteNoggin, RpcClient } from '@noggin/rpc';
const client = new RpcClient(myTransport);
const remote = await openRemoteNoggin({
client,
location: 'file:///work/today.yaml',
});
remote.onDidChange(() => render(remote.items));
await remote.push({ title: 'go' }); // optimistic — UI updates first
RemoteNoggin implements Noggin — the same engine interface an in-process noggin satisfies. UI components and createNogginActions take a Noggin and don't know or care whether the bits arrive in-process or over a transport.
Optimistic apply, formally
Every verb call follows the same three-phase pattern:
- Predict. The client holds a memory noggin seeded from the server's last-confirmed snapshot. Every verb is run against this local engine BEFORE the RPC fires — the prediction is literally the same engine logic the server will run, so it's guaranteed structurally equivalent. The op is recorded in a FIFO queue of in-flight predictions.
- Apply. The diff between the pre-predict and post-predict
document is dispatched through
onDidChange. UI components re-render immediately, well before the RPC round-trip completes.
- Reconcile. The server processes the verb, fires its own
onDidChange, and pushes anoggin.changednotification with the authoritative new snapshot. The notification arrives BEFORE the verb's response (the server-adapter sends them in that order). The client's notification handler pre-consumes the matching FIFO entry and rebuilds the local memory noggin from the server's snapshot, replaying any still-pending ops on top.
If the server rejects the verb, the response arrives instead of a notification; the client removes the pending entry and rebuilds local without it. The thrown error is forwarded to the caller with its engine code intact (path-not-found, cycle, etc.).
Why predictions don't drift
The prediction engine is real @noggin/engine — same applyOps, same atomic-op composition, same change-event vocabulary. Two clients can never disagree about what verb.push({title: 'x'}) does to a given document. The only place a prediction can differ from the server is when the document underneath it has shifted (another client wrote first). For those cases the rebase step in phase 3 above replays the still-pending ops on top of the new authoritative state.
Tests as a spec
The component-level test optimistic-ui-flow.test.tsx injects 50 ms of one-way RPC latency and asserts:
- A gesture's optimistic effect appears within 20 ms.
- A chord of three rapid gestures all predict-apply before any RPC round-trip completes; the final state matches the server's serialised order.
RemoteNoggin.test.ts covers the unit behaviours: prediction, reconciliation, rollback on server reject, ordering preservation, lifecycle.
Reference implementation
The TypeScript types in rpc/src/protocol.ts are the normative spec for request/response/notification shapes. The @noggin/rpc package ships:
- The
RpcMessageenvelope and type guards. RpcClient+RpcServer: generic, protocol-agnostic.NogginRpcError+ the wire ↔ thrown converters.- Three transports:
MemoryTransport,ElectronIpcTransport,PostMessageTransport. createNogginRpcServer+HostServices— the server adapter that maps everyRpcProtocolmethod to engine / provider / host calls.RemoteNoggin— the client-side optimistic adapter that implements the engine'sNoggininterface over a transport.openRemoteNoggin— one-call factory (open + subscribe + ready).
Phases 4 and 5 of the noggin-rpc plan swap the desktop renderer and the VS Code webview onto this stack.