diff --git a/.env.example b/.env.example
index fb1a3c2aa0..9e35203524 100644
--- a/.env.example
+++ b/.env.example
@@ -159,6 +159,11 @@ RUST_LOG=buzz_relay=debug,buzz_db=debug,buzz_auth=debug,buzz_pubsub=debug,tower_
 # Path to a file containing the heartbeat prompt.
 # BUZZ_ACP_HEARTBEAT_PROMPT_FILE=
 
+# ── Desktop development ──────────────────────────────────────────────────────
+# DEV-only: replay first-run onboarding and the Welcome Team kickoff on each
+# app launch while keeping the current identity and relay data.
+# VITE_BUZZ_FORCE_FRESH_ONBOARDING=true
+
 # ── Subscription & filtering ─────────────────────────────────────────────────
 # Subscribe mode: "mentions" (default), "all", or "config" (rule-based).
 # BUZZ_ACP_SUBSCRIBE=mentions
diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 2b6e8d45d1..3d86125298 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -1286,12 +1286,6 @@ async fn tokio_main() -> Result<()> {
 
     let presence_publisher = relay.event_publisher();
     let presence_keys = config.keys.clone();
-    if config.presence_enabled {
-        match publish_presence(&presence_publisher, &presence_keys, "online").await {
-            Ok(_) => tracing::info!("presence set to online"),
-            Err(e) => tracing::warn!("failed to set initial presence: {e}"),
-        }
-    }
 
     // Priority: BUZZ_AUTH_TAG (NIP-OA attestation) → --agent-owner flag.
     let startup_owner: Option<String> = resolve_agent_owner(&config);
@@ -1413,6 +1407,16 @@ async fn tokio_main() -> Result<()> {
         }
     }
 
+    // Online means the harness can receive work, not merely that its socket is
+    // connected. Publishing after channel subscriptions gives desktop callers
+    // a durable readiness boundary before they send a startup mention.
+    if config.presence_enabled {
+        match publish_presence(&presence_publisher, &presence_keys, "online").await {
+            Ok(_) => tracing::info!("presence set to online"),
+            Err(e) => tracing::warn!("failed to set initial presence: {e}"),
+        }
+    }
+
     let dedup_mode = config.dedup_mode;
     let mut queue =
         EventQueue::new(dedup_mode).with_in_flight_deadline(config.max_turn_duration_secs);
diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs
index c40e5d4e9a..191fafe60d 100644
--- a/desktop/src-tauri/src/commands/canvas.rs
+++ b/desktop/src-tauri/src/commands/canvas.rs
@@ -23,14 +23,22 @@ pub async fn get_canvas(
     .await?;
 
     let Some(event) = events.first() else {
-        return Ok(serde_json::json!({ "content": "" }));
+        // Explicit nulls: the TS caller distinguishes "no canvas yet" from
+        // "canvas exists" via `updated_at`/`author`, so these keys must be
+        // present (absent keys deserialize as `undefined`, not `null`).
+        return Ok(serde_json::json!({
+            "content": "",
+            "event_id": null,
+            "updated_at": null,
+            "author": null,
+        }));
     };
 
     Ok(serde_json::json!({
         "content": event.content,
         "event_id": event.id.to_hex(),
-        "created_at": event.created_at.as_secs(),
-        "pubkey": event.pubkey.to_hex(),
+        "updated_at": event.created_at.as_secs(),
+        "author": event.pubkey.to_hex(),
     }))
 }
 
diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs
index d2c4367429..c1d81db8e0 100644
--- a/desktop/src-tauri/src/commands/messages.rs
+++ b/desktop/src-tauri/src/commands/messages.rs
@@ -622,14 +622,42 @@ async fn find_managed_agent_channel_message_by_marker(
 
 fn marker_author_for_scope<'a>(
     marker_scope: Option<&str>,
-    agent_pubkey: &'a str,
-) -> Option<&'a str> {
+    agent_pubkey: Option<&'a str>,
+) -> Result<Option<&'a str>, String> {
     match marker_scope {
-        Some("channel") => None,
-        _ => Some(agent_pubkey),
+        Some("channel") => Ok(None),
+        Some("agent") | None => agent_pubkey
+            .map(Some)
+            .ok_or_else(|| "agent pubkey is required for agent-scoped markers".to_string()),
+        Some(scope) => Err(format!("unsupported marker scope: {scope}")),
     }
 }
 
+#[tauri::command]
+pub async fn has_managed_agent_channel_message_marker(
+    channel_id: String,
+    marker: String,
+    agent_pubkey: Option<String>,
+    marker_scope: Option<String>,
+    state: State<'_, AppState>,
+) -> Result<bool, String> {
+    uuid::Uuid::parse_str(&channel_id)
+        .map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
+    let marker = marker.trim();
+    if marker.is_empty() {
+        return Err("message marker is required".into());
+    }
+    let agent_pubkey = agent_pubkey
+        .as_deref()
+        .map(str::trim)
+        .filter(|value| !value.is_empty());
+
+    let marker_author = marker_author_for_scope(marker_scope.as_deref(), agent_pubkey)?;
+    find_managed_agent_channel_message_by_marker(&state, marker_author, &channel_id, marker)
+        .await
+        .map(|event| event.is_some())
+}
+
 fn stored_managed_agent_auth_tag(auth_tag: Option<&str>) -> Option<String> {
     auth_tag
         .map(str::trim)
@@ -663,6 +691,27 @@ fn managed_agent_submission_auth_tag(
     legacy_managed_agent_auth_tag(&owner_keys, agent_pubkey)
 }
 
+fn build_managed_agent_channel_message(
+    channel_id: uuid::Uuid,
+    content: &str,
+    thread_ref: Option<&events::ThreadRef>,
+    mention_pubkeys: &[String],
+    client_tags: &[Vec<String>],
+) -> Result<nostr::EventBuilder, String> {
+    let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect();
+    events::build_message_with_client_tags(
+        channel_id,
+        content,
+        thread_ref,
+        &mention_refs,
+        &[],
+        &[],
+        &[],
+        client_tags,
+    )
+}
+
+#[allow(clippy::too_many_arguments)]
 #[tauri::command]
 pub async fn send_managed_agent_channel_message(
     agent_pubkey: String,
@@ -670,6 +719,9 @@ pub async fn send_managed_agent_channel_message(
     content: String,
     marker: Option<String>,
     marker_scope: Option<String>,
+    mention_pubkeys: Option<Vec<String>>,
+    parent_event_id: Option<String>,
+    additional_markers: Option<Vec<String>>,
     app: AppHandle,
     state: State<'_, AppState>,
 ) -> Result<SendChannelMessageResponse, String> {
@@ -706,11 +758,15 @@ pub async fn send_managed_agent_channel_message(
     }
     let submission_auth_tag =
         managed_agent_submission_auth_tag(&record, &state, &keys.public_key())?;
+    let thread_ref = match parent_event_id.as_deref() {
+        Some(parent_id) => Some(resolve_thread_ref(parent_id, &state).await?),
+        None => None,
+    };
 
     if let Some(marker) = marker.as_deref() {
         if let Some(existing) = find_managed_agent_channel_message_by_marker(
             &state,
-            marker_author_for_scope(marker_scope.as_deref(), &record.pubkey),
+            marker_author_for_scope(marker_scope.as_deref(), Some(&record.pubkey))?,
             &channel_id,
             marker,
         )
@@ -718,26 +774,34 @@ pub async fn send_managed_agent_channel_message(
         {
             return Ok(SendChannelMessageResponse {
                 event_id: existing.id.to_hex(),
-                parent_event_id: None,
-                root_event_id: None,
-                depth: 0,
+                parent_event_id: thread_ref
+                    .as_ref()
+                    .map(|reference| reference.parent_event_id.to_hex()),
+                root_event_id: thread_ref
+                    .as_ref()
+                    .map(|reference| reference.root_event_id.to_hex()),
+                depth: if thread_ref.is_some() { 1 } else { 0 },
                 created_at: existing.created_at.as_secs() as i64,
             });
         }
     }
 
-    let client_tags = marker
+    let mut client_tags = marker
         .as_deref()
         .map(|marker| vec![vec!["client".to_string(), marker.to_string()]])
         .unwrap_or_default();
-    let builder = events::build_message_with_client_tags(
+    for marker in additional_markers.unwrap_or_default() {
+        let marker = marker.trim();
+        if !marker.is_empty() {
+            client_tags.push(vec!["client".to_string(), marker.to_string()]);
+        }
+    }
+    let mentions = mention_pubkeys.unwrap_or_default();
+    let builder = build_managed_agent_channel_message(
         channel_uuid,
         trimmed,
-        None,
-        &[],
-        &[],
-        &[],
-        &[],
+        thread_ref.as_ref(),
+        &mentions,
         &client_tags,
     )?;
     let result =
@@ -745,157 +809,13 @@ pub async fn send_managed_agent_channel_message(
 
     Ok(SendChannelMessageResponse {
         event_id: result.event_id,
-        parent_event_id: None,
-        root_event_id: None,
-        depth: 0,
+        parent_event_id: parent_event_id.clone(),
+        root_event_id: thread_ref.map(|reference| reference.root_event_id.to_hex()),
+        depth: if parent_event_id.is_some() { 1 } else { 0 },
         created_at: chrono::Utc::now().timestamp(),
     })
 }
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn marker_author_scope_defaults_to_agent() {
-        assert_eq!(
-            marker_author_for_scope(None, "agent-pubkey"),
-            Some("agent-pubkey")
-        );
-        assert_eq!(
-            marker_author_for_scope(Some("agent"), "agent-pubkey"),
-            Some("agent-pubkey")
-        );
-        assert_eq!(
-            marker_author_for_scope(Some("unknown"), "agent-pubkey"),
-            Some("agent-pubkey")
-        );
-    }
-
-    #[test]
-    fn marker_author_scope_can_dedupe_across_channel() {
-        assert_eq!(
-            marker_author_for_scope(Some("channel"), "agent-pubkey"),
-            None
-        );
-    }
-
-    #[test]
-    fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
-        let filter = build_search_messages_filter("  pro  ", 12, Some("channel-1"));
-
-        assert_eq!(filter["search"], serde_json::json!("pro"));
-        assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
-        assert_eq!(filter["limit"], serde_json::json!(12));
-        assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
-    }
-
-    #[test]
-    fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
-        // The relay bridge's `extract_before_id` reads the composite tiebreak
-        // from `before_id`. If this filter sent the id under any other key (an
-        // earlier cut used `n`), the relay would silently drop the tiebreak and
-        // the dense-second keyset would degrade to a bare inclusive `until` —
-        // re-returning the same page forever. Pin the field name here so the
-        // client/relay contract can't drift without a red test (the Playwright
-        // mock reimplements the keyset in JS and cannot catch this).
-        let filter =
-            build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
-
-        assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
-        assert_eq!(filter["before_id"], serde_json::json!("ab"));
-        assert_eq!(filter["limit"], serde_json::json!(200));
-        assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
-        assert!(
-            !filter.contains_key("n"),
-            "tiebreak must be `before_id`, not the `n` alias the relay ignores"
-        );
-    }
-
-    #[test]
-    fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
-        // The relay bridge p-gates EVERY filter before routing
-        // (`p_gated_filters_authorized`): a kindless filter "could match" a
-        // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
-        // before the thread-subtree query runs. The headline Lane-1 fix
-        // (`useThreadReplies` closing the descendant gap) then fails on every
-        // call against a real relay. So the thread filter MUST carry `kinds`,
-        // and every kind MUST be non-p-gated (else the gate still fires). The
-        // Playwright mock does not model p-gating, so this unit test is the
-        // only guard against the client/relay auth contract drifting.
-        let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
-
-        let kinds = filter
-            .get("kinds")
-            .and_then(|v| v.as_array())
-            .expect("thread filter must carry `kinds` so the p-gate passes");
-        assert!(!kinds.is_empty(), "kinds must be non-empty");
-        for kind in kinds {
-            let k = kind.as_u64().expect("kind is a number") as u32;
-            assert!(
-                !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
-                "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
-                 403 that this fix exists to prevent"
-            );
-        }
-        assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
-        assert_eq!(filter["depth_limit"], serde_json::json!(64));
-        assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
-    }
-
-    #[test]
-    fn thread_replies_filter_pages_with_composite_cursor() {
-        // When a cursor is supplied, both the timestamp and the event-id
-        // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
-        // paging degrades to timestamp-only and drops same-second replies.
-        let cursor = crate::models::ThreadCursor {
-            created_at: 1_700_000_000,
-            event_id: "abcd".to_string(),
-        };
-        let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
-        assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
-        assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
-        assert!(
-            !filter.contains_key("#h"),
-            "no channel_id -> no #h scope in the filter"
-        );
-    }
-
-    #[test]
-    fn stored_managed_agent_auth_tag_trims_blank_values() {
-        assert_eq!(
-            stored_managed_agent_auth_tag(Some("  [\"auth\",\"owner\",\"\",\"sig\"]  ")),
-            Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
-        );
-        assert_eq!(stored_managed_agent_auth_tag(Some("   ")), None);
-        assert_eq!(stored_managed_agent_auth_tag(None), None);
-    }
-
-    #[test]
-    fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
-        let owner_keys = Keys::generate();
-        let agent_keys = Keys::generate();
-
-        let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
-            .expect("legacy auth tag should compute")
-            .expect("legacy auth tag should be present");
-
-        let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
-            .expect("legacy auth tag should verify");
-        assert_eq!(owner, owner_keys.public_key());
-    }
-
-    #[test]
-    fn legacy_managed_agent_auth_tag_skips_self_attestation() {
-        let owner_keys = Keys::generate();
-
-        let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
-            .expect("self-attestation should be skipped");
-
-        assert_eq!(tag, None);
-    }
-}
-
 #[tauri::command]
 pub async fn add_reaction(
     event_id: String,
@@ -1021,3 +941,6 @@ fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo {
         category: category.to_string(),
     }
 }
+#[cfg(test)]
+#[path = "messages_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs
new file mode 100644
index 0000000000..3c08f0f113
--- /dev/null
+++ b/desktop/src-tauri/src/commands/messages_tests.rs
@@ -0,0 +1,193 @@
+use super::*;
+
+#[test]
+fn marker_author_scope_validates_scope_and_required_pubkey() {
+    assert_eq!(
+        marker_author_for_scope(None, Some("agent")),
+        Ok(Some("agent"))
+    );
+    assert_eq!(
+        marker_author_for_scope(Some("agent"), Some("agent")),
+        Ok(Some("agent"))
+    );
+    assert_eq!(marker_author_for_scope(Some("channel"), None), Ok(None));
+    assert_eq!(
+        marker_author_for_scope(Some("agent"), None),
+        Err("agent pubkey is required for agent-scoped markers".to_string())
+    );
+    assert_eq!(
+        marker_author_for_scope(None, None),
+        Err("agent pubkey is required for agent-scoped markers".to_string())
+    );
+    assert_eq!(
+        marker_author_for_scope(Some("unexpected"), Some("agent")),
+        Err("unsupported marker scope: unexpected".to_string())
+    );
+}
+
+#[test]
+fn managed_agent_message_builder_adds_mentions_and_client_marker() {
+    let pubkey = Keys::generate().public_key().to_hex();
+    let event = build_managed_agent_channel_message(
+        uuid::Uuid::new_v4(),
+        "Welcome!",
+        None,
+        std::slice::from_ref(&pubkey),
+        &[vec!["client".to_string(), "welcome-v1".to_string()]],
+    )
+    .expect("message should build")
+    .sign_with_keys(&Keys::generate())
+    .expect("message should sign");
+
+    assert!(event.tags.iter().any(|tag| {
+        let parts = tag.as_slice();
+        parts.len() >= 2 && parts[0] == "p" && parts[1] == pubkey
+    }));
+    assert!(event_has_client_marker(&event, "welcome-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_can_carry_multiple_client_markers() {
+    let event = build_managed_agent_channel_message(
+        uuid::Uuid::new_v4(),
+        "Welcome!",
+        None,
+        &[],
+        &[
+            vec!["client".to_string(), "opener-v1".to_string()],
+            vec!["client".to_string(), "closer-v1".to_string()],
+        ],
+    )
+    .expect("message should build")
+    .sign_with_keys(&Keys::generate())
+    .expect("message should sign");
+
+    assert!(event_has_client_marker(&event, "opener-v1"));
+    assert!(event_has_client_marker(&event, "closer-v1"));
+}
+
+#[test]
+fn managed_agent_message_builder_rejects_invalid_mentions() {
+    let error = build_managed_agent_channel_message(
+        uuid::Uuid::new_v4(),
+        "Welcome!",
+        None,
+        &["not-a-pubkey".to_string()],
+        &[],
+    )
+    .expect_err("invalid mentions should fail");
+    assert!(error.contains("pubkey must be a 64-character hex string"));
+}
+#[test]
+fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
+    let filter = build_search_messages_filter("  pro  ", 12, Some("channel-1"));
+
+    assert_eq!(filter["search"], serde_json::json!("pro"));
+    assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
+    assert_eq!(filter["limit"], serde_json::json!(12));
+    assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn channel_messages_before_filter_sends_before_id_the_relay_reads() {
+    // The relay bridge's `extract_before_id` reads the composite tiebreak
+    // from `before_id`. If this filter sent the id under any other key (an
+    // earlier cut used `n`), the relay would silently drop the tiebreak and
+    // the dense-second keyset would degrade to a bare inclusive `until` —
+    // re-returning the same page forever. Pin the field name here so the
+    // client/relay contract can't drift without a red test (the Playwright
+    // mock reimplements the keyset in JS and cannot catch this).
+    let filter = build_channel_messages_before_filter("channel-1", 1_700_000_000, Some("ab"), 200);
+
+    assert_eq!(filter["until"], serde_json::json!(1_700_000_000));
+    assert_eq!(filter["before_id"], serde_json::json!("ab"));
+    assert_eq!(filter["limit"], serde_json::json!(200));
+    assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+    assert!(
+        !filter.contains_key("n"),
+        "tiebreak must be `before_id`, not the `n` alias the relay ignores"
+    );
+}
+
+#[test]
+fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
+    // The relay bridge p-gates EVERY filter before routing
+    // (`p_gated_filters_authorized`): a kindless filter "could match" a
+    // p-gated kind, so it demands a `#p` tag we don't send -> HTTP 403,
+    // before the thread-subtree query runs. The headline Lane-1 fix
+    // (`useThreadReplies` closing the descendant gap) then fails on every
+    // call against a real relay. So the thread filter MUST carry `kinds`,
+    // and every kind MUST be non-p-gated (else the gate still fires). The
+    // Playwright mock does not model p-gating, so this unit test is the
+    // only guard against the client/relay auth contract drifting.
+    let filter = build_thread_replies_filter("root-hex", Some("channel-1"), 64, 200, None);
+
+    let kinds = filter
+        .get("kinds")
+        .and_then(|v| v.as_array())
+        .expect("thread filter must carry `kinds` so the p-gate passes");
+    assert!(!kinds.is_empty(), "kinds must be non-empty");
+    for kind in kinds {
+        let k = kind.as_u64().expect("kind is a number") as u32;
+        assert!(
+            !buzz_core_pkg::kind::P_GATED_KINDS.contains(&k),
+            "kind {k} is p-gated; a p-gated kind in the filter re-triggers the \
+                 403 that this fix exists to prevent"
+        );
+    }
+    assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
+    assert_eq!(filter["depth_limit"], serde_json::json!(64));
+    assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
+}
+
+#[test]
+fn thread_replies_filter_pages_with_composite_cursor() {
+    // When a cursor is supplied, both the timestamp and the event-id
+    // tiebreak must be emitted (`thread_cursor` + `thread_cursor_id`), else
+    // paging degrades to timestamp-only and drops same-second replies.
+    let cursor = crate::models::ThreadCursor {
+        created_at: 1_700_000_000,
+        event_id: "abcd".to_string(),
+    };
+    let filter = build_thread_replies_filter("root-hex", None, 64, 200, Some(&cursor));
+    assert_eq!(filter["thread_cursor"], serde_json::json!(1_700_000_000));
+    assert_eq!(filter["thread_cursor_id"], serde_json::json!("abcd"));
+    assert!(
+        !filter.contains_key("#h"),
+        "no channel_id -> no #h scope in the filter"
+    );
+}
+
+#[test]
+fn stored_managed_agent_auth_tag_trims_blank_values() {
+    assert_eq!(
+        stored_managed_agent_auth_tag(Some("  [\"auth\",\"owner\",\"\",\"sig\"]  ")),
+        Some("[\"auth\",\"owner\",\"\",\"sig\"]".to_string())
+    );
+    assert_eq!(stored_managed_agent_auth_tag(Some("   ")), None);
+    assert_eq!(stored_managed_agent_auth_tag(None), None);
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_verifies_for_agent_pubkey() {
+    let owner_keys = Keys::generate();
+    let agent_keys = Keys::generate();
+
+    let tag = legacy_managed_agent_auth_tag(&owner_keys, &agent_keys.public_key())
+        .expect("legacy auth tag should compute")
+        .expect("legacy auth tag should be present");
+
+    let owner = buzz_sdk_pkg::nip_oa::verify_auth_tag(&tag, &agent_keys.public_key())
+        .expect("legacy auth tag should verify");
+    assert_eq!(owner, owner_keys.public_key());
+}
+
+#[test]
+fn legacy_managed_agent_auth_tag_skips_self_attestation() {
+    let owner_keys = Keys::generate();
+
+    let tag = legacy_managed_agent_auth_tag(&owner_keys, &owner_keys.public_key())
+        .expect("self-attestation should be skipped");
+
+    assert_eq!(tag, None);
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 276f8d14d6..dadd2198b8 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -811,6 +811,7 @@ pub fn run() {
             search_messages,
             send_channel_message,
             send_managed_agent_channel_message,
+            has_managed_agent_channel_message_marker,
             get_forum_posts,
             get_forum_thread,
             get_thread_replies,
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index 2c15813e48..31b31e00d0 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -1361,6 +1361,7 @@ pub fn build_managed_agent_summary(
         pubkey: record.pubkey.clone(),
         name: record.name.clone(),
         persona_id: record.persona_id.clone(),
+        team_id: record.team_id.clone(),
         relay_url: record.relay_url.clone(),
         acp_command: record.acp_command.clone(),
         agent_command: effective_command,
diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs
index 6ee8c0b2ed..937893d531 100644
--- a/desktop/src-tauri/src/managed_agents/teams.rs
+++ b/desktop/src-tauri/src/managed_agents/teams.rs
@@ -31,7 +31,12 @@ struct BuiltInTeam {
     persona_ids: &'static [&'static str],
 }
 
-const BUILT_IN_TEAMS: &[BuiltInTeam] = &[];
+const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam {
+    id: "builtin-team:welcome",
+    name: "Welcome Team",
+    description: Some("A friendly starter trio ready to help you plan, create, and ship."),
+    persona_ids: &["builtin:fizz", "builtin:honey", "builtin:bumble"],
+}];
 
 // Built-in teams that have been retired. A stored copy that still exactly
 // matches its seed is purged on load (the user never touched it); customized
diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs
index 7e9c7656b4..140ac3cab9 100644
--- a/desktop/src-tauri/src/managed_agents/teams_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs
@@ -317,12 +317,62 @@ fn migration_customized_fizz_is_demoted_to_user_team() {
 }
 
 #[test]
-fn migration_empty_store_stays_empty() {
-    // Fresh installs see no teams and no spurious changed flag.
+fn welcome_team_is_seeded_and_idempotent() {
     let (records, changed) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
 
+    assert!(changed);
+    assert_eq!(records.len(), 1);
+    let welcome = &records[0];
+    assert_eq!(welcome.id, "builtin-team:welcome");
+    assert_eq!(welcome.name, "Welcome Team");
+    assert_eq!(
+        welcome.description.as_deref(),
+        Some("A friendly starter trio ready to help you plan, create, and ship.")
+    );
+    assert_eq!(
+        welcome.persona_ids,
+        vec![
+            "builtin:fizz".to_string(),
+            "builtin:honey".to_string(),
+            "builtin:bumble".to_string(),
+        ]
+    );
+    assert!(welcome.is_builtin);
+
+    let expected = serde_json::to_value(&records).unwrap();
+    let (records_after_second_merge, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
+    assert!(!changed);
+    assert_eq!(
+        serde_json::to_value(records_after_second_merge).unwrap(),
+        expected
+    );
+}
+
+#[test]
+fn welcome_team_seed_does_not_overwrite_customization() {
+    let (mut records, _) = merge_teams(Vec::new(), "2026-07-01T00:00:00Z");
+    let welcome = records
+        .iter_mut()
+        .find(|team| team.id == "builtin-team:welcome")
+        .expect("welcome team should be seeded");
+    welcome.name = "My Welcome Team".to_string();
+    welcome.description = Some("My customized starter team.".to_string());
+    welcome.persona_ids = vec!["builtin:honey".to_string()];
+
+    let (records, changed) = merge_teams(records, "2026-07-02T00:00:00Z");
+
     assert!(!changed);
-    assert!(records.is_empty());
+    let welcome = records
+        .iter()
+        .find(|team| team.id == "builtin-team:welcome")
+        .expect("customized welcome team should be preserved");
+    assert_eq!(welcome.name, "My Welcome Team");
+    assert_eq!(
+        welcome.description.as_deref(),
+        Some("My customized starter team.")
+    );
+    assert_eq!(welcome.persona_ids, vec!["builtin:honey".to_string()]);
+    assert!(welcome.is_builtin);
 }
 
 // ── load_teams_readonly tests ──────────────────────────────────────────
@@ -337,9 +387,9 @@ fn load_teams_readonly_absent_file_performs_no_write() {
 
     let records = load_teams_readonly(&path).unwrap();
 
-    // Returns the merged built-in list (currently empty since BUILT_IN_TEAMS
-    // is empty, but the function ran merge_teams + sort_teams).
-    assert!(records.is_empty());
+    // Returns the merged built-in list without persisting it.
+    assert_eq!(records.len(), 1);
+    assert_eq!(records[0].id, "builtin-team:welcome");
 
     // The file must still NOT exist — no write-on-load side effect.
     assert!(
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index bb0781b643..7f69b77e0d 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -454,6 +454,7 @@ pub struct ManagedAgentSummary {
     pub pubkey: String,
     pub name: String,
     pub persona_id: Option<String>,
+    pub team_id: Option<String>,
     pub relay_url: String,
     pub acp_command: String,
     pub agent_command: String,
diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts
index 4d1a4dda68..978509af09 100644
--- a/desktop/src/features/agents/knownAgentPubkeys.ts
+++ b/desktop/src/features/agents/knownAgentPubkeys.ts
@@ -21,3 +21,23 @@ export function mergeKnownAgentPubkeys(
   }
   return pubkeys;
 }
+
+/**
+ * Channel-scoped variant: the managed ∪ relay baseline plus this channel's
+ * bot members (role `bot` or `isAgent`), so member-only agents are included.
+ */
+export function mergeChannelKnownAgentPubkeys(
+  channelMembers:
+    | readonly { pubkey: string; role: string; isAgent: boolean }[]
+    | undefined,
+  managedAgents: readonly { pubkey: string }[] | undefined,
+  relayAgents: readonly { pubkey: string }[] | undefined,
+): ReadonlySet<string> {
+  const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents));
+  for (const member of channelMembers ?? []) {
+    if (member.role === "bot" || member.isAgent) {
+      pubkeys.add(normalizePubkey(member.pubkey));
+    }
+  }
+  return pubkeys;
+}
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 837b15fcf9..77bd557c97 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -1,6 +1,5 @@
 import * as React from "react";
-import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
-import { HashSearch } from "@/shared/ui/icons";
+import { Hash, LogIn } from "lucide-react";
 import { useAppNavigation } from "@/app/navigation/useAppNavigation";
 import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
 import { MessageComposer } from "@/features/messages/ui/MessageComposer";
@@ -45,11 +44,10 @@ import {
   type WelcomeComposerBannerState,
 } from "@/features/channels/ui/WelcomeComposerBanner";
 import {
-  getChannelIntroDescription,
-  getChannelIntroKind,
   isWelcomeSetupSystemMessage,
   mentionsKnownAgent,
 } from "@/features/channels/ui/ChannelPane.helpers";
+import { useChannelIntro } from "@/features/channels/ui/useChannelIntro";
 import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types";
 import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
 import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel";
@@ -57,10 +55,7 @@ import { Button } from "@/shared/ui/button";
 import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel";
 import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
 import type { TimelineMessage } from "@/features/messages/types";
-import {
-  isWelcomeChannel,
-  isWelcomeExperienceChannel as isWelcomeExperience,
-} from "@/features/onboarding/welcome";
+import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome";
 import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds";
 import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
 import { channelChrome } from "@/shared/layout/chromeLayout";
@@ -91,6 +86,8 @@ export const ChannelPane = React.memo(function ChannelPane({
   isSinglePanelView = false,
   isSending,
   isTimelineLoading,
+  entranceMessageId = null,
+  onEntranceMessageComplete,
   messages,
   threadSummaries,
   firstUnreadMessageId = null,
@@ -435,89 +432,20 @@ export const ChannelPane = React.memo(function ChannelPane({
     [activeChannel, currentPubkey, profiles],
   );
 
-  const channelIntro = React.useMemo(() => {
-    if (!activeChannel || activeChannel.channelType === "dm") {
-      return null;
-    }
-
-    const actions = [];
-    if (isWelcomeExperience(activeChannel)) {
-      if (onBrowseChannels) {
-        actions.push({
-          icon: <HashSearch aria-hidden className="h-6 w-6" />,
-          label: "Browse channels",
-          onClick: onBrowseChannels,
-          testId: "welcome-intro-action-browse-channels",
-        });
-      }
-      if (onCreateChannel) {
-        actions.push({
-          icon: <Plus aria-hidden className="h-6 w-6" />,
-          label: "Create a channel",
-          onClick: onCreateChannel,
-          testId: "welcome-intro-action-create-channel",
-        });
-      }
-      if (onAddAgent) {
-        actions.push({
-          icon: <Bot aria-hidden className="h-6 w-6" />,
-          label: "Create an agent",
-          onClick: () =>
-            onAddAgent({
-              beforeSend: () =>
-                messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
-            }),
-          testId: "welcome-intro-action-create-agent",
-        });
-      }
-      return {
-        actions,
-        channelKindLabel: isWelcomeChannel(activeChannel)
-          ? "private welcome channel"
-          : getChannelIntroKind(activeChannel),
-        channelName: activeChannel.name,
-        description: isWelcomeChannel(activeChannel)
-          ? null
-          : getChannelIntroDescription(activeChannel),
-        icon: <Sparkles aria-hidden className="h-7 w-7" />,
-      };
-    }
-
-    if (!activeChannel.archivedAt && activeChannel.isMember) {
-      if (onAddAgent) {
-        actions.push({
-          description: "Add an agent here.",
-          icon: <Bot aria-hidden className="h-6 w-6" />,
-          label: "Create agent",
-          onClick: onAddAgent,
-          testId: "channel-intro-action-create-agent",
-        });
-      }
-
-      if (onOpenMembers) {
-        actions.push({
-          description: "Invite members.",
-          icon: <UserPlus aria-hidden className="h-6 w-6" />,
-          label: "Add people",
-          onClick: onOpenMembers,
-          testId: "channel-intro-action-add-people",
-        });
-      }
-    }
-
-    return {
-      actions,
-      channelKindLabel: getChannelIntroKind(activeChannel),
-      channelName: activeChannel.name,
-      description: getChannelIntroDescription(activeChannel),
-    };
-  }, [
+  const handleWelcomeAddAgent = React.useCallback(() => {
+    onAddAgent?.({
+      beforeSend: () =>
+        messageTimelineRef.current?.scrollToBottomOnNextUpdate(),
+    });
+  }, [onAddAgent]);
+  const channelIntro = useChannelIntro({
     activeChannel,
     onAddAgent,
     onBrowseChannels,
     onCreateChannel,
     onOpenMembers,
-  ]);
+    onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined,
+  });
   const visibleMessages = React.useMemo(() => {
     if (!isWelcomeExperience(activeChannel)) {
       return messages;
@@ -689,6 +617,8 @@ export const ChannelPane = React.memo(function ChannelPane({
                 : "No channel selected"
             }
             isLoading={isTimelineLoading}
+            entranceMessageId={entranceMessageId}
+            onEntranceMessageComplete={onEntranceMessageComplete}
             mainEntries={mainTimelineEntries}
             threadSummaries={threadSummaries}
             messages={visibleMessages}
diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts
index 3004aa4e99..12644d0958 100644
--- a/desktop/src/features/channels/ui/ChannelPane.types.ts
+++ b/desktop/src/features/channels/ui/ChannelPane.types.ts
@@ -53,6 +53,9 @@ export type ChannelPaneProps = {
   isSinglePanelView?: boolean;
   isSending: boolean;
   isTimelineLoading: boolean;
+  /** Newly-created message that should receive the one-shot conversation arrival motion. */
+  entranceMessageId?: string | null;
+  onEntranceMessageComplete?: (messageId: string) => void;
   messages: TimelineMessage[];
   threadSummaries?: ReadonlyMap<string, ChannelWindowThreadSummary>;
   firstUnreadMessageId?: string | null;
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 8bfcae0fe6..fc147a63fb 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -25,8 +25,10 @@ import {
   usePersonasQuery,
   useRelayAgentsQuery,
 } from "@/features/agents/hooks";
+import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys";
 import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
 import { pickWelcomeGuideAgent } from "@/features/onboarding/welcomeGuide";
+import { useWelcomeKickoffEntrance } from "@/features/onboarding/useWelcomeKickoffEntrance";
 import { useWelcomeAgentCreate } from "@/features/channels/useWelcomeAgentCreate";
 import {
   mergeMessages,
@@ -247,6 +249,14 @@ export function ChannelScreen({
     return extraEvents.reduce(mergeMessages, currentMessages);
   }, [activeChannel, findEvents, messagesQuery.data, targetMessageEvents]);
   const threadReplyEvents = threadRepliesQuery.data ?? EMPTY_RELAY_EVENTS;
+  const {
+    entranceMessageId: welcomeEntranceMessageId,
+    handleEntranceComplete: handleWelcomeEntranceComplete,
+  } = useWelcomeKickoffEntrance(
+    activeChannel,
+    resolvedMessages,
+    threadReplyEvents,
+  );
   const messageEventProfilePubkeys = useMessageEventProfilePubkeys(
     resolvedMessages,
     threadReplyEvents,
@@ -284,21 +294,11 @@ export function ChannelScreen({
   });
   const relayAgentsQuery = useRelayAgentsQuery();
   const relayAgents = relayAgentsQuery.data ?? [];
-  const knownAgentPubkeys = React.useMemo(() => {
-    const pubkeys = new Set<string>();
-    for (const member of channelMembers ?? []) {
-      if (member.role === "bot" || member.isAgent) {
-        pubkeys.add(normalizePubkey(member.pubkey));
-      }
-    }
-    for (const agent of managedAgents) {
-      pubkeys.add(normalizePubkey(agent.pubkey));
-    }
-    for (const agent of relayAgents) {
-      pubkeys.add(normalizePubkey(agent.pubkey));
-    }
-    return pubkeys;
-  }, [channelMembers, managedAgents, relayAgents]);
+  const knownAgentPubkeys = React.useMemo(
+    () =>
+      mergeChannelKnownAgentPubkeys(channelMembers, managedAgents, relayAgents),
+    [channelMembers, managedAgents, relayAgents],
+  );
   const messageProfilePubkeys = React.useMemo(
     () => [
       ...new Set([
@@ -840,6 +840,8 @@ export function ChannelScreen({
                   onCreateChannel={openCreateChannel}
                   onOpenMembers={handleOpenMembersSidebar}
                   isFetchingOlder={isFetchingOlder}
+                  entranceMessageId={welcomeEntranceMessageId}
+                  onEntranceMessageComplete={handleWelcomeEntranceComplete}
                   editTarget={
                     editTargetMessage
                       ? {
diff --git a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
index 79978925ea..0fd62cdbd3 100644
--- a/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
+++ b/desktop/src/features/channels/ui/WelcomeComposerBanner.tsx
@@ -391,8 +391,8 @@ export function WelcomeComposerBanner({ state }: WelcomeComposerBannerProps) {
                 key="prompt-copy"
                 variants={welcomeComposerBannerContentVariants}
               >
-                Try mentioning <WelcomeComposerPersonaMention /> to chat with an
-                agent in this channel.
+                Mention <WelcomeComposerPersonaMention /> or another teammate
+                whenever you want their help.
               </motion.span>
             )}
           </AnimatePresence>
diff --git a/desktop/src/features/channels/ui/useChannelIntro.tsx b/desktop/src/features/channels/ui/useChannelIntro.tsx
new file mode 100644
index 0000000000..c22bbff2c2
--- /dev/null
+++ b/desktop/src/features/channels/ui/useChannelIntro.tsx
@@ -0,0 +1,127 @@
+import * as React from "react";
+import { Bot, Plus, Sparkles, UserPlus } from "lucide-react";
+
+import {
+  getChannelIntroDescription,
+  getChannelIntroKind,
+} from "@/features/channels/ui/ChannelPane.helpers";
+import {
+  isWelcomeChannel,
+  isWelcomeExperienceChannel,
+} from "@/features/onboarding/welcome";
+import type { Channel } from "@/shared/api/types";
+import { HashSearch } from "@/shared/ui/icons";
+
+type ChannelIntroAction = {
+  description?: string;
+  icon: React.ReactNode;
+  label: string;
+  onClick: () => void;
+  testId?: string;
+};
+
+/**
+ * Builds the empty-channel intro block (heading, description, action cards)
+ * for the channel timeline. The Welcome channel gets its onboarding trio
+ * (browse / create channel / create agent); other channels get contextual
+ * member actions.
+ */
+export function useChannelIntro({
+  activeChannel,
+  onAddAgent,
+  onBrowseChannels,
+  onCreateChannel,
+  onOpenMembers,
+  onWelcomeAddAgent,
+}: {
+  activeChannel: Channel | null;
+  onAddAgent?: (options?: { beforeSend?: () => void }) => void;
+  onBrowseChannels?: () => void;
+  onCreateChannel?: () => void;
+  onOpenMembers?: () => void;
+  onWelcomeAddAgent?: () => void;
+}) {
+  return React.useMemo(() => {
+    if (!activeChannel || activeChannel.channelType === "dm") {
+      return null;
+    }
+
+    const actions: ChannelIntroAction[] = [];
+    if (isWelcomeExperienceChannel(activeChannel)) {
+      if (onBrowseChannels) {
+        actions.push({
+          icon: <HashSearch aria-hidden className="h-6 w-6" />,
+          label: "Browse channels",
+          onClick: onBrowseChannels,
+          testId: "welcome-intro-action-browse-channels",
+        });
+      }
+
+      if (onCreateChannel) {
+        actions.push({
+          icon: <Plus aria-hidden className="h-6 w-6" />,
+          label: "Create a channel",
+          onClick: onCreateChannel,
+          testId: "welcome-intro-action-create-channel",
+        });
+      }
+
+      if (onWelcomeAddAgent) {
+        actions.push({
+          icon: <Bot aria-hidden className="h-6 w-6" />,
+          label: "Create an agent",
+          onClick: onWelcomeAddAgent,
+          testId: "welcome-intro-action-create-agent",
+        });
+      }
+
+      return {
+        actions,
+        channelKindLabel: isWelcomeChannel(activeChannel)
+          ? "private welcome channel"
+          : getChannelIntroKind(activeChannel),
+        channelName: activeChannel.name,
+        description: isWelcomeChannel(activeChannel)
+          ? null
+          : getChannelIntroDescription(activeChannel),
+        icon: <Sparkles aria-hidden className="h-7 w-7" />,
+      };
+    }
+
+    if (!activeChannel.archivedAt && activeChannel.isMember) {
+      if (onAddAgent) {
+        actions.push({
+          description: "Add an agent here.",
+          icon: <Bot aria-hidden className="h-6 w-6" />,
+          label: "Create agent",
+          onClick: onAddAgent,
+          testId: "channel-intro-action-create-agent",
+        });
+      }
+
+      if (onOpenMembers) {
+        actions.push({
+          description: "Invite members.",
+          icon: <UserPlus aria-hidden className="h-6 w-6" />,
+          label: "Add people",
+          onClick: onOpenMembers,
+          testId: "channel-intro-action-add-people",
+        });
+      }
+    }
+
+    return {
+      actions,
+      channelKindLabel: getChannelIntroKind(activeChannel),
+      channelName: activeChannel.name,
+      description: getChannelIntroDescription(activeChannel),
+    };
+  }, [
+    activeChannel,
+    onAddAgent,
+    onBrowseChannels,
+    onCreateChannel,
+    onOpenMembers,
+    onWelcomeAddAgent,
+  ]);
+}
diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts
index 4b3bbd801e..6bbd1e8a12 100644
--- a/desktop/src/features/messages/lib/formatTimelineMessages.ts
+++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts
@@ -580,3 +580,15 @@ export function collectMessageMentionPubkeys(
 
   return [...pubkeys];
 }
+
+/** Every pubkey a channel surface needs profiles for: authors (signer +
+ *  attributed actor), mentions, and reaction actors, deduplicated. */
+export function collectMessageProfilePubkeys(events: RelayEvent[]) {
+  return [
+    ...new Set([
+      ...collectMessageAuthorPubkeys(events),
+      ...collectMessageMentionPubkeys(events),
+      ...collectReactionActorPubkeys(events),
+    ]),
+  ];
+}
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
index 7738d27f03..de410b5b8f 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
+++ b/desktop/src/features/messages/lib/timelineSnapshot.test.mjs
@@ -403,6 +403,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
   assert.equal(
     selectTimelineBodySurface({
       deferredCount: 0,
+      hasPersistentIntro: false,
       isLoading: true,
       liveCount: 0,
     }),
@@ -411,6 +412,7 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
   assert.equal(
     selectTimelineBodySurface({
       deferredCount: 0,
+      hasPersistentIntro: false,
       isLoading: false,
       liveCount: 3,
     }),
@@ -418,6 +420,18 @@ test("timeline-body-surface: loading and deferred-pending both paint the single
   );
 });
 
+test("timeline-body-surface: first deferred message preserves a persistent channel intro", () => {
+  assert.equal(
+    selectTimelineBodySurface({
+      deferredCount: 0,
+      hasPersistentIntro: true,
+      isLoading: false,
+      liveCount: 1,
+    }),
+    "empty",
+  );
+});
+
 test("timeline-body-surface: deferred rows paint the message list", () => {
   assert.equal(
     selectTimelineBodySurface({
diff --git a/desktop/src/features/messages/lib/timelineSnapshot.ts b/desktop/src/features/messages/lib/timelineSnapshot.ts
index 2c492fec5a..14d415fb60 100644
--- a/desktop/src/features/messages/lib/timelineSnapshot.ts
+++ b/desktop/src/features/messages/lib/timelineSnapshot.ts
@@ -185,10 +185,12 @@ export type TimelineBodySurface = "skeleton" | "empty" | "list";
 
 export function selectTimelineBodySurface({
   deferredCount,
+  hasPersistentIntro = false,
   isLoading,
   liveCount,
 }: {
   deferredCount: number;
+  hasPersistentIntro?: boolean;
   isLoading: boolean;
   liveCount: number;
 }): TimelineBodySurface {
@@ -198,7 +200,10 @@ export function selectTimelineBodySurface({
 
   const renderState = selectDeferredListRenderState(deferredCount, liveCount);
   if (renderState === "pending") {
-    return "skeleton";
+    // A channel/DM intro is already meaningful stable content. Preserve it
+    // while React's deferred snapshot catches up to the first live message;
+    // replacing it with a skeleton makes an append look like a page reload.
+    return hasPersistentIntro ? "empty" : "skeleton";
   }
   return renderState;
 }
diff --git a/desktop/src/features/messages/ui/ChannelIntroBlock.tsx b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
new file mode 100644
index 0000000000..98300acb3e
--- /dev/null
+++ b/desktop/src/features/messages/ui/ChannelIntroBlock.tsx
@@ -0,0 +1,125 @@
+import type * as React from "react";
+import { Hash } from "lucide-react";
+
+import { cn } from "@/shared/lib/cn";
+
+export type ChannelIntroAction = {
+  description?: string;
+  icon: React.ReactNode;
+  label: string;
+  onClick: () => void;
+  testId?: string;
+};
+
+export type ChannelIntro = {
+  actions?: ChannelIntroAction[];
+  channelKindLabel: string;
+  channelName: string;
+  description?: string | null;
+  icon?: React.ReactNode;
+};
+
+/**
+ * The empty-channel intro block: channel icon, heading, kind label, and
+ * action cards. Rendered both as the virtualized timeline's leading row and
+ * as the non-virtualized empty state — one component so the two surfaces
+ * cannot drift and the first message always lands below it without layout
+ * shift.
+ */
+export function ChannelIntroBlock({
+  className,
+  intro,
+}: {
+  className?: string;
+  intro: ChannelIntro;
+}) {
+  return (
+    <div
+      className={cn(
+        "flex w-full flex-col items-start px-3 text-left",
+        className,
+      )}
+      data-testid="message-channel-intro"
+    >
+      <div
+        className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
+        data-testid="message-channel-intro-icon"
+      >
+        {intro.icon ?? <Hash aria-hidden className="h-7 w-7" />}
+      </div>
+      <p className="mt-4 max-w-2xl truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
+        #{intro.channelName}
+      </p>
+      <p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
+        This is the beginning of the{" "}
+        <span className="font-medium text-foreground">
+          {intro.channelKindLabel}
+        </span>
+        .
+      </p>
+      {intro.description ? (
+        <p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
+          {intro.description}
+        </p>
+      ) : null}
+      {intro.actions?.length ? (
+        <div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
+          {intro.actions.map((action) => {
+            const hasDescription = Boolean(action.description);
+
+            return (
+              <button
+                className={cn(
+                  "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
+                  hasDescription
+                    ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
+                    : "h-28 w-64 flex-col rounded-2xl p-4",
+                )}
+                data-testid={action.testId}
+                key={action.label}
+                onClick={action.onClick}
+                type="button"
+              >
+                <span
+                  className={cn(
+                    "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
+                    hasDescription
+                      ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
+                      : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
+                  )}
+                  data-testid={
+                    action.testId ? `${action.testId}-icon` : undefined
+                  }
+                >
+                  {action.icon}
+                </span>
+                <span className="mt-auto min-w-0">
+                  <span
+                    className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
+                    data-testid={
+                      action.testId ? `${action.testId}-title` : undefined
+                    }
+                  >
+                    {action.label}
+                  </span>
+                  {action.description ? (
+                    <span
+                      className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
+                      data-testid={
+                        action.testId
+                          ? `${action.testId}-description`
+                          : undefined
+                      }
+                    >
+                      {action.description}
+                    </span>
+                  ) : null}
+                </span>
+              </button>
+            );
+          })}
+        </div>
+      ) : null}
+    </div>
+  );
+}
diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx
index 8aef56277f..0a762bff35 100644
--- a/desktop/src/features/messages/ui/MessageRow.tsx
+++ b/desktop/src/features/messages/ui/MessageRow.tsx
@@ -86,6 +86,8 @@ export const MessageRow = React.memo(
     onMarkRead,
     onToggleReaction,
     onReply,
+    onEntranceComplete,
+    playEntrance = false,
     onUnfollowThread,
     profiles,
     searchQuery,
@@ -132,6 +134,8 @@ export const MessageRow = React.memo(
     ) => Promise<void>;
     onReply?: (message: TimelineMessage) => void;
     onUnfollowThread?: (message: TimelineMessage) => void;
+    onEntranceComplete?: (messageId: string) => void;
+    playEntrance?: boolean;
     profiles?: UserProfileLookup;
     searchQuery?: string;
     showDepthGuides?: boolean;
@@ -143,6 +147,17 @@ export const MessageRow = React.memo(
     const [badgeBurstEmoji, setBadgeBurstEmoji] = React.useState<string | null>(
       null,
     );
+    const handleEntranceAnimationEnd = React.useCallback(
+      (event: React.AnimationEvent<HTMLElement>) => {
+        if (
+          playEntrance &&
+          event.animationName === "motion-enter-conversation"
+        ) {
+          onEntranceComplete?.(message.id);
+        }
+      },
+      [message.id, onEntranceComplete, playEntrance],
+    );
     const {
       reactions,
       canToggle: canToggleReactions,
@@ -749,6 +764,7 @@ export const MessageRow = React.memo(
         <article
           className={cn(
             "group/message relative z-10 rounded-2xl transition-colors",
+            playEntrance && "motion-enter-conversation",
             "py-1",
             hoverBackground
               ? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
@@ -764,6 +780,7 @@ export const MessageRow = React.memo(
           )}
           data-message-id={message.id}
           data-testid="message-row"
+          onAnimationEnd={handleEntranceAnimationEnd}
         >
           {isThreadReplyLayout ? (
             <>
@@ -836,6 +853,8 @@ export const MessageRow = React.memo(
     prev.onCollapseDescendants === next.onCollapseDescendants &&
     prev.onCollapseDescendantsHoverChange ===
       next.onCollapseDescendantsHoverChange &&
+    prev.onEntranceComplete === next.onEntranceComplete &&
+    prev.playEntrance === next.playEntrance &&
     prev.profiles === next.profiles &&
     prev.searchQuery === next.searchQuery &&
     prev.videoReviewContext === next.videoReviewContext,
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index a8e949ece1..0740cf7da8 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -1,5 +1,4 @@
 import * as React from "react";
-import { Hash } from "lucide-react";
 
 import {
   isDeferredTimelineSnapshotStale,
@@ -18,6 +17,7 @@ import { channelChrome } from "@/shared/layout/chromeLayout";
 import { Spinner } from "@/shared/ui/spinner";
 import { TooltipProvider } from "@/shared/ui/tooltip";
 import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
+import { ChannelIntroBlock, type ChannelIntro } from "./ChannelIntroBlock";
 import { TimelineSkeleton, useTimelineSkeletonRows } from "./TimelineSkeleton";
 import { TimelineMessageList } from "./TimelineMessageList";
 import type { TimelineVirtualizerApi } from "./TimelineMessageList";
@@ -51,6 +51,8 @@ type MessageTimelineProps = {
     participants: DirectMessageIntroParticipant[];
   } | null;
   isLoading?: boolean;
+  entranceMessageId?: string | null;
+  onEntranceMessageComplete?: (messageId: string) => void;
   emptyTitle?: string;
   emptyDescription?: string;
   currentPubkey?: string;
@@ -111,22 +113,6 @@ type MessageTimelineProps = {
   threadUnreadCounts?: ReadonlyMap<string, number>;
 };
 
-type ChannelIntroAction = {
-  description?: string;
-  icon: React.ReactNode;
-  label: string;
-  onClick: () => void;
-  testId?: string;
-};
-
-type ChannelIntro = {
-  actions?: ChannelIntroAction[];
-  channelKindLabel: string;
-  channelName: string;
-  description?: string | null;
-  icon?: React.ReactNode;
-};
-
 /** Stable empty reference used as the `useDeferredValue` initial value so the
  *  first render on channel entry stays light instead of blocking on the full
  *  message list. Must be module-level so its identity never changes. */
@@ -166,6 +152,8 @@ const MessageTimelineBase = React.forwardRef<
     mainEntries,
     threadSummaries,
     isLoading = false,
+    entranceMessageId = null,
+    onEntranceMessageComplete,
     emptyTitle = "No messages yet",
     emptyDescription = "Send the first message to start the thread.",
     currentPubkey,
@@ -282,6 +270,7 @@ const MessageTimelineBase = React.forwardRef<
 
   const timelineBodySurface = selectTimelineBodySurface({
     deferredCount: deferredMessages.length,
+    hasPersistentIntro: channelIntro !== null || directMessageIntro !== null,
     isLoading: isLoading || isDeferredSnapshotStale,
     liveCount: messages.length,
   });
@@ -427,6 +416,7 @@ const MessageTimelineBase = React.forwardRef<
     activeDirectMessageIntro !== null || activeChannelIntro !== null;
   const showGenericEmpty = timelineBodySurface === "empty" && !showIntro;
   const showMessageList = timelineBodySurface === "list";
+  const showChannelIntroOnly = activeChannelIntro !== null && !showMessageList;
 
   const prepareForOwnMessage = React.useCallback(() => {
     // The user's own send is the deliberate Zulip exception: release buffered
@@ -581,70 +571,7 @@ const MessageTimelineBase = React.forwardRef<
   const virtualizedLeadingContent = React.useMemo(
     () =>
       activeChannelIntro ? (
-        <div
-          className="flex w-full flex-col items-start px-3 pb-4 pt-2 text-left"
-          data-testid="message-channel-intro"
-        >
-          <div className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground">
-            {activeChannelIntro.icon ?? (
-              <Hash aria-hidden className="h-7 w-7" />
-            )}
-          </div>
-          <p className="mt-4 max-w-2xl truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
-            #{activeChannelIntro.channelName}
-          </p>
-          <p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
-            This is the beginning of the{" "}
-            <span className="font-medium text-foreground">
-              {activeChannelIntro.channelKindLabel}
-            </span>
-            .
-          </p>
-          {activeChannelIntro.description ? (
-            <p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
-              {activeChannelIntro.description}
-            </p>
-          ) : null}
-          {activeChannelIntro.actions?.length ? (
-            <div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
-              {activeChannelIntro.actions.map((action) => (
-                <button
-                  className={cn(
-                    "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
-                    action.description
-                      ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
-                      : "h-28 w-64 flex-col rounded-2xl p-4",
-                  )}
-                  data-testid={action.testId}
-                  key={action.label}
-                  onClick={action.onClick}
-                  type="button"
-                >
-                  <span
-                    className={cn(
-                      "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
-                      action.description
-                        ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
-                        : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
-                    )}
-                  >
-                    {action.icon}
-                  </span>
-                  <span className="mt-auto min-w-0">
-                    <span className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground">
-                      {action.label}
-                    </span>
-                    {action.description ? (
-                      <span className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground">
-                        {action.description}
-                      </span>
-                    ) : null}
-                  </span>
-                </button>
-              ))}
-            </div>
-          ) : null}
-        </div>
+        <ChannelIntroBlock className="pb-4 pt-2" intro={activeChannelIntro} />
       ) : activeDirectMessageIntro ? (
         <div
           className="mb-2 flex w-full flex-col items-start px-3 pb-2 pt-2 text-left"
@@ -686,6 +613,8 @@ const MessageTimelineBase = React.forwardRef<
       huddleMemberPubkeysPending={huddleMemberPubkeysPending}
       isFollowingThreadById={isFollowingThreadById}
       isMessageUnreadById={isMessageUnreadById}
+      entranceMessageId={entranceMessageId}
+      onEntranceMessageComplete={onEntranceMessageComplete}
       messageFooters={messageFooters}
       mainEntries={renderedMessages === messages ? mainEntries : undefined}
       leadingContent={virtualizedLeadingContent}
@@ -787,20 +716,25 @@ const MessageTimelineBase = React.forwardRef<
             <div
               className={cn(
                 "flex w-full flex-col gap-2",
-                channelChrome.contentPadding,
+                showChannelIntroOnly
+                  ? "pt-[var(--channel-top-chrome-height,4.5rem)]"
+                  : channelChrome.contentPadding,
                 (showIntro || showGenericEmpty || showMessageList) &&
                   "min-h-full",
               )}
               ref={contentRef}
             >
-              <div ref={topSentinelRef} aria-hidden className="h-px" />
+              {showChannelIntroOnly ? null : (
+                <div ref={topSentinelRef} aria-hidden className="h-px" />
+              )}
 
-              {/* Fixed-height slot: an always-mounted height keeps the virtual
-                  spacer's offset stable across the load-older fetch toggle, so
-                  `scrollMargin` doesn't shift mid-fetch and yank the restore. The
-                  visible fetch spinner lives in the absolute overlay above, which
-                  does not occupy inline flow. */}
-              <div aria-hidden className="h-8" />
+              {/* Fixed-height history slot keeps the virtual spacer's offset
+                  stable across load-older fetches. The intro-only state has no
+                  history to anchor, so omitting it matches the virtualized
+                  leading row's top geometry when the first message arrives. */}
+              {showChannelIntroOnly ? null : (
+                <div aria-hidden className="h-8" />
+              )}
 
               <div
                 className={cn(
@@ -838,96 +772,12 @@ const MessageTimelineBase = React.forwardRef<
                 ) : null}
 
                 {activeChannelIntro ? (
-                  <div
-                    className="mt-auto flex w-full flex-col items-start px-3 py-2 text-left"
-                    data-testid="message-channel-intro"
-                  >
-                    <div
-                      className="flex h-[60px] w-[60px] items-center justify-center rounded-2xl border border-border/70 bg-muted/40 text-muted-foreground"
-                      data-testid="message-channel-intro-icon"
-                    >
-                      {activeChannelIntro.icon ?? (
-                        <Hash aria-hidden className="h-7 w-7" />
-                      )}
-                    </div>
-                    <p className="mt-4 max-w-2xl truncate text-xl font-semibold leading-7 tracking-tight text-foreground">
-                      #{activeChannelIntro.channelName}
-                    </p>
-                    <p className="mt-1 max-w-2xl text-sm leading-5 text-muted-foreground">
-                      This is the beginning of the{" "}
-                      <span className="font-medium text-foreground">
-                        {activeChannelIntro.channelKindLabel}
-                      </span>
-                      .
-                    </p>
-                    {activeChannelIntro.description ? (
-                      <p className="mt-2 max-w-xl text-sm leading-5 text-muted-foreground">
-                        {activeChannelIntro.description}
-                      </p>
-                    ) : null}
-                    {activeChannelIntro.actions?.length ? (
-                      <div className="mt-4 flex max-w-full flex-nowrap gap-3 overflow-x-auto pb-1">
-                        {activeChannelIntro.actions.map((action) => {
-                          const hasDescription = Boolean(action.description);
-
-                          return (
-                            <button
-                              className={cn(
-                                "flex shrink-0 border border-border/70 bg-background/70 text-left transition-colors hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
-                                hasDescription
-                                  ? "h-56 w-[13.75rem] flex-col rounded-2xl p-4"
-                                  : "h-28 w-64 flex-col rounded-2xl p-4",
-                              )}
-                              data-testid={action.testId}
-                              key={action.label}
-                              onClick={action.onClick}
-                              type="button"
-                            >
-                              <span
-                                className={cn(
-                                  "flex shrink-0 items-center justify-center rounded-full bg-muted/70 text-muted-foreground",
-                                  hasDescription
-                                    ? "h-12 w-12 [&_svg]:h-6 [&_svg]:w-6"
-                                    : "h-10 w-10 [&_svg]:h-4 [&_svg]:w-4",
-                                )}
-                                data-testid={
-                                  action.testId
-                                    ? `${action.testId}-icon`
-                                    : undefined
-                                }
-                              >
-                                {action.icon}
-                              </span>
-                              <span className="mt-auto min-w-0">
-                                <span
-                                  className="block whitespace-normal break-words text-base font-medium leading-6 text-foreground"
-                                  data-testid={
-                                    action.testId
-                                      ? `${action.testId}-title`
-                                      : undefined
-                                  }
-                                >
-                                  {action.label}
-                                </span>
-                                {action.description ? (
-                                  <span
-                                    className="mt-1 block whitespace-normal break-words text-sm leading-5 text-muted-foreground"
-                                    data-testid={
-                                      action.testId
-                                        ? `${action.testId}-description`
-                                        : undefined
-                                    }
-                                  >
-                                    {action.description}
-                                  </span>
-                                ) : null}
-                              </span>
-                            </button>
-                          );
-                        })}
-                      </div>
-                    ) : null}
-                  </div>
+                  /* Top-anchored like the virtualized leading row, so the
+                     first message arrives below with zero layout shift. */
+                  <ChannelIntroBlock
+                    className="py-2"
+                    intro={activeChannelIntro}
+                  />
                 ) : null}
 
                 {showGenericEmpty ? (
diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx
index 1f57263fb8..a05e936f46 100644
--- a/desktop/src/features/messages/ui/TimelineMessageList.tsx
+++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx
@@ -63,6 +63,8 @@ type TimelineMessageListProps = {
   highlightedMessageId?: string | null;
   isFollowingThreadById?: (rootId: string) => boolean;
   isMessageUnreadById?: (messageId: string) => boolean;
+  entranceMessageId?: string | null;
+  onEntranceMessageComplete?: (messageId: string) => void;
   messageFooters?: Record<string, React.ReactNode>;
   /** Hoisted main-timeline entries (computed once in ChannelPane). Falls back
    *  to deriving them here when omitted (e.g. the deferred-render pass). */
@@ -130,6 +132,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
   huddleMemberPubkeysPending = false,
   isFollowingThreadById,
   isMessageUnreadById,
+  entranceMessageId = null,
+  onEntranceMessageComplete,
   messageFooters,
   mainEntries,
   threadSummaries,
@@ -262,6 +266,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
               isFollowedByContinuation={item.isFollowedByContinuation}
               isFollowingThreadById={isFollowingThreadById}
               isUnread={isMessageUnreadById?.(item.entry.message.id)}
+              playEntrance={item.entry.message.id === entranceMessageId}
+              onEntranceComplete={onEntranceMessageComplete}
               onDelete={onDelete}
               onEdit={onEdit}
               onMarkRead={onMarkRead}
@@ -290,6 +296,8 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
       huddleMemberPubkeysPending,
       isFollowingThreadById,
       isMessageUnreadById,
+      entranceMessageId,
+      onEntranceMessageComplete,
       messageFooters,
       onDelete,
       onEdit,
@@ -831,6 +839,8 @@ type MessageRowItemProps = Pick<
   isContinuation?: boolean;
   isFollowedByContinuation?: boolean;
   isUnread?: boolean;
+  playEntrance?: boolean;
+  onEntranceComplete?: (messageId: string) => void;
   videoReviewContext: ReturnType<typeof buildVideoReviewContextForMessage>;
 };
 
@@ -847,6 +857,8 @@ function MessageRowItem({
   isFollowedByContinuation = false,
   isFollowingThreadById,
   isUnread,
+  playEntrance = false,
+  onEntranceComplete,
   onDelete,
   onEdit,
   onMarkUnread,
@@ -893,6 +905,8 @@ function MessageRowItem({
           }
           isUnread={isUnread}
           isContinuation={isContinuation}
+          playEntrance={playEntrance}
+          onEntranceComplete={onEntranceComplete}
           message={message}
           onDelete={canDelete}
           onEdit={canEdit}
@@ -943,6 +957,8 @@ function MessageRowItem({
         huddleMemberPubkeysPending={huddleMemberPubkeysPending}
         isContinuation={isContinuation}
         isUnread={isUnread}
+        playEntrance={playEntrance}
+        onEntranceComplete={onEntranceComplete}
         message={message}
         onDelete={canDelete}
         onEdit={canEdit}
diff --git a/desktop/src/features/onboarding/devFreshOnboarding.ts b/desktop/src/features/onboarding/devFreshOnboarding.ts
new file mode 100644
index 0000000000..3a129d83ff
--- /dev/null
+++ b/desktop/src/features/onboarding/devFreshOnboarding.ts
@@ -0,0 +1,16 @@
+const FORCE_FRESH_VALUE = "true";
+
+/**
+ * Development-only switch for replaying first-run onboarding with an existing
+ * identity. Vite removes the DEV branch from production builds.
+ */
+export const forceFreshOnboarding =
+  import.meta.env?.DEV === true &&
+  import.meta.env.VITE_BUZZ_FORCE_FRESH_ONBOARDING === FORCE_FRESH_VALUE;
+
+const devBootId = forceFreshOnboarding ? crypto.randomUUID() : null;
+
+/** Give each forced-fresh webview boot its own relay marker namespace. */
+export function welcomeKickoffMarker(baseMarker: string) {
+  return devBootId ? `${baseMarker}.dev-${devBootId}` : baseMarker;
+}
diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts
index b8a14fb07d..7495af48eb 100644
--- a/desktop/src/features/onboarding/hooks.ts
+++ b/desktop/src/features/onboarding/hooks.ts
@@ -9,21 +9,62 @@ import {
 import { channelsQueryKey } from "@/features/channels/hooks";
 import {
   ensureStarterChannels,
+  ensureWelcomeChannel,
   hasEnsuredWelcomeChannel,
   markWelcomeChannelEnsured,
   notifyWelcomeChannelReady,
   rememberPendingWelcomeChannel,
 } from "@/features/onboarding/welcome";
-import { ensureWelcomeGuideIntro } from "@/features/onboarding/welcomeGuide";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
+import { ensureWelcomeCanvas } from "@/features/onboarding/welcomeCanvas";
+import { ensureWelcomeTeam } from "@/features/onboarding/welcomeGuide";
 import { useProfileQuery } from "@/features/profile/hooks";
 import { useCommunities } from "@/features/communities/useCommunities";
 import { useIdentityQuery } from "@/shared/api/hooks";
+import type { Channel } from "@/shared/api/types";
 import {
+  createChannel,
+  deleteChannel,
   ensureStarterChannels as ensureStarterChannelsCommand,
+  getChannelMembers,
   getChannels,
+  updateChannel,
 } from "@/shared/api/tauri";
 
-export type ChannelInitResult = { ok: true } | { ok: false; reason: string };
+const STARTER_CHANNEL_SETUP_TOAST_ID = "starter-channel-setup-error";
+
+export type ChannelInitResult =
+  | { ok: true; focusChannelId?: string }
+  | { ok: false; reason: string; focusChannelId?: string };
+
+const welcomeSeedPromises = new Map<string, Promise<void>>();
+
+function seedWelcomeExperience(
+  queryClient: ReturnType<typeof useQueryClient>,
+  channelId: string,
+  pubkey: string | null,
+  communityScope: string | null,
+) {
+  const key = `${communityScope ?? ""}:${channelId}`;
+  const current = welcomeSeedPromises.get(key);
+  if (current) return current;
+
+  const promise = (async () => {
+    try {
+      await ensureWelcomeTeam(channelId, communityScope);
+      await ensureWelcomeCanvas(channelId);
+      await Promise.all([
+        queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
+        queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
+      ]);
+      markWelcomeChannelEnsured(pubkey, communityScope);
+    } catch (error) {
+      console.warn("Failed to seed the private Welcome experience.", error);
+    }
+  })().finally(() => welcomeSeedPromises.delete(key));
+  welcomeSeedPromises.set(key, promise);
+  return promise;
+}
 
 export async function initializeStarterChannels(
   queryClient: ReturnType<typeof useQueryClient>,
@@ -38,33 +79,83 @@ export async function initializeStarterChannels(
   },
 ): Promise<ChannelInitResult> {
   try {
-    const starterChannels = await ensureStarterChannels({
-      ensureStarterChannels: ensureStarterChannelsCommand,
-      getChannels,
-    });
-
-    markWelcomeChannelEnsured(pubkey, communityScope);
-    if (focus) {
-      rememberPendingWelcomeChannel(starterChannels.welcomeChannel.id);
-    }
-    queryClient.setQueryData(channelsQueryKey, starterChannels.channels);
+    let starterChannels: Awaited<
+      ReturnType<typeof ensureStarterChannels>
+    > | null = null;
+    let starterChannelsError: unknown = null;
     try {
-      await ensureWelcomeGuideIntro(
-        starterChannels.welcomeChannel.id,
-        communityScope,
-      );
-      await Promise.all([
-        queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
-        queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
-      ]);
+      starterChannels = await ensureStarterChannels({
+        ensureStarterChannels: ensureStarterChannelsCommand,
+        getChannels,
+      });
     } catch (error) {
-      console.warn("Failed to initialize Welcome guide.", error);
+      starterChannelsError = error;
+      console.warn("Failed to initialize public starter channels.", error);
     }
+
+    const welcomeChannel = await ensureWelcomeChannel(
+      {
+        createChannel,
+        deleteChannel,
+        getChannelMembers,
+        getChannels,
+        updateChannel,
+      },
+      {
+        replaceExisting: forceFreshOnboarding,
+      },
+    );
+
+    const starterChannelList = starterChannels?.channels ?? [];
+    queryClient.setQueryData<Channel[]>(channelsQueryKey, (channels = []) => {
+      const ensuredIds = new Set(
+        starterChannelList.map((channel) => channel.id),
+      );
+      ensuredIds.add(welcomeChannel.id);
+      return [
+        ...starterChannelList,
+        ...(starterChannelList.some(
+          (channel) => channel.id === welcomeChannel.id,
+        )
+          ? []
+          : [welcomeChannel]),
+        ...channels.filter((channel) => !ensuredIds.has(channel.id)),
+      ];
+    });
+    void seedWelcomeExperience(
+      queryClient,
+      welcomeChannel.id,
+      pubkey,
+      communityScope,
+    );
     await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
     if (focus) {
-      notifyWelcomeChannelReady(starterChannels.welcomeChannel.id);
+      // Refreshing can briefly replace the optimistic cache with an older relay
+      // snapshot. Reinsert the just-ensured channels before announcing focus so
+      // the route can consume the pending private Welcome channel immediately.
+      queryClient.setQueryData<Channel[]>(channelsQueryKey, (channels = []) => {
+        const byId = new Map(
+          [...channels, ...starterChannelList, welcomeChannel].map(
+            (channel) => [channel.id, channel],
+          ),
+        );
+        return [...byId.values()];
+      });
+      rememberPendingWelcomeChannel(welcomeChannel.id);
+      notifyWelcomeChannelReady(welcomeChannel.id);
     }
-    return { ok: true };
+    const focusChannelId = focus ? welcomeChannel.id : undefined;
+    if (starterChannelsError) {
+      return {
+        ok: false,
+        focusChannelId,
+        reason:
+          starterChannelsError instanceof Error
+            ? starterChannelsError.message
+            : "Failed to set up starter channels",
+      };
+    }
+    return { ok: true, focusChannelId };
   } catch (error) {
     console.warn("Failed to initialize starter channels.", error);
     return {
@@ -116,6 +207,9 @@ function onboardingCompletionStorageKey(pubkey: string) {
 }
 
 function readOnboardingCompletion(pubkey: string | null) {
+  if (forceFreshOnboarding) {
+    return false;
+  }
   if (typeof window === "undefined" || !pubkey) {
     return false;
   }
@@ -236,6 +330,7 @@ export function useFirstRunOnboardingGate({
     // main checkout. Skip unconditionally without waiting for the relay
     // profile query. Guarded by !hasCompletedCurrentPubkey so it fires once.
     if (
+      !forceFreshOnboarding &&
       isSharedIdentity &&
       currentPubkey &&
       identityStatus === "success" &&
@@ -292,7 +387,9 @@ export function useFirstRunOnboardingGate({
     // still exists, so onboarding is skipped. A missing event (new user, or no
     // kind:0 on the relay) always shows onboarding regardless of display_name.
     const hasExistingProfile =
-      profileStatus === "success" && profileHasEvent === true;
+      !forceFreshOnboarding &&
+      profileStatus === "success" &&
+      profileHasEvent === true;
 
     setGateState((current) =>
       updateActiveGateState(current, currentPubkey, (activeGateState) => {
@@ -465,15 +562,14 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
         focus,
         pubkey: currentPubkey,
         communityScope: starterChannelsCommunityScope,
+      }).finally(() => {
+        starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
+        starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
       });
       starterChannelsInitPromisesRef.current.set(
         starterChannelsInitKey,
         promise,
       );
-      void promise.finally(() => {
-        starterChannelsInitPromisesRef.current.delete(starterChannelsInitKey);
-        starterChannelsFocusIntentRef.current.delete(starterChannelsInitKey);
-      });
       return promise;
     },
     [currentPubkey, queryClient, starterChannelsCommunityScope],
@@ -501,13 +597,22 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
   const showStarterRetryToast = React.useCallback(
     (reason: string) => {
       toast.error("Couldn't set up starter channels", {
+        id: STARTER_CHANNEL_SETUP_TOAST_ID,
         action: {
           label: "Retry",
-          onClick: () => {
+          onClick: (event) => {
+            event.preventDefault();
             void requestStarterChannels(true).then((result) => {
               if (!result.ok) {
-                showStarterRetryToast(result.reason);
+                window.setTimeout(
+                  // Sonner dismisses an action toast as its click resolves, so
+                  // recreate a failed retry after that dismissal completes.
+                  () => showStarterRetryToast(result.reason),
+                  0,
+                );
+                return;
               }
+              toast.dismiss(STARTER_CHANNEL_SETUP_TOAST_ID);
             });
           },
         },
@@ -519,17 +624,19 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
 
   const completeAndShowWelcome = React.useCallback(() => {
     setIsCompletingStarterSetup(true);
-    gateComplete();
-    void requestStarterChannels(true)
-      .then((starterResult) => {
-        if (!starterResult.ok) {
-          showStarterRetryToast(starterResult.reason);
-        }
-        return refreshChannelsCache(queryClient);
-      })
-      .finally(() => {
-        setIsCompletingStarterSetup(false);
-      });
+    void requestStarterChannels(true).then(async (starterResult) => {
+      await refreshChannelsCache(queryClient);
+      gateComplete();
+      setIsCompletingStarterSetup(false);
+      if (starterResult.focusChannelId) {
+        window.location.hash = `/channels/${encodeURIComponent(
+          starterResult.focusChannelId,
+        )}`;
+      }
+      if (!starterResult.ok) {
+        showStarterRetryToast(starterResult.reason);
+      }
+    });
   }, [
     gateComplete,
     queryClient,
diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
index 85e20b7046..1928ee3835 100644
--- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
+++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx
@@ -13,6 +13,7 @@ import {
   persistCurrentIdentity,
 } from "@/shared/api/tauriIdentity";
 import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
+import { forceFreshOnboarding } from "@/features/onboarding/devFreshOnboarding";
 import { Button } from "@/shared/ui/button";
 import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
 import { AvatarStep } from "./AvatarStep";
@@ -512,6 +513,7 @@ export function OnboardingFlow({
                 }}
                 direction={transitionDirection}
                 state={profileStepState}
+                usesExistingIdentity={forceFreshOnboarding}
               />
             ) : currentPage === "key-import" ? (
               <OnboardingSlideTransition
diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx
index 4aadaf525b..69da9b8f7c 100644
--- a/desktop/src/features/onboarding/ui/ProfileStep.tsx
+++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx
@@ -22,6 +22,7 @@ type ProfileStepProps = {
   direction: OnboardingTransitionDirection;
   transitionEffect?: OnboardingTransitionEffect;
   state: ProfileStepState;
+  usesExistingIdentity?: boolean;
 };
 
 const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500;
@@ -190,6 +191,7 @@ export function ProfileStep({
   direction,
   transitionEffect = "line-slide",
   state,
+  usesExistingIdentity = false,
 }: ProfileStepProps) {
   const {
     advanceWithoutSaving,
@@ -287,6 +289,8 @@ export function ProfileStep({
         >
           {isSaving ? (
             <Spinner aria-label="Saving profile" className="h-4 w-4 border-2" />
+          ) : usesExistingIdentity ? (
+            "Continue"
           ) : (
             "Create an identity key"
           )}
@@ -305,16 +309,18 @@ export function ProfileStep({
           </Button>
         ) : null}
 
-        <Button
-          className="text-muted-foreground hover:text-accent-foreground"
-          data-testid="onboarding-import-key"
-          disabled={isSaving}
-          onClick={importExistingKey}
-          type="button"
-          variant="ghost"
-        >
-          I already have a key
-        </Button>
+        {!usesExistingIdentity ? (
+          <Button
+            className="text-muted-foreground hover:text-accent-foreground"
+            data-testid="onboarding-import-key"
+            disabled={isSaving}
+            onClick={importExistingKey}
+            type="button"
+            variant="ghost"
+          >
+            I already have a key
+          </Button>
+        ) : null}
 
         <div className="flex min-h-8 items-center gap-2">
           <div className="flex-1" />
diff --git a/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
new file mode 100644
index 0000000000..acd83fe062
--- /dev/null
+++ b/desktop/src/features/onboarding/useWelcomeKickoffEntrance.ts
@@ -0,0 +1,43 @@
+import * as React from "react";
+
+import { useWelcomeKickoff } from "@/features/onboarding/welcomeKickoff";
+import type { Channel, RelayEvent } from "@/shared/api/types";
+
+/**
+ * Runs the Welcome kickoff choreography and tracks the one-shot entrance
+ * animation for the newly posted opener.
+ *
+ * The opener lands in the main timeline with a one-shot entrance; the user
+ * opens the thread themselves. Auto-opening it caused a cascade of loading
+ * states (thread skeleton, "No replies" flash) on first run.
+ */
+export function useWelcomeKickoffEntrance(
+  activeChannel: Channel | null,
+  resolvedMessages: readonly RelayEvent[],
+  threadReplyEvents: readonly RelayEvent[],
+) {
+  const welcomeKickoffEvents = React.useMemo(
+    () => [...resolvedMessages, ...threadReplyEvents],
+    [resolvedMessages, threadReplyEvents],
+  );
+  const [entranceMessageId, setEntranceMessageId] = React.useState<
+    string | null
+  >(null);
+  React.useEffect(() => {
+    void activeChannel?.id;
+    setEntranceMessageId(null);
+  }, [activeChannel?.id]);
+  const handleKickoffOpenerPosted = React.useCallback((eventId: string) => {
+    setEntranceMessageId(eventId);
+  }, []);
+  const handleEntranceComplete = React.useCallback((eventId: string) => {
+    setEntranceMessageId((current) => (current === eventId ? null : current));
+  }, []);
+  useWelcomeKickoff(
+    activeChannel,
+    welcomeKickoffEvents,
+    handleKickoffOpenerPosted,
+  );
+
+  return { entranceMessageId, handleEntranceComplete };
+}
diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs
index b09ceb0946..e0a57a4586 100644
--- a/desktop/src/features/onboarding/welcome.test.mjs
+++ b/desktop/src/features/onboarding/welcome.test.mjs
@@ -96,6 +96,28 @@ test("ensureWelcomeChannel creates a private Welcome channel when one is missing
   ]);
 });
 
+test("ensureWelcomeChannel replaces existing Welcome in forced-fresh development mode", async () => {
+  const existingChannel = makeChannel({ id: "old-welcome" });
+  const calls = [];
+
+  const result = await ensureWelcomeChannel(
+    {
+      getChannels: async () => [existingChannel],
+      deleteChannel: async (channelId) => {
+        calls.push(["delete", channelId]);
+      },
+      createChannel: async () => {
+        calls.push(["create"]);
+        return makeChannel({ id: "fresh-welcome" });
+      },
+    },
+    { replaceExisting: true },
+  );
+
+  assert.equal(result.id, "fresh-welcome");
+  assert.deepEqual(calls, [["delete", "old-welcome"], ["create"]]);
+});
+
 test("ensureWelcomeChannel clears ttl on an existing ephemeral Welcome channel", async () => {
   const existingChannel = makeChannel({
     description:
diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts
index a60135e1db..98dc49cf06 100644
--- a/desktop/src/features/onboarding/welcome.ts
+++ b/desktop/src/features/onboarding/welcome.ts
@@ -26,6 +26,7 @@ const WELCOME_CHANNEL_ENSURED_STORAGE_KEY = "buzz-welcome-channel-ensured.v2";
 
 type WelcomeChannelClient = {
   createChannel: (input: CreateChannelInput) => Promise<Channel>;
+  deleteChannel?: (channelId: string) => Promise<void>;
   getChannels: () => Promise<Channel[]>;
   getChannelMembers?: (channelId: string) => Promise<ChannelMember[]>;
   updateChannel?: (input: UpdateChannelInput) => Promise<Channel>;
@@ -44,6 +45,8 @@ export type StarterChannelsResult = {
 
 type WelcomeChannelOptions = {
   allowedMemberPubkeys?: readonly string[];
+  /** Development-only replay mode: replace Welcome instead of reusing history. */
+  replaceExisting?: boolean;
 };
 
 type PendingWelcomeChannel = {
@@ -225,12 +228,26 @@ export async function ensureWelcomeChannel(
 ) {
   const channels = await client.getChannels();
   const existingWelcome = findPrivateWelcomeChannel(channels, options);
+  if (options.replaceExisting && existingWelcome) {
+    if (!client.deleteChannel) {
+      throw new Error("Replacing Welcome requires deleteChannel.");
+    }
+    await client.deleteChannel(existingWelcome.id);
+    return client.createChannel(welcomeChannelInput);
+  }
   if (existingWelcome) {
     return ensureCurrentWelcomeChannelMetadata(client, existingWelcome);
   }
 
   for (const channel of channels.filter(isPrivateWelcomeChannelCandidate)) {
     if (await hasOnlyCurrentHumanMember(client, channel)) {
+      if (options.replaceExisting) {
+        if (!client.deleteChannel) {
+          throw new Error("Replacing Welcome requires deleteChannel.");
+        }
+        await client.deleteChannel(channel.id);
+        return client.createChannel(welcomeChannelInput);
+      }
       return ensureCurrentWelcomeChannelMetadata(client, channel);
     }
   }
diff --git a/desktop/src/features/onboarding/welcomeCanvas.test.mjs b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
new file mode 100644
index 0000000000..8e094937ea
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  ensureWelcomeCanvas,
+  WELCOME_CANVAS_CONTENT,
+} from "./welcomeCanvas.ts";
+
+test("welcome canvas covers purpose, agent use, a first challenge, and help", () => {
+  assert.match(WELCOME_CANVAS_CONTENT, /private channel is your home base/i);
+  assert.match(WELCOME_CANVAS_CONTENT, /Mention an agent/i);
+  assert.match(WELCOME_CANVAS_CONTENT, /quick challenge/i);
+  assert.match(WELCOME_CANVAS_CONTENT, /Buzz user guide/i);
+});
+
+test("ensureWelcomeCanvas seeds a fresh channel with no canvas", async () => {
+  const writes = [];
+  const seeded = await ensureWelcomeCanvas("welcome-1", {
+    getCanvas: async () => ({ content: "", updatedAt: null, author: null }),
+    setCanvas: async (input) => {
+      writes.push(input);
+      return { ok: true, eventId: "e1" };
+    },
+  });
+
+  assert.equal(seeded, true);
+  assert.deepEqual(writes, [
+    { channelId: "welcome-1", content: WELCOME_CANVAS_CONTENT },
+  ]);
+});
+
+test("ensureWelcomeCanvas seeds even when the backend omits the empty-state fields", async () => {
+  // Regression: get_canvas once returned `{ content: "" }` with updated_at and
+  // author absent (undefined). `!== null` treated that as an existing canvas
+  // and seeding silently never ran for any fresh channel.
+  const writes = [];
+  const seeded = await ensureWelcomeCanvas("welcome-1", {
+    getCanvas: async () => ({ content: "" }),
+    setCanvas: async (input) => {
+      writes.push(input);
+      return { ok: true, eventId: "e1" };
+    },
+  });
+
+  assert.equal(seeded, true);
+  assert.equal(writes.length, 1);
+});
+
+test("ensureWelcomeCanvas never overwrites an existing canvas", async () => {
+  const seeded = await ensureWelcomeCanvas("welcome-1", {
+    getCanvas: async () => ({
+      content: "my notes",
+      updatedAt: 1_700_000_000,
+      author: "a".repeat(64),
+    }),
+    setCanvas: async () => {
+      throw new Error("must not overwrite an existing canvas");
+    },
+  });
+
+  assert.equal(seeded, false);
+});
diff --git a/desktop/src/features/onboarding/welcomeCanvas.ts b/desktop/src/features/onboarding/welcomeCanvas.ts
new file mode 100644
index 0000000000..7d592fe30a
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeCanvas.ts
@@ -0,0 +1,41 @@
+import { getCanvas, setCanvas } from "@/shared/api/tauri";
+
+export const WELCOME_CANVAS_CONTENT = `# Welcome to Buzz
+
+This private channel is your home base for getting oriented. Fizz, Honey, and Bumble can help you learn the app, troubleshoot setup, and work through something you are building.
+
+## Work with your agents
+
+- Mention an agent when you want its help.
+- Bring multiple agents into the same conversation when you want different perspectives.
+- Keep decisions, progress, and results in the channel so everyone shares the same context.
+
+## Try something
+
+Bring the team something you are building, or give them a quick challenge to see how they work together.
+
+## Get help
+
+Ask the team a question here, or read the [Buzz user guide](https://github.com/block/buzz#readme).
+`;
+
+type WelcomeCanvasClient = {
+  getCanvas: typeof getCanvas;
+  setCanvas: typeof setCanvas;
+};
+
+/** Seed the Welcome canvas without overwriting anything the user has written. */
+export async function ensureWelcomeCanvas(
+  channelId: string,
+  client: WelcomeCanvasClient = { getCanvas, setCanvas },
+) {
+  const existing = await client.getCanvas(channelId);
+  // Nullish (not `!== null`) so an absent field can never masquerade as an
+  // existing canvas — that exact mismatch silently skipped seeding before.
+  if (existing.updatedAt != null || existing.author != null) {
+    return false;
+  }
+
+  await client.setCanvas({ channelId, content: WELCOME_CANVAS_CONTENT });
+  return true;
+}
diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs
index 49a717d447..4129f938af 100644
--- a/desktop/src/features/onboarding/welcomeGuide.test.mjs
+++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs
@@ -2,11 +2,15 @@ import assert from "node:assert/strict";
 import test from "node:test";
 
 import {
+  activateWelcomeTeamPersonasSequentially,
   LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT,
   pickWelcomeGuideAgent,
   pickWelcomeGuideAgentForRelay,
+  pickWelcomeTeamStarterAgentForRelay,
   WELCOME_GUIDE_AGENT_NAME,
   WELCOME_GUIDE_PERSONA_ID,
+  WELCOME_TEAM_ID,
+  WELCOME_TEAM_STARTERS,
 } from "./welcomeGuide.ts";
 
 const PUB_A = "a".repeat(64);
@@ -46,6 +50,7 @@ function makeAgent(overrides = {}) {
     backendAgentId: null,
     respondTo: "owner-only",
     respondToAllowlist: [],
+    teamId: WELCOME_TEAM_ID,
     ...overrides,
   };
 }
@@ -128,3 +133,112 @@ test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in anothe
     null,
   );
 });
+
+test("starter persona activation is serialized to protect the shared store", async () => {
+  const calls = [];
+  let activeWrites = 0;
+
+  await activateWelcomeTeamPersonasSequentially(
+    ["builtin:fizz", "builtin:honey", "builtin:bumble"],
+    async (personaId) => {
+      assert.equal(activeWrites, 0, "activation writes must never overlap");
+      activeWrites += 1;
+      calls.push(personaId);
+      await new Promise((resolve) => setTimeout(resolve, 1));
+      activeWrites -= 1;
+    },
+  );
+
+  assert.deepEqual(calls, ["builtin:fizz", "builtin:honey", "builtin:bumble"]);
+});
+
+test("welcome team starter definitions and role identities are stable", () => {
+  assert.equal(WELCOME_TEAM_ID, "builtin-team:welcome");
+  assert.deepEqual(WELCOME_TEAM_STARTERS, [
+    { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+    { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+    { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+  ]);
+});
+
+test("starter matching ignores user agents with a Welcome persona", () => {
+  const honey = WELCOME_TEAM_STARTERS[1];
+  const userHoney = makeAgent({
+    personaId: honey.personaId,
+    teamId: null,
+  });
+
+  assert.equal(
+    pickWelcomeTeamStarterAgentForRelay([userHoney], honey, RELAY_A),
+    null,
+  );
+});
+
+test("starter matching uses persona identity rather than display name", () => {
+  const honey = WELCOME_TEAM_STARTERS[1];
+  const renamedHoney = makeAgent({
+    name: "Honey the Helper",
+    personaId: honey.personaId,
+  });
+  const nameOnlyHoney = makeAgent({ name: honey.name, pubkey: PUB_B });
+
+  assert.equal(
+    pickWelcomeTeamStarterAgentForRelay(
+      [nameOnlyHoney, renamedHoney],
+      honey,
+      RELAY_A,
+    ),
+    renamedHoney,
+  );
+});
+
+test("starter matching is relay scoped and normalizes trailing slashes", () => {
+  const bumble = WELCOME_TEAM_STARTERS[2];
+  const otherRelay = makeAgent({
+    personaId: bumble.personaId,
+    relayUrl: RELAY_B,
+    status: "running",
+  });
+  const matchingRelay = makeAgent({
+    personaId: bumble.personaId,
+    relayUrl: `${RELAY_A}/`,
+    pubkey: PUB_B,
+  });
+
+  assert.equal(
+    pickWelcomeTeamStarterAgentForRelay(
+      [otherRelay, matchingRelay],
+      bumble,
+      RELAY_A,
+    ),
+    matchingRelay,
+  );
+});
+
+test("starter matching prefers running, then deployed instances", () => {
+  const fizz = WELCOME_TEAM_STARTERS[0];
+  const stopped = makeAgent({ personaId: fizz.personaId });
+  const deployed = makeAgent({
+    personaId: fizz.personaId,
+    pubkey: PUB_B,
+    status: "deployed",
+  });
+  const running = makeAgent({
+    personaId: fizz.personaId,
+    pubkey: PUB_C,
+    status: "running",
+  });
+
+  assert.equal(
+    pickWelcomeTeamStarterAgentForRelay(
+      [stopped, deployed, running],
+      fizz,
+      RELAY_A,
+    ),
+    running,
+  );
+  assert.equal(
+    pickWelcomeTeamStarterAgentForRelay([stopped, deployed], fizz, RELAY_A),
+    deployed,
+  );
+});
diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts
index 051cfb8c7a..f0fb185c36 100644
--- a/desktop/src/features/onboarding/welcomeGuide.ts
+++ b/desktop/src/features/onboarding/welcomeGuide.ts
@@ -3,14 +3,15 @@ import {
   createManagedAgent,
   getChannelMembers,
   listManagedAgents,
+  updateManagedAgent,
 } from "@/shared/api/tauri";
-import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
 import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas";
 import type { ManagedAgent } from "@/shared/api/types";
 import { normalizePubkey } from "@/shared/lib/pubkey";
 
 export const WELCOME_GUIDE_AGENT_NAME = "Fizz";
 export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
+export const WELCOME_TEAM_ID = "builtin-team:welcome";
 export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1";
 const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit";
 export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
@@ -18,6 +19,25 @@ export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
 export const WELCOME_GUIDE_INTRO_MESSAGE =
   "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.";
 
+export type WelcomeTeamRole = "lead" | "teammate";
+
+export type WelcomeTeamStarterDefinition = Readonly<{
+  name: string;
+  personaId: string;
+  role: WelcomeTeamRole;
+}>;
+
+/** Stable identities used to provision the Rust-seeded Welcome Team. */
+export const WELCOME_TEAM_STARTERS = [
+  { name: "Fizz", personaId: "builtin:fizz", role: "lead" },
+  { name: "Honey", personaId: "builtin:honey", role: "teammate" },
+  { name: "Bumble", personaId: "builtin:bumble", role: "teammate" },
+] as const satisfies readonly WelcomeTeamStarterDefinition[];
+
+export type WelcomeTeamAgents = [ManagedAgent, ManagedAgent, ManagedAgent];
+
+const welcomeTeamPromises = new Map<string, Promise<WelcomeTeamAgents>>();
+
 function normalizeRelayUrl(relayUrl: string | null | undefined) {
   return relayUrl?.trim().replace(/\/+$/, "") ?? null;
 }
@@ -30,17 +50,8 @@ function isAgentScopedToRelay(agent: ManagedAgent, relayUrl?: string | null) {
   return normalizeRelayUrl(agent.relayUrl) === targetRelayUrl;
 }
 
-function isNamedWelcomeGuideAgent(agent: ManagedAgent) {
-  return (
-    agent.name.trim().toLowerCase() === WELCOME_GUIDE_AGENT_NAME.toLowerCase()
-  );
-}
-
 function isBuiltInWelcomeGuideAgent(agent: ManagedAgent) {
-  return (
-    agent.personaId === WELCOME_GUIDE_PERSONA_ID &&
-    isNamedWelcomeGuideAgent(agent)
-  );
+  return agent.personaId === WELCOME_GUIDE_PERSONA_ID;
 }
 
 function isLegacyKitWelcomeGuideAgent(agent: ManagedAgent) {
@@ -82,6 +93,39 @@ export function pickWelcomeGuideAgentForRelay(
   );
 }
 
+/** Find the preferred managed instance for one starter persona and relay. */
+export function pickWelcomeTeamStarterAgentForRelay(
+  agents: ManagedAgent[],
+  starter: WelcomeTeamStarterDefinition,
+  relayUrl?: string | null,
+) {
+  return pickAgentByStatus(
+    agents.filter(
+      (agent) =>
+        agent.teamId === WELCOME_TEAM_ID &&
+        agent.personaId === starter.personaId &&
+        isAgentScopedToRelay(agent, relayUrl),
+    ),
+  );
+}
+
+/** Pubkeys belonging to any managed Welcome Team persona on this relay. */
+export async function getWelcomeTeamAgentPubkeys(relayUrl?: string | null) {
+  const personaIds = new Set<string>(
+    WELCOME_TEAM_STARTERS.map(({ personaId }) => personaId),
+  );
+  return (await listManagedAgents())
+    .filter(
+      (agent) =>
+        agent.teamId === WELCOME_TEAM_ID &&
+        agent.personaId !== null &&
+        personaIds.has(agent.personaId) &&
+        isAgentScopedToRelay(agent, relayUrl),
+    )
+    .map((agent) => agent.pubkey);
+}
+
+/** Legacy Fizz/Kit lookup retained for existing channel reuse checks. */
 export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
   return (await listManagedAgents())
     .filter(
@@ -91,76 +135,138 @@ export async function getWelcomeGuideAgentPubkeys(relayUrl?: string | null) {
     .map((agent) => agent.pubkey);
 }
 
-async function ensureWelcomeGuidePersonaActive() {
-  const guidePersona = (await listPersonas()).find(
-    (persona) => persona.id === WELCOME_GUIDE_PERSONA_ID,
-  );
-  if (!guidePersona) {
-    throw new Error(`${WELCOME_GUIDE_AGENT_NAME} agent not found.`);
-  }
-  if (!guidePersona.isActive) {
-    await setPersonaActive(WELCOME_GUIDE_PERSONA_ID, true);
+export async function activateWelcomeTeamPersonasSequentially(
+  inactivePersonaIds: readonly string[],
+  activate: (personaId: string) => Promise<unknown>,
+) {
+  for (const personaId of inactivePersonaIds) {
+    await activate(personaId);
   }
 }
 
-async function ensureWelcomeGuideAgent(relayUrl?: string | null) {
-  const agents = await listManagedAgents();
-  const existing = pickWelcomeGuideAgentForRelay(agents, relayUrl);
-  if (existing) {
-    return existing;
-  }
-
-  await ensureWelcomeGuidePersonaActive();
+async function ensureWelcomeTeamPersonasActive() {
+  const personas = await listPersonas();
+  const personasById = new Map(
+    personas.map((persona) => [persona.id, persona]),
+  );
 
-  const created = await createManagedAgent({
-    name: WELCOME_GUIDE_AGENT_NAME,
-    personaId: WELCOME_GUIDE_PERSONA_ID,
-    relayUrl: relayUrl ?? undefined,
-    spawnAfterCreate: false,
-    startOnAppLaunch: false,
-    respondTo: "owner-only",
-  });
+  for (const starter of WELCOME_TEAM_STARTERS) {
+    if (!personasById.has(starter.personaId)) {
+      throw new Error(`${starter.name} agent not found.`);
+    }
+  }
 
-  return created.agent;
+  // Persona activation is a read-modify-write operation over one shared file.
+  // Run these sequentially so concurrent writes cannot lose a teammate's
+  // activation and leave Welcome provisioning permanently partial.
+  await activateWelcomeTeamPersonasSequentially(
+    WELCOME_TEAM_STARTERS.filter(
+      ({ personaId }) => !personasById.get(personaId)?.isActive,
+    ).map(({ personaId }) => personaId),
+    (personaId) => setPersonaActive(personaId, true),
+  );
 }
 
-async function ensureWelcomeGuideMembership(
+async function ensureWelcomeTeamMembership(
   channelId: string,
-  agent: ManagedAgent,
+  agents: WelcomeTeamAgents,
 ) {
-  const agentPubkey = normalizePubkey(agent.pubkey);
   const members = await getChannelMembers(channelId).catch(() => []);
-  if (
-    members.some((member) => normalizePubkey(member.pubkey) === agentPubkey)
-  ) {
+  const memberPubkeys = new Set(
+    members.map((member) => normalizePubkey(member.pubkey)),
+  );
+  const missingAgents = agents.filter(
+    (agent) => !memberPubkeys.has(normalizePubkey(agent.pubkey)),
+  );
+  if (missingAgents.length === 0) {
     return;
   }
 
   const result = await addChannelMembers({
     channelId,
-    pubkeys: [agent.pubkey],
+    pubkeys: missingAgents.map((agent) => agent.pubkey),
     role: "bot",
   });
-  const error = result.errors.find(
-    (entry) => normalizePubkey(entry.pubkey) === agentPubkey,
+  const unexpectedError = result.errors.find(
+    ({ error }) => !error.toLowerCase().includes("already"),
   );
-  if (error && !error.error.toLowerCase().includes("already")) {
-    throw new Error(error.error);
+  if (unexpectedError) {
+    throw new Error(unexpectedError.error);
   }
 }
 
-export async function ensureWelcomeGuideIntro(
+/**
+ * Ensure the complete built-in Welcome Team is ready for kickoff.
+ * The team itself is Rust-seeded; this only activates personas, creates any
+ * missing relay-scoped instances, and adds all three to Welcome as bots.
+ */
+async function provisionWelcomeTeam(
   channelId: string,
   relayUrl?: string | null,
-) {
-  const agent = await ensureWelcomeGuideAgent(relayUrl);
-  await ensureWelcomeGuideMembership(channelId, agent);
-  await sendManagedAgentChannelMessage({
-    agentPubkey: agent.pubkey,
-    channelId,
-    content: WELCOME_GUIDE_INTRO_MESSAGE,
-    marker: WELCOME_GUIDE_INTRO_MARKER,
-    markerScope: "channel",
-  });
-  return agent;
+): Promise<WelcomeTeamAgents> {
+  const existingAgents = await listManagedAgents();
+  await ensureWelcomeTeamPersonasActive();
+
+  const agents: ManagedAgent[] = [];
+  for (const starter of WELCOME_TEAM_STARTERS) {
+    const existing = pickWelcomeTeamStarterAgentForRelay(
+      existingAgents,
+      starter,
+      relayUrl,
+    );
+    if (existing) {
+      agents.push(existing);
+      continue;
+    }
+
+    const created = await createManagedAgent({
+      name: starter.name,
+      personaId: starter.personaId,
+      teamId: WELCOME_TEAM_ID,
+      relayUrl: relayUrl ?? undefined,
+      spawnAfterCreate: false,
+      startOnAppLaunch: false,
+      respondTo: "owner-only",
+    });
+    agents.push(created.agent);
+  }
+  const [lead, honey, bumble] = agents;
+  if (!lead || !honey || !bumble) {
+    throw new Error("Welcome Team provisioning did not return every starter.");
+  }
+  const welcomeAgents: WelcomeTeamAgents = [lead, honey, bumble];
+  const leadPubkey = lead.pubkey;
+  for (const index of [1, 2] as const) {
+    const teammate = welcomeAgents[index];
+    const alreadyAllowsLead =
+      teammate.respondTo === "allowlist" &&
+      teammate.respondToAllowlist.some(
+        (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+      );
+    if (!alreadyAllowsLead) {
+      const updated = await updateManagedAgent({
+        pubkey: teammate.pubkey,
+        respondTo: "allowlist",
+        respondToAllowlist: [leadPubkey],
+      });
+      welcomeAgents[index] = updated.agent;
+    }
+  }
+  await ensureWelcomeTeamMembership(channelId, welcomeAgents);
+  return welcomeAgents;
+}
+
+export function ensureWelcomeTeam(
+  channelId: string,
+  relayUrl?: string | null,
+): Promise<WelcomeTeamAgents> {
+  const key = `${normalizeRelayUrl(relayUrl) ?? ""}:${channelId}`;
+  const current = welcomeTeamPromises.get(key);
+  if (current) return current;
+
+  const promise = provisionWelcomeTeam(channelId, relayUrl).finally(() =>
+    welcomeTeamPromises.delete(key),
+  );
+  welcomeTeamPromises.set(key, promise);
+  return promise;
 }
diff --git a/desktop/src/features/onboarding/welcomeKickoff.test.mjs b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
new file mode 100644
index 0000000000..244d6de05e
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.test.mjs
@@ -0,0 +1,297 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  areWelcomeTeammatesOnline,
+  buildWelcomeKickoffCloser,
+  buildWelcomeKickoffOpener,
+  buildWelcomeKickoffOpenerSendInput,
+  classifyWelcomeKickoffResolution,
+  createWelcomeKickoffCoordinator,
+  resolveWelcomeAgentSet,
+  selectWelcomeKickoffIntroTeammates,
+  waitForWelcomeKickoffBeat,
+  waitForWelcomeTeammatesOnline,
+  welcomeTeammateNeedsRestart,
+} from "./welcomeKickoff.ts";
+
+function agent(name, personaId, pubkey) {
+  return {
+    name,
+    personaId,
+    teamId: "builtin-team:welcome",
+    pubkey,
+    relayUrl: "ws://localhost:3000",
+    status: "stopped",
+    lastError: null,
+    lastStartedAt: null,
+  };
+}
+
+const fizz = agent("Fizz", "builtin:fizz", "f".repeat(64));
+const honey = agent("Honey", "builtin:honey", "h".repeat(64));
+const bumble = agent("Bumble", "builtin:bumble", "b".repeat(64));
+
+test("resolveWelcomeAgentSet orders agents by stable persona identity", () => {
+  assert.deepEqual(resolveWelcomeAgentSet([bumble, fizz, honey]), {
+    lead: fizz,
+    teammates: [honey, bumble],
+  });
+  assert.equal(resolveWelcomeAgentSet([fizz, honey]), null);
+});
+
+test("opener uses current agent names and requests bounded simultaneous intros", () => {
+  const opener = buildWelcomeKickoffOpener({ ...fizz, name: "Fizzy" }, [
+    { ...honey, name: "Honeybee" },
+    bumble,
+  ]);
+
+  assert.match(opener, /I'm Fizzy/);
+  assert.match(opener, /@Honeybee and @Bumble/);
+  assert.doesNotMatch(opener, /@@/);
+  assert.match(opener, /sentence or two/);
+  assert.match(opener, /Don't start any work yet/);
+});
+
+test("teammates are not ready until every harness publishes online presence", () => {
+  assert.equal(areWelcomeTeammatesOnline([honey, bumble], undefined), false);
+  assert.equal(
+    areWelcomeTeammatesOnline([honey, bumble], {
+      [honey.pubkey]: "online",
+      [bumble.pubkey]: "offline",
+    }),
+    false,
+  );
+  assert.equal(
+    areWelcomeTeammatesOnline([honey, bumble], {
+      [honey.pubkey]: "online",
+      [bumble.pubkey]: "online",
+    }),
+    true,
+  );
+});
+
+test("readiness wait observes agents becoming online without navigation", async () => {
+  let reads = 0;
+  const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+    isCancelled: () => false,
+    loadPresence: async () => {
+      reads += 1;
+      return reads < 3
+        ? { [honey.pubkey]: "online", [bumble.pubkey]: "offline" }
+        : { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+    },
+    pollMs: 0,
+    waitMs: 1_000,
+  });
+
+  assert.deepEqual(ready, [honey, bumble]);
+  assert.equal(reads, 3);
+});
+
+test("readiness wait retries transient presence failures", async () => {
+  let reads = 0;
+  const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+    isCancelled: () => false,
+    loadPresence: async () => {
+      reads += 1;
+      if (reads === 1) throw new Error("relay unavailable");
+      return { [honey.pubkey]: "online", [bumble.pubkey]: "online" };
+    },
+    pollMs: 0,
+    waitMs: 1_000,
+  });
+
+  assert.deepEqual(ready, [honey, bumble]);
+  assert.equal(reads, 2);
+});
+
+test("readiness wait cancels when Welcome loses focus", async () => {
+  const ready = await waitForWelcomeTeammatesOnline([honey, bumble], {
+    isCancelled: () => true,
+    loadPresence: async () => {
+      throw new Error("cancelled waits must not query");
+    },
+    pollMs: 0,
+    waitMs: 1_000,
+  });
+
+  assert.deepEqual(ready, []);
+});
+
+test("kickoff beat waits for the configured pacing interval", async () => {
+  const startedAt = Date.now();
+  assert.equal(await waitForWelcomeKickoffBeat({ waitMs: 10 }), true);
+  assert.ok(Date.now() - startedAt >= 8);
+});
+
+test("kickoff beat cancels when Welcome loses focus", async () => {
+  const controller = new AbortController();
+  const beat = waitForWelcomeKickoffBeat({
+    signal: controller.signal,
+    waitMs: 1_000,
+  });
+  controller.abort();
+  assert.equal(await beat, false);
+});
+
+test("kickoff coordinator preserves one task across rerenders and cancels on navigation", () => {
+  const coordinator = createWelcomeKickoffCoordinator();
+  const first = coordinator.begin("welcome");
+  assert.ok(first);
+  assert.equal(coordinator.begin("welcome"), null);
+  assert.equal(first.signal.aborted, false);
+
+  coordinator.cancel("welcome");
+  assert.equal(first.signal.aborted, true);
+  assert.ok(coordinator.begin("welcome"));
+});
+
+test("closer degrades coherently for partial and total startup failure", () => {
+  assert.match(buildWelcomeKickoffCloser([]), /What can we help you build/);
+  assert.match(buildWelcomeKickoffCloser(["Honey"]), /Honey is having trouble/);
+  assert.match(
+    buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+    /Honey and Bumble couldn't start/,
+  );
+  assert.match(
+    buildWelcomeKickoffCloser(["Honey", "Bumble"]),
+    /I'm still here to help/,
+  );
+});
+
+test("closer names teammates that did not reply before the intro wait", () => {
+  assert.match(
+    buildWelcomeKickoffCloser([], ["Bumble"]),
+    /Bumble is taking longer to reply/,
+  );
+  assert.match(
+    buildWelcomeKickoffCloser(["Honey"], ["Bumble"]),
+    /Honey and Bumble are taking longer than expected/,
+  );
+});
+
+test("running teammates restart when their allowlist does not include the lead", () => {
+  assert.equal(
+    welcomeTeammateNeedsRestart(
+      {
+        ...honey,
+        status: "running",
+        respondTo: "allowlist",
+        respondToAllowlist: [fizz.pubkey],
+      },
+      fizz.pubkey,
+    ),
+    false,
+  );
+  assert.equal(
+    welcomeTeammateNeedsRestart(
+      {
+        ...bumble,
+        status: "running",
+        respondTo: "allowlist",
+        respondToAllowlist: [honey.pubkey],
+      },
+      fizz.pubkey,
+    ),
+    true,
+  );
+});
+
+test("opener keeps partial-readiness warm and mentions only online teammates", () => {
+  const agentSet = { lead: fizz, teammates: [honey, bumble] };
+  const introTeammates = selectWelcomeKickoffIntroTeammates(
+    agentSet.teammates,
+    [honey],
+  );
+  const input = buildWelcomeKickoffOpenerSendInput(
+    agentSet,
+    introTeammates,
+    "welcome-1",
+  );
+
+  assert.deepEqual(input.mentionPubkeys, [honey.pubkey]);
+  assert.deepEqual(input.additionalMarkers, []);
+  assert.match(input.content, /@Honey, introduce yourself/);
+  assert.doesNotMatch(input.content, /@@/);
+  assert.doesNotMatch(
+    input.content,
+    /Bumble.*trouble|couldn't start|taking longer/i,
+  );
+});
+
+test("opener degrades to one seeded Fizz message when no teammate comes online", () => {
+  const agentSet = { lead: fizz, teammates: [honey, bumble] };
+  const input = buildWelcomeKickoffOpenerSendInput(agentSet, [], "welcome-1");
+
+  assert.deepEqual(input.mentionPubkeys, []);
+  assert.equal(input.additionalMarkers.length, 1);
+  assert.match(input.content, /I'm here with Honey and Bumble/);
+  assert.match(input.content, /What can we help you build/);
+  assert.doesNotMatch(
+    input.content,
+    /introduce yourselves|trouble|couldn't start|taking longer/i,
+  );
+});
+
+test("readiness wait returns the subset that became online by the deadline", async () => {
+  const online = await waitForWelcomeTeammatesOnline([honey, bumble], {
+    isCancelled: () => false,
+    loadPresence: async () => ({
+      [honey.pubkey]: "online",
+      [bumble.pubkey]: "offline",
+    }),
+    pollMs: 0,
+    waitMs: 1,
+  });
+
+  assert.deepEqual(online, [honey]);
+});
+
+function relayEvent({ id, pubkey, createdAt = 1, tags = [], content = "" }) {
+  return {
+    id,
+    pubkey,
+    created_at: createdAt,
+    kind: 9,
+    tags,
+    content,
+    sig: "sig",
+  };
+}
+
+test("closer classification sees replies that arrive during the final beat", async () => {
+  const agentSet = { lead: fizz, teammates: [honey, bumble] };
+  const opener = relayEvent({
+    id: "opener",
+    pubkey: fizz.pubkey,
+    tags: [["client", "buzz-welcome-kickoff.opener.v1"]],
+  });
+  const events = [opener];
+
+  const beforeBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+  assert.deepEqual(
+    beforeBeat.unresolved.map((agent) => agent.name),
+    ["Honey", "Bumble"],
+  );
+
+  const beat = waitForWelcomeKickoffBeat({ waitMs: 5 });
+  events.push(
+    relayEvent({
+      id: "honey-intro",
+      pubkey: honey.pubkey,
+      createdAt: 2,
+      tags: [
+        ["e", opener.id, "", "root"],
+        ["e", opener.id, "", "reply"],
+      ],
+    }),
+  );
+  assert.equal(await beat, true);
+
+  const afterBeat = classifyWelcomeKickoffResolution(events, opener, agentSet);
+  assert.deepEqual(
+    afterBeat.unresolved.map((agent) => agent.name),
+    ["Bumble"],
+  );
+});
diff --git a/desktop/src/features/onboarding/welcomeKickoff.ts b/desktop/src/features/onboarding/welcomeKickoff.ts
new file mode 100644
index 0000000000..c6e1e49874
--- /dev/null
+++ b/desktop/src/features/onboarding/welcomeKickoff.ts
@@ -0,0 +1,727 @@
+import * as React from "react";
+
+import {
+  managedAgentsQueryKey,
+  useAcpRuntimesQuery,
+  useManagedAgentsQuery,
+} from "@/features/agents/hooks";
+import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig";
+import { useCommunities } from "@/features/communities/useCommunities";
+import { welcomeKickoffMarker } from "@/features/onboarding/devFreshOnboarding";
+import { resolveAgentReadiness } from "@/features/onboarding/ui/agentReadiness";
+import {
+  ensureWelcomeTeam,
+  pickWelcomeTeamStarterAgentForRelay,
+  WELCOME_TEAM_STARTERS,
+  type WelcomeTeamStarterDefinition,
+} from "@/features/onboarding/welcomeGuide";
+import { isWelcomeChannel } from "@/features/onboarding/welcome";
+import { getThreadReference } from "@/features/messages/lib/threading";
+import {
+  startManagedAgent,
+  stopManagedAgent,
+} from "@/shared/api/tauriManagedAgents";
+import { hasManagedAgentChannelMessageMarker } from "@/shared/api/tauriManagedAgentMessageMarkers";
+import { sendManagedAgentChannelMessage } from "@/shared/api/tauriManagedAgentMessages";
+import { getPresence, listManagedAgents } from "@/shared/api/tauri";
+import type { Channel, ManagedAgent, RelayEvent } from "@/shared/api/types";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { useQueryClient } from "@tanstack/react-query";
+
+export const WELCOME_KICKOFF_OPENER_MARKER = "buzz-welcome-kickoff.opener.v1";
+export const WELCOME_KICKOFF_CLOSER_MARKER = "buzz-welcome-kickoff.closer.v1";
+export const WELCOME_KICKOFF_PROVIDER_MARKER =
+  "buzz-welcome-kickoff.provider-required.v1";
+
+const openerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_OPENER_MARKER);
+const closerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_CLOSER_MARKER);
+const providerMarker = welcomeKickoffMarker(WELCOME_KICKOFF_PROVIDER_MARKER);
+
+export const WELCOME_KICKOFF_PROVIDER_MESSAGE =
+  "To get started with agents, connect to an AI provider in Settings. Once you're connected, come back here and we'll introduce the team.";
+
+const WELCOME_KICKOFF_CTA =
+  "What can we help you build? Bring us something you're working on, or give us a quick challenge to see how we work together.";
+
+function formatAgentNames(agents: readonly ManagedAgent[]) {
+  if (agents.length === 0) return "";
+  if (agents.length === 1) return agents[0]?.name ?? "";
+  return `${agents
+    .slice(0, -1)
+    .map((agent) => agent.name)
+    .join(", ")} and ${agents[agents.length - 1]?.name ?? ""}`;
+}
+
+function formatMentionNames(agents: readonly ManagedAgent[]) {
+  if (agents.length === 0) return "";
+  if (agents.length === 1) return `@${agents[0]?.name ?? ""}`;
+  return `${agents
+    .slice(0, -1)
+    .map((agent) => `@${agent.name}`)
+    .join(", ")} and @${agents[agents.length - 1]?.name ?? ""}`;
+}
+export function createWelcomeKickoffCoordinator() {
+  const controllers = new Map<string, AbortController>();
+  return {
+    begin(channelId: string) {
+      if (controllers.has(channelId)) return null;
+      const controller = new AbortController();
+      controllers.set(channelId, controller);
+      return controller;
+    },
+    cancel(channelId: string) {
+      controllers.get(channelId)?.abort();
+      controllers.delete(channelId);
+    },
+    finish(channelId: string, controller: AbortController) {
+      if (controllers.get(channelId) === controller) {
+        controllers.delete(channelId);
+      }
+    },
+  };
+}
+
+const kickoffCoordinator = createWelcomeKickoffCoordinator();
+const closerInFlight = new Set<string>();
+const TEAMMATE_READY_POLL_MS = 250;
+const TEAMMATE_READY_WAIT_MS = 60_000;
+const TEAMMATE_INTRO_WAIT_MS = 15_000;
+const KICKOFF_BEAT_MS = 3_000;
+const CLOSER_BEAT_MS = 10_000;
+const closerAbortControllers = new Map<string, AbortController>();
+const closerTimeouts = new Map<
+  string,
+  ReturnType<typeof globalThis.setTimeout>
+>();
+
+type WelcomeAgentSet = {
+  lead: ManagedAgent;
+  teammates: [ManagedAgent, ManagedAgent];
+};
+
+function markerEvent(events: readonly RelayEvent[], marker: string) {
+  return events.find((event) =>
+    event.tags.some(
+      (tag) => tag.length >= 2 && tag[0] === "client" && tag[1] === marker,
+    ),
+  );
+}
+
+export function resolveWelcomeAgentSet(
+  agents: readonly ManagedAgent[],
+): WelcomeAgentSet | null {
+  const ordered = WELCOME_TEAM_STARTERS.map((starter) =>
+    pickWelcomeTeamStarterAgentForRelay([...agents], starter),
+  );
+  if (ordered.some((agent) => !agent)) return null;
+  return {
+    lead: ordered[0] as ManagedAgent,
+    teammates: [ordered[1] as ManagedAgent, ordered[2] as ManagedAgent],
+  };
+}
+
+function normalizeRelayUrl(relayUrl?: string | null) {
+  return relayUrl?.trim().replace(/\/+$/, "") ?? null;
+}
+
+function resolveWelcomeAgentSetForRelay(
+  agents: readonly ManagedAgent[],
+  relayUrl?: string | null,
+) {
+  const normalizedRelayUrl = normalizeRelayUrl(relayUrl);
+  return resolveWelcomeAgentSet(
+    agents.filter(
+      (agent) =>
+        !normalizedRelayUrl ||
+        normalizeRelayUrl(agent.relayUrl) === normalizedRelayUrl,
+    ),
+  );
+}
+
+export function buildWelcomeKickoffOpener(
+  lead: ManagedAgent,
+  introTeammates: readonly ManagedAgent[],
+  allTeammates: readonly ManagedAgent[] = introTeammates,
+) {
+  const introNames = formatMentionNames(introTeammates);
+  if (introTeammates.length === 0) {
+    const teammateNames = formatAgentNames(allTeammates);
+    const teammatePhrase = teammateNames ? ` with ${teammateNames}` : "";
+    return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and I'm here${teammatePhrase} to help you get oriented or work through something you're building.\n\n${WELCOME_KICKOFF_CTA}`;
+  }
+
+  return `Hi, I'm ${lead.name}. Welcome to Buzz. This is your private home base, and we're here to help you get oriented or work through something you're building.\n\n${introNames}, introduce ${introTeammates.length === 1 ? "yourself" : "yourselves"} in a sentence or two — share what you're good at and when to bring you in. Don't start any work yet.`;
+}
+
+export function onlineWelcomeTeammates(
+  teammates: readonly ManagedAgent[],
+  presence: Readonly<Record<string, string>> | undefined,
+) {
+  return teammates.filter(
+    (agent) => presence?.[normalizePubkey(agent.pubkey)] === "online",
+  );
+}
+
+export function areWelcomeTeammatesOnline(
+  teammates: readonly ManagedAgent[],
+  presence: Readonly<Record<string, string>> | undefined,
+) {
+  return (
+    onlineWelcomeTeammates(teammates, presence).length === teammates.length
+  );
+}
+
+export async function waitForWelcomeTeammatesOnline(
+  teammates: readonly ManagedAgent[],
+  options: {
+    isCancelled: () => boolean;
+    loadPresence?: typeof getPresence;
+    pollMs?: number;
+    waitMs?: number;
+  },
+) {
+  const loadPresence = options.loadPresence ?? getPresence;
+  const pollMs = options.pollMs ?? TEAMMATE_READY_POLL_MS;
+  const deadline = Date.now() + (options.waitMs ?? TEAMMATE_READY_WAIT_MS);
+  const pubkeys = teammates.map((agent) => agent.pubkey);
+  let latestOnline: ManagedAgent[] = [];
+
+  while (!options.isCancelled() && Date.now() < deadline) {
+    try {
+      latestOnline = onlineWelcomeTeammates(
+        teammates,
+        await loadPresence(pubkeys),
+      );
+      if (latestOnline.length === teammates.length) {
+        return latestOnline;
+      }
+    } catch (error) {
+      console.warn("Welcome teammate presence check failed; retrying.", error);
+    }
+    await new Promise((resolve) => globalThis.setTimeout(resolve, pollMs));
+  }
+  return options.isCancelled() ? [] : latestOnline;
+}
+
+export async function waitForWelcomeKickoffBeat(
+  options: { signal?: AbortSignal; waitMs?: number } = {},
+) {
+  const waitMs = options.waitMs ?? KICKOFF_BEAT_MS;
+  if (options.signal?.aborted) return false;
+
+  return new Promise<boolean>((resolve) => {
+    const timer = globalThis.setTimeout(() => {
+      options.signal?.removeEventListener("abort", cancel);
+      resolve(true);
+    }, waitMs);
+    const cancel = () => {
+      globalThis.clearTimeout(timer);
+      resolve(false);
+    };
+    options.signal?.addEventListener("abort", cancel, { once: true });
+  });
+}
+
+export function buildWelcomeKickoffCloser(
+  failedNames: readonly string[],
+  delayedNames: readonly string[] = [],
+) {
+  if (failedNames.length === 0 && delayedNames.length === 0) {
+    return WELCOME_KICKOFF_CTA;
+  }
+  if (failedNames.length === 1 && delayedNames.length === 0) {
+    return `${failedNames[0]} is having trouble starting — you can check on them in Agents.\n\n${WELCOME_KICKOFF_CTA}`;
+  }
+  if (failedNames.length > 1 && delayedNames.length === 0) {
+    return `${failedNames.join(" and ")} couldn't start. You can check on them in Agents; I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+  }
+  if (failedNames.length === 0 && delayedNames.length === 1) {
+    return `${delayedNames[0]} is taking longer to reply — I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+  }
+  const names = [...failedNames, ...delayedNames].join(" and ");
+  return `${names} are taking longer than expected. I'm still here to help.\n\n${WELCOME_KICKOFF_CTA}`;
+}
+
+function isReplyToOpener(event: RelayEvent, opener: RelayEvent) {
+  const threadRef = getThreadReference(event.tags);
+  return threadRef.rootId === opener.id || threadRef.parentId === opener.id;
+}
+
+function introAuthorsAfterOpener(
+  events: readonly RelayEvent[],
+  opener: RelayEvent,
+  teammates: readonly [ManagedAgent, ManagedAgent],
+) {
+  const authors = new Set(
+    events
+      .filter(
+        (event) =>
+          event.created_at >= opener.created_at &&
+          isReplyToOpener(event, opener),
+      )
+      .map((event) => normalizePubkey(event.pubkey)),
+  );
+  return new Set(
+    teammates
+      .filter((agent) => authors.has(normalizePubkey(agent.pubkey)))
+      .map((agent) => normalizePubkey(agent.pubkey)),
+  );
+}
+
+function failedAfterKickoff(agent: ManagedAgent, opener: RelayEvent) {
+  if (agent.status !== "stopped" || !agent.lastError || !agent.lastStoppedAt) {
+    return false;
+  }
+  return (
+    Math.floor(new Date(agent.lastStoppedAt).getTime() / 1_000) >=
+    opener.created_at
+  );
+}
+
+export function classifyWelcomeKickoffResolution(
+  events: readonly RelayEvent[],
+  opener: RelayEvent,
+  agentSet: WelcomeAgentSet,
+) {
+  const introAuthors = introAuthorsAfterOpener(
+    events,
+    opener,
+    agentSet.teammates,
+  );
+  const failed = agentSet.teammates.filter((agent) =>
+    failedAfterKickoff(agent, opener),
+  );
+  const unresolved = agentSet.teammates.filter(
+    (agent) =>
+      !introAuthors.has(normalizePubkey(agent.pubkey)) &&
+      !failed.includes(agent),
+  );
+  return { failed, unresolved };
+}
+
+async function resolveLatestWelcomeAgentSet({
+  fallback,
+  queryClient,
+  relayUrl,
+}: {
+  fallback: WelcomeAgentSet;
+  queryClient: ReturnType<typeof useQueryClient>;
+  relayUrl?: string | null;
+}) {
+  const agents = await queryClient.fetchQuery({
+    queryKey: managedAgentsQueryKey,
+    queryFn: listManagedAgents,
+  });
+  return resolveWelcomeAgentSetForRelay(agents, relayUrl) ?? fallback;
+}
+
+async function markerExists(channelId: string, marker: string) {
+  return hasManagedAgentChannelMessageMarker({
+    channelId,
+    marker,
+    markerScope: "channel",
+  });
+}
+
+export function welcomeTeammateNeedsRestart(
+  agent: ManagedAgent,
+  leadPubkey: string,
+) {
+  return (
+    agent.status === "running" &&
+    (agent.needsRestart ||
+      agent.respondTo !== "allowlist" ||
+      !agent.respondToAllowlist.some(
+        (pubkey) => normalizePubkey(pubkey) === normalizePubkey(leadPubkey),
+      ))
+  );
+}
+
+export function selectWelcomeKickoffIntroTeammates(
+  teammates: readonly ManagedAgent[],
+  onlineTeammates: readonly ManagedAgent[],
+) {
+  const onlinePubkeys = new Set(
+    onlineTeammates.map((agent) => normalizePubkey(agent.pubkey)),
+  );
+  return teammates.filter((agent) =>
+    onlinePubkeys.has(normalizePubkey(agent.pubkey)),
+  );
+}
+
+export function buildWelcomeKickoffOpenerSendInput(
+  agentSet: WelcomeAgentSet,
+  introTeammates: readonly ManagedAgent[],
+  channelId: string,
+) {
+  return {
+    agentPubkey: agentSet.lead.pubkey,
+    channelId,
+    content: buildWelcomeKickoffOpener(
+      agentSet.lead,
+      introTeammates,
+      agentSet.teammates,
+    ),
+    marker: openerMarker,
+    markerScope: "channel" as const,
+    mentionPubkeys: introTeammates.map((agent) => agent.pubkey),
+    additionalMarkers: introTeammates.length === 0 ? [closerMarker] : [],
+  };
+}
+
+export async function restartWelcomeTeammate(
+  agent: ManagedAgent,
+  options: {
+    stopAgent?: typeof stopManagedAgent;
+    startAgent?: typeof startManagedAgent;
+  } = {},
+) {
+  const stopAgent = options.stopAgent ?? stopManagedAgent;
+  const startAgent = options.startAgent ?? startManagedAgent;
+  if (agent.status === "running") {
+    await stopAgent(agent.pubkey);
+  }
+  return startAgent(agent.pubkey);
+}
+
+async function sendWelcomeKickoffCloser({
+  agentSet,
+  channelId,
+  content,
+  opener,
+}: {
+  agentSet: WelcomeAgentSet;
+  channelId: string;
+  content: string;
+  opener: RelayEvent;
+}) {
+  if (await markerExists(channelId, closerMarker)) return;
+
+  await sendManagedAgentChannelMessage({
+    agentPubkey: agentSet.lead.pubkey,
+    channelId,
+    content,
+    marker: closerMarker,
+    markerScope: "channel",
+    parentEventId: opener.id,
+  });
+}
+
+/** Runs the Welcome choreography only while the Welcome channel is focused. */
+export function useWelcomeKickoff(
+  activeChannel: Channel | null,
+  channelEvents: readonly RelayEvent[],
+  onKickoffOpenerPosted?: (eventId: string) => void,
+) {
+  const queryClient = useQueryClient();
+  const { activeCommunity } = useCommunities();
+  const runtimesQuery = useAcpRuntimesQuery();
+  const managedAgentsQuery = useManagedAgentsQuery();
+  const { globalConfig, isLoading: configLoading } = useGlobalAgentConfig();
+  const channelId = activeChannel?.id ?? null;
+  const isActiveWelcome = isWelcomeChannel(activeChannel);
+  const focusedWelcomeChannelRef = React.useRef<string | null>(null);
+  focusedWelcomeChannelRef.current = isActiveWelcome ? channelId : null;
+  const channelEventsRef = React.useRef(channelEvents);
+  channelEventsRef.current = channelEvents;
+  const agentSet = React.useMemo(
+    () =>
+      resolveWelcomeAgentSetForRelay(
+        managedAgentsQuery.data ?? [],
+        activeCommunity?.relayUrl,
+      ),
+    [activeCommunity?.relayUrl, managedAgentsQuery.data],
+  );
+  const readiness = React.useMemo(
+    () => resolveAgentReadiness(runtimesQuery.data ?? [], globalConfig),
+    [globalConfig, runtimesQuery.data],
+  );
+  React.useEffect(() => {
+    if (
+      !channelId ||
+      !isActiveWelcome ||
+      configLoading ||
+      runtimesQuery.isPending
+    ) {
+      return;
+    }
+
+    const kickoffController = kickoffCoordinator.begin(channelId);
+    if (!kickoffController) return;
+    const isCancelled = () =>
+      kickoffController.signal.aborted ||
+      focusedWelcomeChannelRef.current !== channelId;
+    const landingBeat = waitForWelcomeKickoffBeat({
+      signal: kickoffController.signal,
+    });
+    void (async () => {
+      try {
+        const welcomeTeam = await ensureWelcomeTeam(
+          channelId,
+          activeCommunity?.relayUrl,
+        );
+        await queryClient.invalidateQueries({
+          queryKey: managedAgentsQueryKey,
+        });
+        const resolvedAgentSet: WelcomeAgentSet = {
+          lead: welcomeTeam[0],
+          teammates: [welcomeTeam[1], welcomeTeam[2]],
+        };
+
+        if (await markerExists(channelId, closerMarker)) {
+          return;
+        }
+        if (!readiness.ready) {
+          await sendManagedAgentChannelMessage({
+            agentPubkey: resolvedAgentSet.lead.pubkey,
+            channelId,
+            content: WELCOME_KICKOFF_PROVIDER_MESSAGE,
+            marker: providerMarker,
+            markerScope: "channel",
+          });
+          return;
+        }
+        const openerAlreadySent = await markerExists(channelId, openerMarker);
+
+        // Start before publishing the mention. buzz-acp replays events from its
+        // startup watermark, so no separate subscription-ready wait is needed.
+        // On resume, restart unresolved teammates but never replay the opener.
+        const agentsToStart = openerAlreadySent
+          ? resolvedAgentSet.teammates
+          : [resolvedAgentSet.lead, ...resolvedAgentSet.teammates];
+        const startResults = await Promise.allSettled(
+          agentsToStart.map((agent) => {
+            const isTeammate = resolvedAgentSet.teammates.some(
+              (teammate) =>
+                normalizePubkey(teammate.pubkey) ===
+                normalizePubkey(agent.pubkey),
+            );
+            if (
+              isTeammate &&
+              welcomeTeammateNeedsRestart(agent, resolvedAgentSet.lead.pubkey)
+            ) {
+              return restartWelcomeTeammate(agent);
+            }
+            return agent.status === "running" || agent.status === "deployed"
+              ? Promise.resolve(agent)
+              : startManagedAgent(agent.pubkey);
+          }),
+        );
+        for (const [index, result] of startResults.entries()) {
+          if (result.status === "rejected") {
+            console.warn(
+              `Failed to start Welcome agent ${agentsToStart[index]?.name ?? "unknown"}.`,
+              result.reason,
+            );
+          }
+        }
+        await queryClient.invalidateQueries({
+          queryKey: managedAgentsQueryKey,
+        });
+        if (openerAlreadySent) return;
+
+        const leadStartIndex = agentsToStart.findIndex(
+          (agent) => agent.pubkey === resolvedAgentSet.lead.pubkey,
+        );
+        if (startResults[leadStartIndex]?.status === "rejected") return;
+        const teammatesToAwait = resolvedAgentSet.teammates.filter(
+          (teammate) =>
+            startResults[
+              agentsToStart.findIndex(
+                (agent) => agent.pubkey === teammate.pubkey,
+              )
+            ]?.status !== "rejected",
+        );
+        const onlineTeammates = await waitForWelcomeTeammatesOnline(
+          teammatesToAwait,
+          { isCancelled },
+        );
+        if (isCancelled()) return;
+        const introTeammates = selectWelcomeKickoffIntroTeammates(
+          resolvedAgentSet.teammates,
+          onlineTeammates,
+        );
+        if (introTeammates.length < resolvedAgentSet.teammates.length) {
+          console.warn(
+            "Some Welcome teammates did not become ready; continuing with a degraded kickoff.",
+          );
+        }
+        if (!(await landingBeat) || isCancelled()) return;
+
+        const openerResult = await sendManagedAgentChannelMessage(
+          buildWelcomeKickoffOpenerSendInput(
+            resolvedAgentSet,
+            introTeammates,
+            channelId,
+          ),
+        );
+        if (!isCancelled()) onKickoffOpenerPosted?.(openerResult.eventId);
+      } catch (error) {
+        console.warn("Failed to start the Welcome team kickoff.", error);
+      } finally {
+        kickoffCoordinator.finish(channelId, kickoffController);
+      }
+    })();
+  }, [
+    activeCommunity?.relayUrl,
+    channelId,
+    configLoading,
+    isActiveWelcome,
+    onKickoffOpenerPosted,
+    queryClient,
+    readiness,
+    runtimesQuery.isPending,
+  ]);
+
+  React.useEffect(() => {
+    void isActiveWelcome;
+    return () => {
+      if (!channelId) return;
+      kickoffCoordinator.cancel(channelId);
+      closerAbortControllers.get(channelId)?.abort();
+      closerAbortControllers.delete(channelId);
+      const timeout = closerTimeouts.get(channelId);
+      if (timeout) globalThis.clearTimeout(timeout);
+      closerTimeouts.delete(channelId);
+      closerInFlight.delete(channelId);
+    };
+  }, [channelId, isActiveWelcome]);
+
+  React.useEffect(() => {
+    if (
+      !channelId ||
+      !isActiveWelcome ||
+      !agentSet ||
+      closerInFlight.has(channelId)
+    )
+      return;
+    const opener = markerEvent(channelEvents, openerMarker);
+    if (!opener || markerEvent(channelEvents, closerMarker)) {
+      return;
+    }
+
+    const { unresolved } = classifyWelcomeKickoffResolution(
+      channelEvents,
+      opener,
+      agentSet,
+    );
+
+    if (unresolved.length > 0) {
+      if (!closerTimeouts.has(channelId)) {
+        const elapsedMs = Math.max(0, Date.now() - opener.created_at * 1_000);
+        const waitMs = Math.max(0, TEAMMATE_INTRO_WAIT_MS - elapsedMs);
+        const timeout = globalThis.setTimeout(() => {
+          closerTimeouts.delete(channelId);
+          if (focusedWelcomeChannelRef.current !== channelId) return;
+          const controller = new AbortController();
+          closerAbortControllers.set(channelId, controller);
+          closerInFlight.add(channelId);
+          void (async () => {
+            if (
+              !(await waitForWelcomeKickoffBeat({
+                signal: controller.signal,
+                waitMs: CLOSER_BEAT_MS,
+              })) ||
+              controller.signal.aborted ||
+              focusedWelcomeChannelRef.current !== channelId
+            )
+              return;
+
+            const latestEvents = channelEventsRef.current;
+            if (markerEvent(latestEvents, closerMarker)) return;
+            const latestOpener =
+              markerEvent(latestEvents, openerMarker) ?? opener;
+            const latestAgentSet = await resolveLatestWelcomeAgentSet({
+              fallback: agentSet,
+              queryClient,
+              relayUrl: activeCommunity?.relayUrl,
+            });
+            const latestResolution = classifyWelcomeKickoffResolution(
+              latestEvents,
+              latestOpener,
+              latestAgentSet,
+            );
+            await sendWelcomeKickoffCloser({
+              agentSet: latestAgentSet,
+              channelId,
+              content: buildWelcomeKickoffCloser(
+                latestResolution.failed.map((agent) => agent.name),
+                latestResolution.unresolved.map((agent) => agent.name),
+              ),
+              opener: latestOpener,
+            });
+          })()
+            .catch((error) => {
+              console.warn("Failed to finish the Welcome team kickoff.", error);
+            })
+            .finally(() => {
+              if (closerAbortControllers.get(channelId) === controller) {
+                closerAbortControllers.delete(channelId);
+              }
+              closerInFlight.delete(channelId);
+            });
+        }, waitMs);
+        closerTimeouts.set(channelId, timeout);
+      }
+      return;
+    }
+
+    const timeout = closerTimeouts.get(channelId);
+    if (timeout) globalThis.clearTimeout(timeout);
+    closerTimeouts.delete(channelId);
+
+    const controller = new AbortController();
+    closerAbortControllers.set(channelId, controller);
+    closerInFlight.add(channelId);
+    void (async () => {
+      if (
+        !(await waitForWelcomeKickoffBeat({
+          signal: controller.signal,
+          waitMs: CLOSER_BEAT_MS,
+        })) ||
+        controller.signal.aborted ||
+        focusedWelcomeChannelRef.current !== channelId
+      )
+        return;
+
+      const latestEvents = channelEventsRef.current;
+      const latestOpener = markerEvent(latestEvents, openerMarker) ?? opener;
+      const latestAgentSet = await resolveLatestWelcomeAgentSet({
+        fallback: agentSet,
+        queryClient,
+        relayUrl: activeCommunity?.relayUrl,
+      });
+      const latestResolution = classifyWelcomeKickoffResolution(
+        latestEvents,
+        latestOpener,
+        latestAgentSet,
+      );
+      await sendWelcomeKickoffCloser({
+        agentSet: latestAgentSet,
+        channelId,
+        content: buildWelcomeKickoffCloser(
+          latestResolution.failed.map((agent) => agent.name),
+        ),
+        opener: latestOpener,
+      });
+    })()
+      .catch((error) => {
+        console.warn("Failed to finish the Welcome team kickoff.", error);
+      })
+      .finally(() => {
+        if (closerAbortControllers.get(channelId) === controller) {
+          closerAbortControllers.delete(channelId);
+        }
+        closerInFlight.delete(channelId);
+      });
+  }, [
+    activeCommunity?.relayUrl,
+    agentSet,
+    channelEvents,
+    channelId,
+    isActiveWelcome,
+    queryClient,
+  ]);
+}
+
+export type { WelcomeTeamStarterDefinition };
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts
index 9082db841d..afb0a2c3c1 100644
--- a/desktop/src/shared/api/tauri.ts
+++ b/desktop/src/shared/api/tauri.ts
@@ -112,6 +112,7 @@ export type RawManagedAgent = {
   pubkey: string;
   name: string;
   persona_id: string | null;
+  team_id?: string | null;
   relay_url: string;
   acp_command: string;
   agent_command: string;
@@ -366,8 +367,11 @@ export async function getCanvas(channelId: string): Promise<CanvasResponse> {
   });
   return {
     content: response.content,
-    updatedAt: response.updated_at,
-    author: response.author,
+    // Normalize absent keys to null: ensureWelcomeCanvas treats null as
+    // "no canvas yet", and `undefined !== null` would make every fresh
+    // channel look already-seeded.
+    updatedAt: response.updated_at ?? null,
+    author: response.author ?? null,
   };
 }
 
@@ -636,6 +640,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
     pubkey: agent.pubkey,
     name: agent.name,
     personaId: agent.persona_id,
+    teamId: agent.team_id ?? null,
     relayUrl: agent.relay_url,
     acpCommand: agent.acp_command,
     agentCommand: agent.agent_command,
@@ -649,7 +654,6 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
     systemPrompt: agent.system_prompt,
     avatarUrl: agent.avatar_url ?? null,
     model: agent.model,
-    // Fallbacks for pre-feature mocks/fixtures. Real records always carry them.
     provider: agent.provider ?? null,
     personaOutOfDate: agent.persona_out_of_date ?? false,
     personaOrphaned: agent.persona_orphaned ?? false,
diff --git a/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
new file mode 100644
index 0000000000..2582e752f2
--- /dev/null
+++ b/desktop/src/shared/api/tauriManagedAgentMessageMarkers.ts
@@ -0,0 +1,15 @@
+import { invokeTauri } from "@/shared/api/tauri";
+
+export async function hasManagedAgentChannelMessageMarker(input: {
+  channelId: string;
+  marker: string;
+  agentPubkey?: string;
+  markerScope?: "agent" | "channel";
+}): Promise<boolean> {
+  return invokeTauri<boolean>("has_managed_agent_channel_message_marker", {
+    channelId: input.channelId,
+    marker: input.marker,
+    agentPubkey: input.agentPubkey ?? null,
+    markerScope: input.markerScope ?? null,
+  });
+}
diff --git a/desktop/src/shared/api/tauriManagedAgentMessages.ts b/desktop/src/shared/api/tauriManagedAgentMessages.ts
index b7a6b2114a..4e01b87c84 100644
--- a/desktop/src/shared/api/tauriManagedAgentMessages.ts
+++ b/desktop/src/shared/api/tauriManagedAgentMessages.ts
@@ -15,6 +15,9 @@ export async function sendManagedAgentChannelMessage(input: {
   content: string;
   marker?: string;
   markerScope?: "agent" | "channel";
+  mentionPubkeys?: string[];
+  parentEventId?: string;
+  additionalMarkers?: string[];
 }): Promise<SendChannelMessageResult> {
   const response = await invokeTauri<RawSendChannelMessageResult>(
     "send_managed_agent_channel_message",
@@ -24,6 +27,9 @@ export async function sendManagedAgentChannelMessage(input: {
       content: input.content,
       marker: input.marker ?? null,
       markerScope: input.markerScope ?? null,
+      mentionPubkeys: input.mentionPubkeys ?? null,
+      parentEventId: input.parentEventId ?? null,
+      additionalMarkers: input.additionalMarkers ?? null,
     },
   );
 
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index b82fa06d3a..fdd355d88b 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -319,6 +319,7 @@ export type ManagedAgent = {
   pubkey: string;
   name: string;
   personaId: string | null;
+  teamId?: string | null;
   relayUrl: string;
   acpCommand: string;
   /** Resolved/effective harness command (persona-wins, override-honored). */
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index fd82e04c6b..fd60621933 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -1,6 +1,7 @@
 @import "tailwindcss";
 @import "tw-animate-css";
 @import "./globals/scrollbars.css";
+@import "./globals/motion.css";
 @import "./globals/animations.css";
 @import "./globals/composer.css";
 @import "./globals/markdown.css";
diff --git a/desktop/src/shared/styles/globals/motion.css b/desktop/src/shared/styles/globals/motion.css
new file mode 100644
index 0000000000..902f2f5be3
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.css
@@ -0,0 +1,57 @@
+/*
+ * Buzz motion system
+ *
+ * Keep shared timing, easing, distance, and semantic motion primitives here.
+ * Features decide when motion runs; this layer defines how Buzz moves.
+ */
+
+:root {
+  /* Durations: feedback → state change → composed arrival. */
+  --motion-duration-instant: 120ms;
+  --motion-duration-fast: 180ms;
+  --motion-duration-standard: 240ms;
+  --motion-duration-arrival: 500ms;
+
+  /* Easing: direct for feedback, confident deceleration for entrances. */
+  --motion-ease-standard: cubic-bezier(0.25, 1, 0.5, 1);
+  --motion-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
+
+  /* Distances stay restrained so motion supports hierarchy without spectacle. */
+  --motion-distance-arrival: 0.75rem;
+  --motion-blur-arrival: 2px;
+}
+
+/* A newly-created conversational object settling into the timeline. */
+.motion-enter-conversation {
+  animation: motion-enter-conversation var(--motion-duration-arrival)
+    var(--motion-ease-arrival) both;
+}
+
+@keyframes motion-enter-conversation {
+  from {
+    filter: blur(var(--motion-blur-arrival));
+    opacity: 0;
+    transform: translateY(var(--motion-distance-arrival));
+  }
+
+  to {
+    filter: blur(0);
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .motion-enter-conversation {
+    animation-duration: 1ms;
+  }
+
+  @keyframes motion-enter-conversation {
+    from,
+    to {
+      filter: none;
+      opacity: 1;
+      transform: none;
+    }
+  }
+}
diff --git a/desktop/src/shared/styles/globals/motion.test.mjs b/desktop/src/shared/styles/globals/motion.test.mjs
new file mode 100644
index 0000000000..e47e9b89a8
--- /dev/null
+++ b/desktop/src/shared/styles/globals/motion.test.mjs
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+const motionCss = readFileSync(
+  new URL("./motion.css", import.meta.url),
+  "utf8",
+);
+
+test("conversation arrival uses shared motion tokens", () => {
+  assert.match(motionCss, /--motion-duration-arrival:\s*500ms/);
+  assert.match(motionCss, /--motion-ease-arrival:/);
+  assert.match(
+    motionCss,
+    /\.motion-enter-conversation\s*\{[\s\S]*var\(--motion-duration-arrival\)[\s\S]*var\(--motion-ease-arrival\)/,
+  );
+});
+
+test("conversation arrival has a reduced-motion treatment", () => {
+  assert.match(
+    motionCss,
+    /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.motion-enter-conversation/,
+  );
+});
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 08d4205094..f9ae719999 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -7272,6 +7272,7 @@ async function handleStartManagedAgent(
   agent.updated_at = now;
   agent.last_started_at = now;
   agent.last_error = null;
+  setMockPresenceStatus(agent.pubkey, "online");
   agent.log_lines.push(
     agent.backend.type === "provider"
       ? `deployed mock provider harness at ${now}`
@@ -7290,6 +7291,7 @@ async function handleStopManagedAgent(args: {
   agent.pid = null;
   agent.updated_at = now;
   agent.last_stopped_at = now;
+  setMockPresenceStatus(agent.pubkey, "offline");
   agent.log_lines.push(`stopped mock harness at ${now}`);
   syncMockRelayAgentsFromManagedAgents();
   return cloneManagedAgent(agent);
@@ -7696,6 +7698,10 @@ async function handleSendManagedAgentChannelMessage(
     channelId: string;
     content: string;
     marker?: string | null;
+    markerScope?: "agent" | "channel" | null;
+    mentionPubkeys?: string[] | null;
+    parentEventId?: string | null;
+    additionalMarkers?: string[] | null;
   },
   _config: E2eConfig | undefined,
 ): Promise<RawSendChannelMessageResponse> {
@@ -7704,25 +7710,41 @@ async function handleSendManagedAgentChannelMessage(
   if (marker) {
     const existing = getMockMessageStore(args.channelId).find(
       (event) =>
-        event.pubkey === agent.pubkey &&
+        (args.markerScope === "channel" || event.pubkey === agent.pubkey) &&
         event.tags.some((tag) => tag[0] === "client" && tag[1] === marker),
     );
     if (existing) {
       return {
         event_id: existing.id,
-        parent_event_id: null,
-        root_event_id: null,
-        depth: 0,
+        parent_event_id: args.parentEventId ?? null,
+        root_event_id: args.parentEventId ?? null,
+        depth: args.parentEventId ? 1 : 0,
         created_at: existing.created_at,
       };
     }
   }
 
   const createdAt = Math.floor(Date.now() / 1000);
+  const tags = args.parentEventId
+    ? buildReplyMessageTags(
+        args.channelId,
+        agent.pubkey,
+        args.parentEventId,
+        args.parentEventId,
+        args.mentionPubkeys ?? undefined,
+      )
+    : buildTopLevelMessageTags(
+        args.channelId,
+        args.mentionPubkeys ?? undefined,
+        agent.pubkey,
+      );
+  for (const clientMarker of [marker, ...(args.additionalMarkers ?? [])]) {
+    if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]);
+  }
   const event = createMockEvent(
     9,
     args.content.trim(),
-    [["h", args.channelId], ...(marker ? [["client", marker]] : [])],
+    tags,
     agent.pubkey,
     createdAt,
   );
@@ -7731,9 +7753,9 @@ async function handleSendManagedAgentChannelMessage(
 
   return {
     event_id: event.id,
-    parent_event_id: null,
-    root_event_id: null,
-    depth: 0,
+    parent_event_id: args.parentEventId ?? null,
+    root_event_id: args.parentEventId ?? null,
+    depth: args.parentEventId ? 1 : 0,
     created_at: createdAt,
   };
 }
@@ -9521,6 +9543,22 @@ export function maybeInstallE2eTauriMocks() {
           payload as Parameters<typeof handleSendChannelMessage>[0],
           activeConfig,
         );
+      case "has_managed_agent_channel_message_marker": {
+        const args = payload as {
+          channelId: string;
+          marker: string;
+          agentPubkey?: string | null;
+          markerScope?: "agent" | "channel" | null;
+        };
+        return getMockMessageStore(args.channelId).some(
+          (event) =>
+            (args.markerScope === "channel" ||
+              event.pubkey === args.agentPubkey) &&
+            event.tags.some(
+              (tag) => tag[0] === "client" && tag[1] === args.marker,
+            ),
+        );
+      }
       case "send_managed_agent_channel_message":
         return handleSendManagedAgentChannelMessage(
           payload as Parameters<typeof handleSendManagedAgentChannelMessage>[0],
@@ -9778,6 +9816,8 @@ export function maybeInstallE2eTauriMocks() {
         // The spec only verifies UI state, not the submitted request shape;
         // returning null mirrors the Rust submit_event success path.
         return null;
+      case "set_canvas":
+        return { ok: true, event_id: mockEventId() };
       case "get_canvas": {
         const canvasReadError = activeConfig?.mock?.canvasReadError;
         if (canvasReadError) {
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index bdb4dc4ede..39a720c923 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -246,22 +246,25 @@ async function expectWelcomePersonaMention(page: Page) {
   expect(alignment.display).toBe("inline-block");
   expect(alignment.verticalAlign).toBe("baseline");
   expect(alignment.lineHeightMatchesBanner).toBe(true);
-  await expect(banner).toContainText("Try mentioning");
+  await expect(banner).toContainText("Mention");
 }
 
-async function expectWelcomeView(page: Page) {
+async function expectPrivateWelcomeLanding(page: Page) {
   await expect(page).toHaveURL(/#\/channels\/[^/?#]+$/);
+  await expect(page.getByTestId("channel-Welcome")).toBeVisible();
+  await expect(page.getByTestId("chat-title")).toContainText("Welcome");
+}
+
+async function expectWelcomeView(page: Page) {
+  await expectPrivateWelcomeLanding(page);
   await expect(page.getByTestId("channel-general")).toBeVisible();
   await expect(page.getByTestId("channel-welcome-everyone")).toBeVisible();
-  await expect(page.getByTestId("chat-title")).toContainText(
-    "welcome-everyone",
-  );
   await expect(page.getByTestId("channel-ephemeral-Welcome")).toHaveCount(0);
   await expect(page.getByTestId("chat-ephemeral-badge")).toHaveCount(0);
   await expect(page.getByTestId("message-unread-pill")).toHaveCount(0);
   await expect(page.getByTestId("message-channel-intro")).toBeVisible();
   await expect(page.getByTestId("message-channel-intro")).toContainText(
-    "This is the beginning of the regular channel.",
+    "private welcome channel",
   );
   await expect(
     page.getByTestId("message-channel-intro").getByRole("button"),
@@ -313,10 +316,10 @@ async function expectWelcomeView(page: Page) {
   await expect(page.getByTestId("message-composer")).toBeVisible();
   await expect(page.getByTestId("welcome-composer-guide-banner")).toBeVisible();
   await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
-    "Try mentioning",
+    "Mention",
   );
   await expect(page.getByTestId("welcome-composer-guide-banner")).toContainText(
-    "to chat with an agent in this channel.",
+    "whenever you want their help.",
   );
   await expectWelcomePersonaMention(page);
   await expectWelcomeComposerBannerLayout(page);
@@ -419,7 +422,7 @@ async function getWelcomeChannelId(page: Page) {
   return (
     channels.find(
       (channel) =>
-        channel.name === "welcome-everyone" && channel.visibility === "open",
+        channel.name === "Welcome" && channel.visibility === "private",
     )?.id ?? null
   );
 }
@@ -474,19 +477,13 @@ async function expectWelcomeGuideIntro(
         return null;
       }
 
-      const [members, agents, introSearch] = await Promise.all([
+      const [members, agents] = await Promise.all([
         invokeMockCommand<{
           members: Array<{ pubkey: string; role: string; is_agent: boolean }>;
         }>(page, "get_channel_members", { channelId }),
         invokeMockCommand<
           Array<{ pubkey: string; name: string; persona_id: string | null }>
         >(page, "list_managed_agents"),
-        invokeMockCommand<{
-          hits: Array<{ pubkey: string; content: string }>;
-        }>(page, "search_messages", {
-          q: "Hi, I'm Fizz",
-          limit: 10,
-        }),
       ]);
       const fizz = agents.find(
         (agent) => agent.name === "Fizz" && agent.persona_id === "builtin:fizz",
@@ -494,12 +491,6 @@ async function expectWelcomeGuideIntro(
       const fizzMember = fizz
         ? members.members.find((member) => member.pubkey === fizz.pubkey)
         : null;
-      const intro = fizz
-        ? introSearch.hits.find((hit) => hit.pubkey === fizz.pubkey)
-        : null;
-      const fizzWelcomeHits = fizz
-        ? introSearch.hits.filter((hit) => hit.pubkey === fizz.pubkey)
-        : [];
       const profileAvatarUrl = fizz
         ? (
             await invokeMockCommand<{
@@ -513,32 +504,17 @@ async function expectWelcomeGuideIntro(
       return {
         fizzIsBot: fizzMember?.role === "bot" && fizzMember.is_agent,
         fizzPersonaId: fizz?.persona_id ?? null,
-        introContent: intro?.content ?? null,
-        introMatchesFizz: Boolean(fizz && intro?.pubkey === fizz.pubkey),
-        fizzWelcomeHitCount: fizzWelcomeHits.length,
         profileAvatarUrl,
       };
     })
     .toEqual({
       fizzIsBot: true,
       fizzPersonaId: "builtin:fizz",
-      introContent:
-        "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.",
-      introMatchesFizz: true,
-      fizzWelcomeHitCount: 1,
       profileAvatarUrl: null,
     });
 
   if (expectVisible) {
-    await expect(page.getByTestId("message-timeline")).toContainText(
-      "Hi, I'm Fizz. Welcome to Buzz.",
-    );
-    await expect(page.getByTestId("message-timeline")).toContainText(
-      "Feel free to ask me what else you can do in Buzz",
-    );
-    await expect(
-      page.getByTestId("message-timeline-day-divider"),
-    ).toBeVisible();
+    await expect(page.getByTestId("message-channel-intro")).toBeVisible();
     await expect(page.getByTestId("system-message-row")).toHaveCount(0);
   }
 }
@@ -943,7 +919,7 @@ test("avatar upload accepts a file whose server-detected MIME is an image", asyn
   await expect(page.getByTestId("onboarding-avatar-error")).toHaveCount(0);
 });
 
-test("first-run onboarding keeps the shell hidden and lands on welcome-everyone after profile setup", async ({
+test("first-run onboarding keeps the shell hidden and lands on private Welcome after profile setup", async ({
   page,
 }) => {
   await seedActiveIdentity(page, FIRST_RUN_ALICE);
@@ -974,66 +950,23 @@ async function retryToastAction(
   { command, title }: { command: string; title: string },
 ) {
   const activeToast = retryToast(page, title);
-  await expect(activeToast).toBeVisible();
   await expect(
     activeToast.getByRole("button", { name: "Retry" }),
   ).toBeVisible();
-
-  const commandCountBeforeRetry = await page.evaluate(
-    (retryCommand) =>
-      (
-        window as Window & {
-          __BUZZ_E2E_COMMANDS__?: string[];
-        }
-      ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
-        .length ?? 0,
-    command,
-  );
+  const before = await commandCount(page, command);
   await activeToast
     .getByRole("button", { name: "Retry" })
-    .evaluate((button) => (button as HTMLButtonElement).click());
-
-  await expect
-    .poll(() =>
-      page.evaluate(
-        ({ retryCommand }) =>
-          (
-            window as Window & {
-              __BUZZ_E2E_COMMANDS__?: string[];
-            }
-          ).__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === retryCommand)
-            .length ?? 0,
-        { retryCommand: command, minimum: commandCountBeforeRetry + 1 },
-      ),
-    )
-    .toBeGreaterThanOrEqual(commandCountBeforeRetry + 1);
-}
-
-async function expectRetryFailureRecreatesActionableToast(
-  page: Page,
-  { command, error, title }: { command: string; error: string; title: string },
-) {
-  const activeToast = retryToast(page, title);
-  await expect(activeToast).toContainText(error);
-
-  await retryToastAction(page, { command, title });
-
-  await expect(activeToast).toHaveCount(1);
-  await expect(activeToast).toContainText(error);
-  await expect(
-    activeToast.getByRole("button", { name: "Retry" }),
-  ).toBeVisible();
+    .dispatchEvent("click");
+  await expect.poll(() => commandCount(page, command)).toBeGreaterThan(before);
 }
 
-async function expectRetrySuccessDismissesToast(
-  page: Page,
-  { command, title }: { command: string; title: string },
-) {
-  const activeToast = retryToast(page, title);
-
-  await retryToastAction(page, { command, title });
-
-  await expect(activeToast).toHaveCount(0);
+async function commandCount(page: Page, command: string) {
+  return page.evaluate(
+    (target) =>
+      window.__BUZZ_E2E_COMMANDS__?.filter((entry) => entry === target)
+        .length ?? 0,
+    command,
+  );
 }
 
 test("failed starter channel retries recreate actionable toasts", async ({
@@ -1044,7 +977,7 @@ test("failed starter channel retries recreate actionable toasts", async ({
   await installMockBridge(
     page,
     {
-      ensureStarterChannelsErrors: [starterError, starterError],
+      ensureStarterChannelsErrors: [starterError, starterError, starterError],
     },
     { skipOnboardingSeed: true },
   );
@@ -1053,11 +986,18 @@ test("failed starter channel retries recreate actionable toasts", async ({
   await page.getByTestId("onboarding-display-name").fill("Morty QA");
   await completeProfileOnboarding(page);
 
-  await expectRetryFailureRecreatesActionableToast(page, {
+  await expectPrivateWelcomeLanding(page);
+  const title = "Couldn't set up starter channels";
+  const activeToast = retryToast(page, title);
+  await expect(activeToast).toContainText(starterError);
+  await retryToastAction(page, {
     command: "ensure_starter_channels",
-    error: starterError,
-    title: "Couldn't set up starter channels",
+    title,
   });
+  await expect(activeToast).toContainText(starterError);
+  await expect(
+    activeToast.getByRole("button", { name: "Retry" }),
+  ).toBeVisible();
 });
 
 test("successful starter channel retry clears its actionable toast", async ({
@@ -1068,7 +1008,7 @@ test("successful starter channel retry clears its actionable toast", async ({
   await installMockBridge(
     page,
     {
-      ensureStarterChannelsErrors: [starterError],
+      ensureStarterChannelsErrors: [starterError, starterError],
     },
     { skipOnboardingSeed: true },
   );
@@ -1077,21 +1017,51 @@ test("successful starter channel retry clears its actionable toast", async ({
   await page.getByTestId("onboarding-display-name").fill("Morty QA");
   await completeProfileOnboarding(page);
 
-  await expectRetrySuccessDismissesToast(page, {
+  const title = "Couldn't set up starter channels";
+  await expect(retryToast(page, title)).toContainText(starterError);
+  await retryToastAction(page, {
     command: "ensure_starter_channels",
-    title: "Couldn't set up starter channels",
+    title,
   });
+  await expect(retryToast(page, title)).toHaveCount(0);
   await expectWelcomeView(page);
-  await expect(page.getByTestId("channel-general")).toBeVisible();
+  await expectStarterChannels(page);
 });
 
-test("first-run onboarding shows setup loading until starter channel bootstrap completes", async ({
+test("first-run onboarding posts the live Fizz kickoff", async ({ page }) => {
+  await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+  await installMockBridge(
+    page,
+    {
+      globalAgentConfig: {
+        env_vars: { OPENAI_API_KEY: "e2e-placeholder" },
+        provider: "openai",
+        model: "gpt-5.5",
+      },
+    },
+    { skipOnboardingSeed: true },
+  );
+  await page.goto("/");
+
+  await page.getByTestId("onboarding-display-name").fill("Morty QA");
+  await completeProfileOnboarding(page);
+
+  await expectPrivateWelcomeLanding(page);
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    "Hi, I'm Fizz. Welcome to Buzz.",
+  );
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    "Honey and Bumble, introduce yourselves",
+  );
+});
+
+test("first-run onboarding lands before Welcome team bootstrap completes", async ({
   page,
 }) => {
   await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
   await installMockBridge(
     page,
-    { createManagedAgentDelayMs: 9_000 },
+    { createManagedAgentDelayMs: 1_000 },
     { skipOnboardingSeed: true },
   );
   await page.goto("/");
@@ -1099,69 +1069,13 @@ test("first-run onboarding shows setup loading until starter channel bootstrap c
   await page.getByTestId("onboarding-display-name").fill("Morty QA");
   await completeProfileOnboarding(page);
 
-  const loadingGate = page.getByTestId("app-loading-gate");
-  await expect(page.getByTestId("onboarding-gate")).toHaveCount(0);
-  await expect(loadingGate).toBeVisible();
-  await expect(loadingGate).toContainText("Setting up your community...");
-
-  // The boot gate is the theme-adaptive grainient with the flapping Buzz bee
-  // as its hero. The mark must paint complete on the FIRST frame — a blank
-  // gate reads as "nothing is loading" — so nothing about it may depend on
-  // SMIL/scripted animation (<animate> count stays 0); the wing flap is pure
-  // CSS on HTML-level wing layers so it keeps running on the compositor even
-  // while boot work hogs the main thread.
-  await expect(
-    loadingGate.getByTestId("setup-grainient-background"),
-  ).toBeVisible();
-  const mark = loadingGate.locator(".buzz-mark");
-  await expect(mark).toBeVisible();
-  const gateTreatment = await loadingGate.evaluate((element) => {
-    const markElement = element.querySelector(".buzz-mark");
-    const markSvgs = markElement
-      ? Array.from(markElement.querySelectorAll("svg"))
-      : [];
-    const wing = element.querySelector(".bee-wing");
-    const wingStyles =
-      wing instanceof SVGElement ? window.getComputedStyle(wing) : null;
-    const wash = element.querySelector(".buzz-setup-grainient__wash");
-    const washStyles =
-      wash instanceof HTMLElement ? window.getComputedStyle(wash) : null;
-    return {
-      animateElementCount: element.querySelectorAll("animate").length,
-      // The document must match the OS scheme before the gate mounts (inline
-      // <style> + script in index.html; with no cached theme the first-launch
-      // default is Buzz following the OS scheme — white here because
-      // Playwright's default color scheme is light).
-      documentBackgroundColor: window.getComputedStyle(document.documentElement)
-        .backgroundColor,
-      grainientAnimation: washStyles?.animationName,
-      grainientUsesRadialGradients:
-        washStyles?.backgroundImage.includes("radial-gradient"),
-      markSvgsUseCurrentColor:
-        markSvgs.length > 0 &&
-        markSvgs.every((svg) => svg.getAttribute("fill") === "currentColor"),
-      wingFlapAnimation: wingStyles?.animationName,
-      wingFlapRunning: wingStyles?.animationPlayState,
-    };
-  });
-  expect(gateTreatment).toEqual({
-    animateElementCount: 0,
-    documentBackgroundColor: "rgb(255, 255, 255)",
-    grainientAnimation: "buzz-grainient-orbit",
-    grainientUsesRadialGradients: true,
-    markSvgsUseCurrentColor: true,
-    wingFlapAnimation: "bee-wing-left-flap",
-    wingFlapRunning: "running",
-  });
-  await expect(loadingGate).not.toHaveClass(/buzz-onboarding-neutral-theme/);
-  await expectShellHidden(page);
-  await page.waitForTimeout(250);
-  await expect(loadingGate).toBeVisible();
-  await expect(mark).toBeVisible();
-
-  await expectWelcomeView(page);
-  await expectStarterChannels(page);
-  await expectWelcomeGuideIntro(page);
+  await expectPrivateWelcomeLanding(page);
+  await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
+  await expect(page.getByTestId("message-timeline")).toContainText(
+    "Hi, I'm Fizz. Welcome to Buzz.",
+  );
+  await page.waitForTimeout(1_500);
+  expect(await commandCount(page, "create_managed_agent")).toBe(3);
 });
 
 test("existing relay profile with display name auto-skips onboarding without localStorage", async ({
