MCP UI blocks

Fenced blocks an agent emits that render as components.

Where this comes from

The idea is MCP UI — that a model should be able to return something the client renders, not just text describing it. The name and the concept come from the MCP-UI project, which introduced it for the Model Context Protocol: a server replies with a UI resource and the host displays it.

Runspace takes the same idea and makes it cheap. There is no protocol to implement and no resource type to negotiate — an agent writes a fenced code block in its ordinary reply, and the frontend renders it. Anything that can produce markdown can produce a chart, which is the point: it works the same whether the answer came from Agentino, Claude Code, Codex, OpenClaw or pi.

Every screenshot below comes from Almanac, whose agents are open source — the prompts that produce these blocks are in islavutin-oss/almanac, under agents/*/SOUL.md.

The blocks

An agent can emit a fenced block and the frontend renders it as a real component instead of code. Seven block types ship — chart, datatable, kpi, insight, form, file and mermaid — and chart alone covers ten kinds of plot. Every one of them is illustrated below.

Five show the reader something. form collects something back, and file hands something over.

Every screenshot below is from a live demo, not a mock-up — the numbers are whatever the data said when the page was captured.

The key names below are the canonical ones, but the renderers accept the obvious synonyms — title for headline, headers for columns, href for url, x/y for xKey/yKey. Models reach for those constantly, and refusing them meant a reader saw a schema complaint where a correct answer should have been. The canonical name always wins when both are present.

A block that genuinely cannot be understood renders a visible error rather than silently doing nothing, so the mistake shows up in development instead of in front of a user.

chart

```chart
{"type": "bar", "title": "Input price by model",
 "data": [{"model": "Ling 3.0 Flash", "usd": 0.021}, {"model": "Solar Pro 4", "usd": 0.03}],
 "xKey": "model", "yKey": "usd", "yFormat": "currency"}
```

data is a list of flat objects; xKey and yKey name which of their keys to plot. yFormat may be number, percent or currency — currency defaults to a dollar sign and takes a currency key to change it.

Ten types ship. Each one below is the real component rendering real data.

bar

The default, and usually the right answer for a category against a value.

A bar chart rendered from a chart block
A bar chart rendered from a chart block

line

A value over an ordered axis, when the shape between points means something.

A line chart rendered from a chart block
A line chart rendered from a chart block

area

A line where the magnitude beneath it is part of the point — a total accumulating, a share filling up.

An area chart rendered from a chart block
An area chart rendered from a chart block

pie

Composition, and only when the parts genuinely sum to a whole. Slices are labelled with their share, so a reader is not estimating angles.

A pie chart rendered from a chart block
A pie chart rendered from a chart block

scatter

Two measurements against each other, when the question is whether they are related at all. Both axes are numeric.

A scatter chart rendered from a chart block
A scatter chart rendered from a chart block

composed

Two series on one frame with independent axes — bars for one, a line for the other. Add y2Key for the second series, and y2Color if the default clashes.

A composed chart rendered from a chart block
A composed chart rendered from a chart block

radar

Several things scored on the same set of axes. Name the series in radarKeys; each row of data is one axis with a value per series.

A radar chart rendered from a chart block
A radar chart rendered from a chart block

heatmap

A grid where colour carries the value. Give it columns, rows and a valueKey; each data row names its cell.

A heatmap rendered from a chart block
A heatmap rendered from a chart block

treemap

Relative size across many categories, when a bar chart would need thirty bars. Cells are labelled with the category and its value.

A treemap rendered from a chart block
A treemap rendered from a chart block

sankey

Flow through stages — how a population narrows, where volume goes. Supply nodes and links instead of the usual data:

```chart
{"type": "sankey", "title": "How a shortlist narrows",
 "nodes": [{"name": "Catalogue"}, {"name": "Meets context"}, {"name": "Shortlist"}],
 "links": [{"source": 0, "target": 1, "value": 396},
           {"source": 1, "target": 2, "value": 12}]}
```

A sankey diagram rendered from a chart block
A sankey diagram rendered from a chart block

Interaction

Any chart can be made clickable. clickHrefKey names a key on each datum holding a link to follow; clickPromptKey holds a question to send back to the agent, so clicking a bar asks about the thing it represents. legendPromptTemplate does the same for legend entries, with {name} substituted.

Every chart also carries CSV and PNG buttons, so a reader can take the data away rather than retyping it.

datatable

```datatable
{"title": "Season summary", "columns": ["Season", "Home wins", "Rate"],
 "rows": [["2020/21", "144", "37.9%"], ["2019/20", "172", "45.5%"]]}
```

A datatable block rendered in a chat
A datatable block rendered in a chat

columns and rows are required. Rows can carry links, expandable detail and per-row actions that dispatch a follow-up prompt.

kpi

```kpi
[{"title": "Models listed", "value": "396", "subtitle": "on OpenRouter"},
 {"title": "Latency measured", "value": "14%", "subtitle": "57 with a published TTFT"}]
```

KPI cards rendered in a channel
KPI cards rendered in a channel

title and value are required. A card without title renders as a bare number, which tells the reader nothing.

insight

```insight
{"kind": "insight", "headline": "Most of the catalogue is unmeasured on latency",
 "body": "339 of 396 listings publish no time to first token. Unmeasured, not fast."}
```

An insight block rendered in a chat
An insight block rendered in a chat

headline is required and is the whole point of the block. kind is one of insight, warning, opportunity, risk.

form

The others render an answer. This one asks a question and gets a reply.

```form
{"title": "Start a conversation",
 "body": "Leave a way to reach you.",
 "fields": [{"key": "name", "label": "Name", "required": true},
            {"key": "contact", "label": "Email or Telegram", "required": true},
            {"key": "detail", "label": "What you are working on", "type": "textarea"}],
 "submitLabel": "Send",
 "prompt": "Please pass this on: {name} ({contact}) — {detail}",
 "done": "Sent."}
```

A form block rendered in a chat
A form block rendered in a chat

There is deliberately no submit endpoint. Submitting dispatches a widget intent — the same mechanism a chart click uses — so the values arrive as an ordinary turn the agent can act on with a tool. No new backend route, no server-side form state, and it works in any host that already renders these blocks.

type may be text, email, tel or textarea. required gates the submit button; whitespace does not satisfy it. prompt is sent on submit with {key} placeholders filled in — a placeholder with no matching field becomes an empty string rather than a literal brace. done replaces the form once it has sent.

Where no chat host is wired up, the button is disabled and says so rather than silently doing nothing.

file

Something the agent generated that the reader should keep — a report to circulate, a spreadsheet to diff next month. Write the artefact through the workspace's file storage, then emit the block with the URL you get back.

```file
{"name": "almanac-shortlist-20260830.csv",
 "url": "/api/workspace/files/abc123",
 "kind": "csv", "size": 6144,
 "caption": "What is in it, and what it does not cover."}
```

A file block rendered as a download row
A file block rendered as a download row

name and url are required; kind picks the icon (csv, markdown, json, text). size is shown so nobody opens a large download by accident.

Only workspace-relative URLs render. An off-site or javascript: URL shows a visible refusal instead — a download row is exactly what a reader clicks without looking, and an agent is not a trusted source of URLs. Protocol-relative //host/path counts as off-site.

Attach a file alongside the answer, never instead of one.

mermaid

```mermaid
flowchart LR
  feeds[Feeds in] --> filter[Relevance filter]
  filter --> filed{Already filed?}
  filed -- no --> publish[Publish digest]
  filed -- yes --> quiet[Stay silent]
```

A mermaid diagram rendered in a channel
A mermaid diagram rendered in a channel

Rendered natively — no library to add. Unlike the four above it carries no JSON payload, so there is nothing to validate: a diagram that does not parse shows mermaid's own error rather than an error box.

Useful when the answer is a process rather than a number — an escalation path, a state machine, how a request moves through a system.

Where to emit them

From a tool, when the shape is known — the tool returns the block and the agent passes it through. That is deterministic and cannot be got wrong by a model having an off day.

From the agent, when the shape depends on the question. If you do this, put the schemas in the SOUL. An agent told "use a kpi block" without being shown the keys will guess, and it will guess wrong often enough to matter.