Implement canonical service registry

This commit is contained in:
Sai-karthik
2026-06-22 21:25:38 +00:00
parent 1be3ab1961
commit c48c28fdb3
11 changed files with 980 additions and 215 deletions

View File

@@ -2,14 +2,15 @@ FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm install
RUN corepack enable && corepack prepare pnpm@10.24.0 --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY tsconfig.json ./
COPY src ./src
RUN npx tsc -p tsconfig.json
RUN ./node_modules/.bin/tsc -p tsconfig.json
FROM base AS runtime
ARG RIVET_RUNNER_VERSION=dev

View File

@@ -116,6 +116,10 @@ services:
ROLEPLAY_SERVICE_URL: ${ROLEPLAY_SERVICE_URL:-http://host.docker.internal:8008}
QSCORE_SERVICE_URL: ${QSCORE_SERVICE_URL:-http://host.docker.internal:8000}
RESUME_SERVICE_URL: ${RESUME_SERVICE_URL:-http://host.docker.internal:8002}
COURSES_SERVICE_URL: ${COURSES_SERVICE_URL:-http://host.docker.internal:8060}
ASSESSMENT_SERVICE_URL: ${ASSESSMENT_SERVICE_URL:-http://host.docker.internal:8070}
MATCHMAKING_SERVICE_URL: ${MATCHMAKING_SERVICE_URL:-http://host.docker.internal:8006}
PATHWAYS_SERVICE_URL: ${PATHWAYS_SERVICE_URL:-http://host.docker.internal:8009}
# Frontend
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
volumes:

View File

@@ -77,10 +77,28 @@ export const config = {
process.env.USER_SERVICE_URL ?? "http://localhost:8003",
resumePublicUrl:
process.env.RESUME_PUBLIC_URL ?? process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
coursesServiceUrl:
process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
coursesPublicUrl:
process.env.COURSES_PUBLIC_URL ?? process.env.COURSES_SERVICE_URL ?? "http://localhost:8060",
assessmentServiceUrl:
process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
assessmentPublicUrl:
process.env.ASSESSMENT_PUBLIC_URL ?? process.env.ASSESSMENT_SERVICE_URL ?? "http://localhost:8070",
matchmakingServiceUrl:
process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
matchmakingPublicUrl:
process.env.MATCHMAKING_PUBLIC_URL ?? process.env.MATCHMAKING_SERVICE_URL ?? "http://localhost:8006",
pathwaysServiceUrl:
process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
pathwaysPublicUrl:
process.env.PATHWAYS_PUBLIC_URL ?? process.env.PATHWAYS_SERVICE_URL ?? "http://localhost:8009",
socialBrandingServiceUrl:
process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
socialBrandingPublicUrl:
process.env.SOCIAL_BRANDING_PUBLIC_URL ?? process.env.SOCIAL_BRANDING_SERVICE_URL ?? "http://localhost:8005",
qscorePublicUrl:
process.env.QSCORE_PUBLIC_URL ?? process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
workflowsDashboardUrl:
process.env.WORKFLOWS_DASHBOARD_URL ??
process.env.FRONTEND_ORIGIN ??

View File

@@ -1,7 +1,17 @@
import { config } from "../config.js";
import { getService, listServices, type ServiceId } from "../services/service-registry.js";
export type GrowServiceId = "resume-service" | "interview-service" | "roleplay-service" | "qscore-service" | "social-branding-service" | "matchmaking-service";
export type GrowFeatureId = "resume-building" | "mock-interview" | "mock-roleplay" | "q-score" | "social-branding" | "matchmaking";
export type GrowServiceId = ServiceId;
export type GrowFeatureId =
| "resume-building"
| "cover-letter"
| "mock-interview"
| "mock-roleplay"
| "q-score"
| "social-branding"
| "matchmaking"
| "pathways"
| "courses"
| "assessment";
export type GrowFeatureDefinition = {
id: GrowFeatureId;
@@ -16,77 +26,18 @@ export type GrowFeatureDefinition = {
operations: string[];
};
export const featureDefinitions: GrowFeatureDefinition[] = [
{
id: "resume-building",
serviceId: "resume-service",
title: "Resume Building",
label: "Resume",
description: "Build, tailor, analyze, and improve resumes for role fit and ATS readiness.",
promptModulePath: "agents/resume.md",
enabled: Boolean(config.resumeServiceUrl),
internalUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
operations: ["resume.state", "resume.templates", "resume.a2aTask", "resume.create", "resume.update", "resume.analyze", "resume.suggestions", "resume.copilot", "resume.optimizeSummary", "resume.optimizeExperience", "resume.suggestSkills", "resume.generateSummary", "resume.versions", "resume.preview"],
},
{
id: "mock-interview",
serviceId: "interview-service",
title: "Mock Interview",
label: "Interview",
description: "Configure, practice, review, and score interview sessions.",
promptModulePath: "agents/interview.md",
enabled: Boolean(config.interviewServiceUrl),
internalUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
operations: ["interview.configure", "interview.preview", "interview.questions", "interview.approve", "interview.assignments", "interview.unassign", "interview.resultsBulk", "interview.review", "interview.leaderboard", "interview.artifacts", "interview.videoUpload", "interview.practice"],
},
{
id: "mock-roleplay",
serviceId: "roleplay-service",
title: "Mock Roleplay",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, manager conversations, and stakeholder roleplays.",
promptModulePath: "agents/roleplay.md",
enabled: Boolean(config.roleplayServiceUrl),
internalUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
operations: ["roleplay.configure", "roleplay.preview", "roleplay.questions", "roleplay.approve", "roleplay.assignments", "roleplay.unassign", "roleplay.resultsBulk", "roleplay.review", "roleplay.leaderboard", "roleplay.artifacts", "roleplay.videoUpload", "roleplay.practice"],
},
{
id: "q-score",
serviceId: "qscore-service",
title: "Q Score",
label: "Q Score",
description: "Analyze overall job-market readiness and convert signals into improvement priorities.",
promptModulePath: "agents/qscore.md",
enabled: Boolean(config.qscoreServiceUrl),
internalUrl: config.qscoreServiceUrl,
operations: ["qscore.ingest", "qscore.compute"],
},
{
id: "social-branding",
serviceId: "social-branding-service",
title: "Social Branding",
label: "Branding",
description: "Build and optimize your professional profile, LinkedIn presence, and personal brand.",
promptModulePath: "agents/social-branding.md",
enabled: Boolean(config.socialBrandingServiceUrl),
internalUrl: config.socialBrandingServiceUrl,
operations: ["branding.profile", "branding.linkedin", "branding.content", "branding.analyze"],
},
{
id: "matchmaking",
serviceId: "matchmaking-service",
title: "Matchmaking",
label: "Matchmaking",
description: "Connect with relevant professionals, mentors, and opportunities through curated matching.",
promptModulePath: "agents/matchmaking.md",
enabled: Boolean(config.matchmakingServiceUrl),
internalUrl: config.matchmakingServiceUrl,
operations: ["matchmaking.find", "matchmaking.connect", "matchmaking.schedule", "matchmaking.review"],
},
];
export const featureDefinitions: GrowFeatureDefinition[] = listServices().map((service) => ({
id: service.featureId as GrowFeatureId,
serviceId: service.id,
title: service.label,
label: service.label,
description: service.description,
promptModulePath: service.promptModulePath,
enabled: service.enabled,
internalUrl: service.backend.baseUrl,
publicUrl: service.backend.publicUrl,
operations: Object.keys(service.backend.endpoints),
}));
export const internalWorkflowModules = [
{
@@ -103,7 +54,8 @@ export function listFeatureDefinitions() {
}
export function getFeatureByServiceId(serviceId: string) {
return featureDefinitions.find((feature) => feature.serviceId === serviceId);
const service = getService(serviceId);
return service ? featureDefinitions.find((feature) => feature.serviceId === service.id) : undefined;
}
export function displayLabelForService(serviceId: string | undefined) {

View File

@@ -15,6 +15,7 @@ import {
type NewGrowHomeNotification,
} from "../db/schema.js";
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
import { buildServiceLink } from "../services/service-registry.js";
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
import { listAvailableMissionDefinitions } from "../missions/registry.js";
@@ -35,13 +36,13 @@ const FRESH_MS = 10 * 60 * 1000;
const EXPIRY_MS = 24 * 60 * 60 * 1000;
const SERVICE_HREFS = {
resume: "/agents/resume",
interview: "/agents/interview",
roleplay: "/agents/roleplay",
qscore: "/agents/qscore",
resume: buildServiceLink("resume-service", "workspace") ?? "/opportunities/resume",
interview: buildServiceLink("interview-service", "discovery") ?? "/upskilling/interview",
roleplay: buildServiceLink("roleplay-service", "discovery") ?? "/upskilling/roleplay",
qscore: buildServiceLink("qscore-service", "dashboard") ?? "/home",
mission: "/missions/active",
social: "/social",
pathways: "/pathways",
social: buildServiceLink("social-branding-service", "profile") ?? "/opportunities/social-media",
pathways: buildServiceLink("pathways-service", "dashboard") ?? "/career-pathways",
rewards: "/rewards",
suggestions: "/suggestions",
productivity: "/productivity",
@@ -101,20 +102,22 @@ function profileFromPreferences(preferences: Record<string, unknown>) {
function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", ctx: HomeContext, mission?: { instanceId?: string; missionId?: string; stageId?: string | null }) {
const profile = profileFromPreferences(ctx.preferences);
const params = new URLSearchParams({ source: "home" });
if (mission?.instanceId) params.set("missionInstanceId", mission.instanceId);
if (mission?.missionId) params.set("missionId", mission.missionId);
if (mission?.stageId) params.set("stageId", mission.stageId);
params.set("targetRole", profile.targetRole);
if (profile.targetCompany !== "target company") params.set("targetCompany", profile.targetCompany);
if (profile.industry) params.set("industry", profile.industry);
if (profile.focusAreas.length) params.set("focusAreas", profile.focusAreas.slice(0, 4).join(","));
if (profile.weakSpots.length) params.set("weakSpots", profile.weakSpots.slice(0, 3).join(","));
if (profile.jobDescription) params.set("jobDescription", profile.jobDescription.slice(0, 900));
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
return buildServiceLink(serviceId, pageId, {
source: "home",
missionInstanceId: mission?.instanceId,
missionId: mission?.missionId,
stageId: mission?.stageId ?? undefined,
targetRole: profile.targetRole,
role: profile.targetRole,
targetCompany: profile.targetCompany !== "target company" ? profile.targetCompany : undefined,
industry: profile.industry,
focusAreas: profile.focusAreas.length ? profile.focusAreas.slice(0, 4).join(",") : undefined,
weakSpots: profile.weakSpots.length ? profile.weakSpots.slice(0, 3).join(",") : undefined,
jobDescription: profile.jobDescription?.slice(0, 900),
type: service === "interview" ? "behavioral" : undefined,
}) ?? SERVICE_HREFS[service];
}
function sourceFromSuggestionRole(role: string): HomeSource {

View File

@@ -4,7 +4,8 @@ import { missionActions, missionSuggestions } from "../db/schema.js";
import type { GrowActiveMission } from "../actors/missions/types.js";
import type { MissionActionPatch } from "./reducer-types.js";
import { defaultMissionActionStatus, type MissionActionDto, type MissionActionRow, type MissionActionStatus, type NewMissionActionInput } from "./action-types.js";
import { missionDetailHref, serviceHref } from "./reducer-helpers.js";
import { missionDetailHref } from "./reducer-helpers.js";
import { buildServiceLink, getService, getServiceActionLabel } from "../services/service-registry.js";
const OPEN_STATUSES: MissionActionStatus[] = ["queued", "running", "waiting_approval", "waiting_user_input", "failed"];
const DONE_STATUSES: MissionActionStatus[] = ["done", "dismissed", "snoozed"];
@@ -48,26 +49,30 @@ export function actionToDto(row: MissionActionRow): MissionActionDto {
function ctaForAction(action: MissionActionRow | NewMissionActionInput) {
const payload = action.payload && typeof action.payload === "object" && !Array.isArray(action.payload) ? action.payload as Record<string, unknown> : {};
const hrefFromPayload = typeof payload.href === "string" ? payload.href : undefined;
const serviceId = action.serviceId ?? "";
const missionHref = missionDetailHref(action.missionInstanceId);
const href = hrefFromPayload ??
(serviceId.includes("interview") ? serviceHref("interview", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("roleplay") ? serviceHref("roleplay", action.missionInstanceId, action.missionId, action.stageId ?? undefined) :
serviceId.includes("resume") ? serviceHref("resume", action.missionInstanceId, action.missionId, action.stageId ?? undefined) : missionHref);
const service = getService(action.serviceId);
const href = hrefFromPayload ?? (
service
? buildServiceLink(service.id, service.curator.defaultPage, {
source: "mission",
missionInstanceId: action.missionInstanceId,
missionId: action.missionId,
stageId: action.stageId ?? undefined,
}) ?? missionHref
: missionHref
);
if (action.mode === "approval_required") return { ctaLabel: "Review", ctaHref: missionHref };
if (action.mode === "user_input_required") return { ctaLabel: "Answer", ctaHref: missionHref };
if (serviceId.includes("interview")) return { ctaLabel: "Start mock", ctaHref: href };
if (serviceId.includes("roleplay")) return { ctaLabel: "Run drill", ctaHref: href };
if (serviceId.includes("resume")) return { ctaLabel: "Open resume", ctaHref: href };
return { ctaLabel: "Open", ctaHref: href };
return { ctaLabel: service ? getServiceActionLabel(service.id, "start") : "Open", ctaHref: href };
}
function suggestionTypeForAction(action: MissionActionRow | NewMissionActionInput) {
if (action.mode === "user_input_required") return "blocked" as const;
if (action.mode === "approval_required") return "review" as const;
if ((action.serviceId ?? "").includes("interview") || (action.serviceId ?? "").includes("roleplay")) return "practice" as const;
if ((action.serviceId ?? "").includes("resume")) return "artifact" as const;
const category = getService(action.serviceId)?.category;
if (category === "practice") return "practice" as const;
if (category === "document") return "artifact" as const;
return "action" as const;
}

View File

@@ -1,4 +1,5 @@
import { asRecord, getNumber, getString } from "../events/envelope.js";
import { buildServiceLink } from "../services/service-registry.js";
import type { MissionActionPatch } from "./reducer-types.js";
export function isResumeEvent(source: string, type: string) {
@@ -134,12 +135,10 @@ export function actionForAgent(missionId: string, agent: "planner" | "resume" |
}
export function serviceHref(service: "resume" | "interview" | "roleplay" | "qscore", missionInstanceId: string, missionId: string, stageId?: string) {
const params = new URLSearchParams({ source: "mission", missionInstanceId, missionId });
if (stageId) params.set("stageId", stageId);
if (service === "interview") return `/agents/interview/setup?${params.toString()}`;
if (service === "roleplay") return `/agents/roleplay/setup?${params.toString()}`;
if (service === "resume") return `/agents/resume?${params.toString()}`;
return `/agents/qscore?${params.toString()}`;
const serviceId = service === "qscore" ? "qscore-service" : `${service}-service`;
const pageId = service === "resume" ? "workspace" : service === "qscore" ? "dashboard" : "setup";
return buildServiceLink(serviceId, pageId, { source: "mission", missionInstanceId, missionId, stageId })
?? missionDetailHref(missionInstanceId);
}
export function missionDetailHref(missionInstanceId: string) {

View File

@@ -442,7 +442,7 @@ export function serviceRoutes() {
const app = new Hono<AuthContext>();
app.use("*", requireUser);
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities() }));
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
app.get("/agents", async (c) => {
const userId = c.get("userId");

View File

@@ -1,6 +1,70 @@
import { config } from "../config.js";
import type { CuratorServiceId, CuratorTask } from "../v1/curator/curator-types.js";
type QueryValue = string | number | undefined | null;
export type QueryValue = string | number | boolean | undefined | null;
export type QueryState = Record<string, QueryValue>;
export type ServiceId =
| "interview-service"
| "roleplay-service"
| "courses-service"
| "assessment-service"
| "matchmaking-service"
| "pathways-service"
| "resume-service"
| "cover-letter-service"
| "qscore-service"
| "social-branding-service";
export type ServiceCategory = "practice" | "learning" | "opportunity" | "document" | "measurement" | "profile";
export type ServiceEndpoint = {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
contract: string;
usage: string;
};
export type ServiceFrontendPage = {
path: string;
aliases?: string[];
queryParams: string[];
usage: string;
};
export type ServiceRecord = {
id: ServiceId;
label: string;
description: string;
category: ServiceCategory;
enabled: boolean;
featureId: string;
promptModulePath: string;
aliases?: string[];
backend: {
baseUrl?: string;
publicUrl?: string;
healthPath: string;
endpoints: Record<string, ServiceEndpoint>;
usage: string;
};
frontend: {
baseUrl: string;
pages: Record<string, ServiceFrontendPage>;
usage: string;
};
curator: {
defaultPage: string;
defaultActionLabel: string;
defaultQueryState?: QueryState;
actionLabels?: Record<string, string>;
toolName: string;
completionEvents: string[];
qscoreSignals?: string[];
usage: string;
};
usageDocs: string[];
};
type MissionServiceId = Extract<CuratorServiceId, "interview-service" | "roleplay-service" | "resume-service">;
@@ -26,10 +90,16 @@ type CuratorRouteInput = {
roleplayBrief?: string;
};
function appendQuery(
pathname: string,
params: Record<string, QueryValue>,
) {
function endpoint(
method: ServiceEndpoint["method"],
path: string,
contract: string,
usage: string,
): ServiceEndpoint {
return { method, path, contract, usage };
}
function appendQuery(pathname: string, params: QueryState = {}) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
@@ -47,6 +117,720 @@ function getSessionId(detail?: Record<string, unknown>) {
return getString(detail?.session_id ?? detail?.sessionId ?? detail?.id);
}
const frontendBaseUrl = config.workflowsDashboardUrl.replace(/\/$/, "");
const serviceRegistry: ServiceRecord[] = [
{
id: "interview-service",
label: "Interview",
description: "Configure, practice, review, and score mock interview sessions.",
category: "practice",
enabled: Boolean(config.interviewServiceUrl),
featureId: "mock-interview",
promptModulePath: "agents/interview.md",
backend: {
baseUrl: config.interviewServiceUrl,
publicUrl: config.interviewPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/interviews/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate interview landing/setup pages."),
configure: endpoint("POST", "/api/v1/configure", "Creates an interview plan from user, org, persona, type, duration, and context.", "Use for committed setup requests."),
preview: endpoint("POST", "/api/v1/configure/preview", "Creates a preview plan without starting live practice.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/configure/questions", "Edits generated questions for a session.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/configure/approve", "Approves a generated session by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/interviews/assignments", "Lists interview assignments by email/status/limit.", "Render assigned practice work."),
createAssignments: endpoint("POST", "/api/v1/interviews/assignments", "Creates interview assignments for assignee emails.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/interviews/assignments/unassign", "Removes interview assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/interviews/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/review/:sessionId", "Returns review/status for a session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/leaderboard", "Returns interview leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/interview/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
discovery: {
path: "/upskilling/interview",
aliases: ["/agents/interview"],
queryParams: ["fresh"],
usage: "Entry screen for role selection and resume of in-progress interview work.",
},
setup: {
path: "/upskilling/interview/setup",
aliases: ["/agents/interview/setup"],
queryParams: ["role", "type", "from_assignment"],
usage: "Collects interview role, type, duration, persona, media mode, and personalization consent.",
},
preview: {
path: "/upskilling/interview/preview",
aliases: ["/agents/interview/preview"],
queryParams: ["role", "type", "persona", "duration", "difficulty", "media", "vip", "from_assignment", "personalize", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Curator default handoff. The page configures the session and opens the launch overlay.",
},
feedback: {
path: "/upskilling/interview/feedback",
queryParams: ["sessionId"],
usage: "Opens feedback/review for a completed or processing session.",
},
session: {
path: "/v2/service-sessions/interview",
queryParams: ["session_id", "goal", "role", "type"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Prefer preview for curator links and setup for mission CTAs that still need user choices.",
},
curator: {
defaultPage: "preview",
defaultActionLabel: "Open interview preview",
actionLabels: {
start: "Start mock",
review: "Review interview",
},
defaultQueryState: {
type: "behavioral",
persona: "payal",
duration: 5,
difficulty: "medium",
media: "video",
},
toolName: "prepare_interview_preview",
completionEvents: ["interview.configured", "interview.review_completed", "interview.completed"],
qscoreSignals: ["communication.interview", "proof.story_bank", "readiness.practice"],
usage: "Include missionInstanceId, missionId, stageId, curatorTaskId, role, and media when building stateful handoffs.",
},
usageDocs: [
"Call buildServiceLink('interview-service', 'preview', state) for curator handoffs.",
"Call getServiceEndpoint('interview-service', 'configure') for backend contract metadata.",
],
},
{
id: "roleplay-service",
label: "Roleplay",
description: "Practice negotiations, recruiter calls, stakeholder conversations, and workplace scenarios.",
category: "practice",
enabled: Boolean(config.roleplayServiceUrl),
featureId: "mock-roleplay",
promptModulePath: "agents/roleplay.md",
backend: {
baseUrl: config.roleplayServiceUrl,
publicUrl: config.roleplayPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
pageState: endpoint("GET", "/api/v1/roleplays/page-state?user_id=:userId", "Returns usage and personalization state.", "Hydrate roleplay landing/setup pages."),
configure: endpoint("POST", "/api/v1/roleplays/configure", "Creates a roleplay scenario from user, org, persona, duration, brief, metadata, qscore, and user_context.", "Use for committed scenario generation."),
preview: endpoint("POST", "/api/v1/roleplays/configure/preview", "Creates a roleplay preview.", "Use for curator/dashboard previews."),
questions: endpoint("POST", "/api/v1/roleplays/configure/questions", "Edits generated roleplay questions or beats.", "Use after preview edits."),
approve: endpoint("POST", "/api/v1/roleplays/configure/approve", "Approves a generated roleplay by session_id.", "Use when a user accepts a preview."),
assignments: endpoint("GET", "/api/v1/roleplays/assignments", "Lists roleplay assignments by email/status/limit.", "Render assigned drills."),
createAssignments: endpoint("POST", "/api/v1/roleplays/assignments", "Creates roleplay assignments.", "Admin or organization handoffs."),
unassign: endpoint("POST", "/api/v1/roleplays/assignments/unassign", "Removes roleplay assignments.", "Admin cleanup."),
resultsBulk: endpoint("POST", "/api/v1/roleplays/results:bulk", "Fetches result summaries for multiple sessions.", "Dashboard history and summaries."),
review: endpoint("GET", "/api/v1/roleplays/review/:sessionId", "Returns review/status for a roleplay session.", "Poll or open feedback."),
leaderboard: endpoint("GET", "/api/v1/roleplays/leaderboard", "Returns roleplay leaderboard.", "Leaderboard widgets."),
artifact: endpoint("GET", "/api/v1/artifacts/:sessionId/:artifactType", "Returns session artifacts.", "Fetch transcript, report, or media artifacts."),
videoUploadUrl: endpoint("POST", "/api/v1/sessions/:sessionId/video/upload-url", "Returns signed upload instructions.", "Browser upload setup."),
markVideoUploaded: endpoint("POST", "/api/v1/sessions/:sessionId/video/uploaded", "Marks uploaded video as available.", "Complete upload flow."),
},
usage: "Backend callers should use the gateway /services/roleplay/* routes when user auth, mission correlation, and event recording are required.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
discovery: {
path: "/upskilling/roleplay",
aliases: ["/agents/roleplay"],
queryParams: ["fresh"],
usage: "Entry screen for scenario discovery and resume of in-progress roleplay work.",
},
setup: {
path: "/upskilling/roleplay/setup",
aliases: ["/agents/roleplay/setup"],
queryParams: ["scenario", "scenario_text", "scenario_name", "from_assignment"],
usage: "Collects roleplay scenario details and stores configure parameters for the builder.",
},
builder: {
path: "/upskilling/roleplay/builder",
aliases: ["/agents/roleplay/builder"],
queryParams: ["sessionId", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "role", "persona", "duration", "mode", "brief"],
usage: "Curator default handoff for generating or resuming a roleplay plan.",
},
feedback: {
path: "/upskilling/roleplay/feedback",
queryParams: ["sessionId"],
usage: "Opens feedback/review for a completed or processing session.",
},
session: {
path: "/v2/service-sessions/roleplay",
queryParams: ["session_id", "goal", "role", "type"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Prefer builder for curator links and setup for mission CTAs that still need user choices.",
},
curator: {
defaultPage: "builder",
defaultActionLabel: "Open roleplay preview",
actionLabels: {
start: "Run drill",
review: "Review roleplay",
},
defaultQueryState: {
role: "Professional",
persona: "emma",
duration: 5,
mode: "video",
},
toolName: "prepare_roleplay_preview",
completionEvents: ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"],
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
},
usageDocs: [
"Call buildServiceLink('roleplay-service', 'builder', state) for curator handoffs.",
"Call getServiceEndpoint('roleplay-service', 'configure') for backend contract metadata.",
],
},
{
id: "resume-service",
label: "Resume",
description: "Build, tailor, analyze, version, and preview resumes.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "resume-building",
promptModulePath: "agents/resume.md",
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability before a handoff."),
state: endpoint("GET", "/api/state/:clerkId", "Returns user resume-builder state.", "Hydrate profile and personalization context."),
templates: endpoint("GET", "/api/v1/templates", "Lists resume templates.", "Render template gallery."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs resume-builder agent actions for a user_id.", "Agent/curator orchestrated work."),
listResumes: endpoint("GET", "/api/v1/resumes?clerk_id=:clerkId", "Lists resumes for a Clerk user.", "Resume hub."),
createResume: endpoint("POST", "/api/v1/resumes", "Creates a resume.", "Resume creation."),
getResume: endpoint("GET", "/api/v1/resumes/:resumeId", "Reads a resume.", "Resume editor."),
updateResume: endpoint("PUT", "/api/v1/resumes/:resumeId", "Updates a resume.", "Resume editor saves."),
analyzeResume: endpoint("POST", "/api/v1/ai/analyze/:resumeId", "Runs AI analysis for a resume.", "Resume score and improvement plan."),
suggestions: endpoint("GET", "/api/v1/ai/suggestions/:resumeId", "Returns AI suggestions.", "Editor improvement rail."),
copilot: endpoint("POST", "/api/v1/ai/copilot", "Runs resume copilot.", "Inline editing assistant."),
versions: endpoint("GET", "/api/v1/resumes/:resumeId/versions", "Lists resume versions.", "Version history."),
preview: endpoint("GET", "/api/v1/export/resumes/:resumeId/preview", "Returns resume preview.", "PDF/preview surface."),
},
usage: "Use gateway /services/resume/* for browser-authenticated requests so Clerk bearer tokens are preserved.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/opportunities/resume",
aliases: ["/agents/resume"],
queryParams: ["tab", "section", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Resume hub. Use tab=resumes by default and section to deep-link editor panels.",
},
editor: {
path: "/opportunities/resume/:resumeId",
queryParams: ["section"],
usage: "Resume editor for a known resume.",
},
templates: {
path: "/opportunities/resume/templates",
queryParams: [],
usage: "Template gallery.",
},
session: {
path: "/v2/service-sessions/resume",
queryParams: ["goal", "role"],
usage: "Legacy service-session launcher used by service agent results.",
},
},
usage: "Curator links should open the workspace unless a concrete resumeId is known.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open resume workspace",
actionLabels: {
start: "Open resume",
review: "Review resume",
},
defaultQueryState: {
tab: "resumes",
},
toolName: "prepare_resume_upload",
completionEvents: ["resume.analysis_completed", "resume.parsed", "resume.updated"],
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
usage: "Include mission state and optional section when linking into resume work.",
},
usageDocs: [
"Call buildServiceLink('resume-service', 'workspace', { tab: 'resumes' }) for curator handoffs.",
"Use the resume gateway proxy for browser calls that need Clerk auth.",
],
},
{
id: "cover-letter-service",
label: "Cover Letter",
description: "Generate, tailor, analyze, version, and preview cover letters.",
category: "document",
enabled: Boolean(config.resumeServiceUrl),
featureId: "cover-letter",
promptModulePath: "agents/cover-letter.md",
aliases: ["coverletter-service"],
backend: {
baseUrl: config.resumeServiceUrl,
publicUrl: config.resumePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe inherited from resume-builder.", "Check resume-builder availability."),
listCoverLetters: endpoint("GET", "/api/v1/cover-letters", "Lists cover letters.", "Cover-letter hub."),
createCoverLetter: endpoint("POST", "/api/v1/cover-letters", "Creates a cover letter.", "Manual creation."),
getCoverLetter: endpoint("GET", "/api/v1/cover-letters/:coverLetterId", "Reads a cover letter.", "Cover-letter editor."),
updateCoverLetter: endpoint("PUT", "/api/v1/cover-letters/:coverLetterId", "Updates a cover letter.", "Editor saves."),
generate: endpoint("POST", "/api/v1/cover-letters/generate", "Generates a tailored cover letter.", "Job application handoff."),
tailor: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/tailor", "Tailors an existing cover letter.", "Application-specific rewrite."),
analyze: endpoint("POST", "/api/v1/cover-letters/:coverLetterId/analyze", "Analyzes a cover letter.", "Strength and fit scoring."),
copilot: endpoint("POST", "/api/v1/cover-letters/copilot", "Runs cover-letter copilot.", "Inline editing assistant."),
preview: endpoint("GET", "/api/v1/export/cover-letters/:coverLetterId/preview", "Returns cover-letter preview.", "Preview/PDF surface."),
},
usage: "Cover letters currently live behind resume-builder and the /services/resume/* proxy.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
workspace: {
path: "/opportunities/resume",
queryParams: ["tab", "source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Open with tab=cover-letters to land in the cover-letter list.",
},
generator: {
path: "/opportunities/cover-letter",
queryParams: [],
usage: "Standalone generation entry point.",
},
editor: {
path: "/opportunities/resume/cover-letters/:coverLetterId",
queryParams: [],
usage: "Cover-letter editor for a known coverLetterId.",
},
},
usage: "Use workspace with tab=cover-letters for general handoffs.",
},
curator: {
defaultPage: "workspace",
defaultActionLabel: "Open cover letters",
actionLabels: {
start: "Write cover letter",
review: "Review cover letter",
},
defaultQueryState: {
tab: "cover-letters",
},
toolName: "prepare_cover_letter_handoff",
completionEvents: ["cover_letter.generated", "cover_letter.updated", "cover_letter.analysis_completed"],
qscoreSignals: ["proof.cover_letter", "readiness.application"],
usage: "Use for application-specific artifact tasks; share mission/job context in query or payload.",
},
usageDocs: [
"Call buildServiceLink('cover-letter-service', 'workspace', { tab: 'cover-letters' }) for curator handoffs.",
"Backend endpoint metadata maps to resume-builder cover-letter APIs.",
],
},
{
id: "courses-service",
label: "Courses",
description: "Create, list, and open upskilling courses.",
category: "learning",
enabled: Boolean(config.coursesServiceUrl),
featureId: "courses",
promptModulePath: "agents/courses.md",
aliases: ["course-service"],
backend: {
baseUrl: config.coursesServiceUrl,
publicUrl: config.coursesPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createCourse: endpoint("POST", "/api/v1/courses", "Creates a course.", "Admin or generated course creation."),
listCourses: endpoint("GET", "/api/v1/courses", "Lists courses with pagination/query filters.", "Course catalog."),
getCourse: endpoint("GET", "/api/v1/courses/:courseId", "Reads course details.", "Course detail page."),
},
usage: "Use for learning plan/course catalog handoffs; course generation stays in the service.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
catalog: {
path: "/upskilling/course",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Course catalog and upskilling entry point.",
},
},
usage: "Open catalog for general learning handoffs.",
},
curator: {
defaultPage: "catalog",
defaultActionLabel: "Open courses",
actionLabels: {
start: "Start course",
},
toolName: "prepare_course_handoff",
completionEvents: ["course.started", "course.completed"],
qscoreSignals: ["skills.learning", "readiness.upskilling"],
usage: "Use for skill-gap tasks that should become learning work.",
},
usageDocs: ["Call buildServiceLink('courses-service', 'catalog', state) for course handoffs."],
},
{
id: "assessment-service",
label: "Assessment",
description: "Create, list, read, and submit assessments.",
category: "measurement",
enabled: Boolean(config.assessmentServiceUrl),
featureId: "assessment",
promptModulePath: "agents/assessment.md",
backend: {
baseUrl: config.assessmentServiceUrl,
publicUrl: config.assessmentPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
createAssessment: endpoint("POST", "/api/v1/assessments", "Creates an assessment.", "Admin or generated assessment creation."),
listAssessments: endpoint("GET", "/api/v1/assessments", "Lists assessments with pagination/query filters.", "Assessment catalog."),
getAssessment: endpoint("GET", "/api/v1/assessments/:assessmentId", "Reads assessment details.", "Assessment page."),
submitAssessment: endpoint("POST", "/api/v1/assessments/:assessmentId/submit", "Submits answers and returns assessment state.", "Completion flow."),
},
usage: "Use for measurement tasks and proof of skill checks.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
assessment: {
path: "/upskilling/assessment",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Assessment landing and active assessment surface.",
},
},
usage: "Open assessment for skill/readiness measurement handoffs.",
},
curator: {
defaultPage: "assessment",
defaultActionLabel: "Open assessment",
actionLabels: {
start: "Start assessment",
review: "Review assessment",
},
toolName: "prepare_assessment_handoff",
completionEvents: ["assessment.started", "assessment.submitted", "assessment.completed"],
qscoreSignals: ["skills.assessment", "readiness.measurement"],
usage: "Use when a task needs a scoreable assessment rather than practice.",
},
usageDocs: ["Call buildServiceLink('assessment-service', 'assessment', state) for assessment handoffs."],
},
{
id: "matchmaking-service",
label: "Matchmaking",
description: "Match users to opportunities, employers, mentors, and networking targets.",
category: "opportunity",
enabled: Boolean(config.matchmakingServiceUrl),
featureId: "matchmaking",
promptModulePath: "agents/matchmaking.md",
aliases: ["jobs-service"],
backend: {
baseUrl: config.matchmakingServiceUrl,
publicUrl: config.matchmakingPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
preferences: endpoint("GET", "/api/v1/preferences/:userId", "Reads matching preferences.", "Hydrate feed filters and personalization."),
writePreferences: endpoint("POST", "/api/v1/preferences", "Writes matching preferences.", "Preference onboarding."),
feed: endpoint("GET", "/api/v1/feed", "Returns matched opportunity feed.", "Job/opportunity feed."),
feedAction: endpoint("POST", "/api/v1/feed/actions", "Records feed actions.", "Save/apply/dismiss tracking."),
opportunity: endpoint("GET", "/api/v1/opportunities/:opportunityId", "Reads opportunity details.", "Opportunity detail panel."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs matching agent tasks.", "Agent/curator orchestrated work."),
},
usage: "Use for opportunity matching and feed intelligence; keep user-specific actions through authenticated gateway routes when added.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
jobs: {
path: "/opportunities/job-matching",
aliases: ["/pathways"],
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId", "query", "role", "location"],
usage: "Job/opportunity matching dashboard.",
},
pathways: {
path: "/career-pathways",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Career pathways dashboard and pathway list.",
},
},
usage: "Use jobs for immediate matching and pathways for career-plan handoffs.",
},
curator: {
defaultPage: "jobs",
defaultActionLabel: "Open matches",
actionLabels: {
start: "View matches",
review: "Review matches",
},
toolName: "prepare_matchmaking_handoff",
completionEvents: ["matchmaking.feed_viewed", "matchmaking.match_saved", "matchmaking.preference_updated"],
qscoreSignals: ["market.matches", "networking.opportunities"],
usage: "Use for immediate opportunity matching or mentor/network suggestions.",
},
usageDocs: [
"Call buildServiceLink('matchmaking-service', 'jobs', state) for job matching.",
"Use pathways-service for generated career pathway plans.",
],
},
{
id: "pathways-service",
label: "Pathways",
description: "Generate, activate, and report on personalized career pathways.",
category: "opportunity",
enabled: Boolean(config.pathwaysServiceUrl),
featureId: "pathways",
promptModulePath: "agents/pathways.md",
aliases: ["career-pathways-service"],
backend: {
baseUrl: config.pathwaysServiceUrl,
publicUrl: config.pathwaysPublicUrl,
healthPath: "/api/v1/health",
endpoints: {
health: endpoint("GET", "/api/v1/health", "Readiness probe.", "Check service availability."),
state: endpoint("GET", "/api/state/:clerkId", "Reads pathway state.", "Hydrate pathway dashboard."),
profileIngest: endpoint("POST", "/api/v1/profiles/ingest", "Ingests a profile for pathway generation.", "Profile setup."),
questionnaire: endpoint("GET", "/api/v1/questionnaires/:userId", "Reads pathway questionnaire.", "Questionnaire resume."),
createQuestionnaire: endpoint("POST", "/api/v1/questionnaires", "Creates questionnaire answers.", "Pathway onboarding."),
generatePathway: endpoint("POST", "/api/v1/pathways/generate", "Generates a pathway.", "Career-plan generation."),
activatePathway: endpoint("POST", "/api/v1/pathways/:pathwayId/activate", "Activates a pathway.", "Commit chosen pathway."),
getPathway: endpoint("GET", "/api/v1/pathways/:pathwayId", "Reads pathway details.", "Pathway detail."),
weeklyPlan: endpoint("GET", "/api/v1/pathways/:pathwayId/weekly-plan", "Reads weekly plan.", "Planner UI."),
report: endpoint("GET", "/api/v1/pathways/:pathwayId/report", "Reads pathway report.", "Progress report."),
opportunities: endpoint("GET", "/api/v1/pathways/:pathwayId/opportunities", "Reads pathway opportunities.", "Pathway opportunity recommendations."),
a2aTask: endpoint("POST", "/a2a/tasks", "Runs pathway agent tasks.", "Agent/curator orchestrated work."),
},
usage: "Use for generated pathway plans and recommendation context. The live container is currently healthchecked separately from matchmaking.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
dashboard: {
path: "/career-pathways",
aliases: ["/pathways"],
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Pathway list/dashboard.",
},
generate: {
path: "/career-pathways/generate",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Pathway generation flow.",
},
questionnaire: {
path: "/career-pathways/questionnaire",
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Pathway questionnaire flow.",
},
detail: {
path: "/career-pathways/dashboard/:pathwayId",
queryParams: [],
usage: "Pathway detail dashboard.",
},
},
usage: "Use dashboard for general pathway handoffs and generate/questionnaire for guided setup.",
},
curator: {
defaultPage: "dashboard",
defaultActionLabel: "Open pathways",
actionLabels: {
start: "Build pathway",
review: "Review pathway",
},
toolName: "prepare_pathways_handoff",
completionEvents: ["pathway.generated", "pathway.activated", "pathway.report_viewed"],
qscoreSignals: ["readiness.pathway", "market.plan"],
usage: "Use for strategic career pathway planning rather than immediate job matching.",
},
usageDocs: ["Call buildServiceLink('pathways-service', 'dashboard', state) for pathway handoffs."],
},
{
id: "qscore-service",
label: "QScore",
description: "Analyze readiness signals and expose score projections.",
category: "measurement",
enabled: Boolean(config.qscoreServiceUrl),
featureId: "q-score",
promptModulePath: "agents/qscore.md",
aliases: ["q-score-service"],
backend: {
baseUrl: config.qscoreServiceUrl,
publicUrl: config.qscorePublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard QScore panel."),
ingest: endpoint("POST", "/api/v1/signals", "Ingests score signals when available.", "Service-to-service signal updates."),
compute: endpoint("POST", "/api/v1/score/compute", "Computes or refreshes score when available.", "Score recalculation."),
},
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by QScore deployment.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
dashboard: {
path: "/home",
aliases: ["/analytics"],
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Home dashboard with QScore panel.",
},
},
usage: "Open home dashboard for QScore review until a dedicated analytics route exists.",
},
curator: {
defaultPage: "dashboard",
defaultActionLabel: "Review QScore",
actionLabels: {
review: "Review QScore",
},
toolName: "prepare_qscore_review",
completionEvents: ["qscore.updated", "qscore.signal_projected"],
qscoreSignals: ["qscore.updated", "qscore.signal_projected"],
usage: "Use for measurement and projected readiness review tasks.",
},
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
},
{
id: "social-branding-service",
label: "Social Branding",
description: "Build and optimize professional profile, LinkedIn, content, and brand signals.",
category: "profile",
enabled: Boolean(config.socialBrandingServiceUrl),
featureId: "social-branding",
promptModulePath: "agents/social-branding.md",
aliases: ["social-service"],
backend: {
baseUrl: config.socialBrandingServiceUrl,
publicUrl: config.socialBrandingPublicUrl,
healthPath: "/health",
endpoints: {
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
state: endpoint("GET", "/api/state/:clerkId", "Reads social/profile state.", "Hydrate personalization context."),
profile: endpoint("GET", "/api/v1/profile", "Reads profile data when available.", "Social profile page."),
linkedin: endpoint("POST", "/api/v1/linkedin", "Connects or imports LinkedIn data when available.", "LinkedIn onboarding."),
analyze: endpoint("POST", "/api/v1/analyze", "Analyzes profile/social brand when available.", "Brand improvement tasks."),
},
usage: "Use /services/social/* gateway proxy for browser-authenticated profile calls.",
},
frontend: {
baseUrl: frontendBaseUrl,
pages: {
profile: {
path: "/opportunities/social-media",
aliases: ["/social"],
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
usage: "Social/profile improvement page.",
},
},
usage: "Open profile for branding, LinkedIn, and social proof handoffs.",
},
curator: {
defaultPage: "profile",
defaultActionLabel: "Open social profile flow",
actionLabels: {
start: "Improve profile",
review: "Review profile",
},
toolName: "prepare_social_branding_handoff",
completionEvents: ["social.profile_updated", "social.linkedin_connected", "social.branding_analyzed"],
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
},
usageDocs: ["Call buildServiceLink('social-branding-service', 'profile', state) for social branding handoffs."],
},
];
const serviceAliases = new Map<string, ServiceId>();
for (const service of serviceRegistry) {
serviceAliases.set(service.id, service.id);
for (const alias of service.aliases ?? []) serviceAliases.set(alias, service.id);
}
export function normalizeServiceId(serviceId?: string | null): ServiceId | undefined {
if (!serviceId) return undefined;
return serviceAliases.get(serviceId);
}
export function listServices() {
return serviceRegistry;
}
export function getService(serviceId?: string | null) {
const normalized = normalizeServiceId(serviceId);
return normalized ? serviceRegistry.find((service) => service.id === normalized) : undefined;
}
export function getServiceBackend(serviceId?: string | null) {
return getService(serviceId)?.backend;
}
export function getServiceFrontend(serviceId?: string | null) {
return getService(serviceId)?.frontend;
}
export function getServiceEndpoint(serviceId: string | undefined, endpointId: string) {
return getService(serviceId)?.backend.endpoints[endpointId];
}
export function getServiceUsageDocs(serviceId?: string | null) {
return getService(serviceId)?.usageDocs ?? [];
}
function resolvePage(service: ServiceRecord, pageId?: string) {
const selectedPageId = pageId || service.curator.defaultPage;
const direct = service.frontend.pages[selectedPageId];
if (direct) return direct;
return Object.values(service.frontend.pages).find((page) => page.aliases?.includes(selectedPageId));
}
export function buildServiceLink(serviceId: string | undefined, pageId?: string, state: QueryState = {}) {
const service = getService(serviceId);
if (!service) return undefined;
const page = resolvePage(service, pageId);
if (!page) return undefined;
const includeDefaultState = !pageId || pageId === service.curator.defaultPage;
return appendQuery(page.path, {
...(includeDefaultState ? service.curator.defaultQueryState : {}),
...state,
});
}
export function listServicesForCatalog() {
return serviceRegistry.map((service) => ({
id: service.id,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
featureId: service.featureId,
backend: {
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
usageDocs: service.usageDocs,
}));
}
export function buildServiceSessionPath(
serviceId: MissionServiceId,
detail?: Record<string, unknown>,
@@ -56,7 +840,7 @@ export function buildServiceSessionPath(
if (serviceId === "interview-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/interview", {
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Interview practice",
@@ -66,7 +850,7 @@ export function buildServiceSessionPath(
if (serviceId === "roleplay-service") {
if (!sessionId) return undefined;
return appendQuery("/v2/service-sessions/roleplay", {
return buildServiceLink(serviceId, "session", {
session_id: sessionId,
goal,
role: getString(detail?.target_role) ?? goal ?? "Roleplay practice",
@@ -74,30 +858,23 @@ export function buildServiceSessionPath(
});
}
return appendQuery("/v2/service-sessions/resume", {
return buildServiceLink(serviceId, "session", {
goal,
role: goal,
});
}
export function buildMissionServiceRoute(input: MissionRouteInput) {
const baseParams = {
const pageId = input.serviceId === "resume-service" ? "workspace" : "setup";
return buildServiceLink(input.serviceId, pageId, {
source: "mission",
missionInstanceId: input.missionInstanceId,
missionId: input.missionId,
stageId: input.stageId,
goal: input.goal,
};
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/setup", baseParams);
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/setup", baseParams);
}
return appendQuery("/agents/resume", baseParams);
role: input.goal,
type: input.serviceId === "interview-service" ? "behavioral" : undefined,
}) ?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
function curatorBaseParams(input: CuratorRouteInput) {
@@ -111,85 +888,64 @@ function curatorBaseParams(input: CuratorRouteInput) {
}
export function buildCuratorServiceRoute(input: CuratorRouteInput) {
if (input.serviceId === "interview-service") {
return appendQuery("/agents/interview/preview", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Product Manager",
type: "behavioral",
persona: input.personaId ?? "payal",
duration: input.durationMinutes ?? 5,
difficulty: input.difficulty ?? "medium",
media: input.requestedMode ?? "video",
});
const service = getService(input.serviceId);
if (!service) {
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
}
if (input.serviceId === "roleplay-service") {
return appendQuery("/agents/roleplay/builder", {
...curatorBaseParams(input),
role: input.targetRole?.trim() || "Professional",
persona: input.personaId ?? "emma",
duration: input.durationMinutes ?? 5,
mode: input.requestedMode ?? "video",
brief: input.roleplayBrief,
});
const state: QueryState = {
...curatorBaseParams(input),
};
if (service.id === "interview-service") {
state.role = input.targetRole?.trim() || "Product Manager";
state.type = "behavioral";
state.persona = input.personaId ?? "payal";
state.duration = input.durationMinutes ?? 5;
state.difficulty = input.difficulty ?? "medium";
state.media = input.requestedMode ?? "video";
}
if (input.serviceId === "resume-service") {
return appendQuery("/agents/resume", curatorBaseParams(input));
}
if (input.serviceId === "qscore-service") {
return appendQuery("/analytics", curatorBaseParams(input));
}
if (input.serviceId === "social-branding-service") {
return appendQuery("/social", curatorBaseParams(input));
}
if (input.serviceId === "matchmaking-service") {
return appendQuery("/pathways", curatorBaseParams(input));
if (service.id === "roleplay-service") {
state.role = input.targetRole?.trim() || "Professional";
state.persona = input.personaId ?? "emma";
state.duration = input.durationMinutes ?? 5;
state.mode = input.requestedMode ?? "video";
state.brief = input.roleplayBrief;
}
return input.missionInstanceId
? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId })
: "/missions/active";
return buildServiceLink(service.id, service.curator.defaultPage, state)
?? appendQuery("/missions/active", { missionInstanceId: input.missionInstanceId });
}
export function getServiceDisplayName(serviceId?: CuratorServiceId, fallback = "Mission planner") {
if (serviceId === "interview-service") return "Interview service";
if (serviceId === "roleplay-service") return "Roleplay service";
if (serviceId === "resume-service") return "Resume service";
if (serviceId === "qscore-service") return "Q Score service";
if (serviceId === "social-branding-service") return "Social branding service";
if (serviceId === "matchmaking-service") return "Pathways service";
return fallback;
export function getServiceDisplayName(serviceId?: string, fallback = "Mission planner") {
return getService(serviceId)?.label ?? fallback;
}
export function getServiceToolName(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") return "prepare_interview_preview";
if (serviceId === "roleplay-service") return "prepare_roleplay_preview";
if (serviceId === "resume-service") return "prepare_resume_upload";
if (serviceId === "qscore-service") return "prepare_qscore_review";
return "prepare_mission_step";
export function getServiceToolName(serviceId?: string) {
return getService(serviceId)?.curator.toolName ?? "prepare_mission_step";
}
export function getServiceCompletionEvents(serviceId?: CuratorServiceId) {
if (serviceId === "interview-service") {
return ["interview.configured", "interview.review_completed", "interview.completed"];
}
if (serviceId === "roleplay-service") {
return ["roleplay.configured", "roleplay.review_completed", "roleplay.completed"];
}
if (serviceId === "resume-service") {
return ["resume.analysis_completed", "resume.parsed", "resume.updated"];
}
if (serviceId === "qscore-service") {
return ["qscore.updated", "qscore.signal_projected"];
}
return ["curator.task.completed"];
export function getCompletionEvents(serviceId?: string) {
return getService(serviceId)?.curator.completionEvents ?? ["curator.task.completed"];
}
export function getServiceActionLabel(task: CuratorTask) {
if (task.serviceId === "interview-service") return "Open interview preview";
if (task.serviceId === "roleplay-service") return "Open roleplay preview";
if (task.serviceId === "resume-service") return "Open resume workspace";
if (task.serviceId === "qscore-service") return "Review Q Score";
return task.cta || "Open";
export const getServiceCompletionEvents = getCompletionEvents;
export function getServiceActionLabel(serviceId?: string, actionId?: string): string;
export function getServiceActionLabel(task: Pick<CuratorTask, "serviceId" | "cta">): string;
export function getServiceActionLabel(
input?: string | Pick<CuratorTask, "serviceId" | "cta">,
actionId?: string,
) {
if (typeof input === "object") {
const service = getService(input.serviceId);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? input.cta ?? "Open";
}
const service = getService(input);
if (actionId && service?.curator.actionLabels?.[actionId]) return service.curator.actionLabels[actionId];
return service?.curator.defaultActionLabel ?? "Open";
}

View File

@@ -3,10 +3,14 @@ import { z } from "zod";
export const curatorServiceIdSchema = z.enum([
"interview-service",
"resume-service",
"cover-letter-service",
"roleplay-service",
"courses-service",
"assessment-service",
"qscore-service",
"social-branding-service",
"matchmaking-service",
"pathways-service",
]);
export type CuratorServiceId = z.infer<typeof curatorServiceIdSchema>;

View File

@@ -1,27 +1,50 @@
import { listFeatureDefinitions, internalWorkflowModules } from "../features/registry.js";
import { internalWorkflowModules } from "../features/registry.js";
import { listServices } from "../services/service-registry.js";
export type ServiceCapability = {
id: string;
name: string;
label?: string;
description?: string;
category?: string;
enabled: boolean;
internalUrl?: string;
publicUrl?: string;
operations: string[];
featureId?: string;
promptModulePath?: string;
healthPath?: string;
backend?: unknown;
frontend?: unknown;
curator?: unknown;
usageDocs?: string[];
};
export function listServiceCapabilities(): ServiceCapability[] {
export function listServiceCapabilities(opts: { public?: boolean } = {}): ServiceCapability[] {
return [
...listFeatureDefinitions().map((feature) => ({
id: feature.serviceId,
name: feature.title,
enabled: feature.enabled,
internalUrl: feature.internalUrl,
publicUrl: feature.publicUrl,
operations: feature.operations,
featureId: feature.id,
promptModulePath: feature.promptModulePath,
...listServices().map((service) => ({
id: service.id,
name: service.label,
label: service.label,
description: service.description,
category: service.category,
enabled: service.enabled,
...(opts.public ? {} : { internalUrl: service.backend.baseUrl }),
publicUrl: service.backend.publicUrl,
operations: Object.keys(service.backend.endpoints),
featureId: service.featureId,
promptModulePath: service.promptModulePath,
healthPath: service.backend.healthPath,
backend: {
...(opts.public ? {} : { baseUrl: service.backend.baseUrl }),
publicUrl: service.backend.publicUrl,
healthPath: service.backend.healthPath,
endpoints: service.backend.endpoints,
usage: service.backend.usage,
},
frontend: service.frontend,
curator: service.curator,
usageDocs: service.usageDocs,
})),
...internalWorkflowModules.map((module) => ({
id: module.id,