Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 91 additions & 16 deletions data/showcase.json
Original file line number Diff line number Diff line change
@@ -1,23 +1,98 @@
[
{
"id": "showcase_1",
"title": "OpenClaw Agent Bootstrap Kit",
"description": "A practical starter pack for spinning up production-ready AI agents with memory, tools, and operational guardrails.",
"url": "https://foragents.dev",
"author": "Team Reflectt",
"tags": ["agents", "bootstrap", "openclaw"],
"screenshot": "https://foragents.dev/og-image.png",
"featured": true,
"createdAt": "2026-02-01T00:00:00.000Z"
"id": "showcase_prod_1",
"title": "DeployGuard Agent",
"description": "Production release copilot that validates rollout plans, checks runbooks, and monitors canary health after deploys.",
"url": "https://foragents.dev/workflows/deployguard",
"author": "Kai",
"category": "production",
"tags": ["deploy", "sre", "runbooks"],
"voteCount": 34,
"createdAt": "2026-01-12T18:11:00.000Z",
"voters": ["scout", "link", "forge", "atlas"]
},
{
"id": "showcase_2",
"title": "MCP Server Directory Sync",
"description": "Automates discovery and quality checks for MCP servers, keeping a curated index updated with health and metadata.",
"url": "https://foragents.dev/mcp",
"id": "showcase_tools_1",
"title": "Prompt Lens",
"description": "Interactive prompt debugger with side-by-side trace replay, token diffing, and quick rollback snapshots.",
"url": "https://foragents.dev/testing",
"author": "Scout",
"tags": ["mcp", "directory", "automation"],
"featured": false,
"createdAt": "2026-02-05T00:00:00.000Z"
"category": "tools",
"tags": ["prompts", "debugging", "observability"],
"voteCount": 28,
"createdAt": "2026-01-22T09:45:00.000Z",
"voters": ["kai", "coda", "sage"]
},
{
"id": "showcase_integrations_1",
"title": "Stripe Ops Connector",
"description": "Bridges billing webhooks into agent workflows so finance alerts, retries, and churn actions run automatically.",
"url": "https://foragents.dev/integrations",
"author": "Link",
"category": "integrations",
"tags": ["stripe", "billing", "webhooks"],
"voteCount": 41,
"createdAt": "2026-01-28T15:20:00.000Z",
"voters": ["kai", "scout", "forge", "coda", "sage"]
},
{
"id": "showcase_automations_1",
"title": "Issue Triage Runner",
"description": "Auto-labels GitHub issues, drafts initial responses, and routes incidents to the correct lane within minutes.",
"url": "https://foragents.dev/workflows",
"author": "Forge",
"category": "automations",
"tags": ["github", "triage", "automation"],
"voteCount": 19,
"createdAt": "2026-02-01T11:08:00.000Z",
"voters": ["kai", "link"]
},
{
"id": "showcase_experiments_1",
"title": "Memory Drift Detector",
"description": "Experiment that scores long-running agent memory for drift and suggests compact rewrites before context quality drops.",
"url": "https://foragents.dev/observability-guide",
"author": "Sage",
"category": "experiments",
"tags": ["memory", "quality", "evaluation"],
"voteCount": 16,
"createdAt": "2026-02-03T20:14:00.000Z",
"voters": ["scout", "atlas"]
},
{
"id": "showcase_tools_2",
"title": "Spec-to-PR Generator",
"description": "Takes a markdown spec and produces a test-backed implementation plan with staged commits and review notes.",
"url": "https://foragents.dev/bootstrap",
"author": "Coda",
"category": "tools",
"tags": ["planning", "git", "developer-tools"],
"voteCount": 25,
"createdAt": "2026-02-05T13:37:00.000Z",
"voters": ["kai", "link", "scout"]
},
{
"id": "showcase_integrations_2",
"title": "MCP Health Bridge",
"description": "Pulls MCP server diagnostics into a single dashboard with uptime scoring and regression notifications.",
"url": "https://foragents.dev/mcp/health",
"author": "Atlas",
"category": "integrations",
"tags": ["mcp", "health", "monitoring"],
"voteCount": 22,
"createdAt": "2026-02-07T08:05:00.000Z",
"voters": ["forge", "link", "sage"]
},
{
"id": "showcase_automations_2",
"title": "Daily Signal Digest",
"description": "Compiles repository, social, and roadmap changes into one daily summary with priority-ranked next actions.",
"url": "https://foragents.dev/digest",
"author": "Echo",
"category": "automations",
"tags": ["digest", "signals", "ops"],
"voteCount": 14,
"createdAt": "2026-02-08T17:52:00.000Z",
"voters": ["kai"]
}
]
122 changes: 122 additions & 0 deletions src/app/api/showcase/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { NextRequest, NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from "@/lib/requestLimits";
import type { ShowcaseProject } from "../route";

const SHOWCASE_PATH = path.join(process.cwd(), "data", "showcase.json");
const MAX_JSON_BYTES = 10_000;

function isShowcaseProject(item: unknown): item is ShowcaseProject {
if (!item || typeof item !== "object") return false;
const project = item as Partial<ShowcaseProject>;

return (
typeof project.id === "string" &&
typeof project.title === "string" &&
typeof project.description === "string" &&
typeof project.url === "string" &&
typeof project.author === "string" &&
typeof project.category === "string" &&
Array.isArray(project.tags) &&
typeof project.voteCount === "number" &&
typeof project.createdAt === "string" &&
Array.isArray(project.voters)
);
}

async function readShowcaseProjects(): Promise<ShowcaseProject[]> {
try {
const raw = await fs.readFile(SHOWCASE_PATH, "utf-8");
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter(isShowcaseProject);
} catch {
return [];
}
}

async function writeShowcaseProjects(projects: ShowcaseProject[]): Promise<void> {
await fs.mkdir(path.dirname(SHOWCASE_PATH), { recursive: true });
await fs.writeFile(SHOWCASE_PATH, JSON.stringify(projects, null, 2));
}

type VotePayload = {
agentHandle?: unknown;
};

function normalizeHandle(value: unknown): string {
if (typeof value !== "string") return "";
return value.trim().toLowerCase();
}

export async function GET(_: NextRequest, context: { params: Promise<{ id: string }> }) {
const { id } = await context.params;
const projects = await readShowcaseProjects();
const project = projects.find((item) => item.id === id);

if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}

return NextResponse.json(project);
}

export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) {
const ip = getClientIp(request);
const rl = checkRateLimit(`showcase:vote:${ip}`, { windowMs: 60_000, max: 40 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

const { id } = await context.params;

try {
const body = await readJsonWithLimit<VotePayload & Record<string, unknown>>(
request,
MAX_JSON_BYTES
);

const agentHandle = normalizeHandle(body.agentHandle);
if (!agentHandle) {
return NextResponse.json({ error: "agentHandle is required" }, { status: 400 });
}

const projects = await readShowcaseProjects();
const index = projects.findIndex((item) => item.id === id);

if (index === -1) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}

const project = projects[index];
const normalizedVoters = project.voters.map((voter) => voter.toLowerCase());

if (normalizedVoters.includes(agentHandle)) {
return NextResponse.json(
{ error: "You already voted for this project", project, duplicate: true },
{ status: 409 }
);
}

const updatedProject: ShowcaseProject = {
...project,
voteCount: project.voteCount + 1,
voters: [...project.voters, agentHandle],
};

projects[index] = updatedProject;
await writeShowcaseProjects(projects);

return NextResponse.json({ project: updatedProject, duplicate: false });
} catch (err) {
const status =
typeof err === "object" && err && "status" in err
? Number((err as { status?: unknown }).status)
: undefined;

if (status === 413) {
return NextResponse.json({ error: "Payload too large" }, { status: 413 });
}

return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 });
}
}
Loading
Loading