Gateway Adapter Pattern

The Gateway Adapter is the single architectural abstraction that enables Diminuendo’s frontend code to run identically on web and desktop. It defines a uniform interface for gateway communication using Effect types, allowing React components, hooks, and Zustand stores to operate without knowledge of whether messages travel over a direct WebSocket or through Tauri IPC to a Rust backend.

The Problem

Diminuendo has two frontend clients with fundamentally different transport mechanisms:
  • Web client: Connects directly to the gateway via WebSocket from the browser. The TypeScript SDK (DiminuendoClient) manages the connection, sending JSON messages and receiving JSON events.
  • Desktop client: Cannot (or should not) hold a WebSocket from the renderer process. Instead, the Tauri v2 Rust backend holds the WebSocket connection via the Rust SDK. The React frontend communicates with the Rust backend through Tauri’s IPC mechanism (invoke for commands, listen for events).
Without an abstraction layer, every component, hook, and store would need conditional logic:
// This is what we want to avoid
if (platform === "web") {
  client.runTurn(sessionId, text)
} else {
  invoke("run_turn", { sessionId, text })
}

The Solution

The GatewayAdapter interface defines all gateway operations using Effect types. Commands return Effect.Effect<T, GatewayError>. The event stream is a Stream.Stream<GatewayEvent, GatewayError>. The rest of the application consumes this interface without caring which implementation is active.

GatewayError

A unified error type for all adapter operations:
export class GatewayError {
  readonly _tag = "GatewayError"
  constructor(
    readonly code: string,
    readonly message: string,
  ) {}
}

GatewayAdapter Interface

import type { Effect, Stream } from "effect"

export interface GatewayAdapter {
  /** Connect to the gateway. Resolves once authenticated. */
  connect(url: string, token?: string): Effect.Effect<void, GatewayError>

  /** Disconnect from the gateway. */
  disconnect(): Effect.Effect<void, never>

  /** Stream of all server events. Subscribe once, consume in stores. */
  readonly events: Stream.Stream<GatewayEvent, GatewayError>

  // Session management
  listSessions(includeArchived?: boolean): Effect.Effect<SessionMeta[], GatewayError>
  createSession(agentType: string, name?: string): Effect.Effect<SessionMeta, GatewayError>
  joinSession(sessionId: string): Effect.Effect<StateSnapshotEvent, GatewayError>
  leaveSession(sessionId: string): Effect.Effect<void, GatewayError>
  renameSession(sessionId: string, name: string): Effect.Effect<SessionMeta, GatewayError>
  archiveSession(sessionId: string): Effect.Effect<SessionMeta, GatewayError>
  unarchiveSession(sessionId: string): Effect.Effect<SessionMeta, GatewayError>
  deleteSession(sessionId: string): Effect.Effect<void, GatewayError>

  // Turn interaction
  runTurn(sessionId: string, text: string): Effect.Effect<void, GatewayError>
  stopTurn(sessionId: string): Effect.Effect<void, GatewayError>
  steer(sessionId: string, content: string): Effect.Effect<void, GatewayError>
  answerQuestion(
    sessionId: string,
    requestId: string,
    answers: Record<string, string>,
  ): Effect.Effect<void, GatewayError>
}
Every protocol method is represented. The interface acts as a complete proxy for the gateway’s client-facing API.

WebGatewayAdapter

The web adapter wraps the TypeScript SDK’s DiminuendoClient. Promise-based SDK methods are lifted into Effect using Effect.tryPromise, and events are delivered as an Effect Stream.Stream via Stream.async.
connect(url: string, token?: string): Effect.Effect<void, GatewayError> {
  return Effect.tryPromise({
    try: () => {
      this.client = new DiminuendoClient({ url, token })
      return this.client.connect()
    },
    catch: (err) => new GatewayError(
      "CONNECTION_FAILED",
      err instanceof Error ? err.message : String(err),
    ),
  })
}
The key characteristic of the web adapter is that it is a thin wrapper. The TypeScript SDK already handles connection management, authentication, reconnection, and request/response correlation. The adapter merely translates between the SDK’s Promise-based API and the Effect-based interface that the shared stores expect.

TauriGatewayAdapter

The Tauri adapter bridges from the React frontend to the Rust backend using Tauri’s IPC primitives:
  • Commands are sent via invoke() from @tauri-apps/api/core. Each invoke call maps to a #[tauri::command] function in the Rust backend that calls the corresponding method on the Rust SDK’s DiminuendoClient.
  • Events are received via listen() from @tauri-apps/api/event. The Rust backend’s event handler emits gateway-event Tauri events, which the adapter consumes and forwards to the Effect stream.
connect(url: string, token?: string): Effect.Effect<void, GatewayError> {
  return Effect.tryPromise({
    try: () => invoke("gateway_connect", { url, token }),
    catch: (err) => new GatewayError(
      "CONNECTION_FAILED",
      String(err),
    ),
  })
}
The Tauri adapter communicates with Rust #[tauri::command] handlers that hold the DiminuendoClient in Tauri’s managed state. The Rust backend spawns a tokio task that reads from the Rust SDK’s event channel and emits each event to the frontend via app_handle.emit("gateway-event", &event).

Data Flow Comparison

The following diagrams illustrate the complete path of a runTurn command and a text_delta event through both adapters:
Command path:
React Component
  → useChat().sendMessage()
  → chatStore.runTurn()
  → WebGatewayAdapter.runTurn()
  → DiminuendoClient.runTurn()
  → WebSocket.send(JSON)
  → Gateway

Event path:
Gateway
  → WebSocket.onmessage
  → DiminuendoClient event handlers
  → WebGatewayAdapter.events stream
  → useGatewayConnection() subscriber
  → chatStore.appendTextDelta()
  → React re-render

Usage in React

The connection store initializes the correct adapter based on platform detection. The useGatewayConnection hook subscribes to the adapter’s event stream and routes events to the appropriate Zustand stores:
// Platform detection and adapter initialization
const adapter: GatewayAdapter = window.__TAURI__
  ? new TauriGatewayAdapter()
  : new WebGatewayAdapter()

// Store initialization
const connectionStore = create<ConnectionState>((set) => ({
  adapter,
  status: "disconnected",
  identity: null,
  // ...
}))
Components never reference a specific adapter implementation. They interact exclusively through hooks that delegate to the adapter via the store:
function ChatInput() {
  const { sendMessage, isRunning } = useChat()

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      sendMessage(inputText)
    }}>
      {/* ... */}
    </form>
  )
}
The Gateway Adapter pattern is an application of the Strategy pattern with dependency injection. The adapter is the strategy, the store is the context, and platform detection is the factory. Swapping transports requires no changes to any UI component or business logic.

Keeping Adapters in Sync

Both adapters must implement the same GatewayAdapter interface, which provides compile-time guarantees that they expose the same set of methods with the same signatures. When a new protocol method is added:
  1. Add the method to the GatewayAdapter interface
  2. TypeScript will produce compilation errors in both adapter implementations
  3. Implement the method in WebGatewayAdapter (wrap SDK call in Effect)
  4. Implement the method in TauriGatewayAdapter (wrap invoke call in Effect)
  5. Add the corresponding #[tauri::command] in the Rust backend
The interface acts as a contract that enforces parity between the two transport layers.