diff --git a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs
--- a/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs
+++ b/crates/api_clients/artcraft/artcraft_router/src/api/router_provider.rs
@@ -11,4 +11,5 @@ pub enum RouterProvider {
   GrokApi,
   KinoviWeb,
   WorldLabs,
+  LocalLlm,
 }
diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs
--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_audio_cost_command.rs
@@ -4,6 +4,13 @@ use tauri::AppHandle;
 
 #[tauri::command]
 pub async fn estimate_audio_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
   if !request.uses_artcraft() {
     return Err(local_error("No direct provider adapter is configured for this modality."));
   }
diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs
--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_image_cost_command.rs
@@ -19,6 +19,13 @@ impl SerializeMarker for EstimateImageCostResponse {}
 
 #[tauri::command]
 pub async fn estimate_image_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
   if matches!(request.provider, Some(GenerationProvider::Midjourney)) {
     if !matches!(request.model(), Some("midjourney" | "midjourney_7" | "midjourney_7_niji" | "midjourney_8")) {
       return Err(CommandErrorResponseWrapper {
diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs
--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_mesh_cost_command.rs
@@ -4,6 +4,13 @@ use tauri::AppHandle;
 
 #[tauri::command]
 pub async fn estimate_mesh_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
   if !request.uses_artcraft() {
     return Err(local_error("No direct provider adapter is configured for this modality."));
   }
diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs
--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_splat_cost_command.rs
@@ -17,6 +17,13 @@ impl SerializeMarker for EstimateSplatCostResponse {}
 
 #[tauri::command]
 pub async fn estimate_splat_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
   if request.uses_artcraft() {
     return omni::estimate(request, Modality::Splat, &app).await;
   }
diff --git a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs
--- a/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/cost_estimate/estimate_video_cost_command.rs
@@ -18,6 +18,13 @@ impl SerializeMarker for EstimateVideoCostResponse {}
 
 #[tauri::command]
 pub async fn estimate_video_cost_command(request: OmniRequest, app: AppHandle) -> OmniResult {
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
   if request.uses_artcraft() {
     return omni::estimate(request, Modality::Video, &app).await;
   }
diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs
--- a/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs
+++ b/crates/desktop/artcraft/src/core/commands/enqueue/common/maybe_notify_frontend_of_billing_errors.rs
@@ -28,12 +28,12 @@ fn billing_error(
   reason: &BillingIssueReason,
 ) {
   let provider = match reason.provider {
-    BillingProvider::Artcraft => GenerationProvider::Artcraft,
-    BillingProvider::Fal => GenerationProvider::Fal,
-    BillingProvider::Kinovi => GenerationProvider::Artcraft, // NB: We don't support Kinovi yet.
-    BillingProvider::Midjourney => GenerationProvider::Midjourney,
-    BillingProvider::Sora => GenerationProvider::Sora,
-  };
+      BillingProvider::Artcraft => GenerationProvider::Artcraft,
+      BillingProvider::Fal => GenerationProvider::Fal,
+      BillingProvider::Kinovi => GenerationProvider::Artcraft, // NB: We don't support Kinovi yet.
+      BillingProvider::Midjourney => GenerationProvider::Midjourney,
+      BillingProvider::Sora => GenerationProvider::Sora,
+    };
   warn!("Billing issue with: {:?}", provider);
   ShowProviderBillingModalEvent::send_for_provider(provider, app);
 }
diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs
--- a/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs
+++ b/crates/desktop/artcraft/src/core/commands/enqueue/generate_error.rs
@@ -84,6 +84,7 @@ pub enum MissingCredentialsReason {
   NeedsSoraCredentials,
   NeedsStorytellerCredentials,
   NeedsWorldLabsCredentials,
+  NeedsLocalLlmConfig,
 }
 
 #[derive(Debug)]
@@ -163,6 +164,10 @@ impl GenerateError {
   pub fn needs_worldlabs_credentials() -> Self {
     Self::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials)
   }
+
+  pub fn needs_local_llm_config() -> Self {
+    Self::MissingCredentials(MissingCredentialsReason::NeedsLocalLlmConfig)
+  }
 }
 
 impl From<AnyhowError> for GenerateError {
diff --git a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs
--- a/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs
+++ b/crates/desktop/artcraft/src/core/commands/enqueue/task_enqueue_success.rs
@@ -49,6 +49,7 @@ impl TaskEnqueueSuccess{
       GenerationProvider::Picsart => GenerationServiceProvider::Picsart,
       GenerationProvider::Pixverse => GenerationServiceProvider::Pixverse,
       GenerationProvider::Runway => GenerationServiceProvider::Runway,
+      GenerationProvider::LocalLlm => GenerationServiceProvider::LocalLlm,
     }
   }
   
diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs
--- a/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs
+++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_image_request_to_artcraft_prompt.rs
@@ -73,8 +73,9 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid
     RouterProvider::Artcraft => GenerationProvider::Artcraft,
     RouterProvider::Midjourney => GenerationProvider::Midjourney,
     RouterProvider::Fal => GenerationProvider::Fal,
+    RouterProvider::LocalLlm => GenerationProvider::LocalLlm,
     // Unused providers -> ArtCraft
-    RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
+    RouterProvider::KinoviWeb => GenerationProvider::Artcraft,
     RouterProvider::GmiCloud => GenerationProvider::Artcraft,
     RouterProvider::GrokApi => GenerationProvider::Artcraft,
     RouterProvider::WorldLabs => GenerationProvider::Artcraft,
diff --git a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs
--- a/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs
+++ b/crates/desktop/artcraft/src/core/commands/generate/common/router_video_request_to_artcraft_prompt.rs
@@ -98,8 +98,9 @@ fn provider_to_generation_provider(provider: RouterProvider) -> GenerationProvid
     RouterProvider::Artcraft => GenerationProvider::Artcraft,
     RouterProvider::Midjourney => GenerationProvider::Midjourney,
     RouterProvider::Fal => GenerationProvider::Fal,
+    RouterProvider::LocalLlm => GenerationProvider::LocalLlm,
     // Unused providers -> ArtCraft
-    RouterProvider::KinoviWeb => GenerationProvider::Artcraft ,
+    RouterProvider::KinoviWeb => GenerationProvider::Artcraft,
     RouterProvider::GmiCloud => GenerationProvider::Artcraft,
     RouterProvider::GrokApi => GenerationProvider::Artcraft,
     RouterProvider::WorldLabs => GenerationProvider::Artcraft,
diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs
--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs
+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/generate_image_command.rs
@@ -8,6 +8,7 @@ use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCrede
 use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
 use crate::core::commands::generate::generate_image::providers::artcraft::handle_artcraft;
 use crate::core::commands::generate::generate_image::providers::artcraft_router::handle_router::handle_router;
+use crate::core::providers::local_llm::local_llm_handler::handle_local_llm;
 use crate::core::commands::generate::generate_image::tauri_generate_image_request::{
   TauriGenerateImageErrorType, TauriGenerateImageRequest, TauriGenerateImageResponse,
 };
@@ -59,6 +60,13 @@ async fn generate_image_native(
     GenerationProvider::Midjourney => {
       handle_midjourney(&request, &app_env_configs, &app.state(), &storyteller_creds_manager).await
     }
+    GenerationProvider::LocalLlm => {
+      handle_local_llm(
+        &request,
+        &app_env_configs,
+        &credential_cache,
+      ).await
+    }
     // All other providers go through the router.
     other => {
       handle_router(
diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs
--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs
+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft_router/handle_router.rs
@@ -50,6 +50,7 @@ fn map_provider_to_credential_key(
 ) -> Result<ProviderCredentialKey, GenerateError> {
   match provider {
     GenerationProvider::Fal => Ok(ProviderCredentialKey::FalApiKey),
+    GenerationProvider::LocalLlm => Err(GenerateError::NoProviderAvailable),
     _ => Err(GenerateError::NotYetImplemented(
       format!("Provider {:?} does not have a mapped credential key yet", provider),
     )),
@@ -63,6 +64,7 @@ fn map_provider_to_missing_credentials_error(provider: GenerationProvider) -> Ge
     GenerationProvider::Midjourney => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsMidjourneyCredentials),
     GenerationProvider::Sora => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsSoraCredentials),
     GenerationProvider::WorldLabs => GenerateError::MissingCredentials(MissingCredentialsReason::NeedsWorldLabsCredentials),
+    GenerationProvider::LocalLlm => GenerateError::NoProviderAvailable,
     _ => GenerateError::NoProviderAvailable,
   }
 }
diff --git a/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs
--- a/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs
+++ b/crates/desktop/artcraft/src/core/commands/generate/omni/dispatch.rs
@@ -14,6 +14,8 @@ use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
 use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
 use crate::core::state::artcraft_usage_tracker::artcraft_usage_type::{ArtcraftUsagePage, ArtcraftUsageType};
 use crate::core::state::task_database::TaskDatabase;
+use crate::core::providers::local_llm::local_llm_handler::handle_local_llm;
+use crate::core::providers::credentials::provider_credential_loading_cache::ProviderCredentialLoadingCache;
 use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
 use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
 use artcraft_client::error::api_error::ApiError;
@@ -38,6 +40,21 @@ pub type OmniResult = Response<OmniResponse, String, ()>;
 
 pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {
   let configs = app.state::<AppEnvConfigs>();
+
+  // LocalLlm does not require storyteller credentials — it runs locally.
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    let credential_cache = app.state::<ProviderCredentialLoadingCache>();
+    let tauri_request: crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest =
+      decode_native(request.clone())?;
+    return match handle_local_llm(&tauri_request, configs.inner(), credential_cache.inner()).await {
+      Ok(success) => Ok(OmniResponse(serde_json::to_value(success).map_err(local_error)?).into()),
+      Err(err) => {
+        notify_frontend_of_errors(app, &err).await;
+        Err(local_error(err))
+      }
+    };
+  }
+
   let manager = app.state::<StorytellerCredentialManager>();
   let maybe_creds = manager.get_credentials().map_err(local_error)?;
   let creds = match maybe_creds {
@@ -88,6 +105,16 @@ pub async fn generate(request: OmniRequest, modality: Modality, app: &AppHandle)
 
 pub async fn estimate(request: OmniRequest, modality: Modality, app: &AppHandle) -> OmniResult {
   let configs = app.state::<AppEnvConfigs>();
+
+  // LocalLlm does not require storyteller credentials — it runs locally.
+  if matches!(request.provider, Some(GenerationProvider::LocalLlm)) {
+    return Ok(OmniResponse(serde_json::json!({
+      "success": true, "cost_in_credits": 0, "cost_in_usd_cents": null,
+      "is_free": true, "is_unlimited": false, "is_rate_limited": false,
+      "has_watermark": false,
+    })).into());
+  }
+
   let manager = app.state::<StorytellerCredentialManager>();
   let creds = manager.get_credentials().map_err(local_error)?;
   let mut fields = request.api_fields(modality);
diff --git a/crates/desktop/artcraft/src/core/events/generation_events/common.rs b/crates/desktop/artcraft/src/core/events/generation_events/common.rs
--- a/crates/desktop/artcraft/src/core/events/generation_events/common.rs
+++ b/crates/desktop/artcraft/src/core/events/generation_events/common.rs
@@ -17,6 +17,7 @@ pub enum GenerationServiceProvider {
   Picsart,
   Pixverse,
   Runway,
+  LocalLlm,
 }
 
 #[derive(Debug, Clone, Serialize)]
diff --git a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs
--- a/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs
+++ b/crates/desktop/artcraft/src/core/providers/credentials/provider_credential_key.rs
@@ -16,6 +16,10 @@ pub enum ProviderCredentialKey {
   HiggsfieldWebLogin,
   MidjourneyLogin,
   RunwayWebLogin,
+
+  // ========== LOCAL LLM ==========
+
+  LocalLlmConfig,
 }
 
 
diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs
new file mode 100644
--- /dev/null
+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_commands.rs
@@ -0,0 +1,47 @@
+use crate::core::providers::local_llm::local_llm_config::LocalLlmConfig;
+use crate::core::providers::local_llm::local_llm_handler::{get_config_or_default, list_models, save_config};
+use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
+use log::info;
+use tauri::State;
+
+/// Save the local LLM configuration to disk
+#[tauri::command]
+pub async fn save_local_llm_config_command(
+  config: LocalLlmConfig,
+  app_env_configs: State<'_, AppEnvConfigs>,
+) -> Result<(), String> {
+  info!("save_local_llm_config_command called");
+  save_config(&app_env_configs.app_data_root, &config)
+    .map_err(|e| format!("Failed to save local LLM config: {:?}", e))
+}
+
+/// Get the current local LLM configuration (or default if none exists)
+#[tauri::command]
+pub async fn get_local_llm_config_command(
+  app_env_configs: State<'_, AppEnvConfigs>,
+) -> Result<LocalLlmConfig, String> {
+  info!("get_local_llm_config_command called");
+  Ok(get_config_or_default(&app_env_configs.app_data_root))
+}
+
+/// Test the connection to the local LLM backend and return available models
+#[tauri::command]
+pub async fn test_local_llm_connection_command(
+  app_env_configs: State<'_, AppEnvConfigs>,
+) -> Result<Vec<String>, String> {
+  info!("test_local_llm_connection_command called");
+  let config = get_config_or_default(&app_env_configs.app_data_root);
+  list_models(&config).await.map_err(|e| format!("{:?}", e))
+}
+
+/// Check if the local LLM backend is reachable
+#[tauri::command]
+pub async fn check_local_llm_health_command(
+  app_env_configs: State<'_, AppEnvConfigs>,
+) -> Result<bool, String> {
+  let config = get_config_or_default(&app_env_configs.app_data_root);
+  match list_models(&config).await {
+    Ok(_) => Ok(true),
+    Err(_) => Ok(false),
+  }
+}
diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs
new file mode 100644
--- /dev/null
+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_config.rs
@@ -0,0 +1,89 @@
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::Path;
+
+/// Configuration for a local LLM backend (Ollama, llama.cpp, LM Studio, etc.)
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum LocalLlmBackend {
+  Ollama,
+  LlamaCpp,
+  LmStudio,
+  Custom,
+}
+
+impl LocalLlmBackend {
+  pub fn default_port(&self) -> u16 {
+    match self {
+      Self::Ollama => 11434,
+      Self::LlamaCpp => 8080,
+      Self::LmStudio => 1234,
+      Self::Custom => 11434,
+    }
+  }
+
+  pub fn default_base_url(&self) -> String {
+    format!("http://localhost:{}", self.default_port())
+  }
+}
+
+/// Configuration for local LLM generation
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct LocalLlmConfig {
+  pub backend: LocalLlmBackend,
+  pub base_url: String,
+  pub model: String,
+  pub api_key: Option<String>,
+  pub temperature: f32,
+  pub max_tokens: u32,
+  pub system_prompt: Option<String>,
+  pub timeout_seconds: u64,
+}
+
+impl Default for LocalLlmConfig {
+  fn default() -> Self {
+    Self {
+      backend: LocalLlmBackend::Ollama,
+      base_url: LocalLlmBackend::Ollama.default_base_url(),
+      model: "llama3.2".to_string(),
+      api_key: None,
+      temperature: 0.7,
+      max_tokens: 2048,
+      system_prompt: None,
+      timeout_seconds: 120,
+    }
+  }
+}
+
+impl LocalLlmConfig {
+  pub fn load_from_file(path: &Path) -> anyhow::Result<Self> {
+    let contents = fs::read_to_string(path)?;
+    let config: LocalLlmConfig = serde_json::from_str(&contents)?;
+    Ok(config)
+  }
+
+  pub fn save_to_file(&self, path: &Path) -> anyhow::Result<()> {
+    let contents = serde_json::to_string_pretty(self)?;
+    if let Some(parent) = path.parent() {
+      fs::create_dir_all(parent)?;
+    }
+    fs::write(path, contents)?;
+    Ok(())
+  }
+
+  pub fn get_chat_completion_url(&self) -> String {
+    match self.backend {
+      LocalLlmBackend::Ollama => format!("{}/api/chat", self.base_url),
+      LocalLlmBackend::LlamaCpp => format!("{}/v1/chat/completions", self.base_url),
+      LocalLlmBackend::LmStudio => format!("{}/v1/chat/completions", self.base_url),
+      LocalLlmBackend::Custom => format!("{}/v1/chat/completions", self.base_url),
+    }
+  }
+
+  pub fn get_models_url(&self) -> String {
+    match self.backend {
+      LocalLlmBackend::Ollama => format!("{}/api/tags", self.base_url),
+      _ => format!("{}/v1/models", self.base_url),
+    }
+  }
+}
diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs
new file mode 100644
--- /dev/null
+++ b/crates/desktop/artcraft/src/core/providers/local_llm/local_llm_handler.rs
@@ -0,0 +1,292 @@
+use log::{error, info, warn};
+use serde_json::{json, Value};
+
+use crate::core::commands::enqueue::generate_error::{GenerateError, MissingCredentialsReason};
+use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;
+use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;
+use crate::core::providers::local_llm::local_llm_config::{LocalLlmConfig, LocalLlmBackend};
+use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
+use crate::core::state::data_dir::app_data_root::AppDataRoot;
+use enums::common::generation_provider::GenerationProvider;
+use enums::tauri::tasks::task_type::TaskType;
+use uuid_utils::uuid::generate_random_uuid;
+
+/// Get the path to the local LLM config file
+fn get_config_path(app_data_root: &AppDataRoot) -> std::path::PathBuf {
+  app_data_root.credentials_dir().path().join("local_llm.config.json")
+}
+
+/// Load the local LLM config from disk
+fn load_config(app_data_root: &AppDataRoot) -> Result<LocalLlmConfig, GenerateError> {
+  let path = get_config_path(app_data_root);
+  if !path.exists() {
+    return Err(GenerateError::MissingCredentials(
+      MissingCredentialsReason::NeedsLocalLlmConfig,
+    ));
+  }
+  LocalLlmConfig::load_from_file(&path)
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!("Failed to load local LLM config: {:?}", e)))
+}
+
+/// Check if the local LLM backend is reachable
+async fn check_backend_health(config: &LocalLlmConfig) -> Result<(), GenerateError> {
+  let client = reqwest::Client::new();
+  let url = config.get_models_url();
+
+  let result = tokio::time::timeout(
+    std::time::Duration::from_secs(5),
+    client.get(&url).send(),
+  )
+  .await;
+
+  match result {
+    Ok(Ok(response)) => {
+      if response.status().is_success() {
+        Ok(())
+      } else {
+        Err(GenerateError::AnyhowError(anyhow::anyhow!(
+          "Local LLM backend returned status: {}",
+          response.status()
+        )))
+      }
+    }
+    Ok(Err(e)) => Err(GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to connect to local LLM backend at {}: {}",
+      config.base_url,
+      e
+    ))),
+    Err(_) => Err(GenerateError::AnyhowError(anyhow::anyhow!(
+      "Timeout connecting to local LLM backend at {}",
+      config.base_url
+    ))),
+  }
+}
+
+/// Send a chat completion request to the local LLM backend
+async fn send_chat_request(
+  config: &LocalLlmConfig,
+  prompt: &str,
+) -> Result<String, GenerateError> {
+  let client = reqwest::Client::new();
+  let url = config.get_chat_completion_url();
+
+  let mut request_body = json!({
+    "model": config.model,
+    "messages": build_messages(config, prompt),
+    "temperature": config.temperature,
+    "max_tokens": config.max_tokens,
+    "stream": false,
+  });
+
+  // Ollama uses "num_predict" instead of "max_tokens"
+  if matches!(config.backend, LocalLlmBackend::Ollama) {
+    request_body["max_tokens"] = json!(null);
+    request_body["num_predict"] = json!(config.max_tokens);
+    request_body["stream"] = json!(false);
+  }
+
+  let mut request = client
+    .post(&url)
+    .json(&request_body)
+    .timeout(std::time::Duration::from_secs(config.timeout_seconds));
+
+  if let Some(api_key) = &config.api_key {
+    request = request.header("Authorization", format!("Bearer {}", api_key));
+  }
+
+  let response = request
+    .send()
+    .await
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to send request to local LLM: {}",
+      e
+    )))?;
+
+  if !response.status().is_success() {
+    let status = response.status();
+    let body = response.text().await.unwrap_or_default();
+    return Err(GenerateError::AnyhowError(anyhow::anyhow!(
+      "Local LLM backend returned error {}: {}",
+      status,
+      body
+    )));
+  }
+
+  let response_json: Value = response
+    .json()
+    .await
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to parse local LLM response: {}",
+      e
+    )))?;
+
+  // Extract the response text based on backend type
+  let response_text = match config.backend {
+    LocalLlmBackend::Ollama => {
+      response_json
+        .get("message")
+        .and_then(|m| m.get("content"))
+        .and_then(|c| c.as_str())
+        .map(|s| s.to_string())
+    }
+    _ => {
+      // OpenAI-compatible format
+      response_json
+        .get("choices")
+        .and_then(|c| c.as_array())
+        .and_then(|arr| arr.first())
+        .and_then(|choice| choice.get("message"))
+        .and_then(|m| m.get("content"))
+        .and_then(|c| c.as_str())
+        .map(|s| s.to_string())
+    }
+  };
+
+  response_text.ok_or_else(|| {
+    GenerateError::AnyhowError(anyhow::anyhow!(
+      "Local LLM response did not contain expected content field"
+    ))
+  })
+}
+
+/// Build the messages array for the chat completion request
+fn build_messages(config: &LocalLlmConfig, prompt: &str) -> Vec<Value> {
+  let mut messages = Vec::new();
+
+  if let Some(system_prompt) = &config.system_prompt {
+    messages.push(json!({
+      "role": "system",
+      "content": system_prompt,
+    }));
+  }
+
+  messages.push(json!({
+    "role": "user",
+    "content": prompt,
+  }));
+
+  messages
+}
+
+/// Handle a local LLM generation request
+pub async fn handle_local_llm(
+  request: &TauriGenerateImageRequest,
+  app_env_configs: &AppEnvConfigs,
+  credential_cache: &ProviderCredentialLoadingCache,
+) -> Result<TaskEnqueueSuccess, GenerateError> {
+  info!("handle_local_llm called");
+
+  // Load the local LLM config
+  let config = load_config(&app_env_configs.app_data_root)?;
+
+  // Check backend health
+  check_backend_health(&config).await?;
+
+  // Extract the prompt from the request
+  let prompt = request.prompt.clone().unwrap_or_default();
+  if prompt.is_empty() {
+    return Err(GenerateError::BadInput(
+      crate::core::commands::enqueue::generate_error::BadInputReason::NoModelSpecified,
+    ));
+  }
+
+  // Send the request to the local LLM
+  let response_text = send_chat_request(&config, &prompt).await?;
+
+  info!("Local LLM response received, length: {}", response_text.len());
+
+  // Create a task success result
+  let job_token = generate_random_uuid();
+  let success = TaskEnqueueSuccess {
+    task_type: crate::core::commands::enqueue::task_enqueue_success::TaskType::ImageGeneration,
+    model: Some(crate::core::events::generation_events::common::GenerationModel::Unknown(
+      config.model.clone(),
+    )),
+    provider: GenerationProvider::LocalLlm,
+    provider_job_id: Some(job_token),
+    maybe_queue_status_url: None,
+    maybe_queue_response_url: None,
+    maybe_prompt_token: None,
+  };
+
+  Ok(success)
+}
+
+/// Save a local LLM config to disk
+pub fn save_config(
+  app_data_root: &AppDataRoot,
+  config: &LocalLlmConfig,
+) -> Result<(), GenerateError> {
+  let path = get_config_path(app_data_root);
+  config
+    .save_to_file(&path)
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!("Failed to save local LLM config: {:?}", e)))
+}
+
+/// Get the current local LLM config (or default if none exists)
+pub fn get_config_or_default(app_data_root: &AppDataRoot) -> LocalLlmConfig {
+  let path = get_config_path(app_data_root);
+  if path.exists() {
+    LocalLlmConfig::load_from_file(&path).unwrap_or_default()
+  } else {
+    LocalLlmConfig::default()
+  }
+}
+
+/// List available models from the local LLM backend
+pub async fn list_models(config: &LocalLlmConfig) -> Result<Vec<String>, GenerateError> {
+  let client = reqwest::Client::new();
+  let url = config.get_models_url();
+
+  let response = client
+    .get(&url)
+    .timeout(std::time::Duration::from_secs(10))
+    .send()
+    .await
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to list models from local LLM: {}",
+      e
+    )))?;
+
+  if !response.status().is_success() {
+    return Err(GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to list models: status {}",
+      response.status()
+    )));
+  }
+
+  let response_json: Value = response
+    .json()
+    .await
+    .map_err(|e| GenerateError::AnyhowError(anyhow::anyhow!(
+      "Failed to parse models response: {}",
+      e
+    )))?;
+
+  let models = match config.backend {
+    LocalLlmBackend::Ollama => {
+      response_json
+        .get("models")
+        .and_then(|m| m.as_array())
+        .map(|arr| {
+          arr.iter()
+            .filter_map(|m| m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()))
+            .collect()
+        })
+        .unwrap_or_default()
+    }
+    _ => {
+      response_json
+        .get("data")
+        .and_then(|d| d.as_array())
+        .map(|arr| {
+          arr.iter()
+            .filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()))
+            .collect()
+        })
+        .unwrap_or_default()
+    }
+  };
+
+  Ok(models)
+}
diff --git a/crates/desktop/artcraft/src/core/providers/local_llm/mod.rs b/crates/desktop/artcraft/src/core/providers/local_llm/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/desktop/artcraft/src/core/providers/local_llm/mod.rs
@@ -0,0 +1,3 @@
+pub mod local_llm_config;
+pub mod local_llm_handler;
+pub mod local_llm_commands;
diff --git a/crates/desktop/artcraft/src/core/providers/mod.rs b/crates/desktop/artcraft/src/core/providers/mod.rs
--- a/crates/desktop/artcraft/src/core/providers/mod.rs
+++ b/crates/desktop/artcraft/src/core/providers/mod.rs
@@ -1 +1,2 @@
-pub mod credentials;
\ No newline at end of file
+pub mod credentials;
+pub mod local_llm;
\ No newline at end of file
diff --git a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs
--- a/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs
+++ b/crates/desktop/artcraft/src/core/state/app_env_configs/app_env_configs.rs
@@ -6,6 +6,7 @@ use artcraft_client::utils::api_host::ApiHost;
 #[derive(Clone)]
 pub struct AppEnvConfigs {
   pub storyteller_host: ApiHost,
+  pub app_data_root: AppDataRoot,
 }
 
 impl AppEnvConfigs {
@@ -32,6 +33,7 @@ impl AppEnvConfigs {
 
     Ok(Self {
       storyteller_host: storyteller_api,
+      app_data_root: root.clone(),
     })
   }
 }
diff --git a/crates/desktop/artcraft/src/lib.rs b/crates/desktop/artcraft/src/lib.rs
--- a/crates/desktop/artcraft/src/lib.rs
+++ b/crates/desktop/artcraft/src/lib.rs
@@ -84,6 +84,12 @@ use crate::services::worldlabs::commands::worldlabs_clear_credentials_command::w
 use crate::services::worldlabs::commands::worldlabs_get_credential_info_command::worldlabs_get_credential_info_command;
 use crate::services::worldlabs::commands::worldlabs_open_login_command::worldlabs_open_login_command;
 use crate::services::worldlabs::commands::worldlabs_receive_bearer_command::worldlabs_receive_bearer_command;
+use crate::core::providers::local_llm::local_llm_commands::{
+  check_local_llm_health_command,
+  get_local_llm_config_command,
+  save_local_llm_config_command,
+  test_local_llm_connection_command,
+};
 use crate::services::worldlabs::state::worldlabs_bearer_bridge::WorldlabsBearerBridge;
 use crate::services::worldlabs::state::worldlabs_credential_manager::WorldlabsCredentialManager;
 use log::error;
@@ -288,6 +294,10 @@ pub fn run() {
     worldlabs_get_credential_info_command,
     worldlabs_open_login_command,
     worldlabs_receive_bearer_command,
+    save_local_llm_config_command,
+    get_local_llm_config_command,
+    test_local_llm_connection_command,
+    check_local_llm_health_command,
   ]);
 
   builder.run(tauri::generate_context!("tauri.conf.json"))
diff --git a/crates/schema/public/enums/src/common/generation_provider.rs b/crates/schema/public/enums/src/common/generation_provider.rs
--- a/crates/schema/public/enums/src/common/generation_provider.rs
+++ b/crates/schema/public/enums/src/common/generation_provider.rs
@@ -34,6 +34,7 @@ pub enum GenerationProvider {
   Picsart,
   Pixverse,
   Runway,
+  LocalLlm,
 }
 
 impl_enum_display_and_debug_using_to_str!(GenerationProvider);
@@ -62,6 +63,7 @@ impl GenerationProvider {
       Self::Picsart => "picsart",
       Self::Pixverse => "pixverse",
       Self::Runway => "runway",
+      Self::LocalLlm => "local_llm",
     }
   }
 
@@ -81,6 +83,7 @@ impl GenerationProvider {
       "picsart" => Ok(Self::Picsart),
       "pixverse" => Ok(Self::Pixverse),
       "runway" => Ok(Self::Runway),
+      "local_llm" => Ok(Self::LocalLlm),
       _ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
     }
   }
@@ -103,6 +106,7 @@ impl GenerationProvider {
       Self::Picsart,
       Self::Pixverse,
       Self::Runway,
+      Self::LocalLlm,
     ])
   }
 }
@@ -132,6 +136,7 @@ mod tests {
       assert_serialization(GenerationProvider::Picsart, "picsart");
       assert_serialization(GenerationProvider::Pixverse, "pixverse");
       assert_serialization(GenerationProvider::Runway, "runway");
+      assert_serialization(GenerationProvider::LocalLlm, "local_llm");
     }
 
     #[test]
@@ -150,6 +155,7 @@ mod tests {
       assert_eq!(GenerationProvider::Picsart.to_str(), "picsart");
       assert_eq!(GenerationProvider::Pixverse.to_str(), "pixverse");
       assert_eq!(GenerationProvider::Runway.to_str(), "runway");
+      assert_eq!(GenerationProvider::LocalLlm.to_str(), "local_llm");
     }
 
     #[test]
@@ -168,6 +174,7 @@ mod tests {
       assert_eq!(GenerationProvider::from_str("picsart").unwrap(), GenerationProvider::Picsart);
       assert_eq!(GenerationProvider::from_str("pixverse").unwrap(), GenerationProvider::Pixverse);
       assert_eq!(GenerationProvider::from_str("runway").unwrap(), GenerationProvider::Runway);
+      assert_eq!(GenerationProvider::from_str("local_llm").unwrap(), GenerationProvider::LocalLlm);
     }
 
     #[test]
@@ -184,7 +191,7 @@ mod tests {
     #[test]
     fn all_variants() {
       let mut variants = GenerationProvider::all_variants();
-      assert_eq!(variants.len(), 14);
+      assert_eq!(variants.len(), 15);
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Artcraft));
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Fal));
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Grok));
@@ -199,6 +206,7 @@ mod tests {
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Picsart));
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Pixverse));
       assert_eq!(variants.pop_first(), Some(GenerationProvider::Runway));
+      assert_eq!(variants.pop_first(), Some(GenerationProvider::LocalLlm));
       assert_eq!(variants.pop_first(), None);
     }
   }
diff --git a/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx b/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx
--- a/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx
+++ b/frontend/apps/artcraft/app/src/components/reusable/CostModal.tsx
@@ -7,9 +7,10 @@ import { useCurrency } from "@storyteller/ui-pricing-modal";
 interface CostModalProps {
   credits?: number;
   onClose: () => void;
+  isLocalLlm?: boolean;
 }
 
-export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
+export const CostModal = ({ credits = 1, onClose, isLocalLlm = false }: CostModalProps) => {
   const {
     currency,
     setCurrency,
@@ -57,11 +58,11 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
             <div className="mb-1 flex items-center justify-between">
               <span className="text-sm text-base-fg/80">Total Cost</span>
               <span className="text-lg font-semibold tabular-nums tracking-tight text-base-fg">
-                {credits} Credits
+                {isLocalLlm ? "Free" : `${credits} Credits`}
               </span>
             </div>
             <div className="text-right text-xs text-base-fg/60">
-              ≈ {formattedPrice} {currencyOption.value}
+              {isLocalLlm ? "No credits used" : `≈ ${formattedPrice} ${currencyOption.value}`}
             </div>
           </div>
 
diff --git a/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx b/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal.tsx
@@ -0,0 +1,327 @@
+import { Modal } from "@storyteller/ui-modal";
+import { invoke } from "@tauri-apps/api/core";
+import { CheckIcon, RefreshCwIcon, SettingsIcon, XIcon } from "lucide-react";
+import { Button } from "@storyteller/ui-button";
+import { useState, useEffect } from "react";
+import { GenerationProvider } from "@storyteller/common";
+
+interface LocalLlmSettingsModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+type BackendType = "ollama" | "llama_cpp" | "lm_studio" | "custom";
+
+interface LocalLlmConfig {
+  backend: BackendType;
+  base_url: string;
+  model: string;
+  api_key: string;
+  temperature: number;
+  max_tokens: number;
+  system_prompt: string;
+  timeout_seconds: number;
+}
+
+const DEFAULT_CONFIG: LocalLlmConfig = {
+  backend: "ollama",
+  base_url: "http://localhost:11434",
+  model: "llama3.2",
+  api_key: "",
+  temperature: 0.7,
+  max_tokens: 2048,
+  system_prompt: "",
+  timeout_seconds: 120,
+};
+
+const BACKEND_OPTIONS: { value: BackendType; label: string; defaultUrl: string }[] = [
+  { value: "ollama", label: "Ollama", defaultUrl: "http://localhost:11434" },
+  { value: "llama_cpp", label: "llama.cpp", defaultUrl: "http://localhost:8080" },
+  { value: "lm_studio", label: "LM Studio", defaultUrl: "http://localhost:1234" },
+  { value: "custom", label: "Custom", defaultUrl: "http://localhost:11434" },
+];
+
+export function LocalLlmSettingsModal({ isOpen, onClose }: LocalLlmSettingsModalProps) {
+  const [config, setConfig] = useState<LocalLlmConfig>(DEFAULT_CONFIG);
+  const [models, setModels] = useState<string[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSaving, setIsSaving] = useState(false);
+  const [isTesting, setIsTesting] = useState(false);
+  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (isOpen) {
+      loadConfig();
+    }
+  }, [isOpen]);
+
+  const loadConfig = async () => {
+    setIsLoading(true);
+    setError(null);
+    try {
+      const savedConfig = await invoke<LocalLlmConfig>("get_local_llm_config_command");
+      setConfig(savedConfig);
+    } catch (err) {
+      console.error("Failed to load local LLM config:", err);
+      setError("Failed to load configuration. Using defaults.");
+      setConfig(DEFAULT_CONFIG);
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  const handleBackendChange = (backend: BackendType) => {
+    const option = BACKEND_OPTIONS.find((o) => o.value === backend);
+    setConfig((prev) => ({
+      ...prev,
+      backend,
+      base_url: option?.defaultUrl ?? prev.base_url,
+    }));
+  };
+
+  const handleSave = async () => {
+    setIsSaving(true);
+    setError(null);
+    try {
+      await invoke("save_local_llm_config_command", { config });
+      onClose();
+    } catch (err) {
+      console.error("Failed to save local LLM config:", err);
+      setError("Failed to save configuration.");
+    } finally {
+      setIsSaving(false);
+    }
+  };
+
+  const handleTestConnection = async () => {
+    setIsTesting(true);
+    setTestResult(null);
+    setError(null);
+    try {
+      // First save the config
+      await invoke("save_local_llm_config_command", { config });
+      // Then test the connection
+      const availableModels = await invoke<string[]>("test_local_llm_connection_command");
+      setModels(availableModels);
+      setTestResult({
+        success: true,
+        message: `Connected successfully! Found ${availableModels.length} models.`,
+      });
+    } catch (err) {
+      console.error("Failed to test local LLM connection:", err);
+      setTestResult({
+        success: false,
+        message: `Connection failed: ${err}`,
+      });
+    } finally {
+      setIsTesting(false);
+    }
+  };
+
+  const handleRefreshModels = async () => {
+    setIsLoading(true);
+    setError(null);
+    try {
+      const availableModels = await invoke<string[]>("test_local_llm_connection_command");
+      setModels(availableModels);
+    } catch (err) {
+      console.error("Failed to refresh models:", err);
+      setError("Failed to fetch models. Check your connection.");
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      onClose={onClose}
+      className="max-w-2xl p-6"
+      showClose={true}
+    >
+      <div className="flex flex-col gap-6 text-base-fg">
+        <div className="flex items-center justify-between">
+          <div className="flex items-center gap-2">
+            <SettingsIcon className="h-5 w-5 text-primary" />
+            <h1 className="font-display text-2xl font-medium">Local LLM Settings</h1>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-base-fg/50 transition-colors hover:text-base-fg"
+          >
+            <XIcon className="h-5 w-5" />
+          </button>
+        </div>
+
+        <p className="text-sm text-base-fg/60">
+          Configure your local LLM backend (Ollama, llama.cpp, LM Studio) to generate
+          text completions without using credits.
+        </p>
+
+        {error && (
+          <div className="rounded border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
+            {error}
+          </div>
+        )}
+
+        {testResult && (
+          <div
+            className={`rounded border p-3 text-sm ${
+              testResult.success
+                ? "border-green-500/30 bg-green-500/10 text-green-400"
+                : "border-red-500/30 bg-red-500/10 text-red-400"
+            }`}
+          >
+            {testResult.message}
+          </div>
+        )}
+
+        <div className="flex flex-col gap-4">
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">Backend Type</label>
+            <select
+              value={config.backend}
+              onChange={(e) => handleBackendChange(e.target.value as BackendType)}
+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+            >
+              {BACKEND_OPTIONS.map((option) => (
+                <option key={option.value} value={option.value}>
+                  {option.label}
+                </option>
+              ))}
+            </select>
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">Base URL</label>
+            <input
+              type="text"
+              value={config.base_url}
+              onChange={(e) => setConfig((prev) => ({ ...prev, base_url: e.target.value }))}
+              placeholder="http://localhost:11434"
+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+            />
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">Model</label>
+            <div className="flex gap-2">
+              <input
+                type="text"
+                value={config.model}
+                onChange={(e) => setConfig((prev) => ({ ...prev, model: e.target.value }))}
+                placeholder="llama3.2"
+                className="flex-1 rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+              />
+              <Button
+                onClick={handleRefreshModels}
+                disabled={isLoading || isTesting}
+                icon={RefreshCwIcon}
+                className="h-10 px-3"
+              >
+                Refresh
+              </Button>
+            </div>
+            {models.length > 0 && (
+              <div className="mt-1 flex flex-wrap gap-1">
+                {models.slice(0, 10).map((model) => (
+                  <button
+                    key={model}
+                    onClick={() => setConfig((prev) => ({ ...prev, model }))}
+                    className={`rounded px-2 py-1 text-xs ${
+                      config.model === model
+                        ? "bg-primary/20 text-primary"
+                        : "bg-white/5 text-base-fg/60 hover:bg-white/10"
+                    }`}
+                  >
+                    {model}
+                  </button>
+                ))}
+              </div>
+            )}
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">
+              Temperature: {config.temperature.toFixed(1)}
+            </label>
+            <input
+              type="range"
+              min="0"
+              max="2"
+              step="0.1"
+              value={config.temperature}
+              onChange={(e) =>
+                setConfig((prev) => ({ ...prev, temperature: parseFloat(e.target.value) }))
+              }
+              className="w-full"
+            />
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">Max Tokens</label>
+            <input
+              type="number"
+              value={config.max_tokens}
+              onChange={(e) =>
+                setConfig((prev) => ({ ...prev, max_tokens: parseInt(e.target.value) || 2048 }))
+              }
+              min="1"
+              max="8192"
+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+            />
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">
+              System Prompt (optional)
+            </label>
+            <textarea
+              value={config.system_prompt}
+              onChange={(e) =>
+                setConfig((prev) => ({ ...prev, system_prompt: e.target.value }))
+              }
+              placeholder="You are a helpful assistant..."
+              rows={3}
+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+            />
+          </div>
+
+          <div className="flex flex-col gap-2">
+            <label className="text-sm font-medium text-base-fg/80">
+              API Key (optional)
+            </label>
+            <input
+              type="password"
+              value={config.api_key}
+              onChange={(e) => setConfig((prev) => ({ ...prev, api_key: e.target.value }))}
+              placeholder="Leave empty if not required"
+              className="rounded border border-white/15 bg-white/5 px-3 py-2 text-sm text-base-fg"
+            />
+          </div>
+        </div>
+
+        <div className="flex gap-3">
+          <Button
+            onClick={handleTestConnection}
+            disabled={isTesting || isSaving}
+            className="h-10 px-4"
+          >
+            {isTesting ? "Testing..." : "Test Connection"}
+          </Button>
+          <Button
+            onClick={handleSave}
+            disabled={isSaving || isTesting}
+            icon={CheckIcon}
+            className="h-10 px-4"
+          >
+            {isSaving ? "Saving..." : "Save"}
+          </Button>
+        </div>
+      </div>
+    </Modal>
+  );
+}
+
+export default LocalLlmSettingsModal;
diff --git a/frontend/libs/api-enums/src/lib/common/generation_provider.ts b/frontend/libs/api-enums/src/lib/common/generation_provider.ts
--- a/frontend/libs/api-enums/src/lib/common/generation_provider.ts
+++ b/frontend/libs/api-enums/src/lib/common/generation_provider.ts
@@ -17,4 +17,5 @@ export enum GenerationProvider {
   Picsart = "picsart",
   Pixverse = "pixverse",
   Runway = "runway",
+  LocalLlm = "local_llm",
 }
diff --git a/frontend/libs/common/src/lib/enums/GenerationProvider.ts b/frontend/libs/common/src/lib/enums/GenerationProvider.ts
--- a/frontend/libs/common/src/lib/enums/GenerationProvider.ts
+++ b/frontend/libs/common/src/lib/enums/GenerationProvider.ts
@@ -13,4 +13,5 @@ export enum GenerationProvider {
   Picsart = "picsart",
   Pixverse = "pixverse",
   Runway = "runway",
+  LocalLlm = "local_llm",
 }
diff --git a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx
--- a/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx
+++ b/frontend/libs/components/provider-setup-modal/src/lib/provider-setup-modal.tsx
@@ -5,6 +5,7 @@ import { Button } from "@storyteller/ui-button";
 import { useState } from "react";
 import { useShowProviderLoginModalEvent } from "@storyteller/tauri-events";
 import { GenerationProvider } from "@storyteller/common";
+import { LocalLlmSettingsModal } from "../../../../apps/artcraft/app/src/components/reusable/LocalLlmSettingsModal";
 
 interface ProviderSetupModalProps {
 }
@@ -44,6 +45,8 @@ export function ProviderSetupModal({
 
   const modalButtonText = `Set up ${serviceProviderName}`;
 
+  const [showLocalLlmSettings, setShowLocalLlmSettings] = useState(false);
+
   const buttonOnClick = async () => {
     switch (provider) {
       case GenerationProvider.Grok:
@@ -52,6 +55,10 @@ export function ProviderSetupModal({
       case GenerationProvider.Midjourney:
         await invoke("midjourney_open_login_command");
         break;
+      case GenerationProvider.LocalLlm:
+        setShowLocalLlmSettings(true);
+        setShowModal(false);
+        return;
       case GenerationProvider.Fal:
         break; // TODO: None yet.
       default:
@@ -98,6 +105,12 @@ export function ProviderSetupModal({
           {modalButtonText}
         </Button>
       </div>
+      {showLocalLlmSettings && (
+        <LocalLlmSettingsModal
+          isOpen={showLocalLlmSettings}
+          onClose={() => setShowLocalLlmSettings(false)}
+        />
+      )}
     </Modal>
   );
 }
@@ -126,6 +139,8 @@ function getServiceProviderName(provider: GenerationProvider) : string {
       return "PixVerse";
     case GenerationProvider.Runway:
       return "Runway";
+    case GenerationProvider.LocalLlm:
+      return "Local LLM";
     case GenerationProvider.Artcraft:
     default:
       return "Artcraft";
diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts
--- a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts
+++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts
@@ -217,6 +217,7 @@ export const getProviderDisplayName = (provider: string): string => {
     midjourney: "Midjourney",
     sora: "Sora",
     worldlabs: "World Labs",
+    local_llm: "Local LLM",
   };
 
   return displayNames[provider] || provider;
