Pouchy Companion — Integration FAQ (answers to partner questions)
Answers to the integration team's questions. Context: a web game embedding the companion in real-time voice (call) mode only, for play-along (陪玩). This voice-first model is our main partner pattern.
1. dist used extensionless relative imports — FIXED ✅
You were right. tsc (moduleResolution: bundler) emitted from './protocol',
which native-browser/Node ESM can't resolve without .js.
Fixed in the SDK build: a post-build step now rewrites dist's relative
imports to include .js (both *.js and *.d.ts). The shared source stays
extensionless (the SvelteKit app consumes it via Vite); only the published dist
is rewritten. New dist looks like:
import { CompanionClient } from './client.js';
export { openCompanionCall } from './call.js';
"type": "module" is set; the bare @elevenlabs/client dynamic import is left
untouched (resolved via your import map — see #2). So "runs in the browser with
no bundler" now actually holds. Rebuild with npm run build to get the fixed
tarball.
2. @elevenlabs/client bare dynamic import — no-bundler import map
The SDK only import('@elevenlabs/client') when the server picks the ElevenLabs
provider. For a no-bundler page, add an import map BEFORE your module script:
<script type="importmap">
{
"imports": {
"@elevenlabs/client": "https://esm.sh/@elevenlabs/client@1.9.0"
}
}
</script>
<script type="module">
import { createCompanion } from './vendor/companion-sdk/index.js'; // or a CDN URL — see #6
// … connectCall() will dynamically import @elevenlabs/client via the map above
</script>
- Recommended CDN:
esm.sh(https://esm.sh/@elevenlabs/client@1.9.0). It serves proper ESM and resolves the package's own deps. (jsDelivr's/+esmendpoint also works:https://cdn.jsdelivr.net/npm/@elevenlabs/client@1.9.0/+esm.) - OpenAI Realtime fallback is genuinely zero-dependency — confirmed. That path
(
openOpenAICall) uses only the browser's nativeRTCPeerConnection+fetch; it never touches@elevenlabs/client.
⚠️ Caveat — the provider is chosen server-side (ElevenLabs Convai primary → OpenAI Realtime fallback), the client can't force it. So unless your Pouchy account is configured OpenAI-only, assume ElevenLabs and ship the import map. It's harmless if OpenAI ends up being used. Tell us if you need the account pinned to OpenAI Realtime and we can configure it.
3. How a player self-serves a token — corrected
Heads-up: the "伴侣接入密钥" panel has no per-scope checkboxes. A generated key already includes the safe default scope bundle, which is exactly what voice play-along needs:
chat,voice,call,files,events.subscribe,worldstate.write,memory.read:app,memory.write:app
So the player's steps are simpler than "tick these four" (note: there is no in-app Wallet panel for this yet — the mint is one API call while signed in to pouchy.ai):
- Sign in to pouchy.ai, then call
POST https://pouchy.ai/api/companion/keyswith the Firebase ID token as the bearer (optionally{ "label": "Poker game" }in the body). - Don't pass
scopes. The default is the safe pack — it already covers voice play-along; the sensitive scopes (wallet.spend/social.message/skills.execute) are only granted when explicitly requested, and play-along does not need them. - The
pchy_…token appears once in the response — copy it immediately (it's never shown again). - Paste it into your game's config field.
For player-friendly onboarding without hand-pasted tokens, prefer Login with Pouchy (below) — the PAT path is the developer-audience shortcut.
If you'd rather not have players hand-paste tokens, use Login with Pouchy (OAuth 2.1 + PKCE) — the player clicks "Connect Pouchy", consents, and your backend gets the token. See the API reference §6.
4. connectCall — full TypeScript surface
// options
type ConnectCallOptions = {
voice?: string; // override the companion voice id
locale?: string; // e.g. 'zh' — open the call in this language
audioElement?: HTMLAudioElement; // route remote audio here (one is created if omitted)
onTranscript?: (e: { role: 'user' | 'assistant'; text: string }) => void;
onSpeakingChange?: (speaking: boolean) => void; // companion started/stopped talking
onError?: (err: Error) => void;
bargeIn?: boolean; // opt-in full-duplex — let the user talk over the companion
// (default false = half-duplex; see the API reference)
};
const call: CompanionCall = await companion.connectCall(options?);
// returned handle — the COMPLETE surface:
interface CompanionCall {
provider: 'elevenlabs-convai' | 'openai-realtime';
close(): void; // hang up (idempotent)
injectEvent(text: string, speak?: boolean): void; // push an out-of-band line into the call;
// speak=true → react now, false → stage for next turn
interrupt(): void; // silent cut (0.36.0): stop the utterance currently
// being spoken WITHOUT generating new speech.
// Idempotent (no-op when nothing is playing).
// GUARANTEED on 'openai-realtime' (cancels the response
// + clears the output buffer); BEST-EFFORT on
// 'elevenlabs-convai' (no public stop-playback API —
// may degrade to a no-op; injectEvent(text, true) is
// the reliable EL-side preemption, but it speaks).
}
That's everything — there are no other fields/methods. Note you usually don't
need injectEvent: connectCall already auto-bridges server-pushed
voiceRelevant world-state into the call, so streaming world-state is the normal
path; injectEvent is only for client-side ad-hoc nudges.
(startCall(opts) is the lower-level half — it just mints CallCredentials and
you open the media yourself; connectCall = startCall + open WebRTC + bridge.)
5. sendWorldState — limits, TTL, salience
There is no fixed request-rate quota on the context endpoint; backpressure is by shape instead (protects the prompt budget + cost):
- Transient events (no
retained): dropped ifsalience < 0.25(the floor); kept newest-first and capped at 32; the prompt digest surfaces the top ~6 by salience. So spamming low-salience events is silently thinned, not rate-limited. retainedstate: one slot pertype, last-write-wins (coalesced). Push the sametypeas often as you like — only the latest is kept.retainedTTL default: no expiry (ttlomitted ⇒ kept until the sametypeis overwritten or the session ends).ttlis in milliseconds (expiresAt = now + ttl).salienceout of range: clamped to[0,1]on ingest (a non-numeric value ⇒ dropped as unset). So> 1does NOT sort above a legitimate1.0— two events at5and3both become1.0and tie-break by recency; use the real[0,1]range if you need ordering. Below the0.25recall floor an event is dropped.
Practical rule for poker: push table state as retained (current hand, blinds,
stack), and mark only the real beats (your_turn, key board cards, win/lose)
voiceRelevant: true with salience ≥ 0.6. Everything else low-salience or
omitted.
6. npm / CDN publishing
The repo is private, so jsDelivr-from-GitHub is out. Recommended (and live): load the SDK straight from Pouchy's origin — a single-file ESM bundle served with CORS at:
https://pouchy.ai/sdk/companion-sdk.js
No bundler, no vendoring, no npm account. Import-map and go:
<script type="importmap">
{
"imports": {
"@pouchy_ai/companion-sdk": "https://pouchy.ai/sdk/companion-sdk.js",
"@elevenlabs/client": "https://esm.sh/@elevenlabs/client@1.9.0"
}
}
</script>
<script type="module">
import { createCompanion } from '@pouchy_ai/companion-sdk';
</script>
- The endpoint sets
Access-Control-Allow-Origin: *(cross-origin module imports require CORS) and reports the build version in theX-SDK-Versionresponse header. It always serves the currently-deployed SDK version; we'll tell you when it changes. - Which version am I actually running? Two self-checks, no guessing: the
X-SDK-Versionresponse header (curl -sI https://pouchy.ai/sdk/companion-sdk.js) and the banner on the bundle's first line (@pouchy_ai/companion-sdk vX.Y.Z). Version numbers in code comments or your own notes drift — the served bytes are the truth (a field report once assumed "0.5.0"; the wire said 0.34.0). @elevenlabs/clientis still external — keep its import-map entry (esm.sh).
Pinning a version (0.31.0+, recommended for production): the floating URL
follows every redeploy. If you'd rather control upgrades, use the immutable
version-pinned URL plus its SRI hash (published on
pouchy.ai/sdk → Installation, always in lockstep with
the release). The machine-readable release index lives at
https://pouchy.ai/sdk/versions — every pinnable version with its immutable
URL and sha384-… integrity value, straight from the served bytes:
<script type="importmap">
{
"imports": {
"@pouchy_ai/companion-sdk": "https://pouchy.ai/sdk/v0.31.0/companion-sdk.min.js"
},
"integrity": {
"https://pouchy.ai/sdk/v0.31.0/companion-sdk.min.js": "sha384-<from the /sdk page>"
}
}
</script>
A minified twin of the floating URL also exists at
https://pouchy.ai/sdk/companion-sdk.min.js (~10 KB gzipped).
⚠️ Hostname rule: the bundle URL must serve the bytes DIRECTLY — no
cross-origin redirect. A browser import() runs in CORS mode, and per the
Fetch spec EVERY hop needs CORS headers — a platform-level 307 (e.g. an
apex↔www domain redirect) fires before our app code and carries none, so the
import dies on the redirect hop with a generic
Failed to fetch dynamically imported module (integrator field report,
2026-07). Self-check once: curl -sI <your bundle URL> — expect HTTP/2 200;
if you see a 307/308, import from the redirect TARGET host directly (and tell
us — the intended config is both hostnames serving directly). The same rule
applies to the import-map integrity block: SRI hashes the FINAL bytes, but a
failed redirect hop dies before hashing, which is easy to misread as a hash
mismatch. Keep the imports and integrity URLs identical, and both on the
direct-serving host.
Alternative — npm (if you'd rather pin exact versions yourself): we can publish
@pouchy_ai/companion-sdk to npm (public or a private registry), then you
npm i @pouchy_ai/companion-sdk or import-map to https://esm.sh/@pouchy_ai/companion-sdk@<version>.
Tell us if you want this; it's a publish step on our side. See
packages/companion-sdk/PUBLISHING.md.
7. Billing & balance — current reality (be aware)
Honest status, since this matters for your "show wallet / minutes" UI:
- Voice usage IS metered, per token, server-side — but as operation counters
(
total,byKind.call,byDay.*) atusers/{uid}/companionUsage/{tokenId}, not as minutes/seconds. It's the reserved billing hook + a future "AI usage" view. - Wallet BALANCE is readable via
client.getWallet()(scopewallet.read, SDK 0.20.0+) →{ balances, totalUsd, currency }— build a wallet-balance UI on that. What is NOT yet exposed is voice minutes / seconds / quota: thecontrol.usageevent is defined in the protocol but not emitted yet, so the SDK cannot read remaining voice minutes to render a per-minute meter. - No charging to the player's wallet is wired yet for companion voice (billing integration was deliberately deferred). So today voice陪玩 is metered but not billed-through.
What this means for you now: a dollar wallet-balance meter is buildable today
(getWallet()); a per-MINUTE voice meter is not — that needs (a) control.usage
emitted with voiceSeconds and (b) a voice-quota read endpoint, both roadmap items.
If the per-minute meter matters for your launch, tell us and we'll prioritize it.
TL;DR for the poker integration
- Rebuild the SDK (
npm run build) —distis now valid no-bundler ESM (#1). - Add the
@elevenlabs/clientimport map (#2); OpenAI fallback is zero-dep. - Player mints a key via
POST /api/companion/keys(signed in; omitscopes— the default pack already hascall/voice/events.subscribe/worldstate.write) (#3). connectCall({ locale:'zh', onTranscript, onSpeakingChange })→ stream poker world-state, mark beatsvoiceRelevant(#4, #5).- Don't rely on reading balance/minutes yet (#7).