diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index baffe0ced..cbd2cd4d9 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -30,7 +30,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
- ["src-tauri/src/managed_agents/persona_card.rs", 700], // PNG/ZIP persona card codec + 21 unit tests (~300 lines of tests)
+ ["src-tauri/src/managed_agents/persona_card.rs", 760], // PNG/ZIP persona card codec + provider/model fields + 27 unit tests (~350 lines of tests)
["src/app/AppShell.tsx", 775],
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
@@ -42,9 +42,10 @@ const overrides = new Map([
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/commands/agents.rs", 820], // remote agent lifecycle routing (local + provider branches) + scope enforcement
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
- ["src/features/agents/ui/AgentsView.tsx", 775], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import
+ ["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
- ["src/features/channels/ui/AddChannelBotDialog.tsx", 600], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement
+ ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
+ ["src/shared/api/types.ts", 515], // persona provider/model fields + forum types
]);
async function walkFiles(directory) {
diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs
index 05465b15c..0e3a35029 100644
--- a/desktop/src-tauri/src/commands/personas.rs
+++ b/desktop/src-tauri/src/commands/personas.rs
@@ -45,6 +45,8 @@ pub fn create_persona(
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
let avatar_url = trim_optional(input.avatar_url);
+ let provider = trim_optional(input.provider);
+ let model = trim_optional(input.model);
let now = now_iso();
let _store_guard = state
@@ -57,6 +59,8 @@ pub fn create_persona(
display_name,
avatar_url,
system_prompt,
+ provider,
+ model,
is_builtin: false,
created_at: now.clone(),
updated_at: now,
@@ -75,6 +79,8 @@ pub fn update_persona(
let display_name = trim_required(&input.display_name, "Display name")?;
let system_prompt = trim_required(&input.system_prompt, "System prompt")?;
let avatar_url = trim_optional(input.avatar_url);
+ let provider = trim_optional(input.provider);
+ let model = trim_optional(input.model);
let _store_guard = state
.managed_agents_store_lock
@@ -93,6 +99,8 @@ pub fn update_persona(
persona.display_name = display_name;
persona.avatar_url = avatar_url;
persona.system_prompt = system_prompt;
+ persona.provider = provider;
+ persona.model = model;
persona.updated_at = now_iso();
save_personas(&app, &personas)?;
@@ -215,7 +223,7 @@ pub async fn export_persona_to_json(
state: State<'_, AppState>,
) -> Result {
// Load persona data under lock, then drop lock before dialog.
- let (display_name, system_prompt, avatar_url) = {
+ let (display_name, system_prompt, avatar_url, provider, model) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -229,11 +237,18 @@ pub async fn export_persona_to_json(
persona.display_name.clone(),
persona.system_prompt.clone(),
persona.avatar_url.clone(),
+ persona.provider.clone(),
+ persona.model.clone(),
)
};
- let json_bytes =
- encode_persona_json(&display_name, &system_prompt, avatar_url.as_deref())?;
+ let json_bytes = encode_persona_json(
+ &display_name,
+ &system_prompt,
+ avatar_url.as_deref(),
+ provider.as_deref(),
+ model.as_deref(),
+ )?;
let slug = crate::util::slugify(&display_name, "persona", 50);
let filename = format!("{slug}.persona.json");
diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs
index 5f2ee9beb..5b6a01b10 100644
--- a/desktop/src-tauri/src/managed_agents/persona_card.rs
+++ b/desktop/src-tauri/src/managed_agents/persona_card.rs
@@ -13,6 +13,8 @@ pub struct ParsedPersonaPreview {
pub display_name: String,
pub system_prompt: String,
pub avatar_data_url: Option,
+ pub provider: Option,
+ pub model: Option,
pub source_file: String,
}
@@ -55,7 +57,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result Result Result {
serde_json::from_slice(&bytes).map_err(|e| format!("Invalid JSON: {e}"))
}
-/// Extract and validate `displayName` + `systemPrompt` from a Sprout persona
-/// JSON value (shared by both the PNG tEXt-chunk path and the standalone JSON path).
-fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> {
+/// Extracted fields from a Sprout persona JSON payload.
+struct SproutPersonaFields {
+ display_name: String,
+ system_prompt: String,
+ avatar_url: Option,
+ provider: Option,
+ model: Option,
+}
+
+/// Extract and validate fields from a Sprout persona JSON value
+/// (shared by both the PNG tEXt-chunk path and the standalone JSON path).
+fn extract_sprout_fields(v: &Value) -> Result {
let version = v.get("version").and_then(|v| v.as_u64()).unwrap_or(0);
if version != 1 {
return Err(format!("Unsupported persona version: {version}"));
@@ -105,15 +120,39 @@ fn extract_sprout_fields(v: &Value) -> Result<(String, String), String> {
if prompt.is_empty() {
return Err("systemPrompt is empty".to_string());
}
- Ok((name, prompt))
+ let avatar_url = v
+ .get("avatarUrl")
+ .and_then(|v| v.as_str())
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ let provider = v
+ .get("provider")
+ .and_then(|v| v.as_str())
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ let model = v
+ .get("model")
+ .and_then(|v| v.as_str())
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ Ok(SproutPersonaFields {
+ display_name: name,
+ system_prompt: prompt,
+ avatar_url,
+ provider,
+ model,
+ })
}
-fn parse_sprout_payload(b64: &str) -> Result<(String, String), String> {
+fn parse_sprout_payload(b64: &str) -> Result {
let v = decode_b64_json(b64)?;
extract_sprout_fields(&v)
}
-fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
+fn parse_chara_payload(b64: &str) -> Result {
let v = decode_b64_json(b64)?;
let data = v.get("data").ok_or("Missing 'data' in chara payload")?;
let name = data
@@ -142,7 +181,13 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
if prompt.is_empty() {
return Err("Chara card has no system_prompt or description".to_string());
}
- Ok((name, prompt))
+ Ok(SproutPersonaFields {
+ display_name: name,
+ system_prompt: prompt,
+ avatar_url: None,
+ provider: None,
+ model: None,
+ })
}
// ---------------------------------------------------------------------------
@@ -152,19 +197,14 @@ fn parse_chara_payload(b64: &str) -> Result<(String, String), String> {
pub fn parse_json_persona(json_bytes: &[u8]) -> Result {
let v: Value =
serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid JSON: {e}"))?;
- let (name, prompt) = extract_sprout_fields(&v)?;
-
- let avatar_data_url = v
- .get("avatarUrl")
- .and_then(|v| v.as_str())
- .map(|s| s.trim())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
+ let fields = extract_sprout_fields(&v)?;
Ok(ParsedPersonaPreview {
- display_name: name,
- system_prompt: prompt,
- avatar_data_url,
+ display_name: fields.display_name,
+ system_prompt: fields.system_prompt,
+ avatar_data_url: fields.avatar_url,
+ provider: fields.provider,
+ model: fields.model,
source_file: String::new(),
})
}
@@ -173,6 +213,8 @@ pub fn encode_persona_json(
display_name: &str,
system_prompt: &str,
avatar_url: Option<&str>,
+ provider: Option<&str>,
+ model: Option<&str>,
) -> Result, String> {
let mut map = serde_json::Map::new();
map.insert("version".to_string(), serde_json::json!(1));
@@ -187,6 +229,12 @@ pub fn encode_persona_json(
if let Some(url) = avatar_url {
map.insert("avatarUrl".to_string(), serde_json::json!(url));
}
+ if let Some(p) = provider {
+ map.insert("provider".to_string(), serde_json::json!(p));
+ }
+ if let Some(m) = model {
+ map.insert("model".to_string(), serde_json::json!(m));
+ }
serde_json::to_vec_pretty(&map).map_err(|e| format!("Failed to serialize JSON: {e}"))
}
@@ -550,7 +598,7 @@ mod tests {
#[test]
fn parse_json_round_trip() {
let bytes =
- encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"))
+ encode_persona_json("Ada Lovelace", "You are Ada.", Some("https://example.com/ada.png"), None, None)
.unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Ada Lovelace");
@@ -564,7 +612,7 @@ mod tests {
#[test]
fn parse_json_round_trip_no_avatar() {
- let bytes = encode_persona_json("Bob", "You are Bob.", None).unwrap();
+ let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Bob");
assert_eq!(result.system_prompt, "You are Bob.");
@@ -574,12 +622,55 @@ mod tests {
#[test]
fn parse_json_round_trip_data_uri_avatar() {
let data_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
- let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri)).unwrap();
+ let bytes = encode_persona_json("Carol", "You are Carol.", Some(data_uri), None, None).unwrap();
let result = parse_json_persona(&bytes).unwrap();
assert_eq!(result.display_name, "Carol");
assert_eq!(result.avatar_data_url.as_deref(), Some(data_uri));
}
+ #[test]
+ fn parse_json_round_trip_with_provider_and_model() {
+ let bytes = encode_persona_json(
+ "Agent Smith",
+ "You are an agent.",
+ None,
+ Some("goose"),
+ Some("claude-sonnet-4"),
+ )
+ .unwrap();
+ let result = parse_json_persona(&bytes).unwrap();
+ assert_eq!(result.display_name, "Agent Smith");
+ assert_eq!(result.system_prompt, "You are an agent.");
+ assert!(result.avatar_data_url.is_none());
+ assert_eq!(result.provider.as_deref(), Some("goose"));
+ assert_eq!(result.model.as_deref(), Some("claude-sonnet-4"));
+ }
+
+ #[test]
+ fn parse_json_round_trip_without_provider_and_model() {
+ let bytes = encode_persona_json("Bob", "You are Bob.", None, None, None).unwrap();
+ let result = parse_json_persona(&bytes).unwrap();
+ assert_eq!(result.display_name, "Bob");
+ assert!(result.provider.is_none());
+ assert!(result.model.is_none());
+ }
+
+ #[test]
+ fn parse_json_backward_compat_no_provider_model_fields() {
+ // Simulate a legacy persona JSON without provider/model fields
+ let json = serde_json::json!({
+ "version": 1,
+ "displayName": "Legacy Persona",
+ "systemPrompt": "Old school prompt"
+ });
+ let bytes = serde_json::to_vec(&json).unwrap();
+ let result = parse_json_persona(&bytes).unwrap();
+ assert_eq!(result.display_name, "Legacy Persona");
+ assert_eq!(result.system_prompt, "Old school prompt");
+ assert!(result.provider.is_none());
+ assert!(result.model.is_none());
+ }
+
#[test]
fn parse_json_invalid_version() {
let json = serde_json::json!({
@@ -620,8 +711,8 @@ mod tests {
#[test]
fn parse_zip_with_json() {
- let j1 = encode_persona_json("Alice", "Prompt A", None).unwrap();
- let j2 = encode_persona_json("Bob", "Prompt B", None).unwrap();
+ let j1 = encode_persona_json("Alice", "Prompt A", None, None, None).unwrap();
+ let j2 = encode_persona_json("Bob", "Prompt B", None, None, None).unwrap();
let zip = make_test_zip(&[("alice.persona.json", &j1), ("bob.persona.json", &j2)]);
let result = parse_zip_personas(&zip).unwrap();
assert_eq!(result.personas.len(), 2);
@@ -633,7 +724,7 @@ mod tests {
#[test]
fn parse_zip_mixed_png_and_json() {
let png = make_test_persona_png("PngPersona", "PNG prompt");
- let json = encode_persona_json("JsonPersona", "JSON prompt", None).unwrap();
+ let json = encode_persona_json("JsonPersona", "JSON prompt", None, None, None).unwrap();
let zip = make_test_zip(&[
("persona.png", &png),
("persona.json", &json),
@@ -648,8 +739,8 @@ mod tests {
#[test]
fn parse_zip_ignores_macos_resource_forks() {
- let j1 = encode_persona_json("Frank", "You are Frank.", None).unwrap();
- let j2 = encode_persona_json("Jackie", "You are Jackie.", None).unwrap();
+ let j1 = encode_persona_json("Frank", "You are Frank.", None, None, None).unwrap();
+ let j2 = encode_persona_json("Jackie", "You are Jackie.", None, None, None).unwrap();
let zip = make_test_zip(&[
("frank-costanza.persona.json", &j1),
("jackie-chiles.persona.json", &j2),
diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs
index f7a8fb548..0518d7546 100644
--- a/desktop/src-tauri/src/managed_agents/personas.rs
+++ b/desktop/src-tauri/src/managed_agents/personas.rs
@@ -58,6 +58,8 @@ fn built_in_persona_records(now: &str) -> Vec {
display_name: persona.display_name.to_string(),
avatar_url: None,
system_prompt: persona.system_prompt.to_string(),
+ provider: None,
+ model: None,
is_builtin: true,
created_at: now.to_string(),
updated_at: now.to_string(),
@@ -99,6 +101,8 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec,
pub system_prompt: String,
+ /// Preferred ACP provider ID (e.g. "goose", "claude", "codex").
+ /// When deploying an agent from this persona, this provider is pre-selected.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub provider: Option,
+ /// Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514").
+ /// Passed to the agent at creation time when deploying from this persona.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model: Option,
#[serde(default)]
pub is_builtin: bool,
pub created_at: String,
@@ -160,6 +168,10 @@ pub struct CreatePersonaRequest {
pub display_name: String,
pub avatar_url: Option,
pub system_prompt: String,
+ #[serde(default)]
+ pub provider: Option,
+ #[serde(default)]
+ pub model: Option,
}
#[derive(Debug, Deserialize)]
@@ -169,6 +181,10 @@ pub struct UpdatePersonaRequest {
pub display_name: String,
pub avatar_url: Option,
pub system_prompt: String,
+ #[serde(default)]
+ pub provider: Option,
+ #[serde(default)]
+ pub model: Option,
}
#[derive(Debug, Deserialize)]
diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts
index 02a3603f5..18c16fc4d 100644
--- a/desktop/src/features/agents/channelAgents.ts
+++ b/desktop/src/features/agents/channelAgents.ts
@@ -52,6 +52,8 @@ export type CreateChannelManagedAgentInput = {
systemPrompt?: string;
avatarUrl?: string;
personaId?: string | null;
+ /** Preferred model ID from the persona. Passed to createManagedAgent. */
+ model?: string;
role?: Exclude;
ensureRunning?: boolean;
backend?: ManagedAgentBackend;
@@ -308,6 +310,7 @@ export async function createChannelManagedAgent(
personaId: input.personaId ?? undefined,
systemPrompt: input.systemPrompt?.trim() || undefined,
avatarUrl: resolvedAvatarUrl,
+ model: input.model?.trim() || undefined,
spawnAfterCreate: isProviderMode,
startOnAppLaunch: isProviderMode ? false : undefined,
backend: input.backend,
diff --git a/desktop/src/features/agents/lib/resolvePersonaProvider.ts b/desktop/src/features/agents/lib/resolvePersonaProvider.ts
new file mode 100644
index 000000000..346265eb5
--- /dev/null
+++ b/desktop/src/features/agents/lib/resolvePersonaProvider.ts
@@ -0,0 +1,91 @@
+import type { AcpProvider } from "@/shared/api/types";
+
+/**
+ * Result of resolving a persona's preferred provider against the set of
+ * currently-available ACP providers.
+ *
+ * `provider` is the provider that should be used for deployment.
+ * `warnings` contains user-visible messages when the resolved provider
+ * differs from what the persona requested (e.g. the configured runtime
+ * was uninstalled) or when no provider is available at all.
+ */
+export type ResolvePersonaProviderResult = {
+ provider: AcpProvider | null;
+ warnings: string[];
+};
+
+/**
+ * Resolve which ACP provider to use when deploying an agent from a persona.
+ *
+ * Resolution order:
+ * 1. If the persona has no `providerId` → use `defaultProvider`, no warnings.
+ * 2. If the persona's `providerId` matches an available provider → use it.
+ * 3. If the persona's `providerId` is set but not found in `providers` →
+ * fall back to `defaultProvider` and emit a warning.
+ * 4. If there is no `defaultProvider` either → return `null` with an error
+ * warning so the UI can block deployment.
+ */
+export function resolvePersonaProvider(
+ personaProviderId: string | undefined | null,
+ providers: readonly AcpProvider[],
+ defaultProvider: AcpProvider | null,
+): ResolvePersonaProviderResult {
+ // Case 1: Persona has no provider preference — use the default.
+ if (!personaProviderId) {
+ return {
+ provider: defaultProvider,
+ warnings: defaultProvider
+ ? []
+ : [
+ "No agent runtimes are available. Install a runtime (e.g. Goose) to deploy agents.",
+ ],
+ };
+ }
+
+ // Case 2: Persona's preferred provider is available.
+ const matched = providers.find((p) => p.id === personaProviderId);
+ if (matched) {
+ return { provider: matched, warnings: [] };
+ }
+
+ // Case 3 & 4: Persona's provider is not available — fall back.
+ if (defaultProvider) {
+ return {
+ provider: defaultProvider,
+ warnings: [
+ `Persona is configured for runtime "${personaProviderId}" but it is not available. Using ${defaultProvider.label} instead.`,
+ ],
+ };
+ }
+
+ return {
+ provider: null,
+ warnings: [
+ `Persona is configured for runtime "${personaProviderId}" but it is not available, and no other runtimes were found.`,
+ ],
+ };
+}
+
+/**
+ * Collect provider-resolution warnings for a list of personas.
+ *
+ * Used by deploy dialogs to surface inline alerts when one or more
+ * personas reference a runtime that isn't currently available.
+ */
+export function collectProviderWarnings(
+ personas: readonly { provider: string | null }[],
+ providers: readonly AcpProvider[],
+ fallbackProvider: AcpProvider | null,
+): string[] {
+ if (!fallbackProvider) return [];
+ const warnings: string[] = [];
+ for (const persona of personas) {
+ const { warnings: w } = resolvePersonaProvider(
+ persona.provider,
+ providers,
+ fallbackProvider,
+ );
+ warnings.push(...w);
+ }
+ return warnings;
+}
diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx
index 3cffe4ccb..64c59bbe8 100644
--- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx
+++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx
@@ -1,3 +1,4 @@
+import { AlertTriangle } from "lucide-react";
import * as React from "react";
import {
@@ -5,6 +6,10 @@ import {
useCreateChannelManagedAgentsMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
+import {
+ collectProviderWarnings,
+ resolvePersonaProvider,
+} from "@/features/agents/lib/resolvePersonaProvider";
import { useChannelsQuery } from "@/features/channels/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type {
@@ -70,6 +75,14 @@ export function AddTeamToChannelDialog({
const resolved = team ? resolvePersonas(team.personaIds, personas) : [];
+ // Surface warnings when a persona's preferred provider is unavailable.
+ // This dialog has no provider selector, so the fallback is always
+ // `defaultProvider` (the first available runtime).
+ const providerWarnings = React.useMemo(
+ () => collectProviderWarnings(resolved, providers, defaultProvider),
+ [resolved, providers, defaultProvider],
+ );
+
function reset() {
setChannelId("");
setRole("bot");
@@ -101,19 +114,32 @@ export function AddTeamToChannelDialog({
}
try {
- const inputs = resolved.map((persona) => ({
- provider: {
- id: defaultProvider.id,
- label: defaultProvider.label,
- command: defaultProvider.command,
- defaultArgs: defaultProvider.defaultArgs,
- },
- name: persona.displayName,
- systemPrompt: persona.systemPrompt,
- avatarUrl: persona.avatarUrl ?? undefined,
- personaId: persona.id,
- role,
- }));
+ // Resolve each persona's preferred provider. This dialog has no
+ // provider selector, so the fallback is `defaultProvider` (first
+ // available runtime). Warnings are computed separately via the
+ // `providerWarnings` memo and rendered as inline alerts above.
+ const inputs = resolved.map((persona) => {
+ const { provider: personaProvider } = resolvePersonaProvider(
+ persona.provider,
+ providers,
+ defaultProvider,
+ );
+ const providerToUse = personaProvider ?? defaultProvider;
+ return {
+ provider: {
+ id: providerToUse.id,
+ label: providerToUse.label,
+ command: providerToUse.command,
+ defaultArgs: providerToUse.defaultArgs,
+ },
+ name: persona.displayName,
+ systemPrompt: persona.systemPrompt,
+ avatarUrl: persona.avatarUrl ?? undefined,
+ model: persona.model ?? undefined,
+ personaId: persona.id,
+ role,
+ };
+ });
const result = await deployMutation.mutateAsync(inputs);
onDeployed(selectedChannel, result);
@@ -214,6 +240,20 @@ export function AddTeamToChannelDialog({
) : null}
+ {providerWarnings.length > 0
+ ? providerWarnings.map((warning) => (
+
+ ))
+ : null}
+
{channelsQuery.error instanceof Error ? (
{channelsQuery.error.message}
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index 1404b0d0f..81466f6bb 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import {
type AttachManagedAgentToChannelResult,
personasQueryKey,
+ useAcpProvidersQuery,
useCreatePersonaMutation,
useDeletePersonaMutation,
useDeleteManagedAgentMutation,
@@ -66,6 +67,7 @@ export function AgentsView() {
const managedAgentsQuery = useManagedAgentsQuery();
const channelsQuery = useChannelsQuery();
const personasQuery = usePersonasQuery();
+ const acpProvidersQuery = useAcpProvidersQuery();
const startMutation = useStartManagedAgentMutation();
const stopMutation = useStopManagedAgentMutation();
const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation();
@@ -412,6 +414,8 @@ export function AgentsView() {
displayName: p.displayName,
avatarUrl: p.avatarDataUrl ?? "",
systemPrompt: p.systemPrompt,
+ provider: p.provider ?? undefined,
+ model: p.model ?? undefined,
},
});
} else if (result.personas.length > 0) {
@@ -471,6 +475,8 @@ export function AgentsView() {
displayName: "",
avatarUrl: "",
systemPrompt: "",
+ provider: undefined,
+ model: undefined,
},
});
}}
@@ -487,6 +493,8 @@ export function AgentsView() {
displayName: `${persona.displayName} copy`,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
+ provider: persona.provider ?? undefined,
+ model: persona.model ?? undefined,
},
});
}}
@@ -503,6 +511,8 @@ export function AgentsView() {
displayName: persona.displayName,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
+ provider: persona.provider ?? undefined,
+ model: persona.model ?? undefined,
},
});
}}
@@ -665,6 +675,8 @@ export function AgentsView() {
isPending={
createPersonaMutation.isPending || updatePersonaMutation.isPending
}
+ providers={acpProvidersQuery.data ?? []}
+ providersLoading={acpProvidersQuery.isLoading}
onOpenChange={(open) => {
if (!open) {
setPersonaDialogState(null);
diff --git a/desktop/src/features/agents/ui/BatchImportDialog.tsx b/desktop/src/features/agents/ui/BatchImportDialog.tsx
index 43bd7239b..a481698da 100644
--- a/desktop/src/features/agents/ui/BatchImportDialog.tsx
+++ b/desktop/src/features/agents/ui/BatchImportDialog.tsx
@@ -95,6 +95,8 @@ export function BatchImportDialog({
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? undefined,
systemPrompt: persona.systemPrompt,
+ provider: persona.provider ?? undefined,
+ model: persona.model ?? undefined,
});
completed += 1;
setImportedCount(completed);
diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx
index cc55d32e6..89d8c7976 100644
--- a/desktop/src/features/agents/ui/PersonaDialog.tsx
+++ b/desktop/src/features/agents/ui/PersonaDialog.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import type {
+ AcpProvider,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
@@ -23,6 +24,8 @@ type PersonaDialogProps = {
initialValues: CreatePersonaInput | UpdatePersonaInput | null;
error: Error | null;
isPending: boolean;
+ providers: AcpProvider[];
+ providersLoading?: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise;
};
@@ -35,12 +38,16 @@ export function PersonaDialog({
initialValues,
error,
isPending,
+ providers,
+ providersLoading = false,
onOpenChange,
onSubmit,
}: PersonaDialogProps) {
const [displayName, setDisplayName] = React.useState("");
const [avatarUrl, setAvatarUrl] = React.useState("");
const [systemPrompt, setSystemPrompt] = React.useState("");
+ const [provider, setProvider] = React.useState("");
+ const [model, setModel] = React.useState("");
React.useEffect(() => {
if (!open || !initialValues) {
@@ -50,6 +57,8 @@ export function PersonaDialog({
setDisplayName(initialValues.displayName);
setAvatarUrl(initialValues.avatarUrl ?? "");
setSystemPrompt(initialValues.systemPrompt);
+ setProvider(initialValues.provider ?? "");
+ setModel(initialValues.model ?? "");
}, [initialValues, open]);
function handleOpenChange(next: boolean) {
@@ -57,6 +66,8 @@ export function PersonaDialog({
setDisplayName("");
setAvatarUrl("");
setSystemPrompt("");
+ setProvider("");
+ setModel("");
}
onOpenChange(next);
@@ -71,6 +82,8 @@ export function PersonaDialog({
displayName,
avatarUrl: avatarUrl.trim() || undefined,
systemPrompt,
+ provider: provider.trim() || undefined,
+ model: model.trim() || undefined,
};
if ("id" in initialValues) {
@@ -93,7 +106,7 @@ export function PersonaDialog({
{description}
-
+
+
+
+
+
+ Optional. When deploying this persona, the selected runtime will
+ be pre-selected. Falls back to the default if unavailable.
+
+
+
+
+
+
setModel(event.target.value)}
+ placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
+ spellCheck={false}
+ value={model}
+ />
+
+ Optional. Passed to the agent at creation time. Leave blank to
+ use the runtime default.
+
+
+
{error ? (
{error.message}
diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
index 91c9e23ef..69213afec 100644
--- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
+++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx
@@ -37,6 +37,10 @@ import {
coerceConfigValues,
ProviderConfigFields,
} from "@/features/agents/ui/ProviderConfigFields";
+import {
+ collectProviderWarnings,
+ resolvePersonaProvider,
+} from "@/features/agents/lib/resolvePersonaProvider";
type AddChannelBotDialogProps = {
backendProviders?: BackendProviderCandidate[];
@@ -145,6 +149,18 @@ export function AddChannelBotDialog({
);
const selectedCount = selectedPersonas.length + (includeGeneric ? 1 : 0);
+ // Surface warnings when a persona's preferred provider differs from the
+ // user-selected provider. In this dialog the user explicitly picks a
+ // runtime via the dropdown, so the fallback is `selectedProvider` (their
+ // choice), NOT `providers[0]`. This differs intentionally from
+ // AddTeamToChannelDialog which has no provider selector and falls back
+ // to the first available runtime.
+ const providerWarnings = React.useMemo(
+ () =>
+ collectProviderWarnings(selectedPersonas, providers, selectedProvider),
+ [selectedPersonas, providers, selectedProvider],
+ );
+
const isProviderMode = runOn !== "local";
const selectedBackendProvider = React.useMemo(
() => resolvedBackendProviders.find((p) => p.id === runOn) ?? null,
@@ -300,15 +316,23 @@ export function AddChannelBotDialog({
},
]
: []),
- ...selectedPersonas.map((persona) => ({
- provider: selectedProvider,
- name: persona.displayName,
- personaId: persona.id,
- systemPrompt: persona.systemPrompt,
- avatarUrl: persona.avatarUrl ?? undefined,
- role: "bot" as const,
- backend,
- })),
+ ...selectedPersonas.map((persona) => {
+ const resolved = resolvePersonaProvider(
+ persona.provider,
+ providers,
+ selectedProvider,
+ );
+ return {
+ provider: resolved.provider ?? selectedProvider,
+ name: persona.displayName,
+ personaId: persona.id,
+ systemPrompt: persona.systemPrompt,
+ avatarUrl: persona.avatarUrl ?? undefined,
+ model: persona.model ?? undefined,
+ role: "bot" as const,
+ backend,
+ };
+ }),
];
setSubmissionNotice(null);
@@ -530,6 +554,20 @@ export function AddChannelBotDialog({
) : null}
+ {providerWarnings.length > 0
+ ? providerWarnings.map((warning) => (
+
+ ))
+ : null}
+
{personasQuery.error instanceof Error ? (
{personasQuery.error.message}
diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts
index 10493cbb5..aaa06658a 100644
--- a/desktop/src/shared/api/tauriPersonas.ts
+++ b/desktop/src/shared/api/tauriPersonas.ts
@@ -10,6 +10,8 @@ type RawParsedPersonaPreview = {
display_name: string;
system_prompt: string;
avatar_data_url: string | null;
+ provider: string | null;
+ model: string | null;
source_file: string;
};
@@ -28,6 +30,8 @@ export type ParsedPersonaPreview = {
displayName: string;
systemPrompt: string;
avatarDataUrl: string | null;
+ provider: string | null;
+ model: string | null;
sourceFile: string;
};
@@ -46,6 +50,8 @@ type RawPersona = {
display_name: string;
avatar_url: string | null;
system_prompt: string;
+ provider?: string | null;
+ model?: string | null;
is_builtin: boolean;
created_at: string;
updated_at: string;
@@ -57,6 +63,8 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
displayName: persona.display_name,
avatarUrl: persona.avatar_url,
systemPrompt: persona.system_prompt,
+ provider: persona.provider ?? null,
+ model: persona.model ?? null,
isBuiltIn: persona.is_builtin,
createdAt: persona.created_at,
updatedAt: persona.updated_at,
@@ -76,6 +84,8 @@ export async function createPersona(
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
+ provider: input.provider,
+ model: input.model,
},
}),
);
@@ -91,6 +101,8 @@ export async function updatePersona(
displayName: input.displayName,
avatarUrl: input.avatarUrl,
systemPrompt: input.systemPrompt,
+ provider: input.provider,
+ model: input.model,
},
}),
);
@@ -113,6 +125,8 @@ export async function parsePersonaFiles(
displayName: p.display_name,
systemPrompt: p.system_prompt,
avatarDataUrl: p.avatar_data_url,
+ provider: p.provider,
+ model: p.model,
sourceFile: p.source_file,
})),
skipped: raw.skipped.map((s) => ({
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 840e4191e..6504ba5b5 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -410,6 +410,10 @@ export type AgentPersona = {
displayName: string;
avatarUrl: string | null;
systemPrompt: string;
+ /** Preferred ACP provider ID (e.g. "goose", "claude"). */
+ provider: string | null;
+ /** Preferred model ID (e.g. "gpt-4o", "claude-sonnet-4-20250514"). */
+ model: string | null;
isBuiltIn: boolean;
createdAt: string;
updatedAt: string;
@@ -419,6 +423,8 @@ export type CreatePersonaInput = {
displayName: string;
avatarUrl?: string;
systemPrompt: string;
+ provider?: string;
+ model?: string;
};
export type UpdatePersonaInput = {
@@ -426,6 +432,8 @@ export type UpdatePersonaInput = {
displayName: string;
avatarUrl?: string;
systemPrompt: string;
+ provider?: string;
+ model?: string;
};
// ── Team types ────────────────────────────────────────────────────────────────