diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index 15eee72b..11dc54fe 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -34,6 +34,10 @@ An agent's terminal opens with its last 100 rows from the past 24 hours, then
follows new ones; a reconnect picks up after the last row it showed. A subagent
terminal shows rows published while it's open, and has no turn-state badges.
+A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
+different agents tell apart. A subagent's tab shows its agent's icon. An agent
+with no icon keeps the hyperhive mark.
+
### Scheduled prompts
`/schedules` has one card per scheduled prompt. The **name** is the card's
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
index cebaaad6..8fe9989d 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
@@ -12,6 +12,7 @@
import { useEffect } from "preact/hooks";
import type { RouteComponentProps } from "wouter-preact";
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
+import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import "./AgentTerminalPage.css";
@@ -27,6 +28,8 @@ export function AgentTerminalPage({
? undefined
: decodeURIComponent(params.subagent);
const { page, label } = termPaths(name, subagent);
+ // A subagent has no icon of its own: its tab wears its agent's.
+ useAgentFavicon(name);
// Runs once per mounted terminal — re-registering on every render
// would just be redundant work against an already-deduped list, but
diff --git a/frontend/packages/swarm-ui/src/shell/useAgentFavicon.ts b/frontend/packages/swarm-ui/src/shell/useAgentFavicon.ts
new file mode 100644
index 00000000..c5cba8e8
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/shell/useAgentFavicon.ts
@@ -0,0 +1,38 @@
+// useAgentFavicon — while the calling page is mounted, the browser tab's icon
+// is this agent's icon, so tabs of different agents tell apart at a glance.
+//
+// The icon comes from swarm-controller's `GET /api/agents/{name}/icon`, an
+// SVG that is a 404 for an agent with none. A `` can't report a failed
+// load, so an off-screen image loads it first and the tab icon switches only
+// once that succeeds: an agent with no icon, or an unreachable controller,
+// keeps the hyperhive mark. Leaving the page puts the page's own icon back.
+import { useEffect } from "preact/hooks";
+
+export function useAgentFavicon(agent: string): void {
+ useEffect(() => {
+ const link = document.querySelector('link[rel="icon"]');
+ if (link === null) return;
+ const original = {
+ href: link.getAttribute("href"),
+ type: link.getAttribute("type"),
+ };
+ const url = `/api/agents/${encodeURIComponent(agent)}/icon`;
+ let cancelled = false;
+
+ const probe = new Image();
+ probe.onload = () => {
+ if (cancelled) return;
+ link.setAttribute("type", "image/svg+xml");
+ link.setAttribute("href", url);
+ };
+ probe.src = url;
+
+ return () => {
+ cancelled = true;
+ for (const [attr, value] of Object.entries(original)) {
+ if (value === null) link.removeAttribute(attr);
+ else link.setAttribute(attr, value);
+ }
+ };
+ }, [agent]);
+}