UI Block Protocol
Stream-embedded protocol for structured UI cards alongside markdown text in the chat stream
The UI Block Protocol (UBP) is the wire-level contract for
agent-emitted UI cards (feature recommendations today, sandboxed
external connector UIs in the next phase). It piggybacks on the
existing chat SSE stream — every ui_block event sits alongside
the regular text_delta and tool_call_* events and renders in
arrival order.
Why a separate protocol
The agent already streams markdown text and structured tool results. Two needs the existing surface doesn't cover:
- Action cards after an answer. When the user asks "how do I add money?", the agent should answer in prose and drop a "Deposit now" button. A markdown link can do that, but a card is more scannable and lets us count clicks for downstream tuning.
- An open extension point. External skills will want to render their own data inside the chat (a bridge quote, a custom watchlist). We want one wire format that both the first-party deposit card and a third-party bridge widget can ride.
The design borrows from two existing protocols:
- Anthropic MCP — namespaced resources + version-negotiated capabilities at session start.
- ChatGPT Apps SDK —
_meta.outputTemplatehint for client- rendered widgets; sandboxed iframe execution.
UBP is not a separate server. It's an inline event on the same chat stream, which is lighter than MCP but covers the same "agent says this card belongs to that text" contract.
Wire shape
The ui_block event sits inside the existing chat SSE union:
type AgentEvent =
| { type: "start"; data: { ts?: number; session_id?: string; supported_blocks?: UiBlockSupport[] } }
| { type: "text_delta"; data: { text: string } }
| { type: "ui_block"; data: UiBlockEvent }
// … tool_call_*, sub_tool_*, pending_question_*, done, error, …
interface UiBlockEvent {
id: string; // stable id within the turn
namespace: string; // "minara.*" reserved; external devs pick their own
block_type: string; // e.g. "feature_recommendation"
version: string; // semver; renderer matches ^major
data: unknown; // validated against the registered schema before emit
position?: "after_text" | "inline" | "before_text";
_meta?: {
"minara/outputTemplate"?: string; // reserved for sandboxed renderers
"minara/source"?: string; // "skill" | "scenario" | …
"minara/reason"?: string; // non-user-visible debug string
[vendorKey: string]: unknown;
};
}Position is interpreted from arrival order. The agent emits
text_delta and ui_block interleaved; the client renders in
the order they arrive, so an inline block is whatever sat
between two text segments on the wire.
Capability negotiation
The client declares which blocks it can render via the
?capabilities= query parameter on the chat stream URL:
POST /v1/chat/stream?capabilities=minara.feature_recommendation@1,acme.tools.bridge_quote@2Each token is <namespace>.<block_type>@<major>. Multi-segment
namespaces are split on the last dot, so
acme.tools.bridge_quote@2 resolves to namespace acme.tools,
block type bridge_quote, major version 2.
The agent intersects the client's tokens with its own registry
and echoes the result in the start event:
{
"type": "start",
"data": {
"ts": 1736000000000,
"session_id": "…",
"supported_blocks": [
{ "namespace": "minara", "block_type": "feature_recommendation", "version": 1 }
]
}
}Any block type missing from the intersection MUST NOT be
emitted by the agent for this stream. The shared
emitUiBlock() pipeline refuses unsupported blocks with the
ui_block_not_supported_by_client error code; the LLM tool
handler surfaces that code back to the model with the hint to
fall back to inline markdown.
Strict capability token grammar
namespace = SEGMENT ("." SEGMENT)*
wire-token = namespace "." SEGMENT "@" MAJOR
SEGMENT = [a-z0-9_-]+
MAJOR = [1-9][0-9]*Rejected: uppercase, whitespace, multiple @, leading-zero or
zero / negative versions, empty / leading / trailing dot
segments. The parser is grammar-only; compatibility resolution
happens at the registry layer.
Built-in block: minara.feature_recommendation@1
A short, ≤2-button card the agent emits at the end of (or inline within) an assistant answer, nudging the user toward a concrete next step — deposit modal, market page, slash-command prefill, etc.
Schema
{
items: Array<{
id: string; // lowercase identifier, unique within the block
label: string; // ≤20 chars, the button text
hint?: string; // ≤48 chars, optional one-line micro-copy
icon?: // optional, allow-list enum
| "wallet" | "deposit" | "swap" | "buy" | "sell"
| "autopilot" | "workflow" | "chart" | "search"
| "settings" | "alert" | "research";
action:
| { kind: "route"; target: string } // /<in-app-path>
| { kind: "slash"; command: string } // "/buy 100 BTC"
| { kind: "modal"; name: "deposit" | "withdraw"; params?: Record<string, unknown> }
| { kind: "external"; url: string }; // https only, host allow-listed
}>; // min 1, max 2
}routeopens an in-app path via the SPA router. Target must start with/<alnum>; the validator rejects//,\, CR/LF/TAB, dot-segments (..), and percent-encoded variants (%2e,%5c,%0a, …) to defeat browser URL normalisation bypasses.slashpre-fills the chat composer with a slash command. Never auto-submits. The argument body excludes all C0 controls and DEL.modalopens a registered modal by ID. The current allow-list isdepositandwithdraw; adding a new modal means bumping the enum AND wiring the opener in the web-ui.externalopens a URL in a new tab. The agent schema only enforceshttps://; the renderer also checks the URL against a client-side host allow-list (currentlyminara.ai,www.minara.ai,agent.minara.ai) and refuses to open anything else with a console warning. Adding to the allow-list is a deliberate decision logged in PR review.
UX caps
Two layers of caps live in code today; a third is reserved for when session-scoped state lands:
| Layer | Cap | Enforced today |
|---|---|---|
| Per single block | items ≤ 2 (schema) | yes |
| Per assistant turn | ui_block events ≤ 2 | yes |
| Per chat session | ui_block events ≤ 20 (defensive ceiling) | reserved — Phase 2 |
Cross-block action-target dedup runs inside the per-turn state:
two recommendations pointing at the same /wallet/deposit
collapse to the first one. Slash dedup uses verb + non-numeric
tokens, so /buy 100 BTC and /buy 999 BTC collapse while
/buy 100 ETH stays distinct.
How the agent decides
Emission is decided by a post-turn recommender: after the main agent turn finishes streaming, the gateway makes one small auxiliary LLM call that reads the user question and the final assistant text, then decides whether a card is a genuinely useful next step. The decision prompt is explicit: "do not recap, only nudge; if there's no clear action, skip."
The recommender only runs when the client advertised the
minara.feature_recommendation@1 capability, the turn produced
real text, and no other ui_block was emitted during the turn.
There is no deterministic ranker / keyword fallback path. If the
model judges that no useful card applies, the turn stays
silent. The MAX_BLOCKS_PER_TURN = 2 cap still applies to every
block producer through the shared emit pipeline.
Extending with new block types (first-party)
- Add the schema under
apps/agent/src/ui-blocks/builtin/<name>.tsas a zod object. Mirror the public TS shape so the web-ui can import it. - Register it in
apps/agent/src/ui-blocks/registry.ts(side effect of theui-blocks/index.tsimport thatapp.tsalready pulls in). - Add a renderer under
apps/web-ui/src/components/chat/blocks/<Name>Card.tsxand wire it into the dispatcher (apps/web-ui/src/components/chat/blocks/registry.tsx). - The web-ui's
clientCapabilityTokens()automatically picks up the new entry, so the?capabilities=handshake covers it on next deploy.
If the block emits user-facing copy, follow the i18n rule —
keys land under chat.<feature>.* in both
apps/web-ui/src/i18n/en/common.json and
apps/web-ui/src/i18n/zh/common.json, same commit.
Phase 2 — external connectors (not yet shipped)
Phase 1 only ships the protocol contract and the first
built-in block. The agent registry has a source: "external"
slot reserved on the type. Non-minara.* blocks that arrive at
the web-ui today render as UnknownBlockCard — the dispatcher
path for external connectors is wired in Phase 2:
- A
connector.jsonmanifest with aui_blockssection. - A sandboxed iframe renderer for
_meta.outputTemplateblocks. - A
postMessagebridge with a whitelist of host actions (navigate,runSlash,openModal). - A
@minara/connector-sdknpm package with helpers to declare a block type.
External blocks coming through the iframe path will not be trusted with direct DOM or app state — every action goes through the same allow-listed gateway routes as built-ins.
Comparison with adjacent protocols
| Concern | MCP | ChatGPT Apps SDK | UBP v1 |
|---|---|---|---|
| Transport | JSON-RPC over stdio / SSE | MCP + UI templates | SSE event inside the chat stream |
| Identifier | URI (scheme://host/path) | tool name + _meta.outputTemplate | <ns>.<type>@<major> |
| Capability negotiation | initialize handshake | client declares supported templates | ?capabilities= + start.supported_blocks |
| Schema | JSON Schema | JSON Schema | zod schemas live on the agent side; shared wire types live in @minara/types |
| External extension | MCP server | connector manifest | connector.json (Phase 2) |
| Render isolation | client-defined | sandboxed iframe + postMessage | built-in React components ‖ sandboxed iframe (Phase 2) |
UBP intentionally stays "inline event on an existing stream" rather than a separate transport. That's the right fit for the "card alongside the answer" use case, and the structure is ready to add an external-connector path without changing the wire format.
Telemetry
Click telemetry is posted to POST /v1/telemetry/block-action
with { blockId, itemId, action }. The endpoint logs and
returns 204; analytics pipelines attach to the structured log
line.
Failure is silent — the user-facing action runs regardless of telemetry success.
Key files
| Path | What it does |
|---|---|
packages/types/src/ui-blocks.ts | Wire-format types + capability token parse/format |
packages/gateway-client/src/sse.ts | AgentEvent discriminated union with the ui_block variant |
apps/agent/src/ui-blocks/registry.ts | Agent-side block schemas + emit-time validation |
apps/agent/src/ui-blocks/builtin/feature-recommendation.ts | The built-in block schema |
apps/agent/src/ui-blocks/emit.ts | Capability check + cap + dedup pipeline |
apps/agent/src/ui-blocks/feature-recommender.ts | Post-turn recommendation decision + emit |
apps/web-ui/src/components/chat/blocks/ | Web-ui dispatcher, cards, action invokers |