feat(assistant): add compact chat and pet cards with single-choice replies (#6301)

* feat(assistant): add character packs and desktop pet companion

Keep assistant persona, saved images and sticker presentation together; add consent-based setup and permission-scoped pet-pack creation, with a sandboxed desktop companion.

Rebase the uncommitted prototype onto current main without quick-discovery changes or local assets. Preserve main's discovery feedback flow; remove superseded in-window companion code.

Generated-by: OpenAI Codex\nGenerated-by: Maka (GPT-6.1)

* fix(assistant): defer setup until the owner conversation is available

Do not enqueue onboarding into a recovered binding before Session Query is active. Add a recovery regression test and advance the protocol compatibility epoch for the new assistant RPCs.

Generated-by: Maka (GPT-6.1)

* feat(desktop): animate pet clicks and idle rest

* feat(pets): support idle variants and agent pack removal

* fix(pets): clarify creation guidance without adding removal

* fix(desktop): align assistant avatar with navigation icons

* fix(assistant): scope appearance onboarding session checks

* fix(assistant): restore concise chat message guidance

* fix(pets): restore format and Windows recovery closure checks

Format idle variant additions and include the pet pack and settings stores in the Windows recovery trigger's derived source closure. Keep the exact closure assertion intact.

Generated-by: Maka (OpenAI GPT-6.1)

* fix(pets): refresh generated surface inventory

Regenerate the Astryx inventory after moving the pet bridge and adding the character image dialog. Keep inventory coverage and drift checks enabled.

Generated-by: Maka (OpenAI GPT-6.1)

* feat(assistant): send character stickers directly by asset ID

* fix(assistant): make existing stickers directly discoverable and sendable

* feat(core): define declarative assistant card messages

* feat(assistant): add compact single-choice chat cards

* feat(pets): show assistant cards with synchronized single-choice actions

* fix(assistant): preserve chat bubbles around card messages

* style(test): format card integration regressions

Apply the repository formatter to the new card/notification and sticker/card regression fixtures; no runtime behavior changes.

Generated-by: Maka (GPT-6.1)

* test(desktop): assert resolved assistant reading width

Check the card turn's computed max-width instead of reading a custom-property declaration, retaining the 800px layout assertion and main's palette-token guard unchanged.

Generated-by: Maka (GPT-6.1)
diff --git a/apps/desktop/src/assistant-web/main.tsx b/apps/desktop/src/assistant-web/main.tsx
index c714b3f..9256c89 100644
--- a/apps/desktop/src/assistant-web/main.tsx
+++ b/apps/desktop/src/assistant-web/main.tsx
@@ -17,11 +17,13 @@
  * under the License.
  */
 
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import { createRoot } from 'react-dom/client';
 import { Theme } from '@astryxdesign/core/theme';
 import { makaTheme } from '../renderer/astryx-theme/maka.js';
 import { LocaleProvider } from '../../../../packages/ui/src/locale-context.js';
+import { MessageCard, MessageCardActionsContext, type MessageCardActions } from '../../../../packages/ui/src/message-card.js';
+import '../../../../packages/ui/src/message-card.css';
 import { Markdown } from '../../../../packages/ui/src/markdown.js';
 import { MessageSummary } from '../../../../packages/ui/src/message-summary.js';
 import type { WebAvatar, WebHistory, WebMessage, WebState } from '../shared/assistant-web.js';
@@ -109,6 +111,14 @@
   const today = date.toDateString() === new Date().toDateString();
   return `${today ? '今天 ' : date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' }) + ' '}${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false })}`;
 }
+function WebCard({ card, id, conversationId }: { card: NonNullable<WebMessage['card']>; id: string; conversationId: string }) {
+  const actions = useMemo<MessageCardActions>(() => ({
+    read: cardId => api('/api/card-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId, cardId }) }),
+    choose: (cardId, actionKey) => api('/api/card-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId, cardId, actionKey }) }),
+  }), [conversationId]);
+  return <MessageCardActionsContext.Provider value={actions}><MessageCard card={card} messageId={id} imageDisplay="link" /></MessageCardActionsContext.Provider>;
+}
+
 function App() {
   const [messages, setMessages] = useState<WebMessage[]>([]);
   const [pending, setPending] = useState<Pending[]>(restore);
@@ -358,9 +368,10 @@
             >
               {m.role === 'assistant' ? <AssistantAvatar avatar={state?.avatar} /> : <UserAvatar />}
               <div className="bubble">
-                <MessageSummary summary={m.summary}>
+                {(!m.card || m.summary || m.text.trim() !== m.card.title.trim()) && <MessageSummary summary={m.summary}>
                   <Markdown text={m.text} density="compact" imageDisplay="link" />
-                </MessageSummary>
+                </MessageSummary>}
+                {m.card && state && <WebCard card={m.card} id={m.id} conversationId={state.conversationId} />}
               </div>
             </article>
           </div>
diff --git a/apps/desktop/src/assistant-web/style.css b/apps/desktop/src/assistant-web/style.css
index 20a9ccd..d7839a1 100644
--- a/apps/desktop/src/assistant-web/style.css
+++ b/apps/desktop/src/assistant-web/style.css
@@ -402,3 +402,5 @@
     max-width: min(680px, calc(100% - 44px));
   }
 }
+
+.bubble:has(.maka-message-card) { width: min(388px, calc(100% - 44px)); }
diff --git a/apps/desktop/src/main/__tests__/assistant-web.test.ts b/apps/desktop/src/main/__tests__/assistant-web.test.ts
index e7336be..6c756c1 100644
--- a/apps/desktop/src/main/__tests__/assistant-web.test.ts
+++ b/apps/desktop/src/main/__tests__/assistant-web.test.ts
@@ -277,7 +277,9 @@
   await writeFile(join(assets, 'index.html'), '<h1>Maka</h1>');
   const sent: unknown[] = [];
   let closed = false;
+  const cardCalls: unknown[] = [];
   const backend: AssistantWebBackend = {
+    cardAction: async input => { cardCalls.push(input); return { selectedKey: input.actionKey ?? null }; },
     state: async () => ({
       conversationId: 'owner',
       working: true,
@@ -309,7 +311,7 @@
     req.end();
   });
   try {
-    for (const path of ['/api/history', '/api/events', '/api/avatar', '/api/messages', '/']) {
+    for (const path of ['/api/history', '/api/events', '/api/avatar', '/api/messages', '/api/card-action', '/']) {
       assert.equal(await rawRequest(`attacker.example:${server.port}`, `http://attacker.example:${server.port}`, path), 403);
       assert.equal(await rawRequest(`attacker.example:${server.port}`, undefined, path), 403);
     }
@@ -356,6 +358,14 @@
       200,
     );
     assert.deepEqual(sent, [input]);
+    const choice = { conversationId: 'owner', cardId: 'card-1', actionKey: '0:1' };
+    const chosen = await fetch(url + '/api/card-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(choice) });
+    assert.equal(chosen.status, 200);
+    assert.deepEqual(await chosen.json(), { selectedKey: '0:1' });
+    assert.deepEqual(cardCalls, [choice]);
+    const invalidChoice = await fetch(url + '/api/card-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...choice, actionKey: 'invalid' }) });
+    assert.equal(invalidChoice.status, 400);
+    assert.equal(cardCalls.length, 1);
     const proxyInput = { ...input, id: 'web-proxy-message-123456' };
     assert.equal(
       (
@@ -394,3 +404,8 @@
   }
   assert.ok(closed);
 });
+
+ test('web history preserves structured assistant cards', () => {
+ const card = { type: 'offer' as const, title: '报销', items: [{ title: '整理材料' }] };
+ assert.deepEqual(visibleWebMessage({ ...assistant('card'), card } as StoredMessage, 3)?.card, card);
+ });
diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts
index 6380177..6659adb 100644
--- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts
+++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts
@@ -22,12 +22,12 @@
 import { join, relative, resolve } from 'node:path';
 import { afterEach, describe, test } from 'node:test';
 import { fileURLToPath } from 'node:url';
-import { act, createElement, Fragment } from 'react';
+import { act, createElement, Fragment, useContext } from 'react';
 import type { StoredMessage } from '@maka/core/session';
 import type { UserQuestionResponse } from '@maka/core/user-question';
 import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary';
 import {
-  AstryxLocaleProvider, LocaleProvider, ToastProvider,
+  AstryxLocaleProvider, LocaleProvider, ToastProvider, MessageCardSendContext,
   type ComposerHandle, type ComposerInteraction, type ComposerProps, type ComposerSendMetadata, type LiveTurnBuffer, type TurnPresentation, type TurnViewModel,
 } from '@maka/ui';
 import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js';
@@ -218,8 +218,9 @@
   let staged!: ReturnType<typeof useComposerStaging>;
   let queued!: ReturnType<typeof useConversationQueue>;
   let mentions!: NonNullable<ReturnType<typeof useComposerMentionsContext>>;
+  let sendCard: ((text: string) => Promise<boolean>) | undefined;
   let shellRenders = 0;
-  function Probe() { staged = useComposerStaging(); queued = useConversationQueue(); mentions = useComposerMentionsContext()!; return null; }
+  function Probe() { sendCard = useContext(MessageCardSendContext); staged = useComposerStaging(); queued = useConversationQueue(); mentions = useComposerMentionsContext()!; return null; }
   function Composer(props: RegionProps) { region = props; return null; }
   let transcriptRenders = 0;
   function Transcript(props: TranscriptProps) { transcript = props; transcriptRenders += 1; return null; }
@@ -277,6 +278,7 @@
   })));
   return {
     root, commands, published,
+    get sendCard() { return sendCard; },
     get target() { return target; },
     get conversation() { return conversation; },
     get region() { assert.ok(region, 'the Composer slot rendered'); return region; },
@@ -1181,3 +1183,28 @@
     }
   });
 });
+
+
+test('card requests use the active conversation send path without touching the composer draft', async () => {
+  const submitted: Array<{ sessionId: string; text: string; attachments: unknown }> = [];
+  const h = harness({ ownerSessionId: 'A', services: {
+    submitMessage: async (sessionId, _placement, command) => {
+      submitted.push({ sessionId, text: command.text, attachments: command.attachmentItems });
+      return { ok: true, disposition: 'turn_started', turnId: 'card-turn', attachments: [], inlineReferences: [], skillInvocation: { loaded: [], failed: [], receipts: [] } };
+    },
+  } });
+  await act(async () => h.target.setActiveId('A'));
+  await act(async () => h.published[0]!([userTurn('earlier', 'hello')]));
+  h.region.composerRef.current = {
+    getText: () => 'unsent draft',
+    setText: () => assert.fail('card must not replace draft'),
+    clearDraft: () => assert.fail('card must not clear draft'),
+  };
+  assert.ok(h.sendCard);
+  await act(async () => assert.equal(await h.sendCard!('整理报销材料'), true));
+  assert.deepEqual(submitted, [{ sessionId: 'A', text: '整理报销材料', attachments: undefined }]);
+  assert.equal(h.region.composerRef.current?.getText?.(), 'unsent draft');
+  await act(async () => h.target.setActiveId('B'));
+  await act(async () => assert.equal(await h.sendCard!('stale card'), false));
+  assert.equal(submitted.length, 1, 'an old card must not send into a different conversation');
+});
diff --git a/apps/desktop/src/main/__tests__/pet-assistant-chat.test.ts b/apps/desktop/src/main/__tests__/pet-assistant-chat.test.ts
index 4cfabbc..8c53f49 100644
--- a/apps/desktop/src/main/__tests__/pet-assistant-chat.test.ts
+++ b/apps/desktop/src/main/__tests__/pet-assistant-chat.test.ts
@@ -24,16 +24,18 @@
 function harness() {
   let enabled = true, owner = 'assistant-owner', blocked = false;
   const calls: any[] = [];
+  const requests: any[] = [];
   const initial = {
     hostId: 'host-a',
     async request(_method: string, input: any) {
+      if (input.method.startsWith('assistant.card-')) { requests.push(input); return {value: {selectedKey: input.input.actionKey ?? null}}; }
       return {value: input.method === 'assistant.enablement' ? {enabled} : {state: {sessionId: owner}}};
     },
     async submitMessage(input: any) { calls.push(input); return {disposition: blocked ? 'blocked' : 'inbox'}; },
   };
   let current: typeof initial | undefined = initial;
   const chat = createPetAssistantChat(() => current as any);
-  return { chat, calls, disable:()=>{enabled=false;}, rebind:()=>{owner='other';},
+  return { chat, calls, requests, disable:()=>{enabled=false;}, rebind:()=>{owner='other';},
     disconnect:()=>{current=undefined;}, block:()=>{blocked=true;}, switchHost:()=>{current={...initial,hostId:'host-b'};} };
 }
 test('sends to the assistant Inbox with the original identity retained for retry', async () => {
@@ -52,3 +54,21 @@
   const h=harness();const target=await h.chat.target();h.block();
   await assert.rejects(h.chat.send({target,id:'id',text:'你好'}), /消息未送达/);
 });
+
+test('pet card actions reuse stored card selection without a second text submission', async () => {
+  const h = harness(); const target = await h.chat.target();
+  assert.deepEqual(await h.chat.cardAction({target, cardId:'card-1'}), {selectedKey:null});
+  assert.deepEqual(await h.chat.cardAction({target, cardId:'card-1',actionKey:'0:1'}), {selectedKey:'0:1'});
+  assert.deepEqual(h.requests, [
+    {method:'assistant.card-status',input:{sessionId:'assistant-owner',cardId:'card-1'}},
+    {method:'assistant.card-reply',input:{sessionId:'assistant-owner',cardId:'card-1',actionKey:'0:1'}},
+  ]);
+  assert.equal(h.calls.length,0);
+});
+test('stale pet cards cannot be sent to another assistant or Host', async () => {
+  for (const change of ['disable','rebind','disconnect','switchHost'] as const) {
+    const h=harness(); const target=await h.chat.target(); h[change]();
+    await assert.rejects(h.chat.cardAction({target,cardId:'card-1',actionKey:'0:0'}));
+    assert.equal(h.requests.length,0);
+  }
+});
diff --git a/apps/desktop/src/main/__tests__/pet-card.test.ts b/apps/desktop/src/main/__tests__/pet-card.test.ts
new file mode 100644
index 0000000..fcf77a0
--- /dev/null
+++ b/apps/desktop/src/main/__tests__/pet-card.test.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { parseHTML } from 'linkedom';
+import { PetCardView } from '../../overlay/pet-card.js';
+import { PetMessageQueue, petMessageDuration } from '../../shared/pet-desktop.js';
+import type { AssistantCard } from '@maka/core/assistant-card';
+const card: AssistantCard = { type:'offer', title:'报销材料', items:[{title:'整理材料', facts:[{label:'已找到',value:'3 份'}],actions:[{kind:'reply',label:'帮我整理',value:'帮我整理报销材料'},{kind:'reply',label:'先不用',value:'先不用'}]}] };
+const settle = () => new Promise<void>(resolve => setImmediate(resolve));
+function setup(call: (v:any)=>Promise<{selectedKey:string|null}>) {
+  const {document}=parseHTML('<div id="card"></div>');
+  const element=document.getElementById('card')!;
+  const view=new PetCardView(element, {cardAction:call,openLink:async()=>{}});
+  view.show('host-owner','card-id',card);
+  return {element,view};
+}
+test('pet shows card outside prose, locks every option and ignores repeat clicks', async () => {
+  let selected:string|null=null, calls=0;
+  const h=setup(async v=> { if(v.actionKey) {calls++;selected=v.actionKey;} return {selectedKey:selected}; });
+  try {
+    assert.ok([...h.element.querySelectorAll('button')].every(b=>b.disabled));
+    await settle();
+    assert.match(h.element.textContent!, /3 份/);
+    const b=h.element.querySelector('button')!; b.click();b.click();
+    await settle();
+    assert.equal(calls,1);assert.equal(selected,'0:0');
+    assert.ok([...h.element.querySelectorAll('button')].every(b=>b.disabled));
+    assert.match(h.element.textContent!,/已发送/);
+    h.view.show();h.view.show('host-owner','card-id',card);await settle();
+    assert.ok([...h.element.querySelectorAll('button')].every(b=>b.disabled));
+  } finally {h.view.show();}
+});
+test('failed submission is retryable; changing card ignores late results', async () => {
+  let fail=true;
+  const h=setup(async v=> {if(v.actionKey&&fail) throw Error('offline');return {selectedKey:v.actionKey??null};});
+  try {
+    await settle();h.element.querySelector('button')!.click();await settle();
+    assert.match(h.element.textContent!,/可以重试/);assert.equal(h.element.querySelector('button')!.disabled,false);
+    fail=false;h.element.querySelector('button')!.click();h.view.show();await settle();
+    assert.equal(h.element.hidden,true);assert.equal(h.element.textContent,'');
+  } finally {h.view.show();}
+});
+test('unsafe cards are refused; card content survives queue and adds reading time', () => {
+  const h=setup(async()=>({selectedKey:null}));
+  try {
+    h.view.show('owner','bad',{...card,items:[{title:'bad',actions:[{kind:'link',label:'run',value:'javascript:alert(1)'}]}]});
+    assert.equal(h.element.hidden,true);
+    const q=new PetMessageQueue();const m={id:'1',text:'你好',card};q.push('owner',[m]);
+    assert.deepEqual(q.current()?.card,card);
+    assert.ok(petMessageDuration(m)>petMessageDuration({id:'1',text:'你好'}));
+  } finally {h.view.show();}
+});
+
+
+test('switching pet cards ignores a late choice and status from the previous card', async () => {
+  let resolveChoice!: (value: { selectedKey: string | null }) => void;
+  let resolveStatus!: (value: { selectedKey: string | null }) => void;
+  let reads = 0;
+  const h = setup(async input => {
+    if (input.cardId === 'next-card') return { selectedKey: null };
+    if (input.actionKey) return new Promise(resolve => { resolveChoice = resolve; });
+    if (++reads === 1) return { selectedKey: null };
+    return new Promise(resolve => { resolveStatus = resolve; });
+  });
+  try {
+    await settle();
+    h.element.querySelector('button')!.click();
+    assert.equal(h.view.pending, true);
+    h.view.show('host-owner', 'next-card', { ...card, title: 'Next card' });
+    await settle();
+    resolveChoice({ selectedKey: '0:0' });
+    await settle();
+    assert.match(h.element.textContent!, /Next card/);
+    assert.doesNotMatch(h.element.textContent!, /已发送/);
+    assert.ok([...h.element.querySelectorAll('button')].every(button => !button.disabled));
+    h.view.show('host-owner', 'card-id', card);
+    h.view.show('host-owner', 'next-card', { ...card, title: 'Next card' });
+    resolveStatus({ selectedKey: '0:0' });
+    await settle();
+    assert.match(h.element.textContent!, /Next card/);
+    assert.ok([...h.element.querySelectorAll('button')].every(button => !button.disabled));
+  } finally { h.view.show(); }
+});
diff --git a/apps/desktop/src/main/__tests__/pet-desktop-window.test.ts b/apps/desktop/src/main/__tests__/pet-desktop-window.test.ts
index f30e03e..d62b34d 100644
--- a/apps/desktop/src/main/__tests__/pet-desktop-window.test.ts
+++ b/apps/desktop/src/main/__tests__/pet-desktop-window.test.ts
@@ -86,7 +86,7 @@
   let focuses=0;
   const submitted: any[] = [];
   const controller = module.exports.createPetDesktop({ workspaceRoot:'/tmp/fixture',settingsStore:store as any,
-    assistantChat: { target: async () => 'assistant-target', send: async input => { submitted.push(input); } },
+    assistantChat: { cardAction: async input => { submitted.push(input); return {selectedKey: input.actionKey ?? null}; }, target: async () => 'assistant-target', send: async input => { submitted.push(input); } },
     mainWindowController:{isMainRenderer:(sender:any)=>sender===main.webContents,browserWindow:()=>main,
       send:(...args:any[])=>main.webContents.send(...args),focus:()=>{focuses++;}} as any });
   await controller.refresh();
@@ -237,3 +237,20 @@
     h.app.emit('before-quit', { defaultPrevented: false });
   }
 });
+
+test('pet card IPC preserves cards, validates identity and rejects malformed actions', async () => {
+  const h=await harness('test'); const sender=h.windows[0]!.webContents;
+  await h.handlers.get('pet-desktop:chat-target')!({sender});
+  const card={type:'offer',title:'整理材料',items:[{title:'报销',actions:[{kind:'reply',label:'帮我整理',value:'整理材料'}]}]};
+  h.ipc.emit('pet-desktop:publish',{sender:h.main.webContents},{kind:'assistant',surface:{activity:'idle',label:'ready',sessionId:'assistant-target'}});
+  h.ipc.emit('pet-desktop:publish',{sender:h.main.webContents},{kind:'messages',sessionId:'assistant-target',messages:[{id:'m',text:'需要吗',card}]});
+  assert.deepEqual(h.windows[0]!.webContents.sent.at(-1)?.[1]?.messages[0].card,card);
+  const call=h.handlers.get('pet-desktop:card-action')!;
+  await call({sender},{target:'assistant-target',cardId:'m',actionKey:'0:0'});
+  assert.equal(h.submitted[0].actionKey,'0:0');
+  await assert.rejects(call({sender:{}},{target:'assistant-target',cardId:'m',actionKey:'0:0'}));
+  await assert.rejects(call({sender},{target:'assistant-target',cardId:'m',actionKey:'bad'}));
+  const n=h.windows[0]!.webContents.sent.length;
+  h.ipc.emit('pet-desktop:publish',{sender:h.main.webContents},{kind:'messages',sessionId:'assistant-target',messages:[{id:'m',text:'unsafe',card:{...card,type:'script'}}]});
+  assert.equal(h.windows[0]!.webContents.sent.length,n);
+});
diff --git a/apps/desktop/src/main/assistant-im-bridge.ts b/apps/desktop/src/main/assistant-im-bridge.ts
index 618e9a4..4f42236 100644
--- a/apps/desktop/src/main/assistant-im-bridge.ts
+++ b/apps/desktop/src/main/assistant-im-bridge.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import { assistantMessageText } from '@maka/core/assistant-card';
+
 import { createHash, randomUUID } from 'node:crypto';
 import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
 import { dirname } from 'node:path';
@@ -255,7 +257,7 @@
         if (message.sequence <= r.through) continue;
         if (message.role === 'assistant') {
           if (r.delivery?.id !== message.id) {
-            r.delivery = { id: message.id, text: message.text, summary: message.summary, attempts: 0, done: false };
+            r.delivery = { id: message.id, text: assistantMessageText(message), summary: message.summary, attempts: 0, done: false };
             await save(r);
           }
           await deliver(r, r.delivery);
diff --git a/apps/desktop/src/main/assistant-web-backend.ts b/apps/desktop/src/main/assistant-web-backend.ts
index e1e2fad..62d7f2b 100644
--- a/apps/desktop/src/main/assistant-web-backend.ts
+++ b/apps/desktop/src/main/assistant-web-backend.ts
@@ -40,6 +40,7 @@
   avatarImage(conversationId: string, id: string): Promise<{ bytes: Buffer; mimeType: string }>;
   history(query: URLSearchParams): Promise<WebHistory>;
   send(input: { conversationId: string; id: string; text: string }): Promise<void>;
+  cardAction(input: { conversationId: string; cardId: string; actionKey?: string }): Promise<{ selectedKey: string | null }>;
   close(): Promise<void>;
 }
 type Client = Pick<
@@ -80,6 +81,7 @@
       role: 'assistant',
       text: message.text,
       summary: message.summary,
+      card: message.card,
       at: message.ts,
     };
   return undefined;
@@ -290,6 +292,16 @@
           : null,
       };
     },
+    async cardAction(input) {
+      const t = await connect();
+      if (t.conversationId !== input.conversationId)
+        throw new AssistantWebError(409, '助手会话已变更,请刷新');
+      const result = await t.client.request('personal-assistant.call', {
+        method: input.actionKey === undefined ? 'assistant.card-status' : 'assistant.card-reply',
+        input: { sessionId: t.sessionId, cardId: input.cardId, ...(input.actionKey === undefined ? {} : { actionKey: input.actionKey }) },
+      });
+      return result.value as { selectedKey: string | null };
+    },
     async send(input) {
       const t = await connect();
       if (t.conversationId !== input.conversationId)
diff --git a/apps/desktop/src/main/assistant-web-server.ts b/apps/desktop/src/main/assistant-web-server.ts
index 61e054c..6a536de 100644
--- a/apps/desktop/src/main/assistant-web-server.ts
+++ b/apps/desktop/src/main/assistant-web-server.ts
@@ -99,7 +99,7 @@
       }
       if (url.pathname === '/api/history' && req.method === 'GET')
         return json(res, 200, await options.backend.history(url.searchParams));
-      if (url.pathname === '/api/messages' && req.method === 'POST') {
+      if ((url.pathname === '/api/messages' || url.pathname === '/api/card-action') && req.method === 'POST') {
         if (!req.headers['content-type']?.startsWith('application/json'))
           throw new AssistantWebError(415, '需要 JSON 请求');
         const chunks: Buffer[] = [];
@@ -109,12 +109,22 @@
           if (bytes > 128 * 1024) throw new AssistantWebError(413, '消息太长,请分段发送');
           chunks.push(Buffer.from(chunk));
         }
-        let input: { id?: unknown; text?: unknown; conversationId?: unknown };
+        let input: { id?: unknown; text?: unknown; conversationId?: unknown; cardId?: unknown; actionKey?: unknown };
         try {
           input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
         } catch {
           throw new AssistantWebError(400, '无效的消息');
         }
+        if (url.pathname === '/api/card-action') {
+          if (!input || typeof input.conversationId !== 'string' || input.conversationId.length > 512
+            || typeof input.cardId !== 'string' || !input.cardId || input.cardId.length > 512
+            || (input.actionKey !== undefined && (typeof input.actionKey !== 'string' || !/^\d+:\d+$/.test(input.actionKey))))
+            throw new AssistantWebError(400, '无效的卡片选项');
+          const result = await options.backend.cardAction(input as { conversationId: string; cardId: string; actionKey?: string });
+          json(res, 200, result);
+          if (input.actionKey !== undefined) void poll();
+          return;
+        }
         if (
           !input ||
           typeof input.id !== 'string' ||
diff --git a/apps/desktop/src/main/pet-assistant-chat.ts b/apps/desktop/src/main/pet-assistant-chat.ts
index cbc326a..af60fba 100644
--- a/apps/desktop/src/main/pet-assistant-chat.ts
+++ b/apps/desktop/src/main/pet-assistant-chat.ts
@@ -37,6 +37,17 @@
   };
   return {
     async target() { return target(client()); },
+    async cardAction(input: { target: string; cardId: string; actionKey?: string }) {
+      const current = client();
+      if (await target(current) !== input.target || resolveClient() !== current)
+        throw Error('助手或 Host 已变化,请重新打开消息');
+      const [, sessionId] = JSON.parse(input.target);
+      const result = await current.request('personal-assistant.call', {
+        method: input.actionKey === undefined ? 'assistant.card-status' : 'assistant.card-reply',
+        input: { sessionId, cardId: input.cardId, ...(input.actionKey === undefined ? {} : { actionKey: input.actionKey }) },
+      });
+      return result.value as { selectedKey: string | null };
+    },
     async send(input: { target: string; id: string; text: string }) {
       const current = client();
       if (await target(current) !== input.target || resolveClient() !== current)
diff --git a/apps/desktop/src/main/pet-desktop.ts b/apps/desktop/src/main/pet-desktop.ts
index 3b81bee..ccc7188 100644
--- a/apps/desktop/src/main/pet-desktop.ts
+++ b/apps/desktop/src/main/pet-desktop.ts
@@ -17,7 +17,8 @@
  * under the License.
  */
 
-import { app, ipcMain, screen, type BrowserWindow } from "electron";
+import { isAssistantCard, isCardWebUrl } from "@maka/core/assistant-card";
+import { app, ipcMain, screen, shell, type BrowserWindow } from "electron";
 import { join } from "node:path";
 import { createPetPackStore } from "@maka/storage/pet-pack-store";
 import type { SettingsStore } from "@maka/storage/settings-store";
@@ -35,6 +36,7 @@
   workspaceRoot: string;
   assistantChat?: {
     target(): Promise<string>;
+    cardAction(input: { target: string; cardId: string; actionKey?: string }): Promise<{ selectedKey: string | null }>;
     send(input: { target: string; id: string; text: string }): Promise<void>;
   };
   settingsStore: SettingsStore;
@@ -259,7 +261,8 @@
           typeof m.id === "string" &&
           typeof m.text === "string" &&
           m.text.length <= 100000 &&
-          (m.summary === undefined || typeof m.summary === "string"),
+          (m.summary === undefined || typeof m.summary === "string") &&
+          (m.card === undefined || isAssistantCard(m.card)),
       )
     ) {
       window?.webContents.send("pet-desktop:messages", value);
@@ -384,6 +387,21 @@
     if (!input.assistantChat) throw Error("助手聊天暂不可用");
     await input.assistantChat.send({ target: v.target, id: v.id, text: v.text });
   });
+  ipcMain.handle("pet-desktop:card-action", async (event, value: unknown) => {
+    assertPet(event.sender);
+    const v = value as { target?: unknown; cardId?: unknown; actionKey?: unknown } | null;
+    if (!v || typeof v.target !== 'string' || v.target.length > 1000 ||
+        typeof v.cardId !== 'string' || !v.cardId || v.cardId.length > 512 ||
+        (v.actionKey !== undefined && (typeof v.actionKey !== 'string' || !/^\d+:\d+$/.test(v.actionKey) || v.actionKey.length > 32)))
+      throw Error('卡片参数不正确');
+    if (!input.assistantChat || mode !== 'assistant') throw Error('助手聊天暂不可用');
+    return input.assistantChat.cardAction({ target: v.target, cardId: v.cardId, ...(v.actionKey === undefined ? {} : { actionKey: v.actionKey }) });
+  });
+  ipcMain.handle("pet-desktop:open-link", async (event, url: unknown) => {
+    assertPet(event.sender);
+    if (!isCardWebUrl(url)) throw Error('链接不受支持');
+    await shell.openExternal(url);
+  });
   screen.on("display-metrics-changed", clamp);
   screen.on("display-removed", clamp);
   const releaseForQuit = () => {
diff --git a/apps/desktop/src/overlay/pet-card.ts b/apps/desktop/src/overlay/pet-card.ts
new file mode 100644
index 0000000..b9f12ad
--- /dev/null
+++ b/apps/desktop/src/overlay/pet-card.ts
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { isAssistantCard, type AssistantCard } from '@maka/core/assistant-card';
+
+export interface PetCardPort {
+  cardAction(input: { target: string; cardId: string; actionKey?: string }): Promise<{ selectedKey: string | null }>;
+  openLink(url: string): Promise<void>;
+}
+
+/** Only the visible card is observed. Selection remains owned by the Host. */
+export class PetCardView {
+  private identity = '';
+  private dispose: (() => void) | undefined;
+  pending = false;
+  constructor(private element: HTMLElement, private port: PetCardPort) {}
+  show(target?: string, cardId?: string, card?: AssistantCard) {
+    const valid = target && cardId && isAssistantCard(card);
+    const identity = valid ? JSON.stringify([target, cardId, card]) : '';
+    if (identity === this.identity) return;
+    this.dispose?.();
+    this.identity = identity;
+    this.pending = false;
+    this.element.replaceChildren();
+    this.element.hidden = !valid;
+    if (!valid) return;
+    let live = true, loaded = false, selected: string | null = null;
+    let timer: ReturnType<typeof setTimeout> | undefined;
+    let error = '';
+    const document = this.element.ownerDocument;
+    const add = (parent: HTMLElement, tag: string, text: string, className = '') => {
+      const node = document.createElement(tag);
+      node.textContent = text; node.className = className; parent.append(node); return node;
+    };
+    const draw = () => {
+      this.element.replaceChildren();
+      add(this.element, 'h2', card.title, 'pet-card-title');
+      const body = add(this.element, 'div', '', 'pet-card-body');
+      body.tabIndex = 0;
+      if (card.description) add(body, 'p', card.description, 'pet-card-description');
+      const actions = add(this.element, 'div', '', 'pet-card-actions');
+      actions.hidden = !card.items.some(item => item.actions?.length);
+      card.items.forEach((item, i) => {
+        const section = add(body, 'section', '', 'pet-card-item');
+        if (item.title !== card.title || card.items.length > 1) add(section, 'h3', item.title);
+        if (item.description) add(section, 'p', item.description);
+        for (const fact of item.facts ?? []) {
+          const row = add(section, 'div', '', 'pet-card-fact');
+          add(row, 'span', fact.label); add(row, 'span', fact.value);
+        }
+        item.actions?.forEach((action, j) => {
+          const key = `${i}:${j}`;
+          const button = add(actions, 'button', selected === key ? '已发送' : action.label) as HTMLButtonElement;
+          button.type = 'button';
+          button.title = action.label;
+          button.setAttribute('aria-label', selected === key ? `${action.label} · 已发送` : action.label);
+          button.disabled = !loaded || this.pending || selected !== null;
+          button.onclick = async () => {
+            if (!live || !loaded || this.pending || selected !== null) return;
+            this.pending = true; error = ''; draw();
+            try {
+              if (action.kind === 'link') await this.port.openLink(action.value);
+              else {
+                const result = await this.port.cardAction({ target, cardId, actionKey: key });
+                if (live) selected = result.selectedKey;
+              }
+            } catch { if (live) error = '未确认发送成功,可以重试'; }
+            finally { if (live) { this.pending = false; draw(); } }
+          };
+        });
+      });
+      if (error) add(this.element, 'p', error, 'pet-card-error').setAttribute('role', 'status');
+    };
+    const refresh = async () => {
+      try {
+        const result = await this.port.cardAction({ target, cardId });
+        if (!live) return;
+        const changed = !loaded || (result.selectedKey !== null && result.selectedKey !== selected);
+        loaded = true;
+        if (result.selectedKey !== null) selected = result.selectedKey;
+        if (changed && !this.pending) { error = ''; draw(); }
+      } catch {
+        if (live && !loaded) { error = '暂时无法读取选项状态,正在重试'; draw(); }
+      } finally {
+        if (live && selected === null) timer = setTimeout(refresh, 3000);
+      }
+    };
+    this.dispose = () => { live = false; clearTimeout(timer); };
+    draw(); void refresh();
+  }
+}
diff --git a/apps/desktop/src/overlay/pet-preload.ts b/apps/desktop/src/overlay/pet-preload.ts
index 5e3fc1d..019eeb3 100644
--- a/apps/desktop/src/overlay/pet-preload.ts
+++ b/apps/desktop/src/overlay/pet-preload.ts
@@ -44,6 +44,8 @@
   move: (dx: number, dy: number) => ipcRenderer.send('pet-desktop:move', {dx,dy}),
   composeOpen: (): Promise<void> => ipcRenderer.invoke("pet-desktop:compose-open"),
   composeFocus: (handler: () => void) => ipcRenderer.on("pet-desktop:compose-focus", handler),
+  cardAction: (input: { target: string; cardId: string; actionKey?: string }): Promise<{ selectedKey: string | null }> => ipcRenderer.invoke('pet-desktop:card-action', input),
+  openLink: (url: string): Promise<void> => ipcRenderer.invoke('pet-desktop:open-link', url),
   chatTarget: (): Promise<string> => ipcRenderer.invoke("pet-desktop:chat-target"),
   send: (input: {target: string; id: string; text: string}): Promise<void> => ipcRenderer.invoke("pet-desktop:chat-send", input),
   state: (handler: (state: PetDesktopState) => void) =>
diff --git a/apps/desktop/src/overlay/pet.css b/apps/desktop/src/overlay/pet.css
index b0f80ff..6305311 100644
--- a/apps/desktop/src/overlay/pet.css
+++ b/apps/desktop/src/overlay/pet.css
@@ -124,3 +124,25 @@
 body[data-pet-animation="poke"] canvas { animation: none; }
 @keyframes idle-sway { 25% { transform: rotate(-2deg) translateY(-2px); } 75% { transform: rotate(2deg) translateY(-2px); } }
 @keyframes idle-stretch { 35% { transform: scaleY(1.04) scaleX(.98) translateY(-2px); } 65% { transform: scaleY(.99) scaleX(1.015); } }
+
+/* Bounded cards shrink to their content; choices stay outside the scroll body. */
+#bubble[data-card="true"] { width: 308px; max-width: 308px; height: auto; max-height: 286px; display: flex; flex-direction: column; }
+#bubble[data-card="true"] #content-row { flex-shrink: 0; }
+#bubble[data-card="true"] #content { max-height: 22px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12px; color: var(--muted); }
+#bubble[data-card="true"] #pagination, #bubble[data-card="true"] .bubble-heading { flex-shrink: 0; }
+#card { display: flex; flex-direction: column; flex: 0 1 auto; min-height: 0; padding-top: 7px; user-select: text; overflow-wrap: anywhere; }
+.pet-card-title { flex-shrink: 0; max-height: 44px; overflow: auto; font-size: 15px; line-height: 1.45; margin: 0 0 7px; font-weight: 650; }
+.pet-card-body { flex: 0 1 auto; min-height: 0; overflow: auto; scrollbar-width: thin; overscroll-behavior: contain; padding-right: 4px; }
+.pet-card-description, .pet-card-item p { font-size: 12px; line-height: 1.5; margin: 3px 0; color: var(--muted); white-space: pre-wrap; }
+.pet-card-item + .pet-card-item { margin-top: 8px; }
+.pet-card-item h3 { font-size: 12px; margin: 0; font-weight: 600; line-height: 1.5; }
+.pet-card-fact { display: flex; gap: 8px; font-size: 11px; line-height: 1.5; margin: 3px 0; }
+.pet-card-fact span:first-child { color: var(--muted); flex-shrink: 0; }
+.pet-card-actions { flex-shrink: 0; max-height: 78px; overflow: auto; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; padding-top: 8px; border-top: 1px solid var(--edge); scrollbar-width: thin; }
+.pet-card-actions button { flex: 1 1 70px; min-width: 0; padding: 7px 8px; border: 1px solid var(--edge); border-radius: 8px; background: color-mix(in srgb, var(--accent) 9%, transparent); font-size: 12px; line-height: 1.35; }
+.pet-card-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 20%, transparent); }
+.pet-card-actions button:disabled { color: var(--muted); background: color-mix(in srgb, var(--muted) 7%, transparent); opacity: .65; }
+.pet-card-error { flex-shrink: 0; max-height: 30px; overflow: auto; color: #b87536; font-size: 11px; margin: 4px 0 0; }
+
+.pet-card-item h3 { display: inline; margin-right: 6px; }
+.pet-card-item p { display: inline; }
diff --git a/apps/desktop/src/overlay/pet.html b/apps/desktop/src/overlay/pet.html
index 8419955..aae5cf6 100644
--- a/apps/desktop/src/overlay/pet.html
+++ b/apps/desktop/src/overlay/pet.html
@@ -31,6 +31,7 @@
       <span class="task-stat waiting"><span class="status-dot" aria-hidden="true"></span><strong id="waiting-count">0</strong><span>等待</span></span>
     </div>
     <div id="content-row"><span id="task-state" hidden>运行中</span><button id="content" title="打开完整对话"></button></div>
+    <div id="card" hidden></div>
     <nav id="pagination" hidden><span id="read-hint">双击切换状态</span><span class="pager"><button id="previous" aria-label="上一条"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 7-5 5 5 5"/></svg></button><span id="count"></span><button id="next" aria-label="下一条"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 7 5 5-5 5"/></svg></button></span></nav>
   </section>
   <button id="pet" title="拖动调整位置 · 双击打开 Maka" aria-label="宠物,双击打开 Maka"><canvas></canvas><span id="notification" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></svg><span id="unread-count"></span></span></button>
diff --git a/apps/desktop/src/overlay/pet.ts b/apps/desktop/src/overlay/pet.ts
index c7c3a58..d9a876f 100644
--- a/apps/desktop/src/overlay/pet.ts
+++ b/apps/desktop/src/overlay/pet.ts
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import { PetCardView, type PetCardPort } from "./pet-card.js";
 import { resolvePetAnimationState } from "@maka/core/pet";
 import { PetMessageQueue, PetBubbleView, PetAnimationBehavior, selectPetIdleAnimation, petMessageText, petMessageDuration, PetBackgroundNotice, shouldClosePetComposer } from "../shared/pet-desktop.js";
 import type {
@@ -30,7 +31,7 @@
 
 declare global {
   interface Window {
-    pet: {
+    pet: PetCardPort & {
       action(action: string): void;
       hitRegions(regions: { x: number; y: number; width: number; height: number }[]): void;
       layout(height: number, petTop: number, below: boolean): void;
@@ -52,6 +53,7 @@
 const bubble = document.querySelector<HTMLElement>("#bubble")!;
 const caption = document.querySelector<HTMLElement>("#caption")!;
 const content = document.querySelector<HTMLElement>("#content")!;
+const cards = new PetCardView(document.querySelector<HTMLElement>("#card")!, window.pet);
 const count = document.querySelector<HTMLElement>("#count")!;
 const pagination = document.querySelector<HTMLElement>("#pagination")!;
 const mode = document.querySelector<HTMLButtonElement>("#mode")!;
@@ -80,9 +82,11 @@
   pet.setAttribute('aria-label', `宠物,${petAction}`);
   document.querySelector<HTMLElement>("#notification")!.hidden = view.unreadCount === 0;
   document.querySelector<HTMLElement>("#unread-count")!.textContent = view.unreadLabel;
-  if (bubble.hidden) return;
+  if (bubble.hidden) { cards.show(); return; }
   const manual = assistant && view.mode === 'messages';
   const message = assistant ? view.message() : undefined;
+  cards.show(surface.sessionId, message?.id, message?.card);
+  bubble.dataset.card = String(!!message?.card);
   const messageIndex = manual ? view.index : queue.index;
   const messageCount = manual ? queue.history.length : queue.messages.length;
   const notice = assistant && !manual ? background.read(surface, now) : undefined;
@@ -251,7 +255,7 @@
   } finally { flipping = false; }
 }
 bubble.addEventListener('dblclick', event => {
-  if ((event.target as Element).closest('#pagination')) return;
+  if ((event.target as Element).closest('#pagination, #card button, #card a')) return;
   event.preventDefault();
   void flipBubble(() => {
     if (current?.mode === 'assistant') view.cycle();
@@ -335,7 +339,7 @@
   if (!composer.hidden && shouldClosePetComposer(draft.value, now-lastInput, sending, composingText)) closeComposer();
   if (now > statusUntil) sendStatus.hidden = true;
   renderBubble(now);
-  if (view.mode === "auto" && queue.current() && !paused && composer.hidden && !flipping) {
+  if (view.mode === "auto" && queue.current() && !paused && !cards.pending && composer.hidden && !flipping) {
     elapsed += dt;
     const duration = petMessageDuration(queue.current()!);
     if (elapsed > duration) {
diff --git a/apps/desktop/src/renderer/application/contracts/pet-desktop.ts b/apps/desktop/src/renderer/application/contracts/pet-desktop.ts
index ef4add8..25e2860 100644
--- a/apps/desktop/src/renderer/application/contracts/pet-desktop.ts
+++ b/apps/desktop/src/renderer/application/contracts/pet-desktop.ts
@@ -19,6 +19,8 @@
 
 import type { PetActivityState, PetPackManifestV1 } from "@maka/core/pet";
 
+import type { AssistantCard } from "@maka/core/assistant-card";
+
 export type PetMode = "session" | "assistant";
 export interface PetSurface {
   activity: PetActivityState;
@@ -34,6 +36,7 @@
   id: string;
   text: string;
   summary?: string;
+  card?: AssistantCard;
 }
 export interface PetDesktopState {
   mode: PetMode;
diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts
index f1610cf..97f2cef 100644
--- a/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts
+++ b/apps/desktop/src/renderer/features/conversation/controller/use-composer-submission.ts
@@ -259,6 +259,12 @@
     clearPendingTurnActions: (sessionId) => turnActionRegistry.clearForSession(sessionId),
   }), [revision, turn, turnActionRegistry.clearForSession]);
   const reader = useMemo(() => ({
+    // Card clicks are independent chat requests: never consume the editor's draft,
+    // attachments or edit-and-resend staging. The normal send path owns admission.
+    sendCardReply: async (text: string) => {
+      if (!ownerSessionId || sharedSessionActive || revisionDraft || !commands.isSessionSelected(ownerSessionId)) return false;
+      return chat.send(text, [], { targetSessionId: ownerSessionId, waitForHostAdmission: true });
+    },
     onSend,
     newTaskSendPending,
     revisionDraft,
@@ -268,7 +274,7 @@
     respondToUserForm: chat.respondToUserForm,
     stop,
     composerResumeAction: resume.composerResumeAction,
-  }), [chat, newTaskSendPending, onSend, resume.composerResumeAction, revision, revisionDraft, stop]);
+  }), [chat, ownerSessionId, sharedSessionActive, newTaskSendPending, onSend, resume.composerResumeAction, revision, revisionDraft, stop]);
   // What the transcript and the chrome readers take, apart from the Composer's
   // submission state, so a send or an edit draft does not repaint the transcript.
   const turnReader = useMemo(() => ({
diff --git a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx
index 1fdbaed..4e5c312 100644
--- a/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx
+++ b/apps/desktop/src/renderer/features/conversation/ui/composer-submission-provider.tsx
@@ -17,6 +17,7 @@
  * under the License.
  */
 
+import { MessageCardSendContext } from '@maka/ui';
 import { useLayoutEffect, useMemo, type ReactNode } from 'react';
 import { CatalogRowWatch } from '../../../application/contracts/session-catalog/catalog-row-watch.js';
 import { useSessionCatalogController } from '../../../application/contracts/session-catalog/session-catalog-state.js';
@@ -84,7 +85,9 @@
           enabled={props.recoveryEnabled && !submission.reader.revisionDraft}
           restoreContext={props.staging.restoreContext} />
         <ConversationNavigationContext.Provider value={navigation}>
-          {props.children}
+          <MessageCardSendContext.Provider value={props.ownerSessionId && !props.sharedSessionActive && !submission.reader.revisionDraft ? submission.reader.sendCardReply : undefined}>
+            {props.children}
+          </MessageCardSendContext.Provider>
         </ConversationNavigationContext.Provider>
       </ComposerTurnContext.Provider>
     </ComposerSubmissionContext.Provider>
diff --git a/apps/desktop/src/renderer/features/personal-assistant/card-actions.tsx b/apps/desktop/src/renderer/features/personal-assistant/card-actions.tsx
new file mode 100644
index 0000000..722a82b
--- /dev/null
+++ b/apps/desktop/src/renderer/features/personal-assistant/card-actions.tsx
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { useMemo, type ReactNode } from 'react';
+import { MessageCardActionsContext, type MessageCardActions } from '@maka/ui';
+import { usePersonalAssistantServices } from './services.js';
+import { originalHost } from './navigation.js';
+
+export function PersonalAssistantCardActions({ sessionId, children }: { sessionId: string; children: ReactNode }) {
+  const services = usePersonalAssistantServices();
+  const actions = useMemo<MessageCardActions>(() => {
+    const call = async (cardId: string, actionKey?: string) => {
+      const target = originalHost(sessionId, (await services.hostSnapshot()).entries);
+      return services.call(actionKey === undefined ? 'assistant.card-status' : 'assistant.card-reply', {
+        sessionId: target.sessionId, cardId, ...(actionKey === undefined ? {} : { actionKey }),
+      }, target.host);
+    };
+    return { read: id => call(id), choose: (id, key) => call(id, key) };
+  }, [sessionId, services]);
+  return <MessageCardActionsContext.Provider value={actions}>{children}</MessageCardActionsContext.Provider>;
+}
diff --git a/apps/desktop/src/renderer/features/personal-assistant/personal-assistant.css b/apps/desktop/src/renderer/features/personal-assistant/personal-assistant.css
index 6aaff8e..5555c3b 100644
--- a/apps/desktop/src/renderer/features/personal-assistant/personal-assistant.css
+++ b/apps/desktop/src/renderer/features/personal-assistant/personal-assistant.css
@@ -30,7 +30,6 @@
 
 /* A conversational surface on the existing transcript and composer. */
 .personal-assistant-chat {
-  --maka-reading-measure: 720px;
   --maka-turn-gap: 10px;
   --chat-user-bg: color-mix(in srgb, var(--accent) 20%, var(--background));
 }
@@ -48,6 +47,8 @@
   max-width: 84%;
   background: var(--muted);
 }
+/* Keep the normal bubble around both prose and its card, including its tail. */
+.personal-assistant-chat .maka-chat-message-bubble-assistant:has(.maka-message-card) { width: min(384px, 100%); }
 .personal-assistant-chat .maka-assistant-answer-content { max-width: 100%; }
 .personal-assistant-chat .maka-turn[data-turn-id^="assistant-output"] .maka-turn-statusbar[data-turn-status="completed"] { display: none; }
 .personal-assistant-chat .maka-turn-footer { margin-top: 0; }
diff --git a/apps/desktop/src/renderer/features/personal-assistant/stories.ts b/apps/desktop/src/renderer/features/personal-assistant/stories.ts
new file mode 100644
index 0000000..6989040
--- /dev/null
+++ b/apps/desktop/src/renderer/features/personal-assistant/stories.ts
@@ -0,0 +1,20 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { PersonalAssistantChatLayout, PersonalAssistantComposer } from './chat-presentation.js';
diff --git a/apps/desktop/src/renderer/features/personal-assistant/task-layout.tsx b/apps/desktop/src/renderer/features/personal-assistant/task-layout.tsx
index 6fd593c..d59bf44 100644
--- a/apps/desktop/src/renderer/features/personal-assistant/task-layout.tsx
+++ b/apps/desktop/src/renderer/features/personal-assistant/task-layout.tsx
@@ -17,10 +17,11 @@
  * under the License.
  */
 
+import { PersonalAssistantCardActions } from './card-actions.js';
 import type { ComponentProps } from 'react';
 import { PersonalAssistantChatLayout } from './chat-presentation.js';
 import { PersonalAssistantTaskFloat } from './task-float.js';
 export function PersonalAssistantTaskLayout({ session, ...props }: ComponentProps<typeof PersonalAssistantChatLayout> & { session?: { id: string; labels?: readonly string[] } }) {
   const layout = <PersonalAssistantChatLayout session={session} {...props} />;
-  return session?.labels?.includes('personal-assistant') ? <div className="personal-assistant-chat-frame" hidden={props.hidden}>{layout}{!props.hidden && <PersonalAssistantTaskFloat key={session.id} sessionId={session.id} />}</div> : layout;
+  return session?.labels?.includes('personal-assistant') ? <PersonalAssistantCardActions key={session.id} sessionId={session.id}><div className="personal-assistant-chat-frame" hidden={props.hidden}>{layout}{!props.hidden && <PersonalAssistantTaskFloat key={session.id} sessionId={session.id} />}</div></PersonalAssistantCardActions> : layout;
 }
diff --git a/apps/desktop/src/renderer/platform/desktop/pet-presentation.ts b/apps/desktop/src/renderer/platform/desktop/pet-presentation.ts
index 46e8802..32dbe91 100644
--- a/apps/desktop/src/renderer/platform/desktop/pet-presentation.ts
+++ b/apps/desktop/src/renderer/platform/desktop/pet-presentation.ts
@@ -57,6 +57,7 @@
                   id: m.id,
                   text: m.text,
                   summary: m.summary,
+                  card: m.card,
                 })),
               });
             initialized = true;
diff --git a/apps/desktop/src/shared/assistant-web.ts b/apps/desktop/src/shared/assistant-web.ts
index cd2dab1..0e89824 100644
--- a/apps/desktop/src/shared/assistant-web.ts
+++ b/apps/desktop/src/shared/assistant-web.ts
@@ -17,12 +17,15 @@
  * under the License.
  */
 
+import type { AssistantCard } from '@maka/core/assistant-card';
+
 export interface WebMessage {
   id: string;
   sequence: number;
   role: 'user' | 'assistant';
   text: string;
   summary?: string;
+  card?: AssistantCard;
   at: number;
 }
 export interface WebAvatar {
diff --git a/apps/desktop/src/shared/pet-desktop.ts b/apps/desktop/src/shared/pet-desktop.ts
index d9f1099..28fc057 100644
--- a/apps/desktop/src/shared/pet-desktop.ts
+++ b/apps/desktop/src/shared/pet-desktop.ts
@@ -127,7 +127,11 @@
  * Keep the existing 4–20s bounds; hover/focus pauses longer reading.
  */
 export function petMessageDuration(message: PetMessage): number {
-  return Math.max(4000, Math.min(20000, 2000 + Array.from(petMessageText(message).replace(/\s/g, "")).length * 220));
+  const card = message.card;
+  const visible = [petMessageText(message), card?.title, card?.description,
+    ...(card?.items ?? []).flatMap(item => [item.title, item.description,
+      ...(item.facts ?? []).map(f => `${f.label} ${f.value}`), ...(item.actions ?? []).map(a => a.label)])].filter(Boolean).join(' ');
+  return Math.max(4000, Math.min(20000, 2000 + Array.from(visible.replace(/\s/g, "")).length * 220));
 }
 
 export function shouldClosePetComposer(text: string, idleMs: number, sending: boolean, composing: boolean) {
diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx
index ba21234..1560379 100644
--- a/apps/desktop/stories/app-shell.stories.tsx
+++ b/apps/desktop/stories/app-shell.stories.tsx
@@ -32,6 +32,7 @@
 } from '@maka/core/events';
 import {
   ChatSurfaceLayout,
+  MessageCardSendContext,
   ChatView,
   applyLiveTurnBufferEvent,
   reconcileLiveTurnBuffer,
@@ -44,6 +45,7 @@
   useUiLocale,
 } from '@maka/ui';
 import type { ChatModelChoice, ComposerHandle, SessionViewMode, TurnViewModel, LiveTurnBuffer } from '@maka/ui';
+import { PersonalAssistantChatLayout, PersonalAssistantComposer } from '../src/renderer/features/personal-assistant/stories';
 import { SessionRail, type SessionRailStoryProps } from '../../../packages/ui/stories/session-rail-harness.js';
 import { deriveMessageQueueProjection } from '../src/renderer/application/contracts/message-queue-projection';
 import { retractQueuedEntryToDraft, withQueuedSteeringTransients } from '../src/renderer/application/contracts/transient-message-projection';
@@ -507,10 +509,11 @@
             // padding, so a story without it measures its own box.
             (<div className="maka-detail-with-artifacts">
               <div className="mainColumn" inert={props.switchingSession || undefined}>
-              <ChatSurfaceLayout
+              <PersonalAssistantChatLayout
+                session={active}
                 hidden={props.chatHidden}
                 composer={
-                  <Composer
+                  <PersonalAssistantComposer
                     {...baseComposerProps}
                     activeSession={active}
                     modelSwitchHasHistory={messages.some(
@@ -528,7 +531,7 @@
                   {...props.chat}
                   revisionNavigation={revisionNavigation}
                 />
-              </ChatSurfaceLayout>
+              </PersonalAssistantChatLayout>
               </div>
             </div>)
           )}
@@ -5099,3 +5102,56 @@
     }
   },
 };
+
+
+function AssistantCardsDemo() {
+  const [accepted, setAccepted] = useState<StoredMessage[]>([]);
+  const composer = useRef<ComposerHandle>(null);
+  const messages: StoredMessage[] = [
+    user('cards-user', 'cards', 3, '今天事情有点多,也想看看周末能去哪儿走走。'),
+    { type: 'assistant', id: 'cards-offer', turnId: 'assistant-output:offer', ts: NOW - 1000, modelId: 'bot', text: '材料我先核对了一遍,你可以选一下怎么处理。', card: {
+      type: 'offer', title: '报销这边,我可以帮你收尾', items: [{ title: '报销这边,我可以帮你收尾', description: '材料找到了 3 份,还缺用车凭证。要我先把已有材料整理好吗?', actions: [
+        { kind: 'reply', label: '帮我整理', value: '请帮我整理这次出差的报销材料,先对应已有车票、酒店发票和行程,列出缺失材料。餐费暂不计入。' },
+        { kind: 'reply', label: '缺什么?', value: '这次报销还缺哪些材料?' },
+        { kind: 'reply', label: '先不用', value: '报销先不用帮我整理,我自己处理。' },
+      ] }],
+    } },
+    { type: 'assistant', id: 'cards-recommendation', turnId: 'assistant-output:recommendation', ts: NOW, modelId: 'bot', text: '周末,换个地方慢下来', card: {
+      type: 'recommendation', title: '周末,换个地方慢下来', items: [
+        { title: '城市影像 · 摄影展', description: '看看人与城市的日常切片,逛完还能顺路喝杯茶。', facts: [{ label: '时间', value: '周六下午' }, { label: '位置', value: '城市艺术中心' }], actions: [{ kind: 'reply', label: '看看详情', value: '帮我查一下城市影像摄影展的开放时间和预约方式。' }] },
+        { title: '河畔散步 · 顺路喝茶', description: '不用赶场,沿河走一段,再找个安静的地方坐坐。', facts: [{ label: '时间', value: '周日下午' }, { label: '步行', value: '约 40 分钟' }], actions: [{ kind: 'reply', label: '看看路线', value: '帮我安排周日下午沿河散步和喝茶的路线。' }] },
+      ],
+    } },
+  ];
+  return <MessageCardSendContext.Provider value={async text => { setAccepted(old => [...old, user(`card-reply-${old.length}`, `reply-${old.length}`, 0, text)]); return true; }}>
+    <ComposedShell sidebarCollapsed frameHeight="100vh" session={{ labels: ['personal-assistant'], name: '个人助手' }} chat={{ messages: [...messages, ...accepted] }} composer={{ ref: composer, draftKey: 'assistant-card-demo' }} />
+  </MessageCardSendContext.Provider>;
+}
+
+// Real path: personal assistant → Bot SendUser with recommendation/offer cards → click a reply to send directly through the chat action boundary.
+// The example places and itinerary are synthetic presentation fixtures.
+export const AssistantCards: Story = {
+  render: () => <AssistantCardsDemo />,
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await waitFor(() => expect(canvas.getByRole('button', { name: '帮我整理' })).toBeVisible());
+    expect(canvasElement.querySelectorAll('.maka-message-card')).toHaveLength(2);
+    const card = canvasElement.querySelector<HTMLElement>('.maka-message-card')!;
+    expect(card.getBoundingClientRect().width).toBeGreaterThan(250);
+    const bubble = card.closest<HTMLElement>('.maka-chat-message-bubble-assistant')!;
+    expect(getComputedStyle(bubble).paddingLeft).toBe('12px');
+    expect(getComputedStyle(bubble).backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
+    expect(getComputedStyle(bubble, '::before').display).not.toBe('none');
+    expect(getComputedStyle(card.closest<HTMLElement>('.maka-turn')!).maxWidth).toBe('800px');
+    const choices = [...card.querySelectorAll<HTMLButtonElement>('button')];
+    expect(choices).toHaveLength(3);
+    expect(new Set(choices.map(button => getComputedStyle(button).backgroundColor)).size).toBe(1);
+    expect(card.querySelectorAll('h3, h4')).toHaveLength(1);
+    expect(card.querySelector('img')).toBeNull();
+    const editor = canvasElement.querySelector<HTMLElement>('[contenteditable="true"]')!;
+    const draft = editor.textContent;
+    await userEvent.click(canvas.getByRole('button', { name: '帮我整理' }));
+    await waitFor(() => expect(canvas.getByText('请帮我整理这次出差的报销材料,先对应已有车票、酒店发票和行程,列出缺失材料。餐费暂不计入。')).toBeVisible());
+    expect(editor.textContent).toBe(draft);
+  },
+};
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md
index a5be197..7ebc988 100644
--- a/docs/astryx-surface-file-inventory.md
+++ b/docs/astryx-surface-file-inventory.md
@@ -6,7 +6,7 @@
 
 Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
 
-**Totals:** 347 files — blocker 0, reimplementation 0, polish 5, aligned 342.
+**Totals:** 350 files — blocker 0, reimplementation 0, polish 5, aligned 345.
 
 ## Exclusions (explicit)
 
@@ -101,6 +101,7 @@
 | `apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx` | dialog-overlay | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/personal-assistant/avatar.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
+| `apps/desktop/src/renderer/features/personal-assistant/card-actions.tsx` | other | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/personal-assistant/chat-presentation.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `apps/desktop/src/renderer/features/personal-assistant/original-reader.css` | styles | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
 | `apps/desktop/src/renderer/features/personal-assistant/original-reader.tsx` | other | Button | aligned — uses Astryx (Button) | aligned |
@@ -331,6 +332,8 @@
 | `packages/ui/src/markdown-math.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
 | `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar) | aligned |
+| `packages/ui/src/message-card.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned |
+| `packages/ui/src/message-card.tsx` | ui-composition | Button, Card, Link | aligned — uses Astryx (Button, Card, Link) | aligned |
 | `packages/ui/src/message-summary.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
 | `packages/ui/src/model-picker-internals.tsx` | ui-composition | SelectorOption | aligned — uses Astryx (SelectorOption) | aligned |
 | `packages/ui/src/model-picker.tsx` | ui-composition | Selector | aligned — uses Astryx (Selector) | aligned |
diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths
index 3cf37cf..1fb53b0 100644
--- a/docs/astryx-surface-file-inventory.paths
+++ b/docs/astryx-surface-file-inventory.paths
@@ -70,6 +70,7 @@
 apps/desktop/src/renderer/features/overlays/ui/overlays-root.tsx
 apps/desktop/src/renderer/features/overlays/ui/search-modal-host.tsx
 apps/desktop/src/renderer/features/personal-assistant/avatar.tsx
+apps/desktop/src/renderer/features/personal-assistant/card-actions.tsx
 apps/desktop/src/renderer/features/personal-assistant/chat-presentation.tsx
 apps/desktop/src/renderer/features/personal-assistant/original-reader.css
 apps/desktop/src/renderer/features/personal-assistant/original-reader.tsx
@@ -300,6 +301,8 @@
 packages/ui/src/markdown-math.tsx
 packages/ui/src/markdown.tsx
 packages/ui/src/mermaid-diagram.tsx
+packages/ui/src/message-card.css
+packages/ui/src/message-card.tsx
 packages/ui/src/message-summary.tsx
 packages/ui/src/model-picker-internals.tsx
 packages/ui/src/model-picker.tsx
diff --git a/packages/core/package.json b/packages/core/package.json
index 83b3ba5..2fe90bc 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -33,6 +33,7 @@
     "./provider-retry-countdown": "./dist/provider-retry-countdown.js",
     "./message-queue-order": "./dist/message-queue-order.js",
     "./interaction": "./dist/interaction.js",
+    "./assistant-card": "./dist/assistant-card.js",
     "./session": "./dist/session.js",
     "./turn-origin": "./dist/turn-origin.js",
     "./session-reference": "./dist/session-reference.js",
diff --git a/packages/core/src/__tests__/assistant-card.test.ts b/packages/core/src/__tests__/assistant-card.test.ts
new file mode 100644
index 0000000..f0ae548
--- /dev/null
+++ b/packages/core/src/__tests__/assistant-card.test.ts
@@ -0,0 +1,97 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+  isAssistantCard,
+  assistantMessageText,
+  assistantCardImageMarkdown,
+  type AssistantCard,
+} from '../assistant-card.js';
+import { decodeCanonicalMessage } from '../session.js';
+import { decodeRuntimeEvent } from '../runtime-event.js';
+const card: AssistantCard = {
+  type: 'offer',
+  title: '出差报销',
+  items: [
+    {
+      title: '整理提交包',
+      facts: [{ label: '还差', value: '酒店发票' }],
+      actions: [
+        { kind: 'reply', label: '帮我整理', value: '请整理这次广州出差报销的已有材料,暂不提交。' },
+      ],
+    },
+  ],
+};
+test('card survives canonical message and event decoding with legacy compatibility', () => {
+  const m = {
+    type: 'assistant',
+    id: 'a',
+    turnId: 't',
+    ts: 1,
+    text: '我可以帮你。',
+    modelId: 'bot',
+    card,
+  };
+  assert.deepEqual(decodeCanonicalMessage(JSON.parse(JSON.stringify(m))), m);
+  const event = {
+    id: 'e',
+    invocationId: 'i',
+    runId: 'r',
+    sessionId: 's',
+    turnId: 't',
+    ts: 1,
+    partial: false,
+    role: 'model',
+    author: 'agent',
+    content: { kind: 'text', text: '我可以帮你。', card },
+  };
+  assert.deepEqual(decodeRuntimeEvent(event).content, event.content);
+  assert.equal(decodeCanonicalMessage({ ...m, card: undefined }).type, 'assistant');
+});
+test('rejects executable markup fields, unsafe URLs and unbounded collections', () => {
+  for (const bad of [
+    { ...card, html: '<script>hi</script>' },
+    { ...card, items: [] },
+    { ...card, items: Array(7).fill(card.items[0]) },
+    {
+      ...card,
+      items: [
+        { title: 'bad', actions: [{ label: 'go', kind: 'link', value: 'javascript:alert(1)' }] },
+      ],
+    },
+    { ...card, items: [{ title: 'bad', image: { url: 'file:///secret', alt: 'cover' } }] },
+  ])
+    assert.equal(isAssistantCard(bad), false);
+  assert.equal(isAssistantCard(card), true);
+});
+test('text fallback includes facts and concrete replies; image source projection is separate', () => {
+  const text = assistantMessageText({ text: '我可以帮你。', card });
+  assert.match(text, /酒店发票/);
+  assert.match(text, /暂不提交/);
+  assert.equal(assistantMessageText({ text: '短消息' }), '短消息');
+  assert.equal(
+    assistantCardImageMarkdown({
+      ...card,
+      items: [{ title: '封面', image: { url: 'https://example.com/image.png', alt: '图片' } }],
+    }),
+    '![](<https://example.com/image.png>)',
+  );
+});
diff --git a/packages/core/src/assistant-card.ts b/packages/core/src/assistant-card.ts
new file mode 100644
index 0000000..fa40317
--- /dev/null
+++ b/packages/core/src/assistant-card.ts
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/** Declarative presentation only. No scripts, arbitrary styles or executable actions. */
+export interface AssistantCardAction {
+  label: string;
+  kind: 'link' | 'reply';
+  /** A web URL for link; the complete user request submitted by a reply choice. */
+  value: string;
+}
+export interface AssistantCardItem {
+  title: string;
+  description?: string;
+  image?: { url: string; alt: string };
+  facts?: { label: string; value: string }[];
+  actions?: AssistantCardAction[];
+}
+export interface AssistantCard {
+  type: 'recommendation' | 'offer' | 'delivery';
+  title: string;
+  description?: string;
+  items: AssistantCardItem[];
+}
+function record(v: unknown): v is Record<string, unknown> {
+  return !!v && typeof v === 'object' && !Array.isArray(v);
+}
+function keys(v: Record<string, unknown>, allowed: string[]) {
+  return Object.keys(v).every((k) => allowed.includes(k));
+}
+function text(v: unknown, max: number): v is string {
+  return typeof v === 'string' && v.trim().length > 0 && v.length <= max;
+}
+export function isCardWebUrl(v: unknown): v is string {
+  if (!text(v, 2048) || /[\s<>]/.test(v)) return false;
+  try {
+    const u = new URL(v);
+    return (u.protocol === 'https:' || u.protocol === 'http:') && !u.username && !u.password;
+  } catch {
+    return false;
+  }
+}
+export function isAssistantCard(v: unknown): v is AssistantCard {
+  if (
+    !record(v) ||
+    !keys(v, ['type', 'title', 'description', 'items']) ||
+    !['recommendation', 'offer', 'delivery'].includes(String(v.type)) ||
+    !text(v.title, 120) ||
+    (v.description !== undefined && !text(v.description, 600)) ||
+    !Array.isArray(v.items) ||
+    v.items.length < 1 ||
+    v.items.length > 6
+  )
+    return false;
+  return v.items.every(
+    (i) =>
+      record(i) &&
+      keys(i, ['title', 'description', 'image', 'facts', 'actions']) &&
+      text(i.title, 120) &&
+      (i.description === undefined || text(i.description, 1200)) &&
+      (i.image === undefined ||
+        (record(i.image) &&
+          keys(i.image, ['url', 'alt']) &&
+          isCardWebUrl(i.image.url) &&
+          text(i.image.alt, 200))) &&
+      (i.facts === undefined ||
+        (Array.isArray(i.facts) &&
+          i.facts.length <= 6 &&
+          i.facts.every(
+            (f) =>
+              record(f) && keys(f, ['label', 'value']) && text(f.label, 40) && text(f.value, 240),
+          ))) &&
+      (i.actions === undefined ||
+        (Array.isArray(i.actions) &&
+          i.actions.length <= 3 &&
+          i.actions.every(
+            (a) =>
+              record(a) &&
+              keys(a, ['label', 'kind', 'value']) &&
+              text(a.label, 40) &&
+              (a.kind === 'link'
+                ? isCardWebUrl(a.value)
+                : a.kind === 'reply' && text(a.value, 2000)),
+          ))),
+  );
+}
+/** Text-only channels, recall and model replay retain all useful card content. */
+export function assistantCardText(card: AssistantCard): string {
+  return [
+    card.title,
+    card.description,
+    ...card.items.flatMap((item) => [
+      item.title,
+      item.description,
+      ...(item.facts ?? []).map((f) => `${f.label}:${f.value}`),
+      ...(item.actions ?? []).map((a) => `${a.label}:${a.value}`),
+    ]),
+  ]
+    .filter(Boolean)
+    .join('\n');
+}
+export function assistantMessageText(message: { text: string; card?: AssistantCard }): string {
+  return message.card ? `${message.text}\n\n${assistantCardText(message.card)}` : message.text;
+}
+/** For image admission only, not a grant to fetch. Remote image consent remains required. */
+export function assistantCardImageMarkdown(card?: AssistantCard): string {
+  return card?.items.flatMap((i) => (i.image ? [`![](<${i.image.url}>)`] : [])).join('\n') ?? '';
+}
diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts
index 8635c10..4de8eac 100644
--- a/packages/core/src/runtime-event.ts
+++ b/packages/core/src/runtime-event.ts
@@ -33,6 +33,8 @@
  * projection, or ledger logic lives here. Those arrive in later nodes.
  */
 
+import { isAssistantCard, type AssistantCard } from './assistant-card.js';
+
 import {
   decodeGoalEventProvenance,
   type LegacyEventWaitProvenance,
@@ -186,6 +188,7 @@
 export interface RuntimeEventTextContent extends MessageContent {
   /** Model-authored collapsed preview, independent of the full text. */
   summary?: string;
+  card?: AssistantCard;
   kind: 'text';
   /** Failed response fragment retained for display, never for model replay. */
   interrupted?: true;
@@ -752,6 +755,7 @@
   ['kind', 'text'],
   [
     'summary',
+    'card',
     'displayText',
     'interrupted',
     'origin',
@@ -1060,6 +1064,7 @@
         ...normalizeMessageContent(value.content as unknown as MessageContent),
         ...(value.content.interrupted === true ? { interrupted: true as const } : {}),
         ...(value.content.summary !== undefined ? { summary: value.content.summary } : {}),
+        ...(value.content.card !== undefined ? { card: value.content.card } : {}),
         ...(value.content.origin !== undefined
           ? { origin: decodeTurnOrigin(value.content.origin) }
           : {}),
@@ -1094,6 +1099,7 @@
       if (
         !hasExactShape(value, TEXT_CONTENT_SHAPE) ||
         (value.summary !== undefined && typeof value.summary !== 'string') ||
+        (value.card !== undefined && !isAssistantCard(value.card)) ||
         (value.origin !== undefined && !isTurnOrigin(value.origin)) ||
         (value.steering !== undefined && value.steering !== true) ||
         (value.runtimeContextName !== undefined &&
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index cec7620..23b9205 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import { isAssistantCard, type AssistantCard } from './assistant-card.js';
+
 import type { ExecutorConfiguration } from './executor-catalog.js';
 
 import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js';
@@ -883,6 +885,7 @@
 export interface AssistantMessage {
   /** Model-authored preview; text remains the complete message. */
   summary?: string;
+  card?: AssistantCard;
   type: 'assistant';
   interrupted?: true;
   id: string;
@@ -1379,7 +1382,7 @@
 );
 const ASSISTANT_MESSAGE_SHAPE = defineObjectShape<AssistantMessage>()(
   ['type', 'id', 'turnId', 'ts', 'text', 'modelId'],
-  ['thinking', 'contentOrder', 'providerOptions', 'interrupted', 'summary'],
+  ['thinking', 'contentOrder', 'providerOptions', 'interrupted', 'summary', 'card'],
 );
 const TOOL_CALL_MESSAGE_SHAPE = defineObjectShape<ToolCallMessage>()(
   ['type', 'id', 'turnId', 'ts', 'toolName', 'args'],
@@ -1671,6 +1674,7 @@
         typeof message.text === 'string' &&
         typeof message.modelId === 'string' &&
         (message.summary === undefined || typeof message.summary === 'string') &&
+        (message.card === undefined || isAssistantCard(message.card)) &&
         (message.interrupted === undefined || message.interrupted === true) &&
         (message.providerOptions === undefined || isRecord(message.providerOptions)) &&
         (message.thinking === undefined || isAssistantThinking(message.thinking)) &&
diff --git a/packages/runtime-host/src/__tests__/assistant-bot.test.ts b/packages/runtime-host/src/__tests__/assistant-bot.test.ts
index 5a1ac02..2c20b66 100644
--- a/packages/runtime-host/src/__tests__/assistant-bot.test.ts
+++ b/packages/runtime-host/src/__tests__/assistant-bot.test.ts
@@ -2098,7 +2098,15 @@
     });
     request = {
       messageId: 'direct-sticker',
-      messages: [{ text: '正文保留', summary: '摘要保留', stickerId: id }, { stickerId: id }],
+      messages: [
+        {
+          text: '正文保留',
+          summary: '摘要保留',
+          stickerId: id,
+          card: { type: 'offer', title: 'Test card', items: [{ title: 'Test item' }] },
+        },
+        { stickerId: id },
+      ],
     };
     await assert.rejects(
       invoke('SendUser', { messageId: 'empty', messages: [{}] }),
@@ -2118,6 +2126,7 @@
     assert.equal(sent.messageIds.length, 2);
     const output = f.visible.filter((m) => m.type === 'assistant');
     assert.equal(output[0].summary, '摘要保留');
+    assert.equal(output[0].card?.title, 'Test card');
     assert.equal(output[0].text, `正文保留\n\n![表情](maka://runtime/attachments/sticker-${id})`);
     assert.equal(output[1].text, `![表情](maka://runtime/attachments/sticker-${id})`);
     await invoke('AssistantCharacterPack', { action: 'remove_asset', assetId: id });
@@ -2409,3 +2418,81 @@
   await until(() => done || !!failure);
   if (failure) throw failure;
 });
+
+test('SendUser card batch validates before delivery and retries identically after restart', async (t) => {
+  const f = await fixture();
+  t.after(f.close);
+  const card = {
+    type: 'offer',
+    title: '报销材料',
+    items: [
+      {
+        title: '整理提交包',
+        facts: [{ label: '待补', value: '酒店发票' }],
+        actions: [
+          { kind: 'reply', label: '帮我整理', value: '帮我整理这次出差的报销材料,暂不提交。' },
+        ],
+      },
+    ],
+  };
+  let done = false;
+  f.runner(async ({ invoke }) => {
+    await invoke('InboxCheck');
+    await assert.rejects(
+      invoke('SendUser', {
+        messageId: 'bad-card',
+        messages: [
+          { text: '先不要发送' },
+          {
+            text: 'bad',
+            card: {
+              ...card,
+              items: [
+                {
+                  title: 'run',
+                  actions: [{ kind: 'link', label: 'run', value: 'javascript:alert(1)' }],
+                },
+              ],
+            },
+          },
+        ],
+      }),
+    );
+    assert.equal(f.visible.filter((m) => m.type === 'assistant').length, 0);
+    const input = {
+      messageId: 'card-batch',
+      messages: [{ text: '可以帮你整理。', summary: '已有材料可先整理', card }],
+    };
+    await invoke('SendUser', input);
+    await invoke('SendUser', input);
+    await assert.rejects(
+      invoke('SendUser', {
+        ...input,
+        messages: [{ ...input.messages[0], card: { ...card, title: 'changed' } }],
+      }),
+      /different batch/,
+    );
+    await invoke('Sleep');
+    done = true;
+  });
+  await f.send('card-user', '报销还没搞完');
+  await until(() => done);
+  assert.deepEqual(
+    f.visible.filter((m) => m.type === 'assistant').map((m) => m.card),
+    [card],
+  );
+  await f.restart();
+  done = false;
+  f.runner(async ({ invoke }) => {
+    await invoke('InboxCheck');
+    await invoke('SendUser', {
+      messageId: 'card-batch',
+      messages: [{ text: '可以帮你整理。', summary: '已有材料可先整理', card }],
+    });
+    await invoke('Sleep');
+    done = true;
+  });
+  await f.send('card-retry', '重试');
+  await until(() => done);
+  assert.equal(f.visible.filter((m) => m.type === 'assistant').length, 1);
+});
diff --git a/packages/runtime-host/src/__tests__/assistant-card-choice.test.ts b/packages/runtime-host/src/__tests__/assistant-card-choice.test.ts
new file mode 100644
index 0000000..7004d36
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/assistant-card-choice.test.ts
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { BotStore } from '../assistant/bot/store.js';
+
+const card = {
+  type: 'offer' as const,
+  title: '选一个',
+  items: [
+    { title: 'A', actions: [{ kind: 'reply' as const, label: 'A', value: '选择 A' }] },
+    { title: 'B', actions: [{ kind: 'reply' as const, label: 'B', value: '选择 B' }] },
+  ],
+};
+function seed(store: BotStore) {
+  store.queueVisible({
+    type: 'assistant',
+    id: 'card',
+    turnId: 'turn',
+    text: '选一个',
+    modelId: 'bot',
+    ts: 1,
+    card,
+  });
+  store.delivered('card');
+}
+test('whole-card selection survives restart and competing clients cannot enqueue a second option', () => {
+  const dir = mkdtempSync(join(tmpdir(), 'maka-card-choice-'));
+  let a = new BotStore(dir);
+  let b = new BotStore(dir);
+  try {
+    seed(a);
+    assert.equal(a.chooseCard('card', '0:0'), '0:0');
+    assert.equal(b.chooseCard('card', '1:0'), '0:0');
+    assert.equal(b.chooseCard('card', '0:0'), '0:0');
+    assert.equal(a.head(), 1);
+    assert.deepEqual(
+      a.pendingVisible().map((x) => 'text' in x && x.text),
+      ['选择 A'],
+    );
+    a.close();
+    a = new BotStore(dir);
+    assert.equal(a.cardSelection('card'), '0:0');
+    assert.equal(a.chooseCard('card', '1:0'), '0:0');
+    assert.equal(a.head(), 1);
+  } finally {
+    a.close();
+    b.close();
+    rmSync(dir, { recursive: true, force: true });
+  }
+});
+test('invalid selection and failed transaction do not consume the card or create Inbox entries', () => {
+  const dir = mkdtempSync(join(tmpdir(), 'maka-card-choice-'));
+  const store = new BotStore(dir);
+  try {
+    seed(store);
+    assert.throws(() => store.chooseCard('missing', '0:0'), /Unknown/);
+    assert.throws(() => store.chooseCard('card', '2:0'), /Invalid/);
+    store.db.exec(
+      "CREATE TRIGGER fail_choice BEFORE INSERT ON card_replies BEGIN SELECT RAISE(ABORT, 'disk failure'); END",
+    );
+    assert.throws(() => store.chooseCard('card', '0:0'), /disk failure/);
+    assert.equal(store.cardSelection('card'), null);
+    assert.equal(store.head(), 0);
+    assert.equal(store.pendingVisible().length, 0);
+    store.db.exec('DROP TRIGGER fail_choice');
+    assert.equal(store.chooseCard('card', '1:0'), '1:0');
+    assert.equal(store.head(), 1);
+  } finally {
+    store.close();
+    rmSync(dir, { recursive: true, force: true });
+  }
+});
+
+test('card choices coexist with durable notification decisions across store instances and restart', () => {
+  const dir = mkdtempSync(join(tmpdir(), 'maka-card-decisions-'));
+  let a = new BotStore(dir);
+  const b = new BotStore(dir);
+  try {
+    seed(a);
+    a.post({ id: 'discovery', type: 'discovery', source: 'test', content: 'fixture only' });
+    const decisions = [{ inboxId: 'discovery', reason: 'test omission' }];
+    a.recordNotNotified('operation-a', decisions);
+    const saved = a.read('discovery').notNotified;
+    assert.equal(b.chooseCard('card', '1:0'), '1:0');
+    b.recordNotNotified('operation-a', decisions);
+    assert.deepEqual(a.read('discovery').notNotified, saved);
+    assert.throws(
+      () => b.recordNotNotified('operation-a', [{ ...decisions[0]!, reason: 'changed' }]),
+      /different reason/,
+    );
+    a.close();
+    a = new BotStore(dir);
+    assert.equal(a.cardSelection('card'), '1:0');
+    assert.equal(a.chooseCard('card', '0:0'), '1:0');
+    assert.deepEqual(a.read('discovery').notNotified, saved);
+    assert.equal(a.pendingVisible().filter((m) => m.type === 'user').length, 1);
+    b.recordNotNotified('operation-b', decisions);
+    assert.equal(a.read('discovery').notNotified.length, 2);
+  } finally {
+    a.close();
+    b.close();
+    rmSync(dir, { recursive: true, force: true });
+  }
+});
diff --git a/packages/runtime-host/src/__tests__/assistant-card-ledger.test.ts b/packages/runtime-host/src/__tests__/assistant-card-ledger.test.ts
new file mode 100644
index 0000000..a048a9f
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/assistant-card-ledger.test.ts
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { decodeRuntimeEvent, type RuntimeEvent } from '@maka/core/runtime-event';
+import type { AssistantMessage, SessionHeader } from '@maka/core/session';
+import { publishBotConversationMessage } from '../assistant/bot/conversation.js';
+import { projectRuntimeEventsToStoredMessages } from '@maka/runtime/runtime-event-read-model';
+import { createAssistantMessageReader } from '../server/session-transcript-reader.js';
+
+test('cards survive canonical ledger publication and cannot change under the same message identity', async () => {
+  let events: RuntimeEvent[] = [];
+  const stores = {
+    runtimeEventStore: {
+      readRuntimeEvents: async () => events,
+      importConversationCopyRuntimeEvents: async (_: string, copies: any[]) => {
+        events = copies[0].events.map(decodeRuntimeEvent);
+      },
+    },
+  } as any;
+  const header = {
+    id: 'owner',
+    cwd: '/owner',
+    backend: 'ai-sdk',
+    llmConnectionSlug: 'test',
+    model: 'model',
+    permissionMode: 'ask',
+  } as unknown as SessionHeader;
+  const message: AssistantMessage = {
+    id: 'card-message',
+    type: 'assistant',
+    turnId: 't',
+    ts: 1,
+    text: '报告已准备好',
+    modelId: 'bot',
+    card: {
+      type: 'delivery',
+      title: '研究报告',
+      items: [
+        {
+          title: '报告',
+          image: { url: 'https://example.com/cover.png', alt: '封面' },
+          actions: [{ kind: 'link', label: '查看', value: 'https://example.com/report' }],
+        },
+      ],
+    },
+  };
+  await publishBotConversationMessage(stores, header, message);
+  await publishBotConversationMessage(stores, header, message);
+  const opening = events.find((e) => e.content?.kind === 'invocation_opened')!;
+  const projected = projectRuntimeEventsToStoredMessages(events, {
+    invocations: [
+      {
+        sessionId: 'owner',
+        invocationId: opening.invocationId,
+        runId: opening.runId,
+        turnId: 't',
+        openedAt: 1,
+        opening: opening.content as any,
+      },
+    ],
+  });
+  const result = projected.messages.find((m) => m.type === 'assistant');
+  assert.deepEqual(result?.type === 'assistant' && result.card, message.card);
+  await assert.rejects(
+    publishBotConversationMessage(stores, header, {
+      ...message,
+      card: { ...message.card!, title: 'changed' },
+    }),
+    /identity conflict/,
+  );
+  const reader = createAssistantMessageReader({
+    ensureTranscriptLedger: async () => {},
+    events: {
+      readTranscriptTurns: async () => [{ firstOrdinal: 1, lastOrdinal: 1 }],
+      readTranscriptRun: async (_s: string, _q: any, read: any) =>
+        read(
+          { lastOrdinal: 1 },
+          events.map((event) => ({ event })),
+        ),
+    } as any,
+  });
+  assert.match(
+    (await reader({ sessionId: 'owner', turnId: 't', messageId: 'card-message' }))!,
+    /!\[\]\(<https:\/\/example.com\/cover.png>\)/,
+  );
+});
diff --git a/packages/runtime-host/src/assistant/bot/card-schema.ts b/packages/runtime-host/src/assistant/bot/card-schema.ts
new file mode 100644
index 0000000..6fe6355
--- /dev/null
+++ b/packages/runtime-host/src/assistant/bot/card-schema.ts
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { z } from 'zod';
+import { isCardWebUrl } from '@maka/core/assistant-card';
+
+const webUrl = z.string().max(2048).refine(isCardWebUrl, 'Use an HTTP(S) URL without credentials');
+const action = z.discriminatedUnion('kind', [
+  z
+    .object({ kind: z.literal('link'), label: z.string().trim().min(1).max(40), value: webUrl })
+    .strict(),
+  z
+    .object({
+      kind: z.literal('reply'),
+      label: z.string().trim().min(1).max(40),
+      value: z.string().trim().min(1).max(2000),
+    })
+    .strict(),
+]);
+export const assistantCardSchema = z
+  .object({
+    type: z.enum(['recommendation', 'offer', 'delivery']),
+    title: z.string().trim().min(1).max(120),
+    description: z.string().trim().min(1).max(600).optional(),
+    items: z
+      .array(
+        z
+          .object({
+            title: z.string().trim().min(1).max(120),
+            description: z.string().trim().min(1).max(1200).optional(),
+            image: z
+              .object({ url: webUrl, alt: z.string().trim().min(1).max(200) })
+              .strict()
+              .optional(),
+            facts: z
+              .array(
+                z
+                  .object({
+                    label: z.string().trim().min(1).max(40),
+                    value: z.string().trim().min(1).max(240),
+                  })
+                  .strict(),
+              )
+              .max(6)
+              .optional(),
+            actions: z.array(action).max(3).optional(),
+          })
+          .strict(),
+      )
+      .min(1)
+      .max(6),
+  })
+  .strict();
diff --git a/packages/runtime-host/src/assistant/bot/conversation.ts b/packages/runtime-host/src/assistant/bot/conversation.ts
index f679fd6..c71565c 100644
--- a/packages/runtime-host/src/assistant/bot/conversation.ts
+++ b/packages/runtime-host/src/assistant/bot/conversation.ts
@@ -54,7 +54,8 @@
         message.type === 'user' ? message.origin : undefined,
       ) ||
       text.author !== (message.type === 'user' ? (message.origin ? 'host' : 'user') : 'agent') ||
-      text.content.summary !== (message.type === 'assistant' ? message.summary : undefined)
+      text.content.summary !== (message.type === 'assistant' ? message.summary : undefined) ||
+      !isDeepStrictEqual(text.content.card, message.type === 'assistant' ? message.card : undefined)
     )
       throw Error('Assistant conversation ledger identity conflict');
     return;
@@ -96,6 +97,9 @@
       content: {
         kind: 'text',
         text: message.text,
+        ...(message.type === 'assistant' && message.card !== undefined
+          ? { card: message.card }
+          : {}),
         ...(message.type === 'assistant' && message.summary !== undefined
           ? { summary: message.summary }
           : {}),
diff --git a/packages/runtime-host/src/assistant/bot/host.ts b/packages/runtime-host/src/assistant/bot/host.ts
index 11c78c5..d055811 100644
--- a/packages/runtime-host/src/assistant/bot/host.ts
+++ b/packages/runtime-host/src/assistant/bot/host.ts
@@ -17,6 +17,9 @@
  * under the License.
  */
 
+import { assistantCardSchema } from './card-schema.js';
+import type { AssistantCard } from '@maka/core/assistant-card';
+
 import { z } from 'zod';
 import { initializePetPack } from './pet-pack.js';
 import { initializeAvatar } from './avatar.js';
@@ -72,6 +75,26 @@
       characterInitialization.delivered(input);
     },
   });
+  const cardInput = z
+    .object({ sessionId: z.string().min(1).max(512), cardId: z.string().min(1).max(512) })
+    .strict();
+  const checkCardOwner = (sessionId: string) => {
+    bot.ready();
+    if (bot.owner() !== sessionId) throw Error('Assistant conversation changed');
+  };
+  ctx.api.register('assistant.card-status', (raw) => {
+    const input = cardInput.parse(raw);
+    checkCardOwner(input.sessionId);
+    return { selectedKey: store.cardSelection(input.cardId) };
+  });
+  ctx.api.register('assistant.card-reply', async (raw) => {
+    const input = cardInput.extend({ actionKey: z.string().regex(/^\d+:\d+$/) }).parse(raw);
+    checkCardOwner(input.sessionId);
+    const selectedKey = store.chooseCard(input.cardId, input.actionKey);
+    await bot.flush();
+    bot.tick();
+    return { selectedKey };
+  });
   ctx.backgroundSessions.register(() => (bot.worker() ? [bot.worker()!] : []));
   ctx.onClose(() => store.close());
   ctx.onStart(() => {
@@ -186,7 +209,7 @@
   );
   register(
     'SendUser',
-    '向用户发送一批聊天消息,messages 按顺序显示为独立气泡。每项可填写 text(完整正文)和 stickerId(表情素材 ID),至少一个;填写 stickerId 即可直接发送表情,不用先查图片链接。表情包也是你的日常表达方式,可以和文字一起发,也可以单独接话。summary(简短摘要)可选。短消息直接发正文,不必写摘要。正文超过 200 字时填写独立摘要,用户默认看到摘要,点击展开正文;摘要由你概括,不是截断正文。需要你从用户的观看体验来看需不需要摘要。转达主动发现时,在该项 discoveryIds 填对应的 discovery 类型 Inbox 消息 ID,记录实际送达关系;普通聊天留空。messageId 标识这批发送,重试时复用。发送后循环继续。',
+    '向用户发送一批聊天消息,messages 按顺序显示为独立气泡。每项可填写 text(完整正文)和 stickerId(表情素材 ID),至少一个;填 stickerId 即可直接发表情,不用查链接。表情包也是你的日常表达方式,可以和文字一起发,也可以单独接话。用户需要选择、确认处理方式或回应具体帮助时,使用 card 提供可点击的选项,省去打字。注意不要因为使用卡片而强行把可以处理的事情交给用户审批;普通解释、提醒和闲聊直接用文字,简单追问无需为了选项堆砌说明。card.type 为 recommendation(候选比较)、offer(帮助或确认)、delivery(成果入口);填写 title 和 items,一个事项用一个 item,多个候选各用一个。单项标题可与 card.title 相同,界面不重复显示;description、facts、image 按需省略,不必配图。actions 填用户可选的回应,选项同等展示,不要求主次按钮。每张卡片只接受一次选择,提交成功后所有选项禁用;需要分别回答的事情请分开发卡片。reply 的 label 是简短、明确的按钮文字,value 是带事项上下文和操作范围的完整用户请求;点击即提交给助手继续处理,不用再发送。link 的 value 是直接打开的 HTTP(S) 地址,图片同样使用 HTTP(S) 地址。不写 HTML/CSS。卡片不受 summary 折叠影响,text 不必重复卡片内容。summary 可选;正文超过 200 字时填写独立摘要,短消息可省略,摘要由你概括,不是截断正文。转达主动发现时,在该项 discoveryIds 填对应的 discovery 类型 Inbox 消息 ID,记录实际送达关系;普通聊天留空。messageId 标识这批发送,重试时复用。发送后循环继续。',
     z
       .object({
         messageId: z.string().min(1).max(200),
@@ -195,6 +218,7 @@
             z
               .object({
                 discoveryIds: z.array(z.string().min(1)).min(1).max(100).optional(),
+                card: assistantCardSchema.optional(),
                 summary: z.string().trim().min(1).max(2000).optional(),
                 text: z.string().trim().min(1).max(48000).optional(),
                 stickerId: z
@@ -215,7 +239,11 @@
       .refine(
         (value) =>
           value.messages.reduce(
-            (sum, message) => sum + (message.summary?.length ?? 0) + (message.text?.length ?? 0),
+            (sum, message) =>
+              sum +
+              (message.summary?.length ?? 0) +
+              (message.text?.length ?? 0) +
+              (message.card ? JSON.stringify(message.card).length : 0),
             0,
           ) <= 48000,
         'Messages must total at most 48000 characters',
@@ -227,6 +255,7 @@
           summary?: string;
           text?: string;
           stickerId?: string;
+          card?: AssistantCard;
           discoveryIds?: string[];
         }[];
       },
@@ -236,7 +265,12 @@
       const request = JSON.stringify(input.messages);
       const previous = store.get<{
         request: string;
-        messages: { text: string; summary?: string; discoveryIds?: string[] }[];
+        messages: {
+          text: string;
+          summary?: string;
+          card?: AssistantCard;
+          discoveryIds?: string[];
+        }[];
       }>(receiptKey);
       if (previous && previous.request !== request)
         throw Error('Message ID has different batch content');
@@ -257,6 +291,7 @@
           }
           messages.push({
             text,
+            ...(message.card ? { card: message.card } : {}),
             ...(message.summary !== undefined ? { summary: message.summary } : {}),
             ...(message.discoveryIds ? { discoveryIds: message.discoveryIds } : {}),
           });
diff --git a/packages/runtime-host/src/assistant/bot/store.ts b/packages/runtime-host/src/assistant/bot/store.ts
index f018375..ecdb350 100644
--- a/packages/runtime-host/src/assistant/bot/store.ts
+++ b/packages/runtime-host/src/assistant/bot/store.ts
@@ -17,11 +17,21 @@
  * under the License.
  */
 
+import { isDeepStrictEqual } from 'node:util';
+import {
+  isAssistantCard,
+  assistantMessageText,
+  type AssistantCard,
+} from '@maka/core/assistant-card';
 import { DatabaseSync } from 'node:sqlite';
 import { mkdirSync, chmodSync } from 'node:fs';
 import { join } from 'node:path';
 import type { StoredMessage } from '@maka/core/session';
-import { assistantBatchOutputTurnId, assistantOutputTurnId } from './turn-ids.js';
+import {
+  assistantInputTurnId,
+  assistantBatchOutputTurnId,
+  assistantOutputTurnId,
+} from './turn-ids.js';
 
 export interface DiscoveryFeedbackLink {
   id: string;
@@ -61,6 +71,7 @@
       CREATE TABLE IF NOT EXISTS notification_decisions(call_id TEXT NOT NULL, inbox_id TEXT NOT NULL,
         at INTEGER NOT NULL, reason TEXT NOT NULL, PRIMARY KEY(call_id,inbox_id));
       CREATE INDEX IF NOT EXISTS notification_decisions_inbox ON notification_decisions(inbox_id,at);
+      CREATE TABLE IF NOT EXISTS card_replies(card_id TEXT PRIMARY KEY, action_key TEXT NOT NULL);
       CREATE TABLE IF NOT EXISTS discovery_messages(message_id TEXT PRIMARY KEY, discovery_ids TEXT NOT NULL, delivered_at INTEGER);
       CREATE TABLE IF NOT EXISTS discovery_feedback(user_id TEXT NOT NULL, message_id TEXT NOT NULL,
         PRIMARY KEY(user_id,message_id));
@@ -101,26 +112,64 @@
     return Number(this.db.prepare('SELECT COALESCE(MAX(seq),0) n FROM inbox').get()!.n);
   }
   post(input: InboxInput, visible?: StoredMessage) {
+    return this.transaction(() => this.postInTransaction(input, visible));
+  }
+  private postInTransaction(input: InboxInput, visible?: StoredMessage) {
+    const existing = this.db.prepare('SELECT * FROM inbox WHERE id=?').get(input.id);
+    const content = JSON.stringify(input.content);
+    if (existing) {
+      if (
+        existing.content !== content ||
+        existing.type !== input.type ||
+        existing.source !== input.source ||
+        existing.task !== (input.taskId ?? null)
+      )
+        throw Error('Inbox message identity already has different content');
+      return Number(existing.seq);
+    }
+    const result = this.db
+      .prepare('INSERT INTO inbox(id,type,source,at,task,content) VALUES(?,?,?,?,?,?)')
+      .run(input.id, input.type, input.source, Date.now(), input.taskId ?? null, content);
+    if (visible) this.queueVisible(visible);
+    // A fresh event retries a failed worker, without a timed restart storm.
+    this.set('error', null);
+    return Number(result.lastInsertRowid);
+  }
+  cardSelection(cardId: string): string | null {
+    return (
+      (this.db.prepare('SELECT action_key FROM card_replies WHERE card_id=?').get(cardId)
+        ?.action_key as string) ?? null
+    );
+  }
+  /** Selection, Inbox admission and visible reply commit together; the first choice wins. */
+  chooseCard(cardId: string, actionKey: string): string {
     return this.transaction(() => {
-      const existing = this.db.prepare('SELECT * FROM inbox WHERE id=?').get(input.id);
-      const content = JSON.stringify(input.content);
-      if (existing) {
-        if (
-          existing.content !== content ||
-          existing.type !== input.type ||
-          existing.source !== input.source ||
-          existing.task !== (input.taskId ?? null)
-        )
-          throw Error('Inbox message identity already has different content');
-        return Number(existing.seq);
-      }
-      const result = this.db
-        .prepare('INSERT INTO inbox(id,type,source,at,task,content) VALUES(?,?,?,?,?,?)')
-        .run(input.id, input.type, input.source, Date.now(), input.taskId ?? null, content);
-      if (visible) this.queueVisible(visible);
-      // A fresh event retries a failed worker, without a timed restart storm.
-      this.set('error', null);
-      return Number(result.lastInsertRowid);
+      const row = this.db.prepare('SELECT payload,delivered FROM outbox WHERE id=?').get(cardId);
+      const message = row && JSON.parse(String(row.payload));
+      if (!row?.delivered || message?.type !== 'assistant' || !isAssistantCard(message.card))
+        throw Error('Unknown or undelivered card');
+      if (!/^\d+:\d+$/.test(actionKey)) throw Error('Invalid card action');
+      const [itemIndex, actionIndex] = actionKey.split(':').map(Number);
+      const action = message.card.items[itemIndex]?.actions?.[actionIndex];
+      if (!action || action.kind !== 'reply') throw Error('Invalid card reply');
+      const selected = this.cardSelection(cardId);
+      if (selected !== null) return selected;
+      const id = `assistant-card-reply:${cardId}`;
+      const content = { text: action.value };
+      this.postInTransaction(
+        { id, type: 'user_message', source: 'user', content },
+        {
+          ...content,
+          type: 'user',
+          id,
+          turnId: assistantInputTurnId(id),
+          ts: Date.now(),
+        },
+      );
+      this.db
+        .prepare('INSERT INTO card_replies(card_id,action_key) VALUES(?,?)')
+        .run(cardId, actionKey);
+      return actionKey;
     });
   }
   list(input: { after?: number; limit?: number; type?: InboxType }) {
@@ -224,7 +273,8 @@
       const previous = JSON.parse(String(old.payload));
       if (
         previous.text !== ('text' in message ? message.text : undefined) ||
-        previous.summary !== (message.type === 'assistant' ? message.summary : undefined)
+        previous.summary !== (message.type === 'assistant' ? message.summary : undefined) ||
+        !isDeepStrictEqual(previous.card, message.type === 'assistant' ? message.card : undefined)
       )
         throw Error('Message ID has different text');
       return previous as StoredMessage;
@@ -238,6 +288,7 @@
   queueVisibleBatch(
     batchId: string,
     texts: readonly {
+      card?: AssistantCard;
       summary?: string;
       text: string;
       discoveryIds?: string[];
@@ -250,11 +301,16 @@
           items.map((entry) => ({
             text: entry.text,
             ...(entry.summary !== undefined ? { summary: entry.summary } : {}),
+            ...(entry.card !== undefined ? { card: entry.card } : {}),
             ...(entry.discoveryIds?.length
               ? { discoveryIds: [...new Set(entry.discoveryIds)].sort() }
               : {}),
           })),
         );
+      for (const entry of texts) {
+        if (entry.card !== undefined && !isAssistantCard(entry.card))
+          throw Error('Invalid assistant card');
+      }
       for (const entry of texts)
         for (const id of entry.discoveryIds ?? [])
           if (!this.db.prepare("SELECT 1 FROM inbox WHERE id=? AND type='discovery'").get(id))
@@ -287,6 +343,7 @@
           ts: timestamp,
           text,
           ...(summary !== undefined ? { summary } : {}),
+          ...(entry.card !== undefined ? { card: entry.card } : {}),
           modelId: 'assistant-bot',
         });
         if (entry.discoveryIds?.length)
@@ -315,7 +372,7 @@
         return {
           messageId: String(row.id),
           discoveryIds: JSON.parse(String(row.discovery_ids)),
-          text: m.text,
+          text: assistantMessageText(m),
           at: new Date(Number(row.delivered_at)).toISOString(),
         };
       });
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 4de2ae0..0261892 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -109,7 +109,9 @@
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
 // Re-check against main before merging; sibling PRs may use the same next epoch.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 237 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 238 as const;
+// 238: Assistant cards and durable single-choice reply/status operations.
+// Older peers cannot display cards or submit and synchronize their choices safely.
 // 237: Codex model discovery carries a compaction compatibility hash.
 // 236: Goal event wait consumers include the assistant character and pet operations from main.
 // 235: Goal event wait consumers include the quick-discovery operations from main.
diff --git a/packages/runtime-host/src/protocol/personal-assistant.ts b/packages/runtime-host/src/protocol/personal-assistant.ts
index d3cefee..c28a90d 100644
--- a/packages/runtime-host/src/protocol/personal-assistant.ts
+++ b/packages/runtime-host/src/protocol/personal-assistant.ts
@@ -64,6 +64,8 @@
       'assistant.set-enabled',
       'assistant.binding',
       'assistant.allocate-workspace',
+      'assistant.card-status',
+      'assistant.card-reply',
       'assistant.avatar',
       'assistant.character-status',
       'assistant.avatar-image',
diff --git a/packages/runtime-host/src/server/session-transcript-reader.ts b/packages/runtime-host/src/server/session-transcript-reader.ts
index abce9aa..dc93078 100644
--- a/packages/runtime-host/src/server/session-transcript-reader.ts
+++ b/packages/runtime-host/src/server/session-transcript-reader.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import { assistantCardImageMarkdown } from '@maka/core/assistant-card';
+
 import type { RuntimeEvent } from '@maka/core/runtime-event';
 import { DURABLE_TOOL_RESULT_PROJECTION_MAX_BYTES } from '@maka/core/durable-tool-result-projection';
 import type { RuntimeInvocationRecord } from '@maka/core/runtime-invocation';
@@ -698,7 +700,7 @@
               (event.refs?.storedMessageId ?? event.refs?.providerEventId ?? event.id) ===
               identity.messageId
             )
-              text = event.content.text;
+              text = event.content.text + '\n' + assistantCardImageMarkdown(event.content.card);
           }
           return { text, next: run.lastOrdinal + 1 };
         },
diff --git a/packages/runtime/src/__tests__/model-history-timeline.test.ts b/packages/runtime/src/__tests__/model-history-timeline.test.ts
index c652152..ee3aa23 100644
--- a/packages/runtime/src/__tests__/model-history-timeline.test.ts
+++ b/packages/runtime/src/__tests__/model-history-timeline.test.ts
@@ -266,3 +266,32 @@
     partial: false,
   };
 }
+
+test('card contents remain model-visible on replay rather than just their introduction', () => {
+  const message = event({
+    id: 'card',
+    role: 'model',
+    author: 'agent',
+    content: {
+      kind: 'text',
+      text: '给你两个选项。',
+      card: {
+        type: 'offer',
+        title: '报销材料',
+        items: [
+          {
+            title: '整理材料',
+            facts: [{ label: '缺少', value: '酒店发票' }],
+            actions: [
+              { kind: 'reply', label: '帮我整理', value: '请整理这次出差报销,先核对酒店发票。' },
+            ],
+          },
+        ],
+      },
+    },
+  });
+  const replay = buildRuntimeEventModelReplayPlan([message]);
+  const text = JSON.stringify(replay.items);
+  assert.match(text, /酒店发票/);
+  assert.match(text, /请整理这次出差报销/);
+});
diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts
index 26a1674..bcd9a3e 100644
--- a/packages/runtime/src/model-history.ts
+++ b/packages/runtime/src/model-history.ts
@@ -48,6 +48,8 @@
  * item without reviving the retired 0.1.x StoredMessage history path.
  */
 
+import { assistantCardText } from '@maka/core/assistant-card';
+
 import {
   isPartialRuntimeEvent,
   isTerminalRuntimeEvent,
@@ -420,6 +422,7 @@
     // attachment-only user message must not estimate to zero, or the
     // history-compact gate drops a model-visible event (#4804).
     if (content.kind === 'text') {
+      if (content.card) total += assistantCardText(content.card).length;
       for (const quote of content.quotes ?? []) {
         total += formatQuoteRefs([quote]).length;
       }
@@ -1009,7 +1012,10 @@
           // envelope); the raw text is a UI/transcript projection only.
           content: steeringReplay
             ? buildSteeringEnvelope(formatTextWithInlineRefs(event.content))
-            : formatTextWithInlineRefs(event.content),
+            : formatTextWithInlineRefs(event.content) +
+              (role === 'assistant' && event.content.card
+                ? '\n\n' + assistantCardText(event.content.card)
+                : ''),
           ...(steeringReplay ? { steering: { eventId: event.id } } : {}),
           ...(runtimeContextName !== undefined ? { runtimeContextName } : {}),
           ...(event.content.attachments ? { attachments: event.content.attachments } : {}),
diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts
index 53e0540..af2e966 100644
--- a/packages/runtime/src/runtime-event-backfill.ts
+++ b/packages/runtime/src/runtime-event-backfill.ts
@@ -168,6 +168,7 @@
               kind: 'text',
               text: message.text,
               ...(message.summary !== undefined ? { summary: message.summary } : {}),
+              ...(message.card !== undefined ? { card: message.card } : {}),
               ...(message.interrupted ? { interrupted: true } : {}),
             },
             actions: { stateDelta: recoveryState(now, message) },
diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts
index f488446..9a1d449 100644
--- a/packages/runtime/src/runtime-event-read-model.ts
+++ b/packages/runtime/src/runtime-event-read-model.ts
@@ -978,6 +978,7 @@
       ts: event.ts,
       text: event.content.text,
       ...(event.content.summary !== undefined ? { summary: event.content.summary } : {}),
+      ...(event.content.card !== undefined ? { card: event.content.card } : {}),
       ...(event.content.interrupted ? { interrupted: true } : {}),
       ...(event.content.providerOptions !== undefined
         ? { providerOptions: structuredClone(event.content.providerOptions) }
diff --git a/packages/ui/src/__tests__/message-card.test.tsx b/packages/ui/src/__tests__/message-card.test.tsx
new file mode 100644
index 0000000..88576d6
--- /dev/null
+++ b/packages/ui/src/__tests__/message-card.test.tsx
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { renderToStaticMarkup } from 'react-dom/server';
+import type { AssistantCard } from '@maka/core/assistant-card';
+import { MessageCard, MessageCardSendContext, MessageCardActionsContext } from '../message-card.js';
+import { MessageSummary } from '../message-summary.js';
+import { act } from 'react';
+import { installDom } from './mermaid-test-dom.js';
+import { LocaleProvider } from '../locale-context.js';
+import { materializeTurns } from '../materialize.js';
+const card: AssistantCard = { type: 'recommendation', title: '周末摄影', items: [{ title: '城市光影', description: '可以顺路去看看。', facts: [{ label: '时间', value: '周六下午' }], actions: [{ kind: 'reply', label: '安排一下', value: '帮我安排周六下午的城市光影摄影展行程。' }] }, { title: '街头日常', actions: [{ kind: 'link', label: '详情', value: 'https://example.com/exhibit' }] }] };
+test('renders multiple choices and facts; card stays outside collapsed prose', () => {
+ const html = renderToStaticMarkup(<LocaleProvider locale="zh-CN"><MessageSummary summary="短摘要"><p>长正文</p></MessageSummary><MessageCardSendContext.Provider value={async () => true}><MessageCard card={card} /></MessageCardSendContext.Provider></LocaleProvider>);
+ assert.match(html, /城市光影/); assert.match(html, /街头日常/); assert.match(html, /周六下午/);
+ assert.match(html, /data-count="2"/); assert.doesNotMatch(html, /点击选项/);
+ assert.ok(html.indexOf('maka-message-card') > html.indexOf('maka-message-summary-toggle'));
+ assert.match(html, /https:\/\/example.com\/exhibit/);
+});
+test('renders ordinary text safely and refuses invalid persisted cards', () => {
+ const render = (c: AssistantCard) => renderToStaticMarkup(<LocaleProvider locale="en"><MessageCard card={c} /></LocaleProvider>);
+ assert.match(render({ ...card, title: '<script>alert(1)</script>' }), /&lt;script&gt;/);
+ assert.equal(render({ ...card, items: [{ title: 'x', actions: [{ kind: 'link', label: 'run', value: 'javascript:alert(1)' }] }] }), '');
+ assert.match(render(card), /disabled/);
+});
+test('live committed messages and reloaded history carry the same card', () => {
+ const m = { type: 'assistant' as const, id: 'a', turnId: 't', ts: 1, text: '看看这些。', modelId: 'bot', card };
+ for (const message of [m, JSON.parse(JSON.stringify({ ...m, contentOrder: ['text'] }))]) {
+  const item = materializeTurns([message], 'en')[0].timeline.find(x => x.kind === 'text');
+  assert.deepEqual(item?.kind === 'text' && item.card, card);
+ }
+});
+
+
+test('a card click submits immediately, deduplicates while pending and permits retry after refusal', async () => {
+ const dom = installDom();
+ const { createRoot } = await import('react-dom/client');
+ const root = createRoot(dom.document.getElementById('root')!);
+ const calls: string[] = [];
+ let finish!: (accepted: boolean) => void;
+ const send = (text: string) => { calls.push(text); return new Promise<boolean>(resolve => { finish = resolve; }); };
+ try {
+  await act(async () => root.render(<LocaleProvider locale="en"><MessageCardSendContext.Provider value={send}><MessageCard card={card} /></MessageCardSendContext.Provider></LocaleProvider>));
+  const button = () => dom.document.querySelector('button')!;
+  await act(async () => { button().click(); button().click(); });
+  assert.deepEqual(calls, [card.items[0]!.actions![0]!.value]);
+  assert.equal(button().hasAttribute('disabled'), true);
+  await act(async () => finish(false));
+  assert.match(dom.document.querySelector('[role="alert"]')?.textContent ?? '', /try again/);
+  await act(async () => button().click());
+  assert.equal(calls.length, 2);
+  await act(async () => finish(true));
+  assert.match(button().textContent ?? '', /Sent/);
+  assert.equal(button().hasAttribute('disabled'), true);
+  assert.equal(dom.document.querySelector('textarea, [contenteditable]'), null);
+ } finally { await act(async () => root.unmount()); dom.restore(); }
+});
+
+
+test('one reply disables all items and links; a failed send leaves every choice retryable', async () => {
+ const dom = installDom();
+ const { createRoot } = await import('react-dom/client');
+ const root = createRoot(dom.document.getElementById('root')!);
+ let calls = 0;
+ const choices: AssistantCard = { ...card, items: [...card.items, { title: '另一个', actions: [{ kind: 'reply', label: '选另一个', value: '另一个' }] }] };
+ try {
+  await act(async () => root.render(<LocaleProvider locale="en"><MessageCardSendContext.Provider value={async () => ++calls > 1}><MessageCard card={choices} /></MessageCardSendContext.Provider></LocaleProvider>));
+  const buttons = () => [...dom.document.querySelectorAll('button')];
+  await act(async () => buttons()[0]!.click());
+  assert.ok(buttons().every(b => !b.disabled));
+  await act(async () => buttons()[1]!.click());
+  assert.ok(buttons().every(b => b.disabled));
+  assert.equal(dom.document.querySelector('a[href="https://example.com/exhibit"]'), null);
+  assert.ok(dom.document.querySelector('[aria-disabled="true"]'));
+  await act(async () => buttons()[0]!.click());
+  assert.equal(calls, 2);
+ } finally { await act(async () => root.unmount()); dom.restore(); }
+});
+test('authoritative choice restores disabled state on remount, without resubmitting', async () => {
+ const dom = installDom();
+ const { createRoot } = await import('react-dom/client');
+ const root = createRoot(dom.document.getElementById('root')!);
+ let selectedKey: string | null = null; let calls = 0;
+ const actions = { read: async () => ({ selectedKey }), choose: async (_: string, key: string) => { calls++; selectedKey ??= key; return { selectedKey }; } };
+ const render = (key: number) => <LocaleProvider locale="en"><MessageCardActionsContext.Provider value={actions}><MessageCard key={key} card={card} messageId="card" /></MessageCardActionsContext.Provider></LocaleProvider>;
+ try {
+  await act(async () => root.render(render(0)));
+  await act(async () => dom.document.querySelector('button')!.click());
+  assert.equal(calls, 1);
+  await act(async () => root.render(render(1)));
+  assert.equal(dom.document.querySelector('button')!.disabled, true);
+  assert.match(dom.document.querySelector('button')!.textContent ?? '', /Sent/);
+  assert.equal(calls, 1);
+ } finally { await act(async () => root.unmount()); dom.restore(); }
+});
diff --git a/packages/ui/src/assistant-chat-copy.ts b/packages/ui/src/assistant-chat-copy.ts
index 80b377d..09d747e 100644
--- a/packages/ui/src/assistant-chat-copy.ts
+++ b/packages/ui/src/assistant-chat-copy.ts
@@ -20,10 +20,10 @@
 import type { ShippedUiCatalog, UiCatalog, UiLocale } from '@maka/core/ui-locale';
 
 const COPY = {
-  en: { chatOptions: 'Chat options', expand: 'Show details', collapse: 'Collapse' },
-  'zh-CN': { chatOptions: '聊天选项', expand: '展开详情', collapse: '收起' },
-  'zh-TW': { chatOptions: '聊天選項', expand: '展開詳情', collapse: '收起' },
-} satisfies ShippedUiCatalog<{ chatOptions: string; expand: string; collapse: string }>;
+  en: { chatOptions: 'Chat options', expand: 'Show details', collapse: 'Collapse', cardSent: 'Sent', cardFailed: 'Could not send. Please try again.' },
+  'zh-CN': { chatOptions: '聊天选项', expand: '展开详情', collapse: '收起', cardSent: '已发送', cardFailed: '未能发送,请重试。' },
+  'zh-TW': { chatOptions: '聊天選項', expand: '展開詳情', collapse: '收起', cardSent: '已傳送', cardFailed: '未能傳送,請重試。' },
+} satisfies ShippedUiCatalog<{ chatOptions: string; expand: string; collapse: string; cardSent: string; cardFailed: string }>;
 
 export function assistantChatCopy(locale: UiLocale) {
   return (COPY as Partial<UiCatalog<typeof COPY.en>>)[locale] ?? COPY.en;
diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx
index 3cc9ab3..4aa9d73 100644
--- a/packages/ui/src/chat-turn.tsx
+++ b/packages/ui/src/chat-turn.tsx
@@ -17,6 +17,9 @@
  * under the License.
  */
 
+import { assistantMessageText, type AssistantCard } from '@maka/core/assistant-card';
+import { MessageCard } from './message-card.js';
+
 import { Fragment, memo, useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
 import { ICON_SIZE, GitBranch, Pencil, RefreshCcw, Timer } from './icons.js';
 import { useClipboardCopyFeedback } from './clipboard-feedback.js';
@@ -879,7 +882,7 @@
                     ? (actionId) => props.onFooterAction?.(turn.turnId, actionId)
                     : undefined
                 }
-                assistantText={finalReply?.text ?? ''}
+                assistantText={finalReply ? assistantMessageText(finalReply) : ''}
               />
             )}
             </LocalizedChatMessage>
@@ -1315,7 +1318,7 @@
  */
 type AssistantAnswerPhase = 'historical' | 'streaming' | 'settled';
 
-type AssistantAnswerBubbleProps = { summary?: string; imageIdentity?: { turnId: string; messageId: string }; imageDisplay?: 'image' | 'link' } & (
+type AssistantAnswerBubbleProps = { card?: AssistantCard; summary?: string; imageIdentity?: { turnId: string; messageId: string }; imageDisplay?: 'image' | 'link' } & (
   | { text: string; phase: 'historical'; interrupted?: true }
   | {
       text: string;
@@ -1371,6 +1374,7 @@
           : 'maka-chat-message-bubble maka-chat-message-bubble-assistant maka-bubble-streaming'
       }
     >
+      {(!props.card || props.summary || props.text.trim() !== props.card.title.trim()) && <>
       <ImageMessageProvider identity={props.imageIdentity} streaming={isProgressiveStreamingEnabled(props.phase === 'streaming')}>
         <Markdown
           text={props.text}
@@ -1392,6 +1396,8 @@
           density="compact"
         />
       </ImageMessageProvider>
+      </>}
+      {props.card && <ImageMessageProvider identity={props.imageIdentity}><MessageCard card={props.card} messageId={props.imageIdentity?.messageId} imageDisplay={props.imageDisplay} /></ImageMessageProvider>}
       {truncated && (
         <Tooltip content={copy.outputTruncatedTitle}>
           {/* Colour-name archive, not the semantic one: Astryx paints
@@ -1460,12 +1466,14 @@
     );
   }
   // Same component either way — a type swap here would remount the answer.
-  if (item.live !== true) return <AssistantAnswerBubble imageIdentity={props.turnId ? { turnId: props.turnId, messageId: item.messageId } : undefined} imageDisplay={props.imageDisplay} text={item.text} summary={item.summary} interrupted={item.interrupted} phase="historical" />;
+  if (item.live !== true) return <AssistantAnswerBubble imageIdentity={props.turnId ? { turnId: props.turnId, messageId: item.messageId } : undefined} imageDisplay={props.imageDisplay} text={item.text} summary={item.summary} card={item.card} interrupted={item.interrupted} phase="historical" />;
   return (
     <AssistantAnswerBubble
       imageIdentity={props.turnId ? { turnId: props.turnId, messageId: item.messageId } : undefined}
       imageDisplay={props.imageDisplay}
       text={item.text}
+      summary={item.summary}
+      card={item.card}
       interrupted={item.interrupted}
       phase={item.complete === true ? 'settled' : 'streaming'}
       settledText={props.initialLiveContent?.get(`text:${item.messageId}`)}
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 4a04caf..d9f759f 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -206,3 +206,5 @@
 export * from './revision-staged-context.js';
 
 export { TranscriptDisclosure } from './transcript-disclosure.js';
+
+export { MessageCard, MessageCardSendContext, MessageCardActionsContext, type MessageCardActions } from './message-card.js';
diff --git a/packages/ui/src/materialize.ts b/packages/ui/src/materialize.ts
index d424bb8..6fe3478 100644
--- a/packages/ui/src/materialize.ts
+++ b/packages/ui/src/materialize.ts
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+import type { AssistantCard } from '@maka/core/assistant-card';
+
 import { deriveTurnRecords, isUserVisibleSessionSystemNote } from '@maka/core/session';
 import { foldTimeline } from './timeline-fold.js';
 import {
@@ -342,6 +344,7 @@
   | {
       kind: "text";
       summary?: string;
+      card?: AssistantCard;
       interrupted?: true;
       text: string;
       messageId: string;
@@ -1083,6 +1086,7 @@
               kind: "text",
               text: message.text,
               ...(message.summary !== undefined ? { summary: message.summary } : {}),
+              ...(message.card !== undefined ? { card: message.card } : {}),
               ...(message.interrupted ? { interrupted: true } : {}),
               messageId: rowId,
               ts: message.ts,
@@ -1108,6 +1112,7 @@
             kind: "text",
             text: message.text,
               ...(message.summary !== undefined ? { summary: message.summary } : {}),
+              ...(message.card !== undefined ? { card: message.card } : {}),
             ...(message.interrupted ? { interrupted: true } : {}),
             messageId: rowId,
             ts: message.ts,
diff --git a/packages/ui/src/message-card.css b/packages/ui/src/message-card.css
new file mode 100644
index 0000000..17cd954
--- /dev/null
+++ b/packages/ui/src/message-card.css
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.maka-message-card {
+  margin-top: 10px; width: 360px; max-width: 100%; height: auto; max-height: 300px; min-width: 0;
+  display: flex; flex-direction: column; overflow: hidden; padding: 0;
+  background: var(--background); color: var(--foreground); border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
+  border-radius: 16px; box-shadow: 0 2px 8px color-mix(in srgb, var(--foreground) 3%, transparent);
+}
+.maka-message-card-heading { flex: 0 0 auto; padding: 13px 16px 10px; border-bottom: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); max-height: 76px; overflow: auto; }
+.maka-message-card h3, .maka-message-card h4 { margin: 0; color: var(--foreground); overflow-wrap: anywhere; }
+.maka-message-card h3 { font-size: 16px; font-weight: 600; line-height: 1.4; }
+.maka-message-card h4 { font-size: 13px; font-weight: 600; line-height: 1.5; }
+.maka-message-card p { margin: 4px 0 0; font-size: 13px; line-height: 1.6; color: var(--muted-foreground); white-space: pre-wrap; overflow-wrap: anywhere; }
+.maka-message-card-body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 10px 16px; scrollbar-width: thin; overscroll-behavior: contain; }
+.maka-message-card-body:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
+.maka-message-card-intro { padding-bottom: 10px; }
+.maka-message-card-items { display: flex; flex-direction: column; gap: 9px; }
+.maka-message-card-item { min-width: 0; display: flex; gap: 12px; }
+.maka-message-card-content { min-width: 0; flex: 1; }
+.maka-message-card-cover { flex: 0 0 72px; width: 72px; max-height: 80px; border-radius: 8px; overflow: hidden; background: color-mix(in srgb, var(--foreground) 5%, var(--background)); }
+.maka-message-card-cover .maka-markdown-image-resource { margin: 0; display: block; }
+.maka-message-card-cover img { width: 72px; height: 80px; object-fit: cover; display: block; margin: 0; border: 0; }
+.maka-message-card dl { margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 3px 12px; font-size: 11px; color: var(--muted-foreground); line-height: 1.5; }
+.maka-message-card dl > div { display: flex; gap: 5px; min-width: 0; }
+.maka-message-card dt, .maka-message-card dd { margin: 0; overflow-wrap: anywhere; }
+.maka-message-card-actions { flex: 0 0 auto; max-height: 104px; overflow: auto; display: flex; flex-wrap: wrap; gap: 7px; padding: 10px; border-top: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); scrollbar-width: thin; }
+.maka-message-card-action { flex: 1 1 90px; min-width: 0; display: flex; }
+.maka-message-card-action > * { width: 100%; text-align: center; overflow-wrap: anywhere; white-space: normal; }
+.maka-message-card-actions .maka-message-card-reply { height: auto; min-height: 36px; border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent); border-radius: 9px; padding: 8px 10px; background: color-mix(in srgb, var(--foreground) 5%, var(--background)); color: var(--foreground); font-size: 12px; font-weight: 500; line-height: 1.5; box-shadow: none; }
+.maka-message-card-actions .maka-message-card-reply:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 10%, var(--background)); }
+.maka-message-card-actions .maka-message-card-reply:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
+.maka-message-card-actions .maka-message-card-reply:disabled { opacity: .55; }
+.maka-message-card-action > a, .maka-message-card-disabled-link { padding: 8px 10px; font-size: 12px; line-height: 1.5; }
+.maka-message-card-disabled-link { opacity: .55; cursor: default; }
+.maka-message-card-hint { flex: 0 0 auto; max-height: 44px; overflow: auto; padding: 4px 12px 8px; font-size: 12px; color: var(--muted-foreground); }
diff --git a/packages/ui/src/message-card.tsx b/packages/ui/src/message-card.tsx
new file mode 100644
index 0000000..e55574c
--- /dev/null
+++ b/packages/ui/src/message-card.tsx
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { createContext, useContext, useEffect, useRef, useState } from 'react';
+import { Button, Card } from '@astryxdesign/core';
+import { Link } from '@astryxdesign/core/Link';
+import { isAssistantCard, type AssistantCard, type AssistantCardAction } from '@maka/core/assistant-card';
+import { MarkdownImage } from './markdown-image.js';
+import { useUiLocale } from './locale-context.js';
+import { assistantChatCopy } from './assistant-chat-copy.js';
+import { redactSecrets } from './redact.js';
+
+/** A user click submits a request through the host's normal chat delivery path. */
+export const MessageCardSendContext = createContext<((text: string) => Promise<boolean>) | undefined>(undefined);
+
+export interface MessageCardActions {
+  read(cardId: string): Promise<{ selectedKey: string | null }>;
+  choose(cardId: string, actionKey: string): Promise<{ selectedKey: string }>;
+}
+export const MessageCardActionsContext = createContext<MessageCardActions | undefined>(undefined);
+
+/** One shared rendering for desktop and web history. */
+export function MessageCard({ card, messageId, imageDisplay = 'image' }: {
+  messageId?: string;
+  card: AssistantCard;
+  imageDisplay?: 'image' | 'link';
+}) {
+  const send = useContext(MessageCardSendContext);
+  const actions = useContext(MessageCardActionsContext);
+  const sending = useRef(false);
+  const [pending, setPending] = useState(false);
+  const [selected, setSelected] = useState<string | null>(null);
+  const [loaded, setLoaded] = useState(false);
+  const [failed, setFailed] = useState(false);
+  useEffect(() => {
+    let live = true;
+    let timer: ReturnType<typeof setTimeout>;
+    setSelected(null);
+    setLoaded(false);
+    if (!actions || !messageId) return;
+    const refresh = async () => {
+      try {
+        const status = await actions.read(messageId);
+        if (!live) return;
+        if (status.selectedKey !== null) setSelected(status.selectedKey);
+        setLoaded(true);
+        if (status.selectedKey !== null) return;
+      } catch { /* Keep unknown cards disabled; retry when the Host reconnects. */ }
+      if (live) timer = setTimeout(refresh, 3000);
+    };
+    void refresh();
+    return () => { live = false; clearTimeout(timer); };
+  }, [actions, messageId]);
+  const locked = pending || selected !== null || (actions ? !messageId || !loaded : !send);
+  async function submit(a: AssistantCardAction, key: string) {
+    if (locked || sending.current) return;
+    sending.current = true;
+    setPending(true);
+    setFailed(false);
+    try {
+      if (actions && messageId) setSelected((await actions.choose(messageId, key)).selectedKey);
+      else if (send && await send(a.value)) setSelected(key);
+      else setFailed(true);
+    } catch { setFailed(true); }
+    finally { sending.current = false; setPending(false); }
+  }
+  const copy = assistantChatCopy(useUiLocale());
+  if (!isAssistantCard(card)) return null;
+  const action = (a: AssistantCardAction, index: number, itemIndex: number) => a.kind === 'link'
+    ? (pending || selected !== null
+      ? <span key={index} className="maka-message-card-disabled-link" aria-disabled="true">{redactSecrets(a.label)}</span>
+      : <Link key={index} href={a.value} isExternalLink type="inherit" hasUnderline>{redactSecrets(a.label)}</Link>)
+    : <Button key={index} type="button" label={redactSecrets(a.label)} size="sm" variant="secondary" className="maka-message-card-reply"
+        isDisabled={locked} onClick={() => void submit(a, `${itemIndex}:${index}`)}>{selected === `${itemIndex}:${index}` ? copy.cardSent : redactSecrets(a.label)}</Button>;
+  const single = card.items.length === 1;
+  const cover = (item: AssistantCard['items'][number]) => item.image && <div className="maka-message-card-cover">{imageDisplay === 'link'
+    ? <Link href={item.image.url} isExternalLink type="inherit" hasUnderline>{redactSecrets(item.image.alt)}</Link>
+    : <MarkdownImage src={item.image.url} alt={redactSecrets(item.image.alt)} redacted={redactSecrets(item.image.url) !== item.image.url} />}</div>;
+  return <Card className="maka-message-card" data-card-type={card.type} data-single={single} aria-label={redactSecrets(card.title)}>
+    <div className="maka-message-card-heading"><h3>{redactSecrets(card.title)}</h3></div>
+    <div className="maka-message-card-body" tabIndex={0}>
+      {card.description && <p className="maka-message-card-intro">{redactSecrets(card.description)}</p>}
+      <div className="maka-message-card-items" data-count={card.items.length}>
+        {card.items.map((item, index) => <section key={index} className="maka-message-card-item">
+          {cover(item)}
+          <div className="maka-message-card-content">
+            {(!single || item.title !== card.title) && <h4>{redactSecrets(item.title)}</h4>}
+            {!!item.facts?.length && <dl>{item.facts.map((fact, n) => <div key={n}><dt>{redactSecrets(fact.label)}</dt><dd>{redactSecrets(fact.value)}</dd></div>)}</dl>}
+            {item.description && (!single || item.description !== card.description) && <p>{redactSecrets(item.description)}</p>}
+          </div>
+        </section>)}
+      </div>
+    </div>
+    {card.items.some(item => item.actions?.length) && <div className="maka-message-card-actions">{card.items.flatMap((item, index) => item.actions?.map((a, n) =>
+      <span className="maka-message-card-action" key={`${index}:${n}`} title={redactSecrets(item.title)}>{action(a, n, index)}</span>) ?? [])}</div>}
+    {failed && <div className="maka-message-card-hint" role="alert">{copy.cardFailed}</div>}
+  </Card>;
+}
diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css
index dba13d7..9014ebe 100644
--- a/packages/ui/src/styles.css
+++ b/packages/ui/src/styles.css
@@ -17,6 +17,8 @@
  * under the License.
  */
 
+@import './message-card.css';
+
 @import 'katex/dist/katex.min.css';
 
 .maka-progress-card { box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; padding: 11px 13px 7px; color: var(--foreground); }