diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce0802a6c..cc3e2f6d3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
+### Added
+
+- Native support for Capminal Skills (`Capminal/agent-skills`) in the Skills hub: browse, inspect, and install allowlisted Capminal skills with publisher branding.
+
## [2026.7.29] - 2026-07-29
### Added
diff --git a/frontend/src/assets/capminal-symbol.svg b/frontend/src/assets/capminal-symbol.svg
new file mode 100644
index 000000000..f71eb4e09
--- /dev/null
+++ b/frontend/src/assets/capminal-symbol.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/frontend/src/views/skills/SkillsPage.test.tsx b/frontend/src/views/skills/SkillsPage.test.tsx
index 1d8a24682..402063516 100644
--- a/frontend/src/views/skills/SkillsPage.test.tsx
+++ b/frontend/src/views/skills/SkillsPage.test.tsx
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { SkillsPage } from './SkillsPage'
import bankrSymbolUrl from '@/assets/bankr-symbol.svg'
+import capminalSymbolUrl from '@/assets/capminal-symbol.svg'
import robinhoodSymbolUrl from '@/assets/robinhood-symbol.png'
vi.mock('sonner', () => ({
@@ -140,6 +141,13 @@ const BANKR_CATALOG_ITEM = {
source: 'bankr',
description: 'Analyze token risk.',
}
+const CAPMINAL_CATALOG_ITEM = {
+ name: 'morse-launch-b20',
+ identifier: 'https://github.com/Capminal/agent-skills/tree/main/morse-launch-b20',
+ provider: 'Capminal',
+ source: 'capminal',
+ description: 'Morse launch B20 skill.',
+}
// A needs_setup skill carrying both a Requirements manifest and Missing bins/env
// (skills.js:743-777,792-803). requirements.items exercises the ready /
// needs_setup / missing_skill status branches plus the missing + requires detail.
@@ -336,6 +344,7 @@ describe('SkillsPage', () => {
expect(tabs.map((tab) => tab.getAttribute('aria-label'))).toEqual([
'Installed',
'Bankr',
+ 'Capminal',
'Robinhood',
'Community',
])
@@ -344,6 +353,11 @@ describe('SkillsPage', () => {
.getByRole('presentation')
.getAttribute('src'),
).toBe(bankrSymbolUrl)
+ expect(
+ within(screen.getByRole('tab', { name: 'Capminal' }))
+ .getByRole('presentation')
+ .getAttribute('src'),
+ ).toBe(capminalSymbolUrl)
expect(
within(screen.getByRole('tab', { name: 'Robinhood' }))
.getByRole('presentation')
@@ -363,6 +377,11 @@ describe('SkillsPage', () => {
const bankr = screen.getByRole('tab', { name: 'Bankr' })
expect(bankr).toHaveAttribute('aria-selected', 'true')
expect(bankr).toHaveFocus()
+
+ fireEvent.keyDown(bankr, { key: 'ArrowRight' })
+ const capminal = screen.getByRole('tab', { name: 'Capminal' })
+ expect(capminal).toHaveAttribute('aria-selected', 'true')
+ expect(capminal).toHaveFocus()
})
it('the status pill filters the installed list', async () => {
@@ -437,6 +456,16 @@ describe('SkillsPage', () => {
expect(within(card).getByRole('presentation')).toHaveAttribute('src', bankrSymbolUrl)
})
+ it('uses the official Capminal icon when a Capminal catalog item has no logo metadata', async () => {
+ wireRpc({ searchResults: [CAPMINAL_CATALOG_ITEM] })
+ renderPage()
+ await waitFor(() => expect(screen.getByLabelText('Skill trader')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('tab', { name: 'Capminal' }))
+
+ const card = await screen.findByLabelText('Catalog skill morse-launch-b20')
+ expect(within(card).getByRole('presentation')).toHaveAttribute('src', capminalSymbolUrl)
+ })
+
// ── Install (per-item busy, correct RPC + params + invalidation) ─────────
it('installing a catalog skill calls skills.install with identifier/source/force and reloads', async () => {
wireRpc()
@@ -912,4 +941,39 @@ describe('SkillsPage', () => {
renderPage()
await waitFor(() => expect(document.title).toBe('Skills - AgentOS Control'))
})
+
+ it('loads and searches Capminal skills when entering the Capminal tab', async () => {
+ wireRpc({
+ searchResults: [
+ {
+ name: 'capminal',
+ identifier: 'https://github.com/capminal-skills/capminal',
+ provider: 'Capminal',
+ source: 'capminal',
+ description: 'Cap World interaction',
+ category: 'crypto',
+ },
+ ],
+ })
+ renderPage()
+ await waitFor(() => expect(screen.getByLabelText('Skill trader')).toBeInTheDocument())
+
+ fireEvent.click(screen.getByRole('tab', { name: 'Capminal' }))
+
+ const card = await screen.findByLabelText('Catalog skill capminal')
+ expect(card).toBeInTheDocument()
+ expect(within(card).getByText('Cap World interaction')).toBeInTheDocument()
+ expect(within(card).getByText('Crypto')).toBeInTheDocument()
+
+ fireEvent.click(within(card).getByRole('button', { name: 'View details for capminal' }))
+ const dialog = await screen.findByRole('dialog')
+ expect(within(dialog).getByText('Capminal')).toBeInTheDocument()
+ expect(within(dialog).getByText('Crypto')).toBeInTheDocument()
+
+ expect(mockRpc.call).toHaveBeenCalledWith('skills.search', {
+ query: '',
+ limit: 500,
+ source: 'capminal',
+ })
+ })
})
diff --git a/frontend/src/views/skills/SkillsPage.tsx b/frontend/src/views/skills/SkillsPage.tsx
index a6f34e523..6013d5735 100644
--- a/frontend/src/views/skills/SkillsPage.tsx
+++ b/frontend/src/views/skills/SkillsPage.tsx
@@ -20,6 +20,7 @@ import { ModalShell } from '@/components/ModalShell'
import { Button } from '@/components/ui/button'
import { useRpc } from '@/app/providers'
import bankrSymbolUrl from '@/assets/bankr-symbol.svg'
+import capminalSymbolUrl from '@/assets/capminal-symbol.svg'
import robinhoodSymbolUrl from '@/assets/robinhood-symbol.png'
import {
CAT_LABEL,
@@ -65,19 +66,25 @@ import {
// skills.js:7 — the Bankr partner tab is shown; the BankrSource backend stays
// wired either way so Bankr skills remain reachable via Community.
const SHOW_BANKR = true
-
-type Tab = 'installed' | 'bankr' | 'robinhood' | 'community'
-type RegistryGroup = 'bankr' | 'community'
-type PartnerBrand = 'bankr' | 'robinhood'
-const TAB_ORDER: Tab[] = SHOW_BANKR
- ? ['installed', 'bankr', 'robinhood', 'community']
- : ['installed', 'robinhood', 'community']
+const SHOW_CAPMINAL = true
+
+type Tab = 'installed' | 'bankr' | 'capminal' | 'robinhood' | 'community'
+type RegistryGroup = 'bankr' | 'capminal' | 'community'
+type PartnerBrand = 'bankr' | 'capminal' | 'robinhood'
+const TAB_ORDER: Tab[] = [
+ 'installed',
+ ...(SHOW_BANKR ? ['bankr' as const] : []),
+ ...(SHOW_CAPMINAL ? ['capminal' as const] : []),
+ 'robinhood',
+ 'community',
+]
// The bundled brand artwork stays a client-side asset: a local import is not
// something a SKILL.md could carry. Membership, however, is the payload's call —
// `publisher.id` is resolved against a server-side allowlist.
const PARTNER_BRANDS: Record = {
bankr: { label: 'Bankr', asset: bankrSymbolUrl },
+ capminal: { label: 'Capminal', asset: capminalSymbolUrl },
robinhood: { label: 'Robinhood', asset: robinhoodSymbolUrl },
}
@@ -184,6 +191,9 @@ function LogoBadge({ item, cls }: { item: RegistryItem; cls: string }) {
if (item.source?.toLowerCase() === 'bankr') {
return
}
+ if (item.source?.toLowerCase() === 'capminal') {
+ return
+ }
return {initials(item.provider || item.name)}
}
return (
@@ -413,10 +423,12 @@ export function SkillsPage() {
// Registry (bankr/community) query text + debounced community query.
const [bankrQuery, setBankrQuery] = useState('')
+ const [capminalQuery, setCapminalQuery] = useState('')
const [robinhoodQuery, setRobinhoodQuery] = useState('')
const [communityText, setCommunityText] = useState('')
const [communityQuery, setCommunityQuery] = useState('')
const [bankrCat, setBankrCat] = useState('all')
+ const [capminalCat, setCapminalCat] = useState('all')
const [robinhoodStatus, setRobinhoodStatus] = useState('all')
const [communityCat, setCommunityCat] = useState('all')
const [githubUrl, setGithubUrl] = useState('')
@@ -482,6 +494,21 @@ export function SkillsPage() {
},
})
+ const capminalSnapshot = useQuery({
+ queryKey: ['skills.search', 'capminal'],
+ enabled: SHOW_CAPMINAL && tab === 'capminal',
+ refetchOnWindowFocus: false,
+ queryFn: async () => {
+ await rpc.waitForConnection()
+ const data = await rpc.call('skills.search', {
+ query: '',
+ limit: 500,
+ source: 'capminal',
+ })
+ return data.results ?? []
+ },
+ })
+
const communitySnapshot = useQuery({
queryKey: ['skills.search', 'community'],
enabled: tab === 'community',
@@ -489,7 +516,7 @@ export function SkillsPage() {
queryFn: async () => {
await rpc.waitForConnection()
const data = await rpc.call('skills.search', { query: '', limit: 500 })
- return communityFilter(data.results ?? [], SHOW_BANKR)
+ return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL)
},
})
@@ -506,7 +533,7 @@ export function SkillsPage() {
query: communityQuery,
limit: 100,
})
- return communityFilter(data.results ?? [], SHOW_BANKR)
+ return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL)
},
})
@@ -694,10 +721,22 @@ export function SkillsPage() {
[bankrSnapshot.data, sessionInstalls],
)
+ const capminalRows = useMemo(
+ () =>
+ mergeRegistryRows(
+ capminalSnapshot.data ?? [],
+ sessionInstalls.filter((r) => r.source === 'capminal'),
+ ),
+ [capminalSnapshot.data, sessionInstalls],
+ )
+
const communityLive = communityQuery ? communitySearch.data : undefined
const communityBrowse = useMemo(
() =>
- mergeRegistryRows(communitySnapshot.data ?? [], communityFilter(sessionInstalls, SHOW_BANKR)),
+ mergeRegistryRows(
+ communitySnapshot.data ?? [],
+ communityFilter(sessionInstalls, SHOW_BANKR, SHOW_CAPMINAL),
+ ),
[communitySnapshot.data, sessionInstalls],
)
const communityRows = communityLive ?? communityBrowse
@@ -724,7 +763,11 @@ export function SkillsPage() {
*/
const registryItemFor = (d: Extract