diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index ada2ef6e..98e38239 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -73,8 +73,10 @@ and config PR.
An opened card has four sections, each folded except **overview**, and each
reads nothing until you open it:
-- **overview**: the agent's turn state, context and cost, its hive, and its open
- config PR.
+- **overview**: the agent's turn state, context and cost, its hive, links to its
+ profile and its config repo on the swarm's forge (built from the forge entry
+ of `GET /api/links`; without one the row is left out), and its
+ open config PR.
- **settings**: inference, instructions, resource caps, permissions and allowed
domains, each with its own editor (described below).
- **accounts**: the agent's linked accounts, and the menu that links another.
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
index 289b1d5b..0fb4be5d 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
@@ -12,6 +12,11 @@
.ui-agent-detail-fields dd {
margin: 0;
}
+.ui-agent-detail-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4em 1em;
+}
.ui-agent-detail-actions {
display: flex;
align-items: center;
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
index bae373ef..ad8ac91b 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
@@ -22,6 +22,8 @@ import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { canTurnIntoCustom, FRESHNESS, type AgentRow } from "../AgentTypes.js";
+import { agentForgeLinks } from "./forge-links.js";
+import { useForgeBase } from "./useForgeBase.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
@@ -98,6 +100,8 @@ function OverviewRows({
row: AgentRow;
actions: AgentActions;
}) {
+ const forge = useForgeBase();
+ const forgeLinks = forge ? agentForgeLinks(forge, row.name) : null;
return (
<>
state
@@ -106,6 +110,19 @@ function OverviewRows({
hive
{row.hive ?? "—"}
+ {forgeLinks ? (
+ <>
+ forge
+
+
+ profile
+
+
+ config repo
+
+
+ >
+ ) : null}
config PR
{row.config_pr ? (
diff --git a/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts b/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts
new file mode 100644
index 00000000..511c1d4a
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts
@@ -0,0 +1,22 @@
+// Where an agent lives on the swarm's forge, without a DOM: its profile and its
+// config repo. The agent's forge login is its name, and its config repo is
+// `agent-configs/`. The forge's address is the swarm's "Forge" entry in
+// `GET /api/links`, the same list the header's links menu shows.
+import type { MenuLink } from "@hive/shared/links-menu.js";
+
+export type AgentForgeLinks = { profile: string; configRepo: string };
+
+/** The forge's base URL from the swarm's service links, or null without one. */
+export function forgeBase(links: MenuLink[]): string | null {
+ const forge = links.find((l) => l.label.trim().toLowerCase() === "forge");
+ return forge ? forge.url.replace(/\/+$/, "") : null;
+}
+
+/** The agent's profile and config repo on the forge at `base`. */
+export function agentForgeLinks(base: string, agent: string): AgentForgeLinks {
+ const name = encodeURIComponent(agent);
+ return {
+ profile: `${base}/${name}`,
+ configRepo: `${base}/agent-configs/${name}`,
+ };
+}
diff --git a/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts b/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts
new file mode 100644
index 00000000..13b3ebb2
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts
@@ -0,0 +1,40 @@
+// The swarm forge's base URL, from `GET /api/links`. Fetched once and shared by
+// every agent card, so opening many cards sends one request. A failed fetch
+// is not kept, so the next card retries; the links just stay hidden meanwhile.
+import { useEffect, useState } from "preact/hooks";
+import type { MenuLink } from "@hive/shared/links-menu.js";
+import { forgeBase } from "./forge-links.js";
+
+let pending: Promise | null = null;
+
+function loadForgeBase(): Promise {
+ if (!pending) {
+ pending = fetch("/api/links")
+ .then((r) =>
+ r.ok
+ ? (r.json() as Promise)
+ : Promise.reject(new Error(`http ${r.status}`)),
+ )
+ .then(forgeBase)
+ .catch(() => {
+ pending = null;
+ return null;
+ });
+ }
+ return pending;
+}
+
+/** The forge's base URL, or null until known (or when the swarm lists none). */
+export function useForgeBase(): string | null {
+ const [base, setBase] = useState(null);
+ useEffect(() => {
+ let live = true;
+ void loadForgeBase().then((b) => {
+ if (live) setBase(b);
+ });
+ return () => {
+ live = false;
+ };
+ }, []);
+ return base;
+}
diff --git a/frontend/packages/swarm-ui/test/forge-links.test.mjs b/frontend/packages/swarm-ui/test/forge-links.test.mjs
new file mode 100644
index 00000000..94389ad6
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/forge-links.test.mjs
@@ -0,0 +1,41 @@
+// Where an agent lives on the swarm's forge: its profile and config repo, from
+// the forge entry of the swarm's service links. Run by `npm test` with Node's
+// type stripping.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { agentForgeLinks, forgeBase } from "../src/agent/detail/forge-links.ts";
+
+const link = (label, url) => ({ label, icon: "x", url });
+
+test("the forge base is the forge entry's URL, any case, without a trailing slash", () => {
+ assert.equal(
+ forgeBase([
+ link("Grafana", "https://grafana.example/"),
+ link("Forge", "https://forge.example/"),
+ ]),
+ "https://forge.example",
+ );
+ assert.equal(
+ forgeBase([link("forge", "https://forge.example")]),
+ "https://forge.example",
+ );
+});
+
+test("no forge entry gives no base", () => {
+ assert.equal(forgeBase([]), null);
+ assert.equal(forgeBase([link("Grafana", "https://grafana.example/")]), null);
+});
+
+test("an agent's profile and config repo sit under the forge base", () => {
+ assert.deepEqual(agentForgeLinks("https://forge.example", "iris"), {
+ profile: "https://forge.example/iris",
+ configRepo: "https://forge.example/agent-configs/iris",
+ });
+});
+
+test("the agent name is encoded", () => {
+ assert.equal(
+ agentForgeLinks("https://forge.example", "a b").profile,
+ "https://forge.example/a%20b",
+ );
+});