On your server

Node.js

Call MorphScreen from a Node.js server with nothing but fetch: carry a page's AG-UI runs, stream an answer's text, or render a finished answer as HTML.

View as Markdown

Node 18 or later has everything this needs. Keep MORPHSCREEN_KEY in the server's environment; it never goes to the page.

A route for the page

The page's connectAgent sends its runs to your route, which adds the key and the model and streams the events back:

server.mjs
// Your server's one route for the page: each AG-UI run goes on to MorphScreen
// with your key, and the events stream straight back. The key never reaches
// the browser, and the server, not the page, picks the model.
import { createServer } from "node:http";

const API = process.env.MORPHSCREEN_API ?? "https://api.morphscreen.com/v1";
const MODEL = process.env.MODEL ?? "deepseek/deepseek-v4.1-flash";

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/api/agent") return res.writeHead(404).end();
  let body = "";
  for await (const chunk of req) body += chunk;
  const run = JSON.parse(body);
  run.forwardedProps = { ...run.forwardedProps, model: MODEL };
  const upstream = await fetch(`${API}/ui`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY },
    body: JSON.stringify(run),
  });
  res.writeHead(upstream.status, { "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream" });
  for await (const chunk of upstream.body) res.write(chunk);
  res.end();
}).listen(process.env.PORT ?? 3000);

In Express, Fastify or Hono, the route body is the same: read the run, set forwardedProps.model, fetch MorphScreen's /v1/ui, and pipe the response body back with its status and content type.

The answer's text, or HTML

For work on the server (a mail, a saved report, a page drawn without JavaScript), stream the answer's text from /v1/chat/completions, or render a finished answer with /v1/render:

morphscreen.mjs
// An answer's text as it streams, and a finished answer as HTML. Run it on
// your server (Node 18 or later): the key stays there.
const API = process.env.MORPHSCREEN_API ?? "https://api.morphscreen.com/v1";
const HEADERS = { "Content-Type": "application/json", "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY };

/** Yields the answer's text, piece by piece, as the model writes it. */
export async function* streamAnswer(messages, model = "deepseek/deepseek-v4.1-flash") {
  const res = await fetch(`${API}/chat/completions`, { method: "POST", headers: HEADERS, body: JSON.stringify({ model, stream: true, messages }) });
  if (!res.ok) throw new Error(`MorphScreen ${res.status}: ${await res.text()}`);
  const decoder = new TextDecoder();
  let buffer = "";
  for await (const chunk of res.body) {
    buffer += decoder.decode(chunk, { stream: true });
    let end;
    while ((end = buffer.indexOf("\n")) >= 0) {
      const line = buffer.slice(0, end).trim();
      buffer = buffer.slice(end + 1);
      if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
      for (const choice of JSON.parse(line.slice(6)).choices ?? []) if (choice.delta?.content) yield choice.delta.content;
    }
  }
}

/** A finished answer drawn by the core as HTML, with its verdict. */
export async function renderHtml(text) {
  const res = await fetch(`${API}/render`, { method: "POST", headers: HEADERS, body: JSON.stringify({ text }) });
  if (!res.ok) throw new Error(`MorphScreen ${res.status}: ${await res.text()}`);
  const { html, morphscreen } = await res.json();
  return { html, verdict: morphscreen };
}
Using it
import { renderHtml, streamAnswer } from "./morphscreen.mjs";

let text = "";
for await (const piece of streamAnswer([{ role: "user", content: "Make me a sales dashboard" }])) text += piece;
const { html, verdict } = await renderHtml(text);

Both are what our tests run against a recorded answer, in Node and in Python.