A mini-app runtime can make service delivery modular. It can also make adding one more service feel almost free.
The code package is small. The host does not need a complete native release. A partner or internal team can work independently. Soon the app has dozens of tiles, overlapping labels, inactive services, and no reliable answer to a basic question: why is this service visible to this customer here?
The service catalog should provide that answer. It needs more than a list of names and icons. It needs a contract covering ownership, eligibility, placement, required host capabilities, lifecycle, and observability.
This article builds a small manifest-driven catalog in TypeScript. The design keeps three decisions separate:
- Admission: Is the service allowed in the ecosystem?
- Eligibility: May the current customer use it in the current context?
- Placement: Where should an eligible service appear?
Keeping these decisions distinct prevents ranking logic from silently becoming policy.
Model the catalog as an operating contract
A useful manifest should be understandable by product, platform, risk, and support teams. Start with fields that have a named consumer.
export type ServiceSurface = "home" | "contextual" | "catalog";
export type ServiceStatus = "draft" | "active" | "suspended" | "retired";
export type AuthLevel = "anonymous" | "authenticated" | "step_up";
export interface MiniAppManifest {
schemaVersion: "1.0";
serviceId: string;
title: string;
category: string;
owner: {
team: string;
supportQueue: string;
};
lifecycle: {
status: ServiceStatus;
reviewAfter: string;
};
eligibility: {
regions: string[];
customerSegments: string[];
minimumAuth: AuthLevel;
requiredEntitlements: string[];
};
capabilities: string[];
discovery: {
allowedSurfaces: ServiceSurface[];
searchTerms: string[];
curatedPriority?: number;
};
entryPoint: {
route: string;
minimumHostVersion: string;
};
}
The manifest deliberately avoids engagement scores and customer identifiers. Those belong in evaluation inputs or analytics, not in the service definition.
owner makes support responsibility visible. reviewAfter creates an expected review date. allowedSurfaces limits where the service may be promoted. minimumAuth and requiredEntitlements describe eligibility without embedding rules in UI components.
Validate the manifest before publication
TypeScript protects code that has already been typed. It does not validate JSON uploaded through a developer portal or received from another team. JSON Schema can enforce the contract at the boundary.
The following schema uses JSON Schema Draft 2020-12:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.org/schemas/mini-app-manifest-1.0.json",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"serviceId",
"title",
"category",
"owner",
"lifecycle",
"eligibility",
"capabilities",
"discovery",
"entryPoint"
],
"properties": {
"schemaVersion": { "const": "1.0" },
"serviceId": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{2,63}$"
},
"title": { "type": "string", "minLength": 1, "maxLength": 80 },
"category": { "type": "string", "minLength": 1, "maxLength": 40 },
"owner": {
"type": "object",
"additionalProperties": false,
"required": ["team", "supportQueue"],
"properties": {
"team": { "type": "string", "minLength": 1 },
"supportQueue": { "type": "string", "minLength": 1 }
}
},
"lifecycle": {
"type": "object",
"additionalProperties": false,
"required": ["status", "reviewAfter"],
"properties": {
"status": {
"enum": ["draft", "active", "suspended", "retired"]
},
"reviewAfter": { "type": "string", "format": "date" }
}
},
"eligibility": {
"type": "object",
"additionalProperties": false,
"required": [
"regions",
"customerSegments",
"minimumAuth",
"requiredEntitlements"
],
"properties": {
"regions": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
},
"customerSegments": {
"type": "array",
"items": { "type": "string" },
"minItems": 1,
"uniqueItems": true
},
"minimumAuth": {
"enum": ["anonymous", "authenticated", "step_up"]
},
"requiredEntitlements": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
}
}
},
"capabilities": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true
},
"discovery": {
"type": "object",
"additionalProperties": false,
"required": ["allowedSurfaces", "searchTerms"],
"properties": {
"allowedSurfaces": {
"type": "array",
"items": { "enum": ["home", "contextual", "catalog"] },
"minItems": 1,
"uniqueItems": true
},
"searchTerms": {
"type": "array",
"items": { "type": "string", "minLength": 2 },
"maxItems": 20,
"uniqueItems": true
},
"curatedPriority": { "type": "integer", "minimum": 0 }
}
},
"entryPoint": {
"type": "object",
"additionalProperties": false,
"required": ["route", "minimumHostVersion"],
"properties": {
"route": { "type": "string", "pattern": "^/" },
"minimumHostVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
}
}
}
}
}
A production schema may reference shared definitions with $ref and add stricter domain constraints. Avoid allowing arbitrary metadata simply because it may be useful later. An unbounded object becomes a second, undocumented API.
Ajv can validate a submitted manifest in the publication pipeline:
import Ajv2020 from "ajv/dist/2020";
import addFormats from "ajv-formats";
import manifestSchema from "./mini-app-manifest.schema.json";
import type { MiniAppManifest } from "./types";
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
const validateManifest = ajv.compile<MiniAppManifest>(manifestSchema);
export function assertValidManifest(
input: unknown
): asserts input is MiniAppManifest {
if (!validateManifest(input)) {
const details = ajv.errorsText(validateManifest.errors, {
separator: "; ",
});
throw new Error(`Invalid mini-app manifest: ${details}`);
}
}
Schema validation confirms that required information exists and has the expected shape. Admission review still needs human and automated checks for security, privacy, commercial terms, content quality, and operational readiness.
Evaluate eligibility before placement
The host should remove ineligible services before ranking or personalisation runs. Otherwise, a strong engagement score could promote a service that is unavailable in the customer’s region or inappropriate for the current authentication state.
export interface EvaluationContext {
region: string;
customerSegment: string;
authLevel: AuthLevel;
entitlements: ReadonlySet<string>;
hostVersion: string;
activeJourney?: string;
}
const authStrength: Record<AuthLevel, number> = {
anonymous: 0,
authenticated: 1,
step_up: 2,
};
export interface EligibilityDecision {
eligible: boolean;
reasons: string[];
}
function compareSemver(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
for (let index = 0; index < 3; index += 1) {
const difference = (a[index] ?? 0) - (b[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
export function evaluateEligibility(
service: MiniAppManifest,
context: EvaluationContext
): EligibilityDecision {
const reasons: string[] = [];
if (service.lifecycle.status !== "active") {
reasons.push(`status:${service.lifecycle.status}`);
}
if (
compareSemver(
context.hostVersion,
service.entryPoint.minimumHostVersion
) < 0
) {
reasons.push("host_version_unsupported");
}
if (!service.eligibility.regions.includes(context.region)) {
reasons.push("region_not_allowed");
}
if (
!service.eligibility.customerSegments.includes(context.customerSegment)
) {
reasons.push("segment_not_allowed");
}
if (
authStrength[context.authLevel] <
authStrength[service.eligibility.minimumAuth]
) {
reasons.push("authentication_insufficient");
}
for (const entitlement of service.eligibility.requiredEntitlements) {
if (!context.entitlements.has(entitlement)) {
reasons.push(`missing_entitlement:${entitlement}`);
}
}
return { eligible: reasons.length === 0, reasons };
}
Reason codes make the decision observable and testable. They are also safer than returning customer data in logs. In production, keep the vocabulary bounded and avoid logging raw account attributes.
The evaluation context resembles the model used by standards such as OpenFeature: stable contextual fields are supplied to a decision without coupling every caller to the policy implementation. A platform can later move the rules to a dedicated policy or feature-management service while preserving the contract.
Assign surfaces with explicit policy
An eligible service can appear in several places. Treat home-screen space as a configured budget rather than a default destination.
export interface PlacementPolicy {
homeSlotBudget: number;
journeyPlacements: Record<string, string[]>;
}
export interface PlacedService {
service: MiniAppManifest;
surface: ServiceSurface;
}
export function buildServiceSurfaces(
services: MiniAppManifest[],
context: EvaluationContext,
policy: PlacementPolicy
): PlacedService[] {
const eligible = services.filter(
(service) => evaluateEligibility(service, context).eligible
);
const contextualIds = new Set(
context.activeJourney
? policy.journeyPlacements[context.activeJourney] ?? []
: []
);
const contextual = eligible
.filter(
(service) =>
contextualIds.has(service.serviceId) &&
service.discovery.allowedSurfaces.includes("contextual")
)
.map((service) => ({ service, surface: "contextual" as const }));
const home = eligible
.filter((service) => service.discovery.allowedSurfaces.includes("home"))
.sort(
(a, b) =>
(a.discovery.curatedPriority ?? Number.MAX_SAFE_INTEGER) -
(b.discovery.curatedPriority ?? Number.MAX_SAFE_INTEGER)
)
.slice(0, policy.homeSlotBudget)
.map((service) => ({ service, surface: "home" as const }));
const alreadyPlaced = new Set(
[...contextual, ...home].map(({ service }) => service.serviceId)
);
const catalog = eligible
.filter(
(service) =>
!alreadyPlaced.has(service.serviceId) &&
service.discovery.allowedSurfaces.includes("catalog")
)
.map((service) => ({ service, surface: "catalog" as const }));
return [...contextual, ...home, ...catalog];
}
The priority in this example is curated and version controlled. It is not an opaque click-through score. A team can later add experimentation or personalisation, but policy should remain visible: which services may appear on each surface, how many permanent slots exist, and which journeys permit contextual placement.
Build retirement into the first version
Catalogs accumulate services because launching has an owner and removing rarely does. A scheduled review can produce a work queue before ownership disappears.
export function servicesDueForReview(
services: MiniAppManifest[],
today = new Date()
): MiniAppManifest[] {
return services.filter((service) => {
if (service.lifecycle.status === "retired") return false;
return new Date(service.lifecycle.reviewAfter).getTime() <= today.getTime();
});
}
The review should consider more than opens. It can examine search success, completed journeys, repeated authentication, support incidents, broken handoffs, reliability, owner status, and contract dates. Rare use may be acceptable for an emergency or legally important service. The review exists to test the service’s purpose, not reward popularity alone.
Suspension and retirement need different runtime behaviour. Suspension should remove launch access quickly while preserving the record and an appropriate customer message. Retirement should remove discovery metadata, close entry routes, archive analytics definitions, and identify any replacement journey.
Instrument the catalog as a product
Useful events include:
type CatalogEvent =
| {
name: "service_impression";
serviceId: string;
surface: ServiceSurface;
}
| {
name: "service_opened";
serviceId: string;
surface: ServiceSurface;
}
| {
name: "catalog_search_completed";
resultCountBucket: "0" | "1-5" | "6-20" | "20+";
}
| {
name: "service_journey_completed";
serviceId: string;
outcome: "completed" | "cancelled" | "failed";
};
Bucket search result counts and avoid recording raw search terms unless there is a reviewed privacy need. Service IDs are bounded by the catalog, making them more suitable dimensions than customer or session identifiers.
Impressions and opens help diagnose placement. Completion and failure show whether the service fulfilled its purpose. Search with zero results identifies missing language, missing services, or customer expectations the catalog does not meet.
Test policy independently from the UI
Eligibility and placement functions should have table-driven tests for:
- suspended and retired services;
- region and segment restrictions;
- insufficient authentication;
- missing entitlements;
- contextual placement with and without an active journey;
- home-slot budgets;
- services allowed only in the searchable catalog;
- duplicate placement across surfaces;
- manifest review dates and invalid schemas.
The UI can then render a decision it receives instead of reimplementing the policy in several screens.
A catalog is part of the platform architecture
Mini-app platforms are often introduced through runtime diagrams: host, sandbox, bridge, management console, and developer tools. The customer experiences another architectural component every day—the service catalog.
When its contract is explicit, the catalog limits home-screen growth, keeps eligibility rules testable, makes ownership visible, and gives services a route out as well as a route in. When it is only an array of tiles, technical modularity can produce product clutter at remarkable speed.
The runtime determines how many services the app can host. The catalog determines whether customers can still understand the app after they arrive.


Top comments (0)