Agentic Workforce ME Developer PortalDocs 1.1 · Widget 0.1.0

Go further

Examples

Runnable starters: plain HTML, React + Vite, Next.js, Vue, Angular, Express identity backend.

Every starter lives in the repository under apps/devportal/examples/ with its own README. The widget starters all load the hosted bundle, so they run without the @hive/embed npm package; switching to the package later is an import change. Replace https://HIVE with your platform origin and the key placeholder with a real publishable key, and add the dev origin listed below to the key's allowed origins. The last entry is the server-side companion to the Backend integration guide: it talks to the platform API with a tenant API key and needs no embed key.

ExampleStackDev originShows
Plain HTMLNo build stephttp://localhost:8080Script-tag auto-init · Content-Security-Policy meta · Queue pattern before load · Events on document
React + ViteReact 19, Vite 6, TypeScripthttp://localhost:5173Custom hook · Live locale switch (EN/AR) · send() / reset() from buttons · Typed window.HiveChat
Next.js 15App Router, React 19, josehttp://localhost:3000next/script with data-manual · Client component lifecycle · Identity token route handler · update({ user }) after sign-in
Vue 3Vue 3.5, Vite 6, TypeScripthttp://localhost:5174Composition API lifecycle · Launcher vs inline container · isCustomElement for <hive-chat>
Angular 17+Standalone components, signalshttp://localhost:4200Injectable HiveChatService · runOutsideAngular + NgZone.run · Signals for status · Template variant
Express identity backendNode, Express 4, jsonwebtokenhttp://localhost:8787HS256 identity JWT · sub / name / email / meta claims · Token fetched before init · identified flag on hive:ready
Backend integration (Node)Node 20, Express 4, @modelcontextprotocol/sdkhttp://localhost:4100HMAC-signed trigger (x-hive-signature) · Direct agent run + approval gate · MCP server over Streamable HTTP · Hive-Signature verification + idempotency · Approvals proxy with a server-held key · Provisioning as code (--teardown)

Plain HTML

One script tag with data-* auto-init, a strict CSP, and programmatic control from a separate file.

Folder: apps/devportal/examples/plain-html/ · dev origin http://localhost:8080 · see README.md in the folder for run steps.

plain-html/index.html — CSP + the script tagHTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Acme — Agentic Workforce ME chat (plain HTML)</title>
    <!--
      Strict CSP that still allows the widget: only script-src and connect-src
      need the platform origin. Styles use adoptedStyleSheets (no unsafe-inline).
      Replace https://HIVE with your platform origin.
    -->
    <meta
      http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self' https://HIVE; connect-src 'self' https://HIVE; style-src 'self'; img-src 'self' data:"
    />
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <main>
      <h1>Acme Support</h1>
      <p>The chat launcher appears in the bottom corner. Or open it from here:</p>
      <button id="help" type="button">Need help?</button>
      <p id="status" class="muted">widget: loading…</p>
    </main>

    <!-- 1. The widget: one tag, auto-initialized from data-* attributes -->
    <script
      async
      src="https://HIVE/embed/v1/hive-embed.js"
      data-key="hive_pk_REPLACE_WITH_YOUR_PUBLISHABLE_KEY"
      data-locale="auto"
      data-welcome="Hi! Ask me anything about Acme."
    ></script>

    <!-- 2. Programmatic control (external file so the CSP needs no inline scripts) -->
    <script src="./app.js"></script>
  </body>
</html>

React + Vite

A useHiveChat hook: load once, update() on option changes, destroy() on unmount, events mirrored into state.

Folder: apps/devportal/examples/react-vite/ · dev origin http://localhost:5173 · see README.md in the folder for run steps.

react-vite/src/useHiveChat.ts — The hookTypeScript
import { useEffect, useRef, useState } from 'react';
import { loadHiveChat, type HiveChatHandle, type HiveChatOptions } from './hive-chat';

export interface HiveChatState {
  ready: boolean;
  open: boolean;
  lastError: string | null;
  messages: number;
}

/**
 * Creates the widget once, keeps options in sync with `update()`, exposes
 * the handle and a small state derived from the `hive:*` events.
 */
export function useHiveChat(origin: string, options: HiveChatOptions) {
  const handleRef = useRef<HiveChatHandle | null>(null);
  const [state, setState] = useState<HiveChatState>({
    ready: false,
    open: false,
    lastError: null,
    messages: 0,
  });
  const serialized = JSON.stringify(options);

  useEffect(() => {
    let cancelled = false;
    const listeners: Array<[string, EventListener]> = [];
    void loadHiveChat(origin).then((api) => {
      if (cancelled) return;
      const handle = api.init({
        ...(JSON.parse(serialized) as HiveChatOptions),
        apiUrl: origin,
      });
      handleRef.current = handle;
      const on = <T>(name: string, fn: (detail: T) => void) => {
        const l: EventListener = (e) => fn((e as CustomEvent<T>).detail);
        handle.element.addEventListener(name, l);
        listeners.push([name, l]);
      };
      on('hive:ready', () => setState((s) => ({ ...s, ready: true, lastError: null })));
      on('hive:open', () => setState((s) => ({ ...s, open: true })));
      on('hive:close', () => setState((s) => ({ ...s, open: false })));
      on('hive:message', () => setState((s) => ({ ...s, messages: s.messages + 1 })));
      on<{ code: string }>('hive:error', (d) =>
        setState((s) => ({ ...s, lastError: d.code })),
      );
    });
    return () => {
      cancelled = true;
      const h = handleRef.current;
      if (h !== null) {
        for (const [name, l] of listeners) h.element.removeEventListener(name, l);
        h.destroy();
        handleRef.current = null;
      }
    };
    // Re-create only when the origin changes; option changes go through update() below.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [origin]);

  useEffect(() => {
    handleRef.current?.update(JSON.parse(serialized) as Partial<HiveChatOptions>);
  }, [serialized]);

  return { handle: handleRef, state };
}

Next.js 15

next/script in the root layout, a client component that mounts the widget, and a route handler that mints the identity JWT.

Folder: apps/devportal/examples/nextjs/ · dev origin http://localhost:3000 · see README.md in the folder for run steps.

nextjs/app/layout.tsx — Load the bundle onceTSX
import type { ReactNode } from 'react';
import Script from 'next/script';

export const metadata = { title: 'Acme — Agentic Workforce ME chat (Next.js)' };

const ORIGIN = process.env.NEXT_PUBLIC_HIVE_ORIGIN ?? '';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body style={{ fontFamily: 'system-ui', margin: 0 }}>
        {children}
        {/*
          Load the hosted loader once for the whole app. `data-manual` means we
          call HiveChat.init() ourselves (see components/hive-chat.tsx) so the
          widget can receive the identity token and react to client state.
        */}
        <Script
          src={`${ORIGIN}/embed/v1/hive-embed.js`}
          strategy="afterInteractive"
          data-manual=""
        />
      </body>
    </html>
  );
}

Vue 3

Mount in onMounted, destroy in onBeforeUnmount, and switch between launcher and inline mode.

Folder: apps/devportal/examples/vue/ · dev origin http://localhost:5174 · see README.md in the folder for run steps.

vue/src/App.vue — The componentVue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';

const KEY = import.meta.env.VITE_HIVE_EMBED_KEY as string;

const mode = ref<'launcher' | 'inline'>('launcher');
const status = ref('loading…');
const inlineHost = ref<HTMLElement | null>(null);
let handle: HiveChatHandle | null = null;

function whenLoaded(): Promise<HiveChatApi> {
  return new Promise((resolve) => {
    const tick = () => {
      const api = window.HiveChat;
      if (api !== undefined && 'init' in api) resolve(api);
      else setTimeout(tick, 50);
    };
    tick();
  });
}

function mount(api: HiveChatApi) {
  handle?.destroy();
  handle = api.init(
    {
      key: KEY,
      locale: 'auto',
      mode: mode.value,
      welcome: 'Hi from Vue! How can I help?',
      theme: { primary: '#7c3aed', position: 'start' },
    },
    mode.value === 'inline' ? inlineHost.value : null,
  );
  handle.element.addEventListener('hive:ready', () => (status.value = 'ready'));
  handle.element.addEventListener('hive:error', (e) => {
    status.value = `error ${(e as CustomEvent<{ code: string }>).detail.code}`;
  });
}

onMounted(async () => {
  const api = await whenLoaded();
  mount(api);
  // Re-mount when switching between launcher and inline (different container).
  watch(mode, () => mount(api));
});

onBeforeUnmount(() => {
  handle?.destroy();
  handle = null;
});
</script>

<template>
  <main
    style="font-family: system-ui; max-width: 720px; margin: 48px auto; padding: 24px"
  >
    <h1>Acme Support (Vue 3)</h1>
    <p>widget: {{ status }}</p>
    <p style="display: flex; gap: 8px">
      <button @click="handle?.open()">Open chat</button>
      <button @click="handle?.send('Where is my order?')">Ask about an order</button>
      <button @click="mode = mode === 'launcher' ? 'inline' : 'launcher'">
        Switch to {{ mode === 'launcher' ? 'inline' : 'launcher' }} mode
      </button>
    </p>
    <!-- Inline mode fills this box; launcher mode ignores it. -->
    <div
      ref="inlineHost"
      :style="{
        height: mode === 'inline' ? '560px' : '0',
        border: mode === 'inline' ? '1px solid #ddd' : 'none',
        borderRadius: '12px',
        overflow: 'hidden',
      }"
    />
  </main>
</template>

Angular 17+

Drop-in service + component for an existing Angular workspace; a CUSTOM_ELEMENTS_SCHEMA variant is included.

Folder: apps/devportal/examples/angular/ · dev origin http://localhost:4200 · see README.md in the folder for run steps.

angular/hive-chat.service.ts — The serviceTypeScript
import { Injectable, NgZone } from '@angular/core';

// Move these to src/environments/environment.ts in a real app.
const HIVE_ORIGIN = 'https://HIVE';
const HIVE_EMBED_KEY = 'hive_pk_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';

export interface HiveChatOptions {
  key: string;
  apiUrl?: string;
  locale?: 'en' | 'ar' | 'auto';
  theme?: Record<string, string | number>;
  title?: string;
  subtitle?: string;
  welcome?: string;
  placeholder?: string;
  user?: { token: string };
  mode?: 'launcher' | 'inline';
  open?: boolean;
  storage?: 'local' | 'session' | 'none';
  visitorId?: string;
}

export interface HiveChatHandle {
  readonly element: HTMLElement;
  open(): void;
  close(): void;
  toggle(): void;
  send(text: string): Promise<void>;
  reset(): void;
  update(opts: Partial<HiveChatOptions>): void;
  destroy(): void;
}

interface HiveChatApi {
  readonly version: string;
  init(opts: HiveChatOptions, container?: Element | null): HiveChatHandle;
}

declare global {
  interface Window {
    HiveChat?: HiveChatApi | { q: unknown[] };
  }
}

@Injectable({ providedIn: 'root' })
export class HiveChatService {
  private api?: Promise<HiveChatApi>;
  private handle: HiveChatHandle | null = null;

  constructor(private readonly zone: NgZone) {}

  /** Loads the hosted bundle once (idempotent). */
  load(): Promise<HiveChatApi> {
    if (this.api) return this.api;
    this.api = new Promise<HiveChatApi>((resolve, reject) => {
      const existing = window.HiveChat;
      if (existing && 'init' in existing) return resolve(existing);
      const s = document.createElement('script');
      s.src = `${HIVE_ORIGIN}/embed/v1/hive-embed.js`;
      s.async = true;
      s.dataset['manual'] = '';
      s.onload = () => {
        const api = window.HiveChat;
        api && 'init' in api
          ? resolve(api)
          : reject(new Error('HiveChat missing after load'));
      };
      s.onerror = () => reject(new Error(`failed to load ${s.src}`));
      document.head.appendChild(s);
    });
    return this.api;
  }

  /** Creates the widget (or returns the existing handle). */
  async init(
    opts: Omit<HiveChatOptions, 'key' | 'apiUrl'> = {},
  ): Promise<HiveChatHandle> {
    const api = await this.load();
    // Run outside Angular so the widget's own DOM work does not trigger change detection.
    this.handle ??= this.zone.runOutsideAngular(() =>
      api.init({ key: HIVE_EMBED_KEY, apiUrl: HIVE_ORIGIN, ...opts }),
    );
    return this.handle;
  }

  get current(): HiveChatHandle | null {
    return this.handle;
  }

  destroy(): void {
    this.handle?.destroy();
    this.handle = null;
  }
}

Express identity backend

The smallest identified-user setup: a server that signs the HS256 identity JWT and a page that starts the widget with it.

Folder: apps/devportal/examples/express-identity/ · dev origin http://localhost:8787 · see README.md in the folder for run steps.

express-identity/server.mjs — Sign the token server-sideJavaScript
import express from 'express';
import jwt from 'jsonwebtoken';
import { readFileSync } from 'node:fs';

const PORT = Number(process.env.PORT ?? 8787);
const HIVE_ORIGIN = process.env.HIVE_ORIGIN ?? 'https://HIVE';
const HIVE_EMBED_KEY =
  process.env.HIVE_EMBED_KEY ?? 'hive_pk_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';
const IDENTITY_SECRET = process.env.HIVE_EMBED_IDENTITY_SECRET;

if (!IDENTITY_SECRET) {
  console.error(
    'HIVE_EMBED_IDENTITY_SECRET is required (shown once when the embed key was created)',
  );
  process.exit(1);
}

const app = express();

// Pretend session: in a real app read the user from your auth cookie/session.
const FAKE_SESSION = {
  id: 'user_1234',
  name: 'Jane Doe',
  email: 'jane@example.com',
  plan: 'pro',
};

/**
 * Mint the identity JWT. HS256 with the key's identity secret (UTF-8 bytes).
 * Claims: sub (required), name, email, meta (optional), iat, exp (≤ 24h ahead).
 * Omit optional claims rather than sending null — "email": null is rejected
 * with 401 EMBED_IDENTITY_INVALID. Keep the token short-lived — the widget only
 * needs it when it (re)mints a session.
 */
app.post('/api/hive-identity', (_req, res) => {
  const user = FAKE_SESSION;
  const claims = {
    ...(user.name ? { name: user.name } : {}),
    ...(user.email ? { email: user.email } : {}),
    ...(user.plan ? { meta: { plan: user.plan } } : {}),
  };
  const token = jwt.sign(claims, IDENTITY_SECRET, {
    algorithm: 'HS256',
    subject: user.id,
    expiresIn: '1h',
  });
  res.json({ token });
});

// Serve the demo page with the origin + key injected (no bundler needed).
const page = readFileSync(new URL('./public/index.html', import.meta.url), 'utf8');
app.get('/', (_req, res) => {
  res
    .type('html')
    .send(
      page
        .replaceAll('__HIVE_ORIGIN__', HIVE_ORIGIN)
        .replaceAll('__HIVE_EMBED_KEY__', HIVE_EMBED_KEY),
    );
});

app.listen(PORT, () => {
  console.log(`identity example → http://localhost:${PORT}`);
});

Backend integration (Node)

The closed loop from the Backend integration guide in one service: a signed-trigger endpoint into a published workflow, a direct agent run whose tool is gated by an approval policy, an MCP server with two tools, a webhook receiver that verifies and de-duplicates, an approvals proxy, and an idempotent setup-hive script.

Folder: apps/devportal/examples/backend-integration-node/ · dev origin http://localhost:4100 · see README.md in the folder for run steps.

backend-integration-node/server.mjs — Trigger, direct run, webhook receiver, approvals proxyJavaScript
// A small "orders" backend wired to Agentic Workforce ME the way the demo portals are:
//
//   POST /orders/:id/escalate  -> fires the HMAC-signed trigger (business event -> workflow -> triage agent)
//   POST /orders/:id/refund    -> starts a direct agent run (refund clerk; orders.refund needs approval)
//   POST /mcp                  -> the MCP server the agents act through (orders.get / orders.refund)
//   POST /hive/webhook         -> receives signed run.* / approval.* events, verifies, de-duplicates
//   GET  /api/approvals        -> approvals proxy for your own UI (server-held key, never in the browser)
//   POST /api/approvals/:id/decision
//
// Run `node scripts/setup-hive.mjs` first; it writes .env with every id/secret.
import express from 'express';
import { triggerHeaders, verifyHiveWebhook } from './hive-signing.mjs';
import { createMcpHandler } from './mcp-server.mjs';
import { store } from './store.mjs';

try {
  process.loadEnvFile('.env');
} catch {
  // No .env yet — rely on the process environment.
}

function required(name) {
  const value = process.env[name];
  if (value === undefined || value === '')
    throw new Error(`Missing env ${name} — run scripts/setup-hive.mjs`);
  return value;
}

const PORT = Number(process.env.PORT ?? 4100);
const HIVE_API_URL = required('HIVE_API_URL').replace(/\/$/, '');
const HIVE_API_KEY = required('HIVE_API_KEY');
const HIVE_TENANT_ID = required('HIVE_TENANT_ID');
const HIVE_TRIGGER_ID = required('HIVE_TRIGGER_ID');
const HIVE_TRIGGER_SECRET = required('HIVE_TRIGGER_SECRET');
const HIVE_WEBHOOK_SECRET = required('HIVE_WEBHOOK_SECRET');
const HIVE_REFUND_AGENT_ID = required('HIVE_REFUND_AGENT_ID');
const MCP_BEARER_TOKEN = required('MCP_BEARER_TOKEN');

const accessLog = [];
const pendingApprovals = new Map();
const log = (name, ok, detail) => {
  const entry = { at: new Date().toISOString(), name, ok, ...detail };
  accessLog.push(entry);
  console.log(JSON.stringify(entry));
};

/** Body as JSON when it parses, else the raw text (a proxy 502 page is not JSON). */
async function readBody(res) {
  const text = await res.text();
  if (text.length === 0) return null;
  try {
    return JSON.parse(text);
  } catch {
    return { detail: text.slice(0, 200) };
  }
}

/** fetch() that turns "connection refused / DNS / timeout" into a tagged platform error. */
async function platformFetch(url, init) {
  try {
    return await fetch(url, init);
  } catch (cause) {
    const err = new Error(
      `platform unreachable: ${cause.cause?.message ?? cause.message}`,
    );
    err.platform = true;
    throw err;
  }
}

/** Server-to-server call to the platform API with the integration's own key. */
async function hive(method, path, body) {
  const res = await platformFetch(`${HIVE_API_URL}${path}`, {
    method,
    headers: {
      authorization: `Bearer ${HIVE_API_KEY}`,
      'x-tenant-id': HIVE_TENANT_ID,
      ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const json = await readBody(res);
  if (!res.ok) {
    const err = new Error(
      `${method} ${path} → ${res.status} ${json?.code ?? ''} ${json?.detail ?? ''}`.trim(),
    );
    err.platform = true;
    err.status = res.status;
    err.problem = json;
    throw err;
  }
  return json;
}

// Express 4 does not catch a rejected promise from an async handler, and Node
// exits on an unhandled rejection — so one unreachable platform API would take
// the whole service down. Route every async handler through this wrapper and let
// the error middleware at the bottom turn failures into a 502.
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

/**
 * Final text of a finished run. Agent runs end with `{ text, json? }`; workflow
 * runs end with `{ outputs: { <node_id>: { text, child_run_id } }, variables }`,
 * so take the text of the last node that produced one.
 */
function summarize(output) {
  if (typeof output !== 'object' || output === null) return null;
  if (typeof output.text === 'string') return output.text;
  const nodes = Object.values(output.outputs ?? {});
  const last = nodes.reverse().find((n) => typeof n?.text === 'string');
  return last?.text ?? null;
}

const app = express();

// 1. Webhook receiver — raw body FIRST (the signature covers the exact bytes).
app.post('/hive/webhook', express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
  const raw = req.body.toString('utf8');
  const check = verifyHiveWebhook(HIVE_WEBHOOK_SECRET, req.get('hive-signature'), raw);
  if (!check.ok) {
    log('webhook.rejected', false, { reason: check.reason });
    res.status(401).json({ error: check.reason });
    return;
  }
  const event = JSON.parse(raw);
  // Retries resend the same envelope id: acknowledge duplicates without re-applying.
  if (store.isProcessed(event.id)) {
    log('webhook.duplicate', true, { event_id: event.id, type: event.type });
    res.status(200).json({ received: true, duplicate: true });
    return;
  }
  log('webhook.received', true, {
    event_id: event.id,
    type: event.type,
    delivery_id: req.get('hive-delivery-id'),
  });
  switch (event.type) {
    case 'run.completed':
    case 'run.failed': {
      store.applyRunEvent(
        event.data.run_id,
        event.data.status,
        summarize(event.data.output) ?? event.data.error?.message ?? null,
      );
      pendingApprovals.forEach((a, id) => {
        if (a.run_id === event.data.run_id) pendingApprovals.delete(id);
      });
      break;
    }
    case 'approval.requested':
      pendingApprovals.set(event.data.approval_id, event.data);
      break;
    default:
      break; // webhook.test and events you did not subscribe to
  }
  // Mark AFTER applying (at-least-once): if applying throws, the 500 below makes
  // the platform retry the same envelope instead of losing it. In a real service
  // persist the event first and apply from a queue — that is still "mark after".
  store.markProcessed(event.id);
  res.status(200).json({ received: true });
});

app.use(express.json({ limit: '1mb' }));

app.get('/health', (_req, res) => res.json({ ok: true }));
app.get('/orders', (_req, res) => res.json({ items: store.listOrders() }));
app.get('/orders/:id', (req, res) => {
  const order = store.getOrder(req.params.id);
  if (order === null) res.status(404).json({ error: 'NOT_FOUND' });
  else res.json(order);
});

// 2a. Business event -> HMAC-signed trigger -> published workflow (triage agent, read-only).
app.post(
  '/orders/:id/escalate',
  asyncHandler(async (req, res) => {
    const order = store.getOrder(req.params.id);
    if (order === null) {
      res.status(404).json({ error: 'NOT_FOUND' });
      return;
    }
    const payload = {
      event: 'order.escalated',
      order_id: order.id,
      reason: req.body?.reason ?? 'Customer requested a refund',
      requested_by: req.body?.requested_by ?? 'support@acme.example',
      occurred_at: new Date().toISOString(),
    };
    const raw = JSON.stringify(payload);
    const hook = await platformFetch(`${HIVE_API_URL}/hooks/${HIVE_TRIGGER_ID}`, {
      method: 'POST',
      headers: triggerHeaders(HIVE_TRIGGER_SECRET, raw),
      body: raw,
    });
    const body = await readBody(hook);
    log('trigger.fired', hook.ok, { status: hook.status, run_id: body?.run_id ?? null });
    if (!hook.ok) {
      res.status(502).json({ error: 'TRIGGER_FAILED', hive: body });
      return;
    }
    store.attachRun(order.id, 'triage', body.run_id);
    res.status(202).json({ run_id: body.run_id, order_id: order.id });
  }),
);

// 2b. Privileged action -> direct agent run. orders.refund sits under an approval
//     policy, so this run pauses (approval.requested) until a human decides; a
//     workflow agent node could not be resumed that way, which is why the refund
//     is a direct run and not a node in the escalation workflow.
app.post(
  '/orders/:id/refund',
  asyncHandler(async (req, res) => {
    const order = store.getOrder(req.params.id);
    if (order === null) {
      res.status(404).json({ error: 'NOT_FOUND' });
      return;
    }
    const request = {
      event: 'refund.requested',
      order_id: order.id,
      reason: req.body?.reason ?? 'Customer requested a refund',
      ...(Number.isInteger(req.body?.amount_minor)
        ? { amount_minor: req.body.amount_minor }
        : {}),
      requested_by: req.body?.requested_by ?? 'support@acme.example',
      // Your own idempotency key: the agent passes it to orders.refund, so a retried
      // run cannot refund twice.
      idempotency_key: `refund:${order.id}:${Date.now()}`,
    };
    const started = await hive('POST', `/v1/agents/${HIVE_REFUND_AGENT_ID}/runs`, {
      input: {
        text: `Process this refund request using your tools: ${JSON.stringify(request)}`,
      },
    });
    log('run.started', true, { run_id: started.run_id, order_id: order.id });
    store.attachRun(order.id, 'refund', started.run_id);
    res.status(202).json({ run_id: started.run_id, order_id: order.id });
  }),
);

// 3. The MCP server the platform's Tool Gateway calls (bearer = the encrypted connection).
app.post('/mcp', createMcpHandler({ bearerToken: MCP_BEARER_TOKEN, store, log }));

// 4. Approvals proxy — your UI calls THIS; the privileged key never leaves the server.
//    Replace `requireStaff` with your real session check (role: manager/admin).
function requireStaff(req, res, next) {
  if (typeof req.get('x-portal-user') !== 'string') {
    res.status(401).json({ error: 'SIGN_IN_REQUIRED' });
    return;
  }
  next();
}

app.get(
  '/api/approvals',
  requireStaff,
  asyncHandler(async (_req, res) => {
    const data = await hive('GET', '/v1/approvals?status=pending');
    res.json({
      items: data.items.map((a) => ({
        id: a.id,
        run_id: a.run_id,
        // Server-qualified for MCP tools ("acme-orders.orders.refund"); show it as-is.
        tool: a.requested_action.tool,
        args: a.requested_action.args,
        risk: a.risk,
        assignee_role: a.assignee_role,
        expires_at: a.expires_at,
      })),
    });
  }),
);

app.post(
  '/api/approvals/:id/decision',
  requireStaff,
  asyncHandler(async (req, res) => {
    const action = req.body?.action;
    if (action !== 'approve' && action !== 'reject') {
      res.status(400).json({ error: 'action must be approve or reject' });
      return;
    }
    try {
      const decided = await hive('POST', `/v1/approvals/${req.params.id}/decision`, {
        action,
        ...(typeof req.body?.feedback === 'string'
          ? { feedback: req.body.feedback }
          : {}),
      });
      pendingApprovals.delete(req.params.id);
      log('approval.decided', true, {
        approval_id: req.params.id,
        action,
        by: req.get('x-portal-user'),
      });
      res.json({ status: decided.status });
    } catch (error) {
      // 409 ALREADY_DECIDED is a benign race with the console or another approver;
      // pass the platform's answer through. Anything else (network) goes to the
      // error middleware below.
      if (error.status === undefined) throw error;
      res.status(error.status).json(error.problem ?? { error: error.message });
    }
  }),
);

app.get('/hive/pending-approvals', requireStaff, (_req, res) =>
  res.json({ items: [...pendingApprovals.values()] }),
);
// The access log shows run ids, tool arguments and who approved what — staff only.
app.get('/hive/access-log', requireStaff, (_req, res) => res.json({ items: accessLog }));

// 5. Last resort: a platform call that failed (unreachable API, non-2xx, non-JSON
//    body) becomes a 502 PLATFORM_UNAVAILABLE instead of an unhandled rejection;
//    anything else is a plain 500. Express selects this handler by its arity (4).
// eslint-disable-next-line no-unused-vars
app.use((error, _req, res, _next) => {
  const status = error.platform === true ? 502 : 500;
  log('request.failed', false, { status, message: error.message });
  res.status(status).json({
    code: status === 502 ? 'PLATFORM_UNAVAILABLE' : 'INTERNAL_ERROR',
    detail: error.message,
    ...(error.problem != null ? { platform: error.problem } : {}),
  });
});

app.listen(PORT, () => {
  console.log(`acme-orders listening on http://localhost:${PORT}`);
  console.log(`  MCP:      POST http://localhost:${PORT}/mcp`);
  console.log(`  Webhook:  POST http://localhost:${PORT}/hive/webhook`);
  console.log(`  Escalate: POST http://localhost:${PORT}/orders/ord_1001/escalate`);
  console.log(`  Refund:   POST http://localhost:${PORT}/orders/ord_1001/refund`);
});

Adding your own

The pattern is always the same: load the bundle (script tag or ESM), call HiveChat.init once per key, keep the handle, and clean up with destroy() when your view unmounts. The JavaScript API page lists every method and event; the Installation page covers framework specifics.