diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js
index 5742a3ad7e..d19645d05f 100644
--- a/api/server/controllers/agents/v1.js
+++ b/api/server/controllers/agents/v1.js
@@ -48,6 +48,7 @@ const {
resolveConfigServers,
userCanUseMCPServers,
} = require('~/server/services/MCP');
+const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
const { getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');
const db = require('~/models');
@@ -517,13 +518,15 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
});
agent.isPublic = isPublic;
+ await attachOwnerContacts([agent]);
+
if (agent.author !== author) {
delete agent.author;
}
if (!expandProperties) {
// VIEW permission: Basic agent info only
- return res.status(200).json({
+ const responseAgent = {
_id: agent._id,
id: agent.id,
name: agent.name,
@@ -538,7 +541,16 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
- });
+ };
+
+ if (agent.support_contact !== undefined) {
+ responseAgent.support_contact = agent.support_contact;
+ }
+ if (agent.owner_contact !== undefined) {
+ responseAgent.owner_contact = agent.owner_contact;
+ }
+
+ return res.status(200).json(responseAgent);
}
// EDIT permission: Full agent details including sensitive configuration
@@ -710,6 +722,8 @@ const updateAgentHandler = async (req, res) => {
updatedAgent.author = updatedAgent.author.toString();
}
+ await attachOwnerContacts([updatedAgent]);
+
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
@@ -1045,9 +1059,10 @@ const getListAgentsHandler = async (req, res) => {
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
+ const agentsWithContacts = await attachOwnerContacts(agents);
const urlCache = cachedRefresh?.urlCache;
- data.data = agents.map((agent) => {
+ data.data = agentsWithContacts.map((agent) => {
if (accessibleSkillSet) {
sanitizeViewerSkillScope(agent, accessibleSkillSet);
}
@@ -1153,6 +1168,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
+ await attachOwnerContacts([updatedAgent]);
try {
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
@@ -1257,6 +1273,8 @@ const revertAgentVersionHandler = async (req, res) => {
updatedAgent.author = updatedAgent.author.toString();
}
+ await attachOwnerContacts([updatedAgent]);
+
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js
index fda2bdd616..7710fbb2a5 100644
--- a/api/server/controllers/agents/v1.spec.js
+++ b/api/server/controllers/agents/v1.spec.js
@@ -1,8 +1,14 @@
const mongoose = require('mongoose');
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
-const { agentSchema, fileSchema } = require('@librechat/data-schemas');
-const { FileSources, PermissionBits, ResourceType } = require('librechat-data-provider');
+const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas');
+const {
+ FileSources,
+ PermissionBits,
+ PrincipalModel,
+ PrincipalType,
+ ResourceType,
+} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Only mock the dependencies that are not database-related
@@ -91,6 +97,32 @@ const { refreshS3Url } = require('@librechat/api');
* @type {import('mongoose').Model}
*/
let Agent;
+let AclEntry;
+let User;
+
+const OWNER_PERMISSION_BITS =
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
+
+const createOwner = (overrides = {}) =>
+ User.create({
+ name: 'Agent Owner',
+ email: `owner-${nanoid(8)}@example.com`,
+ provider: 'local',
+ emailVerified: true,
+ ...overrides,
+ });
+
+const grantAgentOwner = ({ agent, owner, grantedAt = new Date() }) =>
+ AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalModel: PrincipalModel.USER,
+ principalId: owner._id,
+ resourceType: ResourceType.AGENT,
+ resourceId: agent._id,
+ permBits: OWNER_PERMISSION_BITS,
+ grantedBy: owner._id,
+ grantedAt,
+ });
describe('Agent Controllers - Mass Assignment Protection', () => {
let mongoServer;
@@ -102,6 +134,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
+ AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema);
+ User = mongoose.models.User || mongoose.model('User', userSchema);
// Register File so orphan-pruning tests (and the tool_resources validation
// test, which now needs real File docs for its ids) have a working model.
mongoose.models.File || mongoose.model('File', fileSchema);
@@ -114,6 +148,8 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
beforeEach(async () => {
await Agent.deleteMany({});
+ await AclEntry.deleteMany({});
+ await User.deleteMany({});
await mongoose.models.File.deleteMany({});
// Reset all mocks
@@ -510,6 +546,61 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.model_parameters.temperature).toBeUndefined();
expect(response.model_parameters.apiKey).toBeUndefined();
});
+
+ test('should return owner_contact from the first ACL owner when support_contact is missing', async () => {
+ const owner = await createOwner({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Owner Contact Agent',
+ description: 'Uses owner fallback',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: owner._id,
+ });
+ await grantAgentOwner({ agent, owner });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ expect(mockRes.status).toHaveBeenCalledWith(200);
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.owner_contact).toEqual({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ });
+
+ test('should not return owner_contact when support_contact is present', async () => {
+ const owner = await createOwner({
+ name: 'Primary Owner',
+ email: 'primary.owner@example.com',
+ });
+ const agent = await Agent.create({
+ id: `agent_${uuidv4()}`,
+ name: 'Support Contact Agent',
+ description: 'Uses support contact',
+ provider: 'openai',
+ model: 'gpt-4',
+ author: owner._id,
+ support_contact: { name: 'Support Team', email: 'support@example.com' },
+ });
+ await grantAgentOwner({ agent, owner });
+
+ mockReq.params = { id: agent.id };
+
+ await getAgentHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.support_contact).toEqual({
+ name: 'Support Team',
+ email: 'support@example.com',
+ });
+ expect(response.owner_contact).toBeUndefined();
+ });
});
describe('updateAgentHandler', () => {
@@ -1303,6 +1394,71 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].name).toBe('Agent A1');
});
+ test('should return owner_contact for list agents missing support_contact', async () => {
+ const owner = await createOwner({
+ _id: userA,
+ name: 'List Owner',
+ email: 'list.owner@example.com',
+ });
+ await grantAgentOwner({ agent: agentA1, owner });
+
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toEqual({
+ name: 'List Owner',
+ email: 'list.owner@example.com',
+ });
+ });
+
+ test('should use the first ACL owner when an agent has multiple owners', async () => {
+ const firstOwner = await createOwner({
+ name: 'First Owner',
+ email: 'first.owner@example.com',
+ });
+ const secondOwner = await createOwner({
+ name: 'Second Owner',
+ email: 'second.owner@example.com',
+ });
+ await grantAgentOwner({
+ agent: agentA1,
+ owner: secondOwner,
+ grantedAt: new Date('2024-02-01T00:00:00.000Z'),
+ });
+ await grantAgentOwner({
+ agent: agentA1,
+ owner: firstOwner,
+ grantedAt: new Date('2024-01-01T00:00:00.000Z'),
+ });
+
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toEqual({
+ name: 'First Owner',
+ email: 'first.owner@example.com',
+ });
+ });
+
+ test('should omit owner_contact when no owner user can be resolved', async () => {
+ mockReq.user.id = userB.toString();
+ findAccessibleResources.mockResolvedValue([agentA1._id]);
+ findPubliclyAccessibleResources.mockResolvedValue([]);
+
+ await getListAgentsHandler(mockReq, mockRes);
+
+ const response = mockRes.json.mock.calls[0][0];
+ expect(response.data[0].owner_contact).toBeUndefined();
+ });
+
test('should return only expected safe list fields for VIEW callers', async () => {
const hiddenSkillId = new mongoose.Types.ObjectId();
await Agent.findByIdAndUpdate(agentA1._id, {
diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js
index eab7c908aa..e2e6ff1a4f 100644
--- a/api/server/routes/agents/actions.js
+++ b/api/server/routes/agents/actions.js
@@ -22,6 +22,7 @@ const {
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
+const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
const db = require('~/models');
const { canAccessAgentResource } = require('~/server/middleware');
@@ -209,6 +210,7 @@ router.post(
forceVersion: true,
},
);
+ await attachOwnerContacts([updatedAgent]);
// Only update user field for new actions
const actionUpdateData = {
diff --git a/api/server/services/Agents/ownerContact.js b/api/server/services/Agents/ownerContact.js
new file mode 100644
index 0000000000..f73e704276
--- /dev/null
+++ b/api/server/services/Agents/ownerContact.js
@@ -0,0 +1,87 @@
+const { logger } = require('@librechat/data-schemas');
+const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
+const { hasSupportContact, resolveAgentOwnerContact } = require('@librechat/api');
+const db = require('~/models');
+
+const OWNER_PERMISSION_BITS =
+ PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE;
+
+const getFirstOwnerIdsByResource = async (agents) => {
+ const resourceIds = agents
+ .filter((agent) => !hasSupportContact(agent))
+ .map((agent) => agent?._id)
+ .filter(Boolean);
+
+ if (resourceIds.length === 0) {
+ return new Map();
+ }
+
+ try {
+ const entries = await db.aggregateAclEntries([
+ {
+ $match: {
+ resourceType: ResourceType.AGENT,
+ resourceId: { $in: resourceIds },
+ principalType: PrincipalType.USER,
+ permBits: OWNER_PERMISSION_BITS,
+ },
+ },
+ { $sort: { grantedAt: 1, createdAt: 1, _id: 1 } },
+ { $group: { _id: '$resourceId', principalId: { $first: '$principalId' } } },
+ ]);
+
+ return new Map(
+ entries
+ .map((entry) => [entry?._id?.toString(), entry?.principalId?.toString()])
+ .filter(([resourceId, ownerId]) => resourceId && ownerId),
+ );
+ } catch (error) {
+ logger.warn('[/Agents] Failed to resolve agent owner ACL entries', error);
+ return new Map();
+ }
+};
+
+const attachOwnerContacts = async (agents) => {
+ if (!Array.isArray(agents) || agents.length === 0) {
+ return agents;
+ }
+
+ const ownerIdsByResource = await getFirstOwnerIdsByResource(agents);
+ const ownerIds = [
+ ...new Set(
+ agents
+ .filter((agent) => !hasSupportContact(agent))
+ .map((agent) => ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString())
+ .filter(Boolean),
+ ),
+ ];
+
+ let ownersById = new Map();
+ if (ownerIds.length > 0) {
+ try {
+ const users = await db.findUsers({ _id: { $in: ownerIds } }, 'name username email');
+ ownersById = new Map(users.map((user) => [user?._id?.toString(), user]));
+ } catch (error) {
+ logger.warn('[/Agents] Failed to resolve agent owner users', error);
+ }
+ }
+
+ return agents.map((agent) => {
+ if (hasSupportContact(agent)) {
+ delete agent.owner_contact;
+ return agent;
+ }
+ const ownerId = ownerIdsByResource.get(agent?._id?.toString()) ?? agent?.author?.toString();
+ const ownerContact = resolveAgentOwnerContact(agent, ownersById.get(ownerId) ?? null);
+ if (ownerContact) {
+ agent.owner_contact = ownerContact;
+ } else {
+ delete agent.owner_contact;
+ }
+ return agent;
+ });
+};
+
+module.exports = {
+ attachOwnerContacts,
+};
diff --git a/client/src/components/Agents/AgentCard.tsx b/client/src/components/Agents/AgentCard.tsx
index 7e9dd6da10..cdaa9f9d44 100644
--- a/client/src/components/Agents/AgentCard.tsx
+++ b/client/src/components/Agents/AgentCard.tsx
@@ -2,8 +2,9 @@ import React, { useMemo, useState } from 'react';
import { Label, OGDialog, OGDialogTrigger } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
-import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import AgentDetailContent from './AgentDetailContent';
+import { cn, renderAgentAvatar } from '~/utils';
+import AgentContact from './AgentContact';
interface AgentCardProps {
agent: t.Agent;
@@ -33,8 +34,6 @@ const AgentCard: React.FC = ({ agent, onSelect, className = '' }
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
- const displayName = getContactDisplayName(agent);
-
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open && onSelect) {
@@ -102,14 +101,10 @@ const AgentCard: React.FC = ({ agent, onSelect, className = '' }
)}
- {/* Author */}
- {displayName && (
-
-
- {localize('com_ui_by_author', { 0: displayName || '' })}
-
-
- )}
+
diff --git a/client/src/components/Agents/AgentContact.tsx b/client/src/components/Agents/AgentContact.tsx
new file mode 100644
index 0000000000..73f8524165
--- /dev/null
+++ b/client/src/components/Agents/AgentContact.tsx
@@ -0,0 +1,39 @@
+import type t from 'librechat-data-provider';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+
+type AgentContactProps = {
+ agent?: Pick | null;
+ className?: string;
+};
+
+export default function AgentContact({ agent, className = '' }: AgentContactProps) {
+ const localize = useLocalize();
+ const supportName = agent?.support_contact?.name?.trim() ?? '';
+ const supportEmail = agent?.support_contact?.email?.trim() ?? '';
+ const ownerName = agent?.owner_contact?.name?.trim() ?? '';
+ const ownerEmail = agent?.owner_contact?.email?.trim() ?? '';
+ let contact: { name: string; email: string } | null = null;
+ if (supportName || supportEmail) {
+ contact = { name: supportName, email: supportEmail };
+ } else if (ownerName || ownerEmail) {
+ contact = { name: ownerName, email: ownerEmail };
+ }
+
+ const label = contact?.name || contact?.email || localize('com_agents_no_contact_available');
+
+ return (
+
+
{localize('com_agents_contact')}:
+
+ {contact?.email ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+
+
+ );
+}
diff --git a/client/src/components/Agents/AgentDetailContent.tsx b/client/src/components/Agents/AgentDetailContent.tsx
index 7a2a95401d..356da52df1 100644
--- a/client/src/components/Agents/AgentDetailContent.tsx
+++ b/client/src/components/Agents/AgentDetailContent.tsx
@@ -14,18 +14,10 @@ import type t from 'librechat-data-provider';
import { useLocalize, useDefaultConvo, useFavorites } from '~/hooks';
import { renderAgentAvatar, clearMessagesCache } from '~/utils';
import { useChatContext } from '~/Providers';
-
-interface SupportContact {
- name?: string;
- email?: string;
-}
-
-interface AgentWithSupport extends t.Agent {
- support_contact?: SupportContact;
-}
+import AgentContact from './AgentContact';
interface AgentDetailContentProps {
- agent: AgentWithSupport;
+ agent: t.Agent;
}
/**
@@ -106,37 +98,6 @@ const AgentDetailContent: React.FC = ({ agent }) => {
});
};
- /**
- * Format contact information with mailto links when appropriate
- */
- const formatContact = () => {
- if (!agent?.support_contact) return null;
-
- const { name, email } = agent.support_contact;
-
- if (name && email) {
- return (
-
- {name}
-
- );
- }
-
- if (email) {
- return (
-
- {email}
-
- );
- }
-
- if (name) {
- return {name};
- }
-
- return null;
- };
-
return (
{/* Agent avatar */}
@@ -149,12 +110,7 @@ const AgentDetailContent: React.FC = ({ agent }) => {
- {/* Contact info */}
- {agent?.support_contact && formatContact() && (
-
- {localize('com_agents_contact')}: {formatContact()}
-
- )}
+
{/* Agent description */}
diff --git a/client/src/components/Agents/tests/AgentCard.spec.tsx b/client/src/components/Agents/tests/AgentCard.spec.tsx
index 5e16f3d265..1d968f34ea 100644
--- a/client/src/components/Agents/tests/AgentCard.spec.tsx
+++ b/client/src/components/Agents/tests/AgentCard.spec.tsx
@@ -1,9 +1,25 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
-import AgentCard from '../AgentCard';
-import type t from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type t from 'librechat-data-provider';
+import AgentCard from '../AgentCard';
+
+jest.mock('~/utils', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const React = require('react');
+ return {
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+ renderAgentAvatar: (agent: any) => {
+ const avatar = agent.avatar;
+ const src = typeof avatar === 'string' ? avatar : avatar?.filepath;
+ if (src) {
+ return

;
+ }
+ return
;
+ },
+ };
+});
// Mock useLocalize hook
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
@@ -12,7 +28,8 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
- com_ui_by_author: 'by {{0}}',
+ com_agents_contact: 'Contact',
+ com_agents_no_contact_available: 'No contact available',
com_agents_description_card: '{{description}}',
};
return mockTranslations[key] || key;
@@ -26,7 +43,8 @@ jest.mock('~/hooks', () => ({
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
- com_ui_by_author: 'by {{0}}',
+ com_agents_contact: 'Contact',
+ com_agents_no_contact_available: 'No contact available',
com_agents_description_card: '{{description}}',
};
let translation = mockTranslations[key] || key;
@@ -73,46 +91,52 @@ jest.mock('~/Providers', () => ({
}));
// Mock @librechat/client with proper Dialog behavior
-jest.mock('@librechat/client', () => {
- // eslint-disable-next-line @typescript-eslint/no-require-imports
- const React = require('react');
- return {
- ...jest.requireActual('@librechat/client'),
- useToastContext: jest.fn(() => ({
- showToast: jest.fn(),
- })),
- OGDialog: ({ children, open, onOpenChange }: any) => {
- // Store onOpenChange in context for trigger to call
- return (
-
- {React.Children.map(children, (child: any) => {
- if (child?.type?.displayName === 'OGDialogTrigger' || child?.props?.['data-trigger']) {
- return React.cloneElement(child, { onOpenChange });
- }
- // Only render content when open
- if (child?.type?.displayName === 'OGDialogContent' && !open) {
- return null;
- }
- return child;
- })}
-
- );
- },
- OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
- if (asChild && React.isValidElement(children)) {
- return React.cloneElement(children as React.ReactElement
, {
- onClick: (e: any) => {
- (children as any).props?.onClick?.(e);
- onOpenChange?.(true);
- },
- });
- }
- return onOpenChange?.(true)}>{children}
;
- },
- OGDialogContent: ({ children }: any) => {children}
,
- Label: ({ children, className }: any) => {children},
- };
-});
+jest.mock(
+ '@librechat/client',
+ () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const React = require('react');
+ return {
+ useToastContext: jest.fn(() => ({
+ showToast: jest.fn(),
+ })),
+ OGDialog: ({ children, open, onOpenChange }: any) => {
+ // Store onOpenChange in context for trigger to call
+ return (
+
+ {React.Children.map(children, (child: any) => {
+ if (
+ child?.type?.displayName === 'OGDialogTrigger' ||
+ child?.props?.['data-trigger']
+ ) {
+ return React.cloneElement(child, { onOpenChange });
+ }
+ // Only render content when open
+ if (child?.type?.displayName === 'OGDialogContent' && !open) {
+ return null;
+ }
+ return child;
+ })}
+
+ );
+ },
+ OGDialogTrigger: ({ children, asChild, onOpenChange }: any) => {
+ if (asChild && React.isValidElement(children)) {
+ return React.cloneElement(children as React.ReactElement, {
+ onClick: (e: any) => {
+ (children as any).props?.onClick?.(e);
+ onOpenChange?.(true);
+ },
+ });
+ }
+ return onOpenChange?.(true)}>{children}
;
+ },
+ OGDialogContent: ({ children }: any) => {children}
,
+ Label: ({ children, className }: any) => {children},
+ };
+ },
+ { virtual: true },
+);
// Create wrapper with QueryClient
const createWrapper = () => {
@@ -290,13 +314,18 @@ describe('AgentCard', () => {
expect(screen.getByText('Test Agent')).toBeInTheDocument();
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
+ expect(screen.getByText('No contact available')).toBeInTheDocument();
});
- it('displays authorName when support_contact is missing', () => {
+ it('falls back to owner contact when support_contact is missing', () => {
const agentWithAuthorName = {
...mockAgent,
support_contact: undefined,
authorName: 'John Doe',
+ owner_contact: {
+ name: 'Owner User',
+ email: 'owner@example.com',
+ },
};
render(
@@ -305,7 +334,12 @@ describe('AgentCard', () => {
,
);
- expect(screen.getByText('by John Doe')).toBeInTheDocument();
+ expect(screen.getByText('Contact:')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
+ 'href',
+ 'mailto:owner@example.com',
+ );
+ expect(screen.queryByText('by John Doe')).not.toBeInTheDocument();
});
it('has proper accessibility attributes', () => {
diff --git a/client/src/components/Agents/tests/AgentContact.spec.tsx b/client/src/components/Agents/tests/AgentContact.spec.tsx
new file mode 100644
index 0000000000..5225f6c85c
--- /dev/null
+++ b/client/src/components/Agents/tests/AgentContact.spec.tsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import AgentContact from '../AgentContact';
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => {
+ const translations: Record = {
+ com_agents_contact: 'Contact',
+ com_agents_no_contact_available: 'No contact available',
+ };
+ return translations[key] || key;
+ },
+}));
+
+jest.mock('~/utils', () => ({
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+}));
+
+describe('AgentContact', () => {
+ it('uses support contact before owner contact', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Contact:')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
+ 'href',
+ 'mailto:support@example.com',
+ );
+ expect(screen.queryByText('Owner User')).not.toBeInTheDocument();
+ });
+
+ it('falls back to owner contact', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
+ 'href',
+ 'mailto:owner@example.com',
+ );
+ });
+
+ it('renders a plain name when no email is available', () => {
+ render();
+
+ expect(screen.getByText('Owner User')).toBeInTheDocument();
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
+ });
+
+ it('renders no-contact text when no contact is available', () => {
+ render();
+
+ expect(screen.getByText('No contact available')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Agents/tests/AgentDetailContent.spec.tsx b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx
new file mode 100644
index 0000000000..19a2b5ef6d
--- /dev/null
+++ b/client/src/components/Agents/tests/AgentDetailContent.spec.tsx
@@ -0,0 +1,135 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import '@testing-library/jest-dom';
+import AgentDetailContent from '../AgentDetailContent';
+
+jest.mock('librechat-data-provider', () => ({
+ QueryKeys: {
+ agents: 'agents',
+ messages: 'messages',
+ },
+ Constants: {
+ NEW_CONVO: 'new',
+ },
+ EModelEndpoint: {
+ agents: 'agents',
+ },
+ PermissionBits: {
+ EDIT: 2,
+ },
+ LocalStorageKeys: {
+ AGENT_ID_PREFIX: 'agent:',
+ },
+}));
+
+jest.mock(
+ '@librechat/client',
+ () => ({
+ OGDialogContent: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({ children, ...props }: React.ButtonHTMLAttributes) => (
+
+ ),
+ useToastContext: () => ({
+ showToast: jest.fn(),
+ }),
+ }),
+ { virtual: true },
+);
+
+jest.mock('~/hooks', () => ({
+ useDefaultConvo: () => jest.fn((value) => value.conversation),
+ useFavorites: () => ({
+ isFavoriteAgent: jest.fn(() => false),
+ toggleFavoriteAgent: jest.fn(),
+ }),
+ useLocalize: () => (key: string, values?: Record) => {
+ const translations: Record = {
+ com_agents_contact: 'Contact',
+ com_agents_no_contact_available: 'No contact available',
+ com_agents_loading: 'Loading',
+ com_agents_link_copied: 'Link copied',
+ com_agents_link_copy_failed: 'Link copy failed',
+ com_agents_start_chat: 'Start chat',
+ com_agents_chat_with: `Chat with ${values?.name ?? ''}`,
+ com_ui_agent: 'Agent',
+ com_ui_pin: 'Pin',
+ com_ui_unpin: 'Unpin',
+ com_agents_copy_link: 'Copy link',
+ };
+ return translations[key] || key;
+ },
+}));
+
+jest.mock('~/Providers', () => ({
+ useChatContext: () => ({
+ conversation: undefined,
+ newConversation: jest.fn(),
+ }),
+}));
+
+jest.mock('~/utils', () => ({
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+ clearMessagesCache: jest.fn(),
+ renderAgentAvatar: () => ,
+}));
+
+const renderWithClient = (children: React.ReactNode) => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ return render({children});
+};
+
+const baseAgent = {
+ id: 'agent-1',
+ name: 'Agent One',
+ description: 'Agent description',
+ provider: 'openai',
+ model: 'gpt-4',
+ model_parameters: {},
+};
+
+describe('AgentDetailContent', () => {
+ it('renders support contact with mailto link', () => {
+ renderWithClient(
+ ,
+ );
+
+ expect(screen.getByText('Contact:')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
+ 'href',
+ 'mailto:support@example.com',
+ );
+ expect(screen.queryByText('Owner User')).not.toBeInTheDocument();
+ });
+
+ it('falls back to owner contact when support contact is missing', () => {
+ renderWithClient(
+ ,
+ );
+
+ expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
+ 'href',
+ 'mailto:owner@example.com',
+ );
+ });
+});
diff --git a/client/src/components/Chat/Landing.tsx b/client/src/components/Chat/Landing.tsx
index d3a3d3c8b1..ac333198cd 100644
--- a/client/src/components/Chat/Landing.tsx
+++ b/client/src/components/Chat/Landing.tsx
@@ -12,6 +12,7 @@ import {
} from '~/utils';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
+import AgentContact from '~/components/Agents/AgentContact';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { useLocalize, useAuthContext } from '~/hooks';
@@ -88,6 +89,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
}),
[],
);
+ const selectedAgent =
+ isAgent && conversation?.agent_id != null ? agentsMap?.[conversation.agent_id] : undefined;
const getGreeting = useCallback(() => {
if (typeof startupConfig?.interface?.customWelcome === 'string') {
@@ -135,7 +138,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
if (contentRef.current) {
setContentHeight(contentRef.current.offsetHeight);
}
- }, [lineCount, description]);
+ }, [lineCount, description, selectedAgent]);
const getDynamicMargin = useMemo(() => {
let margin = 'mb-0';
@@ -234,6 +237,12 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
{description}
))}
+ {selectedAgent && (
+
+ )}
);
diff --git a/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx
new file mode 100644
index 0000000000..d954c3c91b
--- /dev/null
+++ b/client/src/components/Chat/__tests__/Landing.agent-contact.spec.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import Landing from '../Landing';
+
+let mockConversation: Record | null = null;
+let mockAgentsMap: Record | undefined;
+let mockAssistantMap: Record | undefined;
+
+jest.mock('@react-spring/web', () => ({
+ easings: {
+ easeOutCubic: jest.fn(),
+ },
+}));
+
+jest.mock('librechat-data-provider', () => ({
+ EModelEndpoint: {
+ azureOpenAI: 'azureOpenAI',
+ openAI: 'openAI',
+ },
+}));
+
+jest.mock(
+ '@librechat/client',
+ () => ({
+ BirthdayIcon: () => ,
+ TooltipAnchor: ({ children }: { children: React.ReactNode }) => {children},
+ SplitText: ({ text }: { text: string }) => {text},
+ }),
+ { virtual: true },
+);
+
+jest.mock('~/Providers', () => ({
+ useChatContext: () => ({ conversation: mockConversation }),
+ useAgentsMapContext: () => mockAgentsMap,
+ useAssistantsMapContext: () => mockAssistantMap,
+}));
+
+jest.mock('~/data-provider', () => ({
+ useGetStartupConfig: () => ({ data: { interface: {} } }),
+ useGetEndpointsQuery: () => ({ data: {} }),
+}));
+
+jest.mock('~/hooks', () => ({
+ useAuthContext: () => ({ user: undefined }),
+ useLocalize: () => (key: string) => {
+ const translations: Record = {
+ com_agents_contact: 'Contact',
+ com_agents_no_contact_available: 'No contact available',
+ com_ui_good_morning: 'Good morning',
+ com_ui_good_afternoon: 'Good afternoon',
+ com_ui_good_evening: 'Good evening',
+ com_ui_late_night: 'Good evening',
+ com_ui_weekend_morning: 'Good morning',
+ };
+ return translations[key] || key;
+ },
+}));
+
+jest.mock('~/utils', () => ({
+ CONFIG_HTML_MEDIA_ATTR: {},
+ CONFIG_HTML_MEDIA_TAGS: [],
+ cn: (...classes: string[]) => classes.filter(Boolean).join(' '),
+ createConfigHtmlSanitizer: () => (html: string) => html,
+ getIconEndpoint: ({ endpoint }: { endpoint: string }) => endpoint,
+ getModelSpec: () => undefined,
+ getEntity: ({
+ endpoint,
+ agentsMap,
+ assistantMap,
+ agent_id,
+ assistant_id,
+ }: {
+ endpoint: string;
+ agentsMap?: Record;
+ assistantMap?: Record;
+ agent_id?: string;
+ assistant_id?: string;
+ }) => {
+ if (endpoint === 'agents' && agent_id != null) {
+ return { entity: agentsMap?.[agent_id], isAgent: true, isAssistant: false };
+ }
+ if (assistant_id != null) {
+ return { entity: assistantMap?.[assistant_id], isAgent: false, isAssistant: true };
+ }
+ return { entity: undefined, isAgent: false, isAssistant: false };
+ },
+}));
+
+jest.mock('~/components/Endpoints/ConvoIcon', () => () => );
+
+describe('Landing agent contact', () => {
+ beforeEach(() => {
+ mockConversation = null;
+ mockAgentsMap = undefined;
+ mockAssistantMap = undefined;
+ });
+
+ it('shows contact for the selected agent from agentsMap', () => {
+ mockConversation = {
+ endpoint: 'agents',
+ agent_id: 'agent-1',
+ };
+ mockAgentsMap = {
+ 'agent-1': {
+ id: 'agent-1',
+ name: 'Portal Remote Agent',
+ description: 'Remote Agent Showcase',
+ owner_contact: { name: 'Owner User', email: 'owner@example.com' },
+ },
+ };
+
+ render();
+
+ expect(screen.getByText('Portal Remote Agent')).toBeInTheDocument();
+ expect(screen.getByText('Remote Agent Showcase')).toBeInTheDocument();
+ expect(screen.getByText('Contact:')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Owner User' })).toHaveAttribute(
+ 'href',
+ 'mailto:owner@example.com',
+ );
+ });
+
+ it('does not show contact when the selected agent is missing from agentsMap', () => {
+ mockConversation = {
+ endpoint: 'agents',
+ agent_id: 'missing-agent',
+ greeting: 'Start chatting',
+ };
+ mockAgentsMap = {};
+
+ render();
+
+ expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
+ expect(screen.queryByText('No contact available')).not.toBeInTheDocument();
+ });
+
+ it('does not show contact for assistants', () => {
+ mockConversation = {
+ endpoint: 'assistants',
+ assistant_id: 'assistant-1',
+ };
+ mockAssistantMap = {
+ 'assistant-1': {
+ id: 'assistant-1',
+ name: 'Assistant',
+ description: 'Assistant description',
+ },
+ };
+
+ render();
+
+ expect(screen.getByText('Assistant')).toBeInTheDocument();
+ expect(screen.queryByText('Contact:')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/data-provider/Agents/mutations.ts b/client/src/data-provider/Agents/mutations.ts
index f49c910c98..951e694ec8 100644
--- a/client/src/data-provider/Agents/mutations.ts
+++ b/client/src/data-provider/Agents/mutations.ts
@@ -1,7 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { dataService, MutationKeys, PermissionBits, QueryKeys } from 'librechat-data-provider';
-import type * as t from 'librechat-data-provider';
import type { QueryClient, UseMutationResult } from '@tanstack/react-query';
+import type * as t from 'librechat-data-provider';
/**
* AGENTS
@@ -10,6 +10,7 @@ export const allAgentViewAndEditQueryKeys: t.AgentListParams[] = [
{ requiredPermission: PermissionBits.VIEW },
{ requiredPermission: PermissionBits.EDIT },
];
+
/**
* Create a new agent
*/
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index f602a7c352..b2debb8de9 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -34,6 +34,7 @@
"com_agents_chat_with": "Chat with {{name}}",
"com_agents_clear_search": "Clear search",
"com_agents_contact": "Contact",
+ "com_agents_no_contact_available": "No contact available",
"com_agents_copy_link": "Copy Link",
"com_agents_create_error": "There was an error creating your agent.",
"com_agents_created_by": "by",
diff --git a/packages/api/src/agents/contact.spec.ts b/packages/api/src/agents/contact.spec.ts
new file mode 100644
index 0000000000..1ac5d81ae2
--- /dev/null
+++ b/packages/api/src/agents/contact.spec.ts
@@ -0,0 +1,60 @@
+import { resolveAgentOwnerContact } from './contact';
+
+describe('resolveAgentOwnerContact', () => {
+ it('omits owner fallback when support contact has a name', () => {
+ const result = resolveAgentOwnerContact(
+ { support_contact: { name: 'Support Team' } },
+ { name: 'Agent Owner', email: 'owner@example.com' },
+ );
+
+ expect(result).toBeUndefined();
+ });
+
+ it('omits owner fallback when support contact has an email', () => {
+ const result = resolveAgentOwnerContact(
+ { support_contact: { email: 'support@example.com' } },
+ { name: 'Agent Owner', email: 'owner@example.com' },
+ );
+
+ expect(result).toBeUndefined();
+ });
+
+ it('uses owner name and email when support contact is empty', () => {
+ const result = resolveAgentOwnerContact(
+ { support_contact: { name: ' ', email: '' } },
+ { name: ' Agent Owner ', email: ' owner@example.com ' },
+ );
+
+ expect(result).toEqual({ name: 'Agent Owner', email: 'owner@example.com' });
+ });
+
+ it('falls back to username for owner display name', () => {
+ const result = resolveAgentOwnerContact({}, { username: 'owner.user' });
+
+ expect(result).toEqual({ name: 'owner.user' });
+ });
+
+ it('falls back to authorName when owner has no display name', () => {
+ const result = resolveAgentOwnerContact(
+ { authorName: 'Legacy Author' },
+ { email: 'owner@example.com' },
+ );
+
+ expect(result).toEqual({ name: 'Legacy Author', email: 'owner@example.com' });
+ });
+
+ it('omits owner contact when no owner can be resolved', () => {
+ const result = resolveAgentOwnerContact({ authorName: 'Legacy Author' }, null);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('omits owner contact when all candidate fields are empty', () => {
+ const result = resolveAgentOwnerContact(
+ { authorName: ' ' },
+ { name: '', username: ' ', email: ' ' },
+ );
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/packages/api/src/agents/contact.ts b/packages/api/src/agents/contact.ts
new file mode 100644
index 0000000000..e5a9824e0c
--- /dev/null
+++ b/packages/api/src/agents/contact.ts
@@ -0,0 +1,55 @@
+import type { AgentOwnerContact } from 'librechat-data-provider';
+
+export interface AgentContactSource {
+ authorName?: string | null;
+ support_contact?: {
+ name?: string | null;
+ email?: string | null;
+ } | null;
+}
+
+export interface AgentOwnerContactSource {
+ name?: string | null;
+ username?: string | null;
+ email?: string | null;
+}
+
+const normalizeContactValue = (value?: string | null): string | undefined => {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : undefined;
+};
+
+export const hasSupportContact = (agent: AgentContactSource): boolean => {
+ const support = agent.support_contact;
+ if (!support) {
+ return false;
+ }
+ return !!normalizeContactValue(support.name) || !!normalizeContactValue(support.email);
+};
+
+export function resolveAgentOwnerContact(
+ agent: AgentContactSource,
+ owner: AgentOwnerContactSource | null,
+): AgentOwnerContact | undefined {
+ if (hasSupportContact(agent) || owner == null) {
+ return undefined;
+ }
+
+ const name =
+ normalizeContactValue(owner.name) ??
+ normalizeContactValue(owner.username) ??
+ normalizeContactValue(agent.authorName);
+ const email = normalizeContactValue(owner.email);
+
+ if (!name && !email) {
+ return undefined;
+ }
+
+ return {
+ ...(name ? { name } : {}),
+ ...(email ? { email } : {}),
+ };
+}
diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts
index fc14367e3e..a94eb39d9d 100644
--- a/packages/api/src/agents/index.ts
+++ b/packages/api/src/agents/index.ts
@@ -3,6 +3,7 @@ export * from './attachments';
export * from './chain';
export * from './client';
export * from './config';
+export * from './contact';
export * from './context';
export * from './discovery';
export * from './edges';
diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts
index b222c718ed..bae2a7a72f 100644
--- a/packages/data-provider/src/types/assistants.ts
+++ b/packages/data-provider/src/types/assistants.ts
@@ -209,6 +209,11 @@ export type SupportContact = {
email?: string;
};
+export type AgentOwnerContact = {
+ name?: string;
+ email?: string;
+};
+
/**
* Specifies who can invoke a tool.
* - 'direct': LLM can call directly
@@ -286,6 +291,7 @@ export type Agent = {
version?: number;
category?: string;
support_contact?: SupportContact;
+ owner_contact?: AgentOwnerContact;
/** Per-tool configuration options (deferred loading, allowed callers, etc.) */
tool_options?: AgentToolOptions;
/** Optional allowlist of skill ObjectIds. Only applies when `skills_enabled`. */
diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts
index 08471bf5c8..a71591844f 100644
--- a/packages/data-schemas/src/schema/index.ts
+++ b/packages/data-schemas/src/schema/index.ts
@@ -1,5 +1,6 @@
export { default as actionSchema } from './action';
export { default as agentSchema } from './agent';
+export { default as aclEntrySchema } from './aclEntry';
export { default as agentApiKeySchema } from './agentApiKey';
export { default as agentCategorySchema } from './agentCategory';
export { default as assistantSchema } from './assistant';