Skip to content

Interaction Host Transport

Typically, you use Interaction Transport to define what events to send. Host Transport defines how to send them over specific protocols like Server-Sent Events (SSE) or WebSockets.

The Pattern: Host transport takes the generic events from Interaction Transport and wires them into specific delivery channels. You keep the same event names and payloads and only swap out the host adapter.


1) Node SSE (ServerResponse)

This pattern maps interaction events to SSE chunks without introducing any UI dependency.

js
import { createInteractionSession } from "@geekist/llm-core/interaction";
import { createBuiltinModel } from "@geekist/llm-core/adapters";

/**
 * @typedef {object} NodeResponse
 * @property {(chunk: string) => void} write
 * @property {(name: string, value: string) => void} [setHeader]
 * @property {() => void} [end]
 */

class MemorySessionStore {
  constructor() {
    /** @type {Map<string, import("#interaction").InteractionState>} */
    this.cache = new Map();
  }

  /** @param {import("#interaction").SessionId} sessionId */
  load(sessionId) {
    const key = toSessionKey(sessionId);
    return this.cache.get(key) ?? null;
  }

  /**
   * @param {import("#interaction").SessionId} sessionId
   * @param {import("#interaction").InteractionState} state
   */
  save(sessionId, state) {
    const key = toSessionKey(sessionId);
    this.cache.set(key, state);
    return true;
  }
}

class NodeSseEventStream {
  /**
   * @param {{ response: NodeResponse }} options
   */
  constructor(options) {
    this.response = options.response;
  }

  /**
   * @param {import("#adapters").EventStreamEvent} event
   */
  emit(event) {
    return writeSseEvent({ response: this.response, event });
  }

  /**
   * @param {import("#adapters").EventStreamEvent[]} events
   */
  emitMany(events) {
    return writeSseEvents({ response: this.response, events });
  }
}

/**
 * @param {{ response: NodeResponse }} options
 */
export const createNodeSseEventStream = (options) => new NodeSseEventStream(options);

const store = new MemorySessionStore();

/**
 * @param {{ response: NodeResponse; sessionId: import("#interaction").SessionId; message: string }} input
 */
export const handleChatRequest = (input) => {
  const eventStream = createNodeSseEventStream({ response: input.response });
  const session = createInteractionSession({
    sessionId: input.sessionId,
    store,
    adapters: { model: createBuiltinModel() },
    eventStream,
  });
  return session.send({ role: "user", content: input.message });
};

/**
 * @param {import("#interaction").SessionId} sessionId
 */
const toSessionKey = (sessionId) =>
  typeof sessionId === "string" ? sessionId : sessionId.sessionId;

/**
 * @param {{ response: NodeResponse }} input
 */
const initSseHeaders = (input) => {
  if (input.response.setHeader) {
    input.response.setHeader("content-type", "text/event-stream");
    input.response.setHeader("cache-control", "no-cache");
    input.response.setHeader("connection", "keep-alive");
  }
};

/**
 * @param {{ response: NodeResponse; event: import("#adapters").EventStreamEvent }} input
 */
const writeSseEvent = (input) => {
  initSseHeaders({ response: input.response });
  try {
    input.response.write(formatSseEvent(input.event));
    return true;
  } catch {
    return false;
  }
};

/**
 * @param {{ response: NodeResponse; events: import("#adapters").EventStreamEvent[] }} input
 */
const writeSseEvents = (input) => {
  initSseHeaders({ response: input.response });
  try {
    for (const event of input.events) {
      input.response.write(formatSseEvent(event));
    }
    return true;
  } catch {
    return false;
  }
};

/**
 * @param {import("#adapters").EventStreamEvent} event
 */
const formatSseEvent = (event) => `event: ${event.name}\ndata: ${JSON.stringify(event.data)}\n\n`;

2) Edge / Worker streams

On the edge you typically have a writer that accepts string chunks (e.g. from a TransformStream or platform-specific stream), which can be wrapped as an EventStream.

ts
import { createInteractionSession, type SessionId, type InteractionState } from "@geekist/llm-core/interaction";
import { createBuiltinModel, type EventStream, type EventStreamEvent } from "@geekist/llm-core/adapters";

type SseWriter = {
  write: (chunk: string) => Promise<void> | void;
  close?: () => Promise<void> | void;
};

class MemorySessionStore {
  private cache = new Map<string, InteractionState>();

  load(sessionId: SessionId) {
    return this.cache.get(toSessionKey(sessionId)) ?? null;
  }

  save(sessionId: SessionId, state: InteractionState) {
    this.cache.set(toSessionKey(sessionId), state);
    return true;
  }
}

class EdgeSseEventStream implements EventStream {
  private writer: SseWriter;

  constructor(options: { writer: SseWriter }) {
    this.writer = options.writer;
  }

  emit(event: EventStreamEvent) {
    return writeSseChunk({ writer: this.writer, chunk: formatSseEvent(event) });
  }

  emitMany(events: EventStreamEvent[]) {
    const chunks: string[] = [];
    for (const event of events) {
      chunks.push(formatSseEvent(event));
    }
    return writeSseChunks({ writer: this.writer, chunks });
  }
}

export const createEdgeSseEventStream = (options: { writer: SseWriter }) =>
  new EdgeSseEventStream(options);

const store = new MemorySessionStore();

export const handleEdgeRequest = (input: {
  writer: SseWriter;
  sessionId: SessionId;
  message: string;
}) => {
  const eventStream = createEdgeSseEventStream({ writer: input.writer });
  const session = createInteractionSession({
    sessionId: input.sessionId,
    store,
    adapters: { model: createBuiltinModel() },
    eventStream,
  });
  return session.send({ role: "user", content: input.message });
};

const toSessionKey = (sessionId: SessionId) =>
  typeof sessionId === "string" ? sessionId : sessionId.sessionId;

const writeSseChunk = (input: { writer: SseWriter; chunk: string }) => {
  try {
    const result = input.writer.write(input.chunk);
    return mapMaybe(result, toTrue);
  } catch {
    return false;
  }
};

const writeSseChunks = (input: { writer: SseWriter; chunks: string[] }) => {
  try {
    const results = collectWrites(input.writer, input.chunks);
    return resolveWrites(results);
  } catch {
    return false;
  }
};

const formatSseEvent = (event: EventStreamEvent) =>
  `event: ${event.name}\ndata: ${JSON.stringify(event.data)}\n\n`;

const collectWrites = (writer: SseWriter, chunks: string[]) => {
  const results: Array<Promise<void> | void> = [];
  for (const chunk of chunks) {
    results.push(writer.write(chunk));
  }
  return results;
};

const toTrue = () => true;

const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
  typeof value === "object" &&
  value !== null &&
  "then" in value &&
  typeof (value as { then?: unknown }).then === "function";

const mapMaybe = <TIn, TOut>(value: Promise<TIn> | TIn, map: (value: TIn) => TOut) => {
  if (isPromiseLike(value)) {
    return value.then(map);
  }
  return map(value);
};

const resolveWrites = (values: Array<Promise<void> | void>) => {
  let pending: Promise<void> | null = null;
  for (const value of values) {
    if (isPromiseLike(value)) {
      pending = pending ? pending.then(() => value) : value;
    }
  }
  if (!pending) {
    return true;
  }
  return pending.then(toTrue, toFalse);
};

const toFalse = () => false;

3) Example app (outside core)

There is a minimal Node SSE demo app outside core at:

  • examples/interaction-node-sse

There is also a WebSocket-based agent loop playground that pairs the host transport with a configurable UI and the agent runtime:

  • examples/agentic

It is intentionally tiny and uses the built-in model so you can run it without external APIs.