Skip to content

Connect an External UI

Purpose

Let your product invoke a Production Mission Control deployment for its end users. Your backend holds a named ordinary service virtual key and relays AG-UI events; your browser consumes the SSE response and keeps conversation state in your product.

Prerequisites

  • An active Production deployment with API channel enabled, approved actual backend service owner and publisher funding. A deployment access manager configures it; see build and publish and roles.
  • A named service virtual key, such as Acme storefront backend, with deployment.invoke and this deployment allowed. It can be invoke-only with no direct downstream model/tool access. Configure a backend customer namespace and register demo-user-42 for the example below; see namespace setup.
  • Node.js 24 and an inference origin from your operator.
  • If an Origin header reaches the gateway, it must exactly match AGENT_ALLOWED_ORIGINS. The example below serves the browser and backend from one local origin and makes a server-to-server request without forwarding Origin. Configure and restart the gateway when changing its origin environment setting; your own backend still needs its own browser-origin and user authentication controls.

Procedure

  1. In a new example directory, set the backend environment. Replace the fictional deployment values and keep the real key out of browser code:
export MISSION_CONTROL_GATEWAY_URL=https://gateway.example.test
export MISSION_CONTROL_DEPLOYMENT_ID=deployment_example
export MISSION_CONTROL_API_KEY=mck_example_not_a_real_key
  1. Save this as server.mjs in that directory. This is a loopback demonstration for one synthetic user. Before deploying it, authenticate your own users, derive the opaque end-user subject from that session, enforce conversation ownership, bound/rate-limit input, and persist messages and received events in your application. customer:{subject} is checked under the actual entry key namespace; it does not grant a verified person, payer or personal Connector credential.
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { randomUUID } from "node:crypto";

const { MISSION_CONTROL_GATEWAY_URL: gateway,
        MISSION_CONTROL_DEPLOYMENT_ID: deploymentId,
        MISSION_CONTROL_API_KEY: key } = process.env;
if (!gateway || !deploymentId || !key) throw new Error("Missing backend environment");

createServer(async (req, res) => {
  try {
    if (req.method === "GET" && req.url === "/") {
      res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
      res.end(await readFile(new URL("./index.html", import.meta.url)));
      return;
    }
    if (req.method !== "POST" || req.url !== "/chat") {
      res.writeHead(404).end(); return;
    }
    if (req.headers.origin !== "http://127.0.0.1:8080") {
      res.writeHead(403).end("Origin rejected"); return;
    }
    const chunks = [];
    let bytes = 0;
    for await (const chunk of req) {
      bytes += chunk.length;
      if (bytes > 8192) { res.writeHead(413).end(); return; }
      chunks.push(chunk);
    }
    const { prompt } = JSON.parse(Buffer.concat(chunks).toString("utf8"));
    if (typeof prompt !== "string" || !prompt.trim() || prompt.length > 2000) {
      res.writeHead(400).end("Invalid prompt"); return;
    }
    const abort = new AbortController();
    res.on("close", () => abort.abort());
    const upstream = await fetch(
      `${gateway.replace(/\/+$/, "")}/v1/deployments/${encodeURIComponent(deploymentId)}:invoke`,
      {
        method: "POST", signal: abort.signal,
        headers: { authorization: `Bearer ${key}`,
          "content-type": "application/json", accept: "text/event-stream" },
        body: JSON.stringify({
          threadId: `demo-${randomUUID()}`, runId: randomUUID(),
          customer: { subject: "demo-user-42" },
          messages: [{ id: randomUUID(), role: "user", content: prompt }],
        }),
      },
    );
    if (!upstream.ok) {
      // Preserve the safe gateway error envelope before streaming begins.
      res.writeHead(upstream.status, { "content-type": "application/json" });
      res.end(await upstream.text()); return;
    }
    if (!upstream.body || !upstream.headers.get("content-type")?.includes("text/event-stream")) {
      throw new Error("Expected AG-UI SSE");
    }
    res.writeHead(200, { "content-type": "text/event-stream",
      "cache-control": "no-cache",
      "x-mission-control-trace-id": upstream.headers.get("x-mission-control-trace-id") ?? "" });
    res.flushHeaders();
    await pipeline(Readable.fromWeb(upstream.body), res);
  } catch {
    if (!res.headersSent) res.writeHead(502).end("Invocation unavailable");
    else res.destroy();
  }
}).listen(8080, "127.0.0.1", () => console.log("Open http://127.0.0.1:8080"));
  1. Save index.html beside it. A network chunk is not an SSE event: this reader buffers lines and dispatches only after a blank line, supports LF/CRLF, joins data: fields, ignores comments, and flushes the UTF-8 decoder at EOF. It treats an absent terminal event as uncertain delivery.
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Agent demo</title>
<form><label>Message <input name="prompt" required maxlength="2000"></label>
<button>Send</button></form><pre id="output" aria-live="polite"></pre>
<script type="module">
const form = document.querySelector("form");
const output = document.querySelector("#output");
async function* events(body) {
  const reader = body.getReader(), decoder = new TextDecoder();
  let buffer = "", data = [];
  try {
    while (true) {
      const { value, done } = await reader.read();
      buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
      let end;
      while ((end = buffer.indexOf("\n")) !== -1) {
        const line = buffer.slice(0, end).replace(/\r$/, "");
        buffer = buffer.slice(end + 1);
        if (!line) {
          if (data.length) yield JSON.parse(data.join("\n"));
          data = [];
        } else if (line.startsWith("data:")) {
          data.push(line.slice(5).replace(/^ /, ""));
        }
      }
      if (done) break; // Incomplete final frames are not dispatched.
    }
  } finally { reader.releaseLock(); }
}
form.addEventListener("submit", async (event) => {
  event.preventDefault();
  form.querySelector("button").disabled = true;
  output.textContent = "";
  let terminal = false;
  try {
    const response = await fetch("/chat", { method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ prompt: new FormData(form).get("prompt") }) });
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    output.textContent = `Trace: ${response.headers.get("x-mission-control-trace-id")}\n`;
    for await (const item of events(response.body)) {
      // Display text safely; never interpret model output as HTML.
      output.textContent += `${JSON.stringify(item)}\n`;
      if (item.type === "RUN_FINISHED" || item.type === "RUN_ERROR") terminal = true;
    }
    if (!terminal) throw new Error("Stream ended without a terminal event; reconcile before retrying");
  } catch (error) { output.textContent += `\n${error.message}`; }
  finally { form.querySelector("button").disabled = false; }
});
</script></html>
  1. Run node server.mjs and open http://127.0.0.1:8080 in your browser. Send a prompt. The demo deliberately creates one new conversation per submit. In your product, persist a threadId per owned conversation, a new AG-UI runId per submitted execution input, message IDs, and the relevant message history. Restart this backend after changing its environment/key.
  2. Record X-Mission-Control-Trace-Id with your conversation and inspect it using an authorized account under Observe → Traces. No new key or session is required merely to inspect evidence you already have permission to read.

Expected result

The browser receives RUN_STARTED, intermediate AG-UI events, and a terminal RUN_FINISHED or RUN_ERROR. The gateway independently authorizes entry and managed child work; the publisher Team funds the children and the wrapper adds no duplicate cost. RUN_ERROR can arrive on HTTP 200 after the stream starts; it is not a successful agent result. Unknown extension events can be retained for diagnostics. This demo displays approval events but does not implement approval/resume controls; see the AG-UI contract before adding them.

AG-UI runId is protocol vocabulary, not a Mission Control lifecycle resource. threadId groups the invocation under a server-resolved namespace; it never transfers private ownership. There is no public gateway thread list/detail/delete API. Your backend must retain exact customer conversation ownership; Console private history is independently authorized.

Verification

  • With the API channel disabled, an unapproved owner, or a key lacking deployment.invoke, expect denial before runtime dispatch.
  • A paused deployment refuses new work. Correct current deployment access before retrying.
  • A request reaching the gateway with an unlisted Origin returns 403 origin_not_allowed. Your backend's own origin rejection is separate.
  • With valid configuration, expect SSE and a non-zero 32-character Trace ID.
  • Revoking the key denies the next invocation without creating another Trace.

Failure recovery

Before streaming, inspect the HTTP status and safe { "error": { "code", "message" } } envelope. Restore current deployment/channel/owner grant, key policy, origin configuration or runtime availability according to the code. After streaming starts, process RUN_ERROR and retain partial messages. If delivery is uncertain, reconcile your application's stored state before retrying; do not automatically create another execution. See Agent invoke recovery.