| name | viverse-token-gateway |
| description | Integrate VIVERSE Token Gateway for AI chat in published apps — auth header construction, non-streaming and SSE streaming patterns, rate-limit handling, and error recovery. |
| prerequisites | ["viverse-auth (checkAuth → accessToken)","VIVERSE Studio App ID","App registered with Token Gateway in Studio"] |
| tags | ["viverse","ai","token-gateway","chat","llm","streaming","sse"] |
VIVERSE Token Gateway Integration
Use this skill to add AI chat capabilities to a VIVERSE published app. The Token Gateway proxies LLM requests (via OpenRouter) on behalf of the app owner — no API keys are ever exposed to the browser.
When To Use This Skill
- Add AI NPC dialogue, hint systems, or conversational UI to a VIVERSE world/game
- Need access to LLM models (GPT-4o, Claude, etc.) from browser or PlayCanvas/Three.js code
- Want usage billed to the app owner, not managed per end-user
Read Order
- This file
- patterns/chat-request.md — non-streaming chat
- patterns/streaming-chat.md — SSE streaming via Viverse Morph
Prerequisites
[!CAUTION]
AGENT INSTRUCTION — BLOCKING PREFLIGHT. DO NOT SKIP.
Before writing any Token Gateway code, you MUST ask the user the following two questions and wait for answers:
- "Is VIVERSE SSO (
viverse-auth / checkAuth()) already integrated in this project?"
- If NO → tell the user: "Token Gateway requires VIVERSE SSO auth first. I'll integrate
viverse-auth before proceeding with Token Gateway." Then read and implement viverse-sdk-skills/skills/viverse-auth/SKILL.md completely before continuing.
- If YES → confirm
accessToken is available at call time and VIVERSE_APP_ID is resolved (non-empty) before proceeding.
- "Is this app registered in VIVERSE Studio with an AI quota assigned?"
- If NO → tell the user to go to https://studio.viverse.com, open the app, and enable Token Gateway access. The integration will return
403 until this is done.
- If YES → proceed.
Do not assume auth is in place. Do not proceed with token-gateway implementation until both questions are answered.
This skill requires viverse-auth to be implemented first.
- User is authenticated —
checkAuth() has been called and returned a valid accessToken.
- App ID — available as
VITE_VIVERSE_CLIENT_ID or equivalent. This is a public value, not a secret.
- App is registered — creator has registered the app in VIVERSE Studio and configured an AI quota. If not registered, Token Gateway returns
403.
Do NOT proceed with this skill until viverse-auth integration is complete and working.
Preflight Checklist
Mandatory Compliance Gates (MUST PASS)
These are release blockers for any token-gateway integration task:
-
MUST obtain accessToken via viverse-auth before any Token Gateway call. Do not hardcode tokens.
-
MUST send AccessToken: <htc-jwt> with exact casing — accesstoken (lowercase) is blocked in production CORS preflight.
-
MUST send X-App-Id: <appId> header on every request. This is how the gateway resolves the OR key and applies the correct quota.
-
MUST NOT store or log the accessToken beyond the current session.
-
MUST handle 429 Too Many Requests — read Retry-After header (seconds) and back off before retrying.
-
MUST handle 401 Unauthorized by re-running the auth flow (checkAuth()) before retrying once. Do not retry 401 more than once.
-
MUST handle 403 Forbidden — two distinct cases, both non-retryable:
"app not registered" — app owner needs to register the app in Studio
"app owner has not assigned an AI provider key" — app owner needs to configure their OR API key in Studio
Surface both as developer/config errors, not user-facing errors.
-
MUST handle 503 Service Unavailable (provider key resolution failed) gracefully in UI.
-
MUST use POST /v1/chat/stream (via SG) for streaming — send the same AccessToken + X-App-Id headers as non-streaming.
-
MUST include a VERSION_NAME constant in generated gateway integration code, logged on startup.
-
MUST NOT fall back to a direct OpenRouter call if Token Gateway fails — surface the error instead of bypassing the gateway.
-
MUST specify Content-Type: application/json on all POST requests.
Architecture
Published App (browser)
│
│ AccessToken: <htc-jwt>
│ X-App-Id: my-game-001
▼
Service Gateway (SG Nginx) ← verifies accessToken via AKS
│ injects X-HTC-Account-Id + X-HTC-Auth-Client
├──── non-streaming ──────────► Token Gateway /v1/chat → OpenRouter
│
└──── streaming (SSE) ────────► Token Gateway /v1/chat/stream → OpenRouter
Billing is charged to the app owner (by appId in Studio registry), not the end user.
Endpoint Reference
| Endpoint | Path | Auth |
|---|
| Non-streaming chat | POST /v1/chat | AccessToken + X-App-Id (via SG) |
| Streaming chat (SSE) | POST /v1/chat/stream | AccessToken + X-App-Id (via SG) |
| Available models | GET /v1/models | none |
| Usage stats | GET /v1/usage?appId=... | AccessToken + X-App-Id (via SG) |
Base URL (via SG): https://token-gateway.viverse.com
Dev URL (skip auth): http://localhost:4000 with DEV_SKIP_AUTH=1
Implementation Workflow
1) Obtain accessToken (prerequisite)
You must complete viverse-auth before this step. The full bootstrap — including appId resolution and the mandatory 1200ms handshake delay — must run before any Token Gateway call:
const appId = new URLSearchParams(location.search).get('appId')
|| localStorage.getItem('my_app_id')
|| location.hostname.match(/^([a-z0-9]+)(?:-preview)?\.world\.viverse\.app$/)?.[1]
|| import.meta.env.VITE_VIVERSE_CLIENT_ID
|| '';
if (!appId) throw new Error('[TokenGateway] App ID could not be resolved — set VITE_VIVERSE_CLIENT_ID');
const sdk = window.viverse || window.VIVERSE_SDK || window.vSdk;
const client = new sdk.client({ clientId: appId, domain: 'account.htcvive.com' });
await new Promise(r => setTimeout(r, 1200));
const auth = await client.checkAuth();
const accessToken = auth?.access_token;
if (!accessToken) {
throw new Error('User not authenticated');
}
[!IMPORTANT]
Do not skip the 1200ms delay. Calling checkAuth() before the VIVERSE iframe bridge is ready causes a silent timeout with no token — this is the most common integration failure.
2) Non-streaming chat request
See patterns/chat-request.md for the full service implementation.
Quick reference:
const response = await fetch('https://token-gateway.viverse.com/v1/chat', {
method: 'POST',
headers: {
'AccessToken': accessToken,
'X-App-Id': appId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are a helpful in-game assistant.' },
{ role: 'user', content: userMessage },
],
temperature: 0.7,
max_tokens: 512,
}),
});
if (!response.ok) {
await handleGatewayError(response);
return;
}
const data = await response.json();
const reply = data.choices[0].message.content;
3) Streaming chat request (SSE)
See patterns/streaming-chat.md for the full implementation.
Streaming uses the same auth as non-streaming — AccessToken + X-App-Id via SG, hitting /v1/chat/stream directly:
const response = await fetch('https://token-gateway.viverse.com/v1/chat/stream', {
method: 'POST',
headers: {
'AccessToken': accessToken,
'X-App-Id': appId,
'Content-Type': 'application/json',
},
body: JSON.stringify({ messages, model: 'openai/gpt-4o-mini', stream: true }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (payload === '[DONE]') return;
const chunk = JSON.parse(payload);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) onChunk(delta);
}
}
3b) Dev vs Demo/Prod Auth Routing
Projects always need a local dev setup. First, provision a dev token once using the gateway admin API:
curl -X POST http://localhost:4000/v1/tokens \
-H "x-gateway-secret: <GATEWAY_SERVICE_SECRET>" \
-H "Content-Type: application/json" \
-d '{
"appId": "my-app-dev",
"tenantId": "your-htc-account-id",
"label": "local dev token",
"rateLimit": 60,
"dailyQuota": 500
}'
Alternatively, set DEV_SKIP_AUTH=1 on the gateway to bypass all auth validation (useful for integration tests without a real token).
Then use an environment flag to switch between direct bearer auth (dev) and the SG/SSO path (demo/prod). Do not hardcode either path:
const GATEWAY_ENV = localStorage.getItem('gw_env') || 'demo';
const GW_URLS = {
dev: location.hostname === 'localhost' ? `${location.protocol}//${location.host}` : 'http://10.x.x.x:4000',
demo: 'https://token-gateway.viverse.com',
prod: '',
};
async function buildAuthHeaders() {
if (GATEWAY_ENV === 'dev') {
const token = localStorage.getItem('gw_dev_token');
if (!token) throw new Error('Dev mode requires a vvai_* Gateway Token');
return { 'Authorization': `Bearer ${token}` };
} else {
const accessToken = await ensureVivAuth();
if (!accessToken) throw new Error('VIVERSE login required');
return {
'AccessToken': accessToken,
'X-App-Id': APP_ID,
};
}
}
[!IMPORTANT]
Default GATEWAY_ENV to 'demo' (not 'dev') in published apps. If it defaults to 'dev', the SSO path is never exercised and users will see a "missing token" error in production.
4) Rate Limit Handling
The gateway enforces two independent buckets — both return 429 with Retry-After:
| Bucket | Key | Default limit |
|---|
| App aggregate | ownerTenantId:appId | 100 req/min (Studio-configured) |
| Per-user | userAccountId:appId | min(appLimit/5, 20) req/min |
async function callGatewayWithRetry(payload, maxRetries = 2) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch(GATEWAY_URL, payload);
if (response.status !== 429) return response;
const retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10);
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
}
}
throw new Error('Rate limit exceeded — please try again later');
}
5) Error Handling Summary
async function handleGatewayError(response) {
switch (response.status) {
case 401:
await reAuthenticate();
break;
case 403:
console.error('[TokenGateway] App not registered or quota exhausted.');
showDeveloperAlert('AI features unavailable — check Studio app registration.');
break;
case 429:
showUserMessage('Too many requests — please wait a moment.');
break;
case 503:
showUserMessage('AI service temporarily unavailable.');
break;
default:
console.error('[TokenGateway] Unexpected error', response.status);
}
}
Runtime Preflight
Models
Use GET /v1/models to list available models. Recommended defaults:
| Use case | Model |
|---|
| General chat / NPC dialogue | openai/gpt-4o-mini |
| Complex reasoning / storytelling | openai/gpt-4o |
| Fast, low-cost responses | openai/gpt-3.5-turbo |
| Vision (image input) | openai/gpt-4o |
If model is omitted from the request body, Token Gateway falls back to the app's configured default in Studio, then to gpt-4o-mini.