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 exposed is voice minutes / seconds / quota: thecontrol.usageevent (emitted since 0.58.0) carries per-turn TOKEN totals (promptTokens,completionTokens,cachedTokens), not voice time, so the SDK still 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.
8. Can the agent read/change data in MY system? Do you have DB connectors?
Yes to the first, deliberately no to the second. The Agent Data plane
lets an agent read your authoritative state (Views), change it behind a
user-consent gate (Actions), and react when it changes (Events) —
all over plain signed HTTPS endpoints you host. There are no database
connectors, drivers, or query languages by design: the agent selects a
declared capability by name and never authors a query, so any database (or
none) works behind your endpoints. Declarations are immutable versions; the
per-agent flags are live kill switches; every Action is idempotent at your
backend by a runtime-minted actionId and honestly classified
committed | rejected | unknown.
Start here: Quickstart: Agent Data Plane.
@pouchy_ai/backend-kit (Node ≥18, zero deps) implements the wire contract
— verification, idempotency, reconciliation, webhook verify, event emission.
9. My system is on-prem / intranet (ERP, internal DB) — how does the Data plane reach it?
It doesn't — and that's the design. Pouchy only ever calls the HTTPS
audience hostname you declared; what answers there is a thin shim you
place where Pouchy can reach it, talking to your internal system over your
own private network. Three deployment shapes, in order of preference:
- Shim in your cloud/DMZ — a small service (the published Node receiver pattern, ~100 lines) on a public HTTPS hostname, reaching the internal API/DB over your VPN or private link.
- Reverse tunnel — the shim runs INSIDE the intranet and a tunnel (Cloudflare Tunnel, frp, ngrok-class) publishes the hostname; no inbound firewall holes.
- Existing API gateway — if the ERP already has an internal REST layer, the shim is just verify-and-forward mounted on your gateway's public edge.
The shim's language is yours: @pouchy_ai/backend-kit on Node, or the
dependency-free C#/.NET reference published at
Reference: Data-plane receivers (vector
self-test included — dotnet run proves the port byte-compatible before
deployment).
What keeps this safe rather than scary:
- Database credentials never leave the intranet. The shim needs no
database credential — it holds the project's
pcsk_secret and, at most, a narrow internal-API credential. Note that POUCHY-ACTOR-V1 is a symmetric HMAC: the samepcsk_value that verifies can also MINT valid signatures for every receiver of the project, so treat the shim's copy as a full credential and scope the shim host accordingly. There is no DB driver in the trust boundary. - The signature is the authentication. Verify POUCHY-ACTOR-V1 on every request and answer failures with one uniform 404 — then the shim's public surface is "a 404 unless you can forge an HMAC". Do NOT substitute IP allowlists for verification: platform egress IPs are not fixed, and the signature is strictly stronger anyway.
- Outbound events are ordinary egress HTTPS to
pouchy.ai— usually already permitted from an intranet; no inbound path is needed for Events alone (they defer to next-conversation without the reply webhook). - The Action idempotency store can live in the internal DB (one table
keyed by
actionId) — reconciliation then answers from the system of record itself.
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).