MINARA

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:

  1. 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.
  2. 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.outputTemplate hint 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@2

Each 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
}
  • route opens 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.
  • slash pre-fills the chat composer with a slash command. Never auto-submits. The argument body excludes all C0 controls and DEL.
  • modal opens a registered modal by ID. The current allow-list is deposit and withdraw; adding a new modal means bumping the enum AND wiring the opener in the web-ui.
  • external opens a URL in a new tab. The agent schema only enforces https://; the renderer also checks the URL against a client-side host allow-list (currently minara.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:

LayerCapEnforced today
Per single blockitems ≤ 2 (schema)yes
Per assistant turnui_block events ≤ 2yes
Per chat sessionui_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)

  1. Add the schema under apps/agent/src/ui-blocks/builtin/<name>.ts as a zod object. Mirror the public TS shape so the web-ui can import it.
  2. Register it in apps/agent/src/ui-blocks/registry.ts (side effect of the ui-blocks/index.ts import that app.ts already pulls in).
  3. Add a renderer under apps/web-ui/src/components/chat/blocks/<Name>Card.tsx and wire it into the dispatcher (apps/web-ui/src/components/chat/blocks/registry.tsx).
  4. 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.json manifest with a ui_blocks section.
  • A sandboxed iframe renderer for _meta.outputTemplate blocks.
  • A postMessage bridge with a whitelist of host actions (navigate, runSlash, openModal).
  • A @minara/connector-sdk npm 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

ConcernMCPChatGPT Apps SDKUBP v1
TransportJSON-RPC over stdio / SSEMCP + UI templatesSSE event inside the chat stream
IdentifierURI (scheme://host/path)tool name + _meta.outputTemplate<ns>.<type>@<major>
Capability negotiationinitialize handshakeclient declares supported templates?capabilities= + start.supported_blocks
SchemaJSON SchemaJSON Schemazod schemas live on the agent side; shared wire types live in @minara/types
External extensionMCP serverconnector manifestconnector.json (Phase 2)
Render isolationclient-definedsandboxed iframe + postMessagebuilt-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

PathWhat it does
packages/types/src/ui-blocks.tsWire-format types + capability token parse/format
packages/gateway-client/src/sse.tsAgentEvent discriminated union with the ui_block variant
apps/agent/src/ui-blocks/registry.tsAgent-side block schemas + emit-time validation
apps/agent/src/ui-blocks/builtin/feature-recommendation.tsThe built-in block schema
apps/agent/src/ui-blocks/emit.tsCapability check + cap + dedup pipeline
apps/agent/src/ui-blocks/feature-recommender.tsPost-turn recommendation decision + emit
apps/web-ui/src/components/chat/blocks/Web-ui dispatcher, cards, action invokers

On this page