diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
index 33ca2e5bb..955cd84dd 100644
--- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
@@ -1,6 +1,7 @@
import * as React from "react";
import {
+ BookmarkPlus,
ChevronDown,
ChevronRight,
Clipboard,
@@ -39,6 +40,8 @@ import { EditAgentDialog } from "./EditAgentDialog";
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
import { ModelPicker } from "./ModelPicker";
+import { PersonaDialog } from "./PersonaDialog";
+import { useSaveAsPersonaTemplate } from "./useSaveAsPersonaTemplate";
import { truncatePubkey } from "./agentUi";
export function ManagedAgentRow({
@@ -423,6 +426,7 @@ function AgentActionsMenu({
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
}) {
const [editOpen, setEditOpen] = React.useState(false);
+ const saveAsTemplate = useSaveAsPersonaTemplate();
return (
<>
@@ -483,6 +487,13 @@ function AgentActionsMenu({
) : null}
+ {agent.personaId === null ? (
+ saveAsTemplate.open(agent)}>
+
+ Save as persona template
+
+ ) : null}
+
onAddToChannel(agent)}
@@ -542,6 +553,10 @@ function AgentActionsMenu({
open={editOpen}
/>
) : null}
+
+ {saveAsTemplate.dialogState ? (
+
+ ) : null}
>
);
}
diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs
index 948ae77f1..b4969aea0 100644
--- a/desktop/src/features/agents/ui/personaDialogState.test.mjs
+++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs
@@ -6,6 +6,7 @@ import {
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
+ saveAsPersonaTemplateDialogState,
} from "./personaDialogState.ts";
test("createPersonaDialogState returns a fresh empty draft", () => {
@@ -228,3 +229,107 @@ test("importPersonaDialogState preserves provider=anthropic", () => {
assert.equal(state.initialValues.provider, "anthropic");
});
+
+// ── saveAsPersonaTemplateDialogState (promote an agent → persona template) ────
+
+/** Minimal ManagedAgent fixture; only the fields the builder reads matter. */
+function makeAgent(overrides = {}) {
+ return {
+ pubkey: "npub-agent",
+ name: "Scout",
+ personaId: null,
+ relayUrl: "wss://relay",
+ acpCommand: "",
+ agentCommand: "/usr/local/bin/goose-acp",
+ agentArgs: [],
+ mcpCommand: "",
+ turnTimeoutSeconds: 320,
+ idleTimeoutSeconds: null,
+ maxTurnDurationSeconds: null,
+ parallelism: 24,
+ systemPrompt: "Scout the codebase.",
+ model: "claude-sonnet",
+ mcpToolsets: null,
+ envVars: { ANTHROPIC_API_KEY: "sk-test" },
+ status: "stopped",
+ pid: null,
+ createdAt: "2025-01-01T00:00:00Z",
+ updatedAt: "2025-01-02T00:00:00Z",
+ lastStartedAt: null,
+ lastStoppedAt: null,
+ lastExitCode: null,
+ lastError: null,
+ ...overrides,
+ };
+}
+
+test("saveAsPersonaTemplateDialogState uses persona-template vocabulary", () => {
+ const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
+
+ assert.equal(state.title, "Save as persona template");
+ assert.equal(state.submitLabel, "Save as persona template");
+ assert.equal(state.description, "Reuse this setup to create more agents.");
+});
+
+test("saveAsPersonaTemplateDialogState reverse-maps agentCommand to a runtime id", () => {
+ // commandsMatch compares basenames, so the absolute agentCommand path
+ // resolves to the catalog runtime whose command shares that basename.
+ const state = saveAsPersonaTemplateDialogState(makeAgent(), [
+ { id: "claude", label: "Claude", command: "claude-code-acp" },
+ { id: "goose", label: "Goose", command: "goose-acp" },
+ ]);
+
+ assert.equal(state.initialValues.runtime, "goose");
+});
+
+test("saveAsPersonaTemplateDialogState leaves runtime undefined when nothing matches", () => {
+ // Graceful fallback: an unknown command (or an empty/unloaded catalog)
+ // yields no runtime, and the dialog falls back to its default behavior.
+ const noMatch = saveAsPersonaTemplateDialogState(makeAgent(), [
+ { id: "claude", label: "Claude", command: "claude-code-acp" },
+ ]);
+ const empty = saveAsPersonaTemplateDialogState(makeAgent(), []);
+
+ assert.equal(noMatch.initialValues.runtime, undefined);
+ assert.equal(empty.initialValues.runtime, undefined);
+});
+
+test("saveAsPersonaTemplateDialogState ignores catalog entries with a null command", () => {
+ const state = saveAsPersonaTemplateDialogState(makeAgent(), [
+ { id: "not-installed", label: "Not Installed", command: null },
+ { id: "goose", label: "Goose", command: "goose-acp" },
+ ]);
+
+ assert.equal(state.initialValues.runtime, "goose");
+});
+
+test("saveAsPersonaTemplateDialogState carries name, prompt, model, and envVars", () => {
+ const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
+
+ assert.equal(state.initialValues.displayName, "Scout");
+ assert.equal(state.initialValues.systemPrompt, "Scout the codebase.");
+ assert.equal(state.initialValues.model, "claude-sonnet");
+ assert.deepEqual(state.initialValues.envVars, {
+ ANTHROPIC_API_KEY: "sk-test",
+ });
+ // namePool is persona-only and starts empty for the user to fill.
+ assert.deepEqual(state.initialValues.namePool, []);
+});
+
+test("saveAsPersonaTemplateDialogState omits provider (no top-level field on ManagedAgent)", () => {
+ // A ManagedAgent has no top-level provider, so there is nothing lossless to
+ // carry — the builder must not invent one.
+ const state = saveAsPersonaTemplateDialogState(makeAgent(), []);
+
+ assert.equal("provider" in state.initialValues, false);
+});
+
+test("saveAsPersonaTemplateDialogState tolerates null systemPrompt and model", () => {
+ const state = saveAsPersonaTemplateDialogState(
+ makeAgent({ systemPrompt: null, model: null }),
+ [],
+ );
+
+ assert.equal(state.initialValues.systemPrompt, "");
+ assert.equal(state.initialValues.model, undefined);
+});
diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts
index d1631dea5..92764bba0 100644
--- a/desktop/src/features/agents/ui/personaDialogState.ts
+++ b/desktop/src/features/agents/ui/personaDialogState.ts
@@ -1,7 +1,10 @@
+import { commandsMatch } from "@/features/agents/agentReuse";
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import type {
+ AcpRuntimeCatalogEntry,
AgentPersona,
CreatePersonaInput,
+ ManagedAgent,
UpdatePersonaInput,
} from "@/shared/api/types";
@@ -56,6 +59,60 @@ export function duplicatePersonaDialogState(
};
}
+/**
+ * Reverse-map a managed agent's resolved harness command back to an ACP
+ * runtime ID, so the persona dialog can pre-select the matching runtime.
+ * Returns `undefined` when no runtime matches (or none are loaded yet) — the
+ * dialog then falls back to its default-runtime behavior.
+ */
+function runtimeIdForAgentCommand(
+ agentCommand: string,
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+): string | undefined {
+ const match = runtimes.find(
+ (runtime) =>
+ runtime.command !== null && commandsMatch(runtime.command, agentCommand),
+ );
+ return match?.id;
+}
+
+/**
+ * Dialog state for the opt-in "Save as persona template" action on an existing
+ * agent. Prefills the persona editor from the agent so the user reviews and
+ * confirms before a persona template is created — nothing is minted silently.
+ *
+ * Near-lossless promote: name, system prompt, model, and env vars copy
+ * straight across; the harness command reverse-maps to a runtime ID.
+ * `namePool` is persona-only and starts empty — the user can fill it in the
+ * same dialog (it's how a template bulk-adds bots later).
+ *
+ * Note: "persona template" is the UI name for what the backend calls a
+ * `persona` (kind:30175). This builder produces a backend `CreatePersonaInput`.
+ * `provider` is intentionally not carried: a `ManagedAgent` does not expose a
+ * top-level provider field (it lives in backend/provider config), so there is
+ * nothing lossless to copy here.
+ */
+export function saveAsPersonaTemplateDialogState(
+ agent: ManagedAgent,
+ runtimes: readonly AcpRuntimeCatalogEntry[],
+): PersonaDialogState {
+ return {
+ title: "Save as persona template",
+ description: "Reuse this setup to create more agents.",
+ submitLabel: "Save as persona template",
+ initialValues: {
+ displayName: agent.name,
+ avatarUrl: "",
+ systemPrompt: agent.systemPrompt ?? "",
+ runtime: runtimeIdForAgentCommand(agent.agentCommand, runtimes),
+ model: agent.model ?? undefined,
+ // namePool is persona-only; start empty so the user fills it here.
+ namePool: [],
+ envVars: agent.envVars ?? {},
+ },
+ };
+}
+
export function editPersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts
index 9ea9fc258..2c10f1b84 100644
--- a/desktop/src/features/agents/ui/personaLibraryCopy.ts
+++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts
@@ -1,3 +1,23 @@
+/**
+ * UI copy for the persona library surfaces.
+ *
+ * ── Vocabulary boundary (UI ↔ backend) ──────────────────────────────────────
+ * The product-facing term is **"persona template"**: a reusable setup a user
+ * can save once and reuse to create more agents. The backend has no separate
+ * "template" concept — a persona template IS a **`persona`** (Nostr
+ * **kind:30175**). There is intentionally zero drift between the two:
+ *
+ * UI string backend entity / mutation
+ * ─────────────────────── ────────────────────────────────────────────────
+ * "persona template" ⇄ `persona` (kind:30175)
+ * "Save as persona ⇄ `createPersonaMutation` / `CreatePersonaInput`
+ * template"
+ *
+ * So every "...persona template" label here, in `CreateAgentDialog`'s opt-in
+ * toggle, and in the `useSaveAsPersonaTemplate` / `saveAsPersonaTemplateDialogState`
+ * save-as flow maps to the same backend `persona`. Keep new persona-template
+ * copy in this file and keep the mapping above current if the vocabulary moves.
+ */
export const personaLibraryCopy = {
title: "My agents",
description:
diff --git a/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts
new file mode 100644
index 000000000..d0757fb53
--- /dev/null
+++ b/desktop/src/features/agents/ui/useSaveAsPersonaTemplate.ts
@@ -0,0 +1,99 @@
+import * as React from "react";
+import { toast } from "sonner";
+
+import {
+ useAcpRuntimesQuery,
+ useCreatePersonaMutation,
+} from "@/features/agents/hooks";
+import type {
+ CreatePersonaInput,
+ ManagedAgent,
+ UpdatePersonaInput,
+} from "@/shared/api/types";
+import {
+ saveAsPersonaTemplateDialogState,
+ type PersonaDialogState,
+} from "./personaDialogState";
+
+/**
+ * Self-contained "Save as persona template" flow for surfaces that don't
+ * already host `usePersonaActions` (e.g. an agent row's actions menu or the
+ * sidebar agent profile). Opens the shared `PersonaDialog` prefilled from an
+ * agent and creates a backend persona on submit — no new backend or IPC.
+ *
+ * Keeping the dialog + mutation behind this hook (rather than welding them to
+ * a host component's layout) means a later visual reskin of the create surface
+ * can move the trigger without untangling the save-as behavior.
+ *
+ * "Persona template" is the UI name for what the backend calls a `persona`
+ * (kind:30175); this hook produces a `CreatePersonaInput`.
+ */
+export function useSaveAsPersonaTemplate() {
+ // Hold the agent being promoted (rather than a one-shot snapshot) so the
+ // runtime reverse-map can re-resolve when the runtime list arrives — the
+ // query is lazy (`enabled` flips on open), so its data is usually empty on
+ // the very first open. Deriving the dialog state from `agent` + live
+ // runtimes via a memo avoids that first-open race.
+ const [agent, setAgent] = React.useState(null);
+ // Only fetch runtimes once the user actually opens the dialog.
+ const acpRuntimesQuery = useAcpRuntimesQuery({ enabled: agent !== null });
+ const createPersonaMutation = useCreatePersonaMutation();
+
+ const dialogState = React.useMemo(
+ () =>
+ agent
+ ? saveAsPersonaTemplateDialogState(agent, acpRuntimesQuery.data ?? [])
+ : null,
+ [agent, acpRuntimesQuery.data],
+ );
+
+ const open = React.useCallback((nextAgent: ManagedAgent) => {
+ setAgent(nextAgent);
+ }, []);
+
+ const close = React.useCallback(() => {
+ setAgent(null);
+ }, []);
+
+ const handleSubmit = React.useCallback(
+ async (input: CreatePersonaInput | UpdatePersonaInput) => {
+ // The save-as flow only ever produces a create input.
+ if ("id" in input) return;
+ try {
+ await createPersonaMutation.mutateAsync(input);
+ toast.success(`Saved ${input.displayName} as a persona template.`);
+ setAgent(null);
+ } catch (error) {
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : "Failed to save persona template.",
+ );
+ }
+ },
+ [createPersonaMutation],
+ );
+
+ return {
+ open,
+ dialogState,
+ dialogProps: {
+ open: dialogState !== null,
+ title: dialogState?.title ?? "",
+ description: dialogState?.description ?? "",
+ submitLabel: dialogState?.submitLabel ?? "",
+ initialValues: dialogState?.initialValues ?? null,
+ error:
+ createPersonaMutation.error instanceof Error
+ ? createPersonaMutation.error
+ : null,
+ isPending: createPersonaMutation.isPending,
+ runtimes: acpRuntimesQuery.data ?? [],
+ runtimesLoading: acpRuntimesQuery.isLoading,
+ onOpenChange: (next: boolean) => {
+ if (!next) close();
+ },
+ onSubmit: handleSubmit,
+ },
+ };
+}
diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx
index ad2af7c4a..cca5c20f5 100644
--- a/desktop/src/features/profile/ui/UserProfilePanel.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx
@@ -14,6 +14,8 @@ import {
import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog";
+import { PersonaDialog } from "@/features/agents/ui/PersonaDialog";
+import { useSaveAsPersonaTemplate } from "@/features/agents/ui/useSaveAsPersonaTemplate";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import {
@@ -242,6 +244,17 @@ export function UserProfilePanel({
setEditAgentOpen(true);
}, []);
+ const saveAsTemplate = useSaveAsPersonaTemplate();
+ const canSaveAsTemplate =
+ canEditAgent &&
+ managedAgent !== undefined &&
+ managedAgent.personaId === null;
+ const handleSaveAsTemplate = React.useCallback(() => {
+ if (managedAgent) {
+ saveAsTemplate.open(managedAgent);
+ }
+ }, [managedAgent, saveAsTemplate]);
+
const handleOpenActivity = React.useCallback(() => {
onClose();
onOpenAgentSession?.(pubkey);
@@ -353,6 +366,8 @@ export function UserProfilePanel({
handleEditAgent={handleEditAgent}
handleMessage={handleMessage}
handleOpenActivity={handleOpenActivity}
+ handleSaveAsTemplate={handleSaveAsTemplate}
+ canSaveAsTemplate={canSaveAsTemplate}
isBot={isBot}
isFollowing={isFollowing}
isOwner={isOwner}
@@ -405,6 +420,10 @@ export function UserProfilePanel({
/>
) : null;
+ const saveAsTemplateDialog = saveAsTemplate.dialogState ? (
+
+ ) : null;
+
if (isSplitLayout) {
return (
<>
@@ -416,6 +435,7 @@ export function UserProfilePanel({
{profileBody}
{editAgentDialog}
+ {saveAsTemplateDialog}
>
);
}
@@ -481,6 +501,7 @@ export function UserProfilePanel({
{profileBody}
{editAgentDialog}
+ {saveAsTemplateDialog}
>
);
}
diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
index b00a98673..4c5f546a9 100644
--- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
+++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx
@@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowUpRight,
+ BookmarkPlus,
Brain,
ChevronDown,
ChevronRight,
@@ -73,6 +74,8 @@ export type ProfileSummaryViewProps = {
handleEditAgent: () => void;
handleMessage: () => void;
handleOpenActivity: () => void;
+ handleSaveAsTemplate: () => void;
+ canSaveAsTemplate: boolean;
isBot: boolean;
isFollowing: boolean;
isOwner: boolean | undefined;
@@ -108,6 +111,8 @@ export function ProfileSummaryView({
handleEditAgent,
handleMessage,
handleOpenActivity,
+ handleSaveAsTemplate,
+ canSaveAsTemplate,
isBot,
isFollowing,
isOwner,
@@ -174,8 +179,10 @@ export function ProfileSummaryView({
{!isSelf ? (
;
isFollowing: boolean;
onEditAgent: () => void;
+ onSaveAsTemplate: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType;
@@ -481,6 +492,14 @@ function ProfilePrimaryActions({
testId="user-profile-edit-agent"
/>
) : null}
+ {canSaveAsTemplate ? (
+
+ ) : null}
);
}