import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, ExpressionPayload, InterfaceUpdatePayload, MessagePayload, MessageAttachment, OutboundPayloadMap, OutboundType, PendingConfirm, PendingToolCall, RenderInterfacePayload, SocialMessagePayload, ToolCallPayload, TypingPayload, UsagePayload, VoiceInjectPayload, WorldStateEvent } from './protocol.js'; /** Ergonomic world-state input: a WorldStateEvent with the CloudEvents plumbing * (specversion / id / source) optional — sendWorldState fills them. */ export type WorldStateInput = Omit, 'specversion' | 'id' | 'source'> & Partial, 'specversion' | 'id' | 'source'>>; import { type ControlErrorCodeValue } from './errors.js'; import { type CompanionCall, type CompanionCallOptions } from './call.js'; export interface CompanionClientOptions { /** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */ baseUrl: string; /** A Pouchy access token — normally a session token from POST /v1/sessions; * a PAT (pchy_…) for personal integrations. */ token: string; /** Logical surface (one resumable session per surface), default "default". */ surface?: string; /** Requested I/O modalities (intersected with the token's grant server-side). */ modalities?: string[]; /** Action types this surface can perform (declared at handshake). Stored but * never consumed server-side today — a client-side capability hint whose one * SDK effect is opting a tool-less embed into the auto-offered host-control * verbs on connectCall() voice sessions. It does NOT register tools for text * turns: a tool you want called on a text turn must be declared in `tools`. */ handles?: string[]; /** World-state kinds this surface emits (declared at handshake). */ contextKinds?: string[]; /** Tools the companion may ask this surface to perform (companion.tool_call). */ tools?: CompanionToolDecl[]; /** Static description of THIS app/game so the companion is grounded in where it * is (drives reasoning + the call opener). Live state still flows via * sendWorldState. */ appContext?: { name?: string; description?: string; }; /** Open a REPRESENTATIVE ("on-behalf-of" / 代聊) session: the companion fields * this VISITOR's messages on the owner's behalf — customer-service style — * drawing only on screened owner context (persona + share-screened facts, plus * the knowledge base when the owner's token grants `expose:knowledge`). Each * visitor (stable opaque `id`, 8-64 url-safe chars that YOU keep stable per * end-user) gets their own continuous thread. Requires the token's `represent` * scope — without it the handshake is rejected (never silently downgraded to an * owner-facing session). */ visitor?: { id: string; displayName?: string; }; /** Injectable fetch (for Node < 18 / tests). Defaults to global fetch. */ fetch?: typeof fetch; /** Instrumentation (0.32.0). `true` logs structured events via * console.debug('[pouchy-sdk]', …); a function receives every * `CompanionDebugEvent` (HTTP request/response, delivered envelope, * stream-state transition, synthesized error) for your own logger / * devtools / test recorder. Events never carry the token or any header — * method, path, status, timing, envelope type/id only. Zero cost when * unset. */ debug?: boolean | ((event: CompanionDebugEvent) => void); /** Receive transport for the reply stream. 'sse' (default) works on every * deploy; 'websocket' opts into the lower-latency WS plane when the host * supports it (adapter-node / a WS gateway) and falls back to SSE if the * socket can't open — so enabling it can never stop reply delivery. */ stream?: 'sse' | 'websocket'; /** Origin the WebSocket plane is served from (0.59.0), e.g. * "https://ws.example.com" — the self-hosted WS gateway * (`workers/ws-gateway`). Only the reply socket goes here; every REST call * and the SSE fallback still use `baseUrl`. Unset = derive the socket URL * from `baseUrl`, which against pouchy.ai (no socket route) degrades to * SSE. `http(s)://` is rewritten to `ws(s)://`. */ streamBaseUrl?: string; /** Injectable WebSocket constructor (Node without a global WebSocket, or * tests). Defaults to globalThis.WebSocket when present. */ webSocketImpl?: typeof WebSocket; /** Called when a request or the event stream is rejected with 401 (expired / * revoked token — session tokens live 1h by default). Return a fresh token * (e.g. re-minted by your backend via POST /v1/sessions) and the client * retries transparently; return null to give up, surfacing the failure * exactly as without this hook. Scope denials (403 `missing_scope`) are * configuration errors and never trigger it. Alternative: call `setToken()` * proactively on your own refresh schedule. */ onAuthError?: () => string | null | Promise; /** Opt-in offline send queue (0.34.0). A sendText that fails at the NETWORK * level (fetch rejected before any HTTP response, or `navigator.onLine` is * false) is queued instead of lost, and replayed FIFO once the server is * reachable again — after a successful connect(), on every stream * reconnect, or via `flushOutbox()`. Replays reuse the ORIGINAL turnId: * the server dedupes completed turnIds (60 min TTL, last 32 per session — * `RECENT_TURN_TTL_MS`, widened in 0.39.0), * so a retry after an ambiguous failure can never double-run a turn. HTTP * error responses (4xx/5xx incl. 429) are NEVER queued — the server saw * those; they keep the normal throw semantics. Default false. */ queueOffline?: boolean; /** Replay of pause-orphaned tool calls (0.37.0). When `connect()` resumes a * session whose turn is paused on tool calls THIS surface never answered * (the page reloaded / crashed mid-pause), the handshake carries them as * `pendingToolCalls` and the client re-emits each to your onToolCall * handler with `replayed: true` — post the results as usual and the paused * turn completes instead of being abandoned. Register onToolCall BEFORE * calling connect()/start() to receive them. The result apply is * idempotent per call id, but the APP-side effect may not be: if a tool * has side effects, treat `call.id` as an idempotency key when * `replayed` is set. Pass false to disable the re-emit — the calls stay * readable on the `HelloAck.pendingToolCalls` you get from connect(). * Default true. */ replayPendingToolCalls?: boolean; /** Persistence for the offline queue (see `queueOffline`). Defaults to * localStorage when available (privacy-mode / quota failures fall back * silently), else an in-memory page-lifetime store. The key is * `pouchy-outbox:${surface}`, so each surface keeps its own queue — * and, on a REPRESENTATIVE session (see `visitor`), * `pouchy-outbox-v:${surface}:${visitorId}`, so one visitor's queued * sends can never replay into another visitor's thread (0.50.1). */ outboxStore?: OutboxStore; /** Deadline for a request to produce RESPONSE HEADERS, in ms (0.35.0). * Bounds every HTTP call the client makes (connect, sendText, recall, …) so * a server that accepts the TCP connection but never answers can't hang a * helper forever — previously only `sendText({ awaitReply })` had any * deadline. Reading a response BODY (the SSE event stream, a streaming * sendText reply) is never bounded by this — the timer is cleared the * moment headers arrive. Composes with any per-call AbortSignal (whichever * fires first wins); the timeout rejects as a status-0 CompanionError with * code 'request_timeout'. Default 30_000; 0 disables. * * BUFFERED `/input` sends are the one exception to the 30s default * (0.40.0): there the server answers only when the TURN completes, so the * default deadline for those POSTs is 310s — the server's own turn ceiling * — instead of 30s (which declared healthy >30s turns dead while they kept * running, and billing, server-side). Setting `requestTimeoutMs` explicitly * bounds EVERY request at your value, buffered sends included. */ requestTimeoutMs?: number; } export interface HelloAck { session: string; grantedScopes: string[]; modalities: string[]; resumeCursor: number; /** True when this is a representative (visitor-facing) session — i.e. a * `visitor` was supplied and the token holds `represent`. */ representative: boolean; /** Representative sessions only: whether this visitor is already paired with * the owner (their two companions are friends). Lets you skip offering a * "pair" affordance to an already-connected visitor. */ visitorPaired: boolean; /** Outstanding tool calls of a turn that paused before this handshake — * non-empty only when resuming mid-pause (0.37.0). Unless * `replayPendingToolCalls: false`, each is also re-emitted to onToolCall * with `replayed: true` right after connect() resolves. */ pendingToolCalls: PendingToolCall[]; /** Declared `tools` the handshake refused (0.77.0) — present only when one * was. `index` is the position in the `tools` you sent; `reason` is * `missing_name` (no string `name`), `duplicate_name` (collides with an * earlier tool once mapped to the provider's 64-char name rule) or * `over_cap` (past the 32-tool cap). A refused tool is never offered to * the model, so this is the one place a typo'd decl shows up. A * `duplicate_name` entry also carries `name` (the refused tool) and * `collidesWith` (the earlier tool it maps onto). */ droppedTools?: DroppedToolDecl[]; } /** One `tools` declaration the handshake refused — see `HelloAck.droppedTools`. */ export interface DroppedToolDecl { index: number; reason: 'missing_name' | 'duplicate_name' | 'over_cap' | (string & {}); /** On `duplicate_name`: the refused tool's name, as you declared it. */ name?: string; /** On `duplicate_name`: the earlier declared name it collides with once * both are mapped to the provider's name rule (CR-946). */ collidesWith?: string; } /** A tool the embedding declares the companion may ask it to perform. */ export interface CompanionToolDecl { name: string; description?: string; parameters?: Record; } /** What `onToolCall` hands your handler: the `companion.tool_call` wire payload * plus `argsJson` — `args` pre-parsed as JSON. `undefined` when `args` is * empty or not valid JSON; keep reading the raw `args` string when you need * the exact model output. */ export interface ToolCallEvent extends ToolCallPayload { argsJson?: unknown; } /** Voice-plane credentials, discriminated by provider (see startCall). */ export type CallCredentials = { provider: 'elevenlabs-convai'; /** EL conversation token (open WebRTC with @elevenlabs/client). */ token: string; agentId: string; /** Pass as overrides.agent.prompt.prompt when starting the EL session. */ instructions: string; /** Server-resolved EL voice id (the user's cloned/localized voice). Pass * as overrides.tts.voiceId so the call isn't a stock default voice. */ voice?: string; /** Server-resolved agent language (ISO 639-1, e.g. 'zh'). Pass as * overrides.agent.language so the call opens in the user's language. */ language?: string; /** First-message override ('' = suppress the canned greeting so the call * opens in-character / context-aware). Pass as overrides.agent.firstMessage. */ firstMessage?: string; /** Generation handle of the call-active window this mint stamped (0.41.0). * Self-plumbed integrations: pass it to endSession({ callGen }) at call * end so the server clears exactly this call's window — a stale handle's * late teardown then can't clear a newer call's window. connectCall() * handles this automatically. */ callGen?: string; /** Call-scoped bearer the agent's memory webhook tool presents back to us * mid-call (0.45.0). Self-plumbed integrations: pass it to * `Conversation.startSession` under BOTH dynamic variable names * (0.47.0) — * `dynamicVariables: { [VOICE_TOOL_VARIABLE]: creds.voiceToolToken, * [VOICE_TOOL_VARIABLE_PLAIN]: creds.voiceToolToken }`. * ElevenLabs IGNORES a client-supplied `secret__` variable, so the plain * twin is the one that arrives; it reaches the tool as a request-body * property the platform fills, never as prompt content the LLM sees. * Passing only the first name raises no error — it just leaves the * companion unable to recall anything. connectCall() handles this * automatically. * * ABSENT, not empty, when the session cannot read memory: a client with * no memory grant has nothing to pass, rather than something that fails * inside a live call. Omitting it does not break the call — recall just * answers "no stored memory". */ voiceToolToken?: string; /** `true` when this call was granted Eleven v4 performed cues * (`startCall({ audioTags: true })`, 0.64.0): `instructions` then * teach the model `[laughs]` / `[whispers]` and the transcript carries * them — strip them with `stripAudioTags` before you show or store a * line. `connectCall` does that for you. ABSENT when not granted. */ audioTags?: boolean; /** Whether this call is recorded (`startCall({ record: true })`, * 0.65.0). Present only when the call asked. `true` means the provider * keeps the whole call (both voices) and `getCallRecording(callGen)` * will return it after the call ends. */ recording?: boolean; /** Why a call that asked to be recorded is not (0.65.0). Tell the reader * BEFORE the call connects. */ recordingUnavailable?: CallRecordingUnavailable; /** Pouchy World role sessions only (0.67.0): whether the call carries * the story's current state (see `CallWorldBrief`). ABSENT on an * ordinary session. */ worldBrief?: CallWorldBrief; /** Pouchy World role sessions only (0.68.0): whether the role can * reach its private layer during this call (see `CallPrivateStory`). * ABSENT on an ordinary session. */ privateStory?: CallPrivateStory; /** A talking-face video avatar for this call (0.75.0), present when the * call asked (`startCall({ avatar: { faceId } })`) and got one. Open it * with simli-client: `new SimliClient(video.sessionToken, videoEl, * audioEl, video.iceServers)`, then feed it the companion's remote audio * track (`listenToMediastreamTrack`). The voice still comes from this * call; the face only lip-syncs to it. */ video?: CallVideo; /** Why a call that asked for an avatar has none (0.75.0). The call is * voice-only; tell the reader before it connects. */ videoUnavailable?: CallVideoUnavailable; /** What the server cut to keep `instructions` within the realtime cap * (0.68.0; see `CallTrimmed`). ABSENT when nothing was cut. */ trimmed?: CallTrimmed; } | { provider: 'openai-realtime'; /** Ephemeral client secret (ek_…); open WebRTC directly to OpenAI. */ clientSecret: string; model: string; voice: string; expiresAt: number | null; /** See the elevenlabs-convai branch — same window-generation handle. */ callGen?: string; /** OpenAI Realtime keeps no recording: `false` whenever the call asked * (0.65.0), with `recordingUnavailable` saying why. */ recording?: boolean; recordingUnavailable?: CallRecordingUnavailable; /** See the elevenlabs-convai branch (0.67.0). */ worldBrief?: CallWorldBrief; /** Always `'none'` on a world session here (0.68.0): the OpenAI * Realtime fallback has no webhook tools. */ privateStory?: CallPrivateStory; /** See the elevenlabs-convai branch (0.68.0). */ trimmed?: CallTrimmed; /** See the elevenlabs-convai branch (0.75.0). */ video?: CallVideo; videoUnavailable?: CallVideoUnavailable; }; /** A call's video avatar credentials (0.75.0; companion API 1.9.0). The server * holds the Simli key and hands out a short-lived session bound to one of * your project's READY faces — built with your admin key (admin-sdk * `createVideoFace`, `POST /v1/admin/video-faces`). */ export interface CallVideo { provider: 'simli'; faceId: string; /** For `new SimliClient(sessionToken, videoEl, audioEl, iceServers)`. */ sessionToken: string; /** For simli-client's P2P transport; may be empty — its LiveKit transport * needs none. */ iceServers: Array<{ urls: string; username?: string; credential?: string; }>; /** Seconds Simli keeps the face open — the call window's own length. */ maxSessionLength: number; /** The Simli animation model this session was minted with (0.76.0) — * what you asked for in `avatar.model`, else `fasttalk`. */ model: CallVideoModel; } /** Simli's animation models (0.76.0). `fasttalk` is Simli's default; * `artalk` is the alternative — compare them on your own face. Both bill * at the same per-minute video rate. */ export type CallVideoModel = 'fasttalk' | 'artalk'; /** Why a call that asked for `avatar` has no video (0.75.0). * - `not_enabled` — the token lacks `call.video`: your project has no ready * face yet, or the minter withheld it (`withhold_scopes`). * - `not_configured` — video is not available on this deployment. * - `face_not_allowed` — `faceId` is not one of your project's faces. * - `face_not_ready` — it is yours but still being built (0.75.1). A * scheduled sweep marks a finished face `ready` within about 10 minutes; * the admin-sdk `listVideoFaces()` shows (and advances) it at once. * - `mint_failed` — the video provider refused or could not be reached; the * call itself is unaffected. */ export type CallVideoUnavailable = 'not_enabled' | 'not_configured' | 'face_not_allowed' | 'face_not_ready' | 'mint_failed'; /** Whether a Pouchy World role's call carries the story (0.67.0). * - `included` — the call's instructions hold the story's PUBLIC state (scene, * story clock, flags, the facts already revealed) and this role's own last * beats. Never its secrets, notes, goals or unrevealed facts: `instructions` * reach the reader's device (companion API 1.5.3). Nothing said on the call * changes the story: fold the call back in as a beat on the world turn door * (`trigger.context` + `proposedPatches`). * - `no_story` — the world pins no story package. * - `unavailable` — the story could not be read; the call went ahead without * it. */ export type CallWorldBrief = 'included' | 'no_story' | 'unavailable'; /** Whether a Pouchy World role's call can reach the role's private layer * (0.68.0; companion API 1.6.1). * - `tool` — the call is told about, and carries the credential for, the * in-call `recall_private_story` tool: ElevenLabs asks Pouchy for the role's * secrets and unrevealed facts server-to-server, only when the conversation * needs them. * - `none` — it is not (the OpenAI Realtime fallback, a session with no memory * read scope, or the operator's switch is off): the role keeps its private * knowledge vague on this call. */ export type CallPrivateStory = 'tool' | 'none'; /** Characters the server removed to keep a call's `instructions` within the * realtime cap (0.68.0; companion API 1.5.2). `story` comes off the end of the * Pouchy World story block first, then `context` off the end of the persona / * memory context. Your `brief` and the call framing are never cut. */ export interface CallTrimmed { story?: number; context?: number; } /** Why a call that asked for `record: true` is not recorded (0.65.0). * - `not_enabled` — this agent's recording switch is off (the token lacks * `call.record`; turn it on in the dashboard, then mint a new token). * - `not_configured` — recording is not available on this deployment. * - `no_conversation_id` / `store_failed` — the recording could not be bound * to this call; it will not be retrievable. */ export type CallRecordingUnavailable = 'not_enabled' | 'not_configured' | 'no_conversation_id' | 'store_failed'; /** The answer of `getCallRecording` (0.65.0). Only `ready` carries audio. * - `pending` — the provider is still processing the call; retry after * `retryAfterSec`. * - `none` — nothing was recorded for this call (it did not ask, was not * granted, never connected, or is not this session's). Retrying will not * change it. * - `expired` — it was recorded and the retention window removed it. * - `deleted` — it was removed with `deleteCallRecording`. */ export type CallRecordingResult = { state: 'ready'; audio: Blob; contentType: string; } | { state: 'pending'; retryAfterSec: number; } | { state: 'none' | 'expired' | 'deleted'; }; /** A representative pairing awaiting the visitor's approval (0.74.0, CR-1049). * Hand the visitor `approveUrl` — a Pouchy page where they sign in to their own * account and approve or decline — then poll `getPairRequest(id)`. `pending` * and `declined` requests last until `expiresAt`; an `expired` one is replaced * by calling `pairVisitor` again. */ export interface PairRequestInfo { id: string; status: 'pending' | 'approved' | 'declined' | 'expired'; approveUrl: string; /** ISO 8601. */ expiresAt: string; } /** The answer of `pairVisitor` / `getPairRequest` (0.74.0). `paired: true` * carries the `pairId` — the visitor was already a friend, or is a platform * instance whose template consents. Otherwise `request` says what the visitor * still has to do. */ export interface PairVisitorResult { paired: boolean; pairId: string | null; /** Absent when the pairing completed at once. */ request?: PairRequestInfo; } export interface RecalledMemory { /** The fact's stable id — the handle `forget(id)` takes. Absent on legacy * rows written before ids crossed the wire; such rows cannot be forgotten * item-wise. */ id?: string; content: string; category?: string; kind?: string; importance: number; namespace: string; createdAt?: string; } /** One exchange from a session's conversation log (see `history`). `user` is the * end-user's message, `assistant` the companion's reply; either may be `''` for * a paused / assistant-only row. `ts` is epoch ms. */ export interface CompanionTurn { user: string; assistant: string; ts: number; /** Files the assistant reply produced (CR-729) — the same structural list * its `companion.message` carried, so a restored transcript keeps the * download affordance. Absent on a text-only turn and on older servers. */ attachments?: MessageAttachment[]; } /** The user's current companion avatar (see getAvatar). */ export interface CompanionAvatar { /** Companion display name (the user's chosen name), or null. */ name: string | null; /** Persona archetype driving the default look/voice. */ archetype: 'girlfriend' | 'boyfriend' | 'pet'; /** The active model id (e.g. "default-gf-luna", or a custom id), or null. */ modelId: string | null; /** Absolute URL to the VRM 3D model — the avatar itself. CORS-enabled so a * cross-origin renderer can fetch it. Null only if resolution failed. */ vrmUrl: string | null; /** Absolute URL to a flat 2D portrait when available; null for VRM-only * built-ins today (reserved for custom previews / server-rendered thumbs). */ imageUrl: string | null; } /** One nonzero stablecoin balance line. */ export interface WalletBalance { /** Stablecoin code (USDT / USDC / DAI / USD1). */ currency: string; /** Decimal amount as a string (not atomic units), e.g. "5.83". */ amount: string; } /** Read-only snapshot of the companion instance's OWN wallet (receive-only — * there is no way to move funds via the SDK). Requires the `wallet.read` scope. */ export interface CompanionWallet { /** Nonzero per-currency balances (empty when the wallet is empty). */ balances: WalletBalance[]; /** Total across the balances; the allowed stablecoins are USD-pegged 1:1. */ totalUsd: number; /** Denomination of `totalUsd` (always "USD" today). */ currency: string; } /** Available sizes for the Pouchy brand icon (px). */ export type BrandIconSize = 256 | 512 | 1024; /** Canonical URL of the official Pouchy brand icon at `size` px, derived from a * deployment origin. Standalone (no client/token needed) so a surface can show * the Pouchy mark before/without connecting. */ export declare function pouchyBrandIconUrl(baseUrl: string, size?: BrandIconSize): string; /** The single error type the SDK throws — for HTTP failures (`status` is the * response code) AND for local precondition mistakes (`status: 0`, e.g. calling * a method before `connect()`). `code` is a stable machine-readable tag you can * switch on, shared with the `control.error` stream event vocabulary: * `'not_connected'`, `'missing_option'`, `'not_representative'`, or the server's * own error code for an HTTP failure (when it sends one). Defined in errors.ts * (so call.ts shares it cycle-free); re-exported here so the public import * path is unchanged. */ export { CompanionError } from './errors.js'; /** The server's memory-consolidation diagnostic from `endSession()` / `close()`. * `ok` is whether consolidation ran; `facts` is how many memories it wrote; * `skipped` (when present) says why nothing was written * (`'no_session'` | `'no_content'` | `'throttled'`). * * `extracted` and `writeError` (0.48.0) complete the "it didn't remember" * ladder — for a voice call this response is the ONLY signal you get about * whether the call became memory, and `facts: 0` alone collapses four causes * onto one value: `skipped: 'no_content'` = your transcript never arrived; * `skipped: 'throttled'` = duplicate end beacon (harmless); `extracted: 0` = * the transcript arrived and nothing durable was found in it; `extracted > 0` * with `facts: 0` = facts WERE found and the store refused them, with * `writeError` saying why (e.g. a token whose scopes cannot write the resolved * memory namespace refuses every fact). */ /** What the server's write-time near-duplicate match did with a `remember()` * (0.60.0; the wire has carried it since the M-05b release). Always present * on a successful write. `matched: true` means the fact was MERGED into an * existing row — `cloudId` then names that row, not a new one — so two * different writes can legitimately answer the same id; `revived` says the * merged-into row had been decay-archived and is live again. `skipped` says * why no match was attempted, or (`target-vanished`) why a match that was * found did not merge and a fresh row was minted instead. Typed optional so a * server behind this SDK cannot fail a consumer that reads it. */ export interface RememberDedup { matched: boolean; /** Cosine similarity of the row merged into (present only when matched). */ similarity?: number; revived?: boolean; /** The threshold the match ran against. */ threshold: number; skipped?: 'disabled' | 'opted-out' | 'no-embedding' | 'index-unavailable' | 'target-vanished'; } /** A server refusal of endSession() (0.77.0) — read from * `client.lastEndSessionError`. */ export interface EndSessionError { status: number; code?: string; } export interface EndSessionResult { ok: boolean; facts?: number; /** How many facts the extraction PROPOSED (present whenever it ran) — see * the interface doc for how it splits `facts: 0` into different repairs. */ extracted?: number; /** How many proposed facts the identifier filter refused (a card number, an * order id pasted into the transcript). Its own count so `extracted − * facts` does not read as a REFUSED WRITE and send you to scopes. Absent * when nothing was filtered. 0.56.0. */ filtered?: number; /** How many of `facts` folded into a memory the store ALREADY held rather * than minting a new one (server-side write-time dedup). 0.62.0. * * `facts` counts writes the store ACCEPTED, and a merge is accepted — so * `facts` alone reads as growth that may not have happened. This matters on * the ordinary path, not an edge one: a repeated `endSession()` re-reads the * same recent turns, so a second call routinely re-extracts facts it already * stored and answered `facts: 3` for three merges. `facts - merged` is the * number of NEW memories. Absent when nothing merged (including from a * server older than 0.62.0 — treat absent as 0). */ merged?: number; /** The SUBSET of `merged` whose target memory had been archived by decay and * was brought back by the merge (0.62.0) — a restored memory rather than a * fold into a live one. Absent when none were. */ revived?: number; /** Why the first refused write was refused. Absent when every write landed. */ writeError?: string; /** `'no_session'` / `'throttled'` / `'no_content'`, and since 0.56.0 * `'no_visitor_scope'`: a representative session whose token lacks * `represent:remember` (or that has no visitorId) — nothing was * consolidated anywhere. */ skipped?: string; } /** What `sendText(text, { awaitReply: true })` resolves with: the completed * turn's reply. `text` is the authoritative final assistant text (same value * onMessage receives); `envelope` is the full `companion.message` envelope * for hosts that want the id/ts. */ export interface SendTextReply { seq: number | null; text: string; /** Typed as the `companion.message` envelope, so * `reply.envelope.payload.attachments` compiles (CR-754). */ envelope: CompanionEnvelope; /** The id this turn went out under (0.72.0): the `turnId` you passed, or * the one the SDK minted. Pass it back as `turnId` to retry the SAME turn * after an ambiguous failure — the server replays it instead of re-running. */ turnId?: string; /** `true` when the server replayed an already-completed `turnId` instead of * running the turn (0.72.0): no model call, no billing. Absent — never * `false` — on a turn that ran. */ duplicate?: true; /** Turn-level token totals for cost accounting (#2955), when the server * reported them: whole-turn sums plus `reasoningTokens` (a subset of * completion) and the served `model` when known. Absent on turns that made * no billed model call. */ usage?: TurnUsage; /** The turn was refused by INBOUND MODERATION before any model ran (SDK * 0.63.0): `text` is the configured refusal line, not something a model * chose to say. Absent on every ordinary turn. * * It exists because the two were indistinguishable on the wire — same * shape, same `text` — so an integrator could only discover a block by * A/B experiment, which is exactly what issue #3379 had to do. */ blocked?: true; /** Which gate refused: `classifier` is the moderation verdict (see * `blockedCategories`), `term` is the operator's literal denylist. The * matched term is deliberately never sent — this response reaches the app * client, which may be the end user's own browser. */ blockedBy?: 'classifier' | 'term'; /** Categories that fired, on a `classifier` block. */ blockedCategories?: string[]; /** WHICH classifier window fired, and its char range in the scored text. * `blockedCategories` is a union across up to six overlapping 8,000-char * windows, so on a long input it says a category fired somewhere and * nothing more; this localises it. */ blockedWindows?: { index: number; start: number; end: number; categories: string[]; }[]; } /** The moderation half of a turn ack, shared by the buffered and streaming * legs so the two can never disagree about whether a turn was refused. */ export interface TurnBlockSignal { blocked?: true; blockedBy?: 'classifier' | 'term'; blockedCategories?: string[]; blockedWindows?: { index: number; start: number; end: number; categories: string[]; }[]; } /** Turn-level token usage carried on the input done frame / non-stream ack. */ /** Which pages `ingestFile`'s PDF figure pass read VISUALLY (sent to the * vision provider as images), and why — the `/api/companion/knowledge/file` * route's `figurePass`. */ type IngestFigurePass = { selected: { page: number; reason: 'raster' | 'sparse'; }[]; dropped: number; described: number; declined?: number[]; unanswered?: number[]; /** The scan hit its time budget; the tail was judged on text alone. */ scanTruncated?: boolean; /** Pages whose scan failed (raster signal unknown for them). */ scanFailures?: number; /** Why the pass stopped, when it did. */ halted?: string; }; export interface TurnUsage { promptTokens: number; completionTokens: number; cachedTokens: number; reasoningTokens?: number; model?: string; } /** One send queued while offline (see the `queueOffline` option) — replayed * FIFO by flushOutbox with its ORIGINAL `turnId` (the server's turn-dedup is * what makes the retry safe). `at` is Date.now() at enqueue. * * `unsent` (0.39.0, CR-140) records whether the send provably NEVER reached * the server: `true` when `navigator.onLine` was already false (the request * was never made), so the replay runs the turn exactly once at ANY age. * Absent/`false` = AMBIGUOUS — a dispatch was attempted and failed at the * network level (or a stream dropped mid-flight), so the turn MAY have run * server-side; flushOutbox only auto-replays these while they are younger than * `OUTBOX_AMBIGUOUS_MAX_AGE_MS` (past that the server may have forgotten the * turnId and a replay would double-run it). */ export interface QueuedSend { turnId: string; text: string; images?: string[]; /** Per-turn developer instructions the queued send carried (SDK 0.63.0). * Replayed with it — see `queueSend`. */ instructions?: string; at: number; unsent?: boolean; /** Failed replay attempts that carried NO readable status (SDK 0.50.0). * Absent on items queued by an older build and on items that have never * failed — both read as 0. See `OUTBOX_MAX_REPLAY_ATTEMPTS`. */ attempts?: number; } /** Max age at which flushOutbox will still auto-replay an AMBIGUOUS queued send * (one that may already have run server-side — see `QueuedSend.unsent`). Kept * comfortably BELOW the server's completed-turn dedup window * (`RECENT_TURN_TTL_MS`, 60min) so any ambiguous item replayed within this * bound is still deduped and cannot double-run; older ambiguous items are * dropped (a lost uncertain message is the lesser evil vs a double bill / * duplicate side effect). Provably-unsent items ignore this age bound — they * never ran, so replaying one is always safe — and are instead bounded by * `OUTBOX_MAX_REPLAY_ATTEMPTS` below. */ export declare const OUTBOX_AMBIGUOUS_MAX_AGE_MS: number; /** Failed replay attempts after which `flushOutbox` stops treating a queued * send as transiently-blocked and DROPS it (SDK 0.50.0). * * WHY THIS EXISTS. The queue is FIFO and a failing head item holds up * everything behind it — correct for a message queue, where reordering a * conversation is worse than delaying it, but it assumes the head item can * eventually succeed. One class breaks that assumption: a request the HOST * refuses before it reaches the API (a body over the platform's ~4.5MB limit — * in practice an un-downscaled photo) is rejected without CORS headers, so * cross-origin `fetch` REJECTS and the SDK sees `status: 0` with no `code` — * indistinguishable from the network being down. `sendText` therefore queues * it, and it fails identically on every flush. For an AMBIGUOUS item the * 30-minute age bound above eventually dropped it; for a provably-unsent one * there was no exit at all and it replayed on every reconnect forever. * * ALL THREE CONDITIONS ARE LOAD-BEARING, because dropping a real message is a * worse failure than delaying one: * 1. `status === 0` only. Every readable status (5xx, 429, 409/408/423/425) * means the server saw the request and owns the condition; those still * wait indefinitely. * 2. Attempts are counted only while `navigator.onLine !== false`. A flapping * or absent connection explains the failure on its own and must not spend * the budget — a device offline overnight comes back with 0 attempts. * 3. The item must ALSO be older than `OUTBOX_AMBIGUOUS_MAX_AGE_MS`. A short * outage can produce several reconnects, hence several attempts, in a few * minutes; requiring age too means no realistic outage reaches the drop. * * A drop is announced on `console.warn` and fires `onOutboxChange`. */ export declare const OUTBOX_MAX_REPLAY_ATTEMPTS = 5; /** Pluggable persistence for the offline send queue (the `outboxStore` * option). Both methods receive the storage key * (`pouchy-outbox:${surface}`, or `pouchy-outbox-v:${surface}:${visitorId}` * on a representative session); `load` returns the previously-saved value or * null. */ export interface OutboxStore { load(key: string): string | null; save(key: string, value: string): void; } type Handler = (envelope: CompanionEnvelope) => void; /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply * being generated; `meta.reset` (with an empty chunk) tells the renderer to * CLEAR the partial text (a hop that streamed text turned out to be tool-call * deliberation, not the reply). Deltas are advisory — the final * companion.message (fired through onMessage as usual) is authoritative and * should replace whatever the deltas rendered. */ type DeltaHandler = (chunk: string, meta: { reset?: boolean; }) => void; /** Structured instrumentation event (0.32.0) — see `debug` in * CompanionClientOptions. `at` is Date.now(). Deliberately free of secrets: * no token, no headers, no bodies — safe to pipe to any logger. */ export type CompanionDebugEvent = { kind: 'request'; at: number; method: string; path: string; } | { kind: 'response'; at: number; method: string; path: string; status: number; /** Wall-clock ms from request start to headers. */ ms: number; } | { kind: 'envelope'; at: number; type: string; id: string; } | { kind: 'stream'; at: number; state: CompanionStreamState; prev: CompanionStreamState; } | { kind: 'error'; at: number; message: string; code?: string; status?: number; }; /** Lifecycle of the receive event stream (0.30.0) — observable via * `client.streamState` + `onStreamStateChange`, so a host can render a * connection indicator without hand-rolling its own enum. * - `idle` — before start() * - `connecting` — start() called; first connection being established * - `connected` — the event stream is live (WS or SSE as configured) * - `reconnecting`— connection lost / closed; backing off before the next try * - `degraded_sse`— the WebSocket transport errored and delivery continues on * the SSE fallback (still live — replies keep arriving) * - `stopped` — stop()/close() was called, or a permanent stream failure * (the `stream_unauthorized` control.error) ended the loop */ export type CompanionStreamState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded_sse' | 'stopped'; /** The machine-readable verdict on a resolved confirmation (0.55.0). See * `confirmAction`. `denied` is a decline (nothing ran) and is deliberately * distinct from `rejected` (the APP refused an action the user approved). */ export type ConfirmOutcomeClass = 'success' | 'error' | 'unknown' | 'rejected' | 'denied' | 'unrecognized'; /** `confirmAction`'s response. `outcome` is prose for a person; `outcomeClass` * is the field to switch on. */ export interface ConfirmActionResult { ok: boolean; status: 'approved' | 'denied' | 'exec_failed'; outcome?: string; retryable?: boolean; /** Absent from older servers — read absent as `unrecognized`. */ outcomeClass?: ConfirmOutcomeClass; /** 0.57.0 — the presentable half of the user-signed approval, present only * when the approval passed a passkey step-up on the first-party surface: * `intentDigest` is a sha256 of the confirm's canonical intent (tool, * arguments, scope, id, time), `credentialId` the passkey that signed a * challenge bound to it, `signedAt` when. The signature itself stays on * the server; this is the receipt a host can show and log. */ mandate?: { intentDigest: string; credentialId: string; signedAt: number; }; } /** `getMandate`'s response (0.59.0) — the full user-signed approval the * `mandate` receipt summarizes. Field shapes follow the WebAuthn assertion * the server kept; `verified` is the server's own re-verification. */ export interface MandateDocument { confirmId: string; status: string; /** The canonical intent the digest covers: `{ v, confirmId, tool, args, scope, createdAt, action }`. */ intent: Record; intentDigest: string; mandate: Record; /** The credential that signed the mandate — `publicKey` is COSE, base64url. * `null` when the passkey has since been removed from the account: the * server then has no key to verify with, and `verified` is `null` too * (0.59.1 — typed non-null before, which read a revoked passkey as a * TypeError at `credential.publicKey`). */ credential: { credentialId: string; publicKey: string; format: string; } | null; verify: { challenge: string; rpID: string; origin: string; }; /** The server's own re-verification: `true` / `false` (see `verifyError`), * or `null` when it could not run (no credential) — "could not verify" is * not "did not verify"; a counterparty verifies offline either way. */ verified: boolean | null; /** Why `verified` is `false` (absent otherwise). */ verifyError?: string; [k: string]: unknown; } export declare class CompanionClient { private readonly opts; private readonly doFetch; private _session; private cursor; /** Generation handle of the last call-active window THIS client minted * (startCall → server callGen, 0.41.0). endSession sends it by default so * the server's window clear is fenced to our own call — and sends an * explicit null when this client never minted one, so a text client's * close() can no longer kill a live call's window on the same session. */ private lastCallGen; private _lastEndSessionError; /** Pause-orphaned call replay fired (0.37.0) — once per client instance, so * a manual second connect() can't double-deliver to a live handler. */ private replayedPendingToolCalls; private streaming; private abort; private streamGen; private readonly handlers; private readonly seen; private readonly deltaHandlers; private readonly pendingVoiceTools; private _streamState; private readonly streamStateHandlers; private readonly debugSink; private outbox; private readonly outboxHandlers; private outboxFlush; /** Epoch-ms before which flush attempts are skipped — set from a 429's * Retry-After during replay so the auto-flush on every reconnect doesn't * re-hammer a server that just told us to back off (0.35.0). */ private outboxBlockedUntil; constructor(opts: CompanionClientOptions); /** Fire an instrumentation event. A throwing sink must never break the * request/stream path it observes. */ private dbg; /** The session id of the LAST successful connect(), or null before the * first. Deliberately not cleared by endSession()/close()/setToken(), so * teardown-time logging can still correlate — which means it is an * identifier, NOT a liveness check: `if (client.sessionId)` stays truthy * forever after the first connect. Gate liveness on your own connect/close * bookkeeping (or `streamState`) instead. */ get sessionId(): string | null; /** Why the last endSession() / close() returned `null` when the SERVER * refused it (0.77.0): `{ status, code? }` — e.g. 401 `invalid_token`, 404 * `session_not_found`, 429 `rate_limited`. `null` when the last attempt got * a diagnostic, or never reached the server (no session / fetch rejected). * endSession keeps returning `null` on a refusal, so a host can tell * "token expired, nothing consolidated" apart without wiring `debug`. */ get lastEndSessionError(): EndSessionError | null; /** Current lifecycle of the receive event stream (0.30.0) — `idle` before * start(), then `connecting` / `connected` / `reconnecting` / * `degraded_sse` (WS errored, SSE fallback carrying delivery) / `stopped`. * Subscribe to transitions with `onStreamStateChange`. */ get streamState(): CompanionStreamState; /** Subscribe to receive-stream lifecycle transitions (0.30.0) — drive a * connection indicator: `client.onStreamStateChange((s) => setStatus(s))`. * Fires only on change, with the previous state as the second argument. * Returns an unsubscribe function. */ onStreamStateChange(handler: (state: CompanionStreamState, prev: CompanionStreamState) => void): () => void; private setStreamState; /** Swap the bearer token used by every subsequent request and stream * reconnect. Session tokens expire (default 1h) — call this when your * backend re-mints one, or wire `onAuthError` to do it on demand. */ setToken(token: string): void; private refreshing; /** Run opts.onAuthError (deduped), apply the fresh token, and report whether * the caller should retry. Never throws — a failed refresh means "surface * the original 401". */ private tryRefreshToken; /** doFetch with one transparent retry after a 401-triggered token refresh. * Everything except the stream loop (which has its own auth handling) goes * through here. */ private serverApiVersion; /** True when the server echoes `replyTo` on companion.message (API ≥ 1.1) — * the strict awaitReply correlation is only safe to enforce there. */ private serverEchoesReplyTo; /** doFetch raced against the `requestTimeoutMs` HEADERS deadline (0.35.0). * The deadline only bounds time-to-response-headers: it does NOT swap the * request's AbortSignal, so a streaming response body stays tied to the * caller's own signal, and the timer is cleared the moment the race * settles. On timeout the helper rejects `request_timeout` (status 0) — * code-bearing, so the offline queue's network-failure predicate correctly * ignores it (the server may have received the request). The abandoned * fetch keeps its rejection silenced so a late network error can't surface * as an unhandled rejection. `defaultBudgetMs` is the deadline used when the * host did NOT set `requestTimeoutMs` — buffered `/input` POSTs raise it to * `BUFFERED_TURN_TIMEOUT_MS` because their headers only arrive when the turn * completes; an explicit `requestTimeoutMs` (including 0) always wins. */ private doFetchBounded; private fetchAuthed; /** Normalize a thrown fetch error to a CompanionError. An AbortError (the * caller's signal fired) becomes a switchable `code: 'aborted'`; anything * else that is already a CompanionError passes through; the rest wrap as a * status-0 network failure. */ private asCompanionError; /** Peek the error `code` of a failure response without consuming its body. */ private errorCodeOf; private url; private jsonHeaders; private requireSession; /** Retry-After in seconds for a throttled response: the JSON body's * `retryAfterSec` (companion rate-limit shape) wins, else the standard * Retry-After header. Undefined when the server named neither. * * EVERY response-derived `CompanionError` must carry this — that is the * `CompanionError.retryAfter` contract in errors.ts, and the Python and * Unity clients hold it by construction because each funnels all HTTP * through ONE error factory (`_raise_from` / the CompanionError ctor). * This client's funnel is partial (`postJson` + the turn stream), so the * one-off helpers below have to pass it explicitly. `retry-after.drift` * fails the build if a new one forgets. */ private retryAfterFrom; private postJson; /** Handshake: start or resume the session for this surface. */ connect(opts?: { signal?: AbortSignal; }): Promise; /** Pair this representative session's VISITOR with the owner so their two * companions become friends (the A2A plane: messaging, gifts, visiting). * Representative sessions only. This client's PAT needs `represent:pair`; * `visitorToken` is the visitor's own Pouchy PAT with `social.message`. * A new pairing resolves `paired: false` with a `request`: the visitor * approves at `request.approveUrl` on pouchy.ai, and `getPairRequest` reads * the status (0.74.0). An existing friend resolves `paired: true` at once. */ pairVisitor(visitorToken: string, opts?: { signal?: AbortSignal; }): Promise; /** Status of a request `pairVisitor` opened (0.74.0). */ getPairRequest(requestId: string, opts?: { signal?: AbortSignal; }): Promise; /** Send a user text turn, optionally with images (data URLs) for a multimodal * turn — images need the `files` scope. The reply arrives on the event stream * as a `companion.message` — subscribe via onMessage()/start() to receive it. * * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or * `opts.stream` is true), the reply streams token-by-token on this very * request — delta subscribers fire as chunks arrive, and onMessage still * fires exactly ONCE with the authoritative final text (the streamed copy * is emitted locally; the event-stream replay of the same envelope is * deduplicated by id). Pass `stream: false` to force the buffered path. * * AWAIT THE REPLY (request/response hosts — a REPL, an HTTP handler, a * test): pass `awaitReply: true` and the promise resolves with the final * reply `{ seq, text, envelope }` — no onMessage wiring, no hand-rolled * turn gate. It rides the same streaming request (the terminal `done` * frame carries the authoritative envelope), so it works without start(); * onMessage/onDelta subscribers still fire as usual. Only when the reply * can't ride the request (`stream: false`, or an old server) does it fall * back to the event stream — which then DOES need start() — resolving with * the next `companion.message`, or rejecting with code `reply_timeout` * after `replyTimeoutMs` (default 60s). * * OFFLINE QUEUE (0.34.0, opt-in `queueOffline: true`): a send that never * REACHED the server — fetch rejected at the network level, or * `navigator.onLine` is already false — is queued for replay instead of * lost, and resolves `{ seq: null, queued: true, turnId }`. With * `awaitReply: true` there is no reply to await this session, so it * rejects with code `queued_offline` (the item IS queued first). HTTP * error responses keep the throw semantics above — they are never queued. * * RETRY (0.72.0): every result carries the `turnId` it went out under; * pass it back as `opts.turnId` after an ambiguous failure and the server * replays the completed turn (`duplicate: true`, no model call, no bill) * instead of running a second one. 1–256 chars (64 before 0.73.2), else * `invalid_option`. */ sendText(text: string, opts: { awaitReply: true; images?: string[]; instructions?: string; stream?: boolean; replyTimeoutMs?: number; signal?: AbortSignal; turnId?: string; }): Promise; sendText(text: string, opts?: { images?: string[]; instructions?: string; stream?: boolean; awaitReply?: boolean; replyTimeoutMs?: number; signal?: AbortSignal; turnId?: string; }): Promise<{ seq: number | null; queued?: boolean; turnId?: string; duplicate?: true; }>; /** The pre-queue dispatch of sendText: awaitReply / streaming / buffered. */ private dispatchText; /** True only for a send that never reached the server: fetch rejected at * the network level, which asCompanionError wraps as status 0 with NO * code. Every code-carrying status-0 error is deliberate — `aborted` is * the caller cancelling, `reply_timeout`/`needs_event_stream` mean the * POST was accepted — and any nonzero status is a response the server * produced. None of those may queue. The one exception is `bad_response` * (0.77.0): a 2xx whose body is not JSON is something in the path (a * captive portal's HTML), not the platform, so it queues like a drop — * as AMBIGUOUS, since a proxy may have forwarded it (CR-1036). */ private isNetworkSendFailure; /** Enqueue an offline send. Plain sends resolve `{ seq: null, queued: true, * turnId }`; awaitReply can't await a reply that won't arrive this * session, so it rejects with `queued_offline` — the item is queued * either way. */ private queueSend; /** The awaitReply leg of sendText. Prefers the in-request answer (the * streaming `done` frame's envelope); falls back to the next * `companion.message` off the event stream, bounded by a timeout. The * fallback subscription opens BEFORE the POST so a fast event-stream reply * can't slip through the gap. */ private sendTextAwaitingReply; /** The streaming leg of sendText: parse the POST response's SSE frames — * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the * final envelope locally so onMessage fires immediately and the log replay * dedups by id). Falls back to buffered semantics if the server ever * responds with plain JSON (e.g. an old deployment). */ private sendTextStreaming; /** The live offline queue, loaded from the store on first use so a reloaded * tab resumes what its predecessor persisted. Unreadable or foreign store * content is discarded — a corrupt store must never break sending. */ private loadOutbox; /** The storage key for THIS client's queue. A queued send is addressed to a * session, and `connect()` resolves the session from BOTH `surface` and * `visitor` — so the key must carry both or the queue is shared with a * session it was never meant for. A representative client therefore gets * its own namespace: without one, visitor A's queue was loaded by visitor * B's client on the same surface and replayed into B's thread (B reads A's * private text, it is consolidated into the wrong memory, and the owner is * billed for the turn). * * The visitor namespace is a distinct PREFIX rather than a suffix, for two * reasons. (a) Back-compat: an owner-facing client's key is byte-identical * to what every release since 0.34.0 wrote, so upgrading never orphans a * queue that is already correct. (b) No ambiguity: `pouchy-outbox-v:` can * never collide with `pouchy-outbox:` + any surface (the character after * `pouchy-outbox` differs), and percent-encoding both components keeps the * separator unambiguous, so surface `s` + visitor `x` and the surface * literally named `s:x` stay distinct. */ private outboxKey; /** Persist + notify after EVERY queue mutation (enqueue / flushed / drop), * so the stored copy never lags what a reload would need. Handler * isolation matches emit(): a throwing consumer must not skip the rest. */ private commitOutbox; /** The sends queued while offline (oldest first), as a defensive copy — * see the `queueOffline` option. */ pendingOutbox(): readonly QueuedSend[]; /** Subscribe to offline-queue changes (enqueue / flushed / dropped) — drive * a "N pending" badge from `items.length`. Returns an unsubscribe fn. */ onOutboxChange(handler: (items: readonly QueuedSend[]) => void): () => void; /** Replay the offline queue: FIFO, sequential, each item a normal buffered * input POST reusing its ORIGINAL `turnId` (the server dedupes completed * turnIds — see `queueOffline` — so a retry can never double-run a turn). * Runs automatically after connect() and on every stream reconnect; call * it yourself for a manual retry affordance. One flush at a time — * concurrent calls share the in-flight run. Per item: success (or a * server-side duplicate) → removed; network failure / 5xx / 429 → stop, * keep this item and the rest for the next flush; any other 4xx → the * item itself is invalid (the server deliberately rejected it) — drop it, * console.warn, and keep flushing. Since 0.50.0 the "keep" arm is bounded * for the ONE failure class that carries no status at all: after * `OUTBOX_MAX_REPLAY_ATTEMPTS` online attempts that rejected at the network * level, an item older than `OUTBOX_AMBIGUOUS_MAX_AGE_MS` is dropped rather * than left to block the queue behind it forever. */ flushOutbox(): Promise<{ sent: number; remaining: number; }>; private runOutboxFlush; /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a * subscriber makes sendText stream automatically. Returns an unsubscribe * function. Render chunks as they arrive; on `meta.reset` clear the partial; * when onMessage fires, replace the partial with the final text. */ onDelta(handler: DeltaHandler): () => void; /** Push live world-state — a single event or a batch. Retained events * (retained:true) coalesce per kind; transient ones carry a salience. * * Ergonomic input: `specversion` / `id` / `source` are filled automatically * (id → a fresh handle, source → this surface), so callers only supply the * meaningful fields: `{ type, data, retained?, salience?, voiceRelevant? }`. * A full CloudEvents envelope is still accepted (its fields win). */ sendWorldState(event: WorldStateInput | WorldStateInput[], opts?: { signal?: AbortSignal; }): Promise<{ accepted: number; dropped: number; injected: number; reacted: boolean; duplicates: number; /** How many of `accepted` were transients under the 0.25 salience floor * (an omitted `salience` is 0), which the server discards (0.77.0). * Absent when the server predates the field — unknown, not zero. */ belowFloor?: number; }>; private randomId; /** Open the voice plane. Returns realtime credentials to connect DIRECTLY to * the voice provider (the Pouchy server is out of the audio path). Provider * precedence is server-side: ElevenLabs Convai (primary) → OpenAI Realtime * (fallback). Requires the `call` scope. * * - `elevenlabs-convai`: open the EL session with the returned `token` and * pass `instructions` as `overrides.agent.prompt.prompt` (the EL agent runs * EL's LLM, so the persona/memory snapshot rides in client-side). * - `openai-realtime`: open WebRTC with the short-lived `clientSecret` * (instructions are baked into the session server-side). */ startCall(opts?: { voice?: string; locale?: string; /** Ask for Eleven v4 performed cues — the companion laughs, sighs and * whispers instead of only saying words (0.64.0). ElevenLabs calls * only; the server may decline (`creds.audioTags` says whether it * did). When granted, transcripts carry `[laughs]`-style cues: strip * them with `stripAudioTags` (connectCall does). */ audioTags?: boolean; /** Record the whole call, both voices (0.65.0). ElevenLabs calls on an * agent with recording turned on (`call.record`, the agent's dashboard * switch); `creds.recording` says whether * it is, and `creds.recordingUnavailable` why not. Fetch the audio * after the call with `getCallRecording(creds.callGen)`. */ record?: boolean; /** What THIS call is about, from your application (0.67.0): "she just * got the acceptance letter", "you are calling from the station". The * companion opens on it and keeps to it; it is never read out. Up to * 1,000 characters. Needs the `chat.instructions` scope (the agent's * per-turn instructions switch) — without it the call is refused with * `missing_scope`, so send it from a token your backend minted. */ brief?: string; /** A talking-face video avatar on this call (0.75.0): one of your * project's ready faces (built with your admin key — admin-sdk * `createVideoFace`). Needs the `call.video` scope; * `creds.video` carries the Simli session, `creds.videoUnavailable` * why there is none. Billed per minute on top of the voice. * `model` (0.76.0) picks Simli's animation model — `fasttalk` * (default) or `artalk`; `creds.video.model` says which ran. */ avatar?: { faceId: string; model?: CallVideoModel; }; signal?: AbortSignal; }): Promise; /** Open a live voice call end-to-end: mints credentials (startCall) and opens a * WebRTC session directly to the provider — no first-party app code needed. * Browser-only. Returns a handle to hang up + inject live context. EL Convai * needs the optional '@elevenlabs/client' peer dependency; OpenAI Realtime has * no extra deps. */ connectCall(opts?: CompanionCallOptions & { voice?: string; locale?: string; /** Eleven v4 performed cues (0.64.0) — see `startCall`. Transcripts * passed to `onTranscript` and to memory are stripped of them. */ audioTags?: boolean; /** Record the whole call (0.65.0) — see `startCall`. The returned call * carries `recording` and `callGen`. */ record?: boolean; /** What this call is about (0.67.0) — see `startCall`. */ brief?: string; /** A video avatar (0.75.0) — see `startCall`. The returned call carries * `video` / `videoUnavailable`; drawing the face is yours (simli-client). */ avatar?: { faceId: string; model?: CallVideoModel; }; }): Promise; /** End the session: consolidate what happened into the companion's long-term * memory, so it remembers this experience in future first-party conversations. * Called automatically when a connectCall() handle is closed; call it directly * when a non-voice session ends. Best-effort + idempotent (safe to call twice; * uses keepalive so it survives a page unload). Pass the voice transcript when * you have it (connectCall does this for you). * * Returns the server's consolidation diagnostic — `{ ok, facts?, extracted?, * writeError?, skipped? }` — or `null` if there was no session or the * (swallowed) keepalive request failed. `skipped` tells you WHY nothing was * written: `'no_session'` (you passed the instance id, not the session id), * `'no_content'` (empty turn log / a voice transcript that never arrived), * `'throttled'` (a duplicate end beacon — harmless). `facts` is how many * memories were STORED; `extracted` (0.48.0) is how many the extraction * proposed, so `extracted > 0 && facts === 0` means the store refused them * and `writeError` says why (see `EndSessionResult`). */ endSession(opts?: { transcript?: { role: string; text: string; }[]; /** Which call-active window this teardown owns (0.41.0). A string fences * the server's window clear to that call's mint; `null` says "I hold no * call — leave any live window alone". Defaults to the gen of the last * call THIS client minted (null if none), so a text client's close() no * longer kills a live call's window on the same session. Self-plumbed * integrations pass startCall's `creds.callGen` here at call end. */ callGen?: string | null; }): Promise; /** Tear the session down in one call: stop the event stream and consolidate * memory (endSession) exactly once. The single teardown entry point for a * NON-voice (text) session — mirror of what a connectCall() handle's close() * does for voice. Idempotent. Returns endSession's diagnostic (or null). * NOTE: if you drive `ping()` on your own timer for a background tab, clear * that timer yourself — the client doesn't own it. */ close(): Promise; /** Report the result of a companion.tool_call this surface performed. When all * of the turn's calls are reported, the companion resumes and the continuation * (a companion.message or more tool calls) arrives on the event stream. */ sendToolResult(callId: string, result: { ok?: boolean; result?: unknown; }, opts?: { signal?: AbortSignal; }): Promise<{ allDone: boolean; }>; /** A tool the live voice call asked the app to run. Fires the SAME * companion.tool_call event the text flow uses (so the app's onToolCall + * sendToolResult handle voice identically), and resolves with the result * string fed back to the voice model. Times out so a missing result can't * hang the call. */ private invokeVoiceTool; /** Convenience: subscribe to tool-call requests from the companion. The call * carries the wire payload plus `argsJson` — `args` pre-parsed as JSON * (undefined when empty or malformed), so handlers don't each re-implement * the same try/catch around `JSON.parse(args)`. */ onToolCall(handler: (call: ToolCallEvent, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to errors the companion surfaces — server-side agent * errors and stream failures (e.g. a permanent `stream_unauthorized`) both * arrive as `control.error`. Since 0.30.0 `code` is typed as * `ControlErrorCodeValue` (the CONTROL_ERROR_CODES vocabulary + the * SDK-synthesized stream codes, open for newer servers) so a `switch` on it * autocompletes. A malformed frame with no string `code` is delivered with * the literal fallback `'error'` (not part of the exported vocabulary) — * give your `switch` a `default:` arm rather than enumerating every code. * Returns an unsubscribe fn. */ onError(handler: (err: { code: ControlErrorCodeValue; message: string; }, envelope: CompanionEnvelope) => void): () => void; /** Recall the memory this token is authorized to see. Without `query` the * facts come back ranked by importance/recency; with `query` the server * runs semantic search over them (embedding-based, falls back to ranked * when embeddings are unavailable) — e.g. * `recall({ query: 'food preferences', limit: 10 })`. */ recall(opts?: { limit?: number; query?: string; signal?: AbortSignal; }): Promise; /** Fetch this session's recent conversation turns (oldest→newest) so a * reconnecting embed can restore its transcript. Distinct from `recall` * (durable memory / facts) — this is the raw exchange log. Returns the * token's OWN session turns only; requires a live session (call `connect` * first). `limit` defaults to 20, capped at 50. */ history(opts?: { limit?: number; signal?: AbortSignal; }): Promise; /** Change this session's I/O modalities mid-session (e.g. enable/disable * `voice`). The request is intersected with the token's granted modalities * server-side — a session can't widen past its key — and the EFFECTIVE set is * returned. Requires a live session. */ setModalities(modalities: string[], opts?: { signal?: AbortSignal; }): Promise<{ modalities: string[]; }>; /** Keepalive: bump this session's last-seen time so a long-idle embed stays * "live" within the session TTL (cross-app A2A friend messages keep reaching * it). Cheap; call it on a timer for a background tab. Requires a live session. */ ping(opts?: { signal?: AbortSignal; }): Promise; /** Remember a fact (into this app's namespace unless `namespace` is given and * the token has the core scope). Intimate-tier writes are rejected server-side. * `dedup` (0.60.0) says whether the write MERGED into a row the user already * had — in which case `cloudId` is that existing row's id, the same handle * `forget(id)` takes — or minted a new one. */ remember(fact: { content: string; importance?: number; confidence?: number; /** Only these two are kept server-side — anything else is discarded, so * the type refuses it at compile time instead of dropping it silently. */ category?: 'relationship' | 'shared_experience'; kind?: string; namespace?: string; sensitivity?: 'public' | 'personal'; }, opts?: { signal?: AbortSignal; }): Promise<{ cloudId: string; namespace: string; dedup?: RememberDedup; }>; /** The whole-call recording of a call started with `record: true` (0.65.0): * both voices, mixed by the voice provider, as MP3. Call it after the call * ends; the provider needs a little time, which answers `pending` with a * `retryAfterSec`. Recordings are kept for 7 days — copy what you need to * your own storage. `callGen` is the call's `creds.callGen` (or the * `callGen` of the handle `connectCall` returned); it must belong to THIS * session. Other failures (auth, provider outage) throw `CompanionError`. */ getCallRecording(callGen: string, opts?: { signal?: AbortSignal; }): Promise; /** Delete the recording of a call (0.65.0) — the provider's copy, audio and * transcript. Idempotent. A call with no recording answers `{ deleted: * false }`; your own copy is yours to delete. */ deleteCallRecording(callGen: string, opts?: { signal?: AbortSignal; }): Promise<{ deleted: boolean; }>; /** Forget ONE fact by the `id` a `recall()` row carries. This is the user's * authoritative "stop telling me this": the fact stops recalling and stays * forgotten (background re-derivation will not resurrect it) — the same * gesture the first-party surface uses, not a document delete. Requires * the write scope for the fact's namespace (`memory.write:app` for this * app's rows, `memory.write:core` for core). A fact this token cannot see * — another app's, the intimate tier, or nonexistent — answers the same * 404. Idempotent: forgetting a forgotten fact succeeds and says so. */ forget(id: string, opts?: { signal?: AbortSignal; }): Promise<{ forgotten: boolean; alreadyForgotten: boolean; }>; /** Ingest a DOCUMENT into the user's knowledge base. Unlike `remember` (one * short fact), this distils already-extracted text — a PDF body, a meeting * transcript, notes — into a headline summary PLUS full-text chunks, each * embedded and semantically recallable, and surfaces it in the user's "My * materials" list with source attribution, exactly like a first-party upload. * So the materials entry point can live in YOUR app, not just Pouchy's. * * Extract the text yourself (your own parser, or Pouchy's /api/stt for audio — * send your pchy_ token as the bearer; the relay is auth-gated and metered) * and pass it here. Writes the user's SHARED knowledge, so the token must * hold `memory.write:core` (the user's consent) — otherwise 403. The ingest * is SYNCHRONOUS server-side (summarize + one embedding per chunk, up to * ~2min for a large doc) — the response arrives when the material is fully * recallable. `kind` is a free label for the source type (pdf / audio / * note / …) used only for display + the materials icon. * * `truncated: true` means the document was NOT fully ingested — the text ran * past the 60,000-char cap, or produced more chunks than the 120-chunk * ceiling. What came back IS recallable; the remainder was dropped, so split * the source and ingest the rest. * * `chunksFailed` (when present) counts chunk writes that FAILED: the * document is only partly stored, and a blind retry duplicates the part * that landed. When no chunk could be stored at all, the call throws a 503 * `unavailable` instead — nothing was written, so a retry is safe. */ ingestKnowledge(doc: { text: string; name: string; kind?: string; locale?: string; }, opts?: { signal?: AbortSignal; }): Promise<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean; }>; /** Convenience over `ingestKnowledge`: hand over a RAW file and Pouchy * understands it server-side before ingesting — no parser/transcriber/vision on * your side. Pass a `data:;base64,…` URL. Supported today: **PDF** * (server-side text extraction), **audio/video** (Whisper transcript), and * **image** (server-side vision caption — an objective description becomes the * material). For other types, extract the text yourself and call * `ingestKnowledge`. Same `memory.write:core` requirement and "My materials" * integration as `ingestKnowledge`. * * `truncated` carries the same meaning as on `ingestKnowledge`, and covers * the server-side UNDERSTANDING step too: a PDF whose text layer overruns * the extraction cap reports `true`. `chunksFailed` as on * `ingestKnowledge`. * * `figurePass` (on a deployment with the PDF figure pass enabled) says * which pages were read VISUALLY — sent to the vision provider as images — * and why: `selected` (`raster` / `sparse`), `dropped` by the per-document * cap, `described`, and the pages the model `declined` or left * `unanswered`. Absent when the pass did not run. */ ingestFile(file: { dataUrl: string; name: string; kind?: string; locale?: string; }, opts?: { signal?: AbortSignal; }): Promise<{ ok: boolean; summary: string; chunks: number; chunksFailed?: number; truncated: boolean; figurePass?: IngestFigurePass; }>; /** Fetch the user's CURRENT companion avatar so the embedding can render the * same virtual human Pouchy shows. The avatar is a VRM 3D model (`vrmUrl`, * load it with a VRM/glTF renderer e.g. three-vrm); `imageUrl` is a flat 2D * portrait when one exists (null today for built-in models). URLs are absolute * and the /models asset is CORS-enabled, so a cross-origin renderer can fetch * the VRM directly. Reflects the user's live choice (built-in or custom). */ getAvatar(opts?: { signal?: AbortSignal; }): Promise; /** Read the companion instance's OWN wallet — nonzero stablecoin balances + * total USD. Read-only and receive-only (spends go through the confirm-gated * pay tools, never here). Requires the `wallet.read` scope (or `wallet.spend`, * which subsumes it). Same data the companion gives when asked "what's my * balance?". */ getWallet(opts?: { signal?: AbortSignal; }): Promise; /** Canonical URL of the official Pouchy brand icon (square PNG, transparent * background) at the given size. Static + public — needs no token — so it's * safe to drop straight into an `` for "powered by Pouchy" badges, * the companion's app tile, etc. Sizes: 256 | 512 | 1024 (default 512). */ brandIconUrl(size?: BrandIconSize): string; /** Subscribe to outbound events of a given type, or "*" for all. Returns an * unsubscribe function. Since 0.30.0 the envelope payload is NARROWED per * event name (OutboundPayloadMap) — e.g. `on('companion.typing', (env) => * env.payload.active)` type-checks without a cast; `'*'` keeps the untyped * envelope. Type-only: runtime behavior is unchanged. */ on(type: T, handler: (envelope: CompanionEnvelope) => void): () => void; on(type: '*', handler: Handler): () => void; /** Convenience: subscribe to assistant text replies. */ onMessage(handler: (text: string, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to Instant UI render surfaces (companion.ui_action). * The host draws `payload.interface` with its OWN renderer — a web component, a * native iOS/Android view tree, a CLI/TUI — all consuming the same * platform-neutral genui schema. If the panel set `reportChanges`, feed the * user's edits back via `sendText`/`sendWorldState` to continue the turn. */ onRender(handler: (payload: RenderInterfacePayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to TTS audio clips for the reply (companion.audio, * non-call modality) — play `url`, or resolve `ref` your way. */ onAudio(handler: (payload: AudioClipPayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to avatar cues (companion.expression) — viseme / * expression / gesture for an embed rendering the companion's body. */ onExpression(handler: (payload: ExpressionPayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to the per-token metering echo (control.usage) for a * usage / billing view. */ onUsage(handler: (payload: UsagePayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to sensitive-op confirmation requests * (companion.confirm_request) — a payment, skill run, or friend message the * companion wants the user to approve. Use it to surface the pending * approval in your UI. * * Whether YOU can resolve it depends on the token: * - **Platform session tokens** (minted via /v1/sessions for your end * users): show your own confirm card and call `confirmAction` — your end * user is the account's human, so the session may approve. This is how * confirm-gated custom skills (POST / credentialed) run. * - **First-party user tokens** (Login-with-Pouchy PATs): observe-only — * approval is authed as the Pouchy user (their app / a hosted confirm * page), which is also where the biometric (Face ID / passkey) gate * lives. `confirmAction` returns 403 (code `forbidden`) on these tokens. */ onConfirmRequest(handler: (payload: ConfirmRequestPayload, envelope: CompanionEnvelope) => void): () => void; /** Resolve a pending confirmation (platform session tokens only — see * `onConfirmRequest`). Show the user what they're approving (the event's * `summary`), collect an explicit tap, then pass their decision here. On * approval the recorded action runs server-side; a short NATURAL-LANGUAGE * summary of what it did comes back as `outcome` in this response (render * it where the user tapped) AND as a normal companion.message on the * stream (so the conversation shows it). A denial returns/emits a brief * decline the same way. Single-use: re-resolving a settled confirmId fails * with 409. * * `outcome` is PROSE FOR A PERSON, not a machine-readable shape: the * upstream/MCP payload the action produced is an internal implementation * detail and is summarized before it leaves the server — the same * discipline an ordinary (non-confirmed) tool call already follows, where * the raw result only ever reaches the model. Don't parse it; if you need * structured data, declare your own app tool and read its arguments. * * RETRY: if an approved IDEMPOTENT action (a wallet payment) flakes on a * transient error, `status` is `'exec_failed'` and `retryable` is true — you * MAY call `confirmAction(sameConfirmId, true)` again to re-run it (the server * dedupes a partial first attempt). A non-idempotent action (a message / skill) * stays terminal and is never retryable. * * `outcomeClass` (0.55.0) is the machine-readable half: `status:'approved'` * alone means only "the approval was accepted", and covers an action that * ran, one that failed terminally, and one the server refused. Switch on * `outcomeClass` to tell them apart — `success` (the action proved it * completed), `error`, `unknown` (it may or may not have gone through; do * not repeat it), `rejected` (the app declined it), `denied` (the user * declined; nothing ran), `unrecognized` (ordinary prose that neither * proves nor denies completion). Absent from servers older than this * field — treat absent as `unrecognized`, never as success. */ confirmAction(confirmId: string, approve: boolean, opts?: { signal?: AbortSignal; }): Promise; /** The full user-signed approval document for one confirm (0.59.0; design * #27 Phase 2) — what `confirmAction`'s `mandate` receipt summarizes: * `{ confirmId, status, intent, intentDigest, mandate, credential: * { credentialId, publicKey (COSE, base64url) }, verify: { challenge, rpID, * origin }, verified }`. A counterparty can verify the same document * offline against the keys the handle publishes at * `/a2a/{handle}/mandate-keys` (the Agent Card's `x_pouchy.mandateKeysUrl`). * Same subject rule as `confirmAction`. Resolves null on `404 * mandate_absent` — a deny, an approval that needed no step-up, or a row * from before the feature — and throws on any other failure. */ getMandate(confirmId: string, opts?: { signal?: AbortSignal; }): Promise; /** The session's still-pending confirmations (display-safe: id, summary, * scope, timestamps — never raw args). Use it to rebuild your confirm card * after a reload, since confirm_request events are not replayed. Platform * session tokens only, like `confirmAction`. * * Since 0.61.0 a row carries the three things a rebuilt card has to say and * could not: `status` (`exec_failed` is one the user ALREADY approved whose * run flaked — approving again retries the SAME request, keyed on * `confirmId`, rather than starting a new one), `execUnsettled` (that flake * was the execution DEADLINE, so it may still complete on its own) and * `stepUp` (the approval will demand a passkey / Face ID — the same * advisory the live `companion.confirm_request` carries). Treat an absent * or unrecognized `status` as `pending`. */ pendingConfirms(opts?: { signal?: AbortSignal; }): Promise; /** Convenience: subscribe to live updates of an already-rendered Instant UI * panel (companion.ui_update). Apply each `{ key, value }` to the panel's state * bag and re-evaluate bound displays — no rebuild. */ onInterfaceUpdate(handler: (payload: InterfaceUpdatePayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to inbound A2A messages from the user's paired * friends (companion.social_message), delivered cross-app to any embed whose * token holds `social.message`. Surface them in your own UI / notify the user. */ onSocialMessage(handler: (payload: SocialMessagePayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to the companion's activity indicator * (companion.typing). Fires `active:true` when a turn starts working and * `active:false` when it finishes or pauses for a tool result — spanning the * tool-loop / thinking phase before the first text delta. Drive a "typing…" * affordance from it: `client.onTyping(({ active }) => setTyping(active))`. */ onTyping(handler: (payload: TypingPayload, envelope: CompanionEnvelope) => void): () => void; /** Convenience: subscribe to voice-inject cues (companion.voice_inject) — a * `voiceRelevant` world-state line the companion should say aloud during a * live call. Route `payload.text` to your active voice session when * `payload.speak`. A text-only host can ignore it. */ onVoiceInject(handler: (payload: VoiceInjectPayload, envelope: CompanionEnvelope) => void): () => void; /** Open the event stream (auto-reconnecting with the resume cursor). Uses the * WebSocket plane when opted in + available, else SSE. */ start(): void; /** WebSocket receive loop. Resolves when the socket closes; on any failure * while still streaming it falls back to the SSE loop, so opting into WS can * only ever DEGRADE to SSE, never drop replies. Input is still sent over * REST (sendInput) — receiving is the latency-sensitive half. */ private wsLoop; /** Stop the event stream. */ stop(): void; private emit; /** Returns whether the frame was a real protocol DELIVERY — the `open` * greeting or a typed envelope. The `reconnect` window-control frame and * undecodable payloads return false: they must not arm the delivered-window * clamp exemption, mirroring the rest of the anti-storm roster (python * skips type-less control dicts, go's takeFrame yields only envelopes) — * otherwise a gateway that opens a window, emits one reconnect frame and * EOFs would reconnect at network speed with zero backoff forever. */ private handleFrame; /** SSE receive loop. `degraded` marks that this loop is the WS transport's * fallback, so its healthy state reads `degraded_sse` (still live) instead * of `connected` — the host can tell "fine" from "fine, but on plan B". */ private loop; /** Drain one SSE window. Calls `onDelivered` (once) the moment the first * real protocol frame is decoded — through a callback rather than the * return value, so the caller's delivered-window clamp exemption survives * a read that throws AFTER delivery (mid-window RST). Control frames and * undecodable payloads don't count (see handleFrame). */ private consume; private sleep; /** Backoff sleep with additive jitter (0–25%, 0.30.0) so a fleet of embeds * doesn't reconnect in lockstep after a server deploy/restart (thundering * herd) — every reconnect path sleeps through here, never bare sleep(). */ private backoffSleep; /** How long to wait before re-opening the event stream after a non-OK * response. The `/api/companion/session` prefix is rate-limited, and a * refusal names its own recovery time: a cap is a 429 with Retry-After, * and a limiter outage is a 503 `unavailable` with Retry-After 30 — or, * where the prefix degrades to its outage budget instead of refusing, a * 429 from that budget. Any non-OK Retry-After is honoured here, whichever * it is. The reconnect loop's ceiling * is 8s, so ignoring it meant re-asking ~7×/min for the whole window and * keeping the bucket empty. The outbox flush has respected the server's * Retry-After since 0.35.0; this is the same rule on the stream. * * Clamped on BOTH sides on purpose: never SHORTER than the backoff we * would have slept anyway (so this can only ever reduce request rate, * never raise it), and never longer than STREAM_RETRY_AFTER_CAP_MS — a * proxy is free to emit `Retry-After: 3600`, and a silent hour of no * replies is worse than a few extra polls. Header only: the loop never * reads the error body (it may be a stream that outlives the request), and * the platform 429 sets the header alongside `retryAfterSec`. */ private reconnectDelay; } //# sourceMappingURL=client.d.ts.map