diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md
index ec979796d..d2838c838 100644
--- a/apps/web/PRODUCT.md
+++ b/apps/web/PRODUCT.md
@@ -122,7 +122,9 @@ workbench.
Sessions, send input or cancel work. Deletion follows the public deletion rules;
a busy Session is not deletable and the console never cancels work to make it so.
- **Secrets stay write-only.** Credential tokens, Template environment variables and
- setup commands are never returned, to the administrator included.
+ setup commands are never returned, to the administrator included. An Agent's saved
+ model provider shows its protocol, base URL, limits and whether a key is configured,
+ never the key.
- **Creators.** Core records the key behind every write. The console shows the
creating key of each asset and a project's write history; an asset an
administrator copied in an earlier release shows as Admin copy and an asset
diff --git a/apps/web/e2e/data/agents.mjs b/apps/web/e2e/data/agents.mjs
index 14f77edc9..db6b40bff 100644
--- a/apps/web/e2e/data/agents.mjs
+++ b/apps/web/e2e/data/agents.mjs
@@ -84,8 +84,28 @@ export const agentDefinitions = [
},
];
-/** Which project owns each definition (index into the demo projects). */
-export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3];
+/**
+ * Saved Agents with a model provider, as Core reads them: whether a key is saved,
+ * never the key. One saved only a provider, so it has no harness. No Session uses
+ * them, so the demo Sessions stay the same.
+ */
+export const providerAgentDefinitions = [
+ {
+ id: "agent_11c4f2a8", name: "Spec drafter", model: "MiniMax-M2", reasoning: {},
+ instructions: "Draft a product spec from the linked discussion: problem, users, scope, open questions.",
+ tools: [], metadata: { team: "product" },
+ x_agents_core: { model_provider: { protocol: "anthropic", base_url: "https://api.minimaxi.com/anthropic", api_key_configured: true, context_window: 204800, max_output_tokens: 65536 } },
+ },
+ {
+ id: "agent_12d7e3b9", name: "Research assistant", model: "kimi-k2", reasoning: {},
+ instructions: "Answer research questions with cited sources and a one-paragraph summary.",
+ tools: [], metadata: { team: "research" },
+ x_agents_core: { harness: "codex", model_provider: { protocol: "responses", base_url: "https://api.moonshot.cn/v1", api_key_configured: true } },
+ },
+];
+
+/** Which project owns each definition (index into the demo projects); the provider Agents come last. */
+export const agentProject = [0, 1, 2, 0, 1, 0, 1, 1, 2, 3, 0, 0];
/** Prompt and reply pairs per Agent so Session transcripts read like real work. */
export const agentConversations = {
diff --git a/apps/web/e2e/data/routes.mjs b/apps/web/e2e/data/routes.mjs
index e031b2c17..6f540bf13 100644
--- a/apps/web/e2e/data/routes.mjs
+++ b/apps/web/e2e/data/routes.mjs
@@ -1,5 +1,5 @@
// Synthetic deployment data for the browser acceptance fixture: Sessions, Turns, Items, nodes.
-import { agentConversations, agentDefinitions } from "./agents.mjs";
+import { agentConversations, agentDefinitions, providerAgentDefinitions } from "./agents.mjs";
let seed = 42;
const rand = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296);
const pick = (list) => list[Math.floor(rand() * list.length)];
@@ -99,5 +99,11 @@ export function buildDemo(now = Math.floor(Date.now() / 1000)) {
const sleeping = allocation.compute_phase === "suspended";
return { ...base, environment_id: session.environment.id, mode: "openai_hosted", provider_type: "docker", instance: { kind: "managed_allocation", allocation_id: allocation.id, device_id: null, connection_generation: null }, lifecycle_state: sleeping ? "sleeping" : "active", status: "observed", reason: null, allocation_created_at: session.created_at, observed_at: now - 2, started_at: session.created_at, cpu: sleeping ? null : { usage_seconds_total: 600 + Math.floor(rand() * 4000), capacity_cores: 2, usage_cores: Number((rand() * 1.6).toFixed(2)), utilization_ratio: null }, memory: sleeping ? null : { usage_bytes: Math.floor((0.4 + rand() * 1.4) * 2 ** 30), limit_bytes: 2 * 2 ** 30 } };
});
+ // Added after everything else is generated, so the seeded data above does not change.
+ agents.push(...providerAgentDefinitions.map((definition, index) => ({
+ ...definition, object: "agent", multi_agent: { enabled: false, max_concurrent_subagents: null }, service_tier: "auto",
+ text: { format: { type: "text" }, verbosity: "medium" },
+ created_at: now - 86400 * (3 + index), updated_at: now - 1800 * (index + 1),
+ })));
return { agents, sessions, turns, items, nodes, allocations, observations };
}
diff --git a/apps/web/e2e/resources.spec.ts b/apps/web/e2e/resources.spec.ts
index 7a79621bf..0de4af8a1 100644
--- a/apps/web/e2e/resources.spec.ts
+++ b/apps/web/e2e/resources.spec.ts
@@ -26,3 +26,19 @@ test("goes back to the page a resource was opened from", async ({ page, request
await page.getByRole("button", { name: "Back", exact: true }).first().click();
await expect(page.getByRole("heading", { name: "Report builder", level: 1 })).toBeVisible();
});
+
+test("reads an Agent saved with a model provider and no harness, without its key", async ({ page, request }) => {
+ // Production holds Spec drafter, saved with only an x_agents_core.model_provider.
+ await openConsole(page, request, "agents?project=proj_7f3a91c2");
+ const open = page.getByRole("button", { name: "Open Spec drafter" });
+ await expect(open).toBeVisible();
+ await expect(page.getByText(/Could not load/)).toHaveCount(0);
+ // With one project shown, the cells are model, harness, …
+ await expect(page.getByRole("row").filter({ has: open }).getByRole("cell").nth(1)).toHaveText("Core default");
+
+ await open.click();
+ await expect(page.getByRole("heading", { name: "Spec drafter", level: 1 })).toBeVisible();
+ const provider = page.getByRole("region", { name: "Model provider" });
+ for (const text of ["Anthropic Messages", "https://api.minimaxi.com/anthropic", "Configured", "204,800", "65,536"]) await expect(provider).toContainText(text);
+ await expect(page.locator("body")).not.toContainText(/api_key|\bsk-/);
+});
diff --git a/apps/web/src/features/agents/AgentCatalog.css b/apps/web/src/features/agents/AgentCatalog.css
index 84f70b95e..09c84c86a 100644
--- a/apps/web/src/features/agents/AgentCatalog.css
+++ b/apps/web/src/features/agents/AgentCatalog.css
@@ -12,3 +12,8 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+
+/* A provider's base URL takes two grid columns so it stays on one line, like an ID. */
+.resource-facts > .agent-provider-url {
+ grid-column: span 2;
+}
diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx
index dba41b4c0..44be05396 100644
--- a/apps/web/src/features/agents/AgentsPage.tsx
+++ b/apps/web/src/features/agents/AgentsPage.tsx
@@ -1,4 +1,4 @@
-import type { CoreHarnessKind, SavedAgent } from "@agents-core-web/agents-client";
+import type { CoreHarnessKind, ModelProviderView, SavedAgent } from "@agents-core-web/agents-client";
import { ArrowLeft, Bot, ListTree, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -24,6 +24,9 @@ export function harnessLabel(harness: CoreHarnessKind): string {
return "Codex";
}
+/** Provider protocols by their product names, the same in every language. */
+const protocolNames: Record
{provider.base_url}