# Introduction > MorphScreen lets a model answer with real UI. It reads the answer as it streams, checks every line against the components your app allows, and draws it on the page.
A saved answer from DeepSeek V4 Flash, unedited, streaming into a page through the web SDK at the pace the model wrote it.
A model writes its answer as Markdown with a few tags, like ``, ``, `` and `
`. MorphScreen's core reads that text as it arrives, checks each line against the components your app allows, and hands your page something it can draw at once. When a line breaks a rule, the API asks the same model to fix only that line. [Get a key and make a first call](/docs/quickstart), or [see every component](/docs/components) drawn live. ## How it works
### Get a key Sign up with your email or GitHub. The account comes with a project and its first key. ### Send the chat call you already make The API speaks OpenAI's chat completions. Point your server's call at `https://api.morphscreen.com/v1`, add the `X-MorphScreen-Key` header, and keep the key on your server. ### Draw what comes back The web SDK draws the answer while it streams, with plain DOM or React. On a server, `/v1/render` turns a finished answer into HTML that needs no JavaScript. ### Let the reader act Buttons, forms, quizzes and polls come back to your app as actions. You decide what happens next, usually the model's next turn. ## What's in MorphScreen - [The format](/docs/format): Markdown with tags, written so half an answer is always a valid start of the whole one. - [75 components](/docs/components): layout, charts, forms and buttons in the core, and eight packs for data, shopping, scheduling, media, docs, inputs, interaction and the device. - [Validation and repair](/docs/format/validation): every line checked against your app's catalog, and only the broken lines sent back to the model. - [Your own components](/docs/format/custom-components): describe them once, and the model, the checks and the renderer all know them. - [Agents](/docs/agents): the API speaks AG-UI, and `connectAgent` holds a whole conversation with any AG-UI agent. - [Production](/docs/production): plans and limits, errors you can act on, a dashboard of every call, and nothing of the conversation kept. ## Integrations The web SDK works with any framework: [React](/docs/integrations/react), [Next.js](/docs/integrations/nextjs), [Vue and Nuxt](/docs/integrations/vue), [Svelte and SvelteKit](/docs/integrations/svelte), [Angular](/docs/integrations/angular), [webpack](/docs/integrations/webpack) and [plain JavaScript](/docs/integrations/vanilla). Each page's code is a small app we install, build and run in Chrome every week, so the snippets work as written. Your server can be [Node.js](/docs/integrations/node), [Python](/docs/integrations/python) or anything that speaks HTTP. --- # Quick start > From a key to an answer drawn in your page, in about ten minutes. One route on your server, a few lines in your page. You need a MorphScreen key, a server that can make an HTTP request (Node.js and Python are shown), and a page that can import an npm package. ## Get a key Sign up at [app.morphscreen.com](https://app.morphscreen.com/signin) with your email or GitHub. Your first key is shown once on the welcome page. Copy it into your server's environment: ```bash MORPHSCREEN_KEY=ms_live_... ``` The free plan has 100 calls on our model, so you don't need a model key to try it, then up to 3,000 calls a month on [your own OpenRouter key](/docs/agents/openai-compatible). ## Try a call The API speaks OpenAI's chat completions. This streams an answer from the model included in your plan: ```bash curl https://api.morphscreen.com/v1/chat/completions \ -H "X-MorphScreen-Key: $MORPHSCREEN_KEY" \ -H "Content-Type: application/json" \ -d '{"model": "deepseek/deepseek-v4.1-flash", "stream": true, "messages": [{"role": "user", "content": "Plan a weekend in Lisbon"}]}' ``` Each event's `delta.content` is the next piece of the answer, in [our format](/docs/format): Markdown with a few tags. ## Add a route to your server Your page talks to your server, and your server talks to MorphScreen with the key. One route carries the page's runs on to `/v1/ui` and streams the events back: ```js // 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); ``` The same route in Python, with only the standard library: ```python # Your server's one route for the page, with nothing but the standard # library: 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 json import os import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer API = os.environ.get("MORPHSCREEN_API", "https://api.morphscreen.com/v1") MODEL = os.environ.get("MODEL", "deepseek/deepseek-v4.1-flash") class Agent(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/api/agent": self.send_error(404) return run = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) run["forwardedProps"] = {**run.get("forwardedProps", {}), "model": MODEL} req = urllib.request.Request( f"{API}/ui", data=json.dumps(run).encode(), headers={"Content-Type": "application/json", "X-MorphScreen-Key": os.environ.get("MORPHSCREEN_KEY", "")}, method="POST", ) try: upstream = urllib.request.urlopen(req) except urllib.error.HTTPError as e: upstream = e with upstream: self.send_response(upstream.status) self.send_header("Content-Type", upstream.headers.get("Content-Type", "text/event-stream")) self.end_headers() while chunk := upstream.read1(4096): self.wfile.write(chunk) self.wfile.flush() ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3000"))), Agent).serve_forever() ``` ## Install the web SDK ```sh npm install @morphscreen/web ``` > **Before the launch:** The package goes to npm with the public launch. Until then, `/v1/render` draws answers as HTML on your server ([HTML without JavaScript](/docs/integrations/html)), and everything on this page works the same once it's published. ### Draw the conversation `connectAgent` holds the conversation: it sends what the reader types, draws each answer as it streams, and sends buttons, forms and quiz answers back as the next turn. ```html
``` ```ts import { connectAgent, loadCore } from "@morphscreen/web"; import "@morphscreen/web/morphscreen.css"; // morphscreen-agent.wasm from the package, served with your page const core = await loadCore("/morphscreen-agent.wasm"); const chat = connectAgent(document.querySelector("#chat")!, { core, url: "/api/agent" }); const form = document.querySelector("form")!; form.addEventListener("submit", async (e) => { e.preventDefault(); const input = form.querySelector("input")!; const text = input.value; input.value = ""; await chat.send(text); }); ``` Serve `morphscreen-agent.wasm` from the package with your page. Each [integration](/docs/integrations) shows how for its build tool. That's a working chat. Ask it for a trip, a dashboard or a quiz, and the answer arrives as cards, charts and questions, checked line by line. ## Where next - [The format](/docs/format): what the model writes, and why half an answer is always valid. - [Components](/docs/components): all 75, drawn live, with their attributes. - [Your own components](/docs/format/custom-components): teach the model your product's own cards. - [Actions and state](/docs/format/actions): what comes back when the reader taps, types and answers. --- # MorphScreen vs others > How MorphScreen differs from OpenUI, Thesys C1, A2UI and the Vercel AI SDK's generative UI, and where the measured numbers are. Generative UI libraries share a goal: a model's answer that the reader can use, not just read. They differ in what the model writes, who checks it, and what happens when it's wrong. ## What MorphScreen does differently - **The model writes Markdown with a few tags.** Models already write both, so the format needs a short prompt (about 1,400 tokens with the core components, 2,900 with every pack), and half an answer is always a valid start of the whole one. Nothing waits for a closing bracket or a root statement. - **Every line is checked as it streams.** The core reads each line against the components your app allows, so what reaches your page is already validated, and a mistake is tied to the line it's on. - **Only broken lines are repaired.** When a line breaks a rule, the API asks the same model to rewrite that line, not the whole answer. - **Any model.** The model included in every plan, or any model on OpenRouter with your own key. - **Nothing a model wrote ever runs.** No script, no style, no address you didn't allow (see [Security](/docs/production/security)). ## Against OpenUI, measured We measure ourselves against OpenUI on the benchmark thesysdev publishes for it: the same 46 briefs, the same models and the same rules for what counts as valid. The [benchmark page](/benchmark) has the method, every raw answer and the rows where OpenUI is ahead with why; [every row](/compare/openui) is on its own page. ## The others | Library | What the model writes | Measured against MorphScreen | |---|---|---| | [OpenUI](https://www.openui.com) | OpenUI Lang, a language of its own | yes: [the benchmark](/benchmark) | | [A2UI](https://github.com/a2ui-project/a2ui) | JSON messages that describe components | not yet | | [Vercel AI SDK](https://ai-sdk.dev) | tool calls, which your code turns into components | not yet | | [Thesys C1](https://www.thesys.dev) | its own format, through its API | not yet | Comparisons with the last three need runs of their own on the same briefs; they go on this page, with their raw answers, once we have them. --- # Coding agent setup > Point Claude Code, Cursor, Codex or any coding agent at MorphScreen's docs, written for agents to read. Every page of these docs is also plain Markdown, and the whole set is one file. Give your coding agent either, and it can write MorphScreen code from the real reference instead of guessing. ## The docs, for agents | What | Where | |---|---| | An index of every page, with links | [morphscreen.com/llms.txt](/llms.txt) | | Every page in one file | [morphscreen.com/llms-full.txt](/llms-full.txt) | | One page | its address with `.md` added, like [/docs/quickstart.md](/docs/quickstart.md) | Every page also has **Copy Markdown** under its title, for pasting into a chat. ## A rule for your project Add this to your agent's instructions file (`CLAUDE.md`, `AGENTS.md`, `.cursor/rules`): ```markdown ## MorphScreen This app draws model answers with MorphScreen (@morphscreen/web). - Docs: https://morphscreen.com/llms.txt (read the page you need as .md). - Keep the MorphScreen key on the server; the page talks to our own route. - Components and their attributes: https://morphscreen.com/docs/components.md - Never render model text as HTML yourself; the SDK or /v1/render draws it. ``` ## Asking for UI The model your app uses is told about the components by the system prompt the API builds. Your coding agent doesn't need to know them to build the app, only to write [your own components](/docs/format/custom-components) or to check an answer by hand. --- # The format > What a model writes for MorphScreen: Markdown with a few components written like JSX, designed so half an answer is always a valid start of the whole one. An answer is Markdown with components written like JSX. Models already write both, so the format needs little teaching, and the prompt that teaches it is short. ```markdown ## Weekend in Lisbon Two days is enough for the old town and Belém, if you take the trams for the hills. | Item | USD | |---|---| | Stay | 180 | | Food | 90 | | Trams | 60 | - Where should I stay? - What should I eat? ``` ## Components ```markdown content ``` - A component's name starts with a capital letter. `
` or `
` is text. - Values are quoted; numbers and `true` or `false` may go without quotes, and a bare attribute (`required`) means `true`. - A list of values is split by ` | `: `options="S | M | L"`. - A component with nothing inside closes itself, like ``. - An attribute means the same on every component: `tone` is always `neutral`, `info`, `success`, `warning` or `danger`. Each component says what goes between its tags: Markdown and other components, a Markdown table (a chart's data), a list (follow-up questions), images or nothing. The [component reference](/docs/components) has each one's attributes. ## Markdown Text between components is Markdown: headings, paragraphs, emphasis, inline code, links, lists, quotes, code blocks, rules and tables. Raw HTML is never drawn as HTML. The few tags models slip in anyway, like `` and `
`, are read as formatting, and the rest shows as written. ## Why it streams well - **There's no root to forget.** The answer is the root, and a component's place in the tree is where it's written. - **A value counts only once it's whole.** A component appears when its opening tag is complete, so its attributes never change after it shows. A number or an address is never read half-written. - **What's finished never changes.** A closed component is final, so the page never draws it twice. [How streaming works](/docs/format/streaming) has the detail. ## What the core does with it The core is the same Rust code everywhere: WebAssembly in the browser, inside the API on the server. As the text arrives it builds the tree, checks every line against the components your app allows, and reports each problem on its line. The API repairs those lines with the same model ([Validation and repair](/docs/format/validation)), and the SDK draws the tree. The full grammar is the [format specification](/docs/reference/specification). --- # Components and packs > The 75 components come in a core that every answer has and eight packs an app can choose. Fewer packs, shorter prompt. The components come in packs. `core` is always there: layout, text blocks, numbers, charts, forms and buttons. Eight more packs add data, commerce, the device, docs, inputs, interaction, media and scheduling. | Pack | What it adds | |---|---| | [Core](/docs/components/core) | Cards, columns, tabs, steps, callouts, stats, badges, progress, charts, forms, buttons, follow-up questions | | [Data](/docs/components/data) | Key-value facts, timelines, gauges, heatmaps, funnels, sparklines, trees | | [Commerce](/docs/components/commerce) | Products, plans, carts, ratings, comparisons, prices | | [Device](/docs/components/device) | Maps, camera, barcode scanning, location, files, contacts, calendar, sharing, voice notes, QR codes | | [Docs](/docs/components/docs) | Code with a file name, diffs, formulas, file trees, citations | | [Inputs](/docs/components/inputs) | Star ratings, searchable choices, times, toggle groups, steppers, signatures | | [Interactive](/docs/components/interactive) | Quizzes, flashcards, polls, checklists, what-if sliders | | [Media](/docs/components/media) | Video, audio, embeds, carousels | | [Scheduling](/docs/components/scheduling) | Calendars, events, time slots, agendas, countdowns | ## Choosing packs Every pack is on unless you name the ones you want. The model is told about only the components it may use, and the core checks the answer against the same list. ```js { "model": "deepseek/deepseek-v4.1-flash", "messages": [...], "morphscreen": { "packs": ["data", "interactive"] } } ``` ```js run.forwardedProps = { ...run.forwardedProps, model: MODEL, packs: ["data", "interactive"] }; ``` ```js createAnswer(el, { core, packs: ["data", "interactive"] }); ``` Give the page the same packs as the API, so the page can read everything the model was allowed to write. `[]` is the core alone. ## Prompt length | Packs | System prompt | |---|---| | Core alone | about 1,400 tokens | | Every pack | about 2,900 tokens | Each component adds about 22 tokens. A shorter prompt costs less on every call and leaves the model fewer ways to go wrong; the benchmark measured both ([the benchmark](/benchmark)). ## Names models use on their own Models sometimes write ``, `` or `` without being told. The core reads them as the component they mean and notes it, rather than failing the answer. --- # Your own components > Teach the model your product's own components. Describe one once, give it to the API and the page, and say how to draw it. Next to the built-in components, an app can add its own: a shop's product card, a booking's seat map, a support ticket. It takes three steps, and the same description serves all three: the model learns it from the prompt, the core checks answers against it, and your page draws it. ## Describe it A component lives in a pack: a name, one line on what it's for, its attributes, and what goes between its tags. ```json { "name": "shop", "version": "1.0.0", "components": { "ProductCard": { "about": "a product with its price", "attrs": { "name": { "type": "string", "required": true }, "price": { "type": "string", "required": true }, "stock": ["in", "low", "out"] }, "content": "none" } } } ``` Attribute types are `string`, `number`, `boolean`, `url`, `list` (values split by ` | `), or a list of the values allowed, the first being the default. Content is `none`, `text`, `table`, `list`, `images` or `any` (Markdown and other components). One attribute, one meaning: `stock="low"`, not `style="color: orange"`. A `text` component can take `"actions": true`. The model may then put `Button`s inside it, each with `ask` (the message a tap sends) or `href`, drawn under it as what the reader can do, like "Add to cart". ## Give it to the API The API puts the pack's components in the system prompt and checks the answer against them. Send it from your server with every call: ```js { "model": "deepseek/deepseek-v4.1-flash", "messages": [...], "morphscreen": { "components": [shop] } } ``` ```js run.forwardedProps = { ...run.forwardedProps, model: MODEL, components: [shop] }; ``` ## Draw it Give the page the same pack, and a function that draws the component into an element. It gets the component's attributes, and its content as HTML and as text; it may return a cleanup. ```ts import { createAnswer } from "@morphscreen/web"; import shop from "./shop.json"; createAnswer(el, { core, custom: [shop], components: { ProductCard: (el, { attrs }) => { el.textContent = `${attrs.name}: ${attrs.price}`; }, }, }); ``` With React, pass components: ```tsx import { Answer, type CustomProps } from "@morphscreen/web/react"; import shop from "./shop.json"; function ProductCard({ attrs }: CustomProps) { return (
{attrs.name} {attrs.price}
); } ; ``` `connectAgent` takes `custom` and `components` the same way. ## Your styles, untouched Your component is drawn in a `
` inside the answer. MorphScreen's stylesheet leaves everything in it to yours: it only inherits the answer's font and colour. ## When something's missing A component your page doesn't draw still shows: its attributes as a list, then its content. Nothing an answer holds is dropped without a word. ## Names A name defined twice is an error, never an override, so your pack can't replace a built-in component by accident. The one exception: when you leave `packs` out (every pack), your own `Product` or `Map` replaces the pack's. Name the pack outright and the two clash. --- # The system prompt > How the model learns the format. The API builds the prompt from your catalog and puts it before your own messages; you can also build it yourself. The model learns the format from a system prompt built from the catalog: the components your app allows, their attributes, and the rules that keep answers valid. It's the same catalog the core checks answers against, so the two can't disagree. ## Through the API The API builds the prompt for you. It goes first, and your own messages, system messages included, follow unchanged: ```json { "model": "deepseek/deepseek-v4.1-flash", "messages": [ { "role": "system", "content": "You are Lisbon Trams' trip planner. Answer in British English." }, { "role": "user", "content": "Plan a weekend for two" } ], "morphscreen": { "packs": ["data"] } } ``` Your system message says who the assistant is and what it knows. Ours says how to answer. You don't need to describe the components yourself. ## Building it yourself To run a model of your own, outside the API, build the same prompt with the core and send it as the first system message. In the browser or Node, with the web SDK: ```ts const prompt = core.prompt(["data"], [shop]); // packs, then your own packs ``` On the command line: ```bash mscore prompt --packs data ``` Then check what comes back with the core yourself: a `Session` from `core.session(packs, custom)` reads the text as it streams and gives you the verdict ([the web SDK reference](/docs/reference/web)). The API does that, and the repair, for you. ## What it costs With the core alone the prompt is about 1,400 tokens, and with every pack about 2,900: about 22 tokens a component. The prompt's version is a hash recorded with every call, so the dashboard and the benchmark can tell which prompt an answer came from. --- # Validation and repair > Every line is checked against your app's components as it streams, and the API asks the same model to rewrite only the lines that break a rule. A model gets things wrong: an attribute that doesn't exist, a value outside its list, a component it made up. The core catches each one on its line, as the answer streams, and the API repairs only those lines. ## Lenient where the intent is clear - **Values match without case, and a few synonyms count:** `error` means `danger`, `column` means `bar`. These are reported as notes, not errors. - **A bad value is fixed at its edge:** the default if there is one, or an optional attribute is dropped. A bad table row drops only that row. - **An unknown component is dropped** and its content kept as text, so nothing the model said is lost. - **Names models use on their own** (`
`, ``, ``) are read as what they mean. ## Strict where it isn't Each problem is a diagnostic on its line, with a class: | Class | What it means | |---|---| | `hallucinated-component` | a component that isn't in your catalog | | `signature-mismatch` | an attribute the component doesn't have, or of the wrong kind | | `required-field` | a required attribute left out | | `enum-mismatch` | a value outside an attribute's list, with no default to fall back on | | `malformed-syntax` | a tag that doesn't close, or closes the wrong thing | | `unreadable-tag` | a line that opens like a component but isn't a tag the core can read | | `unsafe-url` | an address that could run code, like `javascript:` | A cut-off answer is reported as truncated, never passed off as whole. ## Repair When an answer has errors, the API sends the model the broken lines with what's wrong with each and asks for those lines again, with nothing else rewritten. Broken nesting, like a tab closed with its parent's tag, is mended with no model at all. - It's on by default. `"repair": false` in the options turns it off. - A blank answer gets one fresh try. - A repair runs on the same model and key as the answer, and counts as part of the same call. - It stops after 30 seconds and keeps what it has. ## What you see **Through `/v1/ui`** (what `connectAgent` reads), a repaired answer replaces the one that streamed, and a `morphscreen.repair` event says what changed. You don't need to do anything. **Through `/v1/chat/completions`**, the last chunk's `morphscreen` field carries the core's verdict, and when a repair was needed, the repaired text: ```json { "morphscreen": { "valid": true, "truncated": false, "components": 12, "diagnostics": [], "repair": { "repaired": true, "errors_before": 2, "rounds": 1, "nesting": false, "ms": 1840, "lines": [7, [12, 14]], "text": "...", "valid": true } } } ``` Draw `repair.text` in place of what streamed. Without streaming, the answer you get is already the repaired one. ## On the dashboard Every call's line shows whether its answer was valid, which components had errors, and what repair did: the [dashboard](/docs/production/dashboard). --- # Actions and state > Components never run code. When the reader taps, types or answers, your app gets an action and decides what happens next. Nothing in an answer runs code. When the reader acts, the SDK hands your app an action, and your app decides what happens: usually the model's next turn. ## Actions | The reader | The action | |---|---| | Taps a `Button` with `ask`, a button outside a form, or a follow-up question | `{ type: "ask", text, node }` | | Submits a `Form` | `{ type: "submit", node, form, button, fields }` | | Finishes a `Quiz` | `{ type: "quiz", node, score, total, answers }` | | Votes in a `Poll` | `{ type: "vote", node, name, choice }` | | Ticks a `Checklist` item, marks a flashcard known | `{ type: "state", node, name, value }` | | Taps a `Button` with `href` | nothing: the link opens | | Switches a tab, opens details, moves a what-if slider | nothing: it all happens on the page | ```ts createAnswer(el, { core, onAction: (action) => { if (action.type === "ask") send(action.text); if (action.type === "submit") send(`Book ${action.fields.flight} for ${action.fields.guests}`); }, }); ``` With `connectAgent`, actions become the next user message by themselves (`sendActions: false` stops that), in words the core writes the same way everywhere: ```text I finished the quiz: 1 of 2 right. 1. What absorbs light in a leaf? I answered "B. Chlorophyll" (right). 2. Which gas do plants take in? I answered "Oxygen" (wrong). ``` ## Form fields `fields` is keyed by each field's `name`, or its label as one word when it has none: | Field | Value | |---|---| | Input, TextArea, Select, Radio, Date, Time, Stepper, Combobox, TimeSlots, RatingInput | text | | Slider | a number | | Switch | `true` or `false` | | Checkboxes, and a ToggleGroup that allows several | a list of the chosen options | | Camera, File | a list of `File`s, the browser's own, for your app to upload | | Location | `{ lat, lng, accuracy }` in degrees and metres when the reader shared it, or the place they typed | | Scan | the code, as text; a photo of it, when they took one, as files under `-photo` | The SDK checks `required` before it sends anything. A photo, a file or a location exists only after the reader acted: nothing is captured until they tap. ## State What the reader has done is also kept as plain JSON for each component, so it can be saved, restored, or shared with an agent: | Component | State | |---|---| | Quiz | `{ "answers": [index or null per question] }` | | Flashcards | `{ "known": [card index] }` | | Poll | `{ "vote": option or null }` | | Checklist | `{ "done": [item index] }` | | WhatIf | `{ "values": { slider name: number } }` | | Form | `{ "fields": { name: value } }` | `onComponentState(node, state)` tells you when it changes, and `answer.applyState({ [node]: state })` sets it, say from a saved session. With AG-UI the state rides in the shared state's `morphscreen` key, so an agent can read what the reader did and set it too ([AG-UI agents](/docs/agents/ag-ui)). --- # Pictures, icons and maps > Answers can show your own pictures, photos found on your own photo key, icons and maps on your own map provider. Nothing loads from a host you didn't choose. An answer can show pictures and maps, and none of it loads from a host your app didn't choose. We don't supply photos: an answer shows your own pictures, or photos found on your own photo key. ## Your own pictures An app with pictures of its own (a shop's products, a listing's rooms) sends them with the request, and the model shows them by id: ```json { "images": [ { "id": "3", "url": "https://cdn.shop.example/mugs/blue.jpg", "alt": "Blue mug", "credit": "Photo: [Shop](https://shop.example)" } ] } ``` The model is told each id and its `alt`, never the address, and writes `![The blue one](img:3)`. The API puts the address in, with the credit under the picture, and allows the picture's host. Up to 200 a request. ## Photos on your own key With `photos` set to your own Pexels, Unsplash or Openverse key, the model may ask for a photo of a real place, dish, animal, plant or artwork by describing it: ```json { "photos": { "pexels": "" } } { "photos": { "unsplash": { "key": "", "app": "" } } } { "photos": { "openverse": { "client_id": "...", "client_secret": "..." } } } ``` ```markdown ![Belém Tower on the Tagus](find) ``` As the answer streams, the API searches that provider on your key and quota and points the picture straight at the provider's address, with its credit. The reader's browser loads it from the provider; we never fetch or keep the photo. Some requests get no photo on purpose and show their description instead: people, things the answer made up ("Customer 9"), and anything no result matches well enough. A wrong photo is worse than none. Without `photos`, the model isn't told it can ask for any. ## Where pictures may load from The SDK loads pictures only from the hosts you list. Any other address, a model-written one included, shows the picture's description instead: loading it would tell that host who is reading. ```ts createAnswer(el, { core, imageHosts: ["cdn.shop.example", "images.pexels.com"] }); ``` List your photo provider's image host when you give a photo key: `images.pexels.com`, `images.unsplash.com`, or `live.staticflickr.com` and `upload.wikimedia.org` for Openverse. ## Icons `Stat` and `Button` take an `icon`: one of 229 Phosphor names. The names models write from other sets (`location`, `MapPin`, `trending_up`) are understood too. The core sends the drawing with the component, so there's no icon font to load. An unknown name shows nothing and never fails the answer. ## Maps A `Map`'s places are drawn at the zoom that fits them. The map under the pins comes from your own map provider, on your key, straight from the provider: ```ts createAnswer(el, { core, maps: { provider: "stadia", key: "..." } }); // { provider: "google", key: "...", language: "en-GB", region: "GB" } Google's Map Tiles API // { provider: "maptiler", key: "..." } // { provider: "thunderforest", key: "..." } // { provider: "tiles", url: "https://tiles.example/{z}/{x}/{y}.png", credit: "© ..." } ``` Each provider's credit shows under the map, as its terms ask. Without a provider a map is its pins on a plain ground, with each place linking to OpenStreetMap, and nothing loads. For a live map you can pan and zoom, pass MapLibre GL 6.16 or later as `maplibre` too. It starts its worker from `maplibre-gl-worker.mjs` beside its script, so serve that file with your bundle; a page with a content security policy allows it as `worker-src 'self'`, and allows the provider's host for images. --- # Theming > Answers look right in your page without touching it. Change their colours, faces and corners with tokens, or a W3C Design Tokens file. `morphscreen.css`, in the web SDK, styles answers and nothing else. Load it once on the page that shows them. ## In your page The SDK puts `ms-answer` on the element it draws into, and every rule sits inside it, so the rest of your page looks the same with or without it. Every class an answer uses starts with `ms-` (`ms-card`, `ms-btn`), so Bootstrap's or daisyUI's `.card` and `.btn` never reach an answer, and ours never reach your page. Your own components inside an answer keep your styles. We check this on every change: 73 answers in pages built with Tailwind and shadcn/ui, Bootstrap and MUI, light and dark, compared element by element with and without the answer. ## Light, dark and mono Answers follow the reader's system setting. Pin one with `data-theme="light"` or `data-theme="dark"` on ``; `data-theme="mono"` is a greyscale palette. ## Your colours Every colour is a token starting `--ms-`, so yours and ours never collide. Set them on `:root`: ```css :root { --ms-accent: #2f6fde; --ms-ink: #1b1d22; } ``` Keep text at 4.5:1 against its ground: every token is checked against that in all three palettes. ## Your faces Answers are set in IBM Plex Sans, with IBM Plex Mono for code, served from the package, never from a font host. To use your app's own: ```css :root { --ms-font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --ms-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } ``` ## A whole theme `@morphscreen/web/theme` applies a W3C Design Tokens file, light and dark, to every component: colours, faces, sizes and corners ([its reference](/docs/reference/theme)). ## Motion Components arrive with short morphs: a chart grows as its rows stream in, a quiz choice turns into a check or a cross, numbers count up. With `prefers-reduced-motion: reduce`, nothing moves. --- # How streaming works > Why an answer can be drawn line by line without ever redrawing what's done, and what the page shows while a component is still arriving. The format is built so the core only ever reads the new text, never re-reads what it has already seen, and the page never redraws what's finished. ## One rule: a value counts once it's whole - A component appears when its opening tag is complete (its `>` arrived), with every attribute final. - Text streams word by word. - A table row counts at its line's end. - A number, a choice from a list or an address is only ever read whole: `max` never shows as "800" on its way to "8000". ## What's done never changes A closed component is final, so the page never draws it twice. Each part of the answer is known by its place in the tree (`"3"`, `"3.0"`, `"3.0.2"`), never by a position in a list that could shift, because parts only ever arrive after the ones before them. ## While a component arrives A component still streaming is drawn from its complete lines so far, and the newer lines are patched in: a chart grows as its rows come, a table fills row by row. It becomes interactive when it closes. ## One parser Reading a whole answer is streaming it in one piece. Our tests check that every split of every recorded answer gives the same tree. The core has also been fuzzed with hundreds of millions of mutated answers, and every crash that turned up was fixed with a test of its own. ## What it costs Streaming through the core is fast enough not to matter: on our benchmark, Chrome spends 655 ms in all to stream and draw 20 answers, against 5841 ms for OpenUI's renderer, and the first text shows in 73 ms against 150 ms ([the benchmark](/benchmark)). [The op stream](/docs/reference/ops) is what the core hands a renderer, for drawing answers yourself. --- # Build agents > Three ways to get MorphScreen answers into an app with an agent or a chat: the AG-UI endpoint, an AG-UI agent of your own, or the OpenAI-style call you already make. There are three ways in, depending on where your conversation lives. | You have | Use | What you get | |---|---|---| | A chat with no agent framework yet | `/v1/ui` and `connectAgent` ([Quick start](/docs/quickstart)) | The model run, checks and repairs on our side; the conversation, actions and state on the page; one route on your server | | An agent that speaks AG-UI (LangGraph, Mastra, CrewAI, Pydantic AI and others) | [AG-UI agents](/docs/agents/ag-ui) | `connectAgent` pointed at your agent, which answers in our format with the prompt from the core | | Code that already calls OpenAI or OpenRouter | [OpenAI-compatible clients](/docs/agents/openai-compatible) | The same call with a new base address and one header; you draw the text yourself | ## In one turn 1. The reader types, or taps something in the last answer. 2. The page sends it, with the conversation, as an AG-UI run or a chat call. 3. The model answers in our format, with the system prompt built from your catalog. 4. The core reads the answer as it streams: on our server through the API, and on the page in the SDK. Each line is checked, and the page draws each part as soon as it's complete. 5. The reader acts on the answer. Buttons, forms and quiz answers come back as actions, and with `connectAgent` they become the next turn. ## Tools `/v1/ui` doesn't run tools yet: a run's `tools` must be empty. An agent of your own can use tools as it likes, and `connectAgent`'s `onToolCall` answers tools that run in the page. --- # AG-UI agents > Hold a whole conversation with any agent that speaks AG-UI 1.0, with answers drawn as they stream, actions sent back and the reader's state shared with the agent. `connectAgent` holds a conversation with an agent that speaks [AG-UI](https://github.com/ag-ui-protocol/ag-ui) 1.0 over HTTP and server-sent events: `/v1/ui`, or your own agent on LangGraph, Mastra, CrewAI, Pydantic AI, the Vercel AI SDK or anything else with an AG-UI adapter. The protocol is read by the core, so a stream is read the same way everywhere. ```ts import { connectAgent, loadCore } from "@morphscreen/web"; import "@morphscreen/web/morphscreen.css"; // morphscreen-agent.wasm from the package, served with your page const core = await loadCore("/morphscreen-agent.wasm"); const chat = connectAgent(document.querySelector("#chat")!, { core, url: "/api/agent", packs: ["interactive"], headers: async () => ({ Authorization: `Bearer ${await getToken()}` }), }); await chat.send("Quiz me on photosynthesis"); ``` The AG-UI client adds about 36 KB to the core, so it's a build of its own: `morphscreen-agent.wasm` instead of `morphscreen.wasm`. Pages that only show answers keep the smaller one. ## Your agent answers in our format An agent of your own needs MorphScreen's system prompt for the components the page will draw: `core.prompt(packs)` in Node or the browser, or `mscore prompt --packs ...` on the command line ([The system prompt](/docs/format/system-prompt)). Give the page the same `packs`. ## Saving the conversation `chat.messages()` and `chat.state()` are what to save; `chat.load(messages, state)` starts from them again. `chat.stop()` stops a run part way. ## What goes back to the agent A button, a follow-up, a submitted form, a finished quiz or a vote becomes the next user message, worded the same way on every platform. Photos and files from a form go along as AG-UI content parts. Everything else the reader does is state: ticks, known cards, slider positions, a vote, quiz answers, what's typed in a form. It lives in the shared state under `morphscreen`, by message id and node id, and goes to the agent with the next run: ```json { "morphscreen": { "msg-1": { "1.2": { "answers": [1, 0] }, "1.5": { "done": [0, 1] }, "1.7": { "fields": { "name": "Ana", "meal": "Fish", "guest": true } } } } } ``` It works the other way too. An agent that sends a `STATE_SNAPSHOT` or `STATE_DELTA` touching these keys sets the components: a box ticks, a form fills in. Setting state never sends a message, because the reader didn't act. The rest of the shared state is your agent's own and is kept as it is. ## How strictly a stream is read Each event is checked against the protocol's schema. A field the schema doesn't describe is removed with a warning; a value it describes but gets wrong stops the stream. Runs, messages, tool calls, steps and reasoning must open and close in order. Older shapes from producers before 1.0 are translated first. The protocol's own conformance streams run in our tests. A stream that breaks the rules stops as `rejected`. A run the agent says failed (`RUN_ERROR`) is shown as failed. A response that ends mid-run is `truncated`, and the answer it was writing is finished as cut off. Each shows as a short note under the conversation. --- # LangGraph > A LangGraph agent that answers in MorphScreen, served over AG-UI with the protocol's own LangGraph adapter, and drawn in the page with connectAgent. A LangGraph graph answers in our format when its model gets MorphScreen's system prompt, and the protocol's own adapter (`ag-ui-langgraph`) serves it over AG-UI for `connectAgent`. The code below is condensed from the example we run in CI, where a scripted model stands in so it needs no network. ## The agent ```python import json, os, subprocess from typing import Any, Optional, TypedDict from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint from fastapi import FastAPI from langchain_core.messages import SystemMessage from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, START, MessagesState, StateGraph PACKS = "interactive,inputs,data" # MorphScreen's system prompt for those packs, built by the core once at start. PROMPT = subprocess.run(["mscore", "prompt", "--packs", PACKS], check=True, capture_output=True, text=True).stdout class State(MessagesState): # What the reader did in earlier answers, by message and node: the page sends it with every run. morphscreen: Optional[dict[str, Any]] class Shared(TypedDict, total=False): morphscreen: Optional[dict[str, Any]] model = ChatOpenAI(model="deepseek/deepseek-v4-flash", base_url="https://openrouter.ai/api/v1", api_key=os.environ["OPENROUTER_API_KEY"], streaming=True) async def answer(state: State, config): prompt = PROMPT if state.get("morphscreen"): prompt += "\n\nWhat the reader has done in your earlier answers, by message id and node id:\n" + json.dumps(state["morphscreen"]) reply = await model.ainvoke([SystemMessage(prompt), *state["messages"]], config) return {"messages": [reply]} builder = StateGraph(State, output_schema=Shared) builder.add_node("answer", answer) builder.add_edge(START, "answer") builder.add_edge("answer", END) graph = builder.compile(checkpointer=MemorySaver()) app = FastAPI() add_langgraph_fastapi_endpoint(app, LangGraphAgent(name="morphscreen", graph=graph, emit_raw_events=False), "/agent") ``` With `emit_raw_events=False` an answer's stream is just its text, which is what the page reads. ```text ag-ui-langgraph[fastapi]==0.0.47 ag-ui-protocol==1.0.0 langgraph==1.2.16 langchain-core==1.6.12 langchain-openai==1.7.2 fastapi==0.143.3 uvicorn==0.54.0 ``` ## The page ```ts const core = await loadCore("/morphscreen-agent.wasm"); const chat = connectAgent(document.querySelector("#chat")!, { core, url: "/agent", packs: ["interactive", "inputs", "data"] }); await chat.send("Quiz me on photosynthesis"); ``` ## What we check The check sends a message, ticks a box, answers the quiz, checks that the tick arrives in LangGraph's state, and reloads to see the conversation come back. --- # OpenAI-compatible clients > Code that calls OpenAI or OpenRouter switches to MorphScreen with a new base address and one header. Answers come back in our format, checked and repaired. The API speaks OpenAI's chat completions, OpenRouter's flavour: streaming, usage with cost, fallback models, OpenRouter's provider settings. Code written for OpenAI's SDK or OpenRouter's switches by its base address and one header, and the rest stays as it is. ## On your own OpenRouter key Any model on OpenRouter runs on your own key. It goes to OpenRouter and nowhere else, never stored or logged. ```ts import OpenAI from "openai"; const client = new OpenAI({ baseURL: "https://api.morphscreen.com/v1", apiKey: process.env.OPENROUTER_API_KEY, defaultHeaders: { "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY }, }); const stream = await client.chat.completions.create({ model: "anthropic/claude-sonnet-4.6", stream: true, messages: [{ role: "user", content: "Compare three running shoes under $120" }], }); for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); ``` ```python import os from openai import OpenAI client = OpenAI( base_url="https://api.morphscreen.com/v1", api_key=os.environ["OPENROUTER_API_KEY"], default_headers={"X-MorphScreen-Key": os.environ["MORPHSCREEN_KEY"]}, ) ``` ## On the model in your plan The model included in your plan needs no OpenRouter key: send no `Authorization` header at all. OpenAI's Node SDK always adds one, so turn it off: ```ts const client = new OpenAI({ baseURL: "https://api.morphscreen.com/v1", apiKey: "unused", defaultHeaders: { "X-MorphScreen-Key": process.env.MORPHSCREEN_KEY, Authorization: null }, }); // model: "deepseek/deepseek-v4.1-flash" ``` From Python, call the API without the SDK ([Python](/docs/integrations/python)), since any `Authorization` header is taken as an OpenRouter key. ## OpenRouter's own SDK ```ts import { OpenRouter } from "@openrouter/sdk"; import { HTTPClient } from "@openrouter/sdk/lib/http.js"; const httpClient = new HTTPClient(); httpClient.addHook("beforeRequest", (req) => { const r = new Request(req); r.headers.set("X-MorphScreen-Key", process.env.MORPHSCREEN_KEY); return r; }); const openrouter = new OpenRouter({ apiKey: process.env.OPENROUTER_API_KEY, serverURL: "https://api.morphscreen.com/v1", httpClient }); ``` ## What changes - The answer is in our format, and the API's system prompt goes before your messages ([The system prompt](/docs/format/system-prompt)). - The last streamed chunk carries the core's verdict, and the repaired text when a repair was needed ([Validation and repair](/docs/format/validation)). - OpenRouter's errors come back as your SDK's own error classes, with OpenRouter's status: a 402 when your OpenRouter credit runs out, a 429 with its wait. - Options go in a `morphscreen` field of the request: `packs`, `components`, `images`, `photos`, `repair` ([the reference](/docs/reference/api/chat-completions)). To draw the text, pass it to the web SDK's `createAnswer` or ``, or render it on your server with `/v1/render`. --- # Going to production > What to have in place before real readers use it: the key on your server, limits you've planned for, errors you handle, and the dashboard to watch. ## Before you launch - **The key stays on your server.** Your page talks to your own route, and the route adds `X-MorphScreen-Key`. A key in a page is a key anyone can copy. If one leaks, revoke it on the dashboard and make another. - **Your server picks the model and the packs.** Don't pass them through from the page as they come, or a reader can choose a model that costs you more. - **Plan for the limits.** Each plan has calls a month, requests a minute and answers at once ([Limits and errors](/docs/production/limits)). Paid plans carry on past the month as extra calls; the free plan stops. - **Handle each error by its type.** A 429 says how long to wait. A 402 says what ran out. Show the reader something useful, not a stack trace. - **Allow what the page loads.** A content security policy needs `'wasm-unsafe-eval'` for the core, and the hosts your pictures, maps and media come from ([Security](/docs/production/security)). - **Watch the first days.** The dashboard shows every call: how it ended, how long it took, whether the answer was valid, and what repair did. ## What we keep A line for each call: when, which key, how long, the model, how it ended, whether the answer was valid and what repair did. Never the prompt or the answer, unless you turn on debug logs for a project, which keeps them for 7 days. [Security](/docs/production/security) has the whole list. ## When something's wrong The [status page](https://status.morphscreen.com) shows whether the API answers, checked every minute, with its uptime and answer times. Write to [support@morphscreen.com](mailto:support@morphscreen.com); [Troubleshooting](/docs/production/troubleshooting) covers the usual problems. --- # Limits and errors > Every plan's limits, what happens when one is reached, and every error the API returns with what to do about it. ## The limits | Limit | Free | Build | Scale | |---|---|---|---| | Calls a month | 3,000 | 25,000 | 500,000 | | Of them on our model | 100 | all | all | | Calls a day | 100 | no daily limit | no daily limit | | Extra calls past the month | none: calls stop | up to 25,000 more | up to 500,000 more | | Requests a minute | 10 | 60 | 300 | | Answers at once | 2 | 10 | 50 | | Renders a month, free | 30,000 | 250,000 | 5,000,000 | Limits are your account's, however many keys and projects it has. A call is one request to `/v1/chat/completions` or `/v1/ui`, its checking and any repair included. A call on the model in your plan may carry up to about 8,000 tokens of conversation. Prices are on the [pricing page](/pricing). A plan's month counts from the day you moved to it. Extra calls are billed with the next charge, at the price of the plan they were made on. ## Errors Every error has the same shape. `type` is the reason to act on; `message` says it in words. ```json { "error": { "code": 429, "message": "this account's plan allows 60 requests a minute", "type": "rate_limited", "metadata": { "error_type": "rate_limit_exceeded", "reason": "rate_limited" } } } ``` | Status | `type` | What happened | What to do | |---|---|---|---| | 400 | `model_needs_your_key` | a model other than the included one, without your OpenRouter key | send `Authorization: Bearer sk-or-...`, or use the included model | | 400 | `invalid_input` and others | the request can't be run as written | fix the request; `message` says what | | 401 | `morphscreen_key_required`, `morphscreen_key_invalid` | no MorphScreen key, or one we don't know or that was revoked | check `X-MorphScreen-Key` | | 401 | `openrouter_key_required`, `openrouter_key_invalid` | OpenRouter turned your key down | check the key at OpenRouter | | 402 | `plan_quota_reached` | this month's calls are used | wait for the month, or move to a bigger plan | | 402 | `extra_limit_reached` | a paid plan's month and as many again as extra | wait for the month, or move up | | 402 | `included_calls_used` | the free plan's calls on our model | send your own OpenRouter key, or pay for a plan | | 402 | `included_calls_paused` | calls on our model are paused for the free plan | send your own OpenRouter key, or try later | | 402 | `openrouter_out_of_credit` | your OpenRouter account is out of credit | add credit at OpenRouter | | 403 | `account_suspended` | the account is suspended | write to support@morphscreen.com | | 403 | `model_refused` | the model's moderation refused the request | change the request | | 404 | `model_not_found` and others | no such model, or no such route | check the name | | 413 | `payload_too_large` | the body is over 1 MB | send less | | 413 | `conversation_too_long` | more than about 8,000 tokens of conversation on our model | trim the conversation, or use your own key | | 429 | `rate_limited` | too many requests this minute | wait for `Retry-After` | | 429 | `too_many_streams` | too many answers at once | wait for one to finish | | 429 | `daily_limit_reached` | the free plan's calls today | wait for `Retry-After`: midnight UTC | | 429 | `render_limit_reached` | this month's renders | wait for the month | | 429 | `openrouter_rate_limited` | OpenRouter's own limit for your key | wait for `Retry-After` | | 502 | | the model failed or couldn't be reached, after our retries | try again | | 503 | | accounts can't be read just now | try again shortly | | 504 | | the model took too long to start | try again, or another model | A 429 always carries `Retry-After` in seconds. Retry 502, 503 and 504 with a backoff; don't retry the rest unchanged. ## Warnings before the end At 80% and 100% of a month's calls, the account's address gets a mail saying how many are left and what happens next. --- # Security > Nothing a model writes ever runs, nothing loads from a host you didn't choose, and nothing of the conversation is kept. What runs, what loads, what's kept, and the content security policy to use. A model can be talked into writing anything: a prompt injection in a page it read, or a reader asking it to. So nothing in an answer is trusted. ## Nothing a model wrote runs The core reads the answer into a tree of the components your catalog knows, and the SDK draws that tree. There's no `eval`, no `new Function` and no script from an answer. - **Text is text.** Raw HTML is never drawn as HTML; the few tags models write anyway (``, `
`) are read as formatting, with their attributes dropped. - **Components never run code.** When the reader acts, your app gets an action and decides what happens ([Actions and state](/docs/format/actions)). - **A what-if formula** is compiled by the core into a short list of numbers, the sliders' names and a few operators, run by a ten-line stack machine as the sliders move. Nothing else can run. - **Links and pictures** never go to `javascript:`, `vbscript:` or `file:`, and to `data:` only for an image. The address is read the way a browser reads it, so `javascript:` is caught too. - **No style attributes.** The core's HTML draws amounts as SVG and classes, so a strict `style-src` holds. A test fills every attribute of every component with 17 attacks and puts raw ` ``` ```vue ``` In a Vue app with Vite but no Nuxt, import the core's address the way the [Plain JavaScript](/docs/integrations/vanilla) page does (`@morphscreen/web/morphscreen.wasm?url`) instead of copying the file. --- # Svelte and SvelteKit > Draw the answer in onMount, with the core imported as a URL, in SvelteKit 3 or any Svelte 5 app. ```sh npm install @morphscreen/web ``` `onMount` runs in the browser only, so the core never loads on the server. Vite's `?url` turns the package's `morphscreen.wasm` into an address it serves. ```svelte
``` ```ts import adapter from "@sveltejs/adapter-static"; import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [sveltekit({ adapter: adapter() })] }); ``` --- # Angular > The core served as an asset and drawn after the view starts, in Angular 22. ```sh npm install @morphscreen/web ``` ## Serve the core and the stylesheet ```json { "styles": ["node_modules/@morphscreen/web/dist/morphscreen.css"], "assets": [{ "glob": "morphscreen.wasm", "input": "node_modules/@morphscreen/web/dist", "output": "/" }] } ``` ## The answer ```ts import { AfterViewInit, Component, ElementRef, viewChild } from "@angular/core"; import { createAnswer, loadCore } from "@morphscreen/web"; import { SAMPLE } from "../sample"; @Component({ selector: "app-root", template: `
`, }) export class App implements AfterViewInit { readonly answerEl = viewChild.required>("answer"); async ngAfterViewInit() { const core = await loadCore("/morphscreen.wasm"); const answer = createAnswer(this.answerEl().nativeElement, { core }); // Your server's stream goes here; this feeds a saved answer in pieces. for (let i = 0; i < SAMPLE.length; i += 24) { answer.push(SAMPLE.slice(i, i + 24)); await new Promise((r) => setTimeout(r, 10)); } answer.end(); document.body.dataset["done"] = "yes"; } } ``` Angular CLI 22 needs Node.js 22.22.3 or later, or 24.15 or later. --- # Plain JavaScript > createAnswer draws into any element, with no framework. Push the text as it arrives and end it when it's done. ```sh npm install @morphscreen/web ``` ```ts import { createAnswer, loadCore } from "@morphscreen/web"; import "@morphscreen/web/morphscreen.css"; import wasm from "@morphscreen/web/morphscreen.wasm?url"; import { SAMPLE } from "./sample"; const core = await loadCore(wasm); const answer = createAnswer(document.querySelector("#answer")!, { core }); // Your server's stream goes here; this feeds a saved answer in pieces. for (let i = 0; i < SAMPLE.length; i += 24) { answer.push(SAMPLE.slice(i, i + 24)); await new Promise((r) => setTimeout(r, 10)); } answer.end(); document.body.dataset.done = "yes"; ``` ```html MorphScreen with Vite
``` `createAnswer` returns the answer: `push(text)` for each piece, `end()` when it's done, `html()` for the finished answer as HTML, `applyState(states)` to set what the reader did, and `destroy()`. [The web SDK reference](/docs/reference/web) has its options. --- # webpack > The core as an asset module and the stylesheet through css-loader, in webpack 5. ```sh npm install @morphscreen/web ``` An `asset/resource` rule turns the imported `.wasm` into the address of a file webpack emits, which `loadCore` fetches. ```js import HtmlWebpackPlugin from "html-webpack-plugin"; export default { entry: "./src/main.js", output: { clean: true }, module: { rules: [ { test: /\.css$/, use: ["style-loader", "css-loader"] }, // The core is served as a file of its own, and loadCore fetches it. { test: /\.wasm$/, type: "asset/resource" }, ], }, plugins: [new HtmlWebpackPlugin({ title: "MorphScreen with webpack", templateContent: '
' })], }; ``` ```js import { createAnswer, loadCore } from "@morphscreen/web"; import "@morphscreen/web/morphscreen.css"; import wasm from "@morphscreen/web/morphscreen.wasm"; import { SAMPLE } from "./sample.js"; const core = await loadCore(wasm); const answer = createAnswer(document.querySelector("#answer"), { core }); // Your server's stream goes here; this feeds a saved answer in pieces. for (let i = 0; i < SAMPLE.length; i += 24) { answer.push(SAMPLE.slice(i, i + 24)); await new Promise((r) => setTimeout(r, 10)); } answer.end(); document.body.dataset.done = "yes"; ``` --- # 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. 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: ```js // 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`: ```js // 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 }; } ``` ```js 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. --- # Python > Call MorphScreen from Python with only the standard library: carry a page's AG-UI runs, stream an answer's text, or render a finished answer as HTML. Nothing to install: these use only Python's standard library. 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: ```python # Your server's one route for the page, with nothing but the standard # library: 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 json import os import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer API = os.environ.get("MORPHSCREEN_API", "https://api.morphscreen.com/v1") MODEL = os.environ.get("MODEL", "deepseek/deepseek-v4.1-flash") class Agent(BaseHTTPRequestHandler): def do_POST(self): if self.path != "/api/agent": self.send_error(404) return run = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) run["forwardedProps"] = {**run.get("forwardedProps", {}), "model": MODEL} req = urllib.request.Request( f"{API}/ui", data=json.dumps(run).encode(), headers={"Content-Type": "application/json", "X-MorphScreen-Key": os.environ.get("MORPHSCREEN_KEY", "")}, method="POST", ) try: upstream = urllib.request.urlopen(req) except urllib.error.HTTPError as e: upstream = e with upstream: self.send_response(upstream.status) self.send_header("Content-Type", upstream.headers.get("Content-Type", "text/event-stream")) self.end_headers() while chunk := upstream.read1(4096): self.wfile.write(chunk) self.wfile.flush() ThreadingHTTPServer(("127.0.0.1", int(os.environ.get("PORT", "3000"))), Agent).serve_forever() ``` In FastAPI, Flask or Django the route does the same: read the run, set `forwardedProps["model"]`, POST it to MorphScreen's `/v1/ui`, and stream the response body back with its status and content type, without buffering it. ## The answer's text, or HTML ```python # An answer's text as it streams, and a finished answer as HTML, with nothing # but the standard library. Run it on your server: the key stays there. import json import os import urllib.request API = os.environ.get("MORPHSCREEN_API", "https://api.morphscreen.com/v1") HEADERS = {"Content-Type": "application/json", "X-MorphScreen-Key": os.environ.get("MORPHSCREEN_KEY", "")} def stream_answer(messages, model="deepseek/deepseek-v4.1-flash"): """Yields the answer's text, piece by piece, as the model writes it.""" body = json.dumps({"model": model, "stream": True, "messages": messages}).encode() req = urllib.request.Request(f"{API}/chat/completions", data=body, headers=HEADERS, method="POST") with urllib.request.urlopen(req) as res: for line in res: line = line.decode().strip() if not line.startswith("data: ") or line == "data: [DONE]": continue for choice in json.loads(line[6:]).get("choices", []): text = (choice.get("delta") or {}).get("content") if text: yield text def render_html(text): """A finished answer drawn by the core as HTML, with its verdict.""" req = urllib.request.Request(f"{API}/render", data=json.dumps({"text": text}).encode(), headers=HEADERS, method="POST") with urllib.request.urlopen(req) as res: out = json.load(res) return out["html"], out["morphscreen"] ``` ```python from morphscreen import render_html, stream_answer text = "".join(stream_answer([{"role": "user", "content": "Make me a sales dashboard"}])) html, verdict = render_html(text) ``` Both are what our tests run against a recorded answer. ## With OpenAI's SDK On your own OpenRouter key, OpenAI's Python SDK works with a new `base_url` and the `X-MorphScreen-Key` header ([OpenAI-compatible clients](/docs/agents/openai-compatible)). For the model in your plan, use the functions above: any `Authorization` header is taken as an OpenRouter key. --- # HTML without JavaScript > Render a finished answer as HTML on your server with /v1/render. It needs no script, passes a strict content security policy, and works in mail, feeds and pages without JavaScript. `/v1/render` draws an answer you already have, with no model called. The HTML has no script and no style attributes: tabs switch with radio buttons, details open, selects and date inputs are the browser's own, charts and maps are SVG. ```bash curl https://api.morphscreen.com/v1/render \ -H "X-MorphScreen-Key: $MORPHSCREEN_KEY" \ -H "Content-Type: application/json" \ -d '{"text": "## Budget\n\n\n"}' ``` ```json { "html": "

Budget

\n
...
", "morphscreen": { "valid": true, "truncated": false, "components": 1, "diagnostics": [] } } ``` Put it in a page with the stylesheets from the web SDK's package (`dist/morphscreen.css` and its `fonts/`, and `dist/morphscreen-page.css` for a page that is just the answer): ```html
``` Every component draws this way; the [component reference](/docs/components) is this HTML. `output: "ops"` or `"tree"` returns the core's op stream or tree instead, for drawing it yourself. Renders are free up to ten times your plan's calls a month. Pictures load only from your images' and photo provider's hosts, and those you list in `image_hosts`. --- # All components > The 75 components a model can use, in 9 packs: what each is for, its attributes, and a live preview drawn by the core. Every answer can use the `core` pack. The others are added by default; an app that names the packs it wants (`packs` in the API and the SDKs) gets core and those, and the model is told about only those. Fewer packs make a shorter system prompt: core alone is about 1,400 tokens, every pack about 2,900. | Pack | Components | What it's for | |---|---|---| | [Core](/docs/components/core) | 25 | Always included: layout, text blocks, numbers, charts, forms and buttons. | | [Data](/docs/components/data) | 7 | Ways to show data beyond charts and tables: facts, timelines, gauges, heatmaps, funnels, sparklines and hierarchies. | | [Commerce](/docs/components/commerce) | 6 | Shopping: products, pricing plans, carts, ratings, comparisons and prices. | | [Device](/docs/components/device) | 10 | The device's own hardware and apps: maps, camera, scanning, location, files, contacts, the calendar, sharing, voice notes and QR codes. Native on every platform, with a web fallback that needs no JavaScript. | | [Docs](/docs/components/docs) | 5 | Technical writing: code with a file name, diffs, formulas, file trees and cited sources. | | [Inputs](/docs/components/inputs) | 6 | More form fields: star ratings, searchable choices, times, toggle groups, steppers and signatures. | | [Interactive](/docs/components/interactive) | 7 | Things the reader does on the page, kept on their device and reported back: a quiz, flashcards, a poll, a checklist, and what-if sliders. | | [Media](/docs/components/media) | 4 | Video, audio, embeds from known hosts, and a carousel of images. | | [Scheduling](/docs/components/scheduling) | 5 | Time: a month calendar, events, time slots to pick, a day's agenda and countdowns. | ## Names models use on their own A few names models reach for without being told are read as the component they mean, and reported as a note, never taught: | They write | It's read as | |---|---| | `
` | ``: a section holding a Markdown table | | `` | a plain group: one cell of Columns | | `` | a plain group: a row of buttons | | `` | a plain group: what models call a run of text | ## Every component - **Core:** [Card](/docs/components/core#card), [Columns](/docs/components/core#columns), [Tabs](/docs/components/core#tabs), [Tab](/docs/components/core#tab), [Details](/docs/components/core#details), [Steps](/docs/components/core#steps), [Step](/docs/components/core#step), [Callout](/docs/components/core#callout), [Stat](/docs/components/core#stat), [Badge](/docs/components/core#badge), [Progress](/docs/components/core#progress), [Avatar](/docs/components/core#avatar), [Gallery](/docs/components/core#gallery), [Chart](/docs/components/core#chart), [FollowUps](/docs/components/core#followups), [Form](/docs/components/core#form), [Input](/docs/components/core#input), [TextArea](/docs/components/core#textarea), [Select](/docs/components/core#select), [Date](/docs/components/core#date), [Slider](/docs/components/core#slider), [Checkboxes](/docs/components/core#checkboxes), [Radio](/docs/components/core#radio), [Switch](/docs/components/core#switch), [Button](/docs/components/core#button) - **Data:** [KeyValue](/docs/components/data#keyvalue), [Timeline](/docs/components/data#timeline), [Gauge](/docs/components/data#gauge), [Heatmap](/docs/components/data#heatmap), [Funnel](/docs/components/data#funnel), [Sparkline](/docs/components/data#sparkline), [Tree](/docs/components/data#tree) - **Commerce:** [Product](/docs/components/commerce#product), [Plan](/docs/components/commerce#plan), [Cart](/docs/components/commerce#cart), [Rating](/docs/components/commerce#rating), [Compare](/docs/components/commerce#compare), [Price](/docs/components/commerce#price) - **Device:** [Map](/docs/components/device#map), [Camera](/docs/components/device#camera), [Scan](/docs/components/device#scan), [Location](/docs/components/device#location), [File](/docs/components/device#file), [Contact](/docs/components/device#contact), [AddToCalendar](/docs/components/device#addtocalendar), [Share](/docs/components/device#share), [VoiceNote](/docs/components/device#voicenote), [QR](/docs/components/device#qr) - **Docs:** [Code](/docs/components/docs#code), [Diff](/docs/components/docs#diff), [Math](/docs/components/docs#math), [FileTree](/docs/components/docs#filetree), [Citation](/docs/components/docs#citation) - **Inputs:** [RatingInput](/docs/components/inputs#ratinginput), [Combobox](/docs/components/inputs#combobox), [Time](/docs/components/inputs#time), [ToggleGroup](/docs/components/inputs#togglegroup), [Stepper](/docs/components/inputs#stepper), [Signature](/docs/components/inputs#signature) - **Interactive:** [Quiz](/docs/components/interactive#quiz), [Question](/docs/components/interactive#question), [Flashcards](/docs/components/interactive#flashcards), [Poll](/docs/components/interactive#poll), [Checklist](/docs/components/interactive#checklist), [WhatIf](/docs/components/interactive#whatif), [Result](/docs/components/interactive#result) - **Media:** [Video](/docs/components/media#video), [Audio](/docs/components/media#audio), [Embed](/docs/components/media#embed), [Carousel](/docs/components/media#carousel) - **Scheduling:** [Calendar](/docs/components/scheduling#calendar), [Event](/docs/components/scheduling#event), [TimeSlots](/docs/components/scheduling#timeslots), [Agenda](/docs/components/scheduling#agenda), [Countdown](/docs/components/scheduling#countdown) --- # Core components > The core pack's 25 components: always included: layout, text blocks, numbers, charts, forms and buttons. Core comes with every answer, whichever packs an app names. ## Markdown Text between components is Markdown: headings, lists, tables, emphasis, links, code and images from the hosts an app allows. ```markdown ## A weekend in Lisbon Lisbon is **compact and hilly**. Most of what you want to see sits between Baixa and Belém, and the trams cover the steep parts. ![Belém Tower on the Tagus](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACmklEQVR42u3dQU7bQBQGYDOteopKZdMjVF3CrhKX7C2oqm5gWfUIbOAmXVTqgiCI43nPMy/fv4QQz/jLe7bjSbj4/uPXInXT7ALAAlgAC2ABLIAFMGABLIAFsAAWwAIYsACW+fLeLgjNt69fDn/48/cfwAVdD3+bIK1FZ+ue9kgVPBntsz+JK2UVvKduQikD3l831Biw62DJKr6IIgY8lkp3Y8BatADWn4d9TsAqWAALYAEsgAVwaiJu8/V9TsAqWADr0sP2fMADqUQc0QFr0ZJSfEHr7gAPYRy3qtKy2f7Gq+73Ra99V8F7lnLCJxtUcKyxzyapZmfRy7Isy+3dvZdLTeDbu/t/uowLAj9D/Y8t0wO/Ysl4euA3CRnPCnx8E2Y8H/BaM4fkaYC3UDEeHXi70PjGe43wYt9/jNV92jfXVyPr5g9vN+C4V/RQxofTTB5eq9evxmnXL44k+cQwu4LT5rZvHR8zzZwRtqonGjteQQ11Hd9q7+78ja7aYsLwMlr07gfFnGa4ZZpxI4wFHuryNJR5+0yDhtfO5xZ90Hh6HX2ChteKneAk78S+Txix0zq36CneFu7VDKd4r6Yn8Fxv+m/ZiQkz7WXcavfkCKScmfbaSjvn9Y6D337usq1NLbrMjdhj+uGOk93Srhvd8ZeAbdn6KRVcdQXFi4UyzmRPq+NG95WpDTXZ0wazooLPZ+nTzfXVyJNdVcpHAVvVNq9xo1v7Aq9ZdVzbuCncqY3fZGoKt3YpN4Vb27gp3NrGTeHWPiS/+/Dx08Pjk71TJg+PT58vL31P1rm0a8DFjX1PVnFjFbz4ALgAFsACWAALYAEMWAALYAEsgAWwAAYsgAWwABbAAlgAC2DAAlgAC2ABLIAFMGABLNPkL96PXxmAn95xAAAAAElFTkSuQmCC) - Day one: Alfama, the castle, a fado dinner - Day two: Belém, pastries, the river walk - Day three: Sintra by train (40 minutes from Rossio) | Neighbourhood | Good for | From the centre | |---|---|---| | Alfama | Old streets, fado | 10 min walk | | Belém | Monuments, pastries | 20 min by tram | | Príncipe Real | Gardens, shops | 15 min walk | > The 28 tram is lovely but packed after 10:00. Ride it early. Book the castle online with `code LISBOA10` for 10% off. ``` ## Card A section, optionally with a header. Between its tags: Markdown and other components. | Attribute | Type | Required | Values | |---|---|---|---| | `title` | text | | | | `subtitle` | text | | | ```markdown Flights are booked. What's left is the hotel, food and two day trips, about **€740** in all. ``` ## Columns Puts its children side by side, stacking on narrow screens. Between its tags: Markdown and other components. ```markdown Three nights in Chiado, breakfast included. Check-in from 15:00. TP 1351 out on Friday 08:40, TP 1358 back on Monday 19:15. A Viva Viagem card covers trams, metro and the river ferries. ``` ## Tabs One panel per Tab. Between its tags: only `Tab` components. ```markdown Three nights, flights booked, hotel to confirm by Thursday. - Time Out Market for the first night - Pastéis de Belém on day two - A tasca in Alfama for fado Comfortable shoes (the hills are steep), a light jacket for the evenings, sunscreen. ``` ## Tab A panel inside Tabs. Between its tags: Markdown and other components. It goes inside `Tabs`. | Attribute | Type | Required | Values | |---|---|---|---| | `title` | text | yes | | ```markdown Three nights, flights booked, hotel to confirm by Thursday. - Time Out Market for the first night - Pastéis de Belém on day two - A tasca in Alfama for fado Comfortable shoes (the hills are steep), a light jacket for the evenings, sunscreen. ``` ## Details A collapsible section, closed until opened (open starts it open). Between its tags: Markdown and other components. | Attribute | Type | Required | Values | |---|---|---|---| | `title` | text | yes | | | `open` | true or false | | | ```markdown
Weekend rates in Chiado run about 30% higher, and this weekend there's a football match at the Estádio da Luz.
Free cancellation until 48 hours before check-in. After that the first night is charged.
``` ## Steps An ordered sequence or timeline. Between its tags: only `Step` components. ```markdown Reply to the hotel's email with the arrival time. ``` ## Step One step inside Steps. Between its tags: Markdown and other components. It goes inside `Steps`. | Attribute | Type | Required | Values | |---|---|---|---| | `title` | text | yes | | | `status` | one of | | `done`, `current`, `todo` (default) | | `subtitle` | text | | | ```markdown Reply to the hotel's email with the arrival time. ``` ## Callout A highlighted note or alert. Between its tags: Markdown and other components. | Attribute | Type | Required | Values | |---|---|---|---| | `tone` | one of | | `neutral`, `info` (default), `success`, `warning`, `danger` | | `title` | text | | | ```markdown Museums are free on Sunday mornings for residents only, so check the ticket rules. Your table at Taberna Sal Grosso is confirmed for 20:30. Metro staff plan a partial strike from 06:30 to 10:30. It must be valid for three months after your return date. Prices are in euros and include VAT. ``` ## Stat One key number, its unit inside value ("3.7 km", "$1,204"). Between its tags: nothing: it closes itself (``). | Attribute | Type | Required | Values | |---|---|---|---| | `label` | text | yes | | | `value` | text | yes | | | `change` | text | | | | `note` | text | | | | `tone` | one of | | `neutral` (default), `info`, `success`, `warning`, `danger` | | `icon` | icon name | | | ```markdown ``` ## Badge A short status label. Between its tags: inline text: its label. | Attribute | Type | Required | Values | |---|---|---|---| | `tone` | one of | | `neutral` (default), `info`, `success`, `warning`, `danger` | ```markdown Order **#4821** is Shipped, invoice Overdue, return window 14 days, payment Failed, label Draft. ``` ## Progress A progress bar. Between its tags: nothing: it closes itself (``). | Attribute | Type | Required | Values | |---|---|---|---| | `value` | number | yes | | | `max` | number | | default `100` | | `label` | text | | | | `tone` | one of | | `neutral` (default), `info`, `success`, `warning`, `danger` | ```markdown ``` ## Avatar A person's picture or initials, with their name. Between its tags: nothing: it closes itself (``). | Attribute | Type | Required | Values | |---|---|---|---| | `name` | text | yes | | | `src` | address | | | | `subtitle` | text | | | ```markdown ``` ## Gallery A grid of images. Between its tags: Markdown images, one a line. ```markdown ![Tram 28 climbing through Alfama](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACmklEQVR42u3dQU7bQBQGYDOteopKZdMjVF3CrhKX7C2oqm5gWfUIbOAmXVTqgiCI43nPMy/fv4QQz/jLe7bjSbj4/uPXInXT7ALAAlgAC2ABLIAFMGABLIAFsAAWwAIYsACW+fLeLgjNt69fDn/48/cfwAVdD3+bIK1FZ+ue9kgVPBntsz+JK2UVvKduQikD3l831Biw62DJKr6IIgY8lkp3Y8BatADWn4d9TsAqWAALYAEsgAVwaiJu8/V9TsAqWADr0sP2fMADqUQc0QFr0ZJSfEHr7gAPYRy3qtKy2f7Gq+73Ra99V8F7lnLCJxtUcKyxzyapZmfRy7Isy+3dvZdLTeDbu/t/uowLAj9D/Y8t0wO/Ysl4euA3CRnPCnx8E2Y8H/BaM4fkaYC3UDEeHXi70PjGe43wYt9/jNV92jfXVyPr5g9vN+C4V/RQxofTTB5eq9evxmnXL44k+cQwu4LT5rZvHR8zzZwRtqonGjteQQ11Hd9q7+78ja7aYsLwMlr07gfFnGa4ZZpxI4wFHuryNJR5+0yDhtfO5xZ90Hh6HX2ChteKneAk78S+Txix0zq36CneFu7VDKd4r6Yn8Fxv+m/ZiQkz7WXcavfkCKScmfbaSjvn9Y6D337usq1NLbrMjdhj+uGOk93Srhvd8ZeAbdn6KRVcdQXFi4UyzmRPq+NG95WpDTXZ0wazooLPZ+nTzfXVyJNdVcpHAVvVNq9xo1v7Aq9ZdVzbuCncqY3fZGoKt3YpN4Vb27gp3NrGTeHWPiS/+/Dx08Pjk71TJg+PT58vL31P1rm0a8DFjX1PVnFjFbz4ALgAFsACWAALYAEMWAALYAEsgAWwAAYsgAWwABbAAlgAC2DAAlgAC2ABLIAFMGABLNPkL96PXxmAn95xAAAAAElFTkSuQmCC) ![Pastéis de nata fresh from the oven](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACmklEQVR42u3dQU7bQBQGYDOteopKZdMjVF3CrhKX7C2oqm5gWfUIbOAmXVTqgiCI43nPMy/fv4QQz/jLe7bjSbj4/uPXInXT7ALAAlgAC2ABLIAFMGABLIAFsAAWwAIYsACW+fLeLgjNt69fDn/48/cfwAVdD3+bIK1FZ+ue9kgVPBntsz+JK2UVvKduQikD3l831Biw62DJKr6IIgY8lkp3Y8BatADWn4d9TsAqWAALYAEsgAVwaiJu8/V9TsAqWADr0sP2fMADqUQc0QFr0ZJSfEHr7gAPYRy3qtKy2f7Gq+73Ra99V8F7lnLCJxtUcKyxzyapZmfRy7Isy+3dvZdLTeDbu/t/uowLAj9D/Y8t0wO/Ysl4euA3CRnPCnx8E2Y8H/BaM4fkaYC3UDEeHXi70PjGe43wYt9/jNV92jfXVyPr5g9vN+C4V/RQxofTTB5eq9evxmnXL44k+cQwu4LT5rZvHR8zzZwRtqonGjteQQ11Hd9q7+78ja7aYsLwMlr07gfFnGa4ZZpxI4wFHuryNJR5+0yDhtfO5xZ90Hh6HX2ChteKneAk78S+Txix0zq36CneFu7VDKd4r6Yn8Fxv+m/ZiQkz7WXcavfkCKScmfbaSjvn9Y6D337usq1NLbrMjdhj+uGOk93Srhvd8ZeAbdn6KRVcdQXFi4UyzmRPq+NG95WpDTXZ0wazooLPZ+nTzfXVyJNdVcpHAVvVNq9xo1v7Aq9ZdVzbuCncqY3fZGoKt3YpN4Vb27gp3NrGTeHWPiS/+/Dx08Pjk71TJg+PT58vL31P1rm0a8DFjX1PVnFjFbz4ALgAFsACWAALYAEMWAALYAEsgAWwAAYsgAWwABbAAlgAC2DAAlgAC2ABLIAFMGABLNPkL96PXxmAn95xAAAAAElFTkSuQmCC) ![The view from Miradouro de Santa Luzia](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKAAAAB4CAIAAAD6wG44AAACmklEQVR42u3dQU7bQBQGYDOteopKZdMjVF3CrhKX7C2oqm5gWfUIbOAmXVTqgiCI43nPMy/fv4QQz/jLe7bjSbj4/uPXInXT7ALAAlgAC2ABLIAFMGABLIAFsAAWwAIYsACW+fLeLgjNt69fDn/48/cfwAVdD3+bIK1FZ+ue9kgVPBntsz+JK2UVvKduQikD3l831Biw62DJKr6IIgY8lkp3Y8BatADWn4d9TsAqWAALYAEsgAVwaiJu8/V9TsAqWADr0sP2fMADqUQc0QFr0ZJSfEHr7gAPYRy3qtKy2f7Gq+73Ra99V8F7lnLCJxtUcKyxzyapZmfRy7Isy+3dvZdLTeDbu/t/uowLAj9D/Y8t0wO/Ysl4euA3CRnPCnx8E2Y8H/BaM4fkaYC3UDEeHXi70PjGe43wYt9/jNV92jfXVyPr5g9vN+C4V/RQxofTTB5eq9evxmnXL44k+cQwu4LT5rZvHR8zzZwRtqonGjteQQ11Hd9q7+78ja7aYsLwMlr07gfFnGa4ZZpxI4wFHuryNJR5+0yDhtfO5xZ90Hh6HX2ChteKneAk78S+Txix0zq36CneFu7VDKd4r6Yn8Fxv+m/ZiQkz7WXcavfkCKScmfbaSjvn9Y6D337usq1NLbrMjdhj+uGOk93Srhvd8ZeAbdn6KRVcdQXFi4UyzmRPq+NG95WpDTXZ0wazooLPZ+nTzfXVyJNdVcpHAVvVNq9xo1v7Aq9ZdVzbuCncqY3fZGoKt3YpN4Vb27gp3NrGTeHWPiS/+/Dx08Pjk71TJg+PT58vL31P1rm0a8DFjX1PVnFjFbz4ALgAFsACWAALYAEMWAALYAEsgAWwAAYsgAWwABbAAlgAC2DAAlgAC2ABLIAFMGABLNPkL96PXxmAn95xAAAAAElFTkSuQmCC) ``` ## Chart Charts the Markdown table inside: first column labels, every column after it numbers (one series each). Between its tags: a Markdown table (first column labels, the others series). | Attribute | Type | Required | Values | |---|---|---|---| | `type` | one of | | `bar` (default), `line`, `area`, `pie`, `donut`, `radar`, `scatter`, `stacked`, `horizontal` | | `title` | text | | | | `x` | text | | | | `y` | text | | | ### bar ```markdown | Month | Signups | |---|---| | Jan | 120 | | Feb | 180 | | Mar | 240 | | Apr | 210 | | May | 305 | ``` ### line ```markdown | Round | Antonelli | Russell | Hamilton | |---|---|---|---| | 1 | 25 | 18 | 15 | | 2 | 43 | 36 | 27 | | 3 | 68 | 51 | 45 | | 4 | 93 | 69 | 57 | | 5 | 111 | 94 | 70 | ``` ### area ```markdown | Day | Visitors | |---|---| | Mon | 1,200 | | Tue | 1,450 | | Wed | 1,380 | | Thu | 1,720 | | Fri | 1,960 | ``` ### stacked ```markdown | Week | High | Medium | Low | |---|---|---|---| | W1 | 12 | 22 | 30 | | W2 | 9 | 25 | 34 | | W3 | 14 | 19 | 28 | | W4 | 7 | 21 | 31 | ``` ### horizontal ```markdown | Course | Fill % | |---|---| | CS 101 Intro to Programming | 98 | | BIO 150 Genetics | 89 | | ART 210 Modern Art History | 78 | | ECO 120 Microeconomics | 71 | ``` ### scatter ```markdown | Price | Rating | |---|---| | 49 | 4.1 | | 79 | 3.6 | | 129 | 4.8 | | 199 | 4.4 | | 249 | 4.6 | ``` ### pie ```markdown | Source | Visits | |---|---| | Search | 5,400 | | Social | 2,100 | | Direct | 1,500 | | Email | 600 | ``` ### donut ```markdown | Plan | Customers | |---|---| | Free | 1,842 | | Pro | 1,214 | | Team | 702 | ``` ### radar ```markdown | Skill | Alex | Sam | |---|---|---| | Speed | 8 | 6 | | Power | 6 | 9 | | Accuracy | 9 | 7 | | Stamina | 7 | 8 | | Teamwork | 8 | 7 | ``` ## FollowUps Suggested next questions the user can tap. Between its tags: a Markdown list. ```markdown - What's the cheapest way to get to Sintra? - Find me a fado house that isn't touristy - Plan day two around the rain forecast ``` ## Form Groups fields; its Button submits them. Between its tags: Markdown and other components. | Attribute | Type | Required | Values | |---|---|---|---| | `name` | text | | | ```markdown