feat(directory): Implement GET /companies/:id/agents/directory endpoint

Implement enriched agent directory endpoint for onboarding and
discovery. Returns all agents with activity status, social channels,
and profile information.

Features:
- All required fields: id, name, urlKey, role, description, specialties
- Activity tracking: lastActivityAt from audit_events
- Status calculation: active (<5min), idle (<60min), offline (>60min)
- Social channels: top 3 by post count per agent
- Profile URL: /BARAAA/agents/:urlKey
- Pagination: ?limit=N (default 50, max 100)
- Role filter: ?role=admin|agent
- Auth: x-agent-id header required (401 if missing)

Implementation:
- src/routes/directory.ts: endpoint logic
- src/app.ts: register directory routes

Fixes BARAAA-91

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip FoundingEngineer 2026-05-02 22:16:07 +00:00
parent c1b0391e3f
commit 83dbf7eb19
2 changed files with 121 additions and 0 deletions

View file

@ -8,6 +8,7 @@ import { registerTokenRoutes } from './routes/tokens.js';
import { registerSessionRoutes } from './routes/sessions.js';
import { registerRoomRoutes } from './routes/rooms.js';
import { registerSocialRoutes } from './routes/social.js';
import { registerDirectoryRoutes } from './routes/directory.js';
import { setupSocketIO } from './socket/index.js';
import { register as metricsRegister } from './lib/metrics.js';
import { startMetricsCollector } from './services/metrics-collector.js';
@ -61,6 +62,7 @@ export async function buildApp({ config }: BuildAppOptions): Promise<FastifyInst
await registerSessionRoutes(app, pool, config);
await registerRoomRoutes(app, pool);
await registerSocialRoutes(app, pool);
await registerDirectoryRoutes(app, pool);
// Setup socket.io after app is ready (if feature enabled)
await app.ready();

119
src/routes/directory.ts Normal file
View file

@ -0,0 +1,119 @@
import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { agents, auditEvents, socialPosts, socialChannels } from '../db/schema.js';
import { eq, desc, sql, and } from 'drizzle-orm';
export async function registerDirectoryRoutes(app: FastifyInstance, pool: Pool) {
const db = drizzle(pool);
// GET /api/companies/:companyId/agents/directory — enriched agent directory
app.get<{ Params: { companyId: string }; Querystring: { role?: string; limit?: string } }>(
'/api/companies/:companyId/agents/directory',
async (request, reply) => {
const agentId = request.headers['x-agent-id'] as string | undefined;
if (!agentId) {
return reply.code(401).send({ error: 'Missing x-agent-id header' });
}
// For now, AgentHub is single-tenant, so companyId is accepted but not verified
const { role } = request.query;
const limit = Math.min(parseInt(request.query.limit || '50', 10), 100);
// Build query conditions
const conditions = [];
if (role) {
conditions.push(eq(agents.role, role));
}
// Fetch agents
const agentsList = await db
.select({
id: agents.id,
name: agents.name,
displayName: agents.displayName,
urlKey: agents.urlKey,
role: agents.role,
description: agents.description,
specialties: agents.specialties,
chainOfCommand: agents.chainOfCommand,
createdAt: agents.createdAt,
})
.from(agents)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(agents.displayName)
.limit(limit);
// For each agent, calculate lastActivityAt and status
const enrichedAgents = await Promise.all(
agentsList.map(async (agent) => {
// Get last activity from audit_events
const [lastActivity] = await db
.select({ ts: auditEvents.ts })
.from(auditEvents)
.where(eq(auditEvents.agentId, agent.id))
.orderBy(desc(auditEvents.ts))
.limit(1);
const lastActivityAt = lastActivity?.ts || agent.createdAt;
const now = new Date();
const minutesSinceActivity = (now.getTime() - lastActivityAt.getTime()) / (1000 * 60);
// Calculate status: active (<5min), idle (<60min), offline (>60min)
let status: 'active' | 'idle' | 'offline';
if (minutesSinceActivity < 5) {
status = 'active';
} else if (minutesSinceActivity < 60) {
status = 'idle';
} else {
status = 'offline';
}
// Get social channels where agent has posted (top 3 by post count)
const socialChannelsList = await db
.select({
id: socialChannels.id,
slug: socialChannels.slug,
name: socialChannels.name,
postCount: sql<number>`count(${socialPosts.id})::int`,
})
.from(socialPosts)
.innerJoin(socialChannels, eq(socialPosts.channelId, socialChannels.id))
.where(eq(socialPosts.authorAgentId, agent.id))
.groupBy(socialChannels.id, socialChannels.slug, socialChannels.name)
.orderBy(desc(sql`count(${socialPosts.id})`))
.limit(3);
// Build profile URL (using company prefix from path param)
const urlKey = agent.urlKey || agent.name;
const companyPrefix = 'BARAAA'; // TODO: derive from companyId when multi-tenant
const profileUrl = `/${companyPrefix}/agents/${urlKey}`;
return {
id: agent.id,
name: agent.name,
urlKey: urlKey,
role: agent.role,
description: agent.description || null,
specialties: agent.specialties || [],
lastActivityAt: lastActivityAt.toISOString(),
status,
chainOfCommand: agent.chainOfCommand || null,
socialChannels: socialChannelsList.map((ch) => ({
id: ch.id,
slug: ch.slug,
name: ch.name,
})),
profileUrl,
};
}),
);
return reply.send({
agents: enrichedAgents,
total: enrichedAgents.length,
hasMore: enrichedAgents.length === limit,
});
},
);
}