Track A — Build the agent service
The marketplace does not run your agent. You build and host it yourself. This page is the integration contract you have to satisfy.
The canonical reference implementation is agent-poc (VoxaAI) — Node.js + Express + MongoDB + Better Auth using its genericOAuth plugin as a Relying Party. You can copy its shape verbatim, or build the same contract in Python, Go, Rust, etc.
What "an agent service" must do
Five integration points:
- OIDC sign-in — redirect to
/api/auth/oauth2/authorize, exchange code at/api/auth/oauth2/token, validate ID token against/api/auth/jwks, fetch/api/auth/oauth2/userinfo. - Session — establish your own session cookie so the agent UI doesn't have to bounce on every page load.
- Tier resolution —
GET /me/subscriptions/:agentIdreturns the user's active tier or 404. Read the tier-hint claimmarketplace:agentTiersas an optimization; the API call is source of truth. - Quota consume — before every chargeable action,
POST /me/agents/:agentId/usage/:metricSlug/consumewith aclientReqId(idempotency key) anddelta. Honor the 429 / 402 / 404 envelope. - Quota release — for fixed metrics, when a unit is freed (e.g. user deletes a knowledge base),
POST .../release. Rolling metrics do not support release (will 422).
Reference stack (VoxaAI)
| Layer | Choice | Why |
|---|---|---|
| Backend | Node 22 + Express | Better Auth has a polished genericOAuth RP plugin; SSE works fine; deploys cleanly on Fargate |
| DB | MongoDB Atlas M10 | Decoupled from marketplace Postgres; Better Auth's Mongo adapter handles sessions |
| Auth client | Better Auth genericOAuth plugin | Handles authorize URL, PKCE, token exchange, refresh rotation, session storage, cookies |
| Frontend | React 18 + Vite + TanStack Query | Same family as the marketplace UI |
| Tier UX | TierContext + useTier() hook | Caches tier from /api/me/tier (which proxies marketplace) |
| Hosting | ECS Fargate behind ALB + AWS Amplify (frontend) | ALB idle timeout must be ≥ 4000s if you stream SSE |
| Secrets | AWS Secrets Manager | Five secrets in the VoxaAI deployment (DB URI, Mesh, Sarvam, ElevenLabs, LiveAvatar) |
Better Auth setup (Node example)
Mirrors agent-poc/backend/auth.js.
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
export const auth = betterAuth({
baseURL: process.env.BACKEND_URL!, // e.g. https://api.voxaai.com
secret: process.env.AUTH_SECRET!,
database: mongoAdapter(/* ... */),
plugins: [
genericOAuth({
providers: [
{
providerId: "marketplace",
clientId: process.env.OAUTH_CLIENT_ID!, // from marketplace agent registration
clientSecret: process.env.OAUTH_CLIENT_SECRET ?? "", // empty for public clients
authorizationUrl:
`${process.env.MARKETPLACE_API_URL}/api/auth/oauth2/authorize`,
tokenUrl:
`${process.env.MARKETPLACE_API_URL}/api/auth/oauth2/token`,
userInfoUrl:
`${process.env.MARKETPLACE_API_URL}/api/auth/oauth2/userinfo`,
discoveryUrl:
`${process.env.MARKETPLACE_API_URL}/api/auth/.well-known/openid-configuration`,
scopes: [
"openid",
"profile",
"email",
"marketplace:role",
"marketplace:installs",
"marketplace:agentTiers",
"offline_access",
],
pkce: true, // mandatory; S256
mapProfileToUser: (claims) => ({
id: claims.sub,
email: claims.email,
name: claims.name,
image: claims.picture,
marketplaceRole: claims["marketplace:role"],
}),
},
],
}),
],
});
The agent frontend kicks off sign-in:
authClient.signIn.oauth2({
providerId: "marketplace",
callbackURL: "/post-signin",
});
The browser redirects to the marketplace, the user signs in, then bounces back to ${BACKEND_URL}/api/auth/oauth2/callback/marketplace. The session cookie is issued by the agent backend (not the marketplace).
Cookie config:
SameSite=None; Secureis required because the agent frontend's origin (voxaai.com) is different from the agent backend's API origin (api.voxaai.com). Browsers will silently drop the cookie otherwise.
Tier resolution
The agent backend resolves the user's current tier via a server-to-server call:
// inside an agent route handler
const accessToken = await auth.api.getAccessToken({ userId: req.user.id });
const res = await fetch(
`${process.env.MARKETPLACE_API_URL}/me/subscriptions/${AGENT_ID}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (res.status === 404) {
// no active or trialing subscription
return res.status(402).json({ error: "Subscribe to use this feature" });
}
const { data } = await res.json();
// data: { id, agentId, tierId, status, currentPeriodEnd, tier: { tierName, features, ... } }
If you want a single client-readable endpoint, mirror VoxaAI's /api/me/tier route that wraps this call and caches it in your DB for a few seconds.
Quota consume / release
Wrap every chargeable action with the consume call. Use crypto.randomUUID() (or any unique value) as clientReqId so retries are idempotent.
const clientReqId = crypto.randomUUID();
const r = await fetch(
`${MP}/me/agents/${AGENT_ID}/usage/sessions/consume`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ delta: 1, clientReqId, metadata: { sessionId } }),
},
);
if (r.status === 429) {
const { error } = await r.json();
// error.code === "QUOTA_EXCEEDED"
// error.details: { remaining, resetsAt, effectiveQuota }
return reply.status(429).json({ upgradeUrl: `/pricing/${AGENT_SLUG}` });
}
if (r.status === 402) return reply.status(402).json({ subscribe: true });
if (!r.ok) throw new Error("usage service unavailable");
// chargeable action proceeds
For fixed allocations (e.g. "active knowledge bases"), when a unit is freed:
await fetch(`${MP}/me/agents/${AGENT_ID}/usage/sites/release`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ delta: 1, clientReqId: `release-${siteId}` }),
});
Calling release on a rolling metric returns 422 — that's intentional, rolling counters never decrement.
Boolean features (gating)
The cleanest way to gate a feature like "voice replies" is to define a boolean metric (e.g. slug voice, kind: fixed) and set the tier quota to 0 (deny) or 1 (allow) per tier. Then in the agent:
const usage = await getUsage("voice");
const allowed = (usage.effectiveQuota ?? 0) > 0;
This keeps the marketplace as the single source of truth for what tiers unlock what features — no hardcoded if (tier === "Pro") branches in your agent.
Refresh token rotation
Better Auth handles this automatically when you include offline_access. Don't try to manage refresh tokens yourself. If you build in another stack, follow OAuth 2.1: rotate refresh tokens on every use, invalidate the old one on success.
Hosting + deployment
Mirror agent-poc/AWS-MIGRATION.md if you want a known-good template:
| Component | AWS service | Notes |
|---|---|---|
| Backend | ECS Fargate (1 vCPU + 2 GB, ALB in front) | ALB idle timeout must be set high (≥ 4000s) if you stream SSE or long-lived avatar sessions |
| Frontend | Amplify | Connects to GitHub, runs pnpm build, serves the SPA |
| DB | MongoDB Atlas (or whatever you prefer) | Allow 0.0.0.0/0 from Atlas, or use NAT gateway + fixed Elastic IP |
| Secrets | Secrets Manager | Inject into ECS task definition |
| CI/CD | GitHub Actions | Build multi-arch image (--platform linux/amd64) → push to ECR → ECS rolling deploy |
A first agent typically runs ~$65/mo on this footprint. Scale up when traffic justifies it.
Local development
cd backend
cp .env.example .env
# Fill: MARKETPLACE_API_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET="", AUTH_SECRET, MONGODB_URI, AGENT_ID, AGENT_SLUG
pnpm install
pnpm dev # http://localhost:3001 (or 3000 — pick one and stick with it)
cd ../frontend
pnpm install
pnpm dev # http://localhost:5173 (Vite proxies /api → backend)
For OAuth to work locally, the agent's redirectUris registered with the marketplace must include the local callback (e.g. http://localhost:3001/api/auth/oauth2/callback/marketplace). See Track B for the exact field.
Pitfalls (read this)
- PKCE is mandatory. The marketplace's OAuth provider rejects authorize requests without
code_challenge(S256). Better Auth handles this; if you roll your own, don't forget. offline_accessis required forrefresh_token. Without it, the marketplace will not issue a refresh token, and your session will die in ~10 hours.- The
marketplace:agentTiersclaim is a hint. It's frozen at token issue time. If a user upgrades while signed in, the claim won't update until token refresh. Always trust/me/subscriptions/:agentIdfor the live state. Authorization: Bearer <access_token>for marketplace API calls. Cookies are for the marketplace's own SPA, not for cross-service calls.clientReqIdmust be unique per logical attempt. Reusing the same UUID for two different consume events will silently no-op the second one.
Continue to Track B — Register the listing.