HyperGen

Client Setup

Mount HyperGen iframes in your frontend — React, Vue, Svelte, or vanilla JS. Manage themes, handle resize, and control the lifecycle.

The client side of HyperGen mounts a sandboxed iframe, injects the bootstrap document, and gives you a controller for managing the iframe lifecycle. Everything you need is in a single file: hypergen-client.ts.

API Overview

ExportPurpose
mountHyperGen(container, options)Create a sandboxed iframe and return a HyperGenController

mountHyperGen()

Creates a sandboxed iframe, loads the bootstrap document, sets up postMessage listeners, and returns a controller.

import { mountHyperGen } from "./hypergen-client";

const controller = mountHyperGen(document.getElementById("agent-ui")!, {
  bootstrapUrl: "/bootstrap",
});

Options

OptionTypeDefaultDescription
bootstrapUrlstring--URL that serves the bootstrap HTML (iframe src)
bootstrapHtmlstring--Inline bootstrap HTML (iframe srcdoc)
themeRecord<string, string>--Initial CSS variables sent after iframe loads
iframeOriginstring"*"Expected iframe origin for postMessage validation
autoResizebooleantrueAuto-resize iframe based on content height
maxHeightnumber2000Maximum iframe height in pixels
minHeightnumber100Minimum iframe height in pixels
extraSandboxPermissionsstring[][]Additional sandbox permissions
classNamestring--CSS class for the iframe element
stylePartial<CSSStyleDeclaration>--Inline styles for the iframe element

You must provide either bootstrapUrl or bootstrapHtml (not both).

bootstrapUrl -- The iframe loads the bootstrap document over HTTP. This is the standard approach: the iframe's src attribute is set to this URL.

bootstrapHtml -- The bootstrap HTML is injected directly via srcdoc. Use this when you generate the bootstrap server-side and want to avoid an extra HTTP request (e.g., in SSR frameworks).

// URL approach (extra HTTP request, simpler)
const ctrl = mountHyperGen(el, { bootstrapUrl: "/bootstrap" });

// Inline HTML approach (no extra request, requires server-side rendering)
const ctrl = mountHyperGen(el, { bootstrapHtml: serverGeneratedHtml });

HyperGenController

The object returned by mountHyperGen(). Use it to manage the iframe after mounting.

controller.setTheme(vars)

Sends CSS variables to the iframe via postMessage. The iframe's bridge script applies them to :root.

controller.setTheme({
  "--hg-surface": "#1a1a2e",
  "--hg-text": "#e0e0e0",
  "--hg-accent": "#7c3aed",
});

Call this any time to update the theme (e.g., when the user toggles dark mode). Only the provided variables are updated -- existing variables are not removed.

controller.onResize(callback)

Registers a callback for content size changes. The iframe's ResizeObserver detects height changes and posts them via postMessage.

const unsubscribe = controller.onResize((height, width) => {
  console.log(`Content: ${height}px tall, ${width}px wide`);
});

// Later: stop listening
unsubscribe();

When autoResize is true (the default), the iframe element's height is automatically adjusted within the minHeight/maxHeight bounds. The callback fires regardless of the autoResize setting.

controller.onNavigate(callback)

Registers a callback for navigation requests from the iframe. Agents trigger navigation by including elements with data-hg-navigate attributes.

const unsubscribe = controller.onNavigate((url) => {
  // Integrate with your router
  router.push(url);
});

Inside the iframe, an agent can generate:

<a data-hg-navigate="/dashboard" href="#">Go to Dashboard</a>

Clicking it sends a hg:navigate postMessage instead of navigating inside the iframe.

controller.onData(callback)

Registers a callback for arbitrary data sent from the iframe. Agents can dispatch custom events that the bridge forwards to the host.

const unsubscribe = controller.onData((payload) => {
  console.log("Data from agent:", payload);
});

Inside the iframe, agent-generated JavaScript can send data:

<script>
  document.dispatchEvent(new CustomEvent("hg:data", {
    detail: { action: "selected", itemId: 42 }
  }));
</script>

controller.destroy()

Tears down the iframe: sends hg:destroy to close SSE connections, removes the message listener, clears all subscriptions, and removes the iframe from the DOM.

controller.destroy();
console.log(controller.destroyed); // true

Always call destroy() when the component unmounts to prevent memory leaks.

controller.iframe

Direct access to the underlying HTMLIFrameElement for advanced use cases.

controller.iframe.style.opacity = "0.5";

controller.destroyed

Boolean indicating whether destroy() has been called.

Framework Integration

React

import { useEffect, useRef } from "react";
import { mountHyperGen, type HyperGenController } from "./hypergen-client";

function AgentUI({ theme }: { theme?: Record<string, string> }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const controllerRef = useRef<HyperGenController | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const controller = mountHyperGen(containerRef.current, {
      bootstrapUrl: "/bootstrap",
      theme,
    });
    controllerRef.current = controller;

    controller.onNavigate((url) => {
      // Use your router: window.location.href = url
    });

    return () => controller.destroy();
  }, []);

  // Update theme when props change
  useEffect(() => {
    if (theme && controllerRef.current && !controllerRef.current.destroyed) {
      controllerRef.current.setTheme(theme);
    }
  }, [theme]);

  return <div ref={containerRef} />;
}

Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from "vue";
import { mountHyperGen, type HyperGenController } from "./hypergen-client";

const props = defineProps<{
  theme?: Record<string, string>;
}>();

const container = ref<HTMLElement | null>(null);
let controller: HyperGenController | null = null;

onMounted(() => {
  if (!container.value) return;

  controller = mountHyperGen(container.value, {
    bootstrapUrl: "/bootstrap",
    theme: props.theme,
  });

  controller.onNavigate((url) => {
    // useRouter().push(url)
  });
});

watch(() => props.theme, (newTheme) => {
  if (newTheme && controller && !controller.destroyed) {
    controller.setTheme(newTheme);
  }
});

onUnmounted(() => {
  controller?.destroy();
});
</script>

<template>
  <div ref="container" />
</template>

Svelte

<script lang="ts">
  import { onMount, onDestroy } from "svelte";
  import { mountHyperGen, type HyperGenController } from "./hypergen-client";

  export let theme: Record<string, string> | undefined = undefined;

  let container: HTMLElement;
  let controller: HyperGenController | null = null;

  onMount(() => {
    controller = mountHyperGen(container, {
      bootstrapUrl: "/bootstrap",
      theme,
    });

    controller.onNavigate((url) => {
      // goto(url)
    });
  });

  onDestroy(() => {
    controller?.destroy();
  });

  $: if (theme && controller && !controller.destroyed) {
    controller.setTheme(theme);
  }
</script>

<div bind:this={container}></div>

Vanilla JS

<div id="agent-ui"></div>

<script type="module">
  import { mountHyperGen } from "./hypergen-client.js";

  const controller = mountHyperGen(
    document.getElementById("agent-ui"),
    { bootstrapUrl: "/bootstrap" }
  );

  controller.setTheme({
    "--hg-accent": "#7c3aed",
    "--hg-surface": "#ffffff",
  });

  controller.onResize((height) => {
    console.log("Content height:", height);
  });

  // Clean up on page unload
  window.addEventListener("beforeunload", () => {
    controller.destroy();
  });
</script>

Auto-Resize Behavior

By default, the iframe automatically resizes to match its content height. The flow:

  1. The iframe's bootstrap document includes a ResizeObserver on <body>
  2. When content changes size, the observer posts a hg:resize message to the host
  3. mountHyperGen() receives the message and adjusts iframe.style.height
  4. The height is clamped between minHeight and maxHeight

The resize transition is smooth (150ms ease-out) to prevent visual jitter.

To disable auto-resize and handle it yourself:

const controller = mountHyperGen(el, {
  bootstrapUrl: "/bootstrap",
  autoResize: false,
});

controller.onResize((height) => {
  // Custom resize logic
  el.style.height = `${Math.min(height, 800)}px`;
});

Multiple Iframes on One Page

You can mount multiple HyperGen iframes, each with its own controller and SSE connection:

const chatCtrl = mountHyperGen(
  document.getElementById("chat")!,
  { bootstrapUrl: "/bootstrap?view=chat" },
);

const dashCtrl = mountHyperGen(
  document.getElementById("dashboard")!,
  { bootstrapUrl: "/bootstrap?view=dashboard" },
);

// Each controller is independent
chatCtrl.setTheme({ "--hg-accent": "#10b981" });
dashCtrl.setTheme({ "--hg-accent": "#f59e0b" });

Each iframe maintains its own SSE connection, theme, and lifecycle. The mountHyperGen() implementation verifies that incoming postMessage events originate from the correct iframe (event.source === iframe.contentWindow), so messages never cross between instances.

Sandbox Permissions

The default sandbox is allow-scripts allow-same-origin allow-forms -- the minimum HTMX requires.

Security

Adding sandbox permissions weakens the security boundary. Only add permissions you actually need.

const controller = mountHyperGen(el, {
  bootstrapUrl: "/bootstrap",
  extraSandboxPermissions: ["allow-popups"],  // Use with caution
});

See ADR-004 for the full security model rationale.

On this page