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 exposed is voice minutes / seconds / quota: the control.usage event (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:

  1. 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.
  2. Reverse tunnel — the shim runs INSIDE the intranet and a tunnel (Cloudflare Tunnel, frp, ngrok-class) publishes the hostname; no inbound firewall holes.
  3. 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 same pcsk_ 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) — 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).