diff --git a/e2e/acp-agents-catalog.spec.ts b/e2e/acp-agents-catalog.spec.ts
new file mode 100644
index 000000000..960849e9d
--- /dev/null
+++ b/e2e/acp-agents-catalog.spec.ts
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { test, expect } from '@playwright/test'
+import { collectPageErrors, loginViaOidc } from './helpers'
+
+test.describe('Agents catalog', () => {
+ test('browse, link out, search, and empty state work without page errors', async ({ page }) => {
+ const errors = collectPageErrors(page)
+ await loginViaOidc(page)
+
+ await page.goto('/settings/agents')
+
+ // The bundled ACP registry snapshot renders immediately — no live network needed.
+ // Assert a few known registry cards by id.
+ const geminiCard = page.getByTestId('agent-catalog-card-gemini')
+ await expect(geminiCard).toBeVisible({ timeout: 10_000 })
+ await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible()
+ await expect(page.getByTestId('agent-catalog-card-goose')).toBeVisible()
+
+ // At least one link-out is present on a card.
+ await expect(geminiCard.getByRole('link').first()).toBeVisible()
+
+ // Search filters the grid: 'gemini' keeps the gemini card, drops claude-acp.
+ const search = page.getByPlaceholder('Search agents')
+ await search.fill('gemini')
+ await expect(page.getByTestId('agent-catalog-card-gemini')).toBeVisible()
+ await expect(page.getByTestId('agent-catalog-card-claude-acp')).toHaveCount(0)
+
+ // The clear (X) button resets the query and restores the filtered-out card.
+ await page.getByRole('button', { name: /clear search/i }).click()
+ await expect(search).toHaveValue('')
+ await expect(page.getByTestId('agent-catalog-card-claude-acp')).toBeVisible()
+
+ // A guaranteed-no-match query shows the empty state.
+ await search.fill('zzzqqqxx')
+ await expect(page.getByText(/no agents found/i)).toBeVisible()
+
+ // The background CDN fetch must not surface page errors even if it fails —
+ // the snapshot fallback covers it.
+ expect(errors).toEqual([])
+ })
+})
diff --git a/src/components/settings/agents/agent-catalog-card.tsx b/src/components/settings/agents/agent-catalog-card.tsx
new file mode 100644
index 000000000..145f9aaec
--- /dev/null
+++ b/src/components/settings/agents/agent-catalog-card.tsx
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Code2, ExternalLink, Terminal } from 'lucide-react'
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { distributionLabel, primaryDistributionKind } from '@/lib/agent-registry-filter'
+import type { RegistryEntry } from '@/types/registry'
+
+type AgentCatalogCardProps = {
+ entry: RegistryEntry
+}
+
+/** A read-only catalogue card for a "bridge" agent: shows the agent's identity
+ * and metadata and links out to its website and source. There's no install
+ * action — these CLIs run on the user's own machine, not inside Thunderbolt. */
+export const AgentCatalogCard = ({ entry }: AgentCatalogCardProps) => {
+ const [iconFailed, setIconFailed] = useState(false)
+
+ const distributionKind = primaryDistributionKind(entry)
+ const websiteUrl = entry.website ?? entry.repository
+ const sourceUrl = entry.repository && entry.repository !== websiteUrl ? entry.repository : null
+ const showIcon = entry.icon && !iconFailed
+ const metadata = [entry.version ? `v${entry.version}` : null, entry.authors.join(', ') || null, entry.license || null]
+ .filter(Boolean)
+ .join(' · ')
+
+ return (
+
+
+