+ {/*
+ Where an agent's inference runs is otherwise invisible without
+ opening its config, and "on shared compute" is the one provider
+ whose health the user can actually act on from the sidebar card.
+ */}
+ {usesMeshCompute(agent) ? (
+
+
+ Shared compute
+
+ ) : null}
{runtimeSource ? {runtimeSource} : null}
{agent.model ? {agent.model} : null}
diff --git a/desktop/src/features/mesh-compute/meshAgents.test.mjs b/desktop/src/features/mesh-compute/meshAgents.test.mjs
new file mode 100644
index 0000000000..245896e476
--- /dev/null
+++ b/desktop/src/features/mesh-compute/meshAgents.test.mjs
@@ -0,0 +1,111 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+/**
+ * Contract tests for mesh agent detection and the card's call to action.
+ *
+ * The CTA's value is that it stays quiet. A nudge that renders every time the
+ * card does becomes furniture, so most of these tests pin *silence*.
+ */
+
+import {
+ deriveMeshCallToAction,
+ summarizeMeshAgents,
+ usesMeshCompute,
+} from "./meshAgents.ts";
+
+function persona(overrides = {}) {
+ return {
+ id: "p1",
+ displayName: "Hop",
+ provider: "relay-mesh",
+ ...overrides,
+ };
+}
+
+test("mesh agents are identified by provider, not by model string", () => {
+ const summary = summarizeMeshAgents([
+ persona({ id: "a", displayName: "Zoe" }),
+ persona({ id: "b", displayName: "Ada" }),
+ persona({ id: "c", displayName: "Other", provider: "anthropic" }),
+ // A model that merely mentions a mesh model is not a mesh agent: intent
+ // lives in the persisted provider, not in a free-text model field.
+ persona({ id: "d", displayName: "Lookalike", provider: null }),
+ ]);
+ assert.equal(summary.count, 2);
+ assert.equal(summary.hasAny, true);
+ assert.deepEqual(summary.names, ["Ada", "Zoe"], "names sort for stable copy");
+});
+
+test("provider matching tolerates whitespace and absence", () => {
+ assert.equal(usesMeshCompute(persona({ provider: " relay-mesh " })), true);
+ assert.equal(usesMeshCompute(persona({ provider: "anthropic" })), false);
+ assert.equal(usesMeshCompute(persona({ provider: null })), false);
+ assert.equal(usesMeshCompute(null), false);
+ assert.equal(usesMeshCompute(undefined), false);
+});
+
+test("an empty persona list is not an error", () => {
+ const summary = summarizeMeshAgents([]);
+ assert.equal(summary.hasAny, false);
+ assert.deepEqual(summary.names, []);
+ assert.equal(summarizeMeshAgents(undefined).hasAny, false);
+});
+
+test("the CTA appears only when compute has no consumer", () => {
+ // Sharing, but nothing can use it — the dead end worth naming.
+ assert.deepEqual(
+ deriveMeshCallToAction({
+ personas: [persona({ provider: "anthropic" })],
+ isSharing: true,
+ meshHasCapacity: false,
+ }),
+ { kind: "createAgent", label: "Set up an agent to use it" },
+ );
+ // Not sharing, but the community has capacity this person could use.
+ assert.equal(
+ deriveMeshCallToAction({
+ personas: [],
+ isSharing: false,
+ meshHasCapacity: true,
+ }).kind,
+ "createAgent",
+ );
+});
+
+test("the CTA stays silent once a mesh agent exists", () => {
+ assert.deepEqual(
+ deriveMeshCallToAction({
+ personas: [persona()],
+ isSharing: true,
+ meshHasCapacity: true,
+ }),
+ { kind: "none" },
+ );
+});
+
+test("no capacity anywhere means the CTA would just move the dead end", () => {
+ // Telling someone to build an agent for a mesh with nothing in it does not
+ // help them; it relocates the problem.
+ assert.deepEqual(
+ deriveMeshCallToAction({
+ personas: [],
+ isSharing: false,
+ meshHasCapacity: false,
+ }),
+ { kind: "none" },
+ );
+});
+
+test("an unloaded persona list never prompts a possible duplicate", () => {
+ // `undefined` is "not fetched yet". Prompting someone to create an agent they
+ // may already have is worse than staying quiet for a moment.
+ assert.deepEqual(
+ deriveMeshCallToAction({
+ personas: undefined,
+ isSharing: true,
+ meshHasCapacity: true,
+ }),
+ { kind: "none" },
+ );
+});
diff --git a/desktop/src/features/mesh-compute/meshAgents.ts b/desktop/src/features/mesh-compute/meshAgents.ts
new file mode 100644
index 0000000000..40e133f0e6
--- /dev/null
+++ b/desktop/src/features/mesh-compute/meshAgents.ts
@@ -0,0 +1,95 @@
+import type { AgentPersona } from "@/shared/api/types";
+
+/**
+ * Which agents are set up to run on Buzz shared compute.
+ *
+ * A persona points at the mesh by carrying `provider === "relay-mesh"` — the
+ * same id the backend gates mesh model discovery on
+ * (`managed_agents::RELAY_MESH_PROVIDER_ID`), so this asks the persistent
+ * record rather than inferring intent from a model string.
+ *
+ * The point is a call to action: sharing compute that no agent can use is a
+ * dead end, and the card is where someone would notice. So the card needs to
+ * know not just *whether* mesh agents exist but whether the whole setup is
+ * inert — capacity with no consumer, or a consumer with no capacity.
+ */
+
+/** The provider id a persona carries when it runs on shared compute. */
+export const RELAY_MESH_PROVIDER_ID = "relay-mesh";
+
+export type MeshAgentSummary = {
+ /** Agents configured to run on shared compute. */
+ count: number;
+ /** Display names, for naming one specifically. Sorted for stable copy. */
+ names: string[];
+ /** True when at least one such agent exists. */
+ hasAny: boolean;
+};
+
+export function summarizeMeshAgents(
+ personas: AgentPersona[] | undefined,
+): MeshAgentSummary {
+ const matching = (personas ?? []).filter(
+ (persona) => persona.provider?.trim() === RELAY_MESH_PROVIDER_ID,
+ );
+ const names = matching
+ .map((persona) => persona.displayName)
+ .sort((a, b) => a.localeCompare(b));
+ return { count: matching.length, names, hasAny: matching.length > 0 };
+}
+
+/**
+ * Whether a given persona runs on shared compute.
+ *
+ * Used to decorate an agent wherever it appears, so "this one is running on the
+ * mesh" is legible without opening its config.
+ */
+export function usesMeshCompute(
+ persona: Pick | null | undefined,
+): boolean {
+ return persona?.provider?.trim() === RELAY_MESH_PROVIDER_ID;
+}
+
+/**
+ * The card's call to action, or null when none is warranted.
+ *
+ * Deliberately silent in the common case. A nudge that appears every time the
+ * card renders becomes furniture, so this only speaks when the setup is
+ * genuinely incomplete in a way the person can act on:
+ *
+ * - sharing (or capacity exists) but no agent can use it → offer to make one
+ * - an agent exists but nothing is shared anywhere → offer to share
+ *
+ * Returns null while the picture is still unknown (`personas === undefined`),
+ * because prompting someone to create an agent they may already have is worse
+ * than staying quiet for a moment.
+ */
+export type MeshCallToAction =
+ | { kind: "createAgent"; label: string }
+ | { kind: "none" };
+
+export function deriveMeshCallToAction({
+ personas,
+ isSharing,
+ meshHasCapacity,
+}: {
+ personas: AgentPersona[] | undefined;
+ isSharing: boolean;
+ /** Anyone in the community is sharing, including this machine. */
+ meshHasCapacity: boolean;
+}): MeshCallToAction {
+ // Unknown yet — say nothing rather than nudge toward a duplicate.
+ if (personas === undefined) {
+ return { kind: "none" };
+ }
+ if (summarizeMeshAgents(personas).hasAny) {
+ return { kind: "none" };
+ }
+ // Compute with no consumer is the dead end worth naming. Only prompt when
+ // there is actually capacity to use — telling someone to build an agent for
+ // a mesh with nothing in it just moves the dead end.
+ if (isSharing || meshHasCapacity) {
+ return { kind: "createAgent", label: "Set up an agent to use it" };
+ }
+ return { kind: "none" };
+}
diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx
index 37e7671c00..4f19aa2c32 100644
--- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx
+++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx
@@ -8,6 +8,9 @@ import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh";
import type { MeshModelCatalog } from "@/shared/api/tauriMesh";
import { meshModelCatalog } from "@/shared/api/tauriMesh";
+import { usePersonasQuery } from "@/features/agents/hooks";
+import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
+import { deriveMeshCallToAction } from "../meshAgents";
import { useMeshDownloadProgress } from "../hooks/useMeshDownloadProgress";
import { useMeshLiveView } from "../hooks/useMeshLiveView";
import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
@@ -99,6 +102,16 @@ export function SidebarMeshComputeCard({
const { progress: downloadProgress, reset: resetDownloadProgress } =
useMeshDownloadProgress();
+ // Sharing compute no agent can use is a dead end, and this card is where
+ // someone would notice. The CTA stays silent unless the setup is actually
+ // incomplete — see deriveMeshCallToAction.
+ const { data: personas } = usePersonasQuery();
+ const callToAction = deriveMeshCallToAction({
+ personas,
+ isSharing: toggle.isSharing,
+ meshHasCapacity: (snapshot?.sharingDeviceCount ?? 0) > 0,
+ });
+
// Inbound work has no counter in mesh-llm, so it is inferred by elimination
// across two samples: serving, inflight > 0, and our own dispatch count flat
// means the work is not ours. Keeping the previous sample in a ref (not
@@ -261,6 +274,17 @@ export function SidebarMeshComputeCard({
showSoloHint={model.showSoloHint}
/>
+ {callToAction.kind === "createAgent" ? (
+
+ ) : null}
+
{errorText ? (