On your server

Express

Two Express routes for MorphScreen, one for connectAgent's runs and one that streams chat completions for a chat UI of your own, with the key kept on the server.

View as Markdown
Terminal
npm install express
Terminal
pnpm add express
Terminal
yarn add express
Terminal
bun add express

Express 5 on Node 18 or later. Keep MORPHSCREEN_KEY in the server's environment; it never goes to the page.

The routes

server.mjs
// Two routes for the page in Express 5: /api/agent carries connectAgent's
// runs on to /v1/ui, and /api/chat streams /v1/chat/completions for a chat
// UI of your own. The key stays on the server, and the server picks the model.
import express from "express";
import { Readable } from "node:stream";

const API = process.env.MORPHSCREEN_API ?? "https://api.morphscreen.com/v1";
const MODEL = process.env.MODEL ?? "deepseek/deepseek-v4.1-flash";
const HEADERS = { "Content-Type": "application/json", "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY };

const app = express();
app.use(express.json({ limit: "1mb" }));

// Sends the body on and streams the answer back. When the reader leaves, the
// call to MorphScreen stops too, so no model keeps writing for nobody.
async function relay(req, res, path, body) {
  const stop = new AbortController();
  res.on("close", () => stop.abort());
  const upstream = await fetch(`${API}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body), signal: stop.signal });
  res.status(upstream.status).type(upstream.headers.get("content-type") ?? "text/event-stream");
  Readable.fromWeb(upstream.body).pipe(res);
}

app.post("/api/agent", (req, res) => relay(req, res, "/ui", { ...req.body, forwardedProps: { ...req.body.forwardedProps, model: MODEL } }));

app.post("/api/chat", (req, res) => relay(req, res, "/chat/completions", { model: MODEL, stream: true, messages: req.body.messages }));

app.listen(process.env.PORT ?? 3000);
  • /api/agent is for connectAgent: the page sends each run, the route sets the model and passes the run to /v1/ui, and the AG-UI events stream back as they come.
  • /api/chat is for a chat UI of your own: it sends the conversation to /v1/chat/completions and passes the server-sent events through. Draw each answer with createAnswer or <Answer>, and read the last chunk for the verdict and a repair (Validation and repair).

You only need the one your page uses.

Notes

  • The page's body limit. A run carries the conversation so far, so express.json gets a 1 MB limit; raise it for long conversations.
  • Stopping. When the reader closes the tab, res closes and the call to MorphScreen is aborted, so the model stops writing.
  • Errors pass through. MorphScreen's status and its JSON error reach the page unchanged: a 401 for a wrong key, a 429 with its wait (Limits and errors).

We run this file in CI against a recorded answer: a page with connectAgent draws it through /api/agent, and /api/chat streams it back whole.