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 /+esm endpoint 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 native RTCPeerConnection + 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):

  1. Sign in to pouchy.ai, then call POST https://pouchy.ai/api/companion/keys with the Firebase ID token as the bearer (optionally { "label": "Poker game" } in the body).
  2. 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.
  3. The pchy_… token appears once in the response — copy it immediately (it's never shown again).
  4. 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 if salience < 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.
  • retained state: one slot per type, last-write-wins (coalesced). Push the same type as often as you like — only the latest is kept.
  • retained TTL default: no expiry (ttl omitted ⇒ kept until the same type is overwritten or the session ends). ttl is in milliseconds (expiresAt = now + ttl).
  • salience out of range: clamped to [0,1] on ingest (a non-numeric value ⇒ dropped as unset). So > 1 does NOT sort above a legitimate 1.0 — two events at 5 and 3 both become 1.0 and tie-break by recency; use the real [0,1] range if you need ordering. Below the 0.25 recall 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 the X-SDK-Version response 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-Version response 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/client is 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.*) at users/{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() (scope wallet.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: the control.usage event 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) — dist is now valid no-bundler ESM (#1).
  • Add the @elevenlabs/client import map (#2); OpenAI fallback is zero-dep.
  • Player mints a key via POST /api/companion/keys (signed in; omit scopes — the default pack already has call/voice/events.subscribe/worldstate.write) (#3).
  • connectCall({ locale:'zh', onTranscript, onSpeakingChange }) → stream poker world-state, mark beats voiceRelevant (#4, #5).
  • Don't rely on reading balance/minutes yet (#7).