Skip to main content

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:

  1. 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.
  2. Session — establish your own session cookie so the agent UI doesn't have to bounce on every page load.
  3. Tier resolutionGET /me/subscriptions/:agentId returns the user's active tier or 404. Read the tier-hint claim marketplace:agentTiers as an optimization; the API call is source of truth.
  4. Quota consume — before every chargeable action, POST /me/agents/:agentId/usage/:metricSlug/consume with a clientReqId (idempotency key) and delta. Honor the 429 / 402 / 404 envelope.
  5. 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)

LayerChoiceWhy
BackendNode 22 + ExpressBetter Auth has a polished genericOAuth RP plugin; SSE works fine; deploys cleanly on Fargate
DBMongoDB Atlas M10Decoupled from marketplace Postgres; Better Auth's Mongo adapter handles sessions
Auth clientBetter Auth genericOAuth pluginHandles authorize URL, PKCE, token exchange, refresh rotation, session storage, cookies
FrontendReact 18 + Vite + TanStack QuerySame family as the marketplace UI
Tier UXTierContext + useTier() hookCaches tier from /api/me/tier (which proxies marketplace)
HostingECS Fargate behind ALB + AWS Amplify (frontend)ALB idle timeout must be ≥ 4000s if you stream SSE
SecretsAWS Secrets ManagerFive 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; Secure is 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:

ComponentAWS serviceNotes
BackendECS 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
FrontendAmplifyConnects to GitHub, runs pnpm build, serves the SPA
DBMongoDB Atlas (or whatever you prefer)Allow 0.0.0.0/0 from Atlas, or use NAT gateway + fixed Elastic IP
SecretsSecrets ManagerInject into ECS task definition
CI/CDGitHub ActionsBuild 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_access is required for refresh_token. Without it, the marketplace will not issue a refresh token, and your session will die in ~10 hours.
  • The marketplace:agentTiers claim 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/:agentId for the live state.
  • Authorization: Bearer <access_token> for marketplace API calls. Cookies are for the marketplace's own SPA, not for cross-service calls.
  • clientReqId must 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.