HubSpot CRM

stream-relay: Streaming Data in HubSpot UI Extensions Without the Connection Limit

· May 5, 2026 · 5 min read
stream-relay: Streaming Data in HubSpot UI Extensions Without the Connection Limit
Contents

A while back I wrote about hs-uix – Carter McKay’s component library that gives HubSpot UI Extensions the tables, forms, and boards the platform doesn’t ship. hs-uix solves the UI half of building a serious CRM card. There’s a second half it can’t touch: the runtime won’t let your card hold a long connection. Carter has a companion library for exactly that – stream-relay – and the two are designed to be used together.

Credit up front: stream-relay is an open-source project by Carter McKay (@05bmckay on GitHub), MIT licensed, at github.com/05bmckay/stream-relay.

The problem it solves

Its own tagline says it plainly: “Resumable, pollable stream proxy for environments where the client can’t hold long connections (HubSpot UI extensions, sandboxed iframes, serverless edges).”

If you’ve tried to stream an LLM response – or any slow upstream – into a HubSpot UI Extension card, you’ve hit this wall. hubspot.fetch has strict time limits, the card environment is sandboxed, and the card re-mounts on navigation. A normal streaming fetch dies halfway through and starts over from nothing every time the user clicks away and back. You can’t just open an SSE connection and hold it.

How it works

stream-relay sits between the card and the slow service as a middleware proxy. The relay holds the long connection to your upstream and buffers the output. The client doesn’t stream directly, it polls the relay and resumes from a byte/offset it persists locally. Reload the card, lose the tab, navigate away and back: the client reconnects and picks up exactly where it left off instead of restarting. Text, structured events, and progress are separate channels (write(), emit(), progress), and persistence is pluggable in-memory by default, or Durable Objects / KV / Redis / SQLite.

The card never holds the connection. The relay does. The card just asks “what’s happened since offset N?” — which is a request short enough to survive HubSpot’s limits.

Installing it

npm install @hs-uix/stream-relay

Same @hs-uix npm scope as the component library — these are built to live in the same project. Four entry points, one per role: /client, /server, /worker, /hono.

The client side (this is what pairs with hs-uix)

The card calls a useStream hook. It manages connection state and hands you the streamed text plus an isStreaming / reconnecting status — which is exactly the state you feed into hs-uix’s status components to make the card feel native:

import { useStream } from "@hs-uix/stream-relay/client";
import { useState } from "react";

function MyCard() {
  const [streamId, setStreamId] = useState(null);
  const [text, setText] = useState("");

  const { isStreaming, reconnecting } = useStream({
    proxyUrl: "https://your-relay.workers.dev",
    streamId,
    fetcher: hubspot.fetch,
    onChunk: (append) => setText((t) => t + append),
    onDone: ({ meta }) => console.log("usage:", meta),
  });

  const start = async () => {
    setText("");
    const res = await hubspot.fetch(`${RELAY_URL}/streams`, {
      method: "POST",
      body: JSON.stringify({ payload: { prompt: "Tell me a joke" } }),
    });
    const { streamId } = await res.json();
    setStreamId(streamId);
  };

  return (
    <>
      <button onClick={start} disabled={isStreaming}>
        {reconnecting ? "Reconnecting..." : isStreaming ? "Running..." : "Run"}
      </button>
      <div>{text}</div>
    </>
  );
}

Note fetcher: hubspot.fetch — it uses HubSpot’s own authenticated fetch, so you’re not punching auth holes to get streaming.

The server side

You stand up the relay yourself. On Cloudflare Workers + Durable Objects:

import { RelayBuffer, createRelayWorker } from "@hs-uix/stream-relay/worker";

export class MyRelay extends RelayBuffer {
  constructor(state, env) {
    super(state, env, {
      upstream: async ({ payload, write }) => {
        for await (const token of callYourLLM(payload.prompt)) {
          write(token);
        }
      },
    });
  }
}

export default createRelayWorker();

Or on plain Node via Hono — same upstream contract, different host:

import { createRelayApp } from "@hs-uix/stream-relay/hono";
import { serve } from "@hono/node-server";

const { app } = createRelayApp({
  upstream: async ({ payload, write }) => {
    for await (const token of callYourLLM(payload.prompt)) {
      write(token);
    }
  },
});

serve({ fetch: app.fetch, port: 8787 });

Your only job is the upstream generator — read from your LLM (or any slow source) and write() tokens. The relay handles buffering, offsets, and resume.

The examples folder is the actual blueprint

Don’t start from the README snippets — the repo ships two complete, runnable examples that are far more useful:

  • examples/worker-llm/ — the backend: a Cloudflare Worker LLM relay (src/index.ts, wrangler.toml). This is your deployable relay, not a toy.
  • examples/hubspot-extension/ — the card: a real HubSpot CRM extension. src/app/cards/AiSummaryCard.tsx is an AI summary card, plus two serverless functions — load-summary-state.js and save-summary-state.js — that persist the summary text, stream ID, and offset onto a contact’s properties.

That second example is the important pattern. The card doesn’t just resume within a session — it persists the offset to a HubSpot property via a serverless function, so a half-finished AI summary survives the user closing the record entirely and coming back tomorrow. The card reads saved state on mount (load-summary-state), streams via useStream with a resume offset, and writes progress back (save-summary-state). It also handles the expired-stream case explicitly (StreamNotFoundError) instead of silently failing.

Using it with hs-uix

This is the combination worth internalizing. hs-uix gives you native CRM card chrome — SectionHeader, StyledText, AutoStatusTag, all built on HubSpot primitives, no iframes. stream-relay gives you the resilient streaming data and a clean isStreaming / reconnecting signal. You wire one into the other: render the streamed text in an hs-uix layout, and map the stream status to an AutoStatusTag (“Streaming”, “Reconnecting”, “Done”) so the card communicates state the way HubSpot’s own UI would. Native-looking card, resilient data pipe, neither one fighting the platform.

One honest caveat

stream-relay is pre-alpha — the wire protocol may shift before 1.0. That’s fine for an internal tool or a card you control end to end; pin your version and read the changelog before upgrading. It’s not yet something I’d ship into a client’s marketplace app without that conversation. The architecture is sound; the version number is the thing to respect.

Bottom line

UI Extensions can’t hold long connections — that’s not a bug you can code around in the card, it’s a platform constraint. stream-relay is the right shape of fix: move the long connection to a relay, let the card poll and resume. Paired with hs-uix it closes the other half of the “build a real CRM card” problem. Thanks to Carter McKay for both halves. Source and the two examples are at github.com/05bmckay/stream-relay, package on npm.

← Back to Resources