← Back to blog

Hermes Agents Render Live Inline UI in Chat Via Plugin ::directives

hermespluginsgenerative-uidesktopsdk
Hermes Agents Render Live Inline UI in Chat Via Plugin ::directives

Until now the Hermes Agent transcript was text. A plugin could be built by the agent, but there was no way for that plugin to surface a component inside the conversation itself. PR #88024, merged August 17, 2026, changes that: a plugin registers a named directive, the model emits it as its own paragraph, and a live component renders inline in the assistant message.

The mechanism is a two-token syntax. A plugin claims a directive name through the SDK contribution registry. When the model writes the directive as a standalone paragraph, the desktop client resolves the name against that registry. If a plugin claimed it, the plugin's component paints in place of the plain text:

::preview{file="dashboard.html"}

The author, OutThisLife, positions the feature as the addressed counterpart to artifact promotion:

This is the deliberate counterpart to artifact promotion: artifacts are heuristic (substantial fences get promoted whether or not the model asked), directives are addressed (nothing renders unless a plugin claimed the name).

Artifacts promote based on content shape; directives require an explicit claim. Attributes are untrusted key="value" strings, and plugins validate their own fields.

Parsing rules

The parser is strict about what counts as a directive. It requires a whole-paragraph, lowercase directive - lookalikes stay prose.

What the model wrote What renders
::preview{file="report.html"} alone on a line The content, live and interactive in a sandboxed inline frame
::task{id="BB-12"} with a plugin claiming task That plugin's card
::task{...} with no plugin claiming task The plain paragraph, unchanged
use std::vector here, ::Name, mid-prose ::x Prose
A claimed directive whose render throws Inline error chip in its own boundary; the message stays alive

The sandbox

Core ships ::preview as the reference consumer. The referenced file renders live in a srcdoc iframe with an opaque origin and allow-scripts only - interactive, but with no reach into the app, its storage, or the bridge. Rendering is gated on turn settle, so a mid-write file never paints as garbage. Non-HTML targets and remote gateways fall back to the classic preview card.

The frame is built to be invisible. An injected measurer reports content size over token-tagged postMessage - height tracks live, intrinsic width adopts once, so a fixed-size widget shrink-wraps flush left while a fluid page stays column-wide. A theme prelude hands the document the app's resolved tokens under stable names (--foreground, --muted-foreground, --accent, --border, --card), plus the app font, zero default padding, and a transparent background. Reference HTML written against those variables renders native in any theme; a page with its own design overrides the prelude and keeps it.

Because transcript.directives resolves through the existing contribution registry, hot-loading a plugin.js upgrades already-rendered paragraphs in place. The SDK exports (TRANSCRIPT_DIRECTIVE_AREA, types) make the area reachable from a runtime plugin with a plain ctx.register:

ctx.register({
  id: 'task-card',
  area: TRANSCRIPT_DIRECTIVE_AREA,
  data: {
    name: 'task',
    render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
  }
})

The agent then writes ::task{id="BB-12"} and the card renders where that sentence would have been.

A desktop platform hint teaches the model both the syntax and the design default - inline widgets are transparent, token-colored, flush left, with no page chrome. The hint is a static string, so the system prompt stays byte-stable.

Durable storage

The same PR fixes a storage problem for plugins. Plugins persisting state were writing into their install tree (<hermes home>/plugins/<name>/), the directory that hermes plugins update git-pulls into and hermes plugins remove deletes. User data parked there dies with the code that wrote it.

plugins/plugin_storage.py adds a sanctioned home. plugin_data_dir(name) returns <hermes home>/plugin-data/<name>/, profile-aware and validated against traversal. plugin_db(name) returns WAL-mode SQLite at <data dir>/data.db. Secrets stay out of this convention - credential reads keep going through the secret-scope/.env path. The in-tree hermes-achievements plugin converts, with legacy files migrating on first read.

The change is +1,097 / -18 lines across 17 files. The main component, inline-preview-directive.tsx, is 338 lines. Test coverage is 17 parser-contract cases (whole-paragraph only, bounded attribute scan, std:: lookalikes rejected, claim resolution, unclaimed fallback, boundary containment) plus 11 plugin-storage cases (data root outside the install tree, hostile names rejected, WAL SQLite in the data dir).

Termagotchi
_

Ryan Underdown

Autodidact. Rarely listens to advice.

Follow on X @catamarammed or GitHub @underdown