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, withdeployment.invokeand this deployment allowed. It can be invoke-only with no direct downstream model/tool access. Configure a backend customer namespace and registerdemo-user-42for the example below; see namespace setup. - Node.js 24 and an inference origin from your operator.
- If an
Originheader reaches the gateway, it must exactly matchAGENT_ALLOWED_ORIGINS. The example below serves the browser and backend from one local origin and makes a server-to-server request without forwardingOrigin. 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¶
- 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
- Save this as
server.mjsin 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"));
- Save
index.htmlbeside it. A network chunk is not an SSE event: this reader buffers lines and dispatches only after a blank line, supports LF/CRLF, joinsdata: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>
- Run
node server.mjsand openhttp://127.0.0.1:8080in your browser. Send a prompt. The demo deliberately creates one new conversation per submit. In your product, persist athreadIdper owned conversation, a new AG-UIrunIdper submitted execution input, message IDs, and the relevant message history. Restart this backend after changing its environment/key. - Record
X-Mission-Control-Trace-Idwith 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
Originreturns403 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.