diff --git a/desktop/src-tauri/assets/card_template.png b/desktop/src-tauri/assets/card_template.png
new file mode 100644
index 0000000000..2225d1d442
Binary files /dev/null and b/desktop/src-tauri/assets/card_template.png differ
diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs
index d3b1a9499d..7bc94da25d 100644
--- a/desktop/src-tauri/src/commands/media_download.rs
+++ b/desktop/src-tauri/src/commands/media_download.rs
@@ -8,7 +8,8 @@ use crate::commands::export_util::save_bytes_with_dialog;
 use crate::commands::media::{detect_and_validate_mime, mint_media_get_auth, sanitize_filename};
 use crate::commands::{
     personas::{
-        decode_snapshot_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, PNG_MAGIC,
+        parse_snapshot_payload_from_bytes, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES,
+        PNG_MAGIC,
     },
     team_snapshot::{
         decode_team_snapshot_from_bytes, MAX_TEAM_SNAPSHOT_JSON_BYTES, MAX_TEAM_SNAPSHOT_PNG_BYTES,
@@ -505,10 +506,12 @@ pub async fn fetch_snapshot_bytes(
 
     // 4. Bytes must parse as the snapshot type selected by the filename.
     //    Team parsing rejects retired flat JSON and persona-pack ZIP inputs
-    //    before anything reaches the frontend.
+    //    before anything reaches the frontend. Agent kinds accept both plain
+    //    manifests and structurally valid locked (encrypted) card envelopes —
+    //    transit validation never decrypts; unlock happens at import time.
     match kind {
         SnapshotFileKind::AgentJson | SnapshotFileKind::AgentPng => {
-            decode_snapshot_from_bytes(&bytes)
+            parse_snapshot_payload_from_bytes(&bytes)
                 .map_err(|e| format!("invalid agent snapshot: {e}"))?;
         }
         SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => {
diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs
new file mode 100644
index 0000000000..c516db1736
--- /dev/null
+++ b/desktop/src-tauri/src/commands/personas/card.rs
@@ -0,0 +1,972 @@
+//! `mint_agent_card` / `save_agent_card` Tauri commands — Agent Trading Cards.
+//!
+//! Mints a collectible trading-card PNG for an agent via one OpenAI Responses
+//! API call (designer model + native `image_generation` tool), then embeds the
+//! agent's `buzz_agent_snapshot` manifest through the existing snapshot
+//! encoder so the card IS an importable `.agent.png`.
+//!
+//! Boundary rules (agreed with Wren, buzz-agent-trading-cards thread):
+//! - Snapshot construction/injection reuses `agent_snapshot.rs` — cards
+//!   inherit manifest-v1 behavior, exclusions, and size checks. No card-only
+//!   wire format exists.
+//! - Memory inclusion is opt-in and shares the export flow's semantics: the
+//!   same three levels (`none`/`core`/`everything`), the same owner-gated
+//!   `get_agent_memory` fetch, and a memory source DERIVED from the resolved
+//!   instance (never caller-supplied), so cross-agent memory pairing is
+//!   structurally impossible. The default is `none`; the encoder still
+//!   rejects `none` + entries.
+//! - The 10 MiB `.agent.png` ceiling is enforced on the FINAL bytes (after
+//!   resize + chunk injection) via `validate_snapshot_encode_size`.
+//! - Round-trip verification decodes the final bytes and compares the logical
+//!   manifest before anything is returned to the frontend.
+//! - The API key is resolved through the same env layering the agent runtime
+//!   uses (global config < persona < agent record) and never leaves Rust.
+//!   It is never logged.
+
+use base64::{engine::general_purpose::STANDARD, Engine as _};
+use serde::{Deserialize, Serialize};
+use tauri::{AppHandle, State};
+
+use super::super::export_util::save_bytes_with_dialog;
+use super::snapshot::{
+    memory_entries_from_listing, parse_memory_level, resolve_from_lists,
+    validate_snapshot_encode_size,
+};
+use crate::{
+    app_state::AppState,
+    commands::engrams::get_agent_memory,
+    managed_agents::{
+        agent_snapshot::{
+            build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png,
+            extract_chunk_payload_png, MemoryLevel,
+        },
+        agent_snapshot_envelope::{
+            decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload,
+        },
+        load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas,
+        save_global_agent_config, validate_global_config,
+    },
+};
+
+/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation
+/// input only: it never participates in the snapshot manifest, PNG chunk,
+/// import decoder, or attachment validation. Embedded at compile time for
+/// deterministic packaging (see `card_template_decodes` test).
+const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png");
+
+/// Designer model driving copy + art direction.
+const DESIGNER_MODEL: &str = "gpt-5.6-sol";
+/// Image model invoked natively via the Responses `image_generation` tool.
+const IMAGE_MODEL: &str = "gpt-image-2";
+/// Final card width in pixels (2:3 portrait → 1500x2250).
+const CARD_WIDTH: u32 = 1500;
+/// Longest edge for the real avatar inlined into an unlocked card's manifest.
+/// Kind:0 pictures render small; 512px keeps the doubly-base64-encoded
+/// manifest chunk modest next to the 1500-wide card body.
+const MANIFEST_AVATAR_MAX_DIM: u32 = 512;
+/// Upper bound for a fetched avatar (pre-resize input to the model).
+const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024;
+/// One mint is a single long API call (~2–3 minutes observed).
+const MINT_TIMEOUT_SECS: u64 = 600;
+
+/// Error prefix the frontend matches to route the user to provider settings
+/// instead of showing a raw failure.
+pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:";
+
+/// Wire shape returned by `mint_agent_card`.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MintedCard {
+    /// Final `.agent.png` bytes (chunk-injected, round-trip verified),
+    /// base64-encoded for the IPC boundary.
+    pub card_png_base64: String,
+    /// Suggested filename, e.g. `eva.agent.png`.
+    pub file_name: String,
+    /// Designer commentary emitted alongside the image (may be empty).
+    pub designer_notes: String,
+    /// True when the embedded snapshot is NIP-44-encrypted to the
+    /// (owner, agent) pair — only their nsecs can import this card.
+    pub locked: bool,
+    /// How much memory is embedded in the card's snapshot ("none"/"core"/
+    /// "everything"). The viewer's import disclosure depends on this.
+    pub memory_level: MemoryLevel,
+}
+
+// ── Card archive ──────────────────────────────────────────────────────────────
+
+/// Sidecar metadata for one archived card PNG. Stored as `<stem>.json` next
+/// to `<stem>.agent.png` in the cards dir — two plain files per mint, no
+/// shared index to corrupt. Listing scans sidecars; a card whose PNG is
+/// missing is skipped rather than failing the whole list.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ArchivedCardMeta {
+    /// Unique on-disk PNG file name within the cards dir.
+    pub stored_file_name: String,
+    /// Suggested save-as name, e.g. `eva.agent.png`.
+    pub file_name: String,
+    /// The id the card was minted for (instance pubkey or definition slug).
+    pub agent_id: String,
+    pub agent_name: String,
+    pub designer_notes: String,
+    pub locked: bool,
+    /// Memory embedded in this card's snapshot. Defaults to `None` when the
+    /// sidecar predates the field — every pre-field mint was minted with
+    /// `MemoryLevel::None` (it was structural), so the default is honest.
+    #[serde(default)]
+    pub memory_level: MemoryLevel,
+    /// ISO-8601 mint timestamp.
+    pub minted_at: String,
+    /// Small JPEG preview for gallery grids, base64. Populated by
+    /// `list_agent_cards` from the sidecar thumb file — never stored in the
+    /// JSON sidecar itself.
+    #[serde(default, skip_deserializing)]
+    pub thumb_jpeg_base64: Option<String>,
+}
+
+fn cards_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
+    let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards");
+    std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?;
+    Ok(dir)
+}
+
+/// Persist a freshly minted card to the archive. Failures are surfaced to the
+/// caller (which logs and continues) — an archive write must never fail a
+/// mint the user already paid for.
+fn archive_minted_card(
+    app: &AppHandle,
+    agent_id: &str,
+    agent_name: &str,
+    card: &MintedCard,
+    bytes: &[u8],
+) -> Result<ArchivedCardMeta, String> {
+    let dir = cards_dir(app)?;
+    let stem = format!(
+        "{}-{}",
+        crate::util::slugify(agent_name, "agent", 50),
+        uuid::Uuid::new_v4()
+    );
+    let stored_file_name = format!("{stem}.agent.png");
+    let meta = ArchivedCardMeta {
+        stored_file_name: stored_file_name.clone(),
+        file_name: card.file_name.clone(),
+        agent_id: agent_id.to_string(),
+        agent_name: agent_name.to_string(),
+        designer_notes: card.designer_notes.clone(),
+        locked: card.locked,
+        memory_level: card.memory_level,
+        minted_at: crate::util::now_iso(),
+        thumb_jpeg_base64: None,
+    };
+    // PNG first, sidecar second: a crash between the two leaves an orphaned
+    // PNG (invisible to the list), never a sidecar pointing at nothing.
+    std::fs::write(dir.join(&stored_file_name), bytes)
+        .map_err(|e| format!("failed to write archived card: {e}"))?;
+    let meta_json = serde_json::to_string_pretty(&meta)
+        .map_err(|e| format!("failed to serialize card metadata: {e}"))?;
+    std::fs::write(dir.join(format!("{stem}.json")), meta_json)
+        .map_err(|e| format!("failed to write card metadata: {e}"))?;
+    // Thumb last and best-effort: the gallery grid falls back to lazy
+    // full-card loading for a card whose thumb is missing.
+    if let Ok(thumb) = encode_card_thumb(bytes) {
+        let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb);
+    }
+    Ok(meta)
+}
+
+/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card
+/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to
+/// draw a grid tile is waste.
+fn encode_card_thumb(bytes: &[u8]) -> Result<Vec<u8>, String> {
+    const THUMB_WIDTH: u32 = 300;
+    let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?;
+    let scale = THUMB_WIDTH as f64 / img.width() as f64;
+    let thumb = img.resize(
+        THUMB_WIDTH,
+        (img.height() as f64 * scale).round().max(1.0) as u32,
+        image::imageops::FilterType::Triangle,
+    );
+    let mut out = Vec::new();
+    // JPEG has no alpha; cards are opaque, so flatten unconditionally.
+    let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8());
+    rgb.write_to(
+        &mut std::io::Cursor::new(&mut out),
+        image::ImageFormat::Jpeg,
+    )
+    .map_err(|e| format!("thumb encode: {e}"))?;
+    Ok(out)
+}
+
+/// Reject any archive file name that could escape the cards dir or name a
+/// non-archive file. Archive names are generated by `archive_minted_card`
+/// (slug + UUID), so a strict shape check loses nothing legitimate.
+fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> {
+    let valid = stored_file_name.ends_with(".agent.png")
+        && !stored_file_name.contains(['/', '\\'])
+        && !stored_file_name.contains("..");
+    if !valid {
+        return Err("Invalid archived card file name.".to_string());
+    }
+    Ok(())
+}
+
+/// List all archived cards, newest first.
+#[tauri::command]
+pub fn list_agent_cards(app: AppHandle) -> Result<Vec<ArchivedCardMeta>, String> {
+    let dir = cards_dir(&app)?;
+    let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?;
+    let mut cards = Vec::new();
+    for entry in entries.flatten() {
+        let path = entry.path();
+        if path.extension().and_then(|e| e.to_str()) != Some("json") {
+            continue;
+        }
+        let Ok(content) = std::fs::read_to_string(&path) else {
+            continue;
+        };
+        let Ok(meta) = serde_json::from_str::<ArchivedCardMeta>(&content) else {
+            // A malformed sidecar hides one card, never the archive.
+            eprintln!(
+                "buzz-desktop: card-archive: skipping malformed sidecar {}",
+                path.display()
+            );
+            continue;
+        };
+        let mut meta = meta;
+        if validate_archive_file_name(&meta.stored_file_name).is_ok()
+            && dir.join(&meta.stored_file_name).is_file()
+        {
+            // Attach the pre-rendered grid thumb when present (best-effort).
+            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
+                meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg")))
+                    .ok()
+                    .map(|b| STANDARD.encode(&b));
+            }
+            cards.push(meta);
+        }
+    }
+    // ISO-8601 sorts lexicographically; newest first.
+    cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at));
+    Ok(cards)
+}
+
+/// Load one archived card's PNG bytes as base64, keyed by its stored file
+/// name (as returned by `list_agent_cards`).
+#[tauri::command]
+pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result<String, String> {
+    validate_archive_file_name(&stored_file_name)?;
+    let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name))
+        .map_err(|e| format!("failed to read archived card: {e}"))?;
+    Ok(STANDARD.encode(&bytes))
+}
+
+// ── Key resolution ────────────────────────────────────────────────────────────
+
+/// Pure layering: global env < persona env < agent record env, then the
+/// process environment as a development fallback. Returns the first
+/// non-empty value for `key`.
+pub(crate) fn resolve_env_from_layers(
+    key: &str,
+    global_env: &std::collections::BTreeMap<String, String>,
+    persona_env: &std::collections::BTreeMap<String, String>,
+    record_env: &std::collections::BTreeMap<String, String>,
+    process_value: Option<String>,
+) -> Option<String> {
+    for layer in [record_env, persona_env, global_env] {
+        if let Some(v) = layer.get(key) {
+            let v = v.trim();
+            if !v.is_empty() {
+                return Some(v.to_string());
+            }
+        }
+    }
+    process_value.filter(|k| !k.trim().is_empty())
+}
+
+/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env
+/// layering as the key) overrides the default host, supporting endpoints and
+/// proxies that speak the OpenAI Responses shape with Bearer auth. Azure
+/// OpenAI is NOT covered by this override alone — it uses its own URL scheme
+/// and `api-key` auth header, which would need a real driver.
+pub(crate) fn responses_url(base_url: Option<String>) -> String {
+    let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string());
+    format!("{}/responses", base.trim_end_matches('/'))
+}
+
+// ── Prompt construction ───────────────────────────────────────────────────────
+
+/// Build the designer instructions. Pure so tests can pin the contract:
+/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND
+/// card text) take primacy over those style defaults, but never over the
+/// fixed contract (frame identity, geometry, text fidelity).
+pub(crate) fn build_card_instructions(
+    agent_name: &str,
+    persona_notes: &str,
+    style_notes: &str,
+) -> String {
+    let owner_directions = if style_notes.trim().is_empty() {
+        String::new()
+    } else {
+        format!(
+            "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \
+             below wherever they conflict (they cannot change the frame, layout, or \
+             text-fidelity requirements). The owner may direct the art, the card text \
+             (type line, ability, flavor), or both:\n{style_notes}\n"
+        )
+    };
+    format!(
+        r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}".
+
+Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs.
+
+Persona notes for the card copy:
+{persona_notes}
+{owner_directions}
+First, write professional trading-card copy at Magic: The Gathering editorial quality:
+- a type line (e.g. "Legendary Agent — Team Lead"),
+- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff),
+- ONE italic flavor-text line, evocative and short, the kind that gets quoted.
+Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open.
+Keep total text-box copy under 220 characters so it renders cleanly.
+
+Then generate the finished card with the image tool, exactly 1024x1536 portrait:
+- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail.
+- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict.
+- Name banner: "{agent_name}" plus the type line beneath it in smaller type.
+- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing.
+- Top-right hex badge: one small emblem of your choice, no text.
+Render all text with perfect fidelity."#
+    )
+}
+
+/// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to
+/// `max_dim` on the longest edge so request payloads stay small.
+fn image_data_url(bytes: &[u8], max_dim: u32) -> Result<String, String> {
+    Ok(format!(
+        "data:image/png;base64,{}",
+        STANDARD.encode(png_bytes_resized(bytes, max_dim)?)
+    ))
+}
+
+/// Re-encode an image as PNG, downscaling so neither side exceeds `max_dim`.
+fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result<Vec<u8>, String> {
+    let img = image::load_from_memory(bytes).map_err(|e| format!("Failed to decode image: {e}"))?;
+    let img = if img.width().max(img.height()) > max_dim {
+        img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3)
+    } else {
+        img
+    };
+    let mut png = Vec::new();
+    img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
+        .map_err(|e| format!("Failed to encode image: {e}"))?;
+    Ok(png)
+}
+
+// ── Response parsing ──────────────────────────────────────────────────────────
+
+/// Extract the generated image (base64) and any designer text from a
+/// Responses API payload. Pure for testability.
+pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> {
+    let output = resp
+        .get("output")
+        .and_then(|o| o.as_array())
+        .ok_or_else(|| "Responses payload has no output array".to_string())?;
+
+    let mut image_b64 = None;
+    let mut notes = Vec::new();
+    for item in output {
+        match item.get("type").and_then(|t| t.as_str()) {
+            Some("image_generation_call") => {
+                if let Some(result) = item.get("result").and_then(|r| r.as_str()) {
+                    image_b64 = Some(result.to_string());
+                }
+            }
+            Some("message") => {
+                if let Some(content) = item.get("content").and_then(|c| c.as_array()) {
+                    for c in content {
+                        if c.get("type").and_then(|t| t.as_str()) == Some("output_text") {
+                            if let Some(text) = c.get("text").and_then(|t| t.as_str()) {
+                                notes.push(text.to_string());
+                            }
+                        }
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    let image_b64 = image_b64.ok_or_else(|| {
+        let types: Vec<&str> = output
+            .iter()
+            .filter_map(|i| i.get("type").and_then(|t| t.as_str()))
+            .collect();
+        format!("No image in Responses output (item types: {types:?})")
+    })?;
+    Ok((image_b64, notes.join("\n")))
+}
+
+// ── Commands ──────────────────────────────────────────────────────────────────
+
+/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card
+/// minting — a narrow seam with deliberately different semantics from the
+/// general `set_global_agent_config`:
+///
+/// - **No agent restarts.** The general command stops/restarts every running
+///   local agent whose effective env changes, because agent env is baked at
+///   spawn time. The mint command re-reads the config from disk on every
+///   mint, so minting needs no restart — and a card setup must never disrupt
+///   running agents as a side effect. Agents pick the key up naturally on
+///   their next (re)start.
+/// - **Read-modify-write of the latest on-disk config.** The config is
+///   re-read immediately before the single-key insert + write (under the
+///   managed-agents store lock, which serializes it against the other card
+///   and agent-store commands), so a settings save that landed after this
+///   dialog opened is not clobbered with a stale dialog-open snapshot.
+///   (The general settings editor performs its own whole-config write; as
+///   today, the last writer wins between the two surfaces.)
+///
+/// Standard global-config validation still applies (POSIX key shape,
+/// reserved-key reject, size caps) — this is not a validation bypass.
+#[tauri::command]
+pub fn card_mint_save_openai_key(
+    key: String,
+    app: AppHandle,
+    state: State<'_, AppState>,
+) -> Result<(), String> {
+    let key = key.trim().to_string();
+    if key.is_empty() {
+        return Err("API key cannot be empty.".to_string());
+    }
+
+    let _store_guard = state
+        .managed_agents_store_lock
+        .lock()
+        .map_err(|e| e.to_string())?;
+
+    let mut config = load_global_agent_config(&app)?;
+    config.env_vars.insert("OPENAI_API_KEY".to_string(), key);
+    validate_global_config(&config)?;
+    save_global_agent_config(&app, &config)
+}
+
+/// Report whether an OpenAI key would resolve for a card mint of agent `id`,
+/// using exactly the same env layering as `mint_agent_card`. Lets the mint
+/// dialog offer inline key setup BEFORE the user commits to a mint, instead
+/// of failing after the fact. Never returns the key itself.
+#[tauri::command]
+pub fn card_mint_key_status(
+    id: String,
+    app: AppHandle,
+    state: State<'_, AppState>,
+) -> Result<bool, String> {
+    let _store_guard = state
+        .managed_agents_store_lock
+        .lock()
+        .map_err(|e| e.to_string())?;
+
+    let instances = load_managed_agents(&app)?;
+    let definitions = load_agent_definitions(&app)?;
+    let (record, _) = resolve_from_lists(&id, &instances, &definitions)?;
+
+    let global = load_global_agent_config(&app).unwrap_or_default();
+    let personas = load_personas(&app).unwrap_or_default();
+    let persona_env = record
+        .persona_id
+        .as_deref()
+        .and_then(|pid| personas.iter().find(|p| p.id == pid))
+        .map(|p| p.env_vars.clone())
+        .unwrap_or_default();
+
+    Ok(resolve_env_from_layers(
+        "OPENAI_API_KEY",
+        &global.env_vars,
+        &persona_env,
+        &record.env_vars,
+        std::env::var("OPENAI_API_KEY").ok(),
+    )
+    .is_some())
+}
+
+/// Mint a trading card for the agent identified by `id` (instance pubkey,
+/// instance slug, or definition slug — same resolution as snapshot export).
+///
+/// When `lock` is true the embedded manifest is NIP-44-encrypted to the
+/// (owner, agent) pair per the locked-envelope contract — this requires a
+/// linked agent instance (the second key endpoint); bare definitions cannot
+/// be locked.
+///
+/// When `memory_level` is `"core"` or `"everything"`, the owner's decrypted
+/// memory for the agent is embedded in the manifest — same levels and fetch
+/// as snapshot export. The memory source is always the resolved instance
+/// itself (derived, never caller-supplied), so it requires a linked instance;
+/// bare definitions can only mint `"none"` (the default).
+///
+/// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes.
+/// Reroll = call again; the command holds no session state.
+#[tauri::command]
+pub async fn mint_agent_card(
+    id: String,
+    style_notes: Option<String>,
+    lock: Option<bool>,
+    memory_level: Option<String>,
+    app: AppHandle,
+    state: State<'_, AppState>,
+) -> Result<MintedCard, String> {
+    let lock = lock.unwrap_or(false);
+    let memory_level = parse_memory_level(memory_level.as_deref().unwrap_or(""))?;
+    // ── Resolve the record + API key under lock ──────────────────────────────
+    let (mut record, is_definition, api_key, base_url) = {
+        let _store_guard = state
+            .managed_agents_store_lock
+            .lock()
+            .map_err(|e| e.to_string())?;
+
+        let instances = load_managed_agents(&app)?;
+        let definitions = load_agent_definitions(&app)?;
+        let (record, is_definition) =
+            resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?;
+
+        let global = load_global_agent_config(&app).unwrap_or_default();
+        let personas = load_personas(&app).unwrap_or_default();
+        let persona_env = record
+            .persona_id
+            .as_deref()
+            .and_then(|pid| personas.iter().find(|p| p.id == pid))
+            .map(|p| p.env_vars.clone())
+            .unwrap_or_default();
+
+        let api_key = resolve_env_from_layers(
+            "OPENAI_API_KEY",
+            &global.env_vars,
+            &persona_env,
+            &record.env_vars,
+            std::env::var("OPENAI_API_KEY").ok(),
+        )
+        .ok_or_else(|| {
+            format!(
+                "{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \
+                 environment variables or global agent settings to mint cards."
+            )
+        })?;
+        let base_url = resolve_env_from_layers(
+            "OPENAI_BASE_URL",
+            &global.env_vars,
+            &persona_env,
+            &record.env_vars,
+            std::env::var("OPENAI_BASE_URL").ok(),
+        );
+
+        (record, is_definition, api_key, base_url)
+    };
+
+    // ── Locking needs its two exact key endpoints up front, BEFORE the
+    //    API spend: the owner identity secret and the agent instance pubkey.
+    let lock_keys = if lock {
+        if is_definition {
+            return Err(
+                "Locked cards need a linked agent instance — this persona has never been \
+                 started, so there is no agent key to lock to."
+                    .to_string(),
+            );
+        }
+        let owner_keys = state.signing_keys()?;
+        // Same canonical check the envelope decoder enforces (incl. curve
+        // validation) — a non-point record pubkey must fail BEFORE the API
+        // spend, not at post-mint encryption.
+        let agent_pubkey = crate::managed_agents::agent_snapshot_envelope::parse_canonical_pubkey(
+            "agentPubkey",
+            &record.pubkey,
+        )
+        .map_err(|_| {
+            "Agent record has an invalid pubkey (not a canonical x-only key).".to_string()
+        })?;
+        if owner_keys.public_key() == agent_pubkey {
+            return Err("Cannot lock a card to itself: owner and agent keys match.".to_string());
+        }
+        Some((owner_keys, agent_pubkey))
+    } else {
+        None
+    };
+
+    // ── Memory needs a keyed instance, resolved up front BEFORE the API
+    //    spend — the memory source is always the resolved instance itself
+    //    (derived, never caller-supplied), so cross-agent pairing cannot be
+    //    expressed. A failed fetch fails the mint here, not after payment.
+    let memory_entries = if memory_level == MemoryLevel::None {
+        Vec::new()
+    } else {
+        if is_definition {
+            return Err(
+                "Cards with memory need a linked agent instance — this persona has never \
+                 been started, so there is no agent memory to include."
+                    .to_string(),
+            );
+        }
+        let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?;
+        memory_entries_from_listing(listing, memory_level)
+    };
+
+    let display_name = record
+        .display_name
+        .clone()
+        .unwrap_or_else(|| record.name.clone());
+
+    // ── Prefer the agent's own kind:0 profile picture ────────────────────────
+    // The record's `avatar_url` is a stale presentation snapshot: with
+    // agent-managed profiles the agent updates its own kind:0 `picture` and
+    // desktop reconciliation is disabled (`agent_settings.rs`), so the relay
+    // profile — not the local record — is the live source of truth for how the
+    // agent looks. Definitions have no keypair and thus no kind:0; they keep
+    // the record's avatar. A relay error fails the mint here, BEFORE the API
+    // spend (same fail-early rule as the key/memory guards above) — minting
+    // with the wrong face wastes the spend it was supposed to protect.
+    if !is_definition {
+        let relay_url = crate::relay::effective_agent_relay_url(
+            &record.relay_url,
+            &crate::relay::relay_ws_url_with_override(&state),
+        );
+        let profile = crate::relay::query_agent_profile(&state, &relay_url, &record.pubkey)
+            .await
+            .map_err(|e| format!("Could not read the agent's profile for its avatar: {e}"))?;
+        record.avatar_url = preferred_avatar_url(
+            profile.and_then(|info| info.picture),
+            record.avatar_url.take(),
+        );
+    }
+
+    // ── Resolve avatar bytes (data URL, else fetch) ──────────────────────────
+    let avatar_bytes = match record.avatar_url.as_deref() {
+        Some(url) if url.starts_with("data:") => decode_avatar_data_url(url)
+            .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?,
+        Some(url) if url.starts_with("http://") || url.starts_with("https://") => {
+            // Relay-hosted avatars (kind:0 pictures under the relay's /media/)
+            // may require Blossom get-auth (`require_media_get_auth`). Mint the
+            // header ONLY for same-origin URLs so the token never leaves the
+            // relay (same contract as `media_download.rs`).
+            let relay_base = crate::relay::relay_api_base_url_with_override(&state);
+            let auth = is_same_origin(url, &relay_base)
+                .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base))
+                .flatten();
+            fetch_avatar(url, auth.as_deref()).await?
+        }
+        _ => {
+            return Err(
+                "Agent has no avatar image. Set an avatar before minting a card.".to_string(),
+            )
+        }
+    };
+
+    // ── Build the manifest now (with any requested memory) so a broken agent
+    //    fails before we spend minutes on the API call. ───────────────────────
+    let manifest_avatar = manifest_avatar_bytes(
+        lock_keys.is_some(),
+        &avatar_bytes,
+        record.avatar_url.as_deref(),
+    )?;
+    let snapshot = build_snapshot(
+        &record,
+        memory_level,
+        memory_entries,
+        manifest_avatar.as_deref(),
+    );
+
+    // ── One Responses API call ───────────────────────────────────────────────
+    // For locked mints, prove the manifest (including any embedded memory)
+    // fits the NIP-44 plaintext cap BEFORE spending minutes on the API call
+    // (same fail-early rule as the memory guard above).
+    if lock_keys.is_some() {
+        let json_len =
+            crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot)?.len();
+        if json_len > buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX {
+            let hint = if memory_level == MemoryLevel::None {
+                "Reduce the avatar size or mint an unlocked card."
+            } else {
+                "Include less memory, reduce the avatar size, or mint an unlocked card."
+            };
+            return Err(format!(
+                "Agent manifest is too large to lock ({json_len} bytes; the encrypted \
+                 format caps at {}). {hint}",
+                buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX
+            ));
+        }
+    }
+    let instructions = build_card_instructions(
+        &display_name,
+        snapshot.definition.system_prompt.as_deref().unwrap_or(""),
+        style_notes.as_deref().unwrap_or(""),
+    );
+    let body = serde_json::json!({
+        "model": DESIGNER_MODEL,
+        "reasoning": {"effort": "high"},
+        "instructions": "You are a senior TCG card designer and MTG rules editor.",
+        "input": [{
+            "role": "user",
+            "content": [
+                {"type": "input_text", "text": instructions},
+                {"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?},
+                {"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?},
+            ],
+        }],
+        "tools": [{
+            "type": "image_generation",
+            "model": IMAGE_MODEL,
+            "quality": "high",
+            "size": "1024x1536",
+            "output_format": "png",
+        }],
+        "tool_choice": "required",
+    });
+
+    let client = reqwest::Client::builder()
+        .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS))
+        .build()
+        .map_err(|e| format!("Failed to build HTTP client: {e}"))?;
+    let resp = client
+        .post(responses_url(base_url))
+        .bearer_auth(&api_key)
+        .json(&body)
+        .send()
+        .await
+        .map_err(|e| format!("Card mint request failed: {e}"))?;
+
+    let status = resp.status();
+    let payload: serde_json::Value = resp
+        .json()
+        .await
+        .map_err(|e| format!("Card mint response was not JSON: {e}"))?;
+    if !status.is_success() {
+        // Never echo the request (it embeds nothing secret, but keep the
+        // failure surface small); the OpenAI error body is safe to surface.
+        let detail = payload
+            .get("error")
+            .and_then(|e| e.get("message"))
+            .and_then(|m| m.as_str())
+            .unwrap_or("unknown error");
+        return Err(format!("Card mint failed (HTTP {status}): {detail}"));
+    }
+
+    let (image_b64, designer_notes) = extract_card_output(&payload)?;
+    let raw_card = STANDARD
+        .decode(image_b64.as_bytes())
+        .map_err(|e| format!("Generated image was not valid base64: {e}"))?;
+
+    // ── Resize to 1500-wide, inject chunk via the existing encoder ──────────
+    let card_img = image::load_from_memory(&raw_card)
+        .map_err(|e| format!("Generated image could not be decoded: {e}"))?;
+    let scale = CARD_WIDTH as f64 / card_img.width() as f64;
+    let card_img = card_img.resize(
+        CARD_WIDTH,
+        (card_img.height() as f64 * scale).round() as u32,
+        image::imageops::FilterType::Lanczos3,
+    );
+    let mut card_png = Vec::new();
+    card_img
+        .write_to(
+            &mut std::io::Cursor::new(&mut card_png),
+            image::ImageFormat::Png,
+        )
+        .map_err(|e| format!("Failed to encode card PNG: {e}"))?;
+
+    let final_bytes = match &lock_keys {
+        None => encode_snapshot_png(&snapshot, Some(&card_png))
+            .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?,
+        Some((owner_keys, agent_pubkey)) => {
+            encode_locked_snapshot_png(&snapshot, owner_keys, agent_pubkey, Some(&card_png))
+                .map_err(|e| format!("Failed to embed locked agent snapshot in card: {e}"))?
+        }
+    };
+
+    // ── Verify: size ceiling + round-trip on the FINAL bytes ────────────────
+    // Locked cards: extract the actual chunk, parse the envelope, decrypt
+    // with the owner key, then compare the logical manifest (ciphertext is
+    // nondeterministic — never compare bytes).
+    validate_snapshot_encode_size(final_bytes.len(), true)?;
+    let decoded = match &lock_keys {
+        None => decode_snapshot_png(&final_bytes)
+            .map_err(|e| format!("Card failed round-trip verification: {e}"))?,
+        Some((owner_keys, _)) => {
+            let payload = extract_chunk_payload_png(&final_bytes)
+                .map_err(|e| format!("Card failed round-trip verification: {e}"))?;
+            match parse_chunk_payload(&payload)
+                .map_err(|e| format!("Card failed round-trip verification: {e}"))?
+            {
+                ChunkPayload::Locked(envelope) => {
+                    decrypt_envelope(&envelope, owner_keys.secret_key())
+                        .map_err(|e| format!("Card failed round-trip verification: {e}"))?
+                }
+                ChunkPayload::Plain(_) => {
+                    return Err(
+                        "Card round-trip verification failed: expected a locked envelope."
+                            .to_string(),
+                    )
+                }
+            }
+        }
+    };
+    if decoded != snapshot {
+        return Err("Card round-trip verification failed: manifest mismatch.".to_string());
+    }
+
+    let slug = crate::util::slugify(&display_name, "agent", 50);
+    let minted = MintedCard {
+        card_png_base64: STANDARD.encode(&final_bytes),
+        file_name: format!("{slug}.agent.png"),
+        designer_notes,
+        locked: lock_keys.is_some(),
+        memory_level,
+    };
+
+    // Archive best-effort: the mint is already paid for and verified, so a
+    // failed archive write logs and continues — it never fails the mint.
+    if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) {
+        eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}");
+    }
+
+    Ok(minted)
+}
+
+/// The avatar the mint should use: the agent's kind:0 `picture` when one is
+/// published and non-blank, else the local record's `avatar_url`.
+///
+/// Pure so the precedence is unit-testable without a relay: a blank or
+/// whitespace-only `picture` must NOT shadow a real record avatar.
+fn preferred_avatar_url(
+    kind0_picture: Option<String>,
+    record_avatar_url: Option<String>,
+) -> Option<String> {
+    kind0_picture
+        .filter(|p| !p.trim().is_empty())
+        .or(record_avatar_url)
+}
+
+/// The avatar bytes the card manifest should inline.
+///
+/// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is
+/// the generated card artwork, and the importer only adopts the body as the
+/// avatar when the manifest carries no inline bytes (`import.rs`) — without
+/// these bytes an imported agent would wear the card as its face. Downscaled
+/// to [`MANIFEST_AVATAR_MAX_DIM`] so the manifest tEXt chunk stays small.
+///
+/// Locked cards keep the data-URL-only behavior: the whole manifest must fit
+/// the NIP-44 plaintext cap (65 KB), which cannot carry inline pixels, and a
+/// locked envelope never reaches the import body override anyway.
+fn manifest_avatar_bytes(
+    locked: bool,
+    avatar_bytes: &[u8],
+    record_avatar_url: Option<&str>,
+) -> Result<Option<Vec<u8>>, String> {
+    if locked {
+        return Ok(decode_avatar_data_url(record_avatar_url.unwrap_or("")));
+    }
+    png_bytes_resized(avatar_bytes, MANIFEST_AVATAR_MAX_DIM)
+        .map(Some)
+        .map_err(|e| format!("Failed to inline the agent avatar into the card manifest: {e}"))
+}
+
+/// True when `url` shares an origin (scheme, host, port) with `relay_base`.
+///
+/// Gate for attaching the minted media get-auth header — the token must never
+/// be sent to a non-relay origin (same contract as `validate_download_url` in
+/// `media_download.rs`, but non-fatal: a foreign origin just fetches
+/// unauthenticated instead of failing the mint).
+fn is_same_origin(url: &str, relay_base: &str) -> bool {
+    match (url::Url::parse(url), url::Url::parse(relay_base)) {
+        (Ok(u), Ok(b)) => u.origin() == b.origin(),
+        _ => false,
+    }
+}
+
+/// Fetch an avatar over HTTP with a hard size cap.
+///
+/// `auth` is an optional pre-minted Blossom get-auth header value, attached
+/// verbatim — the caller is responsible for only supplying it for
+/// relay-origin URLs. Redirects are not followed when auth is present
+/// (redirect-hop guard, same rule as `media_download.rs`).
+///
+/// The cap bounds network and memory, not just the final buffer: the
+/// Content-Length header is checked before any body bytes are read, and the
+/// body is streamed with a running count so a missing or dishonest header
+/// still cannot exceed the cap (same contract as `media_download.rs`).
+async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result<Vec<u8>, String> {
+    use futures_util::StreamExt;
+
+    let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30));
+    if auth.is_some() {
+        // Never let a relay 3xx forward the auth header across origins.
+        builder = builder.redirect(reqwest::redirect::Policy::none());
+    }
+    let client = builder
+        .build()
+        .map_err(|e| format!("Failed to build HTTP client: {e}"))?;
+    let mut req = client.get(url);
+    if let Some(auth) = auth {
+        req = req.header("authorization", auth);
+    }
+    let resp = req
+        .send()
+        .await
+        .map_err(|e| format!("Failed to fetch agent avatar: {e}"))?;
+    if !resp.status().is_success() {
+        return Err(format!("Avatar fetch failed: HTTP {}", resp.status()));
+    }
+
+    if let Some(content_length) = resp.content_length() {
+        if content_length > MAX_AVATAR_FETCH_BYTES as u64 {
+            return Err("Agent avatar is too large to use as card input.".to_string());
+        }
+    }
+
+    let mut bytes = Vec::new();
+    let mut stream = resp.bytes_stream();
+    while let Some(chunk) = stream.next().await {
+        let chunk = chunk.map_err(|e| format!("Failed to read avatar bytes: {e}"))?;
+        append_within_avatar_cap(&mut bytes, &chunk)?;
+    }
+    Ok(bytes)
+}
+
+/// Append a body chunk to the avatar buffer, rejecting before the append if
+/// the total would cross `MAX_AVATAR_FETCH_BYTES`. Split out so the cap
+/// boundary is unit-testable without an HTTP server.
+fn append_within_avatar_cap(buf: &mut Vec<u8>, chunk: &[u8]) -> Result<(), String> {
+    if buf.len() + chunk.len() > MAX_AVATAR_FETCH_BYTES {
+        return Err("Agent avatar is too large to use as card input.".to_string());
+    }
+    buf.extend_from_slice(chunk);
+    Ok(())
+}
+
+/// Save previously minted card bytes to disk via the OS save dialog.
+///
+/// Re-validates the bytes (chunk parses as a plain manifest or a
+/// structurally valid locked envelope, size within the import ceiling) so a
+/// corrupted preview can never be written as a `.agent.png`. No decryption
+/// happens here — the mint already round-trip-verified with the real key.
+#[tauri::command]
+pub async fn save_agent_card(
+    card_png_base64: String,
+    file_name: String,
+    app: AppHandle,
+) -> Result<bool, String> {
+    let bytes = STANDARD
+        .decode(card_png_base64.as_bytes())
+        .map_err(|e| format!("Card bytes were not valid base64: {e}"))?;
+    validate_snapshot_encode_size(bytes.len(), true)?;
+    let payload = extract_chunk_payload_png(&bytes)
+        .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?;
+    parse_chunk_payload(&payload)
+        .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?;
+
+    let safe_name = if file_name.ends_with(".agent.png") && !file_name.contains(['/', '\\']) {
+        file_name
+    } else {
+        "card.agent.png".to_string()
+    };
+    save_bytes_with_dialog(&app, &safe_name, "Agent card", &["png"], &bytes).await
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs
new file mode 100644
index 0000000000..ca69c43866
--- /dev/null
+++ b/desktop/src-tauri/src/commands/personas/card/tests.rs
@@ -0,0 +1,324 @@
+//! Unit tests for `card.rs` — split into a child module file so the parent
+//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`).
+
+use super::*;
+use std::collections::BTreeMap;
+
+#[test]
+fn archive_file_name_validation_rejects_escapes() {
+    assert!(validate_archive_file_name("eva-1234.agent.png").is_ok());
+    for bad in [
+        "../escape.agent.png",
+        "sub/dir.agent.png",
+        "sub\\dir.agent.png",
+        "not-a-card.png",
+        "plain.json",
+        "",
+    ] {
+        assert!(
+            validate_archive_file_name(bad).is_err(),
+            "expected rejection: {bad:?}"
+        );
+    }
+}
+
+#[test]
+fn card_template_decodes_with_expected_shape() {
+    // The embedded template is generation input only, but a corrupt or
+    // accidentally swapped asset should fail the build's test gate, not a
+    // user's first mint.
+    let img = image::load_from_memory(CARD_TEMPLATE_PNG).expect("template must decode");
+    // 2:3-ish portrait frame.
+    assert!(img.height() > img.width(), "template must be portrait");
+    assert!(img.width() >= 512, "template unexpectedly small");
+}
+
+#[test]
+fn key_resolution_layering_record_wins() {
+    let mut global = BTreeMap::new();
+    global.insert("OPENAI_API_KEY".to_string(), "global".to_string());
+    let mut persona = BTreeMap::new();
+    persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string());
+    let mut record = BTreeMap::new();
+    record.insert("OPENAI_API_KEY".to_string(), "record".to_string());
+
+    assert_eq!(
+        resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
+        Some("record")
+    );
+    record.clear();
+    assert_eq!(
+        resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
+        Some("persona")
+    );
+    persona.clear();
+    assert_eq!(
+        resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(),
+        Some("global")
+    );
+    global.clear();
+    assert_eq!(
+        resolve_env_from_layers(
+            "OPENAI_API_KEY",
+            &global,
+            &persona,
+            &record,
+            Some("process".to_string())
+        )
+        .as_deref(),
+        Some("process")
+    );
+    assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none());
+}
+
+#[test]
+fn key_resolution_skips_blank_values() {
+    let mut record = BTreeMap::new();
+    record.insert("OPENAI_API_KEY".to_string(), "   ".to_string());
+    let mut persona = BTreeMap::new();
+    persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string());
+    assert_eq!(
+        resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None)
+            .as_deref(),
+        Some("persona")
+    );
+}
+
+#[test]
+fn responses_url_default_and_override() {
+    assert_eq!(responses_url(None), "https://api.openai.com/v1/responses");
+    // Trailing slashes must not produce a double-slash path.
+    assert_eq!(
+        responses_url(Some("https://proxy.example/v1/".to_string())),
+        "https://proxy.example/v1/responses"
+    );
+    assert_eq!(
+        responses_url(Some("https://proxy.example/v1".to_string())),
+        "https://proxy.example/v1/responses"
+    );
+}
+
+#[test]
+fn instructions_pin_style_match_default_and_owner_primacy() {
+    let base = build_card_instructions("Eva", "leads the team", "");
+    assert!(base.contains("match input image 2's art style EXACTLY"));
+    assert!(base.contains("\"Eva\""));
+    assert!(!base.contains("OWNER'S DIRECTIONS"));
+
+    let directed = build_card_instructions("Eva", "leads the team", "make it stormy");
+    // Owner directions take primacy over style defaults...
+    assert!(directed.contains("OWNER'S DIRECTIONS"));
+    assert!(directed.contains("make it stormy"));
+    assert!(directed.contains("override the default art-style and copy guidance"));
+    // ...but the fixed contract survives: frame, style anchor (as an
+    // overridable default), and text-fidelity requirements stay present.
+    assert!(directed.contains("match input image 2's art style EXACTLY"));
+    assert!(directed.contains("cannot change the frame, layout, or"));
+    assert!(directed.contains("Render all text with perfect fidelity"));
+    // Card-text direction is an explicitly named capability, and the
+    // owner-wording rule acknowledges the fixed 220-char text-box limit
+    // (no mutually impossible "verbatim" vs "under 220 chars" pair).
+    assert!(directed.contains("card text"));
+    assert!(directed.contains("use their wording within the 220-character text-box limit"));
+}
+
+#[test]
+fn extract_card_output_happy_path_and_missing_image() {
+    let ok = serde_json::json!({
+        "output": [
+            {"type": "reasoning"},
+            {"type": "image_generation_call", "result": "aW1n"},
+            {"type": "message", "content": [
+                {"type": "output_text", "text": "notes here"}
+            ]}
+        ]
+    });
+    let (img, notes) = extract_card_output(&ok).unwrap();
+    assert_eq!(img, "aW1n");
+    assert_eq!(notes, "notes here");
+
+    let missing = serde_json::json!({"output": [{"type": "message", "content": []}]});
+    let err = extract_card_output(&missing).unwrap_err();
+    assert!(err.contains("No image"), "{err}");
+
+    let no_output = serde_json::json!({});
+    assert!(extract_card_output(&no_output).is_err());
+}
+
+#[test]
+fn kind0_picture_wins_over_record_avatar_unless_blank() {
+    let some = |s: &str| Some(s.to_string());
+    // Published picture wins.
+    assert_eq!(
+        preferred_avatar_url(some("https://relay/media/k0.png"), some("data:image/png;x")),
+        some("https://relay/media/k0.png")
+    );
+    // No profile / no picture: record avatar survives.
+    assert_eq!(
+        preferred_avatar_url(None, some("data:image/png;x")),
+        some("data:image/png;x")
+    );
+    // Blank or whitespace picture must not shadow a real avatar.
+    assert_eq!(
+        preferred_avatar_url(some(""), some("data:image/png;x")),
+        some("data:image/png;x")
+    );
+    assert_eq!(
+        preferred_avatar_url(some("  "), some("data:image/png;x")),
+        some("data:image/png;x")
+    );
+    // Nothing anywhere: None (caller surfaces the "no avatar" error).
+    assert_eq!(preferred_avatar_url(None, None), None);
+}
+
+#[test]
+fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() {
+    // 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color.
+    let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+        700,
+        700,
+        image::Rgba([9, 120, 33, 255]),
+    ));
+    let mut avatar_png = std::io::Cursor::new(Vec::new());
+    avatar
+        .write_to(&mut avatar_png, image::ImageFormat::Png)
+        .unwrap();
+
+    let inlined = manifest_avatar_bytes(false, avatar_png.get_ref(), None)
+        .unwrap()
+        .expect("unlocked mints must inline the real avatar");
+    let img = image::load_from_memory(&inlined).unwrap();
+    assert_eq!(
+        (img.width(), img.height()),
+        (MANIFEST_AVATAR_MAX_DIM, MANIFEST_AVATAR_MAX_DIM)
+    );
+    assert_eq!(img.to_rgba8().get_pixel(0, 0).0, [9, 120, 33, 255]);
+
+    // Undecodable avatar bytes fail the mint (pre-spend), never silently
+    // produce a card whose import would wear the artwork as a face.
+    assert!(manifest_avatar_bytes(false, b"not a png", None).is_err());
+}
+
+#[test]
+fn locked_manifest_keeps_data_url_only_avatar() {
+    // Locked mints must not inline fetched bytes (NIP-44 cap): only a record
+    // data URL carries over, exactly as before.
+    let unused = [0u8; 4];
+    assert_eq!(
+        manifest_avatar_bytes(true, &unused, Some("data:image/png;base64,aGk="))
+            .unwrap()
+            .as_deref(),
+        Some(b"hi".as_slice())
+    );
+    assert_eq!(
+        manifest_avatar_bytes(true, &unused, Some("https://relay/media/a.png")).unwrap(),
+        None
+    );
+    assert_eq!(manifest_avatar_bytes(true, &unused, None).unwrap(), None);
+}
+
+#[test]
+fn media_get_auth_gate_is_same_origin_only() {
+    // The minted Blossom get-auth header may only travel to the relay's own
+    // origin — scheme, host, and port all count (same contract as
+    // `validate_download_url` in `media_download.rs`).
+    let relay = "https://relay.example.com";
+    assert!(is_same_origin(
+        "https://relay.example.com/media/abc.png",
+        relay
+    ));
+    // Different host, scheme, or port: no auth.
+    assert!(!is_same_origin(
+        "https://evil.example.com/media/abc.png",
+        relay
+    ));
+    assert!(!is_same_origin(
+        "http://relay.example.com/media/abc.png",
+        relay
+    ));
+    assert!(!is_same_origin(
+        "https://relay.example.com:8443/media/abc.png",
+        relay
+    ));
+    // Unparseable inputs fail closed.
+    assert!(!is_same_origin("not a url", relay));
+    assert!(!is_same_origin(
+        "https://relay.example.com/x",
+        "also not a url"
+    ));
+    // Explicit port on both sides matches.
+    assert!(is_same_origin(
+        "http://localhost:3100/media/abc.png",
+        "http://localhost:3100"
+    ));
+}
+
+#[test]
+fn avatar_cap_rejects_before_appending_crossing_chunk() {
+    // The streaming accumulator must reject a chunk that would cross the
+    // cap BEFORE buffering it — this is what bounds memory when
+    // Content-Length is missing or dishonest.
+    let mut buf = vec![0u8; MAX_AVATAR_FETCH_BYTES - 1];
+    assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_ok());
+    assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES);
+    // Exactly at the cap: one more byte must fail and not grow the buffer.
+    assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_err());
+    assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES);
+
+    // A single oversized chunk is rejected outright.
+    let mut fresh = Vec::new();
+    let oversized = vec![0u8; MAX_AVATAR_FETCH_BYTES + 1];
+    assert!(append_within_avatar_cap(&mut fresh, &oversized).is_err());
+    assert!(fresh.is_empty());
+}
+
+#[test]
+fn save_rejects_plain_png_without_snapshot_chunk() {
+    // A plain PNG (no buzz_agent_snapshot chunk) must not be saveable as
+    // a card. Exercise the same validation the command runs.
+    let img = image::DynamicImage::new_rgba8(4, 4);
+    let mut png = Vec::new();
+    img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
+        .unwrap();
+    assert!(decode_snapshot_png(&png).is_err());
+}
+
+#[test]
+fn archived_sidecar_without_memory_level_defaults_to_none() {
+    // Every mint before the memory option existed embedded MemoryLevel::None
+    // structurally, so old sidecars (no memoryLevel field) must deserialize
+    // to None — the gallery's disclosure depends on this being honest.
+    let legacy = r#"{
+        "storedFileName": "eva-1234.agent.png",
+        "fileName": "eva.agent.png",
+        "agentId": "abc",
+        "agentName": "Eva",
+        "designerNotes": "",
+        "locked": false,
+        "mintedAt": "2026-07-28T00:00:00Z"
+    }"#;
+    let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap();
+    assert_eq!(meta.memory_level, MemoryLevel::None);
+
+    let with_level = legacy.replace(
+        "\"locked\": false,",
+        "\"locked\": false, \"memoryLevel\": \"everything\",",
+    );
+    let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap();
+    assert_eq!(meta.memory_level, MemoryLevel::Everything);
+}
+
+#[test]
+fn minted_card_serializes_memory_level_snake_case_value() {
+    // The TS layer narrows on the exact wire strings "none"/"core"/
+    // "everything" — pin the serde representation the frontend will see.
+    let minted = MintedCard {
+        card_png_base64: String::new(),
+        file_name: "eva.agent.png".to_string(),
+        designer_notes: String::new(),
+        locked: false,
+        memory_level: MemoryLevel::Core,
+    };
+    let json = serde_json::to_value(&minted).unwrap();
+    assert_eq!(json["memoryLevel"], "core");
+}
diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs
index 66f7296a25..0cd7ad0324 100644
--- a/desktop/src-tauri/src/commands/personas/mod.rs
+++ b/desktop/src-tauri/src/commands/personas/mod.rs
@@ -306,11 +306,14 @@ pub async fn set_persona_active(
 }
 
 pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47];
+mod card;
 mod snapshot;
-pub use snapshot::encode_agent_snapshot_for_send;
-pub use snapshot::export_agent_snapshot;
+pub use card::*;
+#[cfg(test)]
+pub(crate) use snapshot::import::decode_snapshot_from_bytes;
 pub(crate) use snapshot::import::{
-    decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES,
+    parse_snapshot_payload_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES,
     MAX_SNAPSHOT_PNG_BYTES,
 };
 pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import};
+pub use snapshot::{encode_agent_snapshot_for_send, export_agent_snapshot};
diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs
index 583296dac0..e7bd1597e6 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot.rs
@@ -142,7 +142,7 @@ pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> R
 }
 
 /// Parse a `memory_level` string to `MemoryLevel`.
-fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
+pub(crate) fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
     match s {
         "none" | "" => Ok(MemoryLevel::None),
         "core" => Ok(MemoryLevel::Core),
@@ -153,6 +153,32 @@ fn parse_memory_level(s: &str) -> Result<MemoryLevel, String> {
     }
 }
 
+/// Flatten an owner-decrypted memory listing into manifest entries for
+/// `memory_level`: `Core` takes the core entry only; `Everything` appends all
+/// `mem/*` entries after it. Pure so both the export and card-mint paths share
+/// (and tests can pin) the level → entries selection.
+pub(crate) fn memory_entries_from_listing(
+    listing: crate::commands::engrams::AgentMemoryListing,
+    memory_level: MemoryLevel,
+) -> Vec<AgentSnapshotMemoryEntry> {
+    let mut entries = Vec::new();
+    if let Some(core) = listing.core {
+        entries.push(AgentSnapshotMemoryEntry {
+            slug: core.slug,
+            body: core.body,
+        });
+    }
+    if memory_level == MemoryLevel::Everything {
+        for mem in listing.memories {
+            entries.push(AgentSnapshotMemoryEntry {
+                slug: mem.slug,
+                body: mem.body,
+            });
+        }
+    }
+    entries
+}
+
 /// Parse a `format` string to a PNG flag.
 fn parse_format_is_png(s: &str) -> Result<bool, String> {
     match s {
@@ -267,22 +293,7 @@ pub(crate) async fn materialize_snapshot_bytes(
     // ── Fetch memory ─────────────────────────────────────────────────────────
     let memory_entries: Vec<AgentSnapshotMemoryEntry> = if let Some(pubkey) = memory_pubkey {
         let listing = get_agent_memory(pubkey, app.clone(), state).await?;
-        let mut entries = Vec::new();
-        if let Some(core) = listing.core {
-            entries.push(AgentSnapshotMemoryEntry {
-                slug: core.slug,
-                body: core.body,
-            });
-        }
-        if memory_level == MemoryLevel::Everything {
-            for mem in listing.memories {
-                entries.push(AgentSnapshotMemoryEntry {
-                    slug: mem.slug,
-                    body: mem.body,
-                });
-            }
-        }
-        entries
+        memory_entries_from_listing(listing, memory_level)
     } else {
         Vec::new()
     };
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
index 00a1457393..b769d74d7b 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs
@@ -208,3 +208,64 @@ fn import_png_placeholder_keeps_manifest_avatar_fallback() {
     assert!(decoded.profile.avatar_data_url.is_none());
     assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
 }
+
+/// An unlocked trading card imports the agent's REAL avatar, never the card.
+///
+/// Mint-shaped input: the PNG body is the generated card artwork, while the
+/// manifest inlines the source avatar (`manifest_avatar_bytes` in `card.rs`).
+/// The #3578 body-wins override must not fire when the manifest already
+/// carries inline avatar bytes — otherwise the imported agent publishes the
+/// 1500-wide card as its kind:0 picture.
+#[test]
+fn import_unlocked_card_uses_manifest_avatar_not_card_artwork() {
+    use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png};
+    use base64::{engine::general_purpose::STANDARD, Engine as _};
+
+    // The real avatar: 4×3 solid blue, inlined in the manifest at mint time.
+    let real_avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+        4,
+        3,
+        image::Rgba([23, 91, 177, 255]),
+    ));
+    let mut real_avatar_png = std::io::Cursor::new(Vec::new());
+    real_avatar
+        .write_to(&mut real_avatar_png, image::ImageFormat::Png)
+        .unwrap();
+
+    let mut snapshot = make_snapshot(MemoryLevel::None, vec![]);
+    snapshot.profile.avatar_data_url = Some(format!(
+        "data:image/png;base64,{}",
+        STANDARD.encode(real_avatar_png.get_ref())
+    ));
+    snapshot.profile.avatar_url = Some("https://relay.example/media/live-kind0.png".to_string());
+
+    // The card artwork: a distinct 1500×2250 solid red "trading card" as the
+    // PNG body — the exact dimensions the minter encodes for unlocked cards.
+    // Size matters: 2250px exceeds `snapshot_avatar`'s 2048px decode limit,
+    // so reaching the body override here wouldn't just import the wrong
+    // face — it would fail the import outright.
+    let card_art = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
+        1500,
+        2250,
+        image::Rgba([200, 16, 16, 255]),
+    ));
+    let mut card_png = std::io::Cursor::new(Vec::new());
+    card_art
+        .write_to(&mut card_png, image::ImageFormat::Png)
+        .unwrap();
+    let file_bytes = encode_snapshot_png(&snapshot, Some(card_png.get_ref())).unwrap();
+
+    // Production import decode: the effective avatar must be the real one.
+    let decoded = decode_snapshot_from_bytes(&file_bytes).unwrap();
+    let avatar_bytes =
+        decode_avatar_data_url(decoded.profile.avatar_data_url.as_deref().unwrap()).unwrap();
+    let imported = image::load_from_memory(&avatar_bytes).unwrap();
+    assert_eq!(
+        (imported.width(), imported.height()),
+        (4, 3),
+        "imported avatar must be the source avatar, not the card artwork"
+    );
+    assert_eq!(imported.to_rgba8().get_pixel(0, 0).0, [23, 91, 177, 255]);
+    // The live kind:0 URL fallback survives untouched.
+    assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url);
+}
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
index eccf8ee601..d7f0323304 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs
@@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State};
 use crate::{
     app_state::AppState,
     managed_agents::{
-        agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel},
+        agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, MemoryLevel},
+        agent_snapshot_envelope::{
+            decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload,
+            LOCKED_CARD_REFUSAL,
+        },
         load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition,
         ManagedAgentRecord, RespondTo,
     },
@@ -72,6 +76,16 @@ pub struct AgentSnapshotImportPreview {
     pub has_source_allowlist: bool,
     /// Number of source allowlist entries.
     pub source_allowlist_count: usize,
+    /// Full source allowlist entries, surfaced before import so hidden access
+    /// configuration is never reduced to a count.
+    pub source_allowlist: Vec<String>,
+    /// Pretty-printed, validated manifest exactly as decoded from the file.
+    /// The UI makes this available before confirmation for full payload review.
+    pub manifest_json: String,
+    /// True when the snapshot came from a locked (encrypted) card that this
+    /// machine successfully unlocked. Cards that cannot be unlocked never
+    /// reach a preview — they fail closed with the locked-card refusal.
+    pub locked: bool,
 }
 
 /// The confirmation request sent from the UI after the user reviews the preview.
@@ -210,50 +224,112 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47];
 ///
 /// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected
 /// before allocation to avoid avoidable large-input work.
-pub(crate) fn decode_snapshot_from_bytes(
-    file_bytes: &[u8],
-) -> Result<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
-    if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC {
+///
+/// **Locked cards:** a structurally valid locked envelope parses successfully
+/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can
+/// unlock go through [`decode_snapshot_for_import`]; callers that only need
+/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is.
+pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result<ChunkPayload, String> {
+    let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC {
         if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES {
             return Err(format!(
                 "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.",
                 file_bytes.len() / (1024 * 1024)
             ));
         }
-        let mut snapshot = decode_snapshot_png(file_bytes)?;
+        let chunk_json = extract_chunk_payload_png(file_bytes)?;
+        let mut payload = parse_chunk_payload(&chunk_json)?;
         // The PNG image body is the portable avatar. It deliberately wins over
-        // manifest avatar fields, whose URL may only be reachable by the
-        // sender. A 1×1 export placeholder leaves the manifest fallback intact.
-        if let Some(avatar_data_url) =
-            crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)?
-        {
-            snapshot.profile.avatar_data_url = Some(avatar_data_url);
+        // a manifest avatar *URL*, which may only be reachable by the sender.
+        // A 1×1 export placeholder leaves the manifest fallback intact.
+        // Inline manifest avatar *bytes* are authoritative and never
+        // overridden: trading cards supply the generated card artwork as the
+        // PNG body and carry the agent's real avatar inline — adopting the
+        // body there would import the card as the agent's face.
+        // Locked envelopes stay opaque here — there is no manifest to override
+        // until the unlock path decrypts one.
+        if let ChunkPayload::Plain(snapshot) = &mut payload {
+            if snapshot.profile.avatar_data_url.is_none() {
+                if let Some(avatar_data_url) =
+                    crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(
+                        file_bytes,
+                    )?
+                {
+                    snapshot.profile.avatar_data_url = Some(avatar_data_url);
+                }
+            }
         }
-        if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
-            return Err(
-                "Snapshot is malformed: memory.level is 'none' but entries are present."
-                    .to_string(),
-            );
+        payload
+    } else {
+        // JSON path — apply size cap before serde allocation.
+        if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES {
+            return Err(format!(
+                "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.",
+                file_bytes.len() / (1024 * 1024)
+            ));
         }
-        return Ok(snapshot);
-    }
-    // JSON path — apply size cap before serde allocation.
-    if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES {
-        return Err(format!(
-            "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.",
-            file_bytes.len() / (1024 * 1024)
-        ));
-    }
-    let snapshot = decode_snapshot_json(file_bytes)?;
+        parse_chunk_payload(file_bytes)?
+    };
     // Consistency check: none + non-empty entries is always malformed,
-    // regardless of format.  Mirrors the PNG path above so the rule is
-    // enforced at decode time for both formats.
-    if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None {
+    // regardless of enclosing format. Enforced at decode time for plain
+    // payloads here, and after decryption for locked ones (see
+    // `enforce_memory_consistency` callers).
+    if let ChunkPayload::Plain(snapshot) = &payload {
+        enforce_memory_consistency(snapshot)?;
+    }
+    Ok(payload)
+}
+
+/// The shared malformed-memory guard: `memory.level == none` with non-empty
+/// entries is always rejected before any write.
+fn enforce_memory_consistency(
+    snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot,
+) -> Result<(), String> {
+    if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
         return Err(
             "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(),
         );
     }
-    Ok(snapshot)
+    Ok(())
+}
+
+/// Decode a plain snapshot from raw bytes, refusing locked cards.
+///
+/// Test-only convenience: production call sites either unlock through
+/// [`decode_snapshot_for_import`] or validate structurally through
+/// [`parse_snapshot_payload_from_bytes`].
+#[cfg(test)]
+pub(crate) fn decode_snapshot_from_bytes(
+    file_bytes: &[u8],
+) -> Result<crate::managed_agents::agent_snapshot::AgentSnapshot, String> {
+    match parse_snapshot_payload_from_bytes(file_bytes)? {
+        ChunkPayload::Plain(snapshot) => Ok(*snapshot),
+        ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()),
+    }
+}
+
+/// Decode a snapshot for import, unlocking locked cards when — and only
+/// when — this machine holds one of the envelope's two exact key endpoints
+/// (the owner identity or the named local agent record).
+///
+/// Returns the decoded manifest and whether it came from a locked envelope.
+/// When neither endpoint exists, fails closed with the locked-card refusal —
+/// never partial plaintext, never crypto details.
+pub(crate) fn decode_snapshot_for_import(
+    file_bytes: &[u8],
+    owner_keys: Option<&nostr::Keys>,
+    records: &[ManagedAgentRecord],
+) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> {
+    match parse_snapshot_payload_from_bytes(file_bytes)? {
+        ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)),
+        ChunkPayload::Locked(envelope) => {
+            let secret = resolve_unlock_secret(&envelope, owner_keys, records)
+                .ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?;
+            let snapshot = decrypt_envelope(&envelope, &secret)?;
+            enforce_memory_consistency(&snapshot)?;
+            Ok((snapshot, true))
+        }
+    }
 }
 
 async fn materialize_import_avatar<F, Fut>(
@@ -283,19 +359,38 @@ where
 /// `.agent.png` file. The format is sniffed from the content, not the
 /// extension, so an incorrectly-named file is handled correctly.
 ///
+/// Locked cards are unlocked here when this machine holds one of the
+/// envelope's two exact key endpoints; a card that cannot be unlocked fails
+/// with the locked-card refusal (shown directly to the user), never a
+/// partial preview. Identity-recovery mode is tolerated: owner keys are
+/// simply unavailable, so only the agent-record endpoint can unlock.
+///
 /// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors
-/// represent irrecoverable failures (corrupt / unsupported file) and are
-/// shown directly to the user.
+/// represent irrecoverable failures (corrupt / unsupported / locked-to-
+/// someone-else file) and are shown directly to the user.
 #[tauri::command]
 pub async fn preview_agent_snapshot_import(
     file_bytes: Vec<u8>,
     file_name: String,
+    app: AppHandle,
+    state: State<'_, AppState>,
 ) -> Result<AgentSnapshotImportPreview, String> {
+    // Key material + records are gathered up front (cheap, lock-scoped) so
+    // the blocking decode below owns plain data.
+    let owner_keys = state.signing_keys().ok();
+    let records = {
+        let _store_guard = state
+            .managed_agents_store_lock
+            .lock()
+            .map_err(|e| e.to_string())?;
+        load_managed_agents(&app)?
+    };
     tokio::task::spawn_blocking(move || {
         reject_legacy_persona_filename(&file_name)?;
-        let snapshot = decode_snapshot_from_bytes(&file_bytes)?;
+        let (snapshot, locked) =
+            decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?;
 
-        Ok(build_agent_snapshot_import_preview(&snapshot))
+        build_agent_snapshot_import_preview(&snapshot, locked)
     })
     .await
     .map_err(|e| format!("spawn_blocking failed: {e}"))?
@@ -303,7 +398,8 @@ pub async fn preview_agent_snapshot_import(
 
 pub(crate) fn build_agent_snapshot_import_preview(
     snapshot: &AgentSnapshot,
-) -> AgentSnapshotImportPreview {
+    locked: bool,
+) -> Result<AgentSnapshotImportPreview, String> {
     let memory_level = match snapshot.memory.level {
         MemoryLevel::None => "none",
         MemoryLevel::Core => "core",
@@ -311,7 +407,11 @@ pub(crate) fn build_agent_snapshot_import_preview(
     }
     .to_string();
 
-    AgentSnapshotImportPreview {
+    let manifest_json = serde_json::to_string_pretty(snapshot)
+        .map_err(|e| format!("failed to render snapshot manifest: {e}"))?;
+    let source_allowlist = snapshot.definition.respond_to_allowlist.clone();
+
+    Ok(AgentSnapshotImportPreview {
         display_name: snapshot.profile.display_name.clone(),
         is_builtin: snapshot.definition.source_is_builtin,
         model: snapshot.definition.model.clone(),
@@ -325,9 +425,12 @@ pub(crate) fn build_agent_snapshot_import_preview(
             .or_else(|| snapshot.profile.avatar_url.clone()),
         memory_level,
         memory_entry_count: snapshot.memory.entries.len(),
-        source_allowlist_count: snapshot.definition.respond_to_allowlist.len(),
-        has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(),
-    }
+        source_allowlist_count: source_allowlist.len(),
+        has_source_allowlist: !source_allowlist.is_empty(),
+        source_allowlist,
+        manifest_json,
+        locked,
+    })
 }
 
 // ── `confirm_agent_snapshot_import` ──────────────────────────────────────────
@@ -355,8 +458,20 @@ pub async fn confirm_agent_snapshot_import(
     app: AppHandle,
     state: State<'_, AppState>,
 ) -> Result<AgentSnapshotImportResult, String> {
-    // ── Phase 1: validate (no I/O) ───────────────────────────────────────────
-    let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?;
+    // ── Phase 1: validate (no writes) ────────────────────────────────────────
+    // Locked cards unlock only via this machine's exact key endpoints;
+    // anything else fails closed here, before key generation.
+    let snapshot = {
+        let owner_keys = state.signing_keys().ok();
+        let records = {
+            let _store_guard = state
+                .managed_agents_store_lock
+                .lock()
+                .map_err(|e| e.to_string())?;
+            load_managed_agents(&app)?
+        };
+        decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0
+    };
 
     let display_name = snapshot.profile.display_name.trim().to_string();
     if display_name.is_empty() {
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
index 4289310280..c453b09a9d 100644
--- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs
@@ -564,7 +564,7 @@ fn import_preview_includes_exported_definition_metadata() {
     let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap();
     let decoded = decode_snapshot_from_bytes(&bytes).unwrap();
 
-    let preview = build_agent_snapshot_import_preview(&decoded);
+    let preview = build_agent_snapshot_import_preview(&decoded, false).unwrap();
 
     assert!(preview.is_builtin);
     assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5"));
@@ -949,51 +949,14 @@ fn test_parse_format_is_png_invalid_returns_error() {
 }
 
 // ── Export: validate_snapshot_encode_size ────────────────────────────────────
-//
-// Tests call `validate_snapshot_encode_size` directly so they prove the exact
-// production guard — not a manual reconstruction.  Removing or reversing the
-// check in production code will cause these tests to fail.
 
-/// JSON: boundary-1 passes, boundary is the last legal byte count.
-#[test]
-fn validate_encode_size_json_at_boundary_minus_1_passes() {
-    assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok());
-}
+#[path = "tests_memory_entries.rs"]
+mod memory_entries;
 
-/// JSON: exactly at the boundary is the last accepted size.
-#[test]
-fn validate_encode_size_json_at_boundary_passes() {
-    assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok());
-}
+#[path = "tests_encode_size.rs"]
+mod encode_size;
 
-/// JSON: boundary+1 is rejected.
-#[test]
-fn validate_encode_size_json_over_boundary_is_rejected() {
-    let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err();
-    assert!(
-        err.contains("size limit"),
-        "error must mention size limit, got: {err}"
-    );
-}
+// ── Import: decode_snapshot_for_import (locked cards) ─────────────────────
 
-/// PNG: boundary-1 passes.
-#[test]
-fn validate_encode_size_png_at_boundary_minus_1_passes() {
-    assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok());
-}
-
-/// PNG: exactly at the boundary passes.
-#[test]
-fn validate_encode_size_png_at_boundary_passes() {
-    assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok());
-}
-
-/// PNG: boundary+1 is rejected.
-#[test]
-fn validate_encode_size_png_over_boundary_is_rejected() {
-    let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err();
-    assert!(
-        err.contains("size limit"),
-        "error must mention size limit, got: {err}"
-    );
-}
+#[path = "tests_locked.rs"]
+mod locked_import;
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs
new file mode 100644
index 0000000000..36eaa99716
--- /dev/null
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs
@@ -0,0 +1,55 @@
+//! Export-size guard tests for `validate_snapshot_encode_size`.
+//!
+//! Kept in a sibling file so `snapshot/tests.rs` stays under the
+//! 1000-line gate; `#[path]`-included from there as a child module,
+//! so `super::*` still resolves to the shared test imports.
+//!
+//! Tests call `validate_snapshot_encode_size` directly so they prove the
+//! exact production guard — not a manual reconstruction.  Removing or
+//! reversing the check in production code will cause these tests to fail.
+
+use super::*;
+
+/// JSON: boundary-1 passes, boundary is the last legal byte count.
+#[test]
+fn validate_encode_size_json_at_boundary_minus_1_passes() {
+    assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok());
+}
+
+/// JSON: exactly at the boundary is the last accepted size.
+#[test]
+fn validate_encode_size_json_at_boundary_passes() {
+    assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok());
+}
+
+/// JSON: boundary+1 is rejected.
+#[test]
+fn validate_encode_size_json_over_boundary_is_rejected() {
+    let err = validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err();
+    assert!(
+        err.contains("size limit"),
+        "error must mention size limit, got: {err}"
+    );
+}
+
+/// PNG: boundary-1 passes.
+#[test]
+fn validate_encode_size_png_at_boundary_minus_1_passes() {
+    assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok());
+}
+
+/// PNG: exactly at the boundary passes.
+#[test]
+fn validate_encode_size_png_at_boundary_passes() {
+    assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok());
+}
+
+/// PNG: boundary+1 is rejected.
+#[test]
+fn validate_encode_size_png_over_boundary_is_rejected() {
+    let err = validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err();
+    assert!(
+        err.contains("size limit"),
+        "error must mention size limit, got: {err}"
+    );
+}
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs
new file mode 100644
index 0000000000..296444f78d
--- /dev/null
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs
@@ -0,0 +1,129 @@
+//! Locked-card import tests for `decode_snapshot_for_import`.
+//!
+//! Kept in a sibling file so `snapshot/tests.rs` stays under the
+//! 1000-line gate; `#[path]`-included from there as a child module,
+//! so `super::*` still resolves to the shared test helpers.
+
+use super::*;
+use crate::commands::personas::snapshot::import::{
+    decode_snapshot_for_import, parse_snapshot_payload_from_bytes,
+};
+use crate::managed_agents::agent_snapshot_envelope::{
+    encode_locked_snapshot_png, encrypt_snapshot_envelope, ChunkPayload, LOCKED_CARD_REFUSAL,
+};
+
+/// Build a keyed instance record holding real key material, so the
+/// agent-endpoint unlock path resolves exactly as production does.
+fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord {
+    ManagedAgentRecord {
+        pubkey: agent.public_key().to_hex(),
+        slug: None,
+        persona_id: Some("locked-test".to_string()),
+        private_key_nsec: nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(),
+        ..make_definition("")
+    }
+}
+
+fn locked_png(owner: &nostr::Keys, agent: &nostr::Keys) -> (AgentSnapshot, Vec<u8>) {
+    let snapshot = make_snapshot(MemoryLevel::None, vec![]);
+    let png = encode_locked_snapshot_png(&snapshot, owner, &agent.public_key(), None).unwrap();
+    (snapshot, png)
+}
+
+/// Owner identity key unlocks a locked card; `locked` is reported true.
+#[test]
+fn owner_endpoint_unlocks_locked_png() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let (snapshot, png) = locked_png(&owner, &agent);
+    let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap();
+    assert_eq!(decoded, snapshot);
+    assert!(locked);
+}
+
+/// A local managed-agent record holding the agent nsec unlocks the card
+/// even when the owner identity does not match (e.g. re-import on the
+/// agent's own machine under a different owner identity).
+#[test]
+fn agent_record_endpoint_unlocks_locked_png() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let (snapshot, png) = locked_png(&owner, &agent);
+    let other_identity = nostr::Keys::generate();
+    let records = vec![record_for(&agent)];
+    let (decoded, locked) =
+        decode_snapshot_for_import(&png, Some(&other_identity), &records).unwrap();
+    assert_eq!(decoded, snapshot);
+    assert!(locked);
+}
+
+/// No matching endpoint → only the locked-card refusal, nothing else.
+#[test]
+fn stranger_fails_closed_with_refusal_only() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let (_snapshot, png) = locked_png(&owner, &agent);
+    let stranger = nostr::Keys::generate();
+    let unrelated_record = record_for(&nostr::Keys::generate());
+    let err = decode_snapshot_for_import(&png, Some(&stranger), &[unrelated_record]).unwrap_err();
+    assert_eq!(err, LOCKED_CARD_REFUSAL);
+    // And with no key material at all.
+    let err = decode_snapshot_for_import(&png, None, &[]).unwrap_err();
+    assert_eq!(err, LOCKED_CARD_REFUSAL);
+}
+
+/// Plain snapshots pass through unchanged with `locked == false`, with or
+/// without key material in scope.
+#[test]
+fn plain_snapshot_passes_through_unlocked() {
+    use crate::managed_agents::agent_snapshot::encode_snapshot_png;
+    let snapshot = make_snapshot(MemoryLevel::None, vec![]);
+    let png = encode_snapshot_png(&snapshot, None).unwrap();
+    let owner = nostr::Keys::generate();
+    let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap();
+    assert_eq!(decoded, snapshot);
+    assert!(!locked);
+    let (decoded, locked) = decode_snapshot_for_import(&png, None, &[]).unwrap();
+    assert_eq!(decoded, snapshot);
+    assert!(!locked);
+}
+
+/// The memory-consistency guard fires AFTER decryption too: a locked
+/// envelope whose plaintext declares level none + non-empty entries is
+/// rejected even for a legitimate endpoint.
+#[test]
+fn decrypted_manifest_memory_consistency_enforced() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let malformed = make_snapshot(
+        MemoryLevel::None,
+        vec![AgentSnapshotMemoryEntry {
+            slug: "core".to_string(),
+            body: "leaked".to_string(),
+        }],
+    );
+    // encrypt_snapshot_envelope does not guard memory consistency (the
+    // PNG encoder does), so this constructs the malicious payload.
+    let envelope = encrypt_snapshot_envelope(&malformed, &owner, &agent.public_key()).unwrap();
+    let json = serde_json::to_vec(&envelope).unwrap();
+    let err = decode_snapshot_for_import(&json, Some(&owner), &[]).unwrap_err();
+    assert!(
+        err.contains("'none' but entries are present"),
+        "post-decrypt consistency guard must fire, got: {err}"
+    );
+}
+
+/// Transit validation (`fetch_snapshot_bytes` path) accepts a locked PNG
+/// without any key material — structural validation only, no decryption.
+#[test]
+fn transit_validation_accepts_locked_png_without_keys() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let (_snapshot, png) = locked_png(&owner, &agent);
+    let payload = parse_snapshot_payload_from_bytes(&png).unwrap();
+    assert!(matches!(payload, ChunkPayload::Locked(_)));
+}
+
+/// The keyless plain decoder refuses locked cards with the refusal.
+#[test]
+fn plain_decoder_refuses_locked_cards() {
+    let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate());
+    let (_snapshot, png) = locked_png(&owner, &agent);
+    let err = decode_snapshot_from_bytes(&png).unwrap_err();
+    assert_eq!(err, LOCKED_CARD_REFUSAL);
+}
diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs
new file mode 100644
index 0000000000..b17efa1ad1
--- /dev/null
+++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs
@@ -0,0 +1,55 @@
+//! Tests for `memory_entries_from_listing` — the shared level → entries
+//! selection used by both snapshot export and card minting. Split from
+//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included
+//! from there as a child module, so `super::*` resolves to `tests`'s parent
+//! scope re-exports.
+
+use super::*;
+
+fn listing_fixture() -> crate::commands::engrams::AgentMemoryListing {
+    let entry = |slug: &str, body: &str| crate::commands::engrams::EngramEntry {
+        slug: slug.to_string(),
+        body: body.to_string(),
+        event_id: "e".repeat(64),
+        created_at: 1,
+        outgoing_refs: vec![],
+    };
+    crate::commands::engrams::AgentMemoryListing {
+        core: Some(entry("core", "core body")),
+        memories: vec![entry("mem/a", "a body"), entry("mem/b", "b body")],
+        truncated: false,
+        fetched_at: 1,
+    }
+}
+
+#[test]
+fn memory_entries_core_takes_core_only() {
+    let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Core);
+    assert_eq!(entries.len(), 1);
+    assert_eq!(entries[0].slug, "core");
+    assert_eq!(entries[0].body, "core body");
+}
+
+#[test]
+fn memory_entries_everything_appends_mem_entries_after_core() {
+    let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Everything);
+    assert_eq!(
+        entries.iter().map(|e| e.slug.as_str()).collect::<Vec<_>>(),
+        vec!["core", "mem/a", "mem/b"]
+    );
+}
+
+#[test]
+fn memory_entries_missing_core_still_yields_mem_entries_for_everything() {
+    let mut listing = listing_fixture();
+    listing.core = None;
+    let entries = memory_entries_from_listing(listing, MemoryLevel::Everything);
+    assert_eq!(
+        entries.iter().map(|e| e.slug.as_str()).collect::<Vec<_>>(),
+        vec!["mem/a", "mem/b"]
+    );
+
+    let mut core_only = listing_fixture();
+    core_only.core = None;
+    assert!(memory_entries_from_listing(core_only, MemoryLevel::Core).is_empty());
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 6814008f0d..c4b733e3e0 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -65,10 +65,7 @@ use mesh_llm_stubs::*;
 #[cfg(all(feature = "mesh-llm", target_os = "macos"))]
 use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown};
 use shutdown::{is_restart_request, shut_down_app};
-use std::sync::{
-    atomic::{AtomicBool, Ordering},
-    Arc,
-};
+use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc};
 use tauri::{Emitter, Manager, RunEvent};
 #[cfg(target_os = "macos")]
 use tauri::{Listener, WindowEvent};
@@ -851,6 +848,12 @@ pub fn run() {
             update_team,
             delete_team,
             export_agent_snapshot,
+            card_mint_key_status,
+            card_mint_save_openai_key,
+            mint_agent_card,
+            save_agent_card,
+            list_agent_cards,
+            load_agent_card,
             preview_agent_snapshot_import,
             confirm_agent_snapshot_import,
             encode_agent_snapshot_for_send,
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
index 16a0d35b23..7c08e7095f 100644
--- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs
@@ -306,9 +306,22 @@ pub fn encode_snapshot_png(
         );
     }
 
-    // Manifest → JSON → base64 for the tEXt chunk payload.
+    // Manifest → JSON for the tEXt chunk payload. The payload/PNG composition
+    // is shared with the locked-card encoder in `agent_snapshot_envelope`;
+    // plain cards remain byte-identical to the pre-envelope encoder.
     let json_bytes = encode_snapshot_json(snapshot)?;
-    let chunk_text = STANDARD.encode(&json_bytes);
+    encode_chunk_payload_png(&json_bytes, avatar_bytes)
+}
+
+/// Encode arbitrary chunk-payload JSON (plain manifest or locked envelope)
+/// into a PNG carrying it base64-encoded in the `buzz_agent_snapshot` tEXt
+/// chunk. Shared by the plain encoder above and
+/// `agent_snapshot_envelope::encode_locked_snapshot_png`.
+pub(crate) fn encode_chunk_payload_png(
+    json_bytes: &[u8],
+    avatar_bytes: Option<&[u8]>,
+) -> Result<Vec<u8>, String> {
+    let chunk_text = STANDARD.encode(json_bytes);
 
     // Use the avatar as the PNG image body, transcoding decodable non-PNG
     // avatars. Fall back to a minimal 1×1 transparent placeholder only when
@@ -334,8 +347,11 @@ pub fn encode_snapshot_png(
     Ok(png_bytes)
 }
 
-/// Decode a manifest from a `.agent.png` tEXt chunk.
-pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result<AgentSnapshot, String> {
+/// Extract and base64-decode the raw `buzz_agent_snapshot` chunk payload
+/// (JSON bytes) from a PNG, without interpreting it. The payload may be a
+/// plain manifest or a locked envelope — callers dispatch on the parsed
+/// `format` via `agent_snapshot_envelope::parse_chunk_payload`.
+pub(crate) fn extract_chunk_payload_png(png_bytes: &[u8]) -> Result<Vec<u8>, String> {
     let decoder = Decoder::new(Cursor::new(png_bytes));
     let reader = decoder
         .read_info()
@@ -349,10 +365,18 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result<AgentSnapshot, String> {
         .map(|c| c.text.as_str())
         .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?;
 
-    let json_bytes = STANDARD
+    STANDARD
         .decode(chunk_text.trim())
-        .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?;
+        .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))
+}
 
+/// Decode a manifest from a `.agent.png` tEXt chunk.
+///
+/// Plain snapshots only — a locked (encrypted) chunk payload fails here with
+/// the manifest format error. Import paths that must handle locked cards go
+/// through `agent_snapshot_envelope::parse_chunk_payload` instead.
+pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result<AgentSnapshot, String> {
+    let json_bytes = extract_chunk_payload_png(png_bytes)?;
     decode_snapshot_json(&json_bytes)
 }
 
@@ -473,527 +497,5 @@ fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result<Vec<
 // ── Tests ─────────────────────────────────────────────────────────────────────
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo};
-    use std::collections::BTreeMap;
-
-    /// Build a minimal `ManagedAgentRecord` for testing. Only the fields
-    /// relevant to snapshot export are filled; the rest use defaults.
-    fn minimal_record() -> ManagedAgentRecord {
-        ManagedAgentRecord {
-            pubkey: "deadbeef".to_string(),
-            name: "Test Agent".to_string(),
-            display_name: Some("Test Agent Display".to_string()),
-            persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot
-            team_id: Some("SENTINEL_TEAM_ID".to_string()),       // MUST NOT appear in snapshot
-            private_key_nsec: "nsec1secret".to_string(),         // MUST NOT appear in snapshot
-            auth_tag: Some("auth-tag-secret".to_string()),       // MUST NOT appear in snapshot
-            relay_url: "wss://relay.example.com".to_string(),    // MUST NOT appear in snapshot
-            avatar_url: Some("https://example.com/avatar.png".to_string()),
-            acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot
-            agent_command: "goose".to_string(),            // MUST NOT appear in snapshot
-            agent_command_override: Some("goose-override".to_string()), // MUST NOT appear
-            agent_args: vec!["--arg".to_string()],         // MUST NOT appear in snapshot
-            mcp_command: "mcp-server".to_string(),         // MUST NOT appear in snapshot
-            turn_timeout_seconds: 120,                     // deprecated, MUST NOT appear
-            idle_timeout_seconds: Some(30),
-            max_turn_duration_seconds: Some(600),
-            parallelism: 2,
-            system_prompt: Some("You are a test agent.".to_string()),
-            model: Some("claude-opus-4".to_string()),
-            provider: Some("anthropic".to_string()),
-            persona_source_version: Some("v1.0".to_string()), // MUST NOT appear
-            env_vars: {
-                let mut m = BTreeMap::new();
-                m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear
-                m
-            },
-            start_on_app_launch: true,
-            auto_restart_on_config_change: true,
-            runtime_pid: Some(12345), // MUST NOT appear
-            backend: BackendKind::Provider {
-                // MUST NOT appear — carries a provider secret
-                id: "SENTINEL_BACKEND_ID".to_string(),
-                config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}),
-            },
-            backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear
-            provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear
-            persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear
-            persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear
-            created_at: "2024-01-01T00:00:00Z".to_string(),
-            updated_at: "2024-01-02T00:00:00Z".to_string(),
-            last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear
-            last_stopped_at: None,
-            last_exit_code: Some(0), // MUST NOT appear
-            last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear
-            last_error_code: Some(42), // MUST NOT appear
-            respond_to: RespondTo::default(),
-            respond_to_allowlist: vec!["pubkey1hex".to_string()],
-            slug: Some("test-agent".to_string()),
-            runtime: Some("goose".to_string()),
-            name_pool: vec!["Alice".to_string(), "Bob".to_string()],
-            is_builtin: false,
-            is_active: true,
-            shared: false,
-            source_team: Some("team-id-123".to_string()), // MUST NOT appear
-            source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear
-            definition_respond_to: Some("allowlist".to_string()),
-            catalog_source: None,
-            definition_respond_to_allowlist: vec!["abc123def".to_string()],
-            definition_parallelism: Some(4),
-            relay_mesh: None,
-        }
-    }
-
-    // ── Round-trip tests ──────────────────────────────────────────────────────
-
-    #[test]
-    fn json_round_trip_config_only() {
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
-        let bytes = encode_snapshot_json(&snapshot).unwrap();
-        let parsed = decode_snapshot_json(&bytes).unwrap();
-        assert_eq!(parsed, snapshot);
-    }
-
-    #[test]
-    fn json_round_trip_with_memory() {
-        let record = minimal_record();
-        let entries = vec![
-            AgentSnapshotMemoryEntry {
-                slug: "core".to_string(),
-                body: "I am a test agent.".to_string(),
-            },
-            AgentSnapshotMemoryEntry {
-                slug: "mem/research".to_string(),
-                body: "Some research notes.".to_string(),
-            },
-        ];
-        let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
-        let bytes = encode_snapshot_json(&snapshot).unwrap();
-        let parsed = decode_snapshot_json(&bytes).unwrap();
-        assert_eq!(parsed, snapshot);
-    }
-
-    #[test]
-    fn png_round_trip_no_memory() {
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
-        let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
-        let parsed = decode_snapshot_png(&png_bytes).unwrap();
-        assert_eq!(parsed.definition.name, snapshot.definition.name);
-        assert_eq!(parsed.profile.display_name, snapshot.profile.display_name);
-        assert_eq!(parsed.memory.level, MemoryLevel::None);
-    }
-
-    #[test]
-    fn png_round_trip_with_avatar_png() {
-        // Build a minimal PNG avatar.
-        let avatar = make_png_with_text("dummy", "value").unwrap();
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
-        // Avatar should be inlined as a data URL.
-        assert!(snapshot
-            .profile
-            .avatar_data_url
-            .as_deref()
-            .unwrap_or("")
-            .starts_with("data:image/png;base64,"));
-
-        let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap();
-        let parsed = decode_snapshot_png(&png_bytes).unwrap();
-        assert_eq!(parsed.definition.name, snapshot.definition.name);
-    }
-
-    #[test]
-    fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
-        let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
-            3,
-            2,
-            image::Rgb([0x12, 0x34, 0x56]),
-        ));
-        let mut jpeg_bytes = Vec::new();
-        avatar
-            .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
-            .unwrap();
-
-        let snapshot = build_snapshot(
-            &minimal_record(),
-            MemoryLevel::None,
-            vec![],
-            Some(&jpeg_bytes),
-        );
-        let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap();
-        let decoder = Decoder::new(Cursor::new(png_bytes));
-        let reader = decoder.read_info().unwrap();
-
-        assert_eq!((reader.info().width, reader.info().height), (3, 2));
-    }
-
-    // ── PNG memory parity ─────────────────────────────────────────────────────
-
-    #[test]
-    fn png_round_trip_with_core_memory() {
-        let record = minimal_record();
-        let entries = vec![AgentSnapshotMemoryEntry {
-            slug: "core".to_string(),
-            body: "remember this".to_string(),
-        }];
-        let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
-
-        let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
-        let parsed = decode_snapshot_png(&png_bytes).unwrap();
-
-        assert_eq!(parsed.memory, snapshot.memory);
-    }
-
-    #[test]
-    fn png_round_trip_with_everything_memory() {
-        let record = minimal_record();
-        let entries = vec![
-            AgentSnapshotMemoryEntry {
-                slug: "core".to_string(),
-                body: "remember this".to_string(),
-            },
-            AgentSnapshotMemoryEntry {
-                slug: "mem/notes".to_string(),
-                body: "private notes".to_string(),
-            },
-        ];
-        let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
-
-        let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
-        let parsed = decode_snapshot_png(&png_bytes).unwrap();
-
-        assert_eq!(parsed.memory, snapshot.memory);
-    }
-
-    #[test]
-    fn png_export_with_no_memory_succeeds() {
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
-        assert!(encode_snapshot_png(&snapshot, None).is_ok());
-    }
-
-    #[test]
-    fn png_export_rejects_none_level_with_nonempty_entries() {
-        // Inconsistent state: level == None but entries is non-empty.
-        // The encoder must reject this to prevent a memory-leak bypass.
-        let record = minimal_record();
-        let entries = vec![AgentSnapshotMemoryEntry {
-            slug: "core".to_string(),
-            body: "leaked memory".to_string(),
-        }];
-        // Build with entries, then override level to None in the struct.
-        let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
-        snapshot.memory.level = MemoryLevel::None; // force inconsistency
-        let result = encode_snapshot_png(&snapshot, None);
-        assert!(
-            result.is_err(),
-            "PNG encoder must reject level=None with non-empty entries"
-        );
-        assert!(
-            result
-                .unwrap_err()
-                .contains("memory.level 'none' and non-empty memory entries"),
-            "Error must explain the malformed memory state"
-        );
-    }
-
-    // ── Secret exclusion tests ────────────────────────────────────────────────
-    //
-    // These tests assert that every field in the exclusion list is absent from
-    // the serialized snapshot. We serialize to JSON and assert the key is NOT
-    // present.
-
-    fn snapshot_json_string(record: &ManagedAgentRecord) -> String {
-        let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None);
-        let bytes = encode_snapshot_json(&snapshot).unwrap();
-        String::from_utf8(bytes).unwrap()
-    }
-
-    #[test]
-    fn secret_exclusion_private_key_nsec_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("nsec1secret"),
-            "nsec must not appear in snapshot"
-        );
-        assert!(
-            !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"),
-            "privateKeyNsec field must not appear in snapshot"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_auth_tag_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("auth-tag-secret"),
-            "auth_tag value must not appear in snapshot"
-        );
-        assert!(
-            !json.contains("authTag") && !json.contains("auth_tag"),
-            "authTag field must not appear in snapshot"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_env_vars_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("API_KEY") && !json.contains("secret123"),
-            "env_vars content must not appear in snapshot"
-        );
-        assert!(
-            !json.contains("envVars") && !json.contains("env_vars"),
-            "envVars field must not appear in snapshot"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_relay_url_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("wss://relay.example.com"),
-            "relay_url value must not appear in snapshot"
-        );
-        assert!(
-            !json.contains("relayUrl") && !json.contains("relay_url"),
-            "relayUrl field must not appear in snapshot"
-        );
-    }
-
-    #[test]
-    fn snapshot_omits_removed_mcp_toolsets_config() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"),
-            "removed MCP toolsets config must not re-enter snapshots"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_machine_commands_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        // acp_command / agent_command / agent_command_override / agent_args / mcp_command
-        assert!(
-            !json.contains("/usr/local/bin/acp"),
-            "acp_command path must not appear"
-        );
-        assert!(
-            !json.contains("acpCommand") && !json.contains("acp_command"),
-            "acpCommand field must not appear"
-        );
-        assert!(
-            !json.contains("agentCommand") && !json.contains("agent_command"),
-            "agentCommand field must not appear"
-        );
-        assert!(
-            !json.contains("mcpCommand") && !json.contains("mcp_command"),
-            "mcpCommand field must not appear"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_runtime_state_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("runtimePid") && !json.contains("runtime_pid"),
-            "runtimePid must not appear"
-        );
-        assert!(
-            !json.contains("backendAgentId") && !json.contains("backend_agent_id"),
-            "backendAgentId must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_BACKEND_AGENT_ID"),
-            "backendAgentId value must not appear"
-        );
-        assert!(
-            !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"),
-            "providerBinaryPath must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_PROVIDER_BINARY"),
-            "providerBinaryPath value must not appear"
-        );
-        assert!(
-            !json.contains("lastStartedAt") && !json.contains("last_started_at"),
-            "lastStartedAt must not appear"
-        );
-        assert!(
-            !json.contains("lastExitCode") && !json.contains("last_exit_code"),
-            "lastExitCode must not appear"
-        );
-        // backend blob — neither the type tag nor provider secret must leak.
-        assert!(
-            !json.contains("\"backend\"") && !json.contains("backend"),
-            "backend field must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"),
-            "backend config values must not appear"
-        );
-        // last_error / last_error_code
-        assert!(
-            !json.contains("lastError") && !json.contains("last_error"),
-            "lastError must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_LAST_ERROR"),
-            "lastError value must not appear"
-        );
-        assert!(
-            !json.contains("lastErrorCode") && !json.contains("last_error_code"),
-            "lastErrorCode must not appear"
-        );
-    }
-
-    #[test]
-    fn secret_exclusion_lineage_ids_absent() {
-        let record = minimal_record();
-        let json = snapshot_json_string(&record);
-        assert!(
-            !json.contains("team-id-123"),
-            "source_team value must not appear"
-        );
-        assert!(
-            !json.contains("sourceTeam") && !json.contains("source_team"),
-            "sourceTeam field must not appear"
-        );
-        assert!(
-            !json.contains("sourceTeamPersonaSlug"),
-            "sourceTeamPersonaSlug must not appear"
-        );
-        assert!(
-            !json.contains("personaSourceVersion") && !json.contains("persona_source_version"),
-            "personaSourceVersion must not appear"
-        );
-        // personaId
-        assert!(
-            !json.contains("personaId") && !json.contains("persona_id"),
-            "personaId field must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_PERSONA_ID"),
-            "personaId value must not appear"
-        );
-        // teamId
-        assert!(
-            !json.contains("teamId") && !json.contains("team_id"),
-            "teamId field must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_TEAM_ID"),
-            "teamId value must not appear"
-        );
-        // personaTeamDir
-        assert!(
-            !json.contains("personaTeamDir") && !json.contains("persona_team_dir"),
-            "personaTeamDir field must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_TEAM_DIR"),
-            "personaTeamDir value must not appear"
-        );
-        // personaNameInTeam
-        assert!(
-            !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"),
-            "personaNameInTeam field must not appear"
-        );
-        assert!(
-            !json.contains("SENTINEL_NAME_IN_TEAM"),
-            "personaNameInTeam value must not appear"
-        );
-    }
-
-    // ── Definition field presence tests ──────────────────────────────────────
-
-    #[test]
-    fn definition_fields_present_in_snapshot() {
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
-
-        assert_eq!(snapshot.definition.name, "Test Agent Display");
-        assert!(!snapshot.definition.source_is_builtin);
-        assert_eq!(
-            snapshot.definition.system_prompt.as_deref(),
-            Some("You are a test agent.")
-        );
-        assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose"));
-        assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4"));
-        assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic"));
-        assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]);
-        // definition_respond_to maps to respond_to in the snapshot definition
-        assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist"));
-        // definition_respond_to_allowlist should be included
-        assert!(!snapshot.definition.respond_to_allowlist.is_empty());
-    }
-
-    #[test]
-    fn profile_fields_present_in_snapshot() {
-        let record = minimal_record();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
-        assert_eq!(snapshot.profile.display_name, "Test Agent Display");
-        // No bytes → should fall back to avatar_url
-        assert_eq!(
-            snapshot.profile.avatar_url.as_deref(),
-            Some("https://example.com/avatar.png")
-        );
-        assert!(snapshot.profile.avatar_data_url.is_none());
-    }
-
-    #[test]
-    fn avatar_inlined_when_under_size_limit() {
-        let record = minimal_record();
-        let small_png = make_png_with_text("k", "v").unwrap();
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png));
-        assert!(snapshot.profile.avatar_data_url.is_some());
-        assert!(snapshot.profile.avatar_url.is_none());
-    }
-
-    #[test]
-    fn avatar_url_fallback_when_over_size_limit() {
-        let mut record = minimal_record();
-        record.avatar_url = Some("https://example.com/big.png".to_string());
-        // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec.
-        let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1];
-        let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes));
-        assert!(snapshot.profile.avatar_data_url.is_none());
-        assert_eq!(
-            snapshot.profile.avatar_url.as_deref(),
-            Some("https://example.com/big.png")
-        );
-    }
-
-    // ── Format/version validation ─────────────────────────────────────────────
-
-    #[test]
-    fn invalid_format_discriminator_is_rejected() {
-        let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
-        snapshot.format = "not-a-buzz-snapshot".to_string();
-        let bytes = serde_json::to_vec(&snapshot).unwrap();
-        let result = decode_snapshot_json(&bytes);
-        assert!(result.is_err());
-        assert!(result.unwrap_err().contains("Unsupported snapshot format"));
-    }
-
-    #[test]
-    fn unsupported_version_is_rejected() {
-        let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
-        snapshot.version = 99;
-        let bytes = serde_json::to_vec(&snapshot).unwrap();
-        let result = decode_snapshot_json(&bytes);
-        assert!(result.is_err());
-        assert!(result.unwrap_err().contains("Unsupported snapshot version"));
-    }
-}
+#[path = "agent_snapshot_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
new file mode 100644
index 0000000000..8508c27073
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs
@@ -0,0 +1,638 @@
+//! Locked (encrypted) agent-card envelope — NIP-44 v2 over the snapshot manifest.
+//!
+//! A locked card carries the same `buzz_agent_snapshot` tEXt chunk as a plain
+//! card, but the chunk JSON is a typed outer envelope whose ciphertext
+//! decrypts to the ordinary manifest. The NIP-44 v2 conversation key is
+//! symmetric over the (owner, agent) pair, so BOTH the owner's and the
+//! agent's nsec decrypt the card — nobody else's does (NIP-AE's scheme).
+//!
+//! Wire contract (agreed with Wren, buzz-agent-trading-cards thread):
+//! - Plain cards keep today's exact bytes; detection dispatches once on the
+//!   exact `format` discriminator and rejects unknown versions/schemes
+//!   rather than falling through to manifest parsing.
+//! - Key lookup is exact-endpoint only: the owner identity key when its
+//!   pubkey equals `ownerPubkey`, or a hydrated local managed-agent record
+//!   whose record pubkey AND derived-secret pubkey equal `agentPubkey`.
+//!   No trial decryption; anything else fails closed as locked.
+//! - Caps beyond the outer 10 MiB PNG gate: 65,535-byte NIP-44 plaintext
+//!   limit on the serialized manifest BEFORE encryption; envelope JSON and
+//!   ciphertext are capped before serde/base64/decrypt work; decrypted bytes
+//!   are capped before snapshot parsing.
+//! - Decrypt/auth failures return only the locked-card refusal — never
+//!   partial plaintext or crypto details.
+
+use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX;
+use nostr::nips::nip44::{self, Version};
+use nostr::{Keys, PublicKey, SecretKey};
+use serde::{Deserialize, Serialize};
+
+use super::agent_snapshot::{
+    decode_snapshot_json, encode_chunk_payload_png, encode_snapshot_json, AgentSnapshot,
+    MemoryLevel, FORMAT_DISCRIMINATOR,
+};
+use super::types::ManagedAgentRecord;
+
+/// Discriminator for the locked envelope. Distinct from the plain manifest's
+/// `buzz-agent-snapshot` so detection never guesses.
+pub const LOCKED_FORMAT: &str = "buzz-agent-snapshot-encrypted";
+/// Envelope schema version this module produces and accepts.
+pub const LOCKED_VERSION: u32 = 1;
+/// Encryption scheme identifier this module produces and accepts.
+pub const LOCKED_SCHEME: &str = "nip44-v2";
+
+/// A max-size NIP-44 v2 payload (1 version + 32 nonce + 2 len + 65,536
+/// padded + 32 MAC = 65,603 bytes) base64-encodes to 87,472 chars.
+/// Anything larger is rejected before base64/decrypt work.
+pub const MAX_LOCKED_CIPHERTEXT_BYTES: usize = 90_000;
+/// Envelope JSON = ciphertext + two pubkeys + fixed keys. Rejected before
+/// typed deserialization.
+pub const MAX_LOCKED_ENVELOPE_JSON_BYTES: usize = MAX_LOCKED_CIPHERTEXT_BYTES + 1024;
+
+/// The only error a failed unlock may surface. Deliberately says nothing
+/// about which key was tried or why decryption failed.
+pub const LOCKED_CARD_REFUSAL: &str =
+    "This card is locked to its owner and agent. Only they can import it.";
+
+// ── Envelope types ────────────────────────────────────────────────────────────
+
+/// Typed outer envelope stored (base64 JSON) in the `buzz_agent_snapshot`
+/// chunk of a locked card.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LockedSnapshotEnvelope {
+    /// Always [`LOCKED_FORMAT`].
+    pub format: String,
+    /// Always [`LOCKED_VERSION`].
+    pub version: u32,
+    pub encryption: LockedEncryption,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub struct LockedEncryption {
+    /// Always [`LOCKED_SCHEME`].
+    pub scheme: String,
+    /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor
+    /// knows which counterparty to pair with.
+    pub owner_pubkey: String,
+    /// Agent instance pubkey (64 lowercase hex).
+    pub agent_pubkey: String,
+    /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON.
+    pub ciphertext: String,
+}
+
+/// Result of parsing a chunk payload: either today's plain manifest or a
+/// validated locked envelope. The plain manifest is boxed because it may
+/// inline a multi-KB avatar data URL, dwarfing the envelope variant.
+#[derive(Debug)]
+pub enum ChunkPayload {
+    Plain(Box<AgentSnapshot>),
+    Locked(LockedSnapshotEnvelope),
+}
+
+/// Minimal probe used to read the `format` discriminator without building a
+/// full JSON tree for large plain manifests.
+#[derive(Deserialize)]
+struct FormatProbe {
+    #[serde(default)]
+    format: Option<String>,
+}
+
+// ── Validation ────────────────────────────────────────────────────────────────
+
+/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a
+/// valid x-only pubkey. Lowercase is required so string comparisons against
+/// record pubkeys (always `to_hex()` output) stay sound. Curve validation is
+/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers
+/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would
+/// otherwise pass structurally and fail only at decrypt time.
+pub(crate) fn parse_canonical_pubkey(field: &str, value: &str) -> Result<PublicKey, String> {
+    if value.len() != 64
+        || !value
+            .chars()
+            .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
+    {
+        return Err(format!(
+            "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)."
+        ));
+    }
+    let pubkey = PublicKey::from_hex(value)
+        .map_err(|_| format!("Locked card envelope has an invalid {field}."))?;
+    pubkey
+        .xonly()
+        .map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?;
+    Ok(pubkey)
+}
+
+/// Structural validation of a locked envelope: exact version + scheme,
+/// canonical pubkeys, distinct endpoints, bounded ciphertext. Does no
+/// key lookup or crypto.
+pub fn validate_envelope(
+    envelope: &LockedSnapshotEnvelope,
+) -> Result<(PublicKey, PublicKey), String> {
+    if envelope.format != LOCKED_FORMAT {
+        return Err(format!(
+            "Unsupported locked card format: {:?} (expected {LOCKED_FORMAT:?})",
+            envelope.format
+        ));
+    }
+    if envelope.version != LOCKED_VERSION {
+        return Err(format!(
+            "Unsupported locked card envelope version: {} (expected {LOCKED_VERSION})",
+            envelope.version
+        ));
+    }
+    if envelope.encryption.scheme != LOCKED_SCHEME {
+        return Err(format!(
+            "Unsupported locked card encryption scheme: {:?} (expected {LOCKED_SCHEME:?})",
+            envelope.encryption.scheme
+        ));
+    }
+    let owner = parse_canonical_pubkey("ownerPubkey", &envelope.encryption.owner_pubkey)?;
+    let agent = parse_canonical_pubkey("agentPubkey", &envelope.encryption.agent_pubkey)?;
+    if owner == agent {
+        return Err("Locked card envelope owner and agent pubkeys must differ.".to_string());
+    }
+    if envelope.encryption.ciphertext.len() > MAX_LOCKED_CIPHERTEXT_BYTES {
+        return Err("Locked card ciphertext exceeds the maximum size.".to_string());
+    }
+    if envelope.encryption.ciphertext.is_empty() {
+        return Err("Locked card ciphertext is empty.".to_string());
+    }
+    Ok((owner, agent))
+}
+
+// ── Dispatch ──────────────────────────────────────────────────────────────────
+
+/// Parse a raw chunk payload (JSON bytes from `extract_chunk_payload_png` or
+/// an `.agent.json` file) and dispatch on the exact `format` discriminator.
+///
+/// - `buzz-agent-snapshot` → full plain-manifest decode + validation.
+/// - `buzz-agent-snapshot-encrypted` → size caps, typed envelope parse,
+///   structural validation. No decryption happens here.
+/// - anything else (including missing `format`) → error, never a fall-through.
+pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result<ChunkPayload, String> {
+    let probe: FormatProbe =
+        serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?;
+    match probe.format.as_deref() {
+        Some(f) if f == FORMAT_DISCRIMINATOR => Ok(ChunkPayload::Plain(Box::new(
+            decode_snapshot_json(json_bytes)?,
+        ))),
+        Some(f) if f == LOCKED_FORMAT => {
+            // Cap the envelope JSON before typed deserialization; a locked
+            // envelope is small by construction (unlike plain manifests,
+            // which may inline a multi-MB avatar).
+            if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES {
+                return Err("Locked card envelope exceeds the maximum size.".to_string());
+            }
+            let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes)
+                .map_err(|e| format!("Invalid locked card envelope: {e}"))?;
+            validate_envelope(&envelope)?;
+            Ok(ChunkPayload::Locked(envelope))
+        }
+        Some(other) => Err(format!("Unsupported snapshot format: {other:?}")),
+        None => Err("Snapshot payload has no format discriminator.".to_string()),
+    }
+}
+
+// ── Encrypt ───────────────────────────────────────────────────────────────────
+
+/// Encrypt a snapshot manifest into a locked envelope under the NIP-44 v2
+/// conversation key for (owner secret, agent pubkey).
+///
+/// Fails clearly (never silently truncates) when the serialized manifest
+/// exceeds the NIP-44 plaintext limit.
+pub fn encrypt_snapshot_envelope(
+    snapshot: &AgentSnapshot,
+    owner_keys: &Keys,
+    agent_pubkey: &PublicKey,
+) -> Result<LockedSnapshotEnvelope, String> {
+    let json_bytes = encode_snapshot_json(snapshot)?;
+    if json_bytes.len() > NIP44_PLAINTEXT_MAX {
+        return Err(format!(
+            "Agent manifest is too large to lock ({} bytes; the encrypted \
+             format caps at {NIP44_PLAINTEXT_MAX}). Reduce the avatar size \
+             or mint an unlocked card.",
+            json_bytes.len()
+        ));
+    }
+    let plaintext = std::str::from_utf8(&json_bytes)
+        .map_err(|e| format!("Manifest JSON was not UTF-8: {e}"))?;
+    let ciphertext = nip44::encrypt(
+        owner_keys.secret_key(),
+        agent_pubkey,
+        plaintext,
+        Version::V2,
+    )
+    .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?;
+
+    Ok(LockedSnapshotEnvelope {
+        format: LOCKED_FORMAT.to_string(),
+        version: LOCKED_VERSION,
+        encryption: LockedEncryption {
+            scheme: LOCKED_SCHEME.to_string(),
+            owner_pubkey: owner_keys.public_key().to_hex(),
+            agent_pubkey: agent_pubkey.to_hex(),
+            ciphertext,
+        },
+    })
+}
+
+/// Encode a snapshot into a LOCKED `.agent.png`: encrypt the manifest into
+/// the envelope, then compose the PNG through the same chunk encoder plain
+/// cards use. Mirrors `encode_snapshot_png`'s structural memory guard.
+pub fn encode_locked_snapshot_png(
+    snapshot: &AgentSnapshot,
+    owner_keys: &Keys,
+    agent_pubkey: &PublicKey,
+    avatar_bytes: Option<&[u8]>,
+) -> Result<Vec<u8>, String> {
+    if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() {
+        return Err(
+            "Cannot write a snapshot with memory.level 'none' and non-empty memory entries."
+                .to_string(),
+        );
+    }
+    let envelope = encrypt_snapshot_envelope(snapshot, owner_keys, agent_pubkey)?;
+    let envelope_json = serde_json::to_vec(&envelope)
+        .map_err(|e| format!("Failed to serialize locked card envelope: {e}"))?;
+    encode_chunk_payload_png(&envelope_json, avatar_bytes)
+}
+
+// ── Decrypt ───────────────────────────────────────────────────────────────────
+
+/// Exact-endpoint key resolution (no trial decryption):
+/// - the owner identity secret, only when its pubkey equals `ownerPubkey`;
+/// - a hydrated local managed-agent record whose record pubkey AND
+///   derived-secret pubkey both equal `agentPubkey`.
+///
+/// Returns `None` when neither exact endpoint exists — callers fail closed
+/// with [`LOCKED_CARD_REFUSAL`].
+pub fn resolve_unlock_secret(
+    envelope: &LockedSnapshotEnvelope,
+    owner_keys: Option<&Keys>,
+    records: &[ManagedAgentRecord],
+) -> Option<SecretKey> {
+    if let Some(keys) = owner_keys {
+        if keys.public_key().to_hex() == envelope.encryption.owner_pubkey {
+            return Some(keys.secret_key().clone());
+        }
+    }
+    let record = records
+        .iter()
+        .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?;
+    let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?;
+    if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey {
+        return None;
+    }
+    Some(agent_keys.secret_key().clone())
+}
+
+/// Decrypt a validated envelope with `my_secret`, which must be one of the
+/// envelope's two exact endpoints (its derived pubkey selects the
+/// counterparty). Returns the decoded, validated snapshot manifest.
+///
+/// Every auth/crypto failure maps to [`LOCKED_CARD_REFUSAL`] — nothing about
+/// the failure mode leaks. Manifest decode errors after a successful decrypt
+/// are surfaced normally (the caller proved key possession).
+pub fn decrypt_envelope(
+    envelope: &LockedSnapshotEnvelope,
+    my_secret: &SecretKey,
+) -> Result<AgentSnapshot, String> {
+    let (owner_pub, agent_pub) = validate_envelope(envelope)?;
+    let my_pub = Keys::new(my_secret.clone()).public_key();
+    let counterparty = if my_pub == owner_pub {
+        agent_pub
+    } else if my_pub == agent_pub {
+        owner_pub
+    } else {
+        return Err(LOCKED_CARD_REFUSAL.to_string());
+    };
+
+    let plaintext = nip44::decrypt(my_secret, &counterparty, &envelope.encryption.ciphertext)
+        .map_err(|_| LOCKED_CARD_REFUSAL.to_string())?;
+    if plaintext.len() > NIP44_PLAINTEXT_MAX {
+        return Err(LOCKED_CARD_REFUSAL.to_string());
+    }
+    decode_snapshot_json(plaintext.as_bytes())
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::managed_agents::agent_snapshot::{
+        extract_chunk_payload_png, AgentSnapshotDefinition, AgentSnapshotMemory,
+        AgentSnapshotProfile, FORMAT_VERSION,
+    };
+
+    fn sample_snapshot() -> AgentSnapshot {
+        AgentSnapshot {
+            format: FORMAT_DISCRIMINATOR.to_string(),
+            version: FORMAT_VERSION,
+            definition: AgentSnapshotDefinition {
+                name: "Locked Test".to_string(),
+                system_prompt: Some("You are a locked test agent.".to_string()),
+                runtime: None,
+                model: None,
+                provider: None,
+                parallelism: Some(1),
+                respond_to: None,
+                respond_to_allowlist: Vec::new(),
+                name_pool: Vec::new(),
+                idle_timeout_seconds: None,
+                max_turn_duration_seconds: None,
+                source_is_builtin: false,
+            },
+            profile: AgentSnapshotProfile {
+                display_name: "Locked Test".to_string(),
+                about: None,
+                avatar_data_url: None,
+                avatar_url: None,
+            },
+            memory: AgentSnapshotMemory {
+                level: MemoryLevel::None,
+                entries: Vec::new(),
+            },
+        }
+    }
+
+    fn owner_agent_keys() -> (Keys, Keys) {
+        (Keys::generate(), Keys::generate())
+    }
+
+    /// Minimal hydrated record for endpoint-resolution tests. Only the
+    /// pubkey/nsec pair matters here.
+    fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord {
+        ManagedAgentRecord {
+            pubkey,
+            name: "Locked Test".to_string(),
+            persona_id: None,
+            private_key_nsec,
+            auth_tag: None,
+            relay_url: "ws://localhost:3000".to_string(),
+            avatar_url: None,
+            acp_command: "buzz-acp".to_string(),
+            agent_command: "goose".to_string(),
+            agent_args: vec![],
+            mcp_command: String::new(),
+            turn_timeout_seconds: 300,
+            idle_timeout_seconds: None,
+            max_turn_duration_seconds: None,
+            parallelism: 1,
+            system_prompt: None,
+            model: None,
+            env_vars: std::collections::BTreeMap::new(),
+            start_on_app_launch: false,
+            auto_restart_on_config_change: true,
+            runtime_pid: None,
+            backend: crate::managed_agents::types::BackendKind::Local,
+            backend_agent_id: None,
+            provider_binary_path: None,
+            team_id: None,
+            persona_team_dir: None,
+            persona_name_in_team: None,
+            created_at: String::new(),
+            updated_at: String::new(),
+            last_started_at: None,
+            last_stopped_at: None,
+            last_exit_code: None,
+            last_error: None,
+            last_error_code: None,
+            respond_to: crate::managed_agents::types::RespondTo::OwnerOnly,
+            respond_to_allowlist: vec![],
+            display_name: None,
+            slug: None,
+            runtime: None,
+            name_pool: Vec::new(),
+            is_builtin: false,
+            is_active: true,
+            shared: false,
+            source_team: None,
+            source_team_persona_slug: None,
+            catalog_source: None,
+            definition_respond_to: None,
+            definition_respond_to_allowlist: Vec::new(),
+            definition_parallelism: None,
+            relay_mesh: None,
+            agent_command_override: None,
+            persona_source_version: None,
+            provider: None,
+        }
+    }
+
+    fn locked_envelope() -> (LockedSnapshotEnvelope, Keys, Keys) {
+        let (owner, agent) = owner_agent_keys();
+        let env =
+            encrypt_snapshot_envelope(&sample_snapshot(), &owner, &agent.public_key()).unwrap();
+        (env, owner, agent)
+    }
+
+    #[test]
+    fn owner_secret_decrypts() {
+        let (env, owner, _agent) = locked_envelope();
+        let decoded = decrypt_envelope(&env, owner.secret_key()).unwrap();
+        assert_eq!(decoded, sample_snapshot());
+    }
+
+    #[test]
+    fn agent_secret_decrypts() {
+        let (env, _owner, agent) = locked_envelope();
+        let decoded = decrypt_envelope(&env, agent.secret_key()).unwrap();
+        assert_eq!(decoded, sample_snapshot());
+    }
+
+    #[test]
+    fn unrelated_key_fails_closed_with_refusal_only() {
+        let (env, _owner, _agent) = locked_envelope();
+        let stranger = Keys::generate();
+        let err = decrypt_envelope(&env, stranger.secret_key()).unwrap_err();
+        assert_eq!(err, LOCKED_CARD_REFUSAL);
+    }
+
+    #[test]
+    fn tampered_ciphertext_fails_with_refusal_only() {
+        let (mut env, owner, _agent) = locked_envelope();
+        // Flip a character mid-ciphertext (keep valid base64 alphabet).
+        let mid = env.encryption.ciphertext.len() / 2;
+        let mut bytes = env.encryption.ciphertext.into_bytes();
+        bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' };
+        env.encryption.ciphertext = String::from_utf8(bytes).unwrap();
+        let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err();
+        assert_eq!(err, LOCKED_CARD_REFUSAL);
+    }
+
+    #[test]
+    fn swapped_pubkeys_fail_closed_at_endpoint_resolution() {
+        let (mut env, owner, agent) = locked_envelope();
+        std::mem::swap(
+            &mut env.encryption.owner_pubkey,
+            &mut env.encryption.agent_pubkey,
+        );
+        // The NIP-44 conversation key is symmetric over the pair, so a swap
+        // cannot grant a stranger anything — but it desyncs the routing
+        // hints, and exact-endpoint resolution fails closed rather than
+        // guessing: the owner identity no longer matches `ownerPubkey`, and
+        // no local record holds the pubkey now in `agentPubkey`.
+        assert!(resolve_unlock_secret(&env, Some(&owner), &[]).is_none());
+        let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap();
+        let record = record_with_keys(agent.public_key().to_hex(), nsec);
+        assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).is_none());
+    }
+
+    #[test]
+    fn mislabeled_pubkey_fails_decryption_with_refusal_only() {
+        // Replacing `agentPubkey` with a third party's key makes the owner
+        // derive the wrong conversation key — the NIP-44 MAC fails and only
+        // the refusal surfaces.
+        let (mut env, owner, _agent) = locked_envelope();
+        env.encryption.agent_pubkey = Keys::generate().public_key().to_hex();
+        let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err();
+        assert_eq!(err, LOCKED_CARD_REFUSAL);
+    }
+
+    #[test]
+    fn malformed_pubkeys_rejected_structurally() {
+        let (env, _owner, _agent) = locked_envelope();
+
+        let mut short = env.clone();
+        short.encryption.owner_pubkey = "abc123".to_string();
+        assert!(validate_envelope(&short).unwrap_err().contains("malformed"));
+
+        let mut upper = env.clone();
+        upper.encryption.agent_pubkey = upper.encryption.agent_pubkey.to_uppercase();
+        assert!(validate_envelope(&upper).unwrap_err().contains("malformed"));
+
+        // A 64-hex string that is not a curve point (lift-x fails for
+        // x = p-1... all-f) must be rejected STRUCTURALLY — before any key
+        // lookup or decrypt work — per the wire contract.
+        let mut not_a_point = env.clone();
+        not_a_point.encryption.agent_pubkey = "f".repeat(64);
+        assert!(validate_envelope(&not_a_point)
+            .unwrap_err()
+            .contains("not a curve point"));
+
+        let mut same = env;
+        same.encryption.agent_pubkey = same.encryption.owner_pubkey.clone();
+        assert!(validate_envelope(&same).unwrap_err().contains("differ"));
+    }
+
+    #[test]
+    fn unknown_format_version_scheme_rejected() {
+        let (env, ..) = locked_envelope();
+
+        let mut bad_version = env.clone();
+        bad_version.version = 2;
+        assert!(validate_envelope(&bad_version)
+            .unwrap_err()
+            .contains("version"));
+
+        let mut bad_scheme = env.clone();
+        bad_scheme.encryption.scheme = "nip44-v3".to_string();
+        assert!(validate_envelope(&bad_scheme)
+            .unwrap_err()
+            .contains("scheme"));
+
+        // Unknown top-level format never falls through to manifest parsing.
+        let unknown = serde_json::json!({"format": "buzz-agent-snapshot-v9", "version": 1});
+        let err = parse_chunk_payload(unknown.to_string().as_bytes()).unwrap_err();
+        assert!(err.contains("Unsupported snapshot format"), "{err}");
+
+        let missing = serde_json::json!({"version": 1});
+        let err = parse_chunk_payload(missing.to_string().as_bytes()).unwrap_err();
+        assert!(err.contains("no format discriminator"), "{err}");
+    }
+
+    #[test]
+    fn plaintext_cap_enforced_before_encryption() {
+        let (owner, agent) = owner_agent_keys();
+        let mut snapshot = sample_snapshot();
+        // Inflate the manifest beyond the NIP-44 plaintext limit.
+        snapshot.definition.system_prompt = Some("x".repeat(NIP44_PLAINTEXT_MAX));
+        let err = encrypt_snapshot_envelope(&snapshot, &owner, &agent.public_key()).unwrap_err();
+        assert!(err.contains("too large to lock"), "{err}");
+    }
+
+    #[test]
+    fn ciphertext_and_envelope_caps_enforced_before_crypto() {
+        let (mut env, ..) = locked_envelope();
+        env.encryption.ciphertext = "A".repeat(MAX_LOCKED_CIPHERTEXT_BYTES + 1);
+        assert!(validate_envelope(&env)
+            .unwrap_err()
+            .contains("maximum size"));
+
+        // Oversized envelope JSON is rejected before typed deserialization.
+        let huge = format!(
+            r#"{{"format":"{LOCKED_FORMAT}","version":1,"pad":"{}","encryption":{{}}}}"#,
+            "p".repeat(MAX_LOCKED_ENVELOPE_JSON_BYTES)
+        );
+        let err = parse_chunk_payload(huge.as_bytes()).unwrap_err();
+        assert!(err.contains("maximum size"), "{err}");
+    }
+
+    #[test]
+    fn locked_png_round_trips_through_chunk_and_decrypt() {
+        let (owner, agent) = owner_agent_keys();
+        let snapshot = sample_snapshot();
+        let png = encode_locked_snapshot_png(&snapshot, &owner, &agent.public_key(), None).unwrap();
+
+        let payload = extract_chunk_payload_png(&png).unwrap();
+        let ChunkPayload::Locked(env) = parse_chunk_payload(&payload).unwrap() else {
+            panic!("locked PNG must parse as a locked envelope");
+        };
+        // Both endpoints decrypt to the same logical manifest (compare
+        // manifests, never ciphertext — the NIP-44 nonce is random).
+        assert_eq!(
+            decrypt_envelope(&env, owner.secret_key()).unwrap(),
+            snapshot
+        );
+        assert_eq!(
+            decrypt_envelope(&env, agent.secret_key()).unwrap(),
+            snapshot
+        );
+    }
+
+    #[test]
+    fn plain_manifest_dispatches_to_plain() {
+        let json = encode_snapshot_json(&sample_snapshot()).unwrap();
+        let ChunkPayload::Plain(decoded) = parse_chunk_payload(&json).unwrap() else {
+            panic!("plain manifest must parse as Plain");
+        };
+        assert_eq!(*decoded, sample_snapshot());
+    }
+
+    #[test]
+    fn resolve_unlock_secret_owner_exact_endpoint() {
+        let (env, owner, _agent) = locked_envelope();
+        let secret = resolve_unlock_secret(&env, Some(&owner), &[]).unwrap();
+        assert_eq!(&secret, owner.secret_key());
+
+        // A different identity key is NOT tried.
+        let other = Keys::generate();
+        assert!(resolve_unlock_secret(&env, Some(&other), &[]).is_none());
+        assert!(resolve_unlock_secret(&env, None, &[]).is_none());
+    }
+
+    #[test]
+    fn resolve_unlock_secret_agent_requires_record_and_derived_match() {
+        let (env, _owner, agent) = locked_envelope();
+        let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap();
+
+        let record = record_with_keys(agent.public_key().to_hex(), nsec);
+        let secret = resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).unwrap();
+        assert_eq!(&secret, agent.secret_key());
+
+        // Record pubkey matches but the stored secret derives a DIFFERENT
+        // pubkey → refused (no trial decryption on mismatched material).
+        let mut forged = record.clone();
+        forged.private_key_nsec =
+            nostr::ToBech32::to_bech32(Keys::generate().secret_key()).unwrap();
+        assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&forged)).is_none());
+
+        // Record for some other agent → not an endpoint.
+        let mut unrelated = record;
+        unrelated.pubkey = Keys::generate().public_key().to_hex();
+        assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&unrelated)).is_none());
+    }
+}
diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
new file mode 100644
index 0000000000..b4492418e5
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs
@@ -0,0 +1,599 @@
+//! Unit tests for `managed_agents/agent_snapshot.rs`.
+//!
+//! Kept in a sibling file so `agent_snapshot.rs` stays under the
+//! 1000-line gate; `#[path]`-included from there.
+
+use super::*;
+use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo};
+use std::collections::BTreeMap;
+
+/// Build a minimal `ManagedAgentRecord` for testing. Only the fields
+/// relevant to snapshot export are filled; the rest use defaults.
+fn minimal_record() -> ManagedAgentRecord {
+    ManagedAgentRecord {
+        pubkey: "deadbeef".to_string(),
+        name: "Test Agent".to_string(),
+        display_name: Some("Test Agent Display".to_string()),
+        persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot
+        team_id: Some("SENTINEL_TEAM_ID".to_string()),       // MUST NOT appear in snapshot
+        private_key_nsec: "nsec1secret".to_string(),         // MUST NOT appear in snapshot
+        auth_tag: Some("auth-tag-secret".to_string()),       // MUST NOT appear in snapshot
+        relay_url: "wss://relay.example.com".to_string(),    // MUST NOT appear in snapshot
+        avatar_url: Some("https://example.com/avatar.png".to_string()),
+        acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot
+        agent_command: "goose".to_string(),            // MUST NOT appear in snapshot
+        agent_command_override: Some("goose-override".to_string()), // MUST NOT appear
+        agent_args: vec!["--arg".to_string()],         // MUST NOT appear in snapshot
+        mcp_command: "mcp-server".to_string(),         // MUST NOT appear in snapshot
+        turn_timeout_seconds: 120,                     // deprecated, MUST NOT appear
+        idle_timeout_seconds: Some(30),
+        max_turn_duration_seconds: Some(600),
+        parallelism: 2,
+        system_prompt: Some("You are a test agent.".to_string()),
+        model: Some("claude-opus-4".to_string()),
+        provider: Some("anthropic".to_string()),
+        persona_source_version: Some("v1.0".to_string()), // MUST NOT appear
+        env_vars: {
+            let mut m = BTreeMap::new();
+            m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear
+            m
+        },
+        start_on_app_launch: true,
+        auto_restart_on_config_change: true,
+        runtime_pid: Some(12345), // MUST NOT appear
+        backend: BackendKind::Provider {
+            // MUST NOT appear — carries a provider secret
+            id: "SENTINEL_BACKEND_ID".to_string(),
+            config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}),
+        },
+        backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear
+        provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear
+        persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear
+        persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()),       // MUST NOT appear
+        created_at: "2024-01-01T00:00:00Z".to_string(),
+        updated_at: "2024-01-02T00:00:00Z".to_string(),
+        last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear
+        last_stopped_at: None,
+        last_exit_code: Some(0), // MUST NOT appear
+        last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear
+        last_error_code: Some(42), // MUST NOT appear
+        respond_to: RespondTo::default(),
+        respond_to_allowlist: vec!["pubkey1hex".to_string()],
+        slug: Some("test-agent".to_string()),
+        runtime: Some("goose".to_string()),
+        name_pool: vec!["Alice".to_string(), "Bob".to_string()],
+        is_builtin: false,
+        is_active: true,
+        shared: false,
+        source_team: Some("team-id-123".to_string()), // MUST NOT appear
+        source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear
+        definition_respond_to: Some("allowlist".to_string()),
+        catalog_source: None,
+        definition_respond_to_allowlist: vec!["abc123def".to_string()],
+        definition_parallelism: Some(4),
+        relay_mesh: None,
+    }
+}
+
+// ── Round-trip tests ──────────────────────────────────────────────────────
+
+#[test]
+fn json_round_trip_config_only() {
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+    let bytes = encode_snapshot_json(&snapshot).unwrap();
+    let parsed = decode_snapshot_json(&bytes).unwrap();
+    assert_eq!(parsed, snapshot);
+}
+
+#[test]
+fn json_round_trip_with_memory() {
+    let record = minimal_record();
+    let entries = vec![
+        AgentSnapshotMemoryEntry {
+            slug: "core".to_string(),
+            body: "I am a test agent.".to_string(),
+        },
+        AgentSnapshotMemoryEntry {
+            slug: "mem/research".to_string(),
+            body: "Some research notes.".to_string(),
+        },
+    ];
+    let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
+    let bytes = encode_snapshot_json(&snapshot).unwrap();
+    let parsed = decode_snapshot_json(&bytes).unwrap();
+    assert_eq!(parsed, snapshot);
+}
+
+#[test]
+fn png_round_trip_no_memory() {
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+    let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
+    let parsed = decode_snapshot_png(&png_bytes).unwrap();
+    assert_eq!(parsed.definition.name, snapshot.definition.name);
+    assert_eq!(parsed.profile.display_name, snapshot.profile.display_name);
+    assert_eq!(parsed.memory.level, MemoryLevel::None);
+}
+
+#[test]
+fn png_round_trip_with_avatar_png() {
+    // Build a minimal PNG avatar.
+    let avatar = make_png_with_text("dummy", "value").unwrap();
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
+    // Avatar should be inlined as a data URL.
+    assert!(snapshot
+        .profile
+        .avatar_data_url
+        .as_deref()
+        .unwrap_or("")
+        .starts_with("data:image/png;base64,"));
+
+    let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap();
+    let parsed = decode_snapshot_png(&png_bytes).unwrap();
+    assert_eq!(parsed.definition.name, snapshot.definition.name);
+}
+
+/// Plain-card byte compatibility: `encode_snapshot_png` was refactored
+/// through the shared `encode_chunk_payload_png` when locked cards were
+/// added. Plain cards must emit byte-identical PNGs to the pre-envelope
+/// encoder. This vector reimplements the legacy encoder body verbatim and
+/// asserts equality on all three composition paths: placeholder (no avatar),
+/// PNG-avatar (where tEXt chunk injection ordering matters), and
+/// JPEG-avatar transcode.
+#[test]
+fn plain_encoder_bytes_identical_to_pre_envelope_encoder() {
+    // Verbatim pre-refactor `encode_snapshot_png` body (post memory guard).
+    fn legacy_encode(
+        snapshot: &AgentSnapshot,
+        avatar_bytes: Option<&[u8]>,
+    ) -> Result<Vec<u8>, String> {
+        let json_bytes = encode_snapshot_json(snapshot)?;
+        let chunk_text = STANDARD.encode(&json_bytes);
+        let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) {
+            Some(bytes) => {
+                let encoded_avatar = if bytes.starts_with(b"\x89PNG") {
+                    inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| {
+                        transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
+                    })
+                } else {
+                    transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text)
+                };
+                match encoded_avatar {
+                    Ok(png_bytes) => png_bytes,
+                    Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
+                }
+            }
+            None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?,
+        };
+        Ok(png_bytes)
+    }
+
+    let record = minimal_record();
+
+    // Placeholder path (no avatar).
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+    assert_eq!(
+        encode_snapshot_png(&snapshot, None).unwrap(),
+        legacy_encode(&snapshot, None).unwrap(),
+        "placeholder-path plain PNG bytes must match the pre-envelope encoder"
+    );
+
+    // PNG-avatar path: chunk injected into the avatar image body.
+    let avatar = make_png_with_text("dummy", "value").unwrap();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar));
+    assert_eq!(
+        encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(),
+        legacy_encode(&snapshot, Some(&avatar)).unwrap(),
+        "avatar-path plain PNG bytes must match the pre-envelope encoder"
+    );
+
+    // JPEG-avatar path: transcode-to-PNG composition.
+    let jpeg_avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
+        4,
+        4,
+        image::Rgb([0x10, 0x20, 0x30]),
+    ));
+    let mut jpeg_bytes = Vec::new();
+    jpeg_avatar
+        .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
+        .unwrap();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&jpeg_bytes));
+    assert_eq!(
+        encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(),
+        legacy_encode(&snapshot, Some(&jpeg_bytes)).unwrap(),
+        "transcode-path plain PNG bytes must match the pre-envelope encoder"
+    );
+}
+
+#[test]
+fn png_snapshot_transcodes_jpeg_avatar_into_image_body() {
+    let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(
+        3,
+        2,
+        image::Rgb([0x12, 0x34, 0x56]),
+    ));
+    let mut jpeg_bytes = Vec::new();
+    avatar
+        .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg)
+        .unwrap();
+
+    let snapshot = build_snapshot(
+        &minimal_record(),
+        MemoryLevel::None,
+        vec![],
+        Some(&jpeg_bytes),
+    );
+    let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap();
+    let decoder = Decoder::new(Cursor::new(png_bytes));
+    let reader = decoder.read_info().unwrap();
+
+    assert_eq!((reader.info().width, reader.info().height), (3, 2));
+}
+
+// ── PNG memory parity ─────────────────────────────────────────────────────
+
+#[test]
+fn png_round_trip_with_core_memory() {
+    let record = minimal_record();
+    let entries = vec![AgentSnapshotMemoryEntry {
+        slug: "core".to_string(),
+        body: "remember this".to_string(),
+    }];
+    let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
+
+    let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
+    let parsed = decode_snapshot_png(&png_bytes).unwrap();
+
+    assert_eq!(parsed.memory, snapshot.memory);
+}
+
+#[test]
+fn png_round_trip_with_everything_memory() {
+    let record = minimal_record();
+    let entries = vec![
+        AgentSnapshotMemoryEntry {
+            slug: "core".to_string(),
+            body: "remember this".to_string(),
+        },
+        AgentSnapshotMemoryEntry {
+            slug: "mem/notes".to_string(),
+            body: "private notes".to_string(),
+        },
+    ];
+    let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None);
+
+    let png_bytes = encode_snapshot_png(&snapshot, None).unwrap();
+    let parsed = decode_snapshot_png(&png_bytes).unwrap();
+
+    assert_eq!(parsed.memory, snapshot.memory);
+}
+
+#[test]
+fn png_export_with_no_memory_succeeds() {
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+    assert!(encode_snapshot_png(&snapshot, None).is_ok());
+}
+
+#[test]
+fn png_export_rejects_none_level_with_nonempty_entries() {
+    // Inconsistent state: level == None but entries is non-empty.
+    // The encoder must reject this to prevent a memory-leak bypass.
+    let record = minimal_record();
+    let entries = vec![AgentSnapshotMemoryEntry {
+        slug: "core".to_string(),
+        body: "leaked memory".to_string(),
+    }];
+    // Build with entries, then override level to None in the struct.
+    let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None);
+    snapshot.memory.level = MemoryLevel::None; // force inconsistency
+    let result = encode_snapshot_png(&snapshot, None);
+    assert!(
+        result.is_err(),
+        "PNG encoder must reject level=None with non-empty entries"
+    );
+    assert!(
+        result
+            .unwrap_err()
+            .contains("memory.level 'none' and non-empty memory entries"),
+        "Error must explain the malformed memory state"
+    );
+}
+
+// ── Secret exclusion tests ────────────────────────────────────────────────
+//
+// These tests assert that every field in the exclusion list is absent from
+// the serialized snapshot. We serialize to JSON and assert the key is NOT
+// present.
+
+fn snapshot_json_string(record: &ManagedAgentRecord) -> String {
+    let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None);
+    let bytes = encode_snapshot_json(&snapshot).unwrap();
+    String::from_utf8(bytes).unwrap()
+}
+
+#[test]
+fn secret_exclusion_private_key_nsec_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("nsec1secret"),
+        "nsec must not appear in snapshot"
+    );
+    assert!(
+        !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"),
+        "privateKeyNsec field must not appear in snapshot"
+    );
+}
+
+#[test]
+fn secret_exclusion_auth_tag_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("auth-tag-secret"),
+        "auth_tag value must not appear in snapshot"
+    );
+    assert!(
+        !json.contains("authTag") && !json.contains("auth_tag"),
+        "authTag field must not appear in snapshot"
+    );
+}
+
+#[test]
+fn secret_exclusion_env_vars_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("API_KEY") && !json.contains("secret123"),
+        "env_vars content must not appear in snapshot"
+    );
+    assert!(
+        !json.contains("envVars") && !json.contains("env_vars"),
+        "envVars field must not appear in snapshot"
+    );
+}
+
+#[test]
+fn secret_exclusion_relay_url_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("wss://relay.example.com"),
+        "relay_url value must not appear in snapshot"
+    );
+    assert!(
+        !json.contains("relayUrl") && !json.contains("relay_url"),
+        "relayUrl field must not appear in snapshot"
+    );
+}
+
+#[test]
+fn snapshot_omits_removed_mcp_toolsets_config() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"),
+        "removed MCP toolsets config must not re-enter snapshots"
+    );
+}
+
+#[test]
+fn secret_exclusion_machine_commands_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    // acp_command / agent_command / agent_command_override / agent_args / mcp_command
+    assert!(
+        !json.contains("/usr/local/bin/acp"),
+        "acp_command path must not appear"
+    );
+    assert!(
+        !json.contains("acpCommand") && !json.contains("acp_command"),
+        "acpCommand field must not appear"
+    );
+    assert!(
+        !json.contains("agentCommand") && !json.contains("agent_command"),
+        "agentCommand field must not appear"
+    );
+    assert!(
+        !json.contains("mcpCommand") && !json.contains("mcp_command"),
+        "mcpCommand field must not appear"
+    );
+}
+
+#[test]
+fn secret_exclusion_runtime_state_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("runtimePid") && !json.contains("runtime_pid"),
+        "runtimePid must not appear"
+    );
+    assert!(
+        !json.contains("backendAgentId") && !json.contains("backend_agent_id"),
+        "backendAgentId must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_BACKEND_AGENT_ID"),
+        "backendAgentId value must not appear"
+    );
+    assert!(
+        !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"),
+        "providerBinaryPath must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_PROVIDER_BINARY"),
+        "providerBinaryPath value must not appear"
+    );
+    assert!(
+        !json.contains("lastStartedAt") && !json.contains("last_started_at"),
+        "lastStartedAt must not appear"
+    );
+    assert!(
+        !json.contains("lastExitCode") && !json.contains("last_exit_code"),
+        "lastExitCode must not appear"
+    );
+    // backend blob — neither the type tag nor provider secret must leak.
+    assert!(
+        !json.contains("\"backend\"") && !json.contains("backend"),
+        "backend field must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"),
+        "backend config values must not appear"
+    );
+    // last_error / last_error_code
+    assert!(
+        !json.contains("lastError") && !json.contains("last_error"),
+        "lastError must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_LAST_ERROR"),
+        "lastError value must not appear"
+    );
+    assert!(
+        !json.contains("lastErrorCode") && !json.contains("last_error_code"),
+        "lastErrorCode must not appear"
+    );
+}
+
+#[test]
+fn secret_exclusion_lineage_ids_absent() {
+    let record = minimal_record();
+    let json = snapshot_json_string(&record);
+    assert!(
+        !json.contains("team-id-123"),
+        "source_team value must not appear"
+    );
+    assert!(
+        !json.contains("sourceTeam") && !json.contains("source_team"),
+        "sourceTeam field must not appear"
+    );
+    assert!(
+        !json.contains("sourceTeamPersonaSlug"),
+        "sourceTeamPersonaSlug must not appear"
+    );
+    assert!(
+        !json.contains("personaSourceVersion") && !json.contains("persona_source_version"),
+        "personaSourceVersion must not appear"
+    );
+    // personaId
+    assert!(
+        !json.contains("personaId") && !json.contains("persona_id"),
+        "personaId field must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_PERSONA_ID"),
+        "personaId value must not appear"
+    );
+    // teamId
+    assert!(
+        !json.contains("teamId") && !json.contains("team_id"),
+        "teamId field must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_TEAM_ID"),
+        "teamId value must not appear"
+    );
+    // personaTeamDir
+    assert!(
+        !json.contains("personaTeamDir") && !json.contains("persona_team_dir"),
+        "personaTeamDir field must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_TEAM_DIR"),
+        "personaTeamDir value must not appear"
+    );
+    // personaNameInTeam
+    assert!(
+        !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"),
+        "personaNameInTeam field must not appear"
+    );
+    assert!(
+        !json.contains("SENTINEL_NAME_IN_TEAM"),
+        "personaNameInTeam value must not appear"
+    );
+}
+
+// ── Definition field presence tests ──────────────────────────────────────
+
+#[test]
+fn definition_fields_present_in_snapshot() {
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+
+    assert_eq!(snapshot.definition.name, "Test Agent Display");
+    assert!(!snapshot.definition.source_is_builtin);
+    assert_eq!(
+        snapshot.definition.system_prompt.as_deref(),
+        Some("You are a test agent.")
+    );
+    assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose"));
+    assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4"));
+    assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic"));
+    assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]);
+    // definition_respond_to maps to respond_to in the snapshot definition
+    assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist"));
+    // definition_respond_to_allowlist should be included
+    assert!(!snapshot.definition.respond_to_allowlist.is_empty());
+}
+
+#[test]
+fn profile_fields_present_in_snapshot() {
+    let record = minimal_record();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None);
+    assert_eq!(snapshot.profile.display_name, "Test Agent Display");
+    // No bytes → should fall back to avatar_url
+    assert_eq!(
+        snapshot.profile.avatar_url.as_deref(),
+        Some("https://example.com/avatar.png")
+    );
+    assert!(snapshot.profile.avatar_data_url.is_none());
+}
+
+#[test]
+fn avatar_inlined_when_under_size_limit() {
+    let record = minimal_record();
+    let small_png = make_png_with_text("k", "v").unwrap();
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png));
+    assert!(snapshot.profile.avatar_data_url.is_some());
+    assert!(snapshot.profile.avatar_url.is_none());
+}
+
+#[test]
+fn avatar_url_fallback_when_over_size_limit() {
+    let mut record = minimal_record();
+    record.avatar_url = Some("https://example.com/big.png".to_string());
+    // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec.
+    let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1];
+    let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes));
+    assert!(snapshot.profile.avatar_data_url.is_none());
+    assert_eq!(
+        snapshot.profile.avatar_url.as_deref(),
+        Some("https://example.com/big.png")
+    );
+}
+
+// ── Format/version validation ─────────────────────────────────────────────
+
+#[test]
+fn invalid_format_discriminator_is_rejected() {
+    let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
+    snapshot.format = "not-a-buzz-snapshot".to_string();
+    let bytes = serde_json::to_vec(&snapshot).unwrap();
+    let result = decode_snapshot_json(&bytes);
+    assert!(result.is_err());
+    assert!(result.unwrap_err().contains("Unsupported snapshot format"));
+}
+
+#[test]
+fn unsupported_version_is_rejected() {
+    let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None);
+    snapshot.version = 99;
+    let bytes = serde_json::to_vec(&snapshot).unwrap();
+    let result = decode_snapshot_json(&bytes);
+    assert!(result.is_err());
+    assert!(result.unwrap_err().contains("Unsupported snapshot version"));
+}
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index be9b07cf11..772d707f27 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -1,6 +1,7 @@
 mod agent_env;
 pub(crate) mod agent_events;
 pub(crate) mod agent_snapshot;
+pub(crate) mod agent_snapshot_envelope;
 pub(crate) mod team_snapshot;
 pub(crate) use agent_env::{
     baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor,
diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs
new file mode 100644
index 0000000000..eb9c0f07b0
--- /dev/null
+++ b/desktop/src/features/agents/cardMintStore.test.mjs
@@ -0,0 +1,170 @@
+import assert from "node:assert/strict";
+import { beforeEach, describe, it } from "node:test";
+
+// cardMintStore drives the non-blocking mint flow: the dialog dispatches a
+// job and closes; the composer chip, completion toast, and viewer all read
+// this store. These tests exercise the job lifecycle with an injected mintFn
+// (never the real Tauri command).
+//
+// The store imports sonner (toast); calling toast outside a mounted <Toaster>
+// only queues, so no DOM is required.
+
+import {
+  dismissCardMintJob,
+  getCardGalleryOpen,
+  getCardMintJobs,
+  getCardViewerState,
+  closeCardViewer,
+  openCardViewer,
+  resetCardMintStore,
+  runCardMintJob,
+  setCardGalleryOpen,
+  subscribeCardMintStore,
+  viewMintedCardJob,
+} from "./cardMintStore.ts";
+
+const CARD = {
+  cardPngBase64: "aGVsbG8=",
+  fileName: "eva.agent.png",
+  designerNotes: "notes",
+  locked: false,
+  memoryLevel: "none",
+};
+
+const INPUT = { agentId: "agent-1", agentName: "Eva" };
+
+describe("cardMintStore", () => {
+  beforeEach(() => {
+    resetCardMintStore();
+  });
+
+  it("forwards the mint input — including memoryLevel — to mintFn", async () => {
+    const seen = [];
+    await runCardMintJob(
+      { ...INPUT, styleNotes: "stormy", lock: true, memoryLevel: "core" },
+      (...args) => {
+        seen.push(args);
+        return Promise.resolve({ ...CARD, memoryLevel: "core" });
+      },
+    );
+    assert.deepEqual(seen, [["agent-1", "stormy", true, "core"]]);
+
+    // Omitted memoryLevel stays undefined so Rust applies its "none" default.
+    await runCardMintJob(INPUT, (...args) => {
+      seen.push(args);
+      return Promise.resolve(CARD);
+    });
+    assert.deepEqual(seen[1], ["agent-1", undefined, undefined, undefined]);
+  });
+
+  it("tracks a successful mint through minting → done", async () => {
+    let resolveMint;
+    const pending = new Promise((resolve) => {
+      resolveMint = resolve;
+    });
+    const run = runCardMintJob(INPUT, () => pending);
+
+    let jobs = getCardMintJobs();
+    assert.equal(jobs.length, 1);
+    assert.equal(jobs[0].phase, "minting");
+    assert.equal(jobs[0].input.agentName, "Eva");
+    assert.equal(jobs[0].card, null);
+
+    resolveMint(CARD);
+    await run;
+
+    jobs = getCardMintJobs();
+    assert.equal(jobs.length, 1);
+    assert.equal(jobs[0].phase, "done");
+    assert.deepEqual(jobs[0].card, CARD);
+    assert.equal(jobs[0].error, null);
+  });
+
+  it("records the error message on a failed mint", async () => {
+    await runCardMintJob(INPUT, () => Promise.reject(new Error("boom")));
+    const jobs = getCardMintJobs();
+    assert.equal(jobs.length, 1);
+    assert.equal(jobs[0].phase, "error");
+    assert.equal(jobs[0].error, "boom");
+    assert.equal(jobs[0].card, null);
+  });
+
+  it("strips the NO_OPENAI_KEY wire prefix from mint errors", async () => {
+    await runCardMintJob(INPUT, () =>
+      Promise.reject(new Error("NO_OPENAI_KEY: No OPENAI_API_KEY found.")),
+    );
+    assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found.");
+  });
+
+  it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => {
+    await runCardMintJob(INPUT, () => Promise.resolve(CARD));
+    const jobId = getCardMintJobs()[0].jobId;
+
+    viewMintedCardJob(jobId);
+
+    assert.equal(getCardMintJobs().length, 0);
+    const viewer = getCardViewerState();
+    assert.ok(viewer);
+    assert.equal(viewer.agentName, "Eva");
+    assert.deepEqual(viewer.card, CARD);
+    // Fresh mints keep their input so the viewer can reroll.
+    assert.deepEqual(viewer.remint, INPUT);
+  });
+
+  it("viewMintedCardJob ignores jobs that are still minting", () => {
+    let resolveMint;
+    void runCardMintJob(
+      INPUT,
+      () => new Promise((resolve) => (resolveMint = resolve)),
+    );
+    const jobId = getCardMintJobs()[0].jobId;
+
+    viewMintedCardJob(jobId);
+
+    assert.equal(getCardMintJobs().length, 1);
+    assert.equal(getCardViewerState(), null);
+    resolveMint(CARD); // avoid a dangling promise
+  });
+
+  it("dismissCardMintJob removes only the named job", async () => {
+    await runCardMintJob(INPUT, () => Promise.reject(new Error("a")));
+    await runCardMintJob({ agentId: "agent-2", agentName: "Wren" }, () =>
+      Promise.reject(new Error("b")),
+    );
+    const [first, second] = getCardMintJobs();
+
+    dismissCardMintJob(first.jobId);
+
+    const jobs = getCardMintJobs();
+    assert.equal(jobs.length, 1);
+    assert.equal(jobs[0].jobId, second.jobId);
+  });
+
+  it("openCardViewer/closeCardViewer manage archive views without remint", () => {
+    openCardViewer({ card: CARD, agentName: "Eva", remint: null });
+    assert.equal(getCardViewerState()?.remint, null);
+    closeCardViewer();
+    assert.equal(getCardViewerState(), null);
+  });
+
+  it("gallery open flag toggles and notifies subscribers", () => {
+    let notified = 0;
+    const unsubscribe = subscribeCardMintStore(() => {
+      notified += 1;
+    });
+    setCardGalleryOpen(true);
+    assert.equal(getCardGalleryOpen(), true);
+    setCardGalleryOpen(true); // no-op must not notify
+    setCardGalleryOpen(false);
+    assert.equal(getCardGalleryOpen(), false);
+    assert.equal(notified, 2);
+    unsubscribe();
+  });
+
+  it("concurrent jobs keep distinct snapshots (referential updates)", async () => {
+    const before = getCardMintJobs();
+    await runCardMintJob(INPUT, () => Promise.resolve(CARD));
+    const after = getCardMintJobs();
+    assert.notEqual(before, after, "snapshot identity must change on update");
+  });
+});
diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts
new file mode 100644
index 0000000000..0e4746d445
--- /dev/null
+++ b/desktop/src/features/agents/cardMintStore.ts
@@ -0,0 +1,227 @@
+import * as React from "react";
+import { toast } from "sonner";
+
+import {
+  mintAgentCard,
+  NO_OPENAI_KEY_PREFIX,
+  type MintedAgentCard,
+  type SnapshotMemoryLevel,
+} from "@/shared/api/tauriPersonas";
+
+/**
+ * Module store for agent-card mints (`useSyncExternalStore` pattern, same as
+ * `avatarPresentationStore`).
+ *
+ * A mint is one stateless ~2–3 minute Rust call. Owning the in-flight promise
+ * here — instead of inside the mint dialog — is what makes the dialog
+ * non-blocking: it dispatches and closes, the composer activity rail shows a
+ * live "Minting card…" chip, and completion lands as a clickable toast plus a
+ * persistent "card ready" chip, none of which need the dialog mounted.
+ */
+
+/** Everything needed to run (or re-run) one mint. */
+export type CardMintInput = {
+  agentId: string;
+  agentName: string;
+  styleNotes?: string;
+  lock?: boolean;
+  /** Memory to embed in the card's snapshot. Omitted = "none". */
+  memoryLevel?: SnapshotMemoryLevel;
+};
+
+export type CardMintJob = {
+  jobId: string;
+  input: CardMintInput;
+  phase: "minting" | "done" | "error";
+  /** Populated when phase is "done". */
+  card: MintedAgentCard | null;
+  /** Populated when phase is "error". */
+  error: string | null;
+  startedAt: number;
+};
+
+/** Card content shown by the global viewer dialog. */
+export type CardViewerState = {
+  card: MintedAgentCard;
+  agentName: string;
+  /**
+   * Present when the card can be rerolled (fresh mints carry their input;
+   * archive views do not — the original style notes are gone).
+   */
+  remint: CardMintInput | null;
+  /**
+   * Monotonic per-open sequence, assigned by the store. The viewer keys its
+   * content on this so switching cards remounts (resetting recipients/menu
+   * state) — card bytes can't serve as the key because every card PNG shares
+   * the same header prefix and dimensions.
+   */
+  viewerSeq: number;
+};
+
+let jobs: CardMintJob[] = [];
+let viewer: CardViewerState | null = null;
+let galleryOpen = false;
+const listeners = new Set<() => void>();
+let nextJobId = 1;
+let nextViewerSeq = 1;
+
+function emitChange(): void {
+  for (const listener of listeners) listener();
+}
+
+function updateJob(jobId: string, patch: Partial<CardMintJob>): void {
+  jobs = jobs.map((job) => (job.jobId === jobId ? { ...job, ...patch } : job));
+  emitChange();
+}
+
+/**
+ * Run one mint as a background job. `mintFn` is injectable for tests; the
+ * public `startCardMint` binds the real Tauri command.
+ */
+export async function runCardMintJob(
+  input: CardMintInput,
+  mintFn: (
+    id: string,
+    styleNotes?: string,
+    lock?: boolean,
+    memoryLevel?: SnapshotMemoryLevel,
+  ) => Promise<MintedAgentCard>,
+): Promise<void> {
+  const jobId = `card-mint-${nextJobId++}`;
+  jobs = [
+    ...jobs,
+    {
+      jobId,
+      input,
+      phase: "minting",
+      card: null,
+      error: null,
+      startedAt: Date.now(),
+    },
+  ];
+  emitChange();
+
+  try {
+    const card = await mintFn(
+      input.agentId,
+      input.styleNotes,
+      input.lock,
+      input.memoryLevel,
+    );
+    updateJob(jobId, { phase: "done", card });
+    toast.success(`${input.agentName}'s card is ready`, {
+      action: {
+        label: "View card",
+        onClick: () => viewMintedCardJob(jobId),
+      },
+      duration: 10_000,
+    });
+  } catch (error) {
+    let message = error instanceof Error ? error.message : "Card mint failed.";
+    if (message.startsWith(NO_OPENAI_KEY_PREFIX)) {
+      // The dialog pre-checks the key, so this only happens when the key was
+      // removed between dialog-open and mint. The dialog's key-setup panel is
+      // long gone — surface a plain instruction instead of the wire prefix.
+      message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim();
+    }
+    updateJob(jobId, { phase: "error", error: message });
+    toast.error(`Minting ${input.agentName}'s card failed`, {
+      description: message,
+    });
+  }
+}
+
+/** Start a mint in the background. Fire-and-forget; state flows via the store. */
+export function startCardMint(input: CardMintInput): void {
+  void runCardMintJob(input, mintAgentCard);
+}
+
+/** Open the finished card of a job in the viewer and clear its rail chip. */
+export function viewMintedCardJob(jobId: string): void {
+  const job = jobs.find((candidate) => candidate.jobId === jobId);
+  if (job?.phase !== "done" || !job.card) return;
+  viewer = {
+    card: job.card,
+    agentName: job.input.agentName,
+    remint: job.input,
+    viewerSeq: nextViewerSeq++,
+  };
+  jobs = jobs.filter((candidate) => candidate.jobId !== jobId);
+  emitChange();
+}
+
+/** Remove a job chip (used for error dismissal). */
+export function dismissCardMintJob(jobId: string): void {
+  const next = jobs.filter((candidate) => candidate.jobId !== jobId);
+  if (next.length === jobs.length) return;
+  jobs = next;
+  emitChange();
+}
+
+/** Open the viewer on an arbitrary card (e.g. one loaded from the archive). */
+export function openCardViewer(
+  state: Omit<CardViewerState, "viewerSeq">,
+): void {
+  viewer = { ...state, viewerSeq: nextViewerSeq++ };
+  emitChange();
+}
+
+export function closeCardViewer(): void {
+  if (!viewer) return;
+  viewer = null;
+  emitChange();
+}
+
+export function setCardGalleryOpen(open: boolean): void {
+  if (galleryOpen === open) return;
+  galleryOpen = open;
+  emitChange();
+}
+
+export function resetCardMintStore(): void {
+  jobs = [];
+  viewer = null;
+  galleryOpen = false;
+  emitChange();
+}
+
+export function getCardMintJobs(): CardMintJob[] {
+  return jobs;
+}
+
+export function getCardViewerState(): CardViewerState | null {
+  return viewer;
+}
+
+export function getCardGalleryOpen(): boolean {
+  return galleryOpen;
+}
+
+export function subscribeCardMintStore(listener: () => void): () => void {
+  listeners.add(listener);
+  return () => listeners.delete(listener);
+}
+
+export function useCardMintJobs(): CardMintJob[] {
+  return React.useSyncExternalStore(
+    subscribeCardMintStore,
+    getCardMintJobs,
+    getCardMintJobs,
+  );
+}
+
+export function useCardViewerState(): CardViewerState | null {
+  return React.useSyncExternalStore(
+    subscribeCardMintStore,
+    getCardViewerState,
+    getCardViewerState,
+  );
+}
+
+export function useCardGalleryOpen(): boolean {
+  return React.useSyncExternalStore(
+    subscribeCardMintStore,
+    getCardGalleryOpen,
+    getCardGalleryOpen,
+  );
+}
diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs
new file mode 100644
index 0000000000..ebc7caa8f4
--- /dev/null
+++ b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+// Pins the gallery's query-state boundary: a rejected `list_agent_cards`
+// MUST surface as an error state — never as "No cards yet". `data` falls
+// back to `[]` at the call site, so an emptiness-first branch would render
+// a permissions/IPC failure as a false empty archive of paid cards.
+
+import { agentCardGalleryViewState } from "./agentCardGalleryState.ts";
+
+describe("agentCardGalleryViewState", () => {
+  it("maps a rejected query to an error state with the message, not empty", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: true,
+      error: new Error("cards directory unreadable"),
+      data: undefined,
+    });
+    assert.deepEqual(state, {
+      kind: "error",
+      message: "cards directory unreadable",
+    });
+  });
+
+  it("error wins even when data has fallen back to an empty array", () => {
+    // react-query keeps `data: undefined` on first failure, but the call
+    // site coalesces to []. Guard the exact shape that caused the bug.
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: true,
+      error: new Error("ipc failure"),
+      data: [],
+    });
+    assert.equal(state.kind, "error");
+  });
+
+  it("stringifies non-Error rejections", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: true,
+      error: "permission denied",
+      data: undefined,
+    });
+    assert.deepEqual(state, { kind: "error", message: "permission denied" });
+  });
+
+  it("null/undefined rejections still produce a message", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: true,
+      error: null,
+      data: undefined,
+    });
+    assert.deepEqual(state, { kind: "error", message: "unknown error" });
+  });
+
+  it("loading while fetching", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: true,
+      isError: false,
+      error: null,
+      data: undefined,
+    });
+    assert.deepEqual(state, { kind: "loading" });
+  });
+
+  it("no data yet (not loading, not error) stays loading, not empty", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: false,
+      error: null,
+      data: undefined,
+    });
+    assert.deepEqual(state, { kind: "loading" });
+  });
+
+  it("resolved and empty is the only path to the empty state", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: false,
+      error: null,
+      data: [],
+    });
+    assert.deepEqual(state, { kind: "empty" });
+  });
+
+  it("resolved with cards renders cards", () => {
+    const state = agentCardGalleryViewState({
+      isLoading: false,
+      isError: false,
+      error: null,
+      data: [{ storedFileName: "a.png" }],
+    });
+    assert.deepEqual(state, { kind: "cards" });
+  });
+});
diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.ts b/desktop/src/features/agents/lib/agentCardGalleryState.ts
new file mode 100644
index 0000000000..8f6cf4269e
--- /dev/null
+++ b/desktop/src/features/agents/lib/agentCardGalleryState.ts
@@ -0,0 +1,35 @@
+/**
+ * View-state selection for the minted-card gallery.
+ *
+ * Kept as a pure function so the error boundary is testable without a DOM:
+ * a rejected `list_agent_cards` query MUST surface as an error state, never
+ * as an empty archive — "No cards yet" on a permissions/IPC failure tells
+ * someone their paid, persisted cards do not exist.
+ */
+
+export type AgentCardGalleryViewState =
+  | { kind: "loading" }
+  | { kind: "error"; message: string }
+  | { kind: "empty" }
+  | { kind: "cards" };
+
+export function agentCardGalleryViewState(query: {
+  isLoading: boolean;
+  isError: boolean;
+  error: unknown;
+  data: readonly unknown[] | undefined;
+}): AgentCardGalleryViewState {
+  // Error wins over everything: `data` falls back to `[]` at the call site,
+  // so checking emptiness first would render a failure as a false empty.
+  if (query.isError) {
+    const message =
+      query.error instanceof Error
+        ? query.error.message
+        : String(query.error ?? "unknown error");
+    return { kind: "error", message };
+  }
+  if (query.isLoading || query.data === undefined) {
+    return { kind: "loading" };
+  }
+  return query.data.length === 0 ? { kind: "empty" } : { kind: "cards" };
+}
diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx
new file mode 100644
index 0000000000..219de4aefa
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx
@@ -0,0 +1,364 @@
+import * as React from "react";
+import {
+  AlertCircle,
+  Brain,
+  ExternalLink,
+  GalleryVerticalEnd,
+  KeyRound,
+  Lock,
+  Sparkles,
+} from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { openUrl } from "@tauri-apps/plugin-opener";
+import { toast } from "sonner";
+
+import {
+  setCardGalleryOpen,
+  startCardMint,
+} from "@/features/agents/cardMintStore";
+import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig";
+import {
+  cardMintKeyStatus,
+  cardMintSaveOpenaiKey,
+  type SnapshotMemoryLevel,
+} from "@/shared/api/tauriPersonas";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import { Input } from "@/shared/ui/input";
+import { Switch } from "@/shared/ui/switch";
+import { Textarea } from "@/shared/ui/textarea";
+import { SnapshotOptionMenu } from "./SnapshotOptionMenu";
+
+const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
+
+/** Same three levels as snapshot export; "Agent only" is the safe default. */
+const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [
+  { value: "none", label: "Agent only" },
+  { value: "core", label: "Agent + core memory" },
+  { value: "everything", label: "Agent + all memories" },
+];
+
+/**
+ * The free alternative, as an action: ordinary snapshot export shares the
+ * same importable agent without card art or API spend. Rendered in both the
+ * key-setup panel and the normal pre-mint form (the cost disclosure and its
+ * escape hatch must be visible BEFORE any spend, not only during onboarding).
+ */
+function FreeSharePathRow({
+  disabled,
+  onExportInstead,
+}: {
+  disabled: boolean;
+  onExportInstead?: () => void;
+}) {
+  return (
+    <div
+      className="flex items-center justify-between gap-3"
+      data-testid="agent-card-free-path"
+    >
+      <p className="text-xs text-muted-foreground">
+        Don’t want to spend money? Ordinary export shares the same importable
+        agent — free, just without the card art.
+      </p>
+      {onExportInstead ? (
+        <Button
+          className="shrink-0"
+          data-testid="agent-card-export-instead"
+          disabled={disabled}
+          onClick={onExportInstead}
+          size="sm"
+          variant="outline"
+        >
+          Share without card art
+        </Button>
+      ) : null}
+    </div>
+  );
+}
+
+/**
+ * Mint-a-trading-card dialog — the pre-mint half only: key setup (when
+ * needed) → optional style notes → "Mint card". Minting itself runs as a
+ * background job in `cardMintStore`: this dialog dispatches and closes, the
+ * composer activity rail shows live status, and the finished card opens in
+ * the global `AgentCardViewerDialog` (preview, reroll, save, share).
+ *
+ * The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing
+ * the card shares an importable agent (fresh identity, never secrets; memory
+ * only when the owner opts in below — plaintext unless the card is locked).
+ * All snapshot construction and verification happens in Rust.
+ */
+export function AgentCardMintDialog({
+  agentId,
+  agentName,
+  canLock,
+  onExportInstead,
+  onOpenChange,
+}: {
+  /** Instance pubkey or definition slug — same resolution as snapshot export. */
+  agentId: string;
+  agentName: string;
+  /**
+   * True when the agent has a linked instance (a keypair to lock to).
+   * Locking is disabled — with an explanation — for bare definitions.
+   */
+  canLock: boolean;
+  /**
+   * Free alternative: close this dialog and open the ordinary snapshot
+   * export flow (no API spend). Omitted = the action is not rendered.
+   */
+  onExportInstead?: () => void;
+  onOpenChange: (open: boolean) => void;
+}) {
+  const [styleNotes, setStyleNotes] = React.useState("");
+  const [lockCard, setLockCard] = React.useState(false);
+  const [memoryLevel, setMemoryLevel] =
+    React.useState<SnapshotMemoryLevel>("none");
+  const [keyDraft, setKeyDraft] = React.useState("");
+
+  const queryClient = useQueryClient();
+
+  const effectiveLock = canLock && lockCard;
+  // Embedded memory is plaintext in an unlocked card — and unlocked cards
+  // are meant to be shared. A locked card encrypts the whole manifest to the
+  // (owner, agent) pair, so the plaintext warning would be false there.
+  const showMemoryWarning = memoryLevel !== "none" && !effectiveLock;
+
+  // Whether a key already resolves through the agent's env layering. While
+  // unknown (loading/error) we show the normal mint form — the mint itself
+  // still fails cleanly if no key exists.
+  const keyStatusQuery = useQuery({
+    queryKey: ["cardMintKeyStatus", agentId],
+    queryFn: () => cardMintKeyStatus(agentId),
+  });
+  const needsKey = keyStatusQuery.data === false;
+
+  // Save the pasted key into the global Agent Defaults env — the same single
+  // source of truth every agent inherits. Narrow Rust seam: validated
+  // single-key merge, never restarts running agents (the mint re-reads
+  // config per call, so no restart is needed for minting).
+  const saveKeyMutation = useMutation({
+    mutationFn: (key: string) => cardMintSaveOpenaiKey(key),
+    onSuccess: () => {
+      queryClient.setQueryData(["cardMintKeyStatus", agentId], true);
+      // The Agent Defaults editor caches the whole config — refetch it so a
+      // later-opened settings view shows the key we just wrote.
+      void queryClient.invalidateQueries({
+        queryKey: globalAgentConfigQueryKey,
+      });
+      setKeyDraft("");
+      toast.success(
+        "API key saved to your agent defaults. Running agents pick it up on their next restart.",
+      );
+    },
+    onError: (error) =>
+      toast.error(typeof error === "string" ? error : "Couldn't save the key."),
+  });
+
+  function beginMint() {
+    // Dispatch to the background store and close: the composer rail shows
+    // "Minting card…" and the completion toast opens the viewer.
+    startCardMint({
+      agentId,
+      agentName,
+      styleNotes: styleNotes.trim() || undefined,
+      lock: effectiveLock,
+      memoryLevel: canLock ? memoryLevel : "none",
+    });
+    onOpenChange(false);
+  }
+
+  return (
+    <Dialog onOpenChange={onOpenChange} open>
+      <DialogContent className="max-w-md" data-testid="agent-card-mint-dialog">
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <Sparkles className="h-4 w-4" />
+            {`Create ${agentName}'s card`}
+          </DialogTitle>
+          <DialogDescription>
+            Mint a collectible trading card that doubles as a shareable,
+            importable copy of this agent.
+          </DialogDescription>
+        </DialogHeader>
+
+        {needsKey ? (
+          <div
+            className="flex flex-col gap-4"
+            data-testid="agent-card-key-setup"
+          >
+            <div className="flex flex-col gap-2 rounded-md border border-border p-3">
+              <span className="flex items-center gap-1.5 text-sm font-medium">
+                <KeyRound className="h-3.5 w-3.5" />
+                One-time setup: OpenAI API key
+              </span>
+              <p className="text-xs text-muted-foreground">
+                Minting a card costs money — it generates the art and card text
+                through the OpenAI API with your key (typically well under a
+                dollar per mint, billed by OpenAI). The key is saved to your
+                agent defaults, so you only do this once.
+              </p>
+              <Button
+                className="w-fit px-0 text-xs"
+                data-testid="agent-card-key-link"
+                onClick={() =>
+                  void openUrl(OPENAI_KEYS_URL).catch(() => {
+                    toast.error("Failed to open link");
+                  })
+                }
+                size="sm"
+                variant="link"
+              >
+                <ExternalLink className="mr-1 h-3 w-3" />
+                Get a key at platform.openai.com
+              </Button>
+              <Input
+                autoFocus
+                data-testid="agent-card-key-input"
+                disabled={saveKeyMutation.isPending}
+                onChange={(e) => setKeyDraft(e.target.value)}
+                placeholder="sk-…"
+                type="password"
+                value={keyDraft}
+              />
+            </div>
+            <FreeSharePathRow
+              disabled={saveKeyMutation.isPending}
+              onExportInstead={onExportInstead}
+            />
+            <div className="flex justify-end">
+              <Button
+                data-testid="agent-card-key-save"
+                disabled={
+                  saveKeyMutation.isPending || keyDraft.trim().length === 0
+                }
+                onClick={() => saveKeyMutation.mutate(keyDraft.trim())}
+              >
+                <KeyRound className="mr-2 h-4 w-4" />
+                {saveKeyMutation.isPending ? "Saving…" : "Save key & continue"}
+              </Button>
+            </div>
+          </div>
+        ) : (
+          <div className="flex flex-col gap-4">
+            <div className="flex flex-col gap-1.5">
+              <Textarea
+                onChange={(e) => setStyleNotes(e.target.value)}
+                placeholder="Optional style notes for the art and card text"
+                rows={3}
+                value={styleNotes}
+              />
+              <p className="text-xs text-muted-foreground">
+                E.g. art “stormy night, lightning motif” or ability “Verify —
+                scry 2”. Your directions take priority; anything you leave open
+                is designed for you.
+              </p>
+            </div>
+            <div className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
+              <div className="flex flex-col gap-0.5">
+                <span className="flex items-center gap-1.5 text-sm font-medium">
+                  <Brain className="h-3.5 w-3.5" />
+                  Memories
+                </span>
+                <span className="text-xs text-muted-foreground">
+                  {canLock
+                    ? "Choose how much memory the embedded agent carries."
+                    : "Including memory needs a linked agent instance — start this agent once to enable it."}
+                </span>
+              </div>
+              {canLock ? (
+                <SnapshotOptionMenu
+                  ariaLabel="Memories"
+                  className="font-medium text-foreground"
+                  onValueChange={(value) =>
+                    setMemoryLevel(value as SnapshotMemoryLevel)
+                  }
+                  options={MEMORY_LEVELS}
+                  testId="agent-card-memory-trigger"
+                  value={memoryLevel}
+                />
+              ) : (
+                <span
+                  className="inline-flex h-8 w-auto shrink-0 items-center justify-end px-2 text-sm font-medium"
+                  data-testid="agent-card-memory-value"
+                >
+                  Agent only
+                </span>
+              )}
+            </div>
+            {showMemoryWarning ? (
+              <div
+                className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400"
+                data-testid="agent-card-memory-warning"
+              >
+                <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
+                <p>
+                  Memory is stored as <strong>plaintext</strong> in the card,
+                  readable by anyone who has the PNG — and cards are made for
+                  sharing. Lock the card or only share it with people you trust.
+                </p>
+              </div>
+            ) : null}
+            <div className="flex items-start justify-between gap-3 rounded-md border border-border p-3">
+              <div className="flex flex-col gap-0.5">
+                <span className="flex items-center gap-1.5 text-sm font-medium">
+                  <Lock className="h-3.5 w-3.5" />
+                  Lock card
+                </span>
+                <span className="text-xs text-muted-foreground">
+                  {canLock
+                    ? "Encrypt the embedded agent so only you and this agent can import it. Anyone else sees just the image."
+                    : "Locking needs a linked agent instance — start this agent once to enable it."}
+                </span>
+              </div>
+              <Switch
+                checked={canLock && lockCard}
+                data-testid="agent-card-lock-toggle"
+                disabled={!canLock}
+                onCheckedChange={setLockCard}
+              />
+            </div>
+            <p
+              className="text-xs text-muted-foreground"
+              data-testid="agent-card-cost-note"
+            >
+              Minting calls the OpenAI API with your key and costs money —
+              typically well under a dollar per mint, billed by OpenAI. It runs
+              in the background (takes a few minutes); you can keep using Buzz
+              while it works.
+            </p>
+            <FreeSharePathRow
+              disabled={false}
+              onExportInstead={onExportInstead}
+            />
+            <div className="flex items-center justify-between gap-2">
+              <Button
+                className="px-0 text-xs"
+                data-testid="agent-card-open-gallery"
+                onClick={() => {
+                  onOpenChange(false);
+                  setCardGalleryOpen(true);
+                }}
+                size="sm"
+                variant="link"
+              >
+                <GalleryVerticalEnd className="mr-1 h-3 w-3" />
+                View minted cards
+              </Button>
+              <Button onClick={beginMint} data-testid="agent-card-mint">
+                <Sparkles className="mr-2 h-4 w-4" />
+                Mint card
+              </Button>
+            </div>
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
new file mode 100644
index 0000000000..c2422466e4
--- /dev/null
+++ b/desktop/src/features/agents/ui/AgentCardViewerDialog.tsx
@@ -0,0 +1,369 @@
+import * as React from "react";
+import { Download, Lock, RefreshCw, Send, Sparkles } from "lucide-react";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+import {
+  closeCardViewer,
+  openCardViewer,
+  setCardGalleryOpen,
+  startCardMint,
+  useCardGalleryOpen,
+  useCardViewerState,
+  type CardMintInput,
+} from "@/features/agents/cardMintStore";
+import { agentCardGalleryViewState } from "@/features/agents/lib/agentCardGalleryState";
+import {
+  useOpenDmMutation,
+  useUpsertCachedChannel,
+} from "@/features/channels/hooks";
+import {
+  listAgentCards,
+  loadAgentCard,
+  saveAgentCard,
+  type ArchivedAgentCard,
+  type MintedAgentCard,
+} from "@/shared/api/tauriPersonas";
+import type { UserSearchResult } from "@/shared/api/types";
+import { Button } from "@/shared/ui/button";
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/shared/ui/dialog";
+import {
+  MediaContextMenu,
+  type MediaContextMenuPosition,
+  useDismissMediaContextMenu,
+} from "@/shared/ui/markdown/MediaContextMenu";
+
+import { PersonaShareRecipients } from "./PersonaShareRecipients";
+import { useSnapshotSendController } from "./useSnapshotSendController";
+
+/** Decode base64 card bytes into the number[] shape the upload path expects. */
+function cardBytesFromBase64(b64: string): number[] {
+  return Array.from(atob(b64), (char) => char.charCodeAt(0));
+}
+
+/**
+ * Global card surfaces, mounted once in `AppShell` next to the other global
+ * dialogs and driven by `cardMintStore`: the finished-card viewer plus the
+ * previous-mints gallery.
+ */
+export function AgentCardDialogs() {
+  return (
+    <>
+      <AgentCardViewerDialog />
+      <AgentCardGalleryDialog />
+    </>
+  );
+}
+
+/**
+ * Global viewer for a finished agent trading card — the post-mint half of the
+ * old blocking mint dialog, now openable from anywhere (completion toast,
+ * composer "card ready" chip, or the previous-cards gallery). Mounted once in
+ * `AppShell` next to the other global dialogs and driven by `cardMintStore`.
+ *
+ * The card image carries the same right-click download menu inline channel
+ * images have (`MediaContextMenu`), but routed through `save_agent_card`:
+ * `download_image` only accepts relay `/media/` URLs, and this card exists
+ * only as local base64.
+ */
+export function AgentCardViewerDialog() {
+  const state = useCardViewerState();
+
+  // Keyed remount resets recipients/menu state when a different card opens.
+  if (!state) return null;
+  return (
+    <AgentCardViewerContent
+      key={state.viewerSeq}
+      agentName={state.agentName}
+      card={state.card}
+      remint={state.remint}
+    />
+  );
+}
+
+function AgentCardViewerContent({
+  agentName,
+  card,
+  remint,
+}: {
+  agentName: string;
+  card: MintedAgentCard;
+  remint: CardMintInput | null;
+}) {
+  const [recipients, setRecipients] = React.useState<UserSearchResult[]>([]);
+  const [menu, setMenu] = React.useState<MediaContextMenuPosition | null>(null);
+  const closeMenu = React.useCallback(() => setMenu(null), []);
+  useDismissMediaContextMenu(Boolean(menu), closeMenu);
+
+  const sendController = useSnapshotSendController(true);
+  const openDmMutation = useOpenDmMutation();
+  const upsertCachedChannel = useUpsertCachedChannel();
+
+  const saveMutation = useMutation({
+    mutationFn: () => saveAgentCard(card.cardPngBase64, card.fileName),
+    onSuccess: (saved) => {
+      if (saved) {
+        toast.success(
+          `Saved ${agentName}'s card. Share it — the card IS the agent.`,
+        );
+      }
+    },
+    onError: (error: Error) => toast.error(error.message),
+  });
+
+  const isSending =
+    sendController.state.phase !== "idle" &&
+    sendController.state.phase !== "done" &&
+    sendController.state.phase !== "error";
+  const isBusy = saveMutation.isPending || isSending;
+
+  async function sendToRecipients() {
+    if (recipients.length === 0) return;
+    const sent = await sendController.beginSend(
+      async () => ({
+        fileBytes: cardBytesFromBase64(card.cardPngBase64),
+        fileName: card.fileName,
+      }),
+      async () => {
+        const directMessage = await openDmMutation.mutateAsync({
+          pubkeys: recipients.map((recipient) => recipient.pubkey),
+        });
+        await upsertCachedChannel(directMessage);
+        return directMessage.id;
+      },
+      agentName,
+    );
+    if (sent) {
+      toast.success(`Sent ${agentName}'s card.`);
+      closeCardViewer();
+    } else if (sent === false) {
+      toast.error("Couldn’t send the card. Try again.");
+    }
+  }
+
+  return (
+    <Dialog onOpenChange={(open) => !open && closeCardViewer()} open>
+      <DialogContent
+        className="max-w-md"
+        data-testid="agent-card-viewer-dialog"
+      >
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <Sparkles className="h-4 w-4" />
+            {`${agentName}'s card`}
+          </DialogTitle>
+          <DialogDescription>
+            {card.locked
+              ? "This card is locked: only you and the agent can import it. Anyone else sees just the image."
+              : card.memoryLevel === "none"
+                ? "The card carries the agent — anyone who imports this PNG gets a working copy (config only, fresh identity, no memories)."
+                : `The card carries the agent — anyone who imports this PNG gets a working copy (fresh identity) including its ${card.memoryLevel === "core" ? "core memory" : "memories"}, stored as plaintext.`}
+          </DialogDescription>
+        </DialogHeader>
+        <div className="flex flex-col gap-4">
+          <img
+            alt={`${agentName} trading card`}
+            className="mx-auto max-h-[28rem] rounded-lg border shadow-lg"
+            data-testid="agent-card-preview"
+            onContextMenu={(event) => {
+              event.preventDefault();
+              setMenu({ x: event.clientX, y: event.clientY });
+            }}
+            src={`data:image/png;base64,${card.cardPngBase64}`}
+          />
+          {menu ? (
+            <MediaContextMenu
+              dataAttributes={["data-agent-card-context-menu"]}
+              items={[
+                {
+                  label: "Download card",
+                  onSelect: () => {
+                    closeMenu();
+                    saveMutation.mutate();
+                  },
+                },
+              ]}
+              position={menu}
+            />
+          ) : null}
+          <PersonaShareRecipients
+            disabled={isBusy || !sendController.isDmSafetyReady}
+            excludedPubkeys={
+              sendController.relaySelfPubkey
+                ? [sendController.relaySelfPubkey]
+                : []
+            }
+            onSelectionChange={setRecipients}
+            open
+            selectedUsers={recipients}
+            testIdPrefix="agent-card-share"
+          />
+          <div className="flex justify-end gap-2">
+            {remint ? (
+              <Button
+                disabled={isBusy}
+                data-testid="agent-card-reroll"
+                onClick={() => {
+                  // Reroll = a fresh background mint with the same input; the
+                  // viewer closes and the composer chip takes over.
+                  startCardMint(remint);
+                  closeCardViewer();
+                }}
+                variant="outline"
+              >
+                <RefreshCw className="mr-2 h-4 w-4" />
+                Reroll
+              </Button>
+            ) : null}
+            <Button
+              disabled={isBusy}
+              onClick={() => saveMutation.mutate()}
+              data-testid="agent-card-save"
+              variant={recipients.length > 0 ? "outline" : "default"}
+            >
+              <Download className="mr-2 h-4 w-4" />
+              Save card
+            </Button>
+            {recipients.length > 0 ? (
+              <Button
+                disabled={isBusy}
+                onClick={() => void sendToRecipients()}
+                data-testid="agent-card-send"
+              >
+                <Send className="mr-2 h-4 w-4" />
+                {isSending ? "Sending…" : "Send"}
+              </Button>
+            ) : null}
+          </div>
+        </div>
+      </DialogContent>
+    </Dialog>
+  );
+}
+
+/**
+ * Gallery of all previously minted cards, newest first. Every successful mint
+ * is archived to the app data dir by Rust; clicking a tile loads the full PNG
+ * and opens it in the viewer (save/share, no reroll — the original style
+ * notes are not retained).
+ */
+export function AgentCardGalleryDialog() {
+  const open = useCardGalleryOpen();
+  if (!open) return null;
+  return <AgentCardGalleryContent />;
+}
+
+function AgentCardGalleryContent() {
+  const cardsQuery = useQuery({
+    queryKey: ["agentCardArchive"],
+    queryFn: listAgentCards,
+  });
+
+  const openMutation = useMutation({
+    mutationFn: async (entry: ArchivedAgentCard) => {
+      const cardPngBase64 = await loadAgentCard(entry.storedFileName);
+      return { entry, cardPngBase64 };
+    },
+    onSuccess: ({ entry, cardPngBase64 }) => {
+      setCardGalleryOpen(false);
+      openCardViewer({
+        agentName: entry.agentName,
+        card: {
+          cardPngBase64,
+          fileName: entry.fileName,
+          designerNotes: entry.designerNotes,
+          locked: entry.locked,
+          memoryLevel: entry.memoryLevel,
+        },
+        remint: null,
+      });
+    },
+    onError: (error: Error) => toast.error(error.message),
+  });
+
+  const cards = cardsQuery.data ?? [];
+  const viewState = agentCardGalleryViewState(cardsQuery);
+
+  return (
+    <Dialog onOpenChange={(open) => setCardGalleryOpen(open)} open>
+      <DialogContent
+        className="max-w-lg"
+        data-testid="agent-card-gallery-dialog"
+      >
+        <DialogHeader>
+          <DialogTitle className="flex items-center gap-2">
+            <Sparkles className="h-4 w-4" />
+            Minted cards
+          </DialogTitle>
+          <DialogDescription>
+            Every card you’ve minted, newest first. Click one to view, save, or
+            share it.
+          </DialogDescription>
+        </DialogHeader>
+        {viewState.kind === "loading" ? (
+          <p className="py-8 text-center text-sm text-muted-foreground">
+            Loading cards…
+          </p>
+        ) : viewState.kind === "error" ? (
+          <div
+            className="flex flex-col items-center gap-3 py-8"
+            data-testid="agent-card-gallery-error"
+            role="alert"
+          >
+            <p className="text-center text-sm text-destructive">
+              Couldn’t load your minted cards: {viewState.message}
+            </p>
+            <Button
+              disabled={cardsQuery.isFetching}
+              onClick={() => void cardsQuery.refetch()}
+              size="sm"
+              variant="outline"
+            >
+              <RefreshCw className="mr-2 h-4 w-4" />
+              {cardsQuery.isFetching ? "Retrying…" : "Retry"}
+            </Button>
+          </div>
+        ) : viewState.kind === "empty" ? (
+          <p className="py-8 text-center text-sm text-muted-foreground">
+            No cards yet — mint one from an agent’s profile.
+          </p>
+        ) : (
+          <div className="grid max-h-[60vh] grid-cols-3 gap-3 overflow-y-auto pr-1">
+            {cards.map((entry) => (
+              <button
+                className="group flex flex-col gap-1.5 text-left"
+                data-testid="agent-card-gallery-item"
+                disabled={openMutation.isPending}
+                key={entry.storedFileName}
+                onClick={() => openMutation.mutate(entry)}
+                type="button"
+              >
+                {entry.thumbJpegBase64 ? (
+                  <img
+                    alt={`${entry.agentName} trading card`}
+                    className="aspect-2/3 w-full rounded-lg border object-cover shadow-sm transition-transform group-hover:scale-[1.02]"
+                    src={`data:image/jpeg;base64,${entry.thumbJpegBase64}`}
+                  />
+                ) : (
+                  <div className="flex aspect-2/3 w-full items-center justify-center rounded-lg border bg-muted/40">
+                    <Sparkles className="h-6 w-6 text-muted-foreground" />
+                  </div>
+                )}
+                <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
+                  {entry.locked ? <Lock className="h-3 w-3 shrink-0" /> : null}
+                  <span className="truncate">{entry.agentName}</span>
+                </span>
+              </button>
+            ))}
+          </div>
+        )}
+      </DialogContent>
+    </Dialog>
+  );
+}
diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
index 604bf9f703..0d01cbbcd1 100644
--- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
+++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx
@@ -1,4 +1,5 @@
 import { useAgentManagement } from "@/features/agents/useAgentManagement";
+import { AgentCardDialogs } from "./AgentCardViewerDialog";
 import { AgentDialog } from "./AgentDialog";
 import { SecretRevealDialog } from "./SecretRevealDialog";
 
@@ -55,6 +56,7 @@ export function AgentManagementDialogs() {
           title="Edit agent"
         />
       ) : null}
+      <AgentCardDialogs />
     </>
   );
 }
diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx
index 4a9584dfb9..0527565763 100644
--- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx
@@ -1,5 +1,5 @@
 import * as React from "react";
-import { AlertCircle, Upload } from "lucide-react";
+import { AlertCircle, Lock, Upload } from "lucide-react";
 
 import type {
   AgentSnapshotImportPreview,
@@ -72,7 +72,7 @@ export function AgentSnapshotImportDialog({
     <Dialog onOpenChange={onOpenChange} open={open}>
       <DialogContent
         aria-describedby={undefined}
-        className="max-w-md"
+        className="max-h-[85vh] max-w-2xl overflow-y-auto"
         data-testid="agent-snapshot-import-dialog"
         showCloseButton={false}
       >
@@ -141,7 +141,7 @@ export function AgentSnapshotImportDialog({
 
 // ── Preview body ──────────────────────────────────────────────────────────────
 
-function PreviewBody({
+export function PreviewBody({
   preview,
   hasMemory,
   memoryLevelLabel,
@@ -159,19 +159,49 @@ function PreviewBody({
       {/* Agent identity */}
       <div className="space-y-1">
         <p className="text-sm font-medium">{preview.displayName}</p>
-        {preview.systemPrompt ? (
-          <p className="line-clamp-3 text-xs text-muted-foreground">
-            {preview.systemPrompt}
-          </p>
-        ) : null}
       </div>
 
+      {/* Locked-card provenance: this file was encrypted to this machine's
+          keys and has been unlocked for review. */}
+      {preview.locked ? (
+        <div
+          className="flex items-start gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm"
+          data-testid="agent-snapshot-import-locked-notice"
+        >
+          <Lock className="mt-0.5 h-4 w-4 shrink-0" />
+          <p>
+            This card is <strong>locked</strong> — its agent is encrypted to the
+            original owner and agent keys. Your keys unlocked it; the full
+            decrypted payload is shown below.
+          </p>
+        </div>
+      ) : null}
+
       <AgentDefinitionMetadata
         isBuiltIn={preview.isBuiltIn}
         model={preview.model}
         runtime={preview.runtime}
       />
 
+      {/* Portable behavior — never hide executable configuration behind a summary. */}
+      <section
+        className="space-y-2 rounded-md border border-border p-3"
+        data-testid="agent-snapshot-import-behavior"
+      >
+        <div>
+          <p className="text-sm font-medium">Agent instructions</p>
+          <p className="text-xs text-muted-foreground">
+            Review the instructions this agent will follow after import.
+          </p>
+        </div>
+        <pre
+          className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/60 p-3 text-xs"
+          data-testid="agent-snapshot-import-system-prompt"
+        >
+          {preview.systemPrompt || "No system prompt included."}
+        </pre>
+      </section>
+
       <p className="text-sm text-muted-foreground">
         A new agent will be created with a fresh keypair. The imported agent is
         independent of the source — identity never travels.
@@ -211,9 +241,19 @@ function PreviewBody({
             {preview.sourceAllowlistCount === 1 ? "y" : "ies"})
           </p>
           <p className="text-xs text-muted-foreground">
-            This snapshot includes a source-environment pubkey allowlist. Those
-            identities are not meaningful on your relay.
+            This snapshot includes source-environment pubkeys. Review every key
+            before choosing Keep; Clear is safer when you do not recognize them.
           </p>
+          <ul
+            className="max-h-28 space-y-1 overflow-y-auto rounded bg-muted/60 p-2 font-mono text-xs"
+            data-testid="agent-snapshot-import-allowlist-values"
+          >
+            {preview.sourceAllowlist.map((pubkey) => (
+              <li className="break-all" key={pubkey}>
+                {pubkey}
+              </li>
+            ))}
+          </ul>
           <div className="flex flex-col gap-1.5">
             <label className="flex cursor-pointer items-center gap-2">
               <input
@@ -242,6 +282,22 @@ function PreviewBody({
           </div>
         </div>
       ) : null}
+
+      <details
+        className="rounded-md border border-border p-3"
+        data-testid="agent-snapshot-import-manifest"
+      >
+        <summary className="cursor-pointer text-sm font-medium">
+          Full embedded manifest
+        </summary>
+        <p className="mt-2 text-xs text-muted-foreground">
+          This is the complete portable payload decoded from the file. Secrets,
+          credentials, and source identity are not part of the snapshot format.
+        </p>
+        <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/60 p-3 text-xs">
+          {preview.manifestJson}
+        </pre>
+      </details>
     </div>
   );
 }
diff --git a/desktop/src/features/agents/ui/CardMintComposerChip.tsx b/desktop/src/features/agents/ui/CardMintComposerChip.tsx
new file mode 100644
index 0000000000..158ec9a07b
--- /dev/null
+++ b/desktop/src/features/agents/ui/CardMintComposerChip.tsx
@@ -0,0 +1,78 @@
+import { Loader2, Sparkles, X } from "lucide-react";
+
+import {
+  dismissCardMintJob,
+  useCardMintJobs,
+  viewMintedCardJob,
+} from "@/features/agents/cardMintStore";
+import { Shimmer } from "@/shared/ui/Shimmer";
+
+/**
+ * Composer-rail status for background card mints: a live "Minting card…"
+ * row while the ~2–3 minute mint runs, then a clickable "card is ready" row
+ * (opens the viewer) or a dismissible failure row. Rendered inside the
+ * composer dock's bottom activity rail next to agent-working/typing rows.
+ */
+export function CardMintComposerChip() {
+  const jobs = useCardMintJobs();
+  if (jobs.length === 0) return null;
+
+  return (
+    <div
+      className="flex min-w-0 flex-col justify-center gap-0.5"
+      data-testid="card-mint-composer-chip"
+    >
+      {jobs.map((job) => {
+        if (job.phase === "minting") {
+          return (
+            <span
+              className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground"
+              data-testid="card-mint-status-minting"
+              key={job.jobId}
+            >
+              <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin opacity-70" />
+              <Shimmer className="-my-px truncate py-px">
+                {`Minting ${job.input.agentName}’s card… (takes a few minutes)`}
+              </Shimmer>
+            </span>
+          );
+        }
+        if (job.phase === "done") {
+          return (
+            <button
+              className="flex min-w-0 items-center gap-1.5 text-xs text-primary hover:underline"
+              data-testid="card-mint-status-done"
+              key={job.jobId}
+              onClick={() => viewMintedCardJob(job.jobId)}
+              type="button"
+            >
+              <Sparkles className="h-3.5 w-3.5 shrink-0" />
+              <span className="truncate">
+                {`${job.input.agentName}’s card is ready — view it`}
+              </span>
+            </button>
+          );
+        }
+        return (
+          <span
+            className="flex min-w-0 items-center gap-1.5 text-xs text-destructive"
+            data-testid="card-mint-status-error"
+            key={job.jobId}
+          >
+            <span className="truncate">
+              {`Minting ${job.input.agentName}’s card failed`}
+            </span>
+            <button
+              aria-label="Dismiss failed mint"
+              className="shrink-0 opacity-70 hover:opacity-100"
+              onClick={() => dismissCardMintJob(job.jobId)}
+              type="button"
+            >
+              <X className="h-3.5 w-3.5" />
+            </button>
+          </span>
+        );
+      })}
+    </div>
+  );
+}
diff --git a/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs b/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs
index 3ddaf19724..a02bb5a825 100644
--- a/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs
+++ b/desktop/src/features/agents/ui/agentSnapshotImportDialog.test.mjs
@@ -9,7 +9,7 @@ import test from "node:test";
 // memoryErrors strings are carried through to a bounded list element with the
 // correct data-testid. No DOM or test renderer is needed.
 
-import { ResultBody } from "./AgentSnapshotImportDialog.tsx";
+import { PreviewBody, ResultBody } from "./AgentSnapshotImportDialog.tsx";
 
 /**
  * Walk a React element tree (breadth-first) and collect all elements that
@@ -71,6 +71,68 @@ function makeResult(overrides = {}) {
   };
 }
 
+function makePreview(overrides = {}) {
+  return {
+    displayName: "TestBot",
+    systemPrompt: "Inspect every boundary before changing code.",
+    avatarUrl: null,
+    memoryLevel: "none",
+    memoryEntryCount: 0,
+    hasSourceAllowlist: true,
+    sourceAllowlistCount: 2,
+    sourceAllowlist: ["a".repeat(64), "b".repeat(64)],
+    manifestJson: '{\n  "format": "buzz-agent-snapshot"\n}',
+    locked: false,
+    ...overrides,
+  };
+}
+
+// ── preview transparency ──────────────────────────────────────────────────────
+
+test("preview_body_discloses_prompt_allowlist_and_full_manifest", () => {
+  const preview = makePreview();
+  const element = PreviewBody({
+    preview,
+    hasMemory: false,
+    memoryLevelLabel: "none",
+    keepAllowlist: false,
+    onKeepAllowlistChange: () => {},
+  });
+  const allText = collectText(element).join(" ");
+
+  assert.ok(allText.includes(preview.systemPrompt));
+  for (const pubkey of preview.sourceAllowlist) {
+    assert.ok(allText.includes(pubkey), `missing allowlist pubkey ${pubkey}`);
+  }
+  assert.ok(allText.includes(preview.manifestJson));
+  assert.equal(
+    findAll(
+      element,
+      (n) => n.props?.["data-testid"] === "agent-snapshot-import-manifest",
+    ).length,
+    1,
+  );
+});
+
+// ── locked-card provenance notice ─────────────────────────────────────────────
+
+test("preview_body_shows_locked_notice_only_for_locked_cards", () => {
+  const lockedNotice = (locked) =>
+    findAll(
+      PreviewBody({
+        preview: makePreview({ locked }),
+        hasMemory: false,
+        memoryLevelLabel: "none",
+        keepAllowlist: false,
+        onKeepAllowlistChange: () => {},
+      }),
+      (n) => n.props?.["data-testid"] === "agent-snapshot-import-locked-notice",
+    );
+
+  assert.equal(lockedNotice(true).length, 1);
+  assert.equal(lockedNotice(false).length, 0);
+});
+
 // ── memory errors detail list ─────────────────────────────────────────────────
 
 test("result_body_renders_memory_errors_list_with_test_id", () => {
diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx
index 216b205fcc..f99888f011 100644
--- a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx
+++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx
@@ -1,5 +1,7 @@
 import type { ComponentProps } from "react";
 
+import { CardMintComposerChip } from "@/features/agents/ui/CardMintComposerChip";
+import { useCardMintJobs } from "@/features/agents/cardMintStore";
 import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
 import { ComposerActivityAccessory } from "@/features/messages/ui/ComposerActivityAccessory";
 import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
@@ -31,6 +33,7 @@ export function ChannelComposerActivityAccessory({
   visible,
   workingBotPubkeys,
 }: ChannelComposerActivityAccessoryProps) {
+  const cardMintJobs = useCardMintJobs();
   return (
     <ComposerActivityAccessory
       className="px-5"
@@ -38,6 +41,7 @@ export function ChannelComposerActivityAccessory({
       visible={visible}
     >
       <div className="flex w-full items-center gap-2 overflow-visible pl-2">
+        {cardMintJobs.length > 0 ? <CardMintComposerChip /> : null}
         {workingBotPubkeys.length > 0 ? (
           <div className="flex min-w-0 flex-1 overflow-visible">
             <BotActivityComposerAction
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 70877875f7..20eff07ea6 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -36,6 +36,7 @@ import { useThreadViewMode } from "@/features/channels/lib/threadViewModePrefere
 import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch";
 import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence";
 import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal";
+import { useCardMintJobs } from "@/features/agents/cardMintStore";
 import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar";
 import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
 import {
@@ -408,7 +409,11 @@ export const ChannelPane = React.memo(function ChannelPane({
     activeChannel?.id ?? null,
   );
   const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0;
-  const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity;
+  // Background card mints surface in the same rail ("Minting card…" chip),
+  // so they must also reserve the activity row.
+  const hasCardMintActivity = useCardMintJobs().length > 0;
+  const hasComposerBottomActivity =
+    hasComposerBotActivity || hasTypingActivity || hasCardMintActivity;
   const threadComposerBotTypingPubkeys = React.useMemo(() => {
     if (!openThreadHeadId) return [];
     return botTypingEntries
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx
index c1f713d230..af30728d6a 100644
--- a/desktop/src/features/profile/ui/UserProfilePanel.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx
@@ -71,8 +71,10 @@ import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProf
 import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion";
 import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields";
 import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit";
-import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs";
-import { UserProfileSnapshotExportDialog } from "@/features/profile/ui/UserProfileSnapshotExportDialog";
+import {
+  type CardMintTarget,
+  UserProfilePersonaDialogs,
+} from "@/features/profile/ui/UserProfilePersonaDialogs";
 import {
   deriveProfileChannels,
   type ProfilePanelTab,
@@ -181,6 +183,8 @@ export function UserProfilePanel({
     React.useState<AgentPersona | null>(null);
   const [personaToExportSnapshot, setPersonaToExportSnapshot] =
     React.useState<AgentPersona | null>(null);
+  const [cardMintTarget, setCardMintTarget] =
+    React.useState<CardMintTarget | null>(null);
 
   const personasQuery = usePersonasQuery();
   const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
@@ -824,6 +828,19 @@ export function UserProfilePanel({
           onOpenInstructions={() => setView("instructions")}
           onTabChange={setTab}
           onOpenDm={onOpenDm}
+          onCreateCard={
+            canManagePersona && resolvedPersona
+              ? () =>
+                  setCardMintTarget({
+                    // Prefer the live instance pubkey; fall back to the
+                    // persona/definition id (same resolution as export).
+                    id: managedAgent?.pubkey ?? resolvedPersona.id,
+                    name: resolvedPersona.displayName,
+                    // Locking needs an instance keypair to encrypt to.
+                    canLock: Boolean(managedAgent?.pubkey),
+                  })
+              : undefined
+          }
           presenceStatus={presenceStatus}
           profile={profile}
           pubkey={effectivePubkey}
@@ -921,6 +938,7 @@ export function UserProfilePanel({
   const personaDialogs = (
     <>
       <UserProfilePersonaDialogs
+        cardMintTarget={cardMintTarget}
         createError={
           createPersonaMutation.error instanceof Error
             ? createPersonaMutation.error
@@ -933,8 +951,11 @@ export function UserProfilePanel({
           updateManagedAgentMutation.isPending ||
           createAgentMutation.isPending
         }
+        linkedAgentPubkey={managedAgent?.pubkey ?? null}
         personaDialogState={personaDialogState}
         personaToDelete={personaToDelete}
+        personaToExportSnapshot={personaToExportSnapshot}
+        resolvedPersona={resolvedPersona}
         runtimes={acpRuntimesQuery.data ?? []}
         runtimesLoading={acpRuntimesQuery.isLoading}
         updateError={
@@ -942,22 +963,16 @@ export function UserProfilePanel({
             ? updatePersonaMutation.error
             : null
         }
+        onCloseCardMint={() => setCardMintTarget(null)}
         onCloseDelete={() => setPersonaToDelete(null)}
         onCloseDialog={() => setPersonaDialogState(null)}
+        onCloseExportSnapshot={() => setPersonaToExportSnapshot(null)}
         onConfirmDelete={(selectedPersona) => {
           void handleConfirmDeletePersona(selectedPersona);
         }}
+        onExportSnapshot={setPersonaToExportSnapshot}
         onSubmit={handleSubmitPersona}
       />
-      {personaToExportSnapshot ? (
-        <UserProfileSnapshotExportDialog
-          linkedAgentPubkey={managedAgent?.pubkey ?? null}
-          onOpenChange={(open) => {
-            if (!open) setPersonaToExportSnapshot(null);
-          }}
-          persona={personaToExportSnapshot}
-        />
-      ) : null}
     </>
   );
   return (
diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
index 4514b62d95..057376a38c 100644
--- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
@@ -1,19 +1,11 @@
 import * as React from "react";
-import type { LucideIcon } from "lucide-react";
 import {
   ArrowUpRight,
   ChevronDown,
   ChevronUp,
   CircleAlert,
-  MessageSquare,
-  Pencil,
-  Play,
-  RefreshCw,
-  Square,
-  UserMinus,
   UserPlus,
 } from "lucide-react";
-import { toast } from "sonner";
 
 import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
 import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
@@ -44,19 +36,20 @@ import {
   STATUS_DOT_MASK_CURVE,
 } from "@/features/profile/ui/MaskedAvatarBadgeFrame";
 import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import {
+  ProfilePersonaPrimaryActions,
+  ProfilePrimaryActions,
+} from "@/features/profile/ui/UserProfilePrimaryActions";
 import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
 import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
-import { Spinner } from "@/shared/ui/spinner";
 import type {
   ProfileChannelLink,
   ProfilePanelTab,
 } from "@/features/profile/ui/UserProfilePanelUtils";
-import { useFeatureEnabled } from "@/shared/features";
 import { cn } from "@/shared/lib/cn";
 import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
 import { Badge } from "@/shared/ui/badge";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
 
 export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails";
 
@@ -105,6 +98,8 @@ export type ProfileSummaryViewProps = {
   onOpenInstructions: () => void;
   onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void;
   onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
+  /** Mint an agent trading card. Present only for owner-managed personas. */
+  onCreateCard?: () => void;
   presenceStatus: "online" | "away" | "offline" | undefined;
   profile: ReturnType<typeof useUserProfileQuery>["data"];
   pubkey: string | null;
@@ -218,6 +213,7 @@ export function ProfileSummaryView({
   onOpenInstructions,
   onTabChange,
   onOpenDm,
+  onCreateCard,
   presenceStatus,
   profile,
   pubkey,
@@ -349,6 +345,7 @@ export function ProfileSummaryView({
         <ProfilePersonaPrimaryActions
           canEditAgent={canEditAgent}
           disabled={isAgentActionPending}
+          onCreateCard={onCreateCard}
           onEditAgent={handleEditAgent}
           onStartAgent={handleInstantiateAgent}
         />
@@ -356,6 +353,7 @@ export function ProfileSummaryView({
         <ProfilePrimaryActions
           canEditAgent={canEditAgent}
           followMutation={followMutation}
+          onCreateCard={onCreateCard}
           onEditAgent={handleEditAgent}
           agentActionDisabled={isAgentActionPending}
           agentActionLabel={
@@ -629,181 +627,6 @@ function ProfileHeroDescription({ about }: { about: string }) {
   );
 }
 
-// ── Primary actions ──────────────────────────────────────────────────────────
-
-function ProfilePrimaryActions({
-  agentActionDisabled,
-  agentActionLabel,
-  agentActionLive,
-  canEditAgent,
-  followMutation,
-  isFollowing,
-  messagePending,
-  onAgentPrimaryAction,
-  onAgentRestart,
-  onEditAgent,
-  onMessage,
-  pubkey,
-  unfollowMutation,
-}: {
-  agentActionDisabled?: boolean;
-  agentActionLabel?: string;
-  agentActionLive?: boolean;
-  canEditAgent: boolean;
-  followMutation: ReturnType<typeof useFollowMutation>;
-  isFollowing: boolean;
-  messagePending?: boolean;
-  onAgentPrimaryAction?: () => void;
-  onAgentRestart?: () => void;
-  onEditAgent: () => void;
-  onMessage?: () => void;
-  pubkey: string;
-  unfollowMutation: ReturnType<typeof useUnfollowMutation>;
-}) {
-  const showFollowAction = useFeatureEnabled("pulse");
-  const followToggleMutation = isFollowing ? unfollowMutation : followMutation;
-
-  const handleFollowClick = () => {
-    followToggleMutation.mutate(pubkey, {
-      onError: (error) =>
-        toast.error(
-          `${isFollowing ? "Unfollow" : "Follow"} failed: ${error.message}`,
-        ),
-    });
-  };
-
-  return (
-    <div className="flex items-center justify-center gap-8">
-      {showFollowAction ? (
-        <ProfileQuickAction
-          active={isFollowing}
-          disabled={followToggleMutation.isPending}
-          icon={isFollowing ? UserMinus : UserPlus}
-          label={isFollowing ? "Unfollow" : "Follow"}
-          onClick={handleFollowClick}
-        />
-      ) : null}
-      {onMessage ? (
-        <ProfileQuickAction
-          disabled={messagePending}
-          icon={MessageSquare}
-          isLoading={messagePending}
-          label="Message"
-          onClick={onMessage}
-          testId="user-profile-message"
-        />
-      ) : null}
-      {canEditAgent ? (
-        <ProfileQuickAction
-          icon={Pencil}
-          label="Edit"
-          onClick={onEditAgent}
-          testId="user-profile-edit-agent"
-        />
-      ) : null}
-      {onAgentPrimaryAction && agentActionLabel ? (
-        <ProfileQuickAction
-          active={agentActionLive}
-          disabled={agentActionDisabled}
-          icon={agentActionLive ? Square : Play}
-          label={agentActionLabel}
-          onClick={onAgentPrimaryAction}
-          testId="user-profile-agent-primary-action"
-        />
-      ) : null}
-      {onAgentRestart ? (
-        <ProfileQuickAction
-          disabled={agentActionDisabled}
-          icon={RefreshCw}
-          label="Restart"
-          onClick={onAgentRestart}
-          testId="user-profile-agent-restart"
-        />
-      ) : null}
-    </div>
-  );
-}
-
-function ProfilePersonaPrimaryActions({
-  canEditAgent,
-  disabled,
-  onEditAgent,
-  onStartAgent,
-}: {
-  canEditAgent: boolean;
-  disabled: boolean;
-  onEditAgent: () => void;
-  onStartAgent: () => void;
-}) {
-  return (
-    <div className="flex items-center justify-center gap-8">
-      <ProfileQuickAction
-        disabled={disabled}
-        icon={Play}
-        label="Start agent"
-        onClick={onStartAgent}
-        testId="user-profile-start-agent"
-      />
-      {canEditAgent ? (
-        <ProfileQuickAction
-          disabled={disabled}
-          icon={Pencil}
-          label="Edit"
-          onClick={onEditAgent}
-          testId="user-profile-edit-agent"
-        />
-      ) : null}
-    </div>
-  );
-}
-
-function ProfileQuickAction({
-  active,
-  disabled,
-  icon: Icon,
-  isLoading,
-  label,
-  onClick,
-  testId,
-}: {
-  active?: boolean;
-  disabled?: boolean;
-  icon: LucideIcon;
-  isLoading?: boolean;
-  label: string;
-  onClick: () => void;
-  testId?: string;
-}) {
-  return (
-    <Tooltip>
-      <TooltipTrigger asChild>
-        <button
-          aria-label={label}
-          className={cn(
-            "flex h-12 w-12 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
-            active
-              ? "bg-foreground text-background hover:bg-foreground/90"
-              : "bg-muted/60 text-foreground hover:bg-muted/80",
-          )}
-          data-testid={testId}
-          disabled={disabled}
-          onClick={onClick}
-          type="button"
-        >
-          {isLoading ? (
-            <Spinner aria-hidden="true" className="h-4 w-4 border-2" />
-          ) : (
-            <Icon className="h-4 w-4" />
-          )}
-        </button>
-      </TooltipTrigger>
-      <TooltipContent align="center" side="top">
-        {label}
-      </TooltipContent>
-    </Tooltip>
-  );
-}
-
 // ── Focused views ────────────────────────────────────────────────────────────
 
 export function MemoryFocusedView({
diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx
index 5e468b5320..2de2e2f716 100644
--- a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx
@@ -4,36 +4,59 @@ import type {
   CreatePersonaInput,
   UpdatePersonaInput,
 } from "@/shared/api/types";
+import { AgentCardMintDialog } from "@/features/agents/ui/AgentCardMintDialog";
 import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog";
 import { AgentDialog } from "@/features/agents/ui/AgentDialog";
 import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState";
+import { UserProfileSnapshotExportDialog } from "@/features/profile/ui/UserProfileSnapshotExportDialog";
+
+/** Agent selected for card minting. */
+export type CardMintTarget = {
+  id: string;
+  name: string;
+  canLock: boolean;
+};
 
 export function UserProfilePersonaDialogs({
+  cardMintTarget,
   createError,
   instanceCount,
   isPending,
+  linkedAgentPubkey,
   personaDialogState,
   personaToDelete,
+  personaToExportSnapshot,
+  resolvedPersona,
   runtimes,
   runtimesLoading,
   updateError,
+  onCloseCardMint,
   onCloseDelete,
   onCloseDialog,
+  onCloseExportSnapshot,
   onConfirmDelete,
+  onExportSnapshot,
   onSubmit,
 }: {
+  cardMintTarget: CardMintTarget | null;
   createError: Error | null;
   /** Number of managed-agent instances backed by the persona being deleted. */
   instanceCount: number;
   isPending: boolean;
+  linkedAgentPubkey: string | null;
   personaDialogState: PersonaDialogState | null;
   personaToDelete: AgentPersona | null;
+  personaToExportSnapshot: AgentPersona | null;
+  resolvedPersona: AgentPersona | undefined;
   runtimes: AcpRuntimeCatalogEntry[];
   runtimesLoading: boolean;
   updateError: Error | null;
+  onCloseCardMint: () => void;
   onCloseDelete: () => void;
   onCloseDialog: () => void;
+  onCloseExportSnapshot: () => void;
   onConfirmDelete: (persona: AgentPersona) => void;
+  onExportSnapshot: (persona: AgentPersona) => void;
   onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
 }) {
   return (
@@ -67,6 +90,35 @@ export function UserProfilePersonaDialogs({
         open={personaToDelete !== null}
         persona={personaToDelete}
       />
+      {personaToExportSnapshot ? (
+        <UserProfileSnapshotExportDialog
+          linkedAgentPubkey={linkedAgentPubkey}
+          onOpenChange={(open) => {
+            if (!open) onCloseExportSnapshot();
+          }}
+          persona={personaToExportSnapshot}
+        />
+      ) : null}
+      {cardMintTarget ? (
+        <AgentCardMintDialog
+          agentId={cardMintTarget.id}
+          agentName={cardMintTarget.name}
+          canLock={cardMintTarget.canLock}
+          onExportInstead={
+            resolvedPersona
+              ? () => {
+                  // Free path: swap the mint dialog for the ordinary
+                  // snapshot export flow (same importable agent, no spend).
+                  onCloseCardMint();
+                  onExportSnapshot(resolvedPersona);
+                }
+              : undefined
+          }
+          onOpenChange={(open) => {
+            if (!open) onCloseCardMint();
+          }}
+        />
+      ) : null}
     </>
   );
 }
diff --git a/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx
new file mode 100644
index 0000000000..f03635ff18
--- /dev/null
+++ b/desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx
@@ -0,0 +1,217 @@
+import type { LucideIcon } from "lucide-react";
+import {
+  MessageSquare,
+  Pencil,
+  Play,
+  RefreshCw,
+  Sparkles,
+  Square,
+  UserMinus,
+  UserPlus,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import type {
+  useFollowMutation,
+  useUnfollowMutation,
+} from "@/features/profile/hooks";
+import { useFeatureEnabled } from "@/shared/features";
+import { cn } from "@/shared/lib/cn";
+import { Spinner } from "@/shared/ui/spinner";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+
+// ── Primary actions ──────────────────────────────────────────────────────────
+
+export function ProfilePrimaryActions({
+  agentActionDisabled,
+  agentActionLabel,
+  agentActionLive,
+  canEditAgent,
+  followMutation,
+  isFollowing,
+  messagePending,
+  onAgentPrimaryAction,
+  onAgentRestart,
+  onCreateCard,
+  onEditAgent,
+  onMessage,
+  pubkey,
+  unfollowMutation,
+}: {
+  agentActionDisabled?: boolean;
+  agentActionLabel?: string;
+  agentActionLive?: boolean;
+  canEditAgent: boolean;
+  followMutation: ReturnType<typeof useFollowMutation>;
+  isFollowing: boolean;
+  messagePending?: boolean;
+  onAgentPrimaryAction?: () => void;
+  onAgentRestart?: () => void;
+  onCreateCard?: () => void;
+  onEditAgent: () => void;
+  onMessage?: () => void;
+  pubkey: string;
+  unfollowMutation: ReturnType<typeof useUnfollowMutation>;
+}) {
+  const showFollowAction = useFeatureEnabled("pulse");
+  const followToggleMutation = isFollowing ? unfollowMutation : followMutation;
+
+  const handleFollowClick = () => {
+    followToggleMutation.mutate(pubkey, {
+      onError: (error) =>
+        toast.error(
+          `${isFollowing ? "Unfollow" : "Follow"} failed: ${error.message}`,
+        ),
+    });
+  };
+
+  return (
+    <div className="flex items-center justify-center gap-8">
+      {showFollowAction ? (
+        <ProfileQuickAction
+          active={isFollowing}
+          disabled={followToggleMutation.isPending}
+          icon={isFollowing ? UserMinus : UserPlus}
+          label={isFollowing ? "Unfollow" : "Follow"}
+          onClick={handleFollowClick}
+        />
+      ) : null}
+      {onMessage ? (
+        <ProfileQuickAction
+          disabled={messagePending}
+          icon={MessageSquare}
+          isLoading={messagePending}
+          label="Message"
+          onClick={onMessage}
+          testId="user-profile-message"
+        />
+      ) : null}
+      {canEditAgent ? (
+        <ProfileQuickAction
+          icon={Pencil}
+          label="Edit"
+          onClick={onEditAgent}
+          testId="user-profile-edit-agent"
+        />
+      ) : null}
+      {onAgentPrimaryAction && agentActionLabel ? (
+        <ProfileQuickAction
+          active={agentActionLive}
+          disabled={agentActionDisabled}
+          icon={agentActionLive ? Square : Play}
+          label={agentActionLabel}
+          onClick={onAgentPrimaryAction}
+          testId="user-profile-agent-primary-action"
+        />
+      ) : null}
+      {onAgentRestart ? (
+        <ProfileQuickAction
+          disabled={agentActionDisabled}
+          icon={RefreshCw}
+          label="Restart"
+          onClick={onAgentRestart}
+          testId="user-profile-agent-restart"
+        />
+      ) : null}
+      {onCreateCard ? (
+        <ProfileQuickAction
+          icon={Sparkles}
+          label="Create card"
+          onClick={onCreateCard}
+          testId="user-profile-create-card"
+        />
+      ) : null}
+    </div>
+  );
+}
+
+export function ProfilePersonaPrimaryActions({
+  canEditAgent,
+  disabled,
+  onCreateCard,
+  onEditAgent,
+  onStartAgent,
+}: {
+  canEditAgent: boolean;
+  disabled: boolean;
+  onCreateCard?: () => void;
+  onEditAgent: () => void;
+  onStartAgent: () => void;
+}) {
+  return (
+    <div className="flex items-center justify-center gap-8">
+      <ProfileQuickAction
+        disabled={disabled}
+        icon={Play}
+        label="Start agent"
+        onClick={onStartAgent}
+        testId="user-profile-start-agent"
+      />
+      {canEditAgent ? (
+        <ProfileQuickAction
+          disabled={disabled}
+          icon={Pencil}
+          label="Edit"
+          onClick={onEditAgent}
+          testId="user-profile-edit-agent"
+        />
+      ) : null}
+      {onCreateCard ? (
+        <ProfileQuickAction
+          disabled={disabled}
+          icon={Sparkles}
+          label="Create card"
+          onClick={onCreateCard}
+          testId="user-profile-create-card"
+        />
+      ) : null}
+    </div>
+  );
+}
+
+function ProfileQuickAction({
+  active,
+  disabled,
+  icon: Icon,
+  isLoading,
+  label,
+  onClick,
+  testId,
+}: {
+  active?: boolean;
+  disabled?: boolean;
+  icon: LucideIcon;
+  isLoading?: boolean;
+  label: string;
+  onClick: () => void;
+  testId?: string;
+}) {
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        <button
+          aria-label={label}
+          className={cn(
+            "flex h-12 w-12 items-center justify-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-50",
+            active
+              ? "bg-foreground text-background hover:bg-foreground/90"
+              : "bg-muted/60 text-foreground hover:bg-muted/80",
+          )}
+          data-testid={testId}
+          disabled={disabled}
+          onClick={onClick}
+          type="button"
+        >
+          {isLoading ? (
+            <Spinner aria-hidden="true" className="h-4 w-4 border-2" />
+          ) : (
+            <Icon className="h-4 w-4" />
+          )}
+        </button>
+      </TooltipTrigger>
+      <TooltipContent align="center" side="top">
+        {label}
+      </TooltipContent>
+    </Tooltip>
+  );
+}
diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts
index 66e07f5e88..36151badbc 100644
--- a/desktop/src/shared/api/tauriPersonas.ts
+++ b/desktop/src/shared/api/tauriPersonas.ts
@@ -240,6 +240,108 @@ export async function encodeAgentSnapshotForSend(
   });
 }
 
+// ── Agent trading cards ───────────────────────────────────────────────────────
+
+/** Result of `mint_agent_card`. */
+export type MintedAgentCard = {
+  /** Final `.agent.png` bytes (chunk-injected, verified), base64-encoded. */
+  cardPngBase64: string;
+  /** Suggested filename, e.g. `eva.agent.png`. */
+  fileName: string;
+  /** Designer commentary emitted alongside the image (may be empty). */
+  designerNotes: string;
+  /** True when the embedded manifest is encrypted to the (owner, agent) pair. */
+  locked: boolean;
+  /** How much memory is embedded in the card's snapshot. */
+  memoryLevel: SnapshotMemoryLevel;
+};
+
+/** Error prefix Rust returns when no OpenAI key is configured. */
+export const NO_OPENAI_KEY_PREFIX = "NO_OPENAI_KEY:";
+
+/**
+ * Check whether an OpenAI key would resolve for a card mint of this agent
+ * (same env layering as the mint itself). Never returns the key.
+ */
+export async function cardMintKeyStatus(id: string): Promise<boolean> {
+  return invokeTauri<boolean>("card_mint_key_status", { id });
+}
+
+/**
+ * Save an OPENAI_API_KEY into the global Agent Defaults env for card minting.
+ *
+ * Narrow seam: validated read-modify-write of the single key against the
+ * latest on-disk config. Unlike the general set_global_agent_config, this
+ * NEVER restarts running agents — the mint re-reads config per call, and a
+ * card setup must not disrupt unrelated agents.
+ */
+export async function cardMintSaveOpenaiKey(key: string): Promise<void> {
+  return invokeTauri<void>("card_mint_save_openai_key", { key });
+}
+
+/**
+ * Mint a trading card for an agent. One long API call (~2–3 minutes).
+ * Reroll = call again; the backend holds no session state.
+ *
+ * When `lock` is true, the embedded manifest is NIP-44-encrypted to the
+ * (owner, agent) pair: only those two keys can import the card. Requires a
+ * linked agent instance.
+ *
+ * `memoryLevel` (default `"none"`) embeds the owner's decrypted memory in
+ * the card's snapshot — same levels as snapshot export. Levels other than
+ * `"none"` require a linked agent instance; Rust derives the memory source
+ * from the resolved instance itself.
+ */
+export async function mintAgentCard(
+  id: string,
+  styleNotes?: string,
+  lock?: boolean,
+  memoryLevel?: SnapshotMemoryLevel,
+): Promise<MintedAgentCard> {
+  return invokeTauri<MintedAgentCard>("mint_agent_card", {
+    id,
+    styleNotes: styleNotes || null,
+    lock: lock ?? null,
+    memoryLevel: memoryLevel ?? null,
+  });
+}
+
+/** Save minted card bytes to disk via the OS save dialog. */
+export async function saveAgentCard(
+  cardPngBase64: string,
+  fileName: string,
+): Promise<boolean> {
+  return invokeTauri<boolean>("save_agent_card", { cardPngBase64, fileName });
+}
+
+/** One archived (previously minted) card, as listed by `list_agent_cards`. */
+export type ArchivedAgentCard = {
+  /** On-disk archive key — pass to `loadAgentCard`. */
+  storedFileName: string;
+  /** Suggested save-as name, e.g. `eva.agent.png`. */
+  fileName: string;
+  agentId: string;
+  agentName: string;
+  designerNotes: string;
+  locked: boolean;
+  /** Memory embedded in the card's snapshot ("none" for pre-field archives). */
+  memoryLevel: SnapshotMemoryLevel;
+  /** ISO-8601 mint timestamp. */
+  mintedAt: string;
+  /** Small JPEG grid preview, base64 (absent if the thumb write failed). */
+  thumbJpegBase64: string | null;
+};
+
+/** List all archived cards, newest first. */
+export async function listAgentCards(): Promise<ArchivedAgentCard[]> {
+  return invokeTauri<ArchivedAgentCard[]>("list_agent_cards");
+}
+
+/** Load one archived card's full PNG bytes (base64) by its archive key. */
+export async function loadAgentCard(storedFileName: string): Promise<string> {
+  return invokeTauri<string>("load_agent_card", { storedFileName });
+}
+
 // ── Snapshot import ───────────────────────────────────────────────────────────
 
 /** Preview returned by `preview_agent_snapshot_import` before any write. */
@@ -258,6 +360,16 @@ export type AgentSnapshotImportPreview = {
   /** True when the snapshot's respond_to_allowlist is non-empty. */
   hasSourceAllowlist: boolean;
   sourceAllowlistCount: number;
+  /** Full source pubkeys shown before import, not only their count. */
+  sourceAllowlist: string[];
+  /** Validated, pretty-printed manifest for full payload disclosure. */
+  manifestJson: string;
+  /**
+   * True when the snapshot came from a locked (encrypted) card this machine
+   * unlocked. Cards that cannot be unlocked fail with a refusal error and
+   * never reach a preview.
+   */
+  locked: boolean;
 };
 
 /** Confirmation sent to `confirm_agent_snapshot_import`. */
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 818144415a..355ccea9fc 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -11282,6 +11282,9 @@ export function maybeInstallE2eTauriMocks() {
           memoryEntryCount: 0,
           hasSourceAllowlist: false,
           sourceAllowlistCount: 0,
+          sourceAllowlist: [],
+          manifestJson: "{}",
+          locked: false,
         };
       }
       case "confirm_agent_snapshot_import": {
@@ -11825,10 +11828,18 @@ export function maybeInstallE2eTauriMocks() {
       case "download_image":
       case "save_png_data_url":
       case "download_file":
+      case "save_agent_card":
         // The save dialog can't run headlessly; report a successful save so the
         // FileCard / image-menu click handlers resolve. Specs assert the
         // command was invoked via `__BUZZ_E2E_COMMANDS__`, not the dialog.
         return true;
+      case "card_mint_key_status":
+        // Cards: pretend a key is configured so the mint form renders.
+        return true;
+      case "list_agent_cards":
+        // Cards archive starts empty in E2E; specs exercising the gallery
+        // can extend this with a seeded config knob when needed.
+        return [];
       case "copy_image_to_clipboard":
         return;
       case "copy_text_to_clipboard":
