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.
| Example | Stack | Dev origin | Shows |
|---|---|---|---|
| Plain HTML | No build step | http://localhost:8080 | Script-tag auto-init · Content-Security-Policy meta · Queue pattern before load · Events on document |
| React + Vite | React 19, Vite 6, TypeScript | http://localhost:5173 | Custom hook · Live locale switch (EN/AR) · send() / reset() from buttons · Typed window.HiveChat |
| Next.js 15 | App Router, React 19, jose | http://localhost:3000 | next/script with data-manual · Client component lifecycle · Identity token route handler · update({ user }) after sign-in |
| Vue 3 | Vue 3.5, Vite 6, TypeScript | http://localhost:5174 | Composition API lifecycle · Launcher vs inline container · isCustomElement for <hive-chat> |
| Angular 17+ | Standalone components, signals | http://localhost:4200 | Injectable HiveChatService · runOutsideAngular + NgZone.run · Signals for status · Template variant |
| Express identity backend | Node, Express 4, jsonwebtoken | http://localhost:8787 | HS256 identity JWT · sub / name / email / meta claims · Token fetched before init · identified flag on hive:ready |
| Backend integration (Node) | Node 20, Express 4, @modelcontextprotocol/sdk | http://localhost:4100 | HMAC-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.
<!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>// Plain JS: talk to the widget through window.HiveChat and the element's events.
// The loader may still be downloading when this runs, so we queue the call.
const KEY = 'hive_pk_REPLACE_WITH_YOUR_PUBLISHABLE_KEY';
window.HiveChat = window.HiveChat || { q: [] };
const status = document.getElementById('status');
document.getElementById('help').addEventListener('click', () => {
if (typeof window.HiveChat.init === 'function') {
// init() is idempotent per key: returns the auto-initialized widget's handle.
window.HiveChat.init({ key: KEY }).open();
} else {
// Loader not here yet: queue an init that starts open; drained on load.
window.HiveChat.q.push([{ key: KEY, open: true }]);
}
});
// Events bubble (composed) to document, so we can listen before the element exists.
document.addEventListener('hive:ready', () => {
// Config loaded; the session is minted lazily on first open / first send.
status.textContent = 'widget: ready';
});
document.addEventListener('hive:session', (e) => {
status.textContent = `widget: session (visitor ${e.detail.visitor_id.slice(0, 8)}…)`;
});
document.addEventListener('hive:message', (e) => {
status.textContent = `widget: ${e.detail.role} said ${e.detail.text.length} chars`;
});
document.addEventListener('hive:error', (e) => {
status.textContent = `widget: error ${e.detail.code}`;
});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.
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 };
}import { useState } from 'react';
import { useHiveChat } from './useHiveChat';
const ORIGIN = import.meta.env.VITE_HIVE_ORIGIN as string;
const KEY = import.meta.env.VITE_HIVE_EMBED_KEY as string;
export function App() {
const [locale, setLocale] = useState<'en' | 'ar'>('en');
const { handle, state } = useHiveChat(ORIGIN, {
key: KEY,
locale,
welcome: locale === 'ar' ? 'مرحباً! كيف يمكنني مساعدتك؟' : 'Hi! How can I help?',
theme: { primary: '#0f766e', radius: 14 },
});
return (
<main
style={{ fontFamily: 'system-ui', maxWidth: 640, margin: '48px auto', padding: 24 }}
>
<h1>Acme Support (React + Vite)</h1>
<p>
Widget: {state.ready ? 'ready' : 'loading…'} · panel{' '}
{state.open ? 'open' : 'closed'} · {state.messages} messages
{state.lastError ? ` · error ${state.lastError}` : ''}
</p>
<p style={{ display: 'flex', gap: 8 }}>
<button onClick={() => handle.current?.open()}>Open chat</button>
<button onClick={() => void handle.current?.send('What can you help me with?')}>
Ask a question
</button>
<button onClick={() => setLocale((l) => (l === 'en' ? 'ar' : 'en'))}>
Switch to {locale === 'en' ? 'Arabic' : 'English'}
</button>
<button onClick={() => handle.current?.reset()}>New conversation</button>
</p>
</main>
);
}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.
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>
);
}'use client';
import { useEffect, useRef, useState } from 'react';
interface HiveChatHandle {
element: HTMLElement;
open(): void;
close(): void;
toggle(): void;
send(text: string): Promise<void>;
reset(): void;
update(opts: Record<string, unknown>): void;
destroy(): void;
}
interface HiveChatApi {
version: string;
init(opts: Record<string, unknown>, container?: Element | null): HiveChatHandle;
}
declare global {
interface Window {
HiveChat?: HiveChatApi | { q: unknown[] };
}
}
const KEY = process.env.NEXT_PUBLIC_HIVE_EMBED_KEY ?? '';
const ORIGIN = process.env.NEXT_PUBLIC_HIVE_ORIGIN ?? '';
/** Resolves when the loader from the root layout has installed window.HiveChat. */
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();
});
}
export function HiveChatWidget() {
const handle = useRef<HiveChatHandle | null>(null);
const [status, setStatus] = useState('loading…');
const [identified, setIdentified] = useState(false);
useEffect(() => {
let cancelled = false;
void whenLoaded().then((api) => {
if (cancelled) return;
const h = api.init({
key: KEY,
apiUrl: ORIGIN,
locale: 'auto',
welcome: 'Hi! Ask me anything about your Acme account.',
});
handle.current = h;
h.element.addEventListener('hive:ready', () => setStatus('ready (no session yet)'));
h.element.addEventListener('hive:session', (e) => {
const d = (e as CustomEvent<{ identified: boolean }>).detail;
setStatus(d.identified ? 'session — identified' : 'session — anonymous');
});
h.element.addEventListener('hive:error', (e) =>
setStatus(`error ${(e as CustomEvent<{ code: string }>).detail.code}`),
);
});
return () => {
cancelled = true;
handle.current?.destroy();
handle.current = null;
};
}, []);
async function signIn() {
// Your backend mints the token; the widget re-mints its session with it.
const res = await fetch('/api/hive-identity', { method: 'POST' });
const { token } = (await res.json()) as { token: string };
handle.current?.update({ user: { token } });
setIdentified(true);
}
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => handle.current?.open()}>Open chat</button>
<button onClick={() => void signIn()} disabled={identified}>
{identified ? 'Identified as Jane' : 'Sign in (demo)'}
</button>
<span style={{ color: '#666', fontSize: 14 }}>widget: {status}</span>
</div>
);
}import { SignJWT } from 'jose';
import { NextResponse } from 'next/server';
/**
* Mints the identity JWT for the signed-in user. Runs on the server only;
* the identity secret never reaches the browser.
*
* Claims (see Authentication & security → Identified end users):
* sub (required, ≤ 256 chars), name/email/meta (optional), iat, exp (≤ 24 h ahead).
* Omit optional claims rather than sending null — "email": null is rejected.
*/
export async function POST() {
// Replace with your real session lookup (cookies(), auth(), …).
const user: { id: string; name?: string | null; email?: string | null } = {
id: 'user_1234',
name: 'Jane Doe',
email: 'jane@example.com',
};
const secret = process.env.HIVE_EMBED_IDENTITY_SECRET;
if (secret === undefined || secret === '') {
return NextResponse.json(
{ error: 'HIVE_EMBED_IDENTITY_SECRET is not set' },
{ status: 500 },
);
}
const token = await new SignJWT({
...(user.name ? { name: user.name } : {}),
...(user.email ? { email: user.email } : {}),
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('1h')
.sign(new TextEncoder().encode(secret));
return NextResponse.json({ token });
}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.
<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>import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
vue({
template: {
compilerOptions: {
// Tell Vue that <hive-chat> is a custom element, not a missing component.
isCustomElement: (tag) => tag === 'hive-chat',
},
},
}),
],
server: { port: 5174 },
});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.
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;
}
}import { Component, NgZone, OnDestroy, OnInit, signal } from '@angular/core';
import { HiveChatService } from './hive-chat.service';
@Component({
selector: 'app-chat-launcher',
standalone: true,
template: `
<p>widget: {{ status() }} · {{ messages() }} messages</p>
<button type="button" (click)="open()">Open chat</button>
<button type="button" (click)="ask()">Ask a question</button>
<button type="button" (click)="arabic()">Switch to Arabic</button>
`,
})
export class ChatLauncherComponent implements OnInit, OnDestroy {
readonly status = signal('loading…');
readonly messages = signal(0);
private listeners: Array<[string, EventListener]> = [];
constructor(
private readonly chat: HiveChatService,
private readonly zone: NgZone,
) {}
async ngOnInit(): Promise<void> {
const handle = await this.chat.init({
locale: 'auto',
welcome: 'Hi from Angular! How can I help?',
theme: { primary: '#dd0031' },
});
const on = (name: string, fn: (detail: unknown) => void) => {
const l: EventListener = (e) => this.zone.run(() => fn((e as CustomEvent).detail));
handle.element.addEventListener(name, l);
this.listeners.push([name, l]);
};
on('hive:ready', () => this.status.set('ready'));
on('hive:message', () => this.messages.update((n) => n + 1));
on('hive:error', (d) => this.status.set(`error ${(d as { code: string }).code}`));
}
ngOnDestroy(): void {
const h = this.chat.current;
if (h) for (const [name, l] of this.listeners) h.element.removeEventListener(name, l);
this.chat.destroy();
}
open(): void {
this.chat.current?.open();
}
ask(): void {
void this.chat.current?.send('What are your opening hours?');
}
arabic(): void {
this.chat.current?.update({ locale: 'ar', welcome: 'مرحباً! كيف يمكنني مساعدتك؟' });
}
}
/*
* Variant: render the element in a template (ESM package or hosted loader with
* data-manual — the tag is registered either way once the script has run).
*
* import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
* @Component({
* standalone: true,
* schemas: [CUSTOM_ELEMENTS_SCHEMA],
* template: `<hive-chat [attr.key]="key" mode="inline" locale="auto"
* (hive:message)="onMessage($event)"></hive-chat>`,
* })
* export class InlineChatComponent { key = HIVE_EMBED_KEY; onMessage(e: Event) { … } }
*/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.
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}`);
});<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Acme — identified chat (Express)</title>
<style>
body {
font-family: system-ui, sans-serif;
margin: 0;
padding: 48px 24px;
}
main {
max-width: 640px;
margin: 0 auto;
}
button {
font: inherit;
padding: 10px 16px;
border-radius: 8px;
border: 1px solid #c9c8bf;
background: #fff;
cursor: pointer;
}
.muted {
color: #5c6357;
font-size: 14px;
}
</style>
</head>
<body>
<main>
<h1>Acme Account</h1>
<p>
This page is "signed in" as Jane. Before the widget starts, it asks the backend
for an identity token so the conversation is tied to her account (visible in the
console's Contacts page). The identity secret never leaves the server.
</p>
<button id="open" type="button">Chat with support</button>
<p id="status" class="muted">fetching identity token…</p>
</main>
<script async src="__HIVE_ORIGIN__/embed/v1/hive-embed.js" data-manual></script>
<script>
const status = document.getElementById('status');
window.HiveChat = window.HiveChat || { q: [] };
fetch('/api/hive-identity', { method: 'POST' })
.then((r) => r.json())
.then(({ token }) => {
// Queue works whether or not the loader has arrived yet.
const opts = { key: '__HIVE_EMBED_KEY__', user: { token }, locale: 'auto' };
if (typeof window.HiveChat.init === 'function') window.HiveChat.init(opts);
else window.HiveChat.q.push([opts]);
status.textContent = 'identity token passed to widget';
})
.catch((err) => (status.textContent = 'could not fetch identity token: ' + err));
document.getElementById('open').addEventListener('click', () => {
if (typeof window.HiveChat.init === 'function')
window.HiveChat.init({ key: '__HIVE_EMBED_KEY__' }).open();
});
document.addEventListener('hive:ready', () => {
// Config loaded. The session (and the identity exchange) happens on first open/send.
status.textContent = 'widget ready — open it to mint an identified session';
});
document.addEventListener('hive:session', (e) => {
// detail = { visitor_id, identified }
status.textContent = e.detail.identified
? 'session ready — identified user'
: 'session ready — anonymous';
});
document.addEventListener(
'hive:error',
(e) => (status.textContent = 'error ' + e.detail.code),
);
</script>
</body>
</html>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.
// 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`);
});// The surface the platform's agents act against: an MCP server over Streamable HTTP,
// stateless (a fresh server + transport per request, no sessions), protected
// by a bearer token that lives platform-side only as an encrypted connection.
// Same SDK and transport the demo portals use.
import { createHash, timingSafeEqual } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
/** Constant-time bearer comparison (hash first so lengths never leak). */
function tokenMatches(header, expected) {
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
const presented = createHash('sha256').update(header.slice(7)).digest();
const wanted = createHash('sha256').update(expected).digest();
return timingSafeEqual(presented, wanted);
}
const text = (value) => ({ content: [{ type: 'text', text: JSON.stringify(value) }] });
const toolError = (code, message) => ({
content: [{ type: 'text', text: JSON.stringify({ error: code, message }) }],
isError: true,
});
function buildServer(store, log) {
const server = new McpServer({ name: 'acme-orders', version: '1.0.0' });
server.registerTool(
'orders.get',
{
description:
'Fetch one order by id (ord_…): customer, total, currency, status and refunds so far. The order system is the source of truth.',
inputSchema: { order_id: z.string().min(3) },
},
async ({ order_id }) => {
const order = store.getOrder(order_id);
log('orders.get', order !== null, { order_id });
if (order === null) return toolError('NOT_FOUND', `No order matches ${order_id}`);
return text(order);
},
);
server.registerTool(
'orders.refund',
{
description:
'Refund part or all of an order. amount_minor is in minor units (fils); the refund never exceeds the remaining balance. Idempotent per idempotency_key — reuse the same key when retrying. This moves money: it is gated by a human approval on the platform.',
inputSchema: {
order_id: z.string().min(3),
amount_minor: z.number().int().positive(),
reason: z.string().min(3).max(500),
idempotency_key: z.string().min(8).max(120),
},
},
async ({ order_id, amount_minor, reason, idempotency_key }) => {
const result = store.refund(order_id, amount_minor, reason, idempotency_key);
log('orders.refund', result.error === undefined, { order_id, amount_minor });
if (result.error !== undefined) return toolError(result.error, result.message);
return text({ ok: true, ...result });
},
);
return server;
}
/**
* Express handler for `POST /mcp`. Mount after `express.json()` so the parsed
* body can be handed to the transport.
*/
export function createMcpHandler({ bearerToken, store, log = () => {} }) {
return async (req, res) => {
if (!tokenMatches(req.headers.authorization, bearerToken)) {
log('mcp.auth', false, {});
res.status(401).json({
jsonrpc: '2.0',
error: { code: -32001, message: 'Unauthorized: bearer credential required.' },
id: null,
});
return;
}
const server = buildServer(store, log);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on('close', () => {
void transport.close();
void server.close();
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
log('mcp.error', false, {
message: error instanceof Error ? error.message : String(error),
});
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: { code: -32603, message: 'Internal error' },
id: null,
});
}
}
};
}// HMAC helpers for both directions of the platform <-> backend contract.
//
// Your backend -> platform POST /hooks/:triggerId
// x-hive-signature: sha256=<hex HMAC-SHA256(trigger secret, raw JSON body)>
//
// Platform -> your backend POST <your webhook URL>
// Hive-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(endpoint secret, "<t>.<raw body>")>
//
// Both sides sign the RAW bytes. Never re-serialize JSON before verifying.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TRIGGER_HEADER = 'x-hive-signature';
const WEBHOOK_HEADER = 'hive-signature';
const DEFAULT_TOLERANCE_SEC = 300;
/** `sha256=<hex>` for the exact body string you are about to send. */
export function signTriggerBody(secret, rawBody) {
return `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;
}
/** Headers for a signed trigger request (body must be sent byte-for-byte). */
export function triggerHeaders(secret, rawBody) {
return {
'content-type': 'application/json',
[TRIGGER_HEADER]: signTriggerBody(secret, rawBody),
};
}
function constantTimeEqualHex(a, b) {
if (a.length !== b.length || a.length === 0) return false;
try {
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
} catch {
return false;
}
}
/**
* Verifies a `Hive-Signature` header against the raw webhook body. Returns
* `{ ok: true, timestamp }` or `{ ok: false, reason }` with reason one of
* MALFORMED_HEADER | STALE_TIMESTAMP | SIGNATURE_MISMATCH.
*/
export function verifyHiveWebhook(secret, signatureHeader, rawBody, options = {}) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec((signatureHeader ?? '').trim());
if (match === null) return { ok: false, reason: 'MALFORMED_HEADER' };
const timestamp = Number(match[1]);
const tolerance = options.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
const now = options.nowSec ?? Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > tolerance)
return { ok: false, reason: 'STALE_TIMESTAMP' };
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
if (!constantTimeEqualHex(expected, match[2])) {
return { ok: false, reason: 'SIGNATURE_MISMATCH' };
}
return { ok: true, timestamp };
}
export { TRIGGER_HEADER, WEBHOOK_HEADER, DEFAULT_TOLERANCE_SEC };#!/usr/bin/env node
// Provisions the platform side of this integration as code — idempotently, so it
// is safe to re-run in every environment. Mirrors the demo portals' setup-hive
// scripts. Order matters: the MCP server must exist before the agent manifest
// that references it can be published.
//
// MCP server (acme-orders) -> connection (bearer, encrypted at rest)
// triage agent (published) -> tool grant orders.get -> workflow (published) -> webhook trigger
// refund agent (published) -> tool grant orders.* -> approval policy on orders.refund
// integration API key -> webhook endpoint (signing secret shown once)
//
// Why two agents: an approval policy on an agent tool pauses a *direct* run
// (POST /v1/agents/:id/runs, threads) until a human decides. Inside a workflow
// an agent node cannot be resumed, so a child run that pauses for approval fails
// the workflow (RunError CHILD_RUN_FAILED). Read-only triage therefore runs in
// the trigger → workflow path; the gated refund runs as a direct agent run —
// the same split the Meridian demo makes between intake and its payment clerk.
//
// Required env (a tenant ADMIN key — the integration gets its own key below):
// HIVE_API_URL e.g. http://localhost:4000 (the API_PORT default of a local stack)
// HIVE_ADMIN_API_KEY hive_… (admin)
// HIVE_TENANT_ID tenant uuid
// PUBLIC_URL where the platform can reach THIS service, e.g. https://abc.trycloudflare.com
// (local worker: http://host.docker.internal:4100 + A2A_ALLOW_INSECURE_HOSTS)
// Optional:
// HIVE_MODEL model id for the agent (default openai/gpt-oss-20b)
//
// `node scripts/setup-hive.mjs --teardown` removes everything it created.
import { randomBytes } from 'node:crypto';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const ENV_PATH = resolve(ROOT, '.env');
const existing = readEnvFile(ENV_PATH);
const env = (name, fallback) => process.env[name] ?? existing.get(name) ?? fallback;
const BASE_URL = env('HIVE_API_URL', 'http://localhost:4000').replace(/\/$/, '');
const ADMIN_KEY = env('HIVE_ADMIN_API_KEY');
const TENANT_ID = env('HIVE_TENANT_ID');
const PUBLIC_URL = env('PUBLIC_URL', 'http://host.docker.internal:4100').replace(
/\/$/,
'',
);
const MODEL = env('HIVE_MODEL', 'openai/gpt-oss-20b');
const TEARDOWN = process.argv.includes('--teardown');
if (!ADMIN_KEY || !TENANT_ID) {
console.error('HIVE_ADMIN_API_KEY and HIVE_TENANT_ID are required.');
process.exit(1);
}
const MCP_SERVER_SLUG = 'acme-orders';
const CONNECTION_NAME = 'acme-orders bearer';
const TRIAGE_AGENT_SLUG = 'acme-triage-agent';
const REFUND_AGENT_SLUG = 'acme-refund-agent';
const POLICY_NAME = 'Refunds need a human (acme-orders)';
const WORKFLOW_SLUG = 'acme-order-escalation';
const TRIGGER_NAME = 'Order escalated (acme-orders)';
const API_KEY_NAME = 'acme-orders integration';
const WEBHOOK_URL = `${PUBLIC_URL}/hive/webhook`;
const MCP_ENDPOINT = `${PUBLIC_URL}/mcp`;
// ── REST helper (admin key) ────────────────────────────────────────────────
async function rest(method, path, body) {
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
authorization: `Bearer ${ADMIN_KEY}`,
'x-tenant-id': TENANT_ID,
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
const json = text.length > 0 ? JSON.parse(text) : null;
if (!res.ok) {
throw new Error(
`${method} ${path} → ${res.status} ${json?.code ?? ''}: ${json?.detail ?? text}`,
);
}
return json;
}
function readEnvFile(path) {
const map = new Map();
if (!existsSync(path)) return map;
for (const line of readFileSync(path, 'utf8').split('\n')) {
const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
if (m) map.set(m[1], m[2]);
}
return map;
}
function writeEnvFile(values) {
const merged = new Map(existing);
for (const [k, v] of Object.entries(values)) merged.set(k, v);
const lines = [
'# Written by scripts/setup-hive.mjs — server.mjs reads this at boot.',
'# Secrets are shown by the platform exactly once; re-running the script keeps them.',
...[...merged.entries()].map(([k, v]) => `${k}=${v}`),
'',
];
writeFileSync(ENV_PATH, lines.join('\n'));
}
// ── 1. MCP server + encrypted connection ───────────────────────────────────
async function ensureMcpServer() {
const { items } = await rest('GET', '/v1/mcp-servers?limit=100');
const found = items.find((s) => s.slug === MCP_SERVER_SLUG);
if (found) {
if (found.endpoint !== MCP_ENDPOINT) {
await rest('PATCH', `/v1/mcp-servers/${found.id}`, { endpoint: MCP_ENDPOINT });
console.log(`✓ mcp server ${MCP_SERVER_SLUG} endpoint → ${MCP_ENDPOINT}`);
} else console.log(`✓ mcp server ${MCP_SERVER_SLUG} exists — ${found.id}`);
return found.id;
}
const created = await rest('POST', '/v1/mcp-servers', {
slug: MCP_SERVER_SLUG,
name: 'Acme orders',
transport: 'http',
endpoint: MCP_ENDPOINT,
auth_kind: 'bearer',
enabled: true,
meta: {
description: 'Order system exposed to agents (backend-integration-node example).',
},
});
console.log(`✓ mcp server ${MCP_SERVER_SLUG} created — ${created.id}`);
return created.id;
}
async function ensureConnection(serverId, bearerToken) {
const { items } = await rest('GET', '/v1/connections?limit=100');
const found = items.find(
(c) => c.mcp_server_id === serverId && c.name === CONNECTION_NAME,
);
if (found) {
await rest('PATCH', `/v1/connections/${found.id}`, {
credentials: { token: bearerToken },
});
console.log(`✓ connection "${CONNECTION_NAME}" exists — credential synced`);
return found.id;
}
const created = await rest('POST', '/v1/connections', {
mcp_server_id: serverId,
name: CONNECTION_NAME,
credentials: { token: bearerToken },
credential_meta: { kind: 'service bearer', service: PUBLIC_URL },
});
console.log(`✓ connection "${CONNECTION_NAME}" created (encrypted at rest)`);
return created.id;
}
// ── 2. The agents (published; converged when the manifest drifts) ──────────
const TRIAGE_AGENT = {
slug: TRIAGE_AGENT_SLUG,
name: 'Acme triage agent',
description: 'Reads an escalated order and recommends whether to refund.',
allow: ['orders.get'],
prompt: [
'You are the order triage agent for Acme orders. You receive an escalation event as JSON.',
'Act, do not deliberate: your first step is always the orders.get tool call for the order_id in the event.',
'You cannot refund; a separate refund clerk does that after a human approves.',
'Finish with a one-paragraph recommendation: order id, whether a refund is warranted (only delivered or shipped orders qualify), the recommended amount in minor units (the remaining balance unless the event names a smaller amount) and the reason.',
].join(' '),
};
const REFUND_AGENT = {
slug: REFUND_AGENT_SLUG,
name: 'Acme refund clerk',
description: 'Executes an approved refund through the orders system.',
allow: ['orders.get', 'orders.refund'],
prompt: [
'You are the refund clerk for Acme orders. You receive a refund request as JSON.',
'Act, do not deliberate: your first step is always the orders.get tool call for the order_id in the request.',
'Then refund with the orders.refund tool only when the order status is delivered or shipped. Refund the full remaining balance unless the request names a smaller amount_minor; pass the request idempotency_key as idempotency_key and the request reason as reason.',
'If a refund is not appropriate, do not call orders.refund; explain why in one sentence.',
'Finish with a one-paragraph summary: order id, what you did, amount, and the refund id if any.',
].join(' '),
};
function buildManifest(spec) {
return {
schema_version: '2.0',
identity: {
name: spec.name,
description: spec.description,
persona: { system_prompt: spec.prompt, tone: 'professional', language: 'en' },
},
// Reasoning models spend output tokens on thinking before the tool call —
// keep the budget generous or the run ends mid-thought without acting.
model: { model: MODEL, temperature: 0.1, max_output_tokens: 4000 },
tools: {
builtin: [],
mcp: [{ server: MCP_SERVER_SLUG, allow: spec.allow }],
},
guardrails: { max_steps: 8, blocked_topics: [] },
hitl: { default: 'auto', rules: [] },
};
}
async function ensureUnit() {
const tree = await rest('GET', '/v1/org/tree');
const flat = [];
const walk = (nodes) => {
for (const n of nodes) {
flat.push(n);
walk(n.children ?? []);
}
};
walk(tree.items);
const unit = flat.find((n) => n.kind === 'unit');
if (unit) return unit.id;
const dept = await rest('POST', '/v1/org/nodes', {
kind: 'department',
name: 'Operations',
slug: 'acme-ops',
});
const section = await rest('POST', '/v1/org/nodes', {
parent_id: dept.id,
kind: 'section',
name: 'Customer care',
slug: 'acme-care',
});
const created = await rest('POST', '/v1/org/nodes', {
parent_id: section.id,
kind: 'unit',
name: 'Refunds',
slug: 'acme-refunds',
});
console.log(`✓ org chain created (department → section → unit) — ${created.id}`);
return created.id;
}
async function publishedVersion(agentId) {
const { items } = await rest('GET', `/v1/agents/${agentId}/versions`);
return items
.filter((v) => v.published_at !== null)
.sort((a, b) => b.version - a.version)[0];
}
async function ensureAgent(spec) {
const manifest = buildManifest(spec);
const { items } = await rest('GET', '/v1/agents?limit=100');
let agentId = items.find((a) => a.slug === spec.slug)?.id;
if (!agentId) {
const created = await rest('POST', '/v1/agents', {
node_id: await ensureUnit(),
slug: spec.slug,
name: manifest.identity.name,
description: manifest.identity.description,
manifest,
});
agentId = created.agent.id;
await rest('POST', `/v1/agents/${agentId}/versions/1/publish`);
console.log(`✓ agent ${spec.slug} created + published (v1) — ${agentId}`);
} else {
const published = await publishedVersion(agentId);
const current = published
? await rest('GET', `/v1/agents/${agentId}/versions/${published.version}`)
: undefined;
const same =
current?.manifest?.model?.model === MODEL &&
current?.manifest?.model?.max_output_tokens === manifest.model.max_output_tokens &&
current?.manifest?.identity?.persona?.system_prompt === spec.prompt &&
JSON.stringify(current?.manifest?.tools?.mcp?.[0]?.allow) ===
JSON.stringify(spec.allow);
if (same) console.log(`✓ agent ${spec.slug} up to date — ${agentId}`);
else {
const draft = await rest('PUT', `/v1/agents/${agentId}/versions/draft`, {
manifest,
changelog: `converge ${spec.slug} (setup-hive)`,
});
await rest('POST', `/v1/agents/${agentId}/versions/${draft.version}/publish`);
console.log(`✓ agent ${spec.slug} upgraded → v${draft.version} published`);
}
}
const version = await publishedVersion(agentId);
return { id: agentId, versionId: version.id };
}
// ── 3. Default-deny grants + the approval policy (the HITL gate) ──────────
async function ensureToolGrant(serverId, connectionId, agentId, pattern) {
const { items } = await rest('GET', `/v1/tool-grants?limit=200&agent_id=${agentId}`);
const exists = items.some(
(g) =>
g.mcp_server_id === serverId && g.tool_pattern === pattern && g.effect === 'allow',
);
if (exists) return console.log(`✓ tool grant ${pattern} exists`);
await rest('POST', '/v1/tool-grants', {
agent_id: agentId,
source: 'mcp',
mcp_server_id: serverId,
tool_pattern: pattern,
effect: 'allow',
connection_id: connectionId,
});
console.log(`✓ tool grant ${pattern} created (agent-scoped, allow)`);
}
async function ensureApprovalPolicy(agentId) {
const { items } = await rest('GET', '/v1/approval-policies?limit=100');
if (items.some((p) => p.name === POLICY_NAME))
return console.log(`✓ approval policy exists`);
await rest('POST', '/v1/approval-policies', {
name: POLICY_NAME,
agent_id: agentId,
tool_pattern: 'orders.refund',
source: 'mcp',
risk: 'high',
required_approvals: 1,
approver_role: 'admin',
sla_minutes: 1440,
enabled: true,
});
console.log(
'✓ approval policy created: orders.refund → high risk, 1 approver, 24 h SLA',
);
}
// ── 4. Workflow: start → triage agent → end ───────────────────────────────
function buildGraph(agent) {
return {
nodes: [
{ id: 'start', type: 'start', config: {} },
{
id: 'triage',
type: 'agent',
config: {
agent_id: agent.id,
agent_version_id: agent.versionId,
input_template:
'Triage this order escalation using your tools. Order {{input.order_id}}; reason: {{input.reason}}. Full event: {{input}}',
},
},
{ id: 'end', type: 'end', config: {} },
],
edges: [
{ from: 'start', to: 'triage' },
{ from: 'triage', to: 'end' },
],
};
}
async function ensureWorkflow(agent) {
const graph = buildGraph(agent);
const { items } = await rest('GET', '/v1/workflows?limit=100');
let workflowId = items.find((w) => w.slug === WORKFLOW_SLUG)?.id;
if (!workflowId) {
const created = await rest('POST', '/v1/workflows', {
slug: WORKFLOW_SLUG,
name: 'Order escalation',
graph,
});
workflowId = created.workflow.id;
await rest('POST', `/v1/workflows/${workflowId}/versions/1/publish`);
console.log(`✓ workflow ${WORKFLOW_SLUG} created + published (v1) — ${workflowId}`);
return workflowId;
}
const versions = await rest('GET', `/v1/workflows/${workflowId}/versions`);
const published = versions.items
.filter((v) => v.published_at !== null)
.sort((a, b) => b.version - a.version)[0];
const current = published
? await rest('GET', `/v1/workflows/${workflowId}/versions/${published.version}`)
: undefined;
const node = current?.graph?.nodes?.find((n) => n.id === 'triage');
const same =
node?.config?.agent_version_id === agent.versionId &&
node?.config?.input_template === graph.nodes[1].config.input_template;
if (same) {
console.log(`✓ workflow ${WORKFLOW_SLUG} up to date — ${workflowId}`);
return workflowId;
}
const draft = await rest('PUT', `/v1/workflows/${workflowId}/versions/draft`, {
graph,
});
await rest('POST', `/v1/workflows/${workflowId}/versions/${draft.version}/publish`);
console.log(`✓ workflow ${WORKFLOW_SLUG} upgraded → v${draft.version} published`);
return workflowId;
}
// ── 5. Webhook trigger (HMAC secret shown once; kept in .env) ─────────────
async function ensureTrigger(workflowId) {
const { items } = await rest('GET', '/v1/triggers?limit=100');
const found = items.find((t) => t.name === TRIGGER_NAME && t.kind === 'webhook');
if (found) {
const kept = existing.get('HIVE_TRIGGER_SECRET');
if (existing.get('HIVE_TRIGGER_ID') === found.id && kept) {
console.log(`✓ trigger "${TRIGGER_NAME}" exists — keeping stored secret`);
return { id: found.id, secret: kept };
}
const rotated = await rest('POST', `/v1/triggers/${found.id}/rotate-secret`);
console.log(`✓ trigger "${TRIGGER_NAME}" exists — secret rotated`);
return { id: found.id, secret: rotated.secret };
}
const created = await rest('POST', '/v1/triggers', {
name: TRIGGER_NAME,
kind: 'webhook',
target_kind: 'workflow',
target_id: workflowId,
enabled: true,
config: {},
});
console.log(
`✓ trigger "${TRIGGER_NAME}" created — POST ${BASE_URL}/hooks/${created.id}`,
);
return { id: created.id, secret: created.secret };
}
// ── 6. A dedicated API key for the service (the admin key stays out) ──────
async function ensureApiKey() {
const kept = existing.get('HIVE_API_KEY');
if (kept) {
const probe = await fetch(`${BASE_URL}/v1/agents?limit=1`, {
headers: { authorization: `Bearer ${kept}`, 'x-tenant-id': TENANT_ID },
});
if (probe.ok) {
console.log('✓ integration API key in .env still valid — keeping it');
return kept;
}
}
const created = await rest('POST', '/v1/api-keys', { name: API_KEY_NAME });
console.log(`✓ API key "${API_KEY_NAME}" created — ${created.prefix}`);
return created.secret;
}
// ── 7. Webhook endpoint (signing secret shown once; kept in .env) ─────────
async function ensureWebhookEndpoint() {
const { items } = await rest('GET', '/v1/webhooks');
const found = items.find((e) => e.url === WEBHOOK_URL);
if (found) {
const kept = existing.get('HIVE_WEBHOOK_SECRET');
if (kept) {
console.log(`✓ webhook endpoint exists (${WEBHOOK_URL}) — keeping stored secret`);
return kept;
}
const rotated = await rest('POST', `/v1/webhooks/${found.id}/rotate-secret`);
console.log('✓ webhook endpoint exists — signing secret rotated');
return rotated.secret;
}
const created = await rest('POST', '/v1/webhooks', {
url: WEBHOOK_URL,
events: ['run.completed', 'run.failed', 'approval.requested'],
description: 'acme-orders example — run + approval lifecycle',
});
console.log(`✓ webhook endpoint created → ${WEBHOOK_URL}`);
return created.secret;
}
// ── Teardown: delete what this script owns (by slug / name / url) ─────────
async function teardown() {
const del = async (label, path) => {
await rest('DELETE', path);
console.log(`✗ ${label} deleted`);
};
const hooks = await rest('GET', '/v1/webhooks');
for (const e of hooks.items.filter((e) => e.url === WEBHOOK_URL))
await del('webhook endpoint', `/v1/webhooks/${e.id}`);
const triggers = await rest('GET', '/v1/triggers?limit=100');
for (const t of triggers.items.filter((t) => t.name === TRIGGER_NAME))
await del('trigger', `/v1/triggers/${t.id}`);
const workflows = await rest('GET', '/v1/workflows?limit=100');
for (const w of workflows.items.filter((w) => w.slug === WORKFLOW_SLUG))
await del('workflow', `/v1/workflows/${w.id}`);
const policies = await rest('GET', '/v1/approval-policies?limit=100');
for (const p of policies.items.filter((p) => p.name === POLICY_NAME))
await del('approval policy', `/v1/approval-policies/${p.id}`);
const servers = await rest('GET', '/v1/mcp-servers?limit=100');
const server = servers.items.find((s) => s.slug === MCP_SERVER_SLUG);
if (server) {
const grants = await rest('GET', '/v1/tool-grants?limit=200');
for (const g of grants.items.filter((g) => g.mcp_server_id === server.id))
await del('tool grant', `/v1/tool-grants/${g.id}`);
const conns = await rest('GET', '/v1/connections?limit=100');
for (const c of conns.items.filter((c) => c.mcp_server_id === server.id))
await del('connection', `/v1/connections/${c.id}`);
}
const agents = await rest('GET', '/v1/agents?limit=100');
for (const a of agents.items.filter((a) =>
[TRIAGE_AGENT_SLUG, REFUND_AGENT_SLUG].includes(a.slug),
))
await del(`agent ${a.slug}`, `/v1/agents/${a.id}`);
if (server) await del('mcp server', `/v1/mcp-servers/${server.id}`);
const keys = await rest('GET', '/v1/api-keys');
for (const k of keys.items.filter(
(k) => k.name === API_KEY_NAME && k.revoked_at === null,
))
await del('api key', `/v1/api-keys/${k.id}`);
console.log('Teardown complete. .env was left in place — delete it if you are done.');
}
// ── Run ───────────────────────────────────────────────────────────────────
if (TEARDOWN) {
await teardown();
process.exit(0);
}
console.log(
`Provisioning acme-orders on tenant ${TENANT_ID} (${BASE_URL}) → ${PUBLIC_URL}`,
);
const bearerToken = existing.get('MCP_BEARER_TOKEN') || randomBytes(32).toString('hex');
const mcpServerId = await ensureMcpServer();
const connectionId = await ensureConnection(mcpServerId, bearerToken);
const triage = await ensureAgent(TRIAGE_AGENT);
await ensureToolGrant(mcpServerId, connectionId, triage.id, 'orders.get');
const refund = await ensureAgent(REFUND_AGENT);
await ensureToolGrant(mcpServerId, connectionId, refund.id, 'orders.*');
await ensureApprovalPolicy(refund.id);
const workflowId = await ensureWorkflow(triage);
const trigger = await ensureTrigger(workflowId);
const apiKey = await ensureApiKey();
const webhookSecret = await ensureWebhookEndpoint();
writeEnvFile({
HIVE_API_URL: BASE_URL,
HIVE_TENANT_ID: TENANT_ID,
HIVE_API_KEY: apiKey,
HIVE_TRIGGER_ID: trigger.id,
HIVE_TRIGGER_SECRET: trigger.secret,
HIVE_WEBHOOK_SECRET: webhookSecret,
MCP_BEARER_TOKEN: bearerToken,
PUBLIC_URL,
HIVE_TRIAGE_AGENT_ID: triage.id,
HIVE_REFUND_AGENT_ID: refund.id,
HIVE_WORKFLOW_ID: workflowId,
});
console.log(`\nWrote ${ENV_PATH}`);
console.log('Integration surfaces:');
console.log(
` MCP server ${MCP_SERVER_SLUG} → ${MCP_ENDPOINT} (bearer, encrypted connection)`,
);
console.log(
` Workflow ${WORKFLOW_SLUG} (published, ${TRIAGE_AGENT_SLUG}) ← trigger POST ${BASE_URL}/hooks/${trigger.id}`,
);
console.log(
` Direct runs ${REFUND_AGENT_SLUG} ← POST ${BASE_URL}/v1/agents/${refund.id}/runs`,
);
console.log(` HITL gate orders.refund (approval policy, admin, 24 h)`);
console.log(
` Webhook ${WEBHOOK_URL} (run.completed / run.failed / approval.requested)`,
);
console.log(
'Next: node server.mjs, then POST /orders/ord_1001/escalate and POST /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.