diff --git a/api/server/controllers/SkillStatesController.js b/api/server/controllers/SkillStatesController.js
new file mode 100644
index 0000000000..35679d2ac6
--- /dev/null
+++ b/api/server/controllers/SkillStatesController.js
@@ -0,0 +1,93 @@
+const mongoose = require('mongoose');
+const { logger } = require('@librechat/data-schemas');
+const {
+ MAX_SKILL_STATES,
+ toSkillStatesRecord,
+ validateSkillStatesPayload,
+ pruneOrphanSkillStates,
+} = require('@librechat/api');
+const { ResourceType, PermissionBits } = require('librechat-data-provider');
+const { findAccessibleResources } = require('~/server/services/PermissionService');
+const { updateUser, getUserById } = require('~/models');
+
+/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */
+function buildPruneDeps(user) {
+ return {
+ findExistingSkillIds: async (validIds) => {
+ const Skill = mongoose.models.Skill;
+ if (!Skill) {
+ return validIds;
+ }
+ const existing = await Skill.find({ _id: { $in: validIds } })
+ .select('_id')
+ .lean();
+ return existing.map((doc) => doc._id.toString());
+ },
+ findAccessibleSkillIds: () =>
+ findAccessibleResources({
+ userId: user.id,
+ role: user.role,
+ resourceType: ResourceType.SKILL,
+ requiredPermissions: PermissionBits.VIEW,
+ }),
+ };
+}
+
+const getSkillStatesController = async (req, res) => {
+ try {
+ const userId = req.user.id;
+ const user = await getUserById(userId, 'skillStates');
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ const states = toSkillStatesRecord(user.skillStates);
+ const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user));
+ return res.status(200).json(pruned);
+ } catch (error) {
+ logger.error('[SkillStatesController] Error fetching skill states:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+const updateSkillStatesController = async (req, res) => {
+ try {
+ const { skillStates } = req.body;
+
+ const validationError = validateSkillStatesPayload(skillStates);
+ if (validationError) {
+ const { message, code, limit } = validationError;
+ const payload = { message };
+ if (code) payload.code = code;
+ if (limit != null) payload.limit = limit;
+ return res.status(400).json(payload);
+ }
+
+ const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user));
+
+ if (Object.keys(pruned).length > MAX_SKILL_STATES) {
+ return res.status(400).json({
+ code: 'MAX_SKILL_STATES_EXCEEDED',
+ message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`,
+ limit: MAX_SKILL_STATES,
+ });
+ }
+
+ const user = await updateUser(req.user.id, { skillStates: pruned });
+
+ if (!user) {
+ return res.status(404).json({ message: 'User not found' });
+ }
+
+ return res.status(200).json(toSkillStatesRecord(user.skillStates));
+ } catch (error) {
+ logger.error('[SkillStatesController] Error updating skill states:', error);
+ return res.status(500).json({ message: 'Internal server error' });
+ }
+};
+
+module.exports = {
+ getSkillStatesController,
+ updateSkillStatesController,
+};
diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js
index fd876f03ea..fbabe1bda3 100644
--- a/api/server/controllers/agents/__tests__/openai.spec.js
+++ b/api/server/controllers/agents/__tests__/openai.spec.js
@@ -41,6 +41,7 @@ jest.mock('@librechat/api', () => ({
createChunk: jest.fn().mockReturnValue({}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
+ loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
sendFinalChunk: jest.fn(),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
validateRequest: jest
diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js
index 720bd1e4f3..0824d38d1a 100644
--- a/api/server/controllers/agents/__tests__/responses.unit.spec.js
+++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js
@@ -42,6 +42,7 @@ jest.mock('@librechat/api', () => ({
}),
buildToolSet: jest.fn().mockReturnValue(new Set()),
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
+ loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js
index 13b1fa0d72..4a5dd7b423 100644
--- a/api/server/controllers/agents/openai.js
+++ b/api/server/controllers/agents/openai.js
@@ -14,6 +14,7 @@ const {
createChunk,
buildToolSet,
scopeSkillIds,
+ loadSkillStates,
sendFinalChunk,
createSafeUser,
validateRequest,
@@ -256,6 +257,13 @@ const OpenAIChatCompletionController = async (req, res) => {
})
: [];
+ const { skillStates, defaultActiveOnShare } = await loadSkillStates({
+ userId: req.user.id,
+ appConfig,
+ getUserById: db.getUserById,
+ accessibleSkillIds,
+ });
+
const primaryConfig = await initializeAgent(
{
req,
@@ -273,6 +281,8 @@ const OpenAIChatCompletionController = async (req, res) => {
ephemeralSkillsToggle ? undefined : agent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ skillStates,
+ defaultActiveOnShare,
},
dbMethods,
);
diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js
index 772b564fcc..585a41c4a4 100644
--- a/api/server/controllers/agents/responses.js
+++ b/api/server/controllers/agents/responses.js
@@ -13,6 +13,7 @@ const {
createRun,
buildToolSet,
scopeSkillIds,
+ loadSkillStates,
createSafeUser,
initializeAgent,
getBalanceConfig,
@@ -385,6 +386,13 @@ const createResponse = async (req, res) => {
})
: [];
+ const { skillStates, defaultActiveOnShare } = await loadSkillStates({
+ userId: req.user.id,
+ appConfig,
+ getUserById: db.getUserById,
+ accessibleSkillIds,
+ });
+
const primaryConfig = await initializeAgent(
{
req,
@@ -402,6 +410,8 @@ const createResponse = async (req, res) => {
ephemeralSkillsToggle ? undefined : agent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ skillStates,
+ defaultActiveOnShare,
},
dbMethods,
);
diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js
index 22162fed4e..c6b7c84b2c 100644
--- a/api/server/routes/settings.js
+++ b/api/server/routes/settings.js
@@ -3,11 +3,17 @@ const {
updateFavoritesController,
getFavoritesController,
} = require('~/server/controllers/FavoritesController');
+const {
+ getSkillStatesController,
+ updateSkillStatesController,
+} = require('~/server/controllers/SkillStatesController');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
router.get('/favorites', requireJwtAuth, getFavoritesController);
router.post('/favorites', requireJwtAuth, updateFavoritesController);
+router.get('/skills/active', requireJwtAuth, getSkillStatesController);
+router.post('/skills/active', requireJwtAuth, updateSkillStatesController);
module.exports = router;
diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js
index 08a4eb03ac..32f897b23f 100644
--- a/api/server/services/Endpoints/agents/initialize.js
+++ b/api/server/services/Endpoints/agents/initialize.js
@@ -2,6 +2,7 @@ const { logger } = require('@librechat/data-schemas');
const { EnvVar, createContentAggregator } = require('@librechat/agents');
const {
scopeSkillIds,
+ loadSkillStates,
initializeAgent,
primeInvokedSkills,
validateAgentModel,
@@ -124,6 +125,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
})
: [];
+ const { skillStates, defaultActiveOnShare } = await loadSkillStates({
+ userId: req.user.id,
+ appConfig,
+ getUserById: db.getUserById,
+ accessibleSkillIds,
+ });
+
// Resolve code API key once for the entire run (shared by primeInvokedSkills
// and enrichWithSkillConfigurable) to avoid redundant auth lookups.
let codeApiKey;
@@ -243,6 +251,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
ephemeralSkillsToggle ? undefined : primaryAgent.skills,
),
codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code),
+ skillStates,
+ defaultActiveOnShare,
},
{
getFiles: db.getFiles,
@@ -290,6 +300,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
parentMessageId,
computeAccessibleSkillIds: (agent) =>
scopeSkillIds(accessibleSkillIds, ephemeralSkillsToggle ? undefined : agent.skills),
+ skillStates,
+ defaultActiveOnShare,
},
{
getAgent: db.getAgent,
diff --git a/client/src/components/Skills/buttons/SkillToggle.tsx b/client/src/components/Skills/buttons/SkillToggle.tsx
new file mode 100644
index 0000000000..ff948b1fac
--- /dev/null
+++ b/client/src/components/Skills/buttons/SkillToggle.tsx
@@ -0,0 +1,40 @@
+import { memo } from 'react';
+import { cn } from '~/utils';
+
+interface SkillToggleProps {
+ enabled: boolean;
+ onChange: () => void;
+ ariaLabel: string;
+}
+
+function SkillToggle({ enabled, onChange, ariaLabel }: SkillToggleProps) {
+ return (
+
+ );
+}
+
+export default memo(SkillToggle);
diff --git a/client/src/components/Skills/buttons/index.ts b/client/src/components/Skills/buttons/index.ts
index 2ce3a1af96..93ad2e048e 100644
--- a/client/src/components/Skills/buttons/index.ts
+++ b/client/src/components/Skills/buttons/index.ts
@@ -1,3 +1,4 @@
export { default as AdminSettings } from './AdminSettings';
+export { default as SkillToggle } from './SkillToggle';
export { default as ShareSkill } from './ShareSkill';
export { default as CreateSkillMenu } from './CreateSkillMenu';
diff --git a/client/src/components/Skills/display/SkillDetail.tsx b/client/src/components/Skills/display/SkillDetail.tsx
index 1dab6c4b0a..bbd9dacf52 100644
--- a/client/src/components/Skills/display/SkillDetail.tsx
+++ b/client/src/components/Skills/display/SkillDetail.tsx
@@ -3,11 +3,11 @@ import { format } from 'date-fns';
import { Eye, Code, User, Calendar, EarthIcon, ScrollText } from 'lucide-react';
import { TooltipAnchor } from '@librechat/client';
import type { TSkill } from 'librechat-data-provider';
-import { useLocalize, useAuthContext, useSkillPermissions } from '~/hooks';
+import { useLocalize, useAuthContext, useSkillPermissions, useSkillActiveState } from '~/hooks';
+import { ShareSkill, SkillToggle } from '../buttons';
import SkillMarkdownRenderer from './SkillMarkdownRenderer';
import { parseFrontmatter } from '../utils';
import DeleteSkill from '../dialogs/DeleteSkill';
-import { ShareSkill } from '../buttons';
import { cn } from '~/utils';
interface SkillDetailProps {
@@ -68,7 +68,9 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
const localize = useLocalize();
const { user } = useAuthContext();
const permissions = useSkillPermissions(skill);
+ const { isActive, toggle } = useSkillActiveState();
const [viewMode, setViewMode] = useState<'rendered' | 'source'>('rendered');
+ const skillEnabled = isActive(skill);
const isPublic = skill.isPublic === true;
const isShared = skill.author !== user?.id && Boolean(skill.authorName);
@@ -130,6 +132,11 @@ export default function SkillDetail({ skill, onEdit, onDelete }: SkillDetailProp
{/* Actions */}
+
toggle(skill)}
+ ariaLabel={localize('com_ui_skill_toggle_active')}
+ />
{permissions.canEdit && onEdit && (