diff --git a/apps/web/src/app/api/tools/[id]/rate/route.ts b/apps/web/src/app/api/tools/[id]/rate/route.ts
new file mode 100644
index 0000000..52eb63a
--- /dev/null
+++ b/apps/web/src/app/api/tools/[id]/rate/route.ts
@@ -0,0 +1,341 @@
+import { prisma } from '@tpmjs/db';
+import { headers } from 'next/headers';
+import { type NextRequest, NextResponse } from 'next/server';
+import { auth } from '~/lib/auth';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+export const maxDuration = 60;
+
+const API_VERSION = '1.0.0';
+
+interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: {
+ code: string;
+ message: string;
+ details?: Record;
+ };
+ meta: {
+ version: string;
+ timestamp: string;
+ requestId?: string;
+ };
+}
+
+interface RouteContext {
+ params: Promise<{ id: string }>;
+}
+
+/**
+ * GET /api/tools/[id]/rate
+ * Get the current user's rating for this tool and aggregate stats
+ */
+export async function GET(
+ _request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ // Get tool rating stats (always available)
+ const tool = await prisma.tool.findUnique({
+ where: { id },
+ select: {
+ averageRating: true,
+ ratingCount: true,
+ },
+ });
+
+ if (!tool) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Tool not found' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 404 }
+ );
+ }
+
+ // Get user's rating if logged in
+ let userRating: number | null = null;
+ if (session) {
+ const rating = await prisma.toolRating.findUnique({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ });
+ userRating = rating?.rating ?? null;
+ }
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ userRating,
+ averageRating: tool.averageRating ? Number(tool.averageRating) : null,
+ ratingCount: tool.ratingCount,
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ } catch (error) {
+ console.error('[API Error] GET /api/tools/[id]/rate:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to get rating' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * POST /api/tools/[id]/rate
+ * Rate a tool (1-5 stars)
+ */
+export async function POST(
+ request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 401 }
+ );
+ }
+
+ // Parse request body
+ const body = await request.json();
+ const rating = body.rating;
+
+ // Validate rating
+ if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+
+ // Check tool exists
+ const tool = await prisma.tool.findUnique({
+ where: { id },
+ });
+
+ if (!tool) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Tool not found' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 404 }
+ );
+ }
+
+ // Upsert rating and recalculate aggregates
+ await prisma.$transaction(async (tx) => {
+ // Upsert user's rating
+ await tx.toolRating.upsert({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ create: {
+ userId: session.user.id,
+ toolId: id,
+ rating,
+ },
+ update: {
+ rating,
+ },
+ });
+
+ // Recalculate average rating
+ const aggregates = await tx.toolRating.aggregate({
+ where: { toolId: id },
+ _avg: { rating: true },
+ _count: { rating: true },
+ });
+
+ // Update tool with new aggregates
+ await tx.tool.update({
+ where: { id },
+ data: {
+ averageRating: aggregates._avg.rating,
+ ratingCount: aggregates._count.rating,
+ },
+ });
+ });
+
+ // Get updated stats
+ const updatedTool = await prisma.tool.findUnique({
+ where: { id },
+ select: {
+ averageRating: true,
+ ratingCount: true,
+ },
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ userRating: rating,
+ averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null,
+ ratingCount: updatedTool?.ratingCount ?? 0,
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ } catch (error) {
+ console.error('[API Error] POST /api/tools/[id]/rate:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to rate tool' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE /api/tools/[id]/rate
+ * Remove user's rating for a tool
+ */
+export async function DELETE(
+ _request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 401 }
+ );
+ }
+
+ // Check if rating exists
+ const existingRating = await prisma.toolRating.findUnique({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ });
+
+ if (!existingRating) {
+ const tool = await prisma.tool.findUnique({
+ where: { id },
+ select: { averageRating: true, ratingCount: true },
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ userRating: null,
+ averageRating: tool?.averageRating ? Number(tool.averageRating) : null,
+ ratingCount: tool?.ratingCount ?? 0,
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ }
+
+ // Delete rating and recalculate aggregates
+ await prisma.$transaction(async (tx) => {
+ await tx.toolRating.delete({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ });
+
+ // Recalculate average rating
+ const aggregates = await tx.toolRating.aggregate({
+ where: { toolId: id },
+ _avg: { rating: true },
+ _count: { rating: true },
+ });
+
+ // Update tool with new aggregates
+ await tx.tool.update({
+ where: { id },
+ data: {
+ averageRating: aggregates._avg.rating,
+ ratingCount: aggregates._count.rating,
+ },
+ });
+ });
+
+ // Get updated stats
+ const updatedTool = await prisma.tool.findUnique({
+ where: { id },
+ select: {
+ averageRating: true,
+ ratingCount: true,
+ },
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ userRating: null,
+ averageRating: updatedTool?.averageRating ? Number(updatedTool.averageRating) : null,
+ ratingCount: updatedTool?.ratingCount ?? 0,
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ } catch (error) {
+ console.error('[API Error] DELETE /api/tools/[id]/rate:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove rating' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/tools/[id]/reviews/route.ts b/apps/web/src/app/api/tools/[id]/reviews/route.ts
new file mode 100644
index 0000000..7fdea32
--- /dev/null
+++ b/apps/web/src/app/api/tools/[id]/reviews/route.ts
@@ -0,0 +1,469 @@
+import { prisma } from '@tpmjs/db';
+import { headers } from 'next/headers';
+import { type NextRequest, NextResponse } from 'next/server';
+import { auth } from '~/lib/auth';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+export const maxDuration = 60;
+
+const API_VERSION = '1.0.0';
+
+interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: {
+ code: string;
+ message: string;
+ details?: Record;
+ };
+ meta: {
+ version: string;
+ timestamp: string;
+ requestId?: string;
+ };
+ pagination?: {
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+ };
+}
+
+interface RouteContext {
+ params: Promise<{ id: string }>;
+}
+
+/**
+ * GET /api/tools/[id]/reviews
+ * Get reviews for a tool
+ */
+export async function GET(
+ request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20));
+ const offset = Math.max(0, Number(searchParams.get('offset')) || 0);
+ const sort = searchParams.get('sort') || 'recent'; // 'recent', 'helpful', 'highest', 'lowest'
+
+ // Check tool exists
+ const tool = await prisma.tool.findUnique({
+ where: { id },
+ select: { id: true, reviewCount: true },
+ });
+
+ if (!tool) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Tool not found' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 404 }
+ );
+ }
+
+ // Build order by clause
+ let orderBy: Record[];
+ switch (sort) {
+ case 'helpful':
+ orderBy = [{ helpfulCount: 'desc' }, { createdAt: 'desc' }];
+ break;
+ case 'highest':
+ orderBy = [{ rating: 'desc' }, { createdAt: 'desc' }];
+ break;
+ case 'lowest':
+ orderBy = [{ rating: 'asc' }, { createdAt: 'desc' }];
+ break;
+ case 'recent':
+ default:
+ orderBy = [{ createdAt: 'desc' }];
+ }
+
+ // Fetch reviews with user info
+ const reviews = await prisma.toolReview.findMany({
+ where: {
+ toolId: id,
+ isApproved: true,
+ isHidden: false,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ username: true,
+ },
+ },
+ },
+ orderBy,
+ take: limit + 1,
+ skip: offset,
+ });
+
+ const hasMore = reviews.length > limit;
+ const actualReviews = hasMore ? reviews.slice(0, limit) : reviews;
+
+ return NextResponse.json({
+ success: true,
+ data: actualReviews.map((review) => ({
+ id: review.id,
+ title: review.title,
+ content: review.content,
+ rating: review.rating,
+ helpfulCount: review.helpfulCount,
+ createdAt: review.createdAt.toISOString(),
+ updatedAt: review.updatedAt.toISOString(),
+ user: {
+ id: review.user.id,
+ name: review.user.name,
+ image: review.user.image,
+ username: review.user.username,
+ },
+ })),
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ pagination: {
+ limit,
+ offset,
+ hasMore,
+ },
+ });
+ } catch (error) {
+ console.error('[API Error] GET /api/tools/[id]/reviews:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to get reviews' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * POST /api/tools/[id]/reviews
+ * Create or update a review for a tool
+ */
+export async function POST(
+ request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 401 }
+ );
+ }
+
+ // Parse request body
+ const body = await request.json();
+ const { title, content, rating } = body;
+
+ // Validate content
+ if (typeof content !== 'string' || content.trim().length < 10) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: {
+ code: 'INVALID_CONTENT',
+ message: 'Review content must be at least 10 characters',
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+
+ if (content.length > 5000) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: {
+ code: 'CONTENT_TOO_LONG',
+ message: 'Review content must be less than 5000 characters',
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+
+ // Validate title if provided
+ if (title !== undefined && title !== null) {
+ if (typeof title !== 'string') {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INVALID_TITLE', message: 'Title must be a string' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+ if (title.length > 200) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'TITLE_TOO_LONG', message: 'Title must be less than 200 characters' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+ }
+
+ // Validate rating
+ if (typeof rating !== 'number' || rating < 1 || rating > 5 || !Number.isInteger(rating)) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INVALID_RATING', message: 'Rating must be an integer between 1 and 5' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 400 }
+ );
+ }
+
+ // Check tool exists
+ const tool = await prisma.tool.findUnique({
+ where: { id },
+ });
+
+ if (!tool) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'NOT_FOUND', message: 'Tool not found' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 404 }
+ );
+ }
+
+ // Check if user already has a review
+ const existingReview = await prisma.toolReview.findUnique({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ });
+
+ let review: {
+ id: string;
+ title: string | null;
+ content: string;
+ rating: number;
+ helpfulCount: number;
+ createdAt: Date;
+ updatedAt: Date;
+ user: { id: string; name: string | null; image: string | null; username: string | null };
+ } | null = null;
+ await prisma.$transaction(async (tx) => {
+ if (existingReview) {
+ // Update existing review
+ review = await tx.toolReview.update({
+ where: { id: existingReview.id },
+ data: {
+ title: title?.trim() || null,
+ content: content.trim(),
+ rating,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ username: true,
+ },
+ },
+ },
+ });
+ } else {
+ // Create new review
+ review = await tx.toolReview.create({
+ data: {
+ userId: session.user.id,
+ toolId: id,
+ title: title?.trim() || null,
+ content: content.trim(),
+ rating,
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ image: true,
+ username: true,
+ },
+ },
+ },
+ });
+
+ // Increment review count
+ await tx.tool.update({
+ where: { id },
+ data: { reviewCount: { increment: 1 } },
+ });
+ }
+
+ // Also update rating aggregate (upsert the rating)
+ await tx.toolRating.upsert({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ create: {
+ userId: session.user.id,
+ toolId: id,
+ rating,
+ },
+ update: {
+ rating,
+ },
+ });
+
+ // Recalculate average rating
+ const aggregates = await tx.toolRating.aggregate({
+ where: { toolId: id },
+ _avg: { rating: true },
+ _count: { rating: true },
+ });
+
+ await tx.tool.update({
+ where: { id },
+ data: {
+ averageRating: aggregates._avg.rating,
+ ratingCount: aggregates._count.rating,
+ },
+ });
+ });
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ id: review!.id,
+ title: review!.title,
+ content: review!.content,
+ rating: review!.rating,
+ helpfulCount: review!.helpfulCount,
+ createdAt: review!.createdAt.toISOString(),
+ updatedAt: review!.updatedAt.toISOString(),
+ user: {
+ id: review!.user.id,
+ name: review!.user.name,
+ image: review!.user.image,
+ username: review!.user.username,
+ },
+ },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ } catch (error) {
+ console.error('[API Error] POST /api/tools/[id]/reviews:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to create review' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
+
+/**
+ * DELETE /api/tools/[id]/reviews
+ * Delete user's review for a tool
+ */
+export async function DELETE(
+ _request: NextRequest,
+ context: RouteContext
+): Promise> {
+ const requestId = crypto.randomUUID();
+ const { id } = await context.params;
+
+ try {
+ const session = await auth.api.getSession({
+ headers: await headers(),
+ });
+
+ if (!session) {
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 401 }
+ );
+ }
+
+ // Check if review exists
+ const existingReview = await prisma.toolReview.findUnique({
+ where: {
+ userId_toolId: {
+ userId: session.user.id,
+ toolId: id,
+ },
+ },
+ });
+
+ if (!existingReview) {
+ return NextResponse.json({
+ success: true,
+ data: { deleted: false, message: 'No review to delete' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ }
+
+ // Delete review and update count
+ await prisma.$transaction([
+ prisma.toolReview.delete({
+ where: { id: existingReview.id },
+ }),
+ prisma.tool.update({
+ where: { id },
+ data: { reviewCount: { decrement: 1 } },
+ }),
+ ]);
+
+ return NextResponse.json({
+ success: true,
+ data: { deleted: true },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ });
+ } catch (error) {
+ console.error('[API Error] DELETE /api/tools/[id]/reviews:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete review' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/api/tools/trending/route.ts b/apps/web/src/app/api/tools/trending/route.ts
new file mode 100644
index 0000000..60611bb
--- /dev/null
+++ b/apps/web/src/app/api/tools/trending/route.ts
@@ -0,0 +1,181 @@
+import { prisma } from '@tpmjs/db';
+import { type NextRequest, NextResponse } from 'next/server';
+
+export const runtime = 'nodejs';
+export const dynamic = 'force-dynamic';
+export const maxDuration = 60;
+
+const API_VERSION = '1.0.0';
+
+interface ApiResponse {
+ success: boolean;
+ data?: T;
+ error?: {
+ code: string;
+ message: string;
+ details?: Record;
+ };
+ meta: {
+ version: string;
+ timestamp: string;
+ requestId?: string;
+ };
+ pagination?: {
+ limit: number;
+ offset: number;
+ hasMore: boolean;
+ };
+}
+
+/**
+ * GET /api/tools/trending
+ * Get trending tools based on a composite score of downloads, ratings, and likes
+ *
+ * Trending score = (normalized_downloads * 0.4) + (average_rating * 0.3) + (like_count * 0.2) + (recency * 0.1)
+ */
+export async function GET(request: NextRequest): Promise> {
+ const requestId = crypto.randomUUID();
+
+ try {
+ const { searchParams } = new URL(request.url);
+ const limit = Math.min(50, Math.max(1, Number(searchParams.get('limit')) || 20));
+ const offset = Math.max(0, Number(searchParams.get('offset')) || 0);
+ const category = searchParams.get('category');
+ const period = searchParams.get('period') || 'week'; // 'day', 'week', 'month', 'all'
+
+ // Build where clause
+ const where: Record = {
+ importHealth: 'HEALTHY',
+ };
+
+ if (category) {
+ where.package = { category };
+ }
+
+ // Get date cutoff for recency boost
+ const now = new Date();
+ let recencyPeriodMs: number;
+ switch (period) {
+ case 'day':
+ recencyPeriodMs = 24 * 60 * 60 * 1000;
+ break;
+ case 'week':
+ recencyPeriodMs = 7 * 24 * 60 * 60 * 1000;
+ break;
+ case 'month':
+ recencyPeriodMs = 30 * 24 * 60 * 60 * 1000;
+ break;
+ case 'all':
+ default:
+ recencyPeriodMs = 365 * 24 * 60 * 60 * 1000; // 1 year
+ }
+
+ // Fetch tools with package data
+ // For trending, we order by a combination of factors
+ const tools = await prisma.tool.findMany({
+ where,
+ include: {
+ package: {
+ select: {
+ id: true,
+ npmPackageName: true,
+ npmVersion: true,
+ npmDescription: true,
+ category: true,
+ isOfficial: true,
+ npmDownloadsLastMonth: true,
+ githubStars: true,
+ tier: true,
+ },
+ },
+ },
+ orderBy: [
+ // Primary: quality score (includes downloads)
+ { qualityScore: 'desc' },
+ // Secondary: average rating
+ { averageRating: 'desc' },
+ // Tertiary: like count
+ { likeCount: 'desc' },
+ // Finally: recency
+ { createdAt: 'desc' },
+ ],
+ take: limit + 1,
+ skip: offset,
+ });
+
+ const hasMore = tools.length > limit;
+ const actualTools = hasMore ? tools.slice(0, limit) : tools;
+
+ // Calculate trending scores for display
+ const maxDownloads = Math.max(
+ ...actualTools.map((t) => t.package.npmDownloadsLastMonth || 0),
+ 1
+ );
+ const maxLikes = Math.max(...actualTools.map((t) => t.likeCount), 1);
+
+ const toolsWithScores = actualTools.map((tool) => {
+ const downloads = tool.package.npmDownloadsLastMonth || 0;
+ const rating = tool.averageRating ? Number(tool.averageRating) : 0;
+ const likes = tool.likeCount;
+ const ageMs = now.getTime() - tool.createdAt.getTime();
+ const recencyScore = Math.max(0, 1 - ageMs / recencyPeriodMs); // 0-1, higher for newer
+
+ // Trending score calculation
+ const trendingScore =
+ (downloads / maxDownloads) * 0.4 +
+ (rating / 5) * 0.3 +
+ (likes / maxLikes) * 0.2 +
+ recencyScore * 0.1;
+
+ return {
+ id: tool.id,
+ name: tool.name,
+ description: tool.description,
+ qualityScore: tool.qualityScore ? Number(tool.qualityScore) : null,
+ averageRating: tool.averageRating ? Number(tool.averageRating) : null,
+ ratingCount: tool.ratingCount,
+ reviewCount: tool.reviewCount,
+ likeCount: tool.likeCount,
+ importHealth: tool.importHealth,
+ executionHealth: tool.executionHealth,
+ trendingScore: Math.round(trendingScore * 100) / 100,
+ createdAt: tool.createdAt.toISOString(),
+ package: {
+ id: tool.package.id,
+ npmPackageName: tool.package.npmPackageName,
+ npmVersion: tool.package.npmVersion,
+ npmDescription: tool.package.npmDescription,
+ category: tool.package.category,
+ isOfficial: tool.package.isOfficial,
+ npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth,
+ githubStars: tool.package.githubStars,
+ tier: tool.package.tier,
+ },
+ };
+ });
+
+ // Sort by trending score
+ toolsWithScores.sort((a, b) => b.trendingScore - a.trendingScore);
+
+ return NextResponse.json({
+ success: true,
+ data: toolsWithScores,
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ pagination: {
+ limit,
+ offset,
+ hasMore,
+ },
+ });
+ } catch (error) {
+ console.error('[API Error] GET /api/tools/trending:', error);
+ return NextResponse.json(
+ {
+ success: false,
+ error: { code: 'INTERNAL_ERROR', message: 'Failed to get trending tools' },
+ meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
+ },
+ { status: 500 }
+ );
+ }
+}
diff --git a/apps/web/src/app/docs/api/agents/page.tsx b/apps/web/src/app/docs/api/agents/page.tsx
new file mode 100644
index 0000000..7efe8a6
--- /dev/null
+++ b/apps/web/src/app/docs/api/agents/page.tsx
@@ -0,0 +1,204 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Agents API | TPMJS Docs',
+ description: 'API reference for TPMJS agents endpoints',
+};
+
+export default function AgentsApiPage(): React.ReactElement {
+ return (
+
+
+
Agents API
+
+ The Agents API allows you to manage and interact with AI agents that can use TPMJS tools.
+
+
+
+ {/* List User Agents */}
+
+
+ List Agents
+ GET /api/agents
+
+
+
+ Retrieve a list of your agents. Requires authentication.
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ {/* Create Agent */}
+
+
+ Create Agent
+ POST /api/agents
+
+
+
+ Create a new AI agent with specified configuration.
+
+
+ Request Body
+
+
+ TypeScript Example
+
+
+
+
+ {/* Chat with Agent */}
+
+
+ Chat with Agent
+ POST /api/agents/:uid/chat
+
+
+
+ Send a message to an agent and receive a streaming response. The agent can use any tools
+ attached to it.
+
+
+ Request Body
+
+
+ TypeScript Example
+
+
+
+
+ {/* Add Tool to Agent */}
+
+
+ Add Tool to Agent
+ POST /api/agents/:uid/tools
+
+
+
+ Add a tool to an agent's available tools.
+
+
+ Request Body
+
+
+
+
+ {/* Add Collection to Agent */}
+
+
+ Add Collection to Agent
+ POST /api/agents/:uid/collections
+
+
+ Add all tools from a collection to an agent.
+
+ Request Body
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/docs/api/authentication/page.tsx b/apps/web/src/app/docs/api/authentication/page.tsx
new file mode 100644
index 0000000..69a8239
--- /dev/null
+++ b/apps/web/src/app/docs/api/authentication/page.tsx
@@ -0,0 +1,316 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Authentication | TPMJS Docs',
+ description: 'Authentication guide for the TPMJS API',
+};
+
+export default function AuthenticationPage(): React.ReactElement {
+ return (
+
+
+
Authentication
+
+ Learn how to authenticate with the TPMJS API to access protected endpoints.
+
+
+
+ {/* Overview */}
+
+
+ Overview
+ Understanding TPMJS authentication
+
+
+
+ TPMJS uses API keys for authentication. Some endpoints are public and don't require
+ authentication, while others require a valid API key to access.
+
+
+ Public Endpoints (No Auth Required)
+
+
+ GET /api/tools - List and search tools
+
+
+ GET /api/tools/:id - Get tool details
+
+
+ GET /api/tools/trending - Get trending tools
+
+
+ GET /api/collections - List public collections
+
+
+ GET /api/collections/:uid - Get collection details
+
+
+
+ Protected Endpoints (Auth Required)
+
+
+ POST /api/tools/:id/rate - Rate a tool
+
+
+ POST /api/tools/:id/reviews - Write a review
+
+
+ POST /api/collections - Create a collection
+
+
+ POST /api/agents - Create an agent
+
+
+ GET /api/agents - List your agents
+
+
+
+
+
+ {/* Getting an API Key */}
+
+
+ Getting an API Key
+ How to obtain your API key
+
+
+
+ To get an API key, you need to create a TPMJS account and generate a key from your
+ dashboard.
+
+
+
+ Sign up or log in at tpmjs.com
+ Navigate to Settings → API Keys
+ Click "Generate New Key"
+ Copy your key and store it securely
+
+
+
+
Important
+
+ Your API key is displayed only once when created. Make sure to copy and store it
+ securely. If you lose it, you'll need to generate a new one.
+
+
+
+
+
+ {/* Using Your API Key */}
+
+
+ Using Your API Key
+ How to authenticate requests
+
+
+
+ Include your API key in the Authorization header using the Bearer scheme.
+
+
+ Header Format
+
+
+ cURL Example
+
+
+ JavaScript/TypeScript Example
+
+
+ Python Example
+
+
+
+
+ {/* Error Responses */}
+
+
+ Authentication Errors
+ Common authentication error responses
+
+
+ 401 Unauthorized
+
+ Returned when no API key is provided or the key is invalid.
+
+
+
+ 403 Forbidden
+
+ Returned when the API key is valid but lacks permission for the requested action.
+
+
+
+ 429 Rate Limited
+
+ Returned when you've exceeded the rate limit for your API key.
+
+
+
+
+
+ {/* Rate Limits */}
+
+
+ Rate Limits
+ API request limits
+
+
+
+ TPMJS enforces rate limits to ensure fair usage and protect the API from abuse.
+
+
+
+
+
+
+ Tier
+ Requests/min
+ Requests/day
+
+
+
+
+ Free
+ 60
+ 1,000
+
+
+ Pro
+ 300
+ 10,000
+
+
+ Enterprise
+ Custom
+ Custom
+
+
+
+
+
+ Rate Limit Headers
+
+ Every response includes rate limit information in headers:
+
+
+
+
+
+ {/* Best Practices */}
+
+
+ Security Best Practices
+ Keep your API key secure
+
+
+
+
+ Never commit API keys to version control. Use environment variables
+ instead.
+
+
+ Rotate keys regularly. Generate new keys periodically and revoke old
+ ones.
+
+
+ Use separate keys for development and production. This limits the
+ impact if a key is compromised.
+
+
+ Monitor usage. Check your API usage in the dashboard to detect
+ unusual activity.
+
+
+ Revoke compromised keys immediately. If you suspect a key has been
+ exposed, revoke it and generate a new one.
+
+
+
+ Environment Variables Example
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/docs/api/collections/page.tsx b/apps/web/src/app/docs/api/collections/page.tsx
new file mode 100644
index 0000000..e6913c6
--- /dev/null
+++ b/apps/web/src/app/docs/api/collections/page.tsx
@@ -0,0 +1,359 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Collections API | TPMJS Docs',
+ description: 'API reference for TPMJS collections endpoints',
+};
+
+export default function CollectionsApiPage(): React.ReactElement {
+ return (
+
+
+
Collections API
+
+ The Collections API allows you to create and manage curated sets of tools for specific use
+ cases.
+
+
+
+ {/* List Collections */}
+
+
+ List Collections
+ GET /api/collections
+
+
+
+ Retrieve a paginated list of public collections with optional filtering.
+
+
+ Query Parameters
+
+
+ limit - Number of results (default: 20, max: 50)
+
+
+ offset - Pagination offset
+
+
+ q - Search query
+
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ {/* Get Collection */}
+
+
+ Get Collection
+ GET /api/collections/:uid
+
+
+
+ Retrieve detailed information about a specific collection, including its tools.
+
+
+ Path Parameters
+
+
+ uid - Collection unique identifier or slug
+
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ {/* Create Collection */}
+
+
+ Create Collection
+ POST /api/collections
+
+
+
+ Create a new collection. Requires authentication.
+
+
+ Request Body
+
+
+ TypeScript Example
+
+
+
+
+ {/* Update Collection */}
+
+
+ Update Collection
+ PATCH /api/collections/:uid
+
+
+
+ Update an existing collection. Requires authentication and ownership.
+
+
+ Request Body
+
+
+ Example Request
+
+
+
+
+ {/* Add Tool to Collection */}
+
+
+ Add Tool to Collection
+ POST /api/collections/:uid/tools
+
+
+
+ Add a tool to a collection. Requires authentication and ownership.
+
+
+ Request Body
+
+
+ TypeScript Example
+
+
+
+
+ {/* Remove Tool from Collection */}
+
+
+ Remove Tool from Collection
+ DELETE /api/collections/:uid/tools/:toolId
+
+
+
+ Remove a tool from a collection. Requires authentication and ownership.
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ {/* Like Collection */}
+
+
+ Like Collection
+ POST /api/collections/:uid/like
+
+
+
+ Like or unlike a collection. Requires authentication.
+
+
+ TypeScript Example
+
+
+
+
+ {/* Delete Collection */}
+
+
+ Delete Collection
+ DELETE /api/collections/:uid
+
+
+
+ Delete a collection. Requires authentication and ownership. This action cannot be
+ undone.
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/docs/api/tools/page.tsx b/apps/web/src/app/docs/api/tools/page.tsx
new file mode 100644
index 0000000..d1378e3
--- /dev/null
+++ b/apps/web/src/app/docs/api/tools/page.tsx
@@ -0,0 +1,220 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+
+export const metadata: Metadata = {
+ title: 'Tools API | TPMJS Docs',
+ description: 'API reference for TPMJS tools endpoints',
+};
+
+export default function ToolsApiPage(): React.ReactElement {
+ return (
+
+
+
Tools API
+
+ The Tools API allows you to search, discover, and execute TPMJS tools programmatically.
+
+
+
+ {/* List Tools */}
+
+
+ List Tools
+ GET /api/tools
+
+
+
+ Retrieve a paginated list of tools with optional filtering.
+
+
+ Query Parameters
+
+
+ limit - Number of results (default: 20, max: 50)
+
+
+ offset - Pagination offset
+
+
+ category - Filter by category
+
+
+ q - Search query
+
+
+
+ Example Request
+
+
+ Example Response
+
+
+
+
+ {/* Get Tool by ID */}
+
+
+ Get Tool
+ GET /api/tools/:id
+
+
+
+ Retrieve detailed information about a specific tool.
+
+
+ Path Parameters
+
+
+ id - Tool ID or package/tool slug
+
+
+
+ Example Request
+
+
+
+
+ {/* Execute Tool */}
+
+
+ Execute Tool
+ POST /api/tools/execute/:slug
+
+
+
+ Execute a tool with an AI agent. Returns streaming SSE response.
+
+
+ Request Body
+
+
+ TypeScript Example
+ console.log(text),
+});
+
+console.log(result.output);`}
+ language="typescript"
+ showCopy={true}
+ />
+
+
+
+ {/* Trending Tools */}
+
+
+ Trending Tools
+ GET /api/tools/trending
+
+
+
+ Get trending tools based on downloads, ratings, and activity.
+
+
+ Query Parameters
+
+
+ period - Time period: day, week, month, all (default: week)
+
+
+ category - Filter by category
+
+
+ limit - Number of results (default: 20)
+
+
+
+ Example Request
+
+
+
+
+ {/* Rate Tool */}
+
+
+ Rate Tool
+ POST /api/tools/:id/rate
+
+
+
+ Rate a tool from 1-5 stars. Requires authentication.
+
+
+ Request Body
+
+
+ Example Response
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/docs/quickstart/page.tsx b/apps/web/src/app/docs/quickstart/page.tsx
new file mode 100644
index 0000000..a839222
--- /dev/null
+++ b/apps/web/src/app/docs/quickstart/page.tsx
@@ -0,0 +1,417 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+import Link from 'next/link';
+
+export const metadata: Metadata = {
+ title: 'Quickstart Guide | TPMJS Docs',
+ description: 'Get started with TPMJS in under 5 minutes',
+};
+
+export default function QuickstartPage(): React.ReactElement {
+ return (
+
+
+
Quickstart Guide
+
+ Get your AI agent access to thousands of tools in under 5 minutes.
+
+
+
+ {/* Prerequisites */}
+
+
+ Prerequisites
+ What you need before starting
+
+
+
+ Node.js 18 or later
+ npm, pnpm, or yarn
+
+ An AI provider API key (OpenAI, Anthropic, or{' '}
+
+ any AI SDK provider
+
+ )
+
+
+
+
+
+ {/* Step 1: Installation */}
+
+
+ Step 1: Install the SDK
+ Add TPMJS packages to your project
+
+
+
+ Install the TPMJS SDK packages alongside the Vercel AI SDK:
+
+
+ npm
+
+
+ pnpm
+
+
+ yarn
+
+
+
+ Also install your preferred AI provider SDK:
+
+
+
+
+
+ {/* Step 2: Basic Usage */}
+
+
+ Step 2: Add Tools to Your Agent
+ Give your agent access to the TPMJS registry
+
+
+
+ Import the TPMJS tools and add them to your AI agent:
+
+
+
+
+
+
+ {/* Step 3: Understanding the Tools */}
+
+
+ Step 3: Understanding the Tools
+ How registrySearch and registryExecute work
+
+
+
+
registrySearchTool
+
+ Searches the TPMJS registry to find tools for any task. Returns metadata including the
+ toolId needed for execution.
+
+
+
+
+
+
registryExecuteTool
+
+ Executes any tool from the registry by its toolId. Tools run in a secure sandbox—no
+ local installation required.
+
+
+
+
+
+
+ {/* Step 4: Passing API Keys */}
+
+
+ Step 4: Configure API Keys
+ Pre-configure API keys for tools that need them
+
+
+
+ Many tools require API keys (e.g., Firecrawl, Exa, Tavily). Create a wrapper to
+ auto-inject your keys:
+
+
+ Create tools.ts
+ = {
+ FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!,
+ EXA_API_KEY: process.env.EXA_API_KEY!,
+ TAVILY_API_KEY: process.env.TAVILY_API_KEY!,
+};
+
+// Create a wrapped version that auto-injects keys
+export const registryExecute = tool({
+ description: registryExecuteTool.description,
+ parameters: registryExecuteTool.parameters,
+ execute: async ({ toolId, params }) => {
+ return registryExecuteTool.execute({ toolId, params, env: API_KEYS });
+ },
+});`}
+ language="typescript"
+ showCopy={true}
+ />
+
+ Use the wrapped tool
+
+
+
+
+ {/* Step 5: Real-World Example */}
+
+
+ Step 5: Complete Example
+ A full working example you can copy
+
+
+ agent.ts
+ = {
+ FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '',
+ EXA_API_KEY: process.env.EXA_API_KEY || '',
+};
+
+// Create the wrapped execute tool
+const registryExecute = {
+ ...registryExecuteTool,
+ execute: async (args: Parameters[0]) => {
+ return registryExecuteTool.execute({
+ ...args,
+ env: { ...API_KEYS, ...args.env },
+ });
+ },
+};
+
+// Main agent function
+async function runAgent(prompt: string) {
+ const result = await generateText({
+ model: anthropic('claude-sonnet-4-20250514'),
+ tools: {
+ registrySearch: registrySearchTool,
+ registryExecute,
+ },
+ maxSteps: 5, // Allow multiple tool calls
+ system: \`You are a helpful assistant with access to thousands of tools.
+
+Available tool workflow:
+1. Use registrySearch to find tools for your task
+2. Use registryExecute to run the tools you find
+3. Synthesize the results into a helpful response
+
+Always search for tools first before attempting to execute them.\`,
+ prompt,
+ });
+
+ return result.text;
+}
+
+// Example usage
+const response = await runAgent(
+ 'Search for web scraping tools, then scrape https://example.com and summarize it'
+);
+console.log(response);`}
+ language="typescript"
+ showCopy={true}
+ />
+
+
+
+ {/* Next Steps */}
+
+
+ Next Steps
+ Continue learning about TPMJS
+
+
+
+
+
+ SDK Documentation →
+
+
+ Detailed reference for all SDK functions and options
+
+
+
+
+ Tools API Reference →
+
+
+ REST API endpoints for searching and executing tools
+
+
+
+
+ Agents API Reference →
+
+
+ Create and manage AI agents with TPMJS tools
+
+
+
+
+ Publishing Guide →
+
+
+ Learn how to publish your own tools to TPMJS
+
+
+
+
+ Browse Tool Registry →
+
+
+ Explore available tools and find ones for your use case
+
+
+
+
+
+
+ {/* Troubleshooting */}
+
+
+ Common Issues
+ Solutions to frequently encountered problems
+
+
+
+
+
+ Tool execution fails with "missing API key"
+
+
+ Check that you're passing the required environment variables in the{' '}
+ env parameter of registryExecute.
+
+
+
+
+
+
Tool not found in search results
+
+ Try broader search terms or check the tool category. You can also browse the full
+ registry at{' '}
+
+ tpmjs.com
+
+ .
+
+
+
+
+
Agent not using the tools
+
+ Make sure your system prompt clearly instructs the agent to use registrySearch
+ first, then registryExecute. Also ensure{' '}
+ maxSteps is set high enough (default is 1).
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/docs/sdk/page.tsx b/apps/web/src/app/docs/sdk/page.tsx
new file mode 100644
index 0000000..6278d07
--- /dev/null
+++ b/apps/web/src/app/docs/sdk/page.tsx
@@ -0,0 +1,651 @@
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
+import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
+import type { Metadata } from 'next';
+import Link from 'next/link';
+
+export const metadata: Metadata = {
+ title: 'SDK Reference | TPMJS Docs',
+ description: 'Complete reference for the TPMJS SDK packages',
+};
+
+export default function SdkPage(): React.ReactElement {
+ return (
+
+
+
SDK Reference
+
+ Complete reference for the TPMJS SDK packages: @tpmjs/registry-search and
+ @tpmjs/registry-execute.
+
+
+
+ {/* Overview */}
+
+
+ Overview
+ TPMJS SDK packages
+
+
+
+ The TPMJS SDK consists of two packages that give your AI agent access to the tool
+ registry:
+
+
+
+
+
@tpmjs/registry-search
+
+ Search the TPMJS registry to find tools for any task
+
+
+
+
@tpmjs/registry-execute
+
+ Execute any tool from the registry in a secure sandbox
+
+
+
+
+ Installation
+
+
+ Peer Dependencies
+
+ Both packages require ai (Vercel AI SDK) and{' '}
+ zod as peer dependencies.
+
+
+
+
+
+ {/* registrySearchTool */}
+
+
+ registrySearchTool
+ @tpmjs/registry-search
+
+
+
+ An AI SDK tool that searches the TPMJS registry for tools matching a query.
+
+
+ Import
+
+
+ Basic Usage
+
+
+ Parameters
+
+
+
+
+ Parameter
+ Type
+ Required
+ Description
+
+
+
+
+ query
+ string
+ Yes
+
+ Search query (keywords, tool names, descriptions)
+
+
+
+ category
+ string
+ No
+ Filter by category
+
+
+ limit
+ number
+ No
+
+ Max results (1-20, default 5)
+
+
+
+
+
+
+ Return Value
+ ;
+}`}
+ language="typescript"
+ showCopy={true}
+ />
+
+ Available Categories
+
+ Filter results by category to narrow down your search:
+
+
+ {[
+ 'web-scraping',
+ 'search-engines',
+ 'ai-models',
+ 'data-processing',
+ 'communication',
+ 'file-management',
+ 'code-execution',
+ 'database',
+ 'calendar',
+ 'e-commerce',
+ 'finance',
+ 'social-media',
+ 'weather',
+ 'maps',
+ 'translation',
+ 'image-processing',
+ 'audio-processing',
+ 'video-processing',
+ 'utilities',
+ 'other',
+ ].map((cat) => (
+
+ {cat}
+
+ ))}
+
+
+
+
+ {/* registryExecuteTool */}
+
+
+ registryExecuteTool
+ @tpmjs/registry-execute
+
+
+
+ An AI SDK tool that executes any tool from the TPMJS registry in a secure sandbox.
+
+
+ Import
+
+
+ Basic Usage
+
+
+ Parameters
+
+
+
+
+ Parameter
+ Type
+ Required
+ Description
+
+
+
+
+ toolId
+ string
+ Yes
+
+ Tool identifier in format: package::name
+
+
+
+ params
+ object
+ Yes
+
+ Parameters to pass to the tool
+
+
+
+ env
+ object
+ No
+
+ Environment variables (API keys) to pass to the tool
+
+
+
+
+
+
+ Return Value
+
+
+ Passing API Keys
+
+
+
+
+ {/* Creating a Wrapped Execute Tool */}
+
+
+ Creating a Wrapped Execute Tool
+ Pre-configure API keys for seamless execution
+
+
+
+ Create a wrapper around registryExecuteTool to automatically inject your API keys:
+
+
+ = {
+ FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!,
+ EXA_API_KEY: process.env.EXA_API_KEY!,
+ TAVILY_API_KEY: process.env.TAVILY_API_KEY!,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
+};
+
+// Wrapped tool with pre-configured keys
+export const registryExecute = tool({
+ description: registryExecuteTool.description,
+ parameters: registryExecuteTool.parameters,
+ execute: async ({ toolId, params }) => {
+ return registryExecuteTool.execute({
+ toolId,
+ params,
+ env: API_KEYS,
+ });
+ },
+});`}
+ language="typescript"
+ showCopy={true}
+ />
+
+
+
+ {/* Direct API Usage */}
+
+
+ Direct API Usage
+ Use the SDK without AI agent integration
+
+
+
+ Both tools can also be used directly without an AI agent:
+
+
+ Direct Search
+
+
+ Direct Execution
+
+
+
+
+ {/* Environment Variables */}
+
+
+ Environment Variables
+ Configure SDK behavior
+
+
+
+ The SDK supports the following environment variables:
+
+
+
+
+
+
+ Variable
+ Default
+ Description
+
+
+
+
+ TPMJS_API_URL
+
+ https://tpmjs.com
+
+
+ Base URL for the TPMJS registry API
+
+
+
+ TPMJS_EXECUTOR_URL
+
+ https://executor.tpmjs.com
+
+
+ URL for the sandbox executor service
+
+
+
+
+
+
+ Self-Hosting
+
+ If you're running your own TPMJS registry, set these environment variables:
+
+
+
+
+
+ {/* TypeScript Types */}
+
+
+ TypeScript Types
+ Type definitions exported by the SDK
+
+
+ @tpmjs/registry-search
+
+
+ @tpmjs/registry-execute
+ ;
+ env?: Record;
+}
+
+// ExecuteToolResult
+interface ExecuteToolResult {
+ toolId: string;
+ executionTimeMs: number;
+ output: unknown;
+}`}
+ language="typescript"
+ showCopy={true}
+ />
+
+
+
+ {/* Error Handling */}
+
+
+ Error Handling
+ Handle errors from the SDK
+
+
+
+ Both SDK tools throw errors that can be caught and handled:
+
+
+
+
+
+
+ {/* Complete Example */}
+
+
+ Complete Example
+ Full agent implementation with both tools
+
+
+ = {
+ FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '',
+ EXA_API_KEY: process.env.EXA_API_KEY || '',
+};
+
+// Wrapped execute tool with pre-configured keys
+const registryExecute = tool({
+ description: registryExecuteTool.description,
+ parameters: registryExecuteTool.parameters,
+ execute: async ({ toolId, params }) => {
+ return registryExecuteTool.execute({ toolId, params, env: API_KEYS });
+ },
+});
+
+// System prompt that guides the agent
+const systemPrompt = \`You are a helpful assistant with access to thousands of tools.
+
+To complete tasks, follow this workflow:
+1. Use registrySearch to find relevant tools
+2. Review the search results and pick the best tool
+3. Use registryExecute to run the tool with appropriate parameters
+4. Synthesize the results into a helpful response
+
+Always explain what you're doing and why.\`;
+
+// Main function
+async function runAgent(userPrompt: string) {
+ const result = await streamText({
+ model: anthropic('claude-sonnet-4-20250514'),
+ tools: {
+ registrySearch: registrySearchTool,
+ registryExecute,
+ },
+ maxSteps: 10,
+ system: systemPrompt,
+ prompt: userPrompt,
+ });
+
+ // Stream the response
+ for await (const chunk of result.textStream) {
+ process.stdout.write(chunk);
+ }
+
+ console.log('\\n---');
+ console.log('Tool calls:', (await result.toolCalls).length);
+}
+
+// Usage
+await runAgent('Find a web scraping tool and scrape https://example.com');`}
+ language="typescript"
+ showCopy={true}
+ />
+
+
+
+ {/* Related Resources */}
+
+
+ Related Resources
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx
index 29af85a..c864762 100644
--- a/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx
+++ b/apps/web/src/app/tool/[...slug]/ToolDetailClient.tsx
@@ -13,7 +13,9 @@ import { useState } from 'react';
import { AppHeader } from '~/components/AppHeader';
import { BundleSize } from '~/components/BundleSize';
import { DownloadSparkline } from '~/components/DownloadSparkline';
+import { LikeButton } from '~/components/LikeButton';
import { Markdown } from '~/components/Markdown';
+import { Rating } from '~/components/Rating';
import { ToolPlayground } from '~/components/ToolPlayground';
interface Package {
@@ -67,6 +69,10 @@ export interface Tool {
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
healthCheckError?: string | null;
lastHealthCheck?: string | null;
+ likeCount?: number;
+ averageRating?: string | null;
+ ratingCount?: number;
+ reviewCount?: number;
package: Package;
createdAt: string;
updatedAt: string;
@@ -224,11 +230,30 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
)}
- {pkg.isOfficial && (
-
- Official
-
- )}
+
+ {pkg.isOfficial && (
+
+ Official
+
+ )}
+
+
+
+
+
{pkg.category}
diff --git a/apps/web/src/app/tool/[...slug]/page.tsx b/apps/web/src/app/tool/[...slug]/page.tsx
index 0937f29..cee4e3d 100644
--- a/apps/web/src/app/tool/[...slug]/page.tsx
+++ b/apps/web/src/app/tool/[...slug]/page.tsx
@@ -78,6 +78,10 @@ async function getTool(slug: string[]): Promise
{
executionHealth: tool.executionHealth ?? undefined,
healthCheckError: tool.healthCheckError ?? null,
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
+ likeCount: tool.likeCount,
+ averageRating: tool.averageRating?.toString() ?? null,
+ ratingCount: tool.ratingCount,
+ reviewCount: tool.reviewCount,
createdAt: tool.createdAt.toISOString(),
updatedAt: tool.updatedAt.toISOString(),
package: {
diff --git a/apps/web/src/components/Rating.tsx b/apps/web/src/components/Rating.tsx
new file mode 100644
index 0000000..d7ea6fc
--- /dev/null
+++ b/apps/web/src/components/Rating.tsx
@@ -0,0 +1,275 @@
+'use client';
+
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import { useCallback, useEffect, useState } from 'react';
+import { useSession } from '@/lib/auth-client';
+
+interface RatingProps {
+ toolId: string;
+ initialRating?: number | null;
+ initialAverageRating?: number | null;
+ initialRatingCount?: number;
+ size?: 'sm' | 'md' | 'lg';
+ showAverage?: boolean;
+ showCount?: boolean;
+ interactive?: boolean;
+ onRatingChange?: (
+ rating: number | null,
+ averageRating: number | null,
+ ratingCount: number
+ ) => void;
+}
+
+export function Rating({
+ toolId,
+ initialRating = null,
+ initialAverageRating = null,
+ initialRatingCount = 0,
+ size = 'md',
+ showAverage = true,
+ showCount = true,
+ interactive = true,
+ onRatingChange,
+}: RatingProps): React.ReactElement {
+ const { data: session } = useSession();
+ const [userRating, setUserRating] = useState(initialRating);
+ const [averageRating, setAverageRating] = useState(initialAverageRating);
+ const [ratingCount, setRatingCount] = useState(initialRatingCount);
+ const [hoverRating, setHoverRating] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [hasFetched, setHasFetched] = useState(false);
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ // Fetch initial rating status when user is logged in
+ useEffect(() => {
+ if (hasFetched) return;
+
+ const fetchRatingStatus = async () => {
+ try {
+ const response = await fetch(`/api/tools/${toolId}/rate`);
+ const data = await response.json();
+ if (data.success) {
+ setUserRating(data.data.userRating);
+ setAverageRating(data.data.averageRating);
+ setRatingCount(data.data.ratingCount);
+ }
+ } catch (error) {
+ console.error('Failed to fetch rating status:', error);
+ } finally {
+ setHasFetched(true);
+ }
+ };
+
+ fetchRatingStatus();
+ }, [toolId, hasFetched]);
+
+ // Update from props when they change
+ useEffect(() => {
+ if (!hasFetched) {
+ setUserRating(initialRating);
+ setAverageRating(initialAverageRating);
+ setRatingCount(initialRatingCount);
+ }
+ }, [initialRating, initialAverageRating, initialRatingCount, hasFetched]);
+
+ const submitRating = useCallback(
+ async (newRating: number | null, previousRating: number | null) => {
+ const response = await fetch(`/api/tools/${toolId}/rate`, {
+ method: newRating ? 'POST' : 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: newRating ? JSON.stringify({ rating: newRating }) : undefined,
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ setUserRating(data.data.userRating);
+ setAverageRating(data.data.averageRating);
+ setRatingCount(data.data.ratingCount);
+ onRatingChange?.(data.data.userRating, data.data.averageRating, data.data.ratingCount);
+ } else {
+ setUserRating(previousRating);
+ }
+ },
+ [toolId, onRatingChange]
+ );
+
+ const handleRate = useCallback(
+ async (rating: number) => {
+ if (!session) {
+ setShowTooltip(true);
+ setTimeout(() => setShowTooltip(false), 2000);
+ return;
+ }
+
+ if (isLoading || !interactive) return;
+
+ const newRating = userRating === rating ? null : rating;
+ const previousRating = userRating;
+
+ setUserRating(newRating);
+ setIsLoading(true);
+
+ try {
+ await submitRating(newRating, previousRating);
+ } catch (error) {
+ console.error('Failed to rate:', error);
+ setUserRating(previousRating);
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [session, userRating, isLoading, interactive, submitRating]
+ );
+
+ const sizeClasses = {
+ sm: 'w-4 h-4',
+ md: 'w-5 h-5',
+ lg: 'w-6 h-6',
+ };
+
+ const starSize = sizeClasses[size];
+
+ // Determine which rating to display (hover > user > average)
+ const displayRating = hoverRating ?? userRating ?? averageRating ?? 0;
+
+ return (
+
+
+ {[1, 2, 3, 4, 5].map((star) => {
+ const isFilled = star <= displayRating;
+ const isHalfFilled = !isFilled && star - 0.5 <= displayRating;
+
+ return (
+
handleRate(star)}
+ onMouseEnter={() => interactive && setHoverRating(star)}
+ onMouseLeave={() => interactive && setHoverRating(null)}
+ className={`
+ ${interactive ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
+ transition-transform duration-100
+ ${isLoading ? 'opacity-50' : ''}
+ focus:outline-none focus:ring-2 focus:ring-primary/50 rounded
+ `}
+ aria-label={`Rate ${star} stars`}
+ >
+
+ {isFilled ? (
+
+ ) : isHalfFilled ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+
+ {(showAverage || showCount) && (
+
+ {showAverage && averageRating !== null && (
+ {averageRating.toFixed(1)}
+ )}
+ {showCount && ratingCount > 0 && (
+
+ ({ratingCount} {ratingCount === 1 ? 'rating' : 'ratings'})
+
+ )}
+
+ )}
+
+ {showTooltip && (
+
+ )}
+
+ );
+}
+
+/**
+ * Display-only rating (non-interactive)
+ */
+interface RatingDisplayProps {
+ averageRating: number | null;
+ ratingCount?: number;
+ size?: 'sm' | 'md' | 'lg';
+ showCount?: boolean;
+ className?: string;
+}
+
+export function RatingDisplay({
+ averageRating,
+ ratingCount = 0,
+ size = 'sm',
+ showCount = true,
+ className = '',
+}: RatingDisplayProps): React.ReactElement {
+ const sizeClasses = {
+ sm: 'w-3 h-3',
+ md: 'w-4 h-4',
+ lg: 'w-5 h-5',
+ };
+
+ const starSize = sizeClasses[size];
+ const rating = averageRating ?? 0;
+
+ return (
+
+
+ {[1, 2, 3, 4, 5].map((star) => {
+ const isFilled = star <= rating;
+ const isHalfFilled = !isFilled && star - 0.5 <= rating;
+
+ return (
+
+ {isFilled ? (
+
+ ) : isHalfFilled ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ {averageRating !== null && (
+
+ {averageRating.toFixed(1)}
+
+ )}
+
+ {showCount && ratingCount > 0 && (
+
({ratingCount})
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/ReviewCard.tsx b/apps/web/src/components/ReviewCard.tsx
new file mode 100644
index 0000000..3f032e5
--- /dev/null
+++ b/apps/web/src/components/ReviewCard.tsx
@@ -0,0 +1,190 @@
+'use client';
+
+import { Card, CardContent } from '@tpmjs/ui/Card/Card';
+import { Icon } from '@tpmjs/ui/Icon/Icon';
+import Image from 'next/image';
+import Link from 'next/link';
+import { RatingDisplay } from './Rating';
+
+interface ReviewUser {
+ id: string;
+ name: string;
+ image: string | null;
+ username: string | null;
+}
+
+export interface Review {
+ id: string;
+ title: string | null;
+ content: string;
+ rating: number;
+ helpfulCount: number;
+ createdAt: string;
+ updatedAt: string;
+ user: ReviewUser;
+}
+
+interface ReviewCardProps {
+ review: Review;
+ className?: string;
+}
+
+function formatDate(dateString: string): string {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) {
+ return 'Today';
+ } else if (diffDays === 1) {
+ return 'Yesterday';
+ } else if (diffDays < 7) {
+ return `${diffDays} days ago`;
+ } else if (diffDays < 30) {
+ const weeks = Math.floor(diffDays / 7);
+ return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
+ } else if (diffDays < 365) {
+ const months = Math.floor(diffDays / 30);
+ return `${months} ${months === 1 ? 'month' : 'months'} ago`;
+ } else {
+ return date.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ }
+}
+
+function getInitials(name: string): string {
+ return name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2);
+}
+
+function UserAvatar({ user }: { user: ReviewUser }): React.ReactElement {
+ const initials = getInitials(user.name);
+
+ if (user.image) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {initials}
+
+ );
+}
+
+export function ReviewCard({ review, className = '' }: ReviewCardProps): React.ReactElement {
+ const userLink = review.user.username ? `/@${review.user.username}` : null;
+
+ return (
+
+
+ {/* Header */}
+
+ {/* Avatar */}
+ {userLink ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* User info and rating */}
+
+
+ {userLink ? (
+
+ {review.user.name}
+
+ ) : (
+ {review.user.name}
+ )}
+
+ {formatDate(review.createdAt)}
+
+
+
+
+
+
+ {/* Title */}
+ {review.title && {review.title} }
+
+ {/* Content */}
+ {review.content}
+
+ {/* Helpful count */}
+ {review.helpfulCount > 0 && (
+
+
+
+ {review.helpfulCount} {review.helpfulCount === 1 ? 'person' : 'people'} found this
+ helpful
+
+
+ )}
+
+
+ );
+}
+
+/**
+ * Reviews section with list of reviews and write review form
+ */
+interface ReviewsSectionProps {
+ initialReviews?: Review[];
+ className?: string;
+}
+
+export function ReviewsSection({
+ initialReviews = [],
+ className = '',
+}: ReviewsSectionProps): React.ReactElement {
+ // For now, just display the reviews statically
+ // Interactive features (write review, load more) can be added later
+
+ if (initialReviews.length === 0) {
+ return (
+
+
+
No reviews yet
+
+ Be the first to share your experience with this tool
+
+
+ );
+ }
+
+ return (
+
+ {initialReviews.map((review) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 97a6857..7d6390f 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -95,16 +95,26 @@ model Tool {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+ // Rating aggregates
+ averageRating Decimal? @map("average_rating") @db.Decimal(2, 1) // 1.0 to 5.0
+ ratingCount Int @default(0) @map("rating_count")
+ reviewCount Int @default(0) @map("review_count")
+
// Relations
simulations Simulation[]
healthChecks HealthCheck[]
collections CollectionTool[]
agents AgentTool[]
likes ToolLike[]
+ ratings ToolRating[]
+ reviews ToolReview[]
@@unique([packageId, name])
@@index([qualityScore])
@@index([likeCount])
+ @@index([averageRating])
+ @@index([ratingCount])
+ @@index([reviewCount])
@@index([importHealth])
@@index([executionHealth])
@@index([lastHealthCheck])
@@ -346,6 +356,8 @@ model User {
agents Agent[]
apiKeys UserApiKey[]
toolLikes ToolLike[]
+ toolRatings ToolRating[]
+ toolReviews ToolReview[]
collectionLikes CollectionLike[]
agentLikes AgentLike[]
activities UserActivity[]
@@ -761,6 +773,65 @@ model AgentLike {
@@map("agent_likes")
}
+/// ToolRating - user ratings for tools (1-5 stars)
+model ToolRating {
+ id String @id @default(cuid())
+
+ // Relationships
+ userId String @map("user_id")
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ toolId String @map("tool_id")
+ tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
+
+ // Rating value (1-5)
+ rating Int @db.SmallInt
+
+ // Timestamps
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@unique([userId, toolId])
+ @@index([toolId])
+ @@index([userId])
+ @@index([rating])
+ @@map("tool_ratings")
+}
+
+/// ToolReview - user reviews for tools
+model ToolReview {
+ id String @id @default(cuid())
+
+ // Relationships
+ userId String @map("user_id")
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ toolId String @map("tool_id")
+ tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
+
+ // Review content
+ title String? @db.VarChar(200)
+ content String @db.Text
+ rating Int @db.SmallInt // 1-5, denormalized for convenience
+
+ // Moderation
+ isApproved Boolean @default(true) @map("is_approved")
+ isHidden Boolean @default(false) @map("is_hidden")
+
+ // Helpful votes
+ helpfulCount Int @default(0) @map("helpful_count")
+
+ // Timestamps
+ createdAt DateTime @default(now()) @map("created_at")
+ updatedAt DateTime @updatedAt @map("updated_at")
+
+ @@unique([userId, toolId])
+ @@index([toolId])
+ @@index([userId])
+ @@index([rating])
+ @@index([isApproved, isHidden])
+ @@index([createdAt])
+ @@map("tool_reviews")
+}
+
// ============================================================================
// Activity Stream Models
// ============================================================================
diff --git a/packages/ui/src/Icon/icons.ts b/packages/ui/src/Icon/icons.ts
index 0d3aafd..f2a14a1 100644
--- a/packages/ui/src/Icon/icons.ts
+++ b/packages/ui/src/Icon/icons.ts
@@ -148,6 +148,14 @@ export const icons = {
viewBox: '0 0 24 24',
path: 'M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z',
},
+ star: {
+ viewBox: '0 0 24 24',
+ path: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
+ },
+ starFilled: {
+ viewBox: '0 0 24 24',
+ path: 'M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z',
+ },
} as const;
export type IconName = keyof typeof icons;