diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index 829b230a5b..6b24735d44 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -927,6 +927,10 @@ PLATFORM_HINTS = {
"video play inline, and other files arrive as download links. You can "
"also include image URLs in markdown format  and they "
"render inline as photos. "
+ "To show a workspace file as a live inline preview card (an HTML page "
+ "you built, a report), put ::preview{file=\"path/to/file.html\"} alone "
+ "on its own line — desktop plugins can register more ::name{...} "
+ "directives like it. "
"When the user asks to add, enable, or authorize an MCP server (or a "
"task clearly needs one that is missing), use the setup_mcp tool if "
"it is available — it shows an inline consent card right in the chat; "
diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx
index 88b8ae7513..c0b76919e8 100644
--- a/apps/desktop/src/app/contrib/controller.tsx
+++ b/apps/desktop/src/app/contrib/controller.tsx
@@ -6,6 +6,7 @@ import { SessionDraftTitle } from '@/app/chat/session-draft-title'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution, paletteToggle } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
+import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { IdleMount } from '@/components/idle-mount'
import { $layoutEditMode, toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { allPaneIds, group, groupLeafIds, split } from '@/components/pane-shell/tree/model'
@@ -40,6 +41,7 @@ import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { NEW_SESSION_TITLE, sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
import { Download, FileText, LayoutDashboard, PanelBottom, Terminal, Upload, Zap } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
+import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
import { setYoloEnabled } from '@/lib/yolo-session'
import { pruneComposerPopoutZones } from '@/store/composer-popout'
import {
@@ -274,6 +276,18 @@ registry.registerMany([
run: () => void discoverRuntimePlugins()
} satisfies PaletteContribution
},
+ // The core `::preview{file="…"}` transcript directive — the model (or a
+ // skill) addresses the existing preview card deliberately instead of
+ // relying on artifact/fence heuristics. Also the reference consumer for
+ // the `transcript.directives` area plugins register into.
+ {
+ id: 'transcript.preview',
+ area: TRANSCRIPT_DIRECTIVE_AREA,
+ data: {
+ name: 'preview',
+ render: ({ attrs }) => (attrs.file ? : null)
+ } satisfies TranscriptDirectiveContribution
+ },
{
id: 'layout.reset',
area: PALETTE_AREA,
diff --git a/apps/desktop/src/components/assistant-ui/markdown-text.tsx b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
index 2158e54662..b2e6ba0c2b 100644
--- a/apps/desktop/src/components/assistant-ui/markdown-text.tsx
+++ b/apps/desktop/src/components/assistant-ui/markdown-text.tsx
@@ -38,6 +38,7 @@ import { cn } from '@/lib/utils'
import { ArtifactCard } from './artifact-card'
import { SessionRefLink } from './directive-text'
import { detectEmbed, extractAlert, MarkdownAlert, RichCodeBlock, UrlEmbed } from './embeds'
+import { paragraphPlainText, TranscriptDirectiveLeaf, useIsClaimedDirective } from './transcript-directive'
// Math rendering plugin (KaTeX). Configured once at module scope — the
// plugin is stateless beyond its internal cache so re-creating per-render
@@ -462,6 +463,36 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
)
}
+/**
+ * Paragraph override. Almost always a plain `
` — but a paragraph that is
+ * exactly one `::name{...}` directive claimed by a plugin renders as that
+ * plugin's transcript component instead (`transcript.directives` area). The
+ * claim check subscribes to the registry, so hot-loading a plugin upgrades
+ * already-rendered directives in place; unclaimed directives stay prose.
+ */
+function MarkdownParagraph({
+ children,
+ className,
+ streaming,
+ ...props
+}: ComponentProps<'p'> & { streaming?: boolean }) {
+ const plain = paragraphPlainText(children)
+ const claimed = useIsClaimedDirective(plain)
+
+ if (claimed && plain !== null) {
+ return
+ }
+
+ return (
+ // Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
+ // must out-specify Tailwind Typography's `prose` margins — so no
+ // `my-*` here on purpose.
+
+ {children}
+
+ )
+}
+
function MarkdownTextSurface({
containerClassName,
containerProps,
@@ -493,12 +524,7 @@ function MarkdownTextSurface({
h4: ({ className, ...props }: ComponentProps<'h4'>) => (
),
- p: ({ className, ...props }: ComponentProps<'p'>) => (
- // Vertical rhythm is owned by styles.css (`--paragraph-gap`), which
- // must out-specify Tailwind Typography's `prose` margins — so no
- // `my-*` here on purpose.
-
- ),
+ p: (props: ComponentProps<'p'>) => ,
a: MarkdownLink,
// Inline code must not vote when an ancestor resolves `dir="auto"`
// (HTML's algorithm skips descendants that carry their own dir),
diff --git a/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx b/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx
new file mode 100644
index 0000000000..8e0afd9230
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/transcript-directive.test.tsx
@@ -0,0 +1,79 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { registry } from '@/contrib/registry'
+import { TRANSCRIPT_DIRECTIVE_AREA, type TranscriptDirectiveContribution } from '@/lib/transcript-directives'
+
+import { paragraphPlainText, TranscriptDirectiveLeaf } from './transcript-directive'
+
+describe('paragraphPlainText', () => {
+ it('passes through a plain string', () => {
+ expect(paragraphPlainText('::tasks')).toBe('::tasks')
+ })
+
+ it('joins an all-string child array (streamed text chunks)', () => {
+ expect(paragraphPlainText(['::preview{file=', '"a.html"}'])).toBe('::preview{file="a.html"}')
+ })
+
+ it('disqualifies paragraphs with element children', () => {
+ expect(paragraphPlainText(['::tasks ', bold])).toBeNull()
+ expect(paragraphPlainText(null)).toBeNull()
+ expect(paragraphPlainText([])).toBeNull()
+ })
+})
+
+describe('TranscriptDirectiveLeaf', () => {
+ afterEach(cleanup)
+
+ const contribution = (over?: Partial) =>
+ registry.register({
+ id: 'test:demo',
+ area: TRANSCRIPT_DIRECTIVE_AREA,
+ source: 'plugin:test',
+ data: {
+ name: 'demo',
+ render: ({ attrs }) => {attrs.label ?? 'demo'}
,
+ ...over
+ } satisfies TranscriptDirectiveContribution
+ })
+
+ it('renders the registered component for a claimed directive', () => {
+ const dispose = contribution()
+
+ try {
+ render()
+ expect(screen.getByTestId('demo-card').textContent).toBe('hi')
+ } finally {
+ dispose()
+ }
+ })
+
+ it('renders nothing for an unclaimed directive', () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('renders nothing for plain prose', () => {
+ const { container } = render()
+
+ expect(container.firstChild).toBeNull()
+ })
+
+ it('contains a throwing plugin render to its own boundary', () => {
+ const dispose = contribution({
+ render: () => {
+ throw new Error('plugin bug')
+ }
+ })
+
+ try {
+ render()
+ // The chip fallback renders the contribution id, not a dead subtree.
+ expect(screen.getByRole('button')).toBeTruthy()
+ } finally {
+ dispose()
+ }
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/transcript-directive.tsx b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx
new file mode 100644
index 0000000000..b3904515a5
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/transcript-directive.tsx
@@ -0,0 +1,70 @@
+import type { FC, ReactNode } from 'react'
+
+import { type Contribution, useContributions } from '@/contrib'
+import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
+import {
+ parseTranscriptDirective,
+ TRANSCRIPT_DIRECTIVE_AREA,
+ type TranscriptDirectiveContribution
+} from '@/lib/transcript-directives'
+
+/**
+ * The transcript's directive slot. Given a paragraph's raw text, renders the
+ * registered plugin component when the whole paragraph is a claimed
+ * `::name{...}` directive; returns null otherwise so the caller keeps its
+ * plain `` — an unclaimed directive is just prose.
+ *
+ * Resolution is registry-backed (`transcript.directives`), so hot-loading a
+ * plugin upgrades already-rendered paragraphs in place, exactly like every
+ * other contribution area.
+ */
+
+/** Extract the paragraph's text when it is text-only — directives never carry
+ * inline markup, so any non-string child disqualifies the paragraph. */
+export function paragraphPlainText(children: ReactNode): string | null {
+ if (typeof children === 'string') {
+ return children
+ }
+
+ if (Array.isArray(children) && children.length > 0 && children.every(child => typeof child === 'string')) {
+ return children.join('')
+ }
+
+ return null
+}
+
+/** The contribution claiming `name`, if any. First registration wins. */
+function claimFor(contributions: readonly Contribution[], name: string) {
+ return contributions.find(c => (c.data as TranscriptDirectiveContribution | undefined)?.name === name)
+}
+
+export const TranscriptDirectiveLeaf: FC<{ text: string; streaming?: boolean }> = ({ text, streaming }) => {
+ const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
+ const parsed = parseTranscriptDirective(text)
+ const match = parsed ? claimFor(contributions, parsed.name) : undefined
+ const contribution = match?.data as TranscriptDirectiveContribution | undefined
+
+ if (!parsed || !match || !contribution?.render) {
+ return null
+ }
+
+ return (
+
+
+ contribution.render({ attrs: parsed.attrs, source: parsed.source, streaming: streaming ?? false })
+ }
+ />
+
+ )
+}
+
+/** True when the paragraph text will resolve to a registered directive —
+ * callers that must decide `
` vs slot before rendering use this with the
+ * same registry snapshot the leaf reads. */
+export function useIsClaimedDirective(text: string | null): boolean {
+ const contributions = useContributions(TRANSCRIPT_DIRECTIVE_AREA)
+ const parsed = text === null ? null : parseTranscriptDirective(text)
+
+ return parsed !== null && claimFor(contributions, parsed.name) !== undefined
+}
diff --git a/apps/desktop/src/lib/transcript-directives.test.ts b/apps/desktop/src/lib/transcript-directives.test.ts
new file mode 100644
index 0000000000..b40f0838b6
--- /dev/null
+++ b/apps/desktop/src/lib/transcript-directives.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+
+import { parseTranscriptDirective } from './transcript-directives'
+
+describe('parseTranscriptDirective', () => {
+ it('parses a bare directive with no attributes', () => {
+ expect(parseTranscriptDirective('::tasks')).toEqual({ name: 'tasks', attrs: {}, source: '::tasks' })
+ })
+
+ it('parses double-quoted attributes', () => {
+ expect(parseTranscriptDirective('::preview{file="demo.html"}')).toEqual({
+ name: 'preview',
+ attrs: { file: 'demo.html' },
+ source: '::preview{file="demo.html"}'
+ })
+ })
+
+ it('parses multiple attributes and accepts single quotes', () => {
+ expect(parseTranscriptDirective(`::vis{file="a b.html" height='480'}`)?.attrs).toEqual({
+ file: 'a b.html',
+ height: '480'
+ })
+ })
+
+ it('lowercases attribute keys but preserves values', () => {
+ expect(parseTranscriptDirective('::vis{File="A.html"}')?.attrs).toEqual({ file: 'A.html' })
+ })
+
+ it('tolerates surrounding whitespace', () => {
+ expect(parseTranscriptDirective(' ::tasks{id="1"} ')?.name).toBe('tasks')
+ })
+
+ it('rejects prose containing a directive mid-text', () => {
+ expect(parseTranscriptDirective('see ::preview{file="x.html"} above')).toBeNull()
+ })
+
+ it('rejects multi-line paragraphs', () => {
+ expect(parseTranscriptDirective('::preview{file="x.html"}\nmore')).toBeNull()
+ })
+
+ it('rejects C++ scope-resolution lookalikes', () => {
+ expect(parseTranscriptDirective('::std')).toEqual({ name: 'std', attrs: {}, source: '::std' })
+ expect(parseTranscriptDirective('std::vector')).toBeNull()
+ expect(parseTranscriptDirective('::Vector')).toBeNull()
+ })
+
+ it('rejects unquoted attribute values', () => {
+ expect(parseTranscriptDirective('::preview{file=demo.html}')?.attrs).toEqual({})
+ })
+
+ it('bounds pathological input instead of scanning it', () => {
+ expect(parseTranscriptDirective(`::x{${'a="b" '.repeat(400)}}`)).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/lib/transcript-directives.ts b/apps/desktop/src/lib/transcript-directives.ts
new file mode 100644
index 0000000000..d0629f4b8a
--- /dev/null
+++ b/apps/desktop/src/lib/transcript-directives.ts
@@ -0,0 +1,82 @@
+import type { ReactNode } from 'react'
+
+/**
+ * TRANSCRIPT DIRECTIVES — the transcript as a contribution area.
+ *
+ * A plugin registers a named directive; the model addresses it by emitting a
+ * paragraph of the form `::name{key="value"}` and that leaf renders as the
+ * plugin's component, inline in the assistant message. 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).
+ *
+ * The parse is deliberately narrow — a directive must be the entire
+ * paragraph, so it can never hijack mid-prose text, and an unclaimed or
+ * malformed directive falls back to the plain paragraph it always was.
+ * Attributes are untrusted model output: plugins validate their own fields.
+ */
+
+export const TRANSCRIPT_DIRECTIVE_AREA = 'transcript.directives'
+
+/** Props handed to a directive contribution's `render`. */
+export interface TranscriptDirectiveProps {
+ /** Parsed, untrusted attributes (e.g. `{ file: 'demo.html' }`). */
+ attrs: Readonly>
+ /** Original directive source text (diagnostics / fallback rendering). */
+ source: string
+ /** True while the surrounding message is still streaming. */
+ streaming: boolean
+}
+
+/** Payload of a `transcript.directives` contribution's `data`. */
+export interface TranscriptDirectiveContribution {
+ /** The name the model addresses: `::{...}`. Lowercase, `[a-z0-9-]`,
+ * unique across plugins — first registration wins on collision. */
+ name: string
+ /** Renders the directive leaf. Mounted inside the contribution error
+ * boundary, so a throw degrades to an inline error, not a dead message. */
+ render: (props: TranscriptDirectiveProps) => ReactNode
+}
+
+export interface ParsedTranscriptDirective {
+ name: string
+ attrs: Record
+ source: string
+}
+
+// The whole paragraph, nothing else on the line: `::name` or `::name{...}`.
+// Length caps bound the attr scan on adversarial input.
+const DIRECTIVE_RE = /^::([a-z][a-z0-9-]{0,63})(?:\{([^{}]{0,1024})\})?$/
+
+// `key="value"` pairs; single quotes accepted for model sloppiness.
+const ATTR_RE = /([a-z][\w-]{0,63})=(?:"([^"]*)"|'([^']*)')/gi
+
+/**
+ * Parse a paragraph as a transcript directive. Returns null unless the ENTIRE
+ * trimmed text is one directive — prose containing `::` stays prose.
+ * Pure and synchronous — safe to call during render.
+ */
+export function parseTranscriptDirective(text: string): ParsedTranscriptDirective | null {
+ const trimmed = text.trim()
+
+ // Cheap reject before the regex: directives are short single lines.
+ if (!trimmed.startsWith('::') || trimmed.length > 1200 || trimmed.includes('\n')) {
+ return null
+ }
+
+ const match = DIRECTIVE_RE.exec(trimmed)
+
+ if (!match) {
+ return null
+ }
+
+ const attrs: Record = {}
+
+ if (match[2]) {
+ for (const pair of match[2].matchAll(ATTR_RE)) {
+ attrs[pair[1].toLowerCase()] = pair[2] ?? pair[3] ?? ''
+ }
+ }
+
+ return { name: match[1], attrs, source: trimmed }
+}
diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts
index af75b6a7b3..82f60bfba9 100644
--- a/apps/desktop/src/sdk/index.ts
+++ b/apps/desktop/src/sdk/index.ts
@@ -457,6 +457,13 @@ export {
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
+/** The transcript as a contribution area: register a named `::directive{...}`
+ * and the model can render your component inline in assistant messages. */
+export {
+ TRANSCRIPT_DIRECTIVE_AREA,
+ type TranscriptDirectiveContribution,
+ type TranscriptDirectiveProps
+} from '@/lib/transcript-directives'
/** THE full per-toolset config panel core Settings renders — provider picker,
* env vars / API keys, model catalog picker, and post-setup runners. Route-
* decoupled (the "manage keys" deep link is a no-op outside the router); pass
diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md
index 04cfbd3a53..305c2ce727 100644
--- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md
+++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md
@@ -93,6 +93,14 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
`ctx.register({ id: 'nav', area: SIDEBAR_NAV_AREA, data: { path: '/my-page', label: 'My Page', codicon: 'project' } })`
(renders below Artifacts, lights up at the route) — and/or a
`PALETTE_AREA` command calling `host.navigate('/my-page')`.
+- TRANSCRIPT directives: register `area: TRANSCRIPT_DIRECTIVE_AREA` with
+ `data: { name: 'task', render: ({ attrs, streaming }) => jsx(...) }` and
+ the assistant can render your component inline in a chat message by
+ emitting `::task{id="BB-12"}` alone on its own line. Attrs are untrusted
+ `key="value"` strings — validate them. Unclaimed/malformed directives fall
+ back to plain text; core's own `::preview{file="…"}` is the reference.
+ After registering one, TELL the model it exists (a bundled skill or the
+ user's instructions) — it won't discover the name on its own.
- `ctx.storage.get/set/remove` — persistence namespaced to your plugin.
- `ctx.os` — the curated OS door, attributed to your plugin:
`ctx.os.notify({ title, body?, silent? })` posts a native OS notification.
diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md
index 06fd4baa1b..03331b6af9 100644
--- a/website/docs/developer-guide/desktop-plugin-sdk.md
+++ b/website/docs/developer-guide/desktop-plugin-sdk.md
@@ -349,6 +349,42 @@ ctx.register({ id: 'noir', area: THEMES_AREA, data: myDesktopTheme })
attachment source, or transform a draft before it is sent (`ComposerMiddleware`
with a `handler(draft) => draft | null`).
+### Transcript directives — inline components the model addresses
+
+`TRANSCRIPT_DIRECTIVE_AREA` makes the transcript itself a contribution area.
+Register a named directive and the agent can render your component inline in
+an assistant message by emitting a paragraph of the form `::name{key="value"}`:
+
+```javascript
+import { TRANSCRIPT_DIRECTIVE_AREA } from '@hermes/plugin-sdk'
+
+ctx.register({
+ id: 'task-card',
+ area: TRANSCRIPT_DIRECTIVE_AREA,
+ data: {
+ name: 'task', // the model writes ::task{id="BB-12"}
+ render: ({ attrs, streaming }) => jsx(TaskCard, { taskId: attrs.id, streaming })
+ }
+})
+```
+
+Rules the host enforces so the surface stays safe:
+
+- The directive must be the **entire paragraph** — `::name` mid-prose stays
+ prose, so plugin components can never hijack running text.
+- Attributes are **untrusted model output** (`key="value"` pairs, string-only).
+ Validate your own fields; render nothing on garbage rather than guessing.
+- An **unclaimed** directive (no plugin registered for the name) renders as
+ the plain paragraph it always was — nothing breaks when a plugin is off.
+- Renders are wrapped in the contribution error boundary: a throw degrades to
+ an inline error chip, never a dead message.
+- First registration wins on a name collision; namespace adventurous names
+ with your slug (`myplugin-board`, not `board`).
+
+Core ships one directive as the reference consumer: `::preview{file="…"}`
+renders the standard preview-attachment card for a workspace file. Tell the
+agent about your directive in a skill (that's how it learns to emit it).
+
### Mount-scoped chrome (`Contribute`)
`ctx.register` is for **permanent** contributions. When chrome should live and