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.
npm install expresspnpm add expressyarn add expressbun add expressExpress 5 on Node 18 or later. Keep MORPHSCREEN_KEY in the server's environment; it never goes to the page.
The routes
// 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/agentis forconnectAgent: 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/chatis for a chat UI of your own: it sends the conversation to/v1/chat/completionsand passes the server-sent events through. Draw each answer withcreateAnsweror<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.jsongets a 1 MB limit; raise it for long conversations. - Stopping. When the reader closes the tab,
rescloses 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.