+ Platform Stack
+
+
+flowchart TB
+ subgraph USER["USER LAYER"]
+ UI["End user → Control UI
(openclaw-ui-proxy · nginx mTLS bridge)"]
+ end
+
+ subgraph AGENT["AGENT LAYER · BYOA"]
+ OC["OpenClaw harness
Agent Sandbox · Landlock policies"]
+ end
+
+ subgraph PLATFORM["PLATFORM LAYER · Red Hat"]
+ GW["OpenShell Gateway — egress choke · key injection"]
+ ML["MLflow — tracing + prompt registry"]
+ NG["NeMo Guardrails · TrustyAI"]
+ end
+
+ subgraph INFERENCE["INFERENCE LAYER"]
+ IR["inference.local → NeMo → MaaS → LLM"]
+ end
+
+ subgraph INFRA["INFRASTRUCTURE"]
+ OCP["OpenShift + RHOAI 3.x · Agent Sandbox Operator (OLM)"]
+ end
+
+ USER --> AGENT --> PLATFORM --> INFERENCE --> INFRA
+
+
+
+
+
+ | Layer |
+ Tag |
+ Components |
+
+
+
+
+ | User |
+  |
+ Control UI via openclaw-ui-proxy (nginx mTLS bridge + password auth) |
+
+
+ | Agent (BYOA) |
+  |
+ OpenClaw in Agent Sandbox — interchangeable harness; Landlock + egress policies |
+
+
+ | Platform |
+
+
+
+ |
+ OpenShell · MLflow · NeMo Guardrails (TrustyAI) |
+
+
+ | Inference |
+  |
+ inference.local → MaaS — router injects API key; credentials never enter the sandbox |
+
+
+ | Infrastructure |
+  |
+ OCP + RHOAI 3.x — pinned operators; Agent Sandbox via OLM |
+
+
+
+
+
+ BYOA: the agent harness is interchangeable; the platform stack works regardless of framework.
+ Live map: v5/live.html step Overall Demo ·
+ Deep dive: Agent Sandbox and OpenShell — How It Works ·
+ Full matrix: AGENTS.md § Tech Stack
+
+
+
+
diff --git a/scripts/export-architecture-gif-preview.sh b/scripts/export-architecture-gif-preview.sh
new file mode 100755
index 0000000..4cfc700
--- /dev/null
+++ b/scripts/export-architecture-gif-preview.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Generate three temporary GIF variants for comparison (not for commit).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+TESTS_DIR="${ROOT_DIR}/tests"
+PREVIEW_DIR="${ROOT_DIR}/assets/preview"
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "error: required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+require_cmd python3
+require_cmd node
+require_cmd npm
+require_cmd ffmpeg
+
+if [[ ! -d "${TESTS_DIR}/node_modules/@playwright/test" ]]; then
+ echo "==> Installing test dependencies"
+ (cd "${TESTS_DIR}" && npm ci)
+fi
+
+export PLAYWRIGHT_BROWSERS_PATH=0
+(cd "${TESTS_DIR}" && npx playwright install chromium)
+
+mkdir -p "${PREVIEW_DIR}"
+
+run_variant() {
+ local name="$1"
+ local last_index="$2"
+ local fps="$3"
+ local out="${PREVIEW_DIR}/overall-architecture-${name}.gif"
+
+ echo ""
+ echo "==> Variant: ${name} (hops 0..${last_index}, ${fps} fps) -> ${out}"
+ (
+ cd "${TESTS_DIR}"
+ ARCH_GIF_LAST_INDEX="${last_index}" \
+ ARCH_GIF_FPS="${fps}" \
+ ARCH_GIF_OUTPUT="${out}" \
+ node export-architecture-gif.mjs
+ )
+}
+
+# 1) Request path only — End user through LLM (7 hops)
+run_variant "request-path" 6 2
+
+# 2) Request + response — through Landlock round-trip (14 hops)
+run_variant "request-response" 13 2
+
+# 3) Full baseline — all hops including egress + MLflow trace (22 hops)
+run_variant "full" 21 3
+
+echo ""
+echo "==> Preview GIFs written to ${PREVIEW_DIR}/"
+ls -lh "${PREVIEW_DIR}"/overall-architecture-*.gif
diff --git a/scripts/export-readme-architecture-gif.sh b/scripts/export-readme-architecture-gif.sh
new file mode 100755
index 0000000..b0efcae
--- /dev/null
+++ b/scripts/export-readme-architecture-gif.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# Export docs/demo/v5/live.html step 0 (Overall Demo) to assets/overall-architecture.gif.
+# Frame-by-frame Playwright capture + ffmpeg palettegen. Manual only — not run in CI.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
+TESTS_DIR="${ROOT_DIR}/tests"
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "error: required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+require_cmd python3
+require_cmd node
+require_cmd npm
+require_cmd ffmpeg
+
+if [[ ! -d "${TESTS_DIR}/node_modules/@playwright/test" ]]; then
+ echo "==> Installing test dependencies"
+ (cd "${TESTS_DIR}" && npm ci)
+fi
+
+echo "==> Ensuring Playwright Chromium is installed (project-local)"
+export PLAYWRIGHT_BROWSERS_PATH=0
+(cd "${TESTS_DIR}" && npx playwright install chromium)
+
+echo "==> Exporting README architecture GIF (baseline hops ${ARCH_GIF_FIRST_INDEX:-0}..${ARCH_GIF_LAST_INDEX:-6})"
+(cd "${TESTS_DIR}" && PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-gif.mjs)
diff --git a/tests/export-architecture-diagram.mjs b/tests/export-architecture-diagram.mjs
index f8e36c6..cf249c3 100644
--- a/tests/export-architecture-diagram.mjs
+++ b/tests/export-architecture-diagram.mjs
@@ -98,7 +98,7 @@ async function exportDiagram() {
},
);
- await page.goto(PAGE_URL, { waitUntil: "networkidle" });
+ await page.goto(PAGE_URL, { waitUntil: "load" });
await page.waitForFunction(() => document.body.classList.contains("nr-v5-step-0"));
await page.waitForSelector(".nr-v5-overall-mounted", { state: "attached" });
diff --git a/tests/export-architecture-gif.mjs b/tests/export-architecture-gif.mjs
new file mode 100644
index 0000000..7c75775
--- /dev/null
+++ b/tests/export-architecture-gif.mjs
@@ -0,0 +1,279 @@
+/**
+ * Export v5 live companion step 0 (Overall Demo) FlowStory embed to an animated GIF.
+ * Captures one frame per hop (jumpTo index) and assembles with ffmpeg palettegen.
+ * No cluster required.
+ *
+ * Env:
+ * ARCH_GIF_FIRST_INDEX — first jumpTo index (default 0)
+ * ARCH_GIF_LAST_INDEX — last jumpTo index (default 6 = request path through LLM)
+ * ARCH_GIF_FRAME_DELAY_MS — wait after jumpTo before screenshot (default 700)
+ * ARCH_GIF_FPS — output GIF frame rate (default 2)
+ * ARCH_GIF_WIDTH — output width in px (default 1200)
+ * ARCH_GIF_INCLUDE_RESET — include idle frame before hop 1 (default false)
+ * ARCH_GIF_OUTPUT — output path (default assets/overall-architecture.gif)
+ */
+import { chromium } from "@playwright/test";
+import { spawn, spawnSync } from "node:child_process";
+import { mkdtemp, mkdir, rm } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, "..");
+const DOCS_DIR = path.join(ROOT_DIR, "docs");
+const OUTPUT_PATH = process.env.ARCH_GIF_OUTPUT
+ ? path.resolve(ROOT_DIR, process.env.ARCH_GIF_OUTPUT)
+ : path.join(ROOT_DIR, "assets", "overall-architecture.gif");
+
+const HTTP_PORT = Number(process.env.ARCH_DIAGRAM_HTTP_PORT || 18765);
+const PAGE_URL = `http://127.0.0.1:${HTTP_PORT}/demo/v5/live.html#step-0`;
+const VIEWPORT = { width: 1600, height: 1100 };
+const DIAGRAM_SELECTOR = ".nr-v5-overall-mounted .fs-overall-canvas-wrap";
+
+const FIRST_INDEX = Number(process.env.ARCH_GIF_FIRST_INDEX ?? 0);
+const LAST_INDEX = Number(process.env.ARCH_GIF_LAST_INDEX ?? 6);
+const FRAME_DELAY_MS = Number(process.env.ARCH_GIF_FRAME_DELAY_MS ?? 700);
+const OUTPUT_FPS = Number(process.env.ARCH_GIF_FPS ?? 2);
+const OUTPUT_WIDTH = Number(process.env.ARCH_GIF_WIDTH ?? 1200);
+const INCLUDE_RESET =
+ (process.env.ARCH_GIF_INCLUDE_RESET ?? "false").toLowerCase() === "true";
+
+const LAYERS_DOCK_MODE_KEY = "agentops-layers-dock-mode";
+const LAYERS_DOCK_VISIBLE_KEY = "agentops-layers-dock-visible";
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function requireFfmpeg() {
+ const probe = spawnSync("ffmpeg", ["-version"], { stdio: "ignore" });
+ if (probe.status !== 0) {
+ throw new Error("ffmpeg is required for GIF export (install ffmpeg and retry)");
+ }
+}
+
+async function waitForServer(url, timeoutMs = 15_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ const res = await fetch(url, { signal: AbortSignal.timeout(2_000) });
+ if (res.ok) return;
+ } catch {
+ // retry
+ }
+ await sleep(200);
+ }
+ throw new Error(`Timed out waiting for static server at ${url}`);
+}
+
+function startStaticServer() {
+ const child = spawn("python3", ["-m", "http.server", String(HTTP_PORT)], {
+ cwd: DOCS_DIR,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+ child.on("error", (err) => {
+ console.error("Failed to start python3 http.server:", err.message);
+ process.exit(1);
+ });
+
+ return child;
+}
+
+function assembleGif(framesDir, frameCount, outputPath) {
+ const inputPattern = path.join(framesDir, "frame-%04d.png");
+ const palettePath = path.join(framesDir, "palette.png");
+ const scale = `scale=${OUTPUT_WIDTH}:-1:flags=lanczos`;
+
+ const paletteGen = spawnSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-framerate",
+ String(OUTPUT_FPS),
+ "-i",
+ inputPattern,
+ "-frames:v",
+ String(frameCount),
+ "-vf",
+ `${scale},palettegen=stats_mode=diff`,
+ palettePath,
+ ],
+ { stdio: "inherit" },
+ );
+ if (paletteGen.status !== 0) {
+ throw new Error("ffmpeg palettegen failed");
+ }
+
+ const paletteUse = spawnSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-framerate",
+ String(OUTPUT_FPS),
+ "-i",
+ inputPattern,
+ "-i",
+ palettePath,
+ "-frames:v",
+ String(frameCount),
+ "-lavfi",
+ `${scale}[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=5`,
+ "-loop",
+ "0",
+ outputPath,
+ ],
+ { stdio: "inherit" },
+ );
+ if (paletteUse.status !== 0) {
+ throw new Error("ffmpeg paletteuse failed");
+ }
+}
+
+async function exportGif() {
+ if (FIRST_INDEX < 0 || LAST_INDEX < FIRST_INDEX) {
+ throw new Error(`Invalid hop range: ${FIRST_INDEX}..${LAST_INDEX}`);
+ }
+
+ requireFfmpeg();
+ await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
+
+ const framesDir = await mkdtemp(path.join(os.tmpdir(), "agentops-arch-gif-"));
+ const server = startStaticServer();
+ let browser;
+
+ try {
+ await waitForServer(PAGE_URL.split("#")[0]);
+
+ browser = await chromium.launch({
+ headless: true,
+ args: ["--disable-gpu", "--disable-font-subpixel-positioning"],
+ });
+ const page = await browser.newPage({
+ viewport: VIEWPORT,
+ deviceScaleFactor: 1,
+ });
+
+ await page.addInitScript(
+ ({ modeKey, visibleKey }) => {
+ localStorage.clear();
+ localStorage.setItem(modeKey, "off");
+ localStorage.setItem(visibleKey, "false");
+
+ const style = document.createElement("style");
+ style.textContent = `
+ :root {
+ --nr-mono: "DejaVu Sans Mono", "Liberation Mono", monospace !important;
+ --nr-display: "DejaVu Sans", "Liberation Sans", sans-serif !important;
+ --nr-body: "DejaVu Sans", "Liberation Sans", sans-serif !important;
+ }
+ * {
+ -webkit-font-smoothing: none !important;
+ font-smooth: never !important;
+ }
+ `;
+ document.documentElement.appendChild(style);
+ },
+ {
+ modeKey: LAYERS_DOCK_MODE_KEY,
+ visibleKey: LAYERS_DOCK_VISIBLE_KEY,
+ },
+ );
+
+ await page.goto(PAGE_URL, { waitUntil: "load" });
+
+ await page.waitForFunction(() => document.body.classList.contains("nr-v5-step-0"));
+ await page.waitForSelector(".nr-v5-overall-mounted", { state: "attached" });
+ await page.waitForFunction(() => Boolean(window.__flowstory?._canvas));
+ await page.evaluate(() => document.body.classList.add("light"));
+
+ const stepCount = await page.evaluate(() => {
+ const viz = window.__flowstory;
+ if (!viz?._diagram?.flows) {
+ throw new Error("FlowStory diagram not loaded");
+ }
+ viz.closeOverlay?.();
+ const flowId = viz.state.activeFlow || viz._diagram.defaultFlow || "baseline";
+ const steps = viz._diagram.flows[flowId]?.steps;
+ if (!steps?.length) {
+ throw new Error(`No steps for flow: ${flowId}`);
+ }
+ return steps.length;
+ });
+
+ if (LAST_INDEX >= stepCount) {
+ throw new Error(
+ `ARCH_GIF_LAST_INDEX=${LAST_INDEX} exceeds baseline hop count (${stepCount})`,
+ );
+ }
+
+ const diagram = page.locator(DIAGRAM_SELECTOR);
+ await diagram.waitFor({ state: "visible" });
+ await page.evaluate(() => document.fonts?.ready);
+
+ let frameNumber = 1;
+
+ async function captureFrame() {
+ const framePath = path.join(framesDir, `frame-${String(frameNumber).padStart(4, "0")}.png`);
+ await diagram.screenshot({
+ path: framePath,
+ animations: "disabled",
+ scale: "css",
+ });
+ frameNumber += 1;
+ }
+
+ if (INCLUDE_RESET) {
+ await page.evaluate(() => {
+ window.__flowstory?.reset?.();
+ });
+ await page.waitForTimeout(FRAME_DELAY_MS);
+ await captureFrame();
+ }
+
+ for (let index = FIRST_INDEX; index <= LAST_INDEX; index += 1) {
+ await page.evaluate((hopIndex) => {
+ const viz = window.__flowstory;
+ viz.closeOverlay?.();
+ viz.jumpTo(hopIndex);
+ const flowId = viz.state.activeFlow || viz._diagram.defaultFlow || "baseline";
+ const steps = viz._diagram.flows[flowId]?.steps ?? [];
+ const expectedDone = hopIndex + 1;
+ if (viz.state.currentStepDone !== expectedDone && hopIndex < steps.length - 1) {
+ throw new Error(
+ `jumpTo(${hopIndex}) expected currentStepDone=${expectedDone}, got ${viz.state.currentStepDone}`,
+ );
+ }
+ }, index);
+ await page.waitForTimeout(FRAME_DELAY_MS);
+ await captureFrame();
+ }
+
+ const frameCount = frameNumber - 1;
+ if (frameCount === 0) {
+ throw new Error("No frames captured");
+ }
+
+ console.log(
+ `Assembling ${frameCount} frame(s) (hops ${FIRST_INDEX}..${LAST_INDEX}${INCLUDE_RESET ? ", with reset" : ""}) at ${OUTPUT_FPS} fps`,
+ );
+ assembleGif(framesDir, frameCount, OUTPUT_PATH);
+ console.log(`Wrote ${OUTPUT_PATH}`);
+ } finally {
+ if (browser) {
+ await browser.close();
+ }
+ server.kill("SIGTERM");
+ await sleep(300);
+ if (!server.killed) {
+ server.kill("SIGKILL");
+ }
+ await rm(framesDir, { recursive: true, force: true });
+ }
+}
+
+exportGif().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/tests/package.json b/tests/package.json
index ed6b577..de2d793 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -13,6 +13,7 @@
"test:log-rules": "npx playwright test observability-log-rules.spec.ts",
"test:demo-unit": "npx playwright test observability-log-rules.spec.ts demo-scenario-consistency.spec.ts overall-in-doc-legend-layout.spec.ts ui-helpers.unit.spec.ts",
"export-architecture": "PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-diagram.mjs",
+ "export-architecture-gif": "PLAYWRIGHT_BROWSERS_PATH=0 node export-architecture-gif.mjs",
"test:scenario-a": "bash ../scripts/run-playwright-tests.sh scenario-a-regression.spec.ts",
"test:mlflow": "bash ../scripts/run-playwright-tests.sh mlflow-ui.spec.ts"
},