noggin

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 over createNogginRpcServer; the same UI components render against a RemoteNoggin in both hosts.

Architecture

A noggin-rpc connection always has two ends:

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:

Implementations MUST NOT:

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

MethodPurpose
noggin.openOpen a noggin by canonical location. Returns a per-connection sessionId and the full document snapshot.
noggin.closeRelease server resources for a session.
noggin.snapshotRe-request the full document. Used to recover after a missed noggin.changed (UI heard about an error and wants to resync).
noggin.showServer-side equivalent of the engine's verbs.show — returns the current tree view without mutating.
noggin.subscribeBegin streaming noggin.changed / noggin.errored notifications for the given session. Returns a subscriptionId.
noggin.unsubscribeStop 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:

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)
MethodEngine verbReturns
verb.pushverbs.pushCurrentTreeView
verb.addverbs.addCurrentTreeView
verb.moveverbs.moveCurrentTreeView
verb.gotoverbs.gotoCurrentTreeView
verb.doneverbs.doneCurrentTreeView
verb.popverbs.popCurrentTreeView
verb.editverbs.editCurrentTreeView
verb.noteverbs.noteCurrentTreeView
verb.deleteverbs.deleteDeleteResult
verb.copyverbs.copyCopyResult

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.

MethodPurpose
host.pickFileShow a file-open dialog; returns selected paths (or [] on cancel).
host.pickNewFileShow a save-as dialog; returns the chosen path or null.
host.showInputBoxSingle-line text input modal.
host.showQuickPickList of options with optional filter.
host.showConfirmYes/no modal.
host.showErrorError toast / dialog. Always resolves { acknowledged: true }.
host.openExternalHand 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

MethodPurpose
provider.listEnumerate registered providers (scheme + default flag + display name).
provider.describeDetailed info for one provider.
provider.createDrive the provider's user-facing flow for creating a new noggin (e.g. "Save As…"). Returns a location or null on cancel.
provider.openDrive the provider's user-facing flow for opening an existing noggin (e.g. file picker). Returns a location or null.
provider.listInstancesProvider-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:

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:

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:

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:

  1. 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.
  1. 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.
  1. Reconcile. The server processes the verb, fires its own onDidChange, and pushes a noggin.changed notification 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:

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:

Phases 4 and 5 of the noggin-rpc plan swap the desktop renderer and the VS Code webview onto this stack.