# Changelog

All notable changes to `@pouchy_ai/companion-sdk` are documented in this file.

## [0.78.1] - 2026-10-23

### Fixed

- **A voice call's `invoke_action` / `navigate` `params` that the model sent as
  a JSON string now reach `onToolCall` as the object.** The ElevenLabs model
  sometimes sends the whole `params` object as a string, and the unwrap
  re-encoded it, so `JSON.parse(args).game` read `undefined` unless the handler
  parsed twice. A string that parses to a plain object is handed over as that
  object; anything else (a non-JSON string, an array, a number) passes through
  exactly as before, and a `navigate` call whose `params` needed nothing keeps
  its `args` byte-for-byte. Text turns are unchanged: there `invoke_action`
  arrives wrapped, as documented. A handler that already parsed a string
  `params` itself keeps working; the second parse now simply never runs.

## [0.78.0] - 2026-10-22

Round #914. Companion API 1.13.0.

### Behaviour changes

- **Streamed `sendText` failures that used to be a code-less status 502 now
  carry a code (CR-997).** A streamed reply with no body, or with a `done`
  frame that cannot be parsed, rejects `code: 'bad_response'` and keeps the
  HTTP status it actually got (was a synthesized 502). A stream that ends
  without a `done` frame rejects `code: 'stream_incomplete'` with status 0.
  Both are in `SDK_SYNTHESIZED_ERROR_CODES`. Code that matched on
  `status === 502` for these cases must switch on `code`.
- **With `queueOffline`, a `bad_response` send is queued** as an ambiguous
  send (the turn may have run), like a dropped connection, rather than lost.
  Retry it under the same `turnId`.
- **A stream 404 / 410 / 400 is terminal (CR-790).** The event stream emits
  `control.error` with code `stream_rejected` (the message carries the status
  and the server's code) and stops, as it already did for 401 / 403. It used
  to reconnect forever. `stream_rejected` joins `SDK_STREAM_ERROR_CODES`.
- **Every live-call failure is a `CompanionError` (CR-974):**
  `CompanionError(message, 0, 'call_dropped' | 'call_provider_error')`. The
  message text is unchanged.
- `getCallRecording()` turns a body that fails mid-download into a status-0
  `CompanionError` instead of a raw stream error (CR-1148).

### Added

- `CompanionError.degraded` (`'limiter_unreachable'`), read from every
  error body (CR-1040). A `rate_limited` 429 that carries it comes from the
  limiter-outage half budget, not from your permanent quota.
- `mandate_absent` (404) is in `COMPANION_ERROR_CODES`. `getMandate()` returns
  `null` on it (CR-874).
- `HelloAck.droppedTools` entries with reason `duplicate_name` now carry `name`
  (the refused tool) and `collidesWith` (the earlier tool it collides with)
  (CR-946).
- `ingestKnowledge()` / `ingestFile()` return `chunksFailed` on a partial
  ingest. An ingest where no chunk was stored rejects as a typed 503
  `unavailable`; nothing was written, so a retry is safe (CR-1061).
  `ingestFile()`'s return type also declares `figurePass` (CR-1044).
- `CompanionTurn.attachments?` on `history()` (CR-729). `createCompanionView`
  (and so `/svelte` and `/vue`) puts a message's `envelope.payload.attachments`
  on `CompanionTranscriptEntry.attachments?`, and restored assistant lines keep
  theirs (CR-731).
- `createCompanionView`'s snapshot carries `restoreError { code, message }`
  when `history()` rejects. An empty resolved history leaves it unset (CR-784).
- `onMessage`'s envelope and `SendTextReply.envelope` are typed
  `CompanionEnvelope<MessagePayload>` (CR-754).
- `CompanionView.sendText` is typed `CompanionClient['sendText']`, so an
  `awaitReply` send through the view types its reply (CR-1054).

### Changed

- `stripAudioTags` / `extractAudioTags` also strip padded (`[ laughs ]`),
  full-width (`［laughs］`) and trailing-ellipsis (`[sighs…]`) cues (CR-1140).
- Docs: `chat.instructions` is granted only by the agent template's
  `instructions: true` flag. Naming it at mint has no effect (CR-927). The
  zero-code widget example uses `#token=` (CR-865). Versioned-CDN pinning
  points at `GET https://pouchy.ai/sdk/versions` (CR-724). Video-face doc
  comments no longer say to poll `listVideoFaces` (CR-1476).
- CDN bundle rebuilt (`cdn/versions/0.78.0/`). The gzip size budgets are
  raised: minified 18,750 → 19,250 bytes (measured 19,118) and full
  42,750 → 44,000 (measured 43,733).

## [0.77.0]

### Added

- **`client.lastEndSessionError`** (`EndSessionError`): when `endSession()` /
  `close()` returns `null` because the server refused it, `{ status, code? }`
  says why (401 `invalid_token`, 404 `session_not_found`, 429 `rate_limited`).
  `null` after a call that got a diagnostic or never reached the server.
  `endSession()` still returns `null` on a refusal, so `=== null` checks are
  unchanged.
- **`HelloAck.droppedTools`** (`DroppedToolDecl[]`, API 1.12.0): the `tools`
  the handshake refused, `[{ index, reason }]` with `missing_name` /
  `duplicate_name` / `over_cap`. A `{ tool: 'x' }` typo used to be a silent 200
  and a tool that never fired.
- **`sendWorldState()` returns `belowFloor`** (API 1.12.0): how much of
  `accepted` was transients under the 0.25 salience floor, which the server
  discards. Absent on an older server (unknown, not zero).
- **`bad_response`** error code: a 2xx whose body is not JSON (a captive
  portal's HTML page) now rejects with `code: 'bad_response'` and its HTTP
  status, instead of status 200 and no code. With `queueOffline` the send is
  **queued** as an ambiguous send, like a dropped connection; before, it was
  lost.
- `typesVersions` in `package.json`: `@pouchy_ai/companion-sdk/svelte` and
  `/vue` now carry types under TypeScript `moduleResolution: "node"`, which
  ignores `exports`.

### Changed

- `on('hello.ack')` and `on('*')` now receive the event stream's greeting
  envelope, once per stream open. `hello.ack` was in `OUTBOUND_TYPES` and the
  greeting is a typed envelope, but the client consumed it and never emitted
  it. A `'*'` subscriber sees one more envelope per (re)connect.
- `forget()` keeps a refusal's actionable `hint` in the error message
  (`<error> — <hint>`), as the POST methods already did.
- Server, no SDK bytes: `remember()` on a namespace the token cannot write
  now rejects `code: 'missing_scope'` (was `forbidden`; API 1.12.0), as
  `forget()` and `recall()` already did. The intimate tier stays `forbidden`.
- Doc comment on the stream reconnect delay: a limiter outage is a 503
  `unavailable` with `Retry-After`, not a 429.
- CDN size budget: the full (unminified) bundle's gzip budget is raised
  42,250 → 42,750 bytes (measured 42,616); the minified bundle stays under its
  unchanged 18,750 (measured 18,704).

## [0.76.0]

### Added

- **`avatar.model`** (API 1.11.0): `startCall` / `connectCall({ avatar: {
  faceId, model } })` picks Simli's animation model — `'fasttalk'` (Simli's
  default, and what every earlier call used) or `'artalk'`. New type
  `CallVideoModel`. `CallVideo.model` echoes the model the session was minted
  with. Both bill at the same per-minute video rate.

## [0.75.1]

### Added

- `CallVideoUnavailable` gains **`face_not_ready`** (API 1.10.0): the face is
  your project's but is still being built. It used to read `face_not_allowed`,
  which sent you looking for a typo in a face id that was correct.

### Changed

- Video-avatar faces are **self-serve**: build them with your project's admin
  key — `@pouchy_ai/admin-sdk` 0.46.0 `createVideoFace(image)` /
  `listVideoFaces()` / `deleteVideoFace(faceId)`, or `POST
  /v1/admin/video-faces` — billed per face built. Doc comments and the README
  say so; no behaviour of this package changed beyond the new union member.

## [0.75.0]

### Added

- **Video avatar on calls** (API 1.9.0). `connectCall({ avatar: { faceId } })`
  / `startCall({ avatar })` ask for a talking-face video avatar, run on Simli.
  The handle and credentials carry `video` (a `CallVideo`: `{ provider:
  'simli', faceId, sessionToken, iceServers, maxSessionLength }`) to open with
  simli-client, fed the companion's remote audio track, or `videoUnavailable`
  (a `CallVideoUnavailable`: `not_enabled` / `not_configured` /
  `face_not_allowed` / `mint_failed`). The call connects either way.
- The `call.video` scope. A session gets it beside `call` only when the
  platform has registered a face for the project; withhold it per reader
  (`withhold_scopes`) to keep the voice and drop the face.

Billed per minute on top of the voice. A call that does not ask is unchanged.

### Changed

- The CDN bundles' size budgets rise for the surface above: minified 18.5 KB →
  18.75 KB, full 42 KB → 42.25 KB gzipped. 0.75.0 measures 18,539 / 42,034
  bytes (0.74.0: 18,486 / 41,877); the minified bundle was already 14 bytes
  under its old budget.

## [0.74.0]

### Changed

- **`pairVisitor` waits for the visitor's own approval** (API 1.7.0, CR-1049).
  The visitor's PAT proves who they are; it is no longer their consent. A NEW
  pairing now resolves `{ paired: false, pairId: null, request }`, where
  `request` is a `PairRequestInfo` (`{ id, status, approveUrl, expiresAt }`):
  open `approveUrl` for the visitor, who signs in to their own Pouchy account
  and approves or declines there, then poll the new
  **`getPairRequest(id)`** until `paired: true`. Nothing is written until they
  approve. An existing active friendship, or a platform-instance visitor
  (whose template is the consent), still resolves `{ paired: true, pairId }` at
  once. Why: any app holding a user's `social.message` token could make them an
  active friend of an account the app created, which opens messaging, visits,
  the friend wallet and a payment target.

### Added

- `getPairRequest(requestId)` and the `PairVisitorResult` / `PairRequestInfo`
  types. A request lasts 24 hours; a decline holds for that window; calling
  `pairVisitor` again after expiry opens a fresh one.

Code that only read `pairId` keeps compiling; it now sees `null` until the
visitor approves.

## [0.73.2]

### Fixed

- **`sendText`'s `turnId` accepts up to 256 characters** (it refused anything
  over 64 with `invalid_option`). The server used to IGNORE a longer id, which
  silently turned dedupe off for the very retry the caller was counting on, so
  0.72.0 refused one locally. The server now honours ids up to 256 characters
  and refuses a longer or non-string one with `400 invalid_request` instead of
  ignoring it, so the SDK's bound follows it. Needs a server from round #863
  onward: an older deployment still ignores an id over 64 characters (the turn
  runs, undeduped), exactly as before.

## [0.73.1]

### Fixed

- **`stream: 'websocket'` falls back to SSE when the gateway refuses the
  socket.** The ws-gateway closes with `4401` when the platform answers its
  upstream open 401/403, and `4502` for any other upstream failure (404
  `session_not_found`, 429, 5xx). A close the SERVER starts fires only
  `onclose`, and the client read every close as clean, so a revoked token or
  an ended session re-dialled the gateway every 8 s forever, in
  `streamState: 'reconnecting'`, with no error anywhere. A `4000`–`4999` close
  now takes the existing degrade-to-SSE path (as the gateway README always
  said it did), where the real status surfaces (`stream_unauthorized` on a
  401), and the `ws_degraded` debug event names the close code. Normal closes
  (`1000`, `1001`, `1006`) reconnect over WebSocket as before.
- **`reply_unavailable` now means the reply is really gone.** A fault reading
  the session's event log on a duplicate `turnId` used to return the same bare
  replay as an absent reply, which this SDK reads as the terminal
  `reply_unavailable`. The server now answers that fault with 503
  `unavailable` + `Retry-After` on both the buffered and the streaming arm (the
  streaming arm previously 500'd), so it surfaces as an ordinary retryable
  `CompanionError`: retry under the same `turnId`, still unbilled. Server-side
  change; no client code moved for it.

## [0.73.0]

### Fixed

- **An `awaitReply` retry with `stream: false` no longer ends in
  `reply_timeout` on a turn the server answered.** The server's buffered
  replay of a completed `turnId` was `{ seq, duplicate: true }` with no reply,
  so the SDK waited on the event stream for a `companion.message` that was
  delivered when the turn first ran, and rejected `reply_timeout` after the
  full `replyTimeoutMs` (60 s by default). That reads exactly like a real
  timeout, so a retry loop around it never ended. The server now sends the
  recorded `text` and `envelope` on the buffered replay too (as the streaming
  `done` frame always has), and the SDK resolves with them.

### Added

- `CompanionError` `code: 'reply_unavailable'` (synthesized, status 0): an
  `awaitReply` retry of a completed `turnId` whose recorded reply the server
  no longer holds (it has left the session's event log, or the server
  predates the fix above). It rejects at once instead of waiting out
  `replyTimeoutMs` for a reply that cannot arrive. The turn did run, once;
  read it with `history()` if you need the text.

## [0.72.0]

### Added

- **`sendText(text, { turnId })`: retry a turn without paying for it twice.**
  Every send went out under an id the SDK minted and kept to itself (it was
  only returned on an offline-queued send). So after an AMBIGUOUS failure —
  `reply_timeout`, `request_timeout`, a stream that dropped, a 5xx that
  arrived after the turn ran — the only retry was a new `sendText`, under a
  new id. The server could not tell it was the same turn, ran it again, and
  billed it again. Python (`turn_id=`), Go (`WithTurnID`) and Unity already
  took a caller id; the JS SDK now does too.
  - `opts.turnId` (1–64 characters) is sent as the turn's id. The server
    ignores an id outside that range, which would quietly turn dedupe off for
    exactly the retry you are relying on, so the SDK refuses one before
    sending with `CompanionError` `code: 'invalid_option'` (a new synthesized
    code).
  - Every result names the id it went out under: plain sends now carry
    `turnId` (previously only queued ones did), and `SendTextReply` gains
    `turnId`.
  - A retry the server answers from its dedupe window (60 min, last 32 turns
    per session) carries `duplicate: true` on the plain result and on
    `SendTextReply`: nothing ran and nothing was billed. Absent, never
    `false`, on a turn that ran.
  - For an `awaitReply` retry keep the default streaming leg: its replay
    carries the recorded reply text, while the `stream: false` replay carries
    only `seq`.

  Additive: a call that passes no `turnId` sends exactly what it did before.

### Changed

- The full (unminified) CDN bundle's size budget rises from 41.5 KB to 42 KB
  gzipped: 0.72.0 measures 41,547 bytes, 47 over, for the surface above. The
  minified bundle stays under its unchanged 18.5 KB (18,312 bytes).


### Fixed

- **The offline queue no longer obeys a server's `Retry-After` for longer than
  a minute.** A 429 on replay blocked every later `flushOutbox()`, the host's
  own manual retry included, for as long as `Retry-After` said, with no upper
  limit. A proxy's `Retry-After: 3600`, or an HTTP-date skewed by a wrong
  clock, silenced every queued message for an hour. The block is now capped at
  60 s, the same cap the event stream's reconnect has applied since 0.43.1. A
  shorter `Retry-After` is honoured exactly as before.
- **README:** the rate-limit backoff example read `e.retryAfter * 1000`.
  `retryAfter` is `number | undefined`, so that line fails `tsc --strict`, and
  in JavaScript it sleeps `NaN` ms, i.e. not at all, when the server names no
  window. It is now `(e.retryAfter ?? 1) * 1000`.

## [0.71.0]

### Added

- **`onEnd(end)`: a call says why it ended.** `connectCall` / `openCompanionCall`
  take `onEnd`, which fires exactly once per call, on every path, with
  `end.reason`:
  - `user`: your own `close()`.
  - `agent`: the companion ended the call with ElevenLabs' `end_call` system
    tool.
  - `remote`: the provider closed the call cleanly without saying why.
  - `error`: a failure. `end.error` carries it, and `onError` has already
    received the same `Error`.

  Until now every remote end reached `onError(new Error('call disconnected'))`,
  so a host could not tell the companion hanging up from the line dropping.
  `CallEnd` is exported.

  The ElevenLabs client always passed the reason, and the SDK dropped it. Two
  limits of that signal:
  - `agent` needs the agent to send its tool responses to the client. Without
    that, the same hang-up arrives as `remote`.
  - `openai-realtime` has no clean remote end, so every end there other than
    `close()` is `error`.

### Fixed

- **On ElevenLabs, a clean end could not be reported as one.** The liveness
  backstop acted on the status flip to `disconnected`, which the ElevenLabs
  client fires *before* it calls `onDisconnect` with the reason. So the
  backstop always ended the call first, as a fault. It now waits one task. It
  still ends a call whose `onDisconnect` never comes.

### Compatibility

- Without `onEnd`, nothing changes: every end other than your `close()` still
  reaches `onError` as before. Passing `onEnd` moves a clean remote end
  (`agent` / `remote`) out of `onError`, so `onError` means failure only.
- `connectCall` still sends the call to `/end` (memory, billing) on every end,
  the companion's hang-up included.
- The CDN size budget is raised by 500 B on both bundles (18,500 min / 41,500
  full, gzipped). This release measured 18,169 / 41,346 B; the growth is the
  surface above.

## [0.70.0]

### Added

- **Each companion line of a call reports the voice cues it performed.** On a
  call where `audioTags` was granted, `onTranscript`'s companion lines carry
  `audioTags: string[]`: the cues in order, without brackets (`['sighs',
  'laughs']`), `[]` when the line performed none. `text` stays clean, as
  before. The field is absent on user lines and on calls without the grant, so
  its absence means "not applicable", never zero. Until now the cues were
  stripped before anything you could see, so whether the companion used them
  could only be judged by ear.
- `extractAudioTags(text)`: the same cues as a list, for self-plumbed
  ElevenLabs sessions. It agrees with `stripAudioTags` on what a cue is.

## [0.69.0]

### Added

- **`call.setMuted(muted)`, `call.isMuted()`, `call.canMute` and
  `connectCall({ muted })`: the reader's own mute.** The half-duplex path
  re-opens the microphone 350 ms after every companion line, so a mute an
  application built on the provider client switched itself back on. The SDK
  now keeps the reader's mute apart from the half-duplex mute and opens the mic
  only when neither holds. `canMute` says whether `setMuted` controls the mic
  on this call (`true` on `openai-realtime`; on `elevenlabs-convai` whenever the
  installed `@elevenlabs/client` has `setMicMuted`, which every version in the
  peer range does). `muted: true` disables the OpenAI track before the offer
  is made; on ElevenLabs it applies once the session is up, because that client
  has no start-muted option.
- **`call.sendText(text)`: a line the reader typed, as their turn.** The
  companion answers it as the reader's words (OpenAI: a user `input_text`
  item plus `response.create`, cancelling a response in flight first;
  ElevenLabs: `user_message`). The SDK reports it to `onTranscript` as
  `role: 'user'`, once, so it reaches the transcript `connectCall` hands to
  `/end` and the companion remembers it. Nothing reaches the provider before
  the call. Returns `false` and sends nothing for blank text, after `close()`,
  or on a closed channel.

### Changed

- The CDN size budget for the unminified `companion-sdk.js` is raised from
  40,500 to 41,000 bytes gzipped (measured 40,584; 40,196 at 0.68.0). The
  minified bundle, the one to serve, is 17,917 bytes against its unchanged
  18,000 budget.
- `injectEvent`'s doc now says what it is NOT: its line is context, never
  reported to `onTranscript` or memory, and `connectCall` uses it for
  server-pushed world moments. Behaviour is unchanged.

## [0.68.0]

### Fixed

- **`call.privateStory` and `call.trimmed` reach the `connectCall` handle, and
  both are typed on `CallCredentials`.** The server has sent them since
  companion API 1.6.1 and 1.5.2, but the SDK did not declare them and
  `connectCall` built its handle without them, so an integrator using
  `connectCall` could not see either. `privateStory` (`CallPrivateStory`) is
  `'tool'` when a Pouchy World role's call can reach its private layer through
  the in-call `recall_private_story` tool, else `'none'`. `trimmed`
  (`CallTrimmed`) is `{ story?, context? }`, the characters cut to keep the
  instructions within the realtime cap; absent when nothing was cut. Additive:
  no existing field changed.
- The `CallWorldBrief` doc comment said the call carries "the facts and notes"
  the role may know. Since companion API 1.5.3 it carries the story's public
  state only; the comment now says so.

## [0.67.0]

### Added

- **`connectCall({ brief })` / `startCall({ brief })` — what THIS call is
  about.** Your application's note ("she just got the acceptance letter",
  "you are calling from the station"): the companion opens on it and keeps to
  it, and never reads it out. Up to 1,000 characters. It joins the call's
  instructions as the application's words, not the user's, and is not
  moderated as user speech — so it needs the `chat.instructions` scope (the
  agent's per-turn instructions switch in the dashboard); without it the call
  is refused with `missing_scope` before anything is charged. Send it from a
  token your backend minted, never one an end user can edit.
- **`worldBrief` on the credentials and the `connectCall` handle** (type
  `CallWorldBrief`). A call made with a **Pouchy World** role's session token
  now carries where the story is — the scene, the story clock, flags, the
  facts already revealed, and the role's own last beats — from the server.
  PUBLIC state only: never the role's secrets, notes, goals or unrevealed
  facts, because a call's `instructions` reach the reader's device (server
  1.5.3). `worldBrief` says `'included'`, `'no_story'` (the world pins no
  story package) or `'unavailable'` (it could not be read; the call went ahead
  without it). Absent on an ordinary session. Nothing said on the call changes
  the story: fold the call back in as a beat on the world turn door with
  `trigger.context` and `proposedPatches`.

## [0.66.0]

### Added

- **`call.record` in the scope vocabulary.** Whole-call recording
  (`record: true`, 0.65.0) is now turned on per agent, by the integrator, in the
  dashboard: the agent's recording switch grants `call.record` on its session
  tokens (a token cannot ask for it; `withhold_scopes` can still remove it for
  one reader). A token without it gets `recordingUnavailable: 'not_enabled'`.
  Nothing else in the SDK changes; 0.65.0 callers keep working. Mint a fresh
  token after turning the switch on.

## [0.65.0]

### Added

- **Whole-call recording: `connectCall({ record: true })` and
  `startCall({ record: true })`.** The voice provider records the call — both
  voices, mixed — so an app that cannot capture call audio on the device
  (React Native's native WebRTC has no WebAudio or MediaRecorder) gets the same
  file a browser could make. Recording is granted per project by Pouchy and
  runs on ElevenLabs only. The call handle and the credentials say what
  happened before the call connects: `recording: true`, or `recording: false`
  with `recordingUnavailable` (`'not_enabled' | 'not_configured' |
  'no_conversation_id' | 'store_failed'`). The `connectCall` handle now also
  carries `callGen`.
- **`getCallRecording(callGen)`** — after the call: `{ state: 'ready', audio,
  contentType }` (an MP3 `Blob`), `{ state: 'pending', retryAfterSec }` while
  the provider is still processing it, or `{ state: 'none' | 'expired' |
  'deleted' }`. Only real failures (auth, provider outage) throw
  `CompanionError`. Recordings are kept for 7 days; copy them to your own
  storage.
- **`deleteCallRecording(callGen)`** — removes the provider's copy, audio and
  transcript. Idempotent.
- Types: `CallRecordingResult`, `CallRecordingUnavailable`.

Without the flag nothing changes: the request is byte-identical and no call
is recorded.

CDN size budget raised (per `bundle-size.test.ts`'s contract): min
17,500 → 18,000 B gz (measured 17,574), full 40,000 → 40,500 B gz (measured
40,025).

## [0.64.0]

### Added

- **Performed voice cues on calls: `connectCall({ audioTags: true })` and
  `startCall({ audioTags: true })`.** With Eleven v4 the companion's voice can
  ACT OUT short cues — it laughs, sighs, whispers, speaks softly — instead of
  only saying words. Opt-in, because the provider's transcript then contains
  the cues (`[laughs] 你也太可爱了吧`). ElevenLabs calls only; the server may
  decline (the OpenAI Realtime fallback would read a cue aloud, and an operator
  can switch the capability off), and the credentials say what happened:
  `creds.audioTags === true` only when granted.
- **`connectCall` strips the cues for you.** `onTranscript` and the transcript
  consolidated into memory at call end carry clean text.
- **`stripAudioTags(text)`** for integrations that open the ElevenLabs session
  themselves from `startCall` credentials: apply it to every agent transcript
  line before you show or store it. It removes ASCII-word cues only
  (`[laughs]`, `[short pause]`); a line's own `【重点】` or `[1]` is left alone.

Without the flag nothing changes: the server still forbids bracketed tags on
your calls, exactly as before.

## [0.63.1]

### Fixed

- **The view controller (`createCompanionView`, and so the `/svelte` and `/vue`
  adapters) no longer strands a failed turn's half-streamed reply.** Only
  `onMessage` or a `reset` delta cleared `draft` and `typing`, and neither
  arrives after a failure — so a `done {ok:false}` after some deltas, or a
  stream that dropped mid-reply, left the partial reply and the typing
  indicator on screen with the user's line already rolled back above it. A
  rejected `sendText` now clears both, unless another send from the same view
  is still in flight: a 409 `turn_pending` rejects WHILE the turn it lost to is
  streaming, and that draft is live.
- **`reply_timeout` and `needs_event_stream` no longer roll back the user's
  line.** Both mean the POST was accepted (the SDK's own offline queue already
  reads them that way), so the reply can still land through `onMessage` — and
  it used to land under no question. `queued_offline` keeps the line as before.
  A code-less status-0 network failure is ambiguous (the send may or may not
  have reached the server) and still rolls back, unchanged. If your host
  re-appended the user's text itself on those two codes to work around the
  rollback, drop that, or the line shows twice.

## [0.63.0]

### Added

- `sendText(text, { instructions })` — Per-turn developer **`instructions`** on a text turn, and a turn that says
when inbound moderation refused it (issue #3379).

**The channel.** Inbound moderation scores the turn's `text`, and `text` was
the only channel whose content actually governed behaviour — `/context` events
reach the prompt but render as world-state telemetry, so facts placed there are
used and rules placed there are not. An app with per-turn rules therefore had
to prepend them to the user's message, and the classifier's verdict on "what
the user said" became a function of the DEVELOPER's phrasing: the reporter
measured the same nine user sentences at 18/27 refused against 0/27 on a
meaning-preserving reword of their own instruction blocks, with a benign
ten-character question refused deterministically inside a 20,070-character
payload. `instructions` joins the prompt after the history and immediately
before the user's message, and is never scored as user speech. Needs the new
`chat.instructions` scope — not granted by default, because the field skips
moderation and the session token is routinely held by the end user's own
client. 32,000 chars max (413, never a silent truncation), and NOT persisted:
it governs the turn it rides and nothing after it.

**The signal.** A refused turn used to be indistinguishable from a model reply
— same shape, same text — so the only way to discover a block was to A/B it. A
blocked turn now reports that it was blocked, which gate refused
(`classifier` / `term`), the categories, and WHICH of the up-to-six overlapping
8,000-char classifier windows fired, with its char range in the scored text.
The matched denylist term is deliberately never sent: this response reaches the
app client, which may be the end user's browser.

  `SendTextReply` gains `blocked` / `blockedBy` / `blockedCategories` /
  `blockedWindows`, absent (never `false`) on an ordinary turn, on both the
  buffered and the streaming leg. `QueuedSend` carries `instructions` too, so
  an offline send replays under the rules the app chose for it. `COMPANION_SCOPES`
  gains `chat.instructions`.

  CDN min-bundle budget raised 17,000 → 17,500 B gzipped (measured 17,098),
  recorded here per the size gate's own contract. The growth is surface, not
  comments: the min twin is the artifact integrators serve.

## [0.62.0]

### Added

- **`EndSessionResult.merged` / `.revived` — `facts` counts writes the store
  ACCEPTED, and an accepted write is not necessarily a NEW memory.** Since the
  server gained write-time deduplication, a re-mention of something it already
  holds folds into the existing row instead of minting one, and that fold is an
  accepted write — so it was counted in `facts` with nothing saying so. This is
  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 and zero new memories.
  `merged` is how many of `facts` folded; `facts - merged` is the number of NEW
  memories. `revived` is the subset of `merged` whose target had been archived
  by memory decay and was brought back by the merge — a restored memory rather
  than a fold into a live one, which is a different event worth its own count.
  Both are absent when zero, and absent from a server older than 0.62.0; treat
  absent as 0. `facts` deliberately keeps its meaning, so the existing reading
  of `extracted > 0` with `facts: 0` as "the store refused the writes" is
  unchanged — narrowing `facts` to newly-minted rows would have made a
  fully-merged consolidation report that refusal, a false failure in place of
  an over-count.

  Types only: no runtime code changed, and no existing field moved or changed
  meaning.

### Fixed

- **The committed CDN bundle now provably matches the source beside it.** The
  0.61.0 bundle did not: `src/lib/companion-sdk/client.ts` was edited after that
  release's rebuild, so the `pendingConfirms()` documentation 0.61.0 existed to
  add is absent from `cdn/companion-sdk.js` as published — and every check was
  green, because the three CDN gates ask where it was built from, whether the
  banner version and export names line up, and whether the SRI matches the bytes
  on disk (which a stale bundle satisfies exactly). `cdn/integrity.json` now
  carries `sourceHash`, a digest of the SDK source the bundle was built from,
  and a test recomputes it — the drift that shipped 0.61.0 is red on it while
  all three older gates stay green.

  For consumers: nothing to do. Effect on the artifact was confined to comments
  (the 0.61.0→0.62.0 unminified diff is comment lines only, and the minified
  bundle differs by its banner version alone), and `versions/0.61.0/` is
  deliberately left as published — a version archive is immutable, since its
  sha384 lives in integrators' pinned `integrity` attributes.

## [0.61.0]

### Added

- **`PendingConfirm` carries `status`, `execUnsettled` and `stepUp` — the three
  facts a confirm card rebuilt from `pendingConfirms()` needs and could not
  get.** This SDK documents `pendingConfirms()` as the way to rebuild your
  confirm card after a reload, and until now the snapshot answered a strictly
  narrower question than the live `companion.confirm_request` event:
  - `stepUp` is on the event and was absent here, while `requiresStepUp` /
    `STEP_UP_SCOPES` are server-only — so no host could derive it. A reload
    mid-`wallet.spend` rebuilt the approval as an ordinary tap, and the approve
    POST then came back `401 step_up_required`: the user met a biometric prompt
    the card never announced. The server re-derives the requirement, so nothing
    was ever approvable without it; the gap was in what the card could SAY. It
    is computed with the same predicate the approve endpoint enforces (the
    row's scope unioned with the executing tool's), not a weaker one.
  - `status` separates the two rows this endpoint has always returned together.
    A confirmation stays listed while it is `pending` OR `exec_failed` — the
    latter being one the user ALREADY approved, whose execution then flaked —
    and nothing distinguished them, so an op that had already been authorised
    came back under the same "Approve?" button as a fresh request. Only the
    idempotent wallet pay tools reach `exec_failed`, and approving again re-runs
    the SAME request (keyed on `confirmId`) rather than starting a new one,
    which is the safe recovery worth telling the user about.
  - `execUnsettled` splits `exec_failed` itself: the flake was the execution
    DEADLINE, so the callee was left running and may still complete on its own
    — the opposite truth from a definite failure. Reporting the union without
    the discriminator would have moved the problem one level down.

  All three are optional and absent from an older server; treat an absent or
  unrecognized `status` as `pending`. No existing field changed, so a host on
  0.60.0 is unaffected. Mirrored in the Python, Go, Dart and C# SDKs.

## [0.60.0]

### Added

- **`remember()` types the `dedup` outcome the wire has answered since the
  server's write-time near-duplicate match shipped (M-05b).** The resolved
  value is `{ cloudId, namespace, dedup }` with `dedup: RememberDedup` —
  `{ matched, similarity?, revived?, threshold, skipped? }`, exported from the
  package root. Before this the field arrived at runtime and was a TS2339 for a
  TypeScript consumer, and nothing on the typed surface said that on a merge
  `cloudId` names an EXISTING row (the same handle `forget(id)` takes) rather
  than a new one — so two writes answering the same id read as a bug instead
  of a merge. Additive: `dedup` is typed optional so a server behind this SDK
  cannot fail a consumer that reads it. The companion OpenAPI description of
  `POST /api/companion/memory` now says the same, and a census pins every
  literal-described `200` in that spec to the keys its route handler actually
  returns.

## [0.59.1]

### Fixed

- **`CompanionError.errorId` on every HTTP funnel — for real this time.** 0.56.0
  said the field was "read off the JSON envelope in every HTTP funnel"; nine of
  the twelve (`recall`, `history`, `setModalities`, `ping`, `forget`,
  `getAvatar`, `getWallet`, `getMandate`, `pendingConfirms`) built the error
  without it, so a persisted 5xx behind any of them arrived with `errorId`
  undefined and nothing to quote to support. One reader at every site now, and
  a census (`error-funnel.census.test.ts`) that fails on the next omission.
- **`MandateDocument` says what the wire says.** `credential` is
  `{ credentialId, publicKey, format } | null` and `verified` is
  `boolean | null` — the server answers both `null` when the passkey has since
  been removed from the account (there is no key to verify with), and
  `verifyError?: string` names why a `verified: false`. The 0.59.0 type had
  both non-null, so `doc.credential.publicKey` was a TypeError on a revoked
  passkey and `verified: null` read as "did not verify". A TypeScript consumer
  that dereferenced `credential` unguarded gets a compile error pointing at
  exactly the line that would have thrown at runtime — that is the migration.

## [0.59.0]

- **`streamBaseUrl`** option — the origin the WebSocket plane is served from
  (a self-hosted `workers/ws-gateway`). Until now the socket URL was derived
  from the one `baseUrl` every REST call uses, so pointing the SDK at a
  gateway broke REST and pointing it at pouchy.ai never opened a socket; the
  gateway README described an option that did not exist. Only the reply
  socket goes to `streamBaseUrl`; REST and the SSE fallback keep `baseUrl`.
- **`getMandate(confirmId)`** — the full user-signed approval document behind
  a confirm's `mandate` receipt (design #27 Phase 2), typed as
  `MandateDocument`; `null` on `mandate_absent`. The document verifies
  offline against the keys a handle publishes at `/a2a/{handle}/mandate-keys`.
- Docs: the `companion.expression` payload comment says what is produced
  (`expression` only; `viseme` / `gesture` unused).
- Size gate: the unminified CDN bundle budget rises 38→40 KB gz (measured
  38,260 after this release; 37,959 before). The minified bundle's 17 KB
  budget is unchanged.

## [0.58.0]

- **`companion.audio` and `companion.expression` are emitted** (design #25
  Phases 1–2) — the last two reserved events have producers. `onAudio` fires
  after a text turn with `{ url }`, a capability URL of an mp3 of the reply,
  when the agent opted in (dashboard → reply cues → audio), the session
  negotiated `voice` and the token holds the `voice` scope; `onExpression`
  fires with `{ expression }`, a VRM 1.0 preset read from the reply's tone,
  when the agent opted in. Both ride behind the reply and never delay it;
  neither fires on A2A or channel surfaces. Vocabulary and payload types are
  unchanged — only the "reserved" markers went. No SDK code change.

## [0.57.0]

- **`ConfirmActionResult.mandate?`** — the presentable half of a user-signed
  approval (design #27 Phase 1). Present only when the approval passed a
  passkey step-up on the first-party surface: `{ intentDigest, credentialId,
  signedAt }`, where `intentDigest` is a sha256 of the confirm's canonical
  intent (tool, arguments, scope, id, time) and the passkey signed a challenge
  bound to it — so the signature covers WHAT was approved, not merely that
  someone with the credential was present. The signature itself stays on the
  server. Additive; absent from older servers and from approvals that needed
  no step-up.

## [0.56.0]

### Added

- **`CompanionError.errorId`** — the server's error reference (`err_…`) on a
  persisted 5xx, read off the JSON envelope in every HTTP funnel and off the
  failure `done` frame. It is the id a `server_errors` row is keyed by and the
  thing to quote to support; a real 500 used to arrive as `status:500 /
  code:'internal' / message:'An unexpected error occurred.'` with the id
  dropped although the server had put it on the wire. Absent on 4xx and
  transport failures. Additive.
- **`endSession()` returns `filtered`** — how many proposed facts the
  identifier filter refused (a card number pasted into a transcript), so
  `extracted − facts` no longer reads as a refused write and points you at
  scopes. And a new `skipped` value, `'no_visitor_scope'`: a representative
  session whose token lacks `represent:remember` (or has no visitorId) used to
  answer a bare `{ ok: true, facts: 0 }`, indistinguishable from "consolidated,
  nothing durable found". Additive; no protocol version change.

## [0.55.0]

### Added

- **`confirmAction()` returns `outcomeClass`** — the machine-readable half of a
  resolution. `status: 'approved'` alone only says the approval was accepted:
  it covers an action that ran, one that failed terminally on a non-idempotent
  tool, and one the server refused, and `outcome` is prose a host must not
  parse. `outcomeClass` is the server's fail-closed classification of the raw
  outcome — `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). Exported as
  `ConfirmOutcomeClass`, with the response typed as `ConfirmActionResult`.
  Additive; absent from older servers — read absent as `unrecognized`, never
  as success. No protocol version change (a REST response field, not a stream
  event).

## [0.54.2]

### Fixed

- **A re-handshake that mints a NEW session now moves the open event stream
  with it.** The receive loops captured the session id once when they opened,
  and `start()` is a no-op while a loop is running — so a second `connect()`
  after the server's resume window (~12h idle) or after `endSession()` left
  the stream bound to the OLD session: `streamState` read `connected` (the old
  document still answered 200 + pings) while every `companion.*` event for the
  new session went undelivered and `awaitReply` fell through to
  `reply_timeout`. `connect()` now restarts the stream (`stop()` + `start()`,
  the public idempotent pair) when the id changed and a stream is open; a
  re-handshake that RESUMES the same session leaves it alone, and one with no
  stream open starts nothing. Hosts watching `onStreamStateChange` see one
  `stopped` → `connecting` → `connected` transition per new session.
- **`needs_event_stream` is now reachable in buffered mode.** With
  `stream: false`, a turn that paused on the app's own declared tools waited
  out the full `replyTimeoutMs` (60s by default) and rejected `reply_timeout`,
  where the README promised the immediate `needs_event_stream`: the buffered
  leg never read the ack's `kind`. It does now — the same `kind: 'tool_calls'`
  the streaming leg reads from its done frame — so an `awaitReply` without
  `start()` fails fast with the documented code. No wire change.

## [0.54.1]

### Fixed

- **`TurnUsage` and `MessageAttachment` are now importable from the package
  root.** Both were declared and documented as public — 0.52.0's entry says
  "new exported type `TurnUsage`", the README names `MessageAttachment[]` on
  `companion.message` — and both are the field types of exports that DID
  resolve (`SendTextReply.usage`, `MessagePayload.attachments`), but neither
  was re-exported from `index.ts`, so `import type { TurnUsage } from
  '@pouchy_ai/companion-sdk'` failed with TS2305 and an integrator had to
  retype the shape or reach for `NonNullable<SendTextReply['usage']>`. The
  export-surface gate that caught the previous instance of this (0.36.x,
  `DataActivityPayload`) only walked the types named directly in
  `OutboundPayloadMap`; it now also asserts that every type the READMEs or
  this changelog name as public is importable from the root. Types only —
  the bundle's runtime bytes are unchanged.

## [0.54.0]

- **`MessagePayload.attachments`** — files the turn PRODUCED
  (`create_spreadsheet` / `create_document`: xlsx, csv, docx, md, txt) now ride
  the `companion.message` envelope structurally: `{ fileId, fileName, mimeType,
  url }[]` (new `MessageAttachment` type). `url` is the same 7-day capability
  URL the reply text contains — the field exists so a host can render a
  download affordance without parsing prose the model may rewrite or drop.
  Additive and optional; absent on turns that produced no files. No
  `PROTOCOL_VERSION` change.
- The `/embed` widget forwards them to the host page on `pouchy:message`
  (`{ text, attachments? }`).

## [0.53.0]

- **`forget(id)`** — forget ONE fact by the `id` a `recall()` row now carries
  (`RecalledMemory.id`, additive; absent on legacy rows, which cannot be
  forgotten item-wise). Server-side this is `DELETE
  /api/companion/memory/{factId}`, closing the token lane's per-item gap —
  bulk deletion has always had the wipe paths (#2996).
- The semantics are the user's authoritative "stop telling me this"
  (`userForgotten`), the same gesture the first-party surface uses — the fact
  stops recalling and background re-derivation will not resurrect it. Never a
  document delete.
- Authorization is per-fact: the write scope for the fact's namespace
  (`memory.write:app` / `memory.write:core`). A fact the token cannot SEE —
  another app's namespace, the intimate tier, or nonexistent — answers the
  same 404, so refusals cannot map the memory space. Idempotent: forgetting a
  forgotten fact succeeds with `alreadyForgotten: true`.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
with the usual pre-1.0 caveat: while the major version is `0`, a **minor** bump
(`0.x.0`) may contain breaking changes and a **patch** bump (`0.x.y`) is reserved
for backwards-compatible fixes and additions. The wire protocol has its own
version (`PROTOCOL_VERSION`, currently `1`) guarded by `protocol.drift.test.ts`;
a protocol bump is always called out explicitly here.

## [0.52.0]

### Added

- **Turn-level token usage** (issue #2955). `sendText(text, { awaitReply: true })`
  now resolves with an optional `usage` block —
  `{ promptTokens, completionTokens, cachedTokens, reasoningTokens?, model? }`
  (whole-turn sums; `reasoningTokens` is a subset of completion, not additive;
  `model` is the served model, fallback-aware) — read off the input done frame
  (streaming) or the non-stream ack. New exported type `TurnUsage`.
- `control.usage` is now **emitted** after a companion input turn that billed
  tokens, carrying the same token totals; subscribe with `client.onUsage(...)`.
  `UsagePayload` gains `promptTokens` / `completionTokens` / `cachedTokens` /
  `reasoningTokens` / `model`. No `PROTOCOL_VERSION` change — all fields are
  additive and optional.

## [0.51.5]

### Fixed

- **A WebSocket stuck in CONNECTING now times out instead of hanging the
  stream plane forever.** Browsers impose no WebSocket connect deadline of
  their own, so a gateway that black-holed the handshake — firing neither
  `onopen` nor `onerror` — left the WS loop awaiting a promise that never
  settled, and the SSE fallback (which exists precisely for a broken WS
  plane) was never reached. The CONNECTING phase now runs under the same
  budget the SSE leg's headers phase took in 0.51.4: `requestTimeoutMs`,
  30s default, `0` still opts out. Expiry follows the ordinary errored
  path, so the client degrades to SSE (`streamState: 'degraded_sse'`) and
  delivery resumes. This closes the JS third of CR-638, alongside the Go
  SDK 0.1.7 and Unity preview.12 headers-phase bounds shipped with it.
- **Arrays no longer count as protocol deliveries.** The 0.51.4 degenerate-
  frame guard tested `typeof env !== 'object'`, which admits JSON arrays —
  so `event: open` / `data: []` still armed the delivered-window exemption
  through the `open` arm, exactly the shape that guard was built against
  (python's `isinstance(parsed, dict)` sibling never admitted arrays). A
  frame whose data parses to an array is now no delivery on any arm.

## [0.51.4]

### Fixed

- **A degenerate `open` frame no longer arms the delivered-window
  exemption.** The `open` arm accepted any frame whose data merely
  *parsed* — `data: null`, a bare number, a string — as the greeting, the
  one frame shape past the two 0.51.3 closed. A broken gateway emitting
  `event: open` / `data: null` then EOF earned the exemption and
  reconnected at network speed with zero backoff forever. A frame whose
  data is not a JSON object is now no delivery on any arm, matching the
  rest of the anti-storm roster (python skips non-dict frames, go's
  `takeFrame` yields only typed envelopes).
- **The SSE connect's headers phase is now bounded.** The stream fetch
  previously waited on response headers with no client-side limit — a
  reconnect where TCP/TLS completed but headers never arrived (hung
  function, load-balancer black-hole after accept, half-open socket after
  resume-from-sleep) sat until the *platform's* backstop fired, typically
  minutes, while every roster sibling bounds this phase at ~30s. The
  headers-await is now raced against the same budget every other request
  uses: `requestTimeoutMs` if set, 30s otherwise (`requestTimeoutMs: 0`
  still opts out). A timeout lands on the ordinary 500ms→8s reconnect
  ladder. The read-stall watchdog (`SSE_STALL_MS`) is unchanged — it
  always armed only *after* headers.

  No API-surface change. (Note for hosts that set a small
  `requestTimeoutMs` for snappy REST failures: that budget now also bounds
  the stream connect. Headers arrive immediately from a healthy origin, so
  this only shows against one that was already black-holing.)

## [0.51.3]

### Fixed

- **The 0.51.2 delivered-window exemption now has the roster's exact scope,
  on both edges.** Two divergences from the policy every other binding
  (python, go, dart, csharp, the Unity pump) carries were closed:
  - *Delivery survives a mid-read death.* The SSE leg reported delivery via
    `consume()`'s return value, so a window that delivered frames and then
    died mid-read (a proxy RSTing streams, a mobile network cutting over)
    lost the flag to the thrown read and escalated the backoff ladder
    500ms→8s against an origin that had just proven itself healthy. Delivery
    is now flagged the moment the first frame decodes — the same
    clear-at-first-delivery scope the rest of the roster has always had —
    so a delivered-then-died window retries at the 500ms base instead of
    climbing.
  - *Control frames no longer count as delivery.* Both legs treated ANY
    decoded frame — including the `reconnect` window-control frame and
    undecodable payloads — as proof a real server spoke, where every other
    binding counts only the greeting and typed envelopes. A gateway that
    opened a window, emitted one reconnect frame and EOF'd would have
    reconnected at network speed with zero backoff forever; it now sits out
    the anti-storm clamp like any other zero-delivery close.

  No API-surface change.

## [0.51.2]

### Fixed

- **A short stream window that DELIVERED frames no longer sits out the
  anti-storm backoff.** Both stream legs (SSE and WebSocket) clamped ANY
  clean close under 2s, so a server legitimately ending a window early —
  after handing over the greeting frame or a reply — was punished like a
  gateway that accepts and instantly closes. Every other binding in the
  anti-storm roster (python, go, dart, csharp, the Unity pump) already
  scoped the clamp to zero-delivery windows; the JS client now carries the
  same exemption: a window that decoded at least one protocol frame
  reconnects immediately, and only a short-lived EMPTY clean close climbs
  the backoff ladder.
- **Connection-lifetime and stall-watchdog measurements moved to a
  monotonic clock** (`performance.now()`, falling back to `Date.now()`
  where unavailable). The wall clock can step in either direction — NTP
  correction, a device resuming from sleep — so a lifetime derived from it
  could misread a healthy 45s window as instant (spurious backoff) or a
  broken instant close as long-lived (backoff reset, near-hot loop); the
  SSE stall watchdog could likewise fire a spurious cancel on a forward
  step. Wall-clock stamps that mean wall time are unchanged: debug-record
  `at` fields, persisted outbox item ages, and server-named real-time
  deadlines (Retry-After math, the outbox rate-limit hold).

## [0.51.1]

### Fixed

- **The SSE leg (the default transport) no longer hot-loops against a gateway
  that serves headers and instantly EOFs.** The WS leg has had a short-lived-
  connection clamp since 0.35.0: a connection that lived under 2s from open
  sits out a 500ms→8s anti-storm backoff before reconnecting, because a
  gateway that accepts and instantly closes (auth-as-close, an LB idle
  policy, a misconfigured proxy) answers every reconnect the same way. The
  SSE loop — the transport every client uses unless it opts into
  `stream: 'websocket'` — had no equivalent: a 200-with-body that closed
  cleanly reset the backoff at headers-received and refetched with zero
  delay, an unbounded request loop in exactly that gateway shape (measured
  in the suite: 739 reconnects in 900ms unguarded). Now both legs apply the
  same clamp, stamped at headers-received so handshake time never counts as
  connection lifetime: a clean close under 2s backs off and escalates
  (surfacing `reconnecting` on `streamState` during the sit-out); a
  long-lived close — the server's designed ~45s window cadence — still
  reconnects immediately. One behavioural nuance rides along: an error on a
  short-lived connection now escalates the backoff instead of re-arming
  500ms forever, which is the WS leg's semantics and strictly anti-storm.

## [0.51.0]

### Added

- **`companion.skill_card` — a skill's own card, delivered to the host.** When
  the companion runs a tool of an installed skill whose author declared a
  `card_template`, the rendered card now arrives on the event stream as
  `{ skill, tool, markdown }`. Subscribe with
  `client.on('companion.skill_card', …)`; the payload type is exported as
  `SkillCardPayload`.

  This closes a gap where the manifest field was parsed, safety-reviewed and
  rendered on the first-party client while reaching the platform plane nowhere
  at all — so a skill author's card passed every install gate and then did
  nothing, with no error, for every embed consumer. Integrators had been
  teaching the model to emit a text marker and regexing it back out of the
  reply; this replaces that with a shape the skill's author declared.

  It is deliberately **not** an open payload channel: the bytes are bounded by
  the author's template and the install review, substituted against that
  skill's own HTTP response. The model chooses whether a tool runs, never the
  shape of what is delivered.

  `markdown` is TEXT, not HTML — a host handed HTML tends to `innerHTML` it,
  and the non-web SDKs cannot use it. Placeholders the response did not fill
  are stripped. Requires the `skills.execute` scope (the same scope that let
  the call happen); a handler that never fires means the skill ships no
  template, not that the call failed.

  Additive and backwards compatible: existing handlers, sessions and skills are
  unaffected, and an SDK build that does not know the type keeps it as a raw
  envelope.

## [0.50.1]

- **A representative session's offline queue is now scoped to its visitor.** The
  outbox persistence key was `pouchy-outbox:${surface}` — surface only — while
  `connect()` resolves the session from **both** `surface` and `visitor` ("each
  visitor gets their own continuous thread"). On one browser, two representative
  clients on the same surface therefore shared one queue: visitor A's queued
  send was loaded by visitor B's client and replayed into **B's** session on B's
  first `connect()`. A's private text landed in B's thread, was consolidated into
  the wrong long-term memory, and billed the owner a turn — and because a
  provably-unsent item (`unsent: true`, queued while `navigator.onLine === false`)
  is deliberately exempt from the 30-minute staleness guard, it replayed at any
  age rather than expiring.

  A visitor-scoped client now persists under `pouchy-outbox-v:${surface}:${visitorId}`
  (both components percent-encoded). Two deliberate properties:

  - **Owner-facing clients are byte-identical to before.** With no `visitor`, the
    key is still exactly `pouchy-outbox:${surface}`, so upgrading never orphans a
    queue that was already correctly scoped — which is the majority of them.
  - **The namespace is a distinct PREFIX, not a suffix.** `pouchy-outbox-v:`
    cannot be spelled by `pouchy-outbox:` plus any surface name, so a surface
    named to look like a namespaced key cannot collide with a real visitor's queue.

  One-time effect for representative deployments: items already queued under the
  old shared key are not migrated. They are, by construction, of unknown visitor
  provenance — replaying them is the defect — and the old key is left untouched
  rather than cleared, because an owner-facing client on the same surface and
  browser legitimately owns it.

  No API change: no new option, no changed signature, nothing removed. If you
  supply a custom `outboxStore`, it simply receives the new key for
  visitor-scoped clients.

## [0.50.0]

- **The offline queue can no longer be blocked forever by a send that can never
  succeed.** `flushOutbox` is FIFO and stops on the first failure, keeping that
  item and 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 go through. 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 a
  cross-origin `fetch` **rejects** and the SDK sees `status: 0` with no `code`,
  which is indistinguishable from the network being down. `sendText` therefore
  queued it, and it failed identically on every flush: for an *ambiguous* item
  the 30-minute `OUTBOX_AMBIGUOUS_MAX_AGE_MS` guard eventually dropped it, but a
  *provably-unsent* one (queued while `navigator.onLine === false`) had **no
  exit at all** and replayed on every reconnect indefinitely, holding up every
  send behind it.

  Such an item is now dropped, with a `console.warn` naming the likely cause and
  an `onOutboxChange` notification, after **`OUTBOX_MAX_REPLAY_ATTEMPTS`** (new
  export, `5`) failed attempts. Because dropping a real message is a worse
  failure than delaying one, three conditions must all hold, and each is pinned
  by a test proven against mutated source:

  1. **`status === 0` only.** Any readable status — 5xx, 429, or the
     409/408/423/425 transients — means the server saw the request and owns the
     condition; those still wait indefinitely, exactly as before.
  2. **Attempts are counted only while `navigator.onLine !== false`.** A missing
     or flapping connection explains the failure by itself and must not spend
     the budget, so a device offline overnight comes back with a full one.
  3. **The item must also be older than `OUTBOX_AMBIGUOUS_MAX_AGE_MS`** (30 min).
     A short outage can produce several reconnects — hence several attempts — in
     a couple of minutes; requiring age too means no realistic outage reaches
     the drop.

  `QueuedSend` gains an optional `attempts?: number` (absent on items queued by
  an older build and on items that have never failed — both read as `0`), so the
  budget survives a reload. **Behaviour change**, hence the minor bump: a queued
  send that previously waited forever can now be discarded. Nothing that
  succeeds today changes, and CR-140's guarantee that a provably-unsent item
  replays at any age still holds for any item that is not repeatedly failing.

  The root cause is documented on the API side too: see "Request body size" in
  `companion-api-reference.md` — downscale images client-side (a 1024–1280px
  long edge at JPEG q0.8, which is what Pouchy's own clients do) and the
  situation does not arise.

## [0.49.0]

- **`ingestKnowledge()` and `ingestFile()` now declare `truncated`.** Both
  routes have always answered `{ ok, summary, chunks, truncated }`, and the API
  reference tells callers to act on it — `truncated: true` means the document
  was NOT fully ingested (text past the 60,000-char cap, or more chunks than the
  120-chunk ceiling; on `ingestFile` it also covers the server-side
  understanding step) and the remainder must be split and ingested separately.
  The declared return type omitted it, so `r.truncated` was a **type error** and
  the documented recovery was invisible at the call site. Additive and
  type-only: no runtime behaviour changes and no executable delta — the
  annotation erases, and this change's only trace in the CDN bundle is the JSDoc
  describing `truncated` (the minified bundle, which strips comments, carries
  none of it).
- **Three error paths that discarded their only diagnostic context now report
  it.** (a) The WebSocket→SSE degrade swallowed its synthesized error entirely:
  an embed that had silently fallen back to plan B was distinguishable from one
  that chose SSE by a single bit (`streamState === 'degraded_sse'`), even though
  the `debug` option documents "synthesized errors". It now emits a `debug`
  `kind:'error'` event (`code: 'ws_degraded'`) before degrading. (b) The SSE
  loop's non-OK reconnect dropped `res.status`, collapsing a proxy 502, a 429
  and a body-less 200 into one indistinguishable reconnect; it now emits
  `code: 'stream_reconnect'` carrying the status, and the loop's outer catch
  reports the connection error the same way. An intentional `stop()` still
  reports nothing. (c) `connectCall()`'s OpenAI Realtime SDP exchange threw a
  bare `SDP exchange failed (<status>)` without reading the provider's response
  body — the one place an expired or malformed ephemeral secret is explained, on
  the hardest path in the SDK to debug. The body is now folded into the thrown
  message (whitespace-collapsed, capped at 200 chars, read while the SDP abort
  timer is still armed so it cannot hang a hot mic, and never able to throw).
- **The terminal event-stream 401 stops advising a hook that already ran.**
  0.48.1's message pointed every permanent 401 at "wire `onAuthError` to refresh
  automatically" — including the branch reached *because* a wired `onAuthError`
  had already returned three consecutive tokens the server also rejected. That
  sub-case now names the actual problem: the tokens the hook mints (wrong user,
  or missing the `events.subscribe` scope). With no hook wired the message is
  unchanged.
- Housekeeping: the CDN size budget for the **unminified** `companion-sdk.js`
  was raised 35KB → 38KB gzipped (measured 35,163B; 34,488B at 0.48.1), a
  conscious raise per that gate's contract — the head release had already spent
  98.5% of the old budget before this change. The growth is almost entirely
  source-comment weight, which esbuild keeps only in the unminified artifact:
  the minified bundle most integrators serve grew 16,011B → 16,243B and its
  17KB budget is unchanged.

## [0.48.1]

- **`endSession()` no longer casts an HTTP error body into
  `EndSessionResult`.** A 401/403/429/5xx from `/end` used to return the
  server's `{ ok:false, error, code }` error envelope cast to the result type —
  a manufactured `{ ok: false }` state the documented `skipped`-ladder never
  names, whose `error`/`code` fields the declared type cannot carry. It now
  returns `null` (the documented value for "the request failed") and emits a
  `debug` error event carrying the HTTP status and the server's error code, so
  an expired-mid-call token is diagnosable instead of reading as
  "consolidation ran and failed". Python/Dart already raise here; JS stays
  deliberately non-throwing (teardown runs from pagehide).
- **The terminal event-stream failure message now names the right repair per
  status.** A permanent 401 said "the token likely lacks the events.subscribe
  scope" — the 403 diagnosis. It now says the token was rejected (likely the
  ~1h session token expiring) and points at re-minting / `onAuthError`; 403
  keeps the scope hint.
- Docs: the completed-turn dedup window in the `queueOffline` JSDoc, the
  Python `send_text` docstring and the protocol/API references said
  "~10 min / last 8" — stale since the window widened to 60 min / 32 per
  session in 0.39.0 (both READMEs were already correct). An integrator
  hand-rolling retry sized to the stale figure would mint a fresh turnId past
  10 min and run a second billed turn.
- Docs: `sessionId` is documented as the id of the LAST successful
  `connect()` — it is deliberately never cleared by
  `endSession()`/`close()`/`setToken()`, so it is an identifier for log
  correlation, not a liveness check.

## [0.48.0]

- **`EndSessionResult` now declares the full consolidation diagnostic the
  server already returns.** `/end` grew `extracted` (how many facts the
  extraction PROPOSED, present whenever it ran) and `writeError` (why the first
  refused write was refused) server-side; the type, JSDoc, READMEs, OpenAPI
  description and API reference now carry them. `extracted > 0` with
  `facts: 0` isolates "facts were found and the store refused them" (e.g. a
  token whose scopes cannot write the resolved memory namespace) from
  `extracted: 0` = "nothing durable found" — for a voice call this response is
  the only signal an embedder gets about whether the call became memory.
  Additive only: no wire or behavior change.

## [0.47.0]

- **In-call memory recall now sends the credential under TWO dynamic variable
  names.** `VOICE_TOOL_VARIABLE_PLAIN` (`pouchy_voice_token`) is passed
  alongside `VOICE_TOOL_VARIABLE` (`secret__pouchy_voice_token`) and rides its
  own `X-Pouchy-Voice-Token` header (`VOICE_TOOL_HEADER_PLAIN`). The endpoint
  accepts whichever one VERIFIES, and records which did.

  Why: four field readings had ElevenLabs invoke the tool with the
  Authorization header carrying the literal `{{secret__pouchy_voice_token}}` —
  unsubstituted, 30 characters — even though the client sent the variable at
  session start and the agent carried a placeholder default. The most
  economical explanation is that the `secret__` class exists precisely to stop
  a CLIENT from setting it, which is exactly what the 0.46.0 design asked it to
  do.

  **Self-plumbed integrators** — anyone calling `Conversation.startSession`
  themselves instead of using `connectCall` — should now pass BOTH:
  `dynamicVariables: { [VOICE_TOOL_VARIABLE]: creds.voiceToolToken,
  [VOICE_TOOL_VARIABLE_PLAIN]: creds.voiceToolToken }`. `connectCall` does it
  for you. Passing only the `secret__` one is what 0.46.0 did, and it does not
  reach the tool.

  Additive: nothing was removed or renamed.

## [0.46.0]

### Added

- **In-call memory recall.** `startCall()`'s `elevenlabs-convai` credentials now
  carry `voiceToolToken` — a call-scoped bearer for the server-side webhook tool
  the agent uses to read the session's memory MID-CALL. `connectCall()` passes it
  to `Conversation.startSession` as
  `dynamicVariables: { [VOICE_TOOL_VARIABLE]: creds.voiceToolToken }`
  automatically; the variable name is exported as `VOICE_TOOL_VARIABLE` for
  self-plumbed hosts.

  The `secret__` prefix on that variable is load-bearing rather than a
  convention: ElevenLabs treats that class as header-only and never sends it to
  the LLM, which is what lets a credential ride a live conversation without ever
  entering a prompt.

  **Self-plumbed hosts must pass it, and forgetting to throws nothing.** EL
  substitutes the agent's empty default, the tool call 401s, and the companion
  says it can't recall anything — a call that sounds completely normal with
  memory switched off. That is exactly how this shipped one release too early:
  the server minted the token and both client paths dropped it. Source-level
  guards now pin every `startSession` argument object.

  The field is **absent** (not empty) when the session holds no memory grant, so
  a client with no scopes has nothing to pass rather than something that fails
  inside a live call.

## [Unreleased]

- Documented (server-side behaviour change, no SDK code change): `remember()`
  now REFUSES content over 2,000 characters with a `payload_too_large`
  `CompanionError` (HTTP 413). The server has always stored only the first
  2,000 characters of a fact, but it answered `{ ok: true, cloudId }` while
  doing it — a truncated fact is indistinguishable from a complete one on every
  later `recall()`, and nothing told the caller anything had been dropped.

  If you were writing longer facts, they were already being cut; the change is
  that you now find out. Split them into separate `remember()` calls, or send
  document-length text to the knowledge plane, which chunks and embeds it for
  retrieval instead of storing one clipped fact.

## [0.45.0] - 2026-08-06

- Added: `quota_exhausted` to `COMPANION_ERROR_CODES` (and therefore to the
  `CompanionErrorCode` / `CompanionErrorCodeValue` unions). The server answers
  `402 { code: 'quota_exhausted' }` when an account's monthly allowance is
  spent — credits, or realtime voice minutes on `startCall`.

  **Handle it separately from `rate_limited`.** Both refuse a call, and the
  correct response to each is the opposite: a 429 means back off and retry
  (`CompanionError.retryAfter` says how long), whereas this one is not
  retryable at all — nothing changes until the plan is upgraded or the
  allowance resets on the 1st (UTC), so a retry loop on it never terminates.

  Purely additive: the vocabulary is append-only, no existing code was renamed
  or removed, and `CompanionErrorCodeValue` already carried a forward-compat
  string arm — so code written against 0.44.x compiles and behaves unchanged.
  Servers without credit enforcement switched on never emit it.

## [0.44.1] - 2026-08-01

- Fix: `DataActivityPayload` is now re-exported from the package root —
  `import type { DataActivityPayload } from '@pouchy_ai/companion-sdk'` works
  as the README and the 0.44.0 entry above always documented. (It was the one
  `OutboundPayloadMap` payload interface missing from the `index.ts` type
  re-exports; a drift gate now pins the full map → root-export parity so a
  future payload type cannot ship root-unimportable.) Type-only — no runtime
  or wire change.

## [0.44.0] - 2026-08-01

New outbound event (additive; `PROTOCOL_VERSION` stays `1` — unknown types
were already ignored by older clients):

- **`companion.data_activity`** — a Data-plane operation the agent performed
  server-side: a View read (`outcome: ok | unavailable | error`) or a settled
  Action (`outcome: committed | rejected | unknown`, plus its stable
  `actionId`). METADATA ONLY by contract: `{ kind, capability, outcome, ms,
  actionId? }` — never rows, intent values, endpoints or receipts. Render
  `unknown` as "pending verification", never as success or failure. Emitted
  only when the session token carries the new **`data.activity`** scope,
  granted automatically from the agent template's Data flag (same shape as
  `genui` → `ui.render`). Subscribe with
  `on('companion.data_activity', fn)`; typed via `DataActivityPayload`.
- New scope literal `data.activity` in `COMPANION_SCOPES` (not sensitive).

## [0.43.1] - 2026-07-30

Fixes. No surface change: no export, method, type or wire field moved, so nothing
a consumer imports is affected. Both fixes are additive at the point they are
observed — an error that previously had `retryAfter === undefined` may now carry
a number (which its own optional type already allowed), and the stream's
reconnect can only ever wait LONGER than it did, never sooner.

- **`CompanionError.retryAfter` is now populated on every response-derived
  error**, not just the ones raised by `sendText`/`connect`. The documented
  contract ("on a `rate_limited` (429) failure, `retryAfter` carries the
  server's Retry-After in SECONDS") was held only by the shared `postJson`
  funnel and the streaming turn leg; `recall`, `history`, `setModalities`,
  `ping`, `getAvatar`, `getWallet` and `pendingConfirms` each parsed their own
  error response and dropped it. The platform throttles `/api/companion/memory`,
  `/api/companion/session/**` and the `/api/companion` catch-all with a 429 that
  names `retryAfterSec` in the body *and* `Retry-After` in the header — those
  seven callers were discarding both. `ping()` is the one this hurt most, since
  its own documentation tells you to call it on a timer. The Python and Unity
  clients were never affected (each funnels all HTTP through one error factory).

- **The event stream honours a 429's `Retry-After` when reconnecting.** The
  reconnect loop backs off 500ms → 8s; on a throttle that meant re-asking about
  seven times a minute for the whole rate-limit window and keeping the bucket
  empty. It now waits at least as long as the server asked, clamped to the
  backoff it would have slept anyway (so the request rate can only ever go
  down) and to a 60s ceiling (so a proxy's `Retry-After: 3600` cannot silently
  mute an embed for an hour). The offline outbox has respected `Retry-After`
  since 0.35.0; this is the same rule on the stream.

## [0.43.0] - 2026-07-26

Additive: a confirmation can now be rendered in the user's own language. No wire-protocol
bump (`PROTOCOL_VERSION` stays 1); no existing field changed or moved.

- **`summaryKey` / `summaryVars`** on `ConfirmRequestPayload` and `PendingConfirm` — the
  same sentence as data (an i18n key `confirmSummary.*` plus its placeholder values)
  alongside the English `summary`, which is unchanged and still always present. A host
  that knows the user's language renders
  `summaryKey ? localize(summaryKey, summaryVars) : summary`; a host that does nothing
  keeps the exact behaviour it has today.

  The server cannot localize this for you: the turn endpoint (`/input`) carries no
  locale, so the sentence has to travel as data to the one process that knows the
  user's language. Both fields are optional — absent for a tool that ships no keyed
  summary, and on confirmations recorded before this release.

  Shipped for Pouchy's own hosted confirm page, which was asking users to approve
  payments against an English sentence regardless of the language they run Pouchy in.

## [0.42.0] - 2026-07-25

Additive: the Instant UI ⇄ Google A2UI v0.9 projection ships in the package.
No wire-protocol bump (`PROTOCOL_VERSION` stays 1); no existing export moved.

- **`toA2UI` / `fromA2UI`** — project an Instant UI panel (the
  `companion.ui_action` `payload.interface`) losslessly to Google's A2UI v0.9
  flat wire format and back, so a renderer built against the A2UI catalog can
  draw Pouchy panels with the adapter in front. Previously the adapter lived
  only in the app repo (`src/lib/genui/a2ui-adapter.ts`) and integrators had
  to vendor the file; that path now delegates to this package. Concepts A2UI
  has no standard slot for (Progress, Accordion, area/pie chart kinds,
  `computed`, `when`, tones, etc.) ride a per-component `_pouchy` extension —
  A2UI renderers ignore it; `fromA2UI` reconstructs the exact panel from it.
- **Instant UI panel types** — the strict panel/node type family
  (`DynamicInterface`, `GenuiNode`, …) and the `INSTANT_UI_NODE_TYPES`
  catalog (as data, for renderer capability checks) are exported alongside.
  `toA2UI` accepts either the strict panel type or the loose
  `payload.interface` exactly as `onRender` delivers it. Embedded Button /
  table-row actions are typed as an opaque `EmbeddedAction { type }` — hosts
  hand them back, never interpret them.
- **CDN bundle size** — the projection adds ~1.7KB gzip to the min bundle
  (~15.7KB total) / ~3.2KB to the full one (~32.2KB); the size-gate budgets
  were raised consciously (14→17KB min, 29→35KB full) per the gate's
  contract.

## [0.41.0] - 2026-07-25

Additive: call-window generation fencing + world-state replay observability.
No wire-protocol bump (`PROTOCOL_VERSION` stays 1); every new field is
optional and every change is backward compatible on both sides.

- **Call-window generation fencing (CR-142 legs c/d).** `POST /call` now
  returns `callGen` — the generation handle of the 20-min call-active window
  that mint stamped — and `endSession()` sends a `callGen` field so the
  server can fence its window clear to the call that owns it:
  - a **call teardown** (`connectCall` handle close / connect-phase failure)
    carries the gen of **its own mint**, so a stale handle's late `/end`
    landing after a fresh re-mint on the same session no-ops instead of
    silencing the new call's voice injects for its lifetime;
  - a **text client** that never minted a call sends `callGen: null`, so its
    `close()` no longer kills a live call's window on the same session;
  - an **omitted** field (every pre-0.41 SDK, gen-less REST callers) keeps
    the 0.40.0 legacy contract: unconditional clear.
  Self-plumbed integrations (raw `startCall` + own WebRTC): pass
  `creds.callGen` to `endSession({ callGen })` at call end — `startCall`
  also remembers it, so a plain `endSession()` from the same client does the
  right thing. Against a pre-fencing server the SDK degrades safely (the
  window self-expires ≤20 min, the pre-0.40 posture).
- **`sendWorldState` now returns `duplicates`** — how much of `accepted` was
  the server's envelope-id replay dedup (idempotency shipped server-side
  2026-07-25). Stamp stable `id`s on host-level retries to get idempotent
  re-sends; ids minted by the SDK are fresh per call, so only same-body
  retries (e.g. the automatic 401-refresh replay) dedup by default.

## [0.40.1] - 2026-07-25

Fixes (the 0.40.0 timeout class closed on every remaining long-work POST; a
failed call connect no longer strands the server's call-active window).

- **`sendToolResult`, `ingestKnowledge`, `ingestFile` now default to the 310 s
  long-work deadline; `confirmAction` to 65 s.** 0.40.0 exempted only the
  buffered `/input` sends from the general 30 s `requestTimeoutMs` default —
  but the same headers-arrive-when-the-work-completes shape applies to
  `/tool-result` (the server runs the full billed continuation before
  responding, up to 300 s), the two knowledge ingests (synchronous summarize +
  per-chunk embedding, up to 300 s), and `/confirm` (an approve EXECUTES the
  action first, up to 60 s). Under the 30 s default a >30 s continuation
  rejected `request_timeout` while it kept running server-side; a large-PDF
  ingest timed out mid-ingest and the natural retry raced the still-running
  first ingest; a timed-out approve could never be retried (confirms are
  single-use), silently degrading the outcome-in-response channel. As in
  0.40.0, an explicitly set `requestTimeoutMs` (including `0`) still bounds
  every request at your value. `endSession()` deliberately keeps the 30 s
  default — a teardown must not hold its awaiter for minutes; a slow
  consolidation still completes server-side and only the diagnostic return is
  lost.
- **`connectCall()` tears down the server's call-active window when the
  connect phase fails after a successful mint.** Minting credentials stamps a
  20-minute call-active window server-side; every connect-phase failure (mic
  permission denied, missing `@elevenlabs/client` peer dependency, SDP
  failure) THREW out of `connectCall` without ever reaching the close/onError
  teardown, so the window outlived a call that never existed — for up to 20
  minutes the server routed `voice_inject` moments into a dead call (each
  consuming a rate/budget slot) while text play-along stayed suppressed. The
  connect attempt is now torn down exactly like an instant hang-up (the same
  `endSession()` the `close()` path posts, which clears the window) before the
  original error is rethrown. Mint failures (401/403/429) are unaffected — no
  window was stamped, and no session-end signal is sent.

## [0.40.0] - 2026-07-25

Fix (buffered sends no longer time out on healthy long turns — CR-141).

- **The buffered `/input` path is exempt from the general 30 s
  `requestTimeoutMs` default.** `requestTimeoutMs` (0.35.0) is a
  time-to-response-HEADERS deadline — correct for the SSE legs, where headers
  arrive immediately. But a buffered send (no `onDelta` subscriber and no
  `stream: true`, or an outbox replay) gets its headers only when the TURN
  completes (the server runs up to 300 s), so the 30 s default rejected
  `request_timeout` on any turn longer than 30 s **while the turn kept running,
  billing, and executing tools server-side** — and `@pouchy_ai/react`'s
  `useMessages` rollback then erased the user's message from the thread. The
  buffered `/input` POSTs (plain buffered send, `awaitReply` + `stream: false`,
  and outbox replays) now default to a 310 s deadline — the server's own turn
  ceiling plus headroom — so a hung server is still cut. An explicitly set
  `requestTimeoutMs` (including `0` = disabled) continues to bound EVERY
  request at your value, buffered sends included.
- This also makes the documented `awaitReply` + `stream: false` contract
  reachable: the reply now times out as `reply_timeout` after `replyTimeoutMs`
  (60 s default), instead of `request_timeout` at 30 s firing first with the
  wrong code.
- Server-side companion fix riding this release note (no SDK code involved):
  `POST /session/{id}/end` — which `connectCall().close()` already calls via
  `endSession()` — now also clears the voice-call-active window, so a hang-up
  no longer leaves a phantom ≤20 min window in which world-state kept being
  spoken into a dead call while text play-along reactions stayed suppressed.

## [0.39.0] - 2026-07-25

Fix (offline outbox reliability — two silent-loss / double-run classes). Both
only bite the persisted `queueOffline` queue (0.34.0), whose items replay on
the NEXT connect — potentially long after they were queued.

- **A transient 409 during replay no longer discards the send.** `flushOutbox`
  treated any 4xx-except-429 as "the server rejected this, drop it" — but the
  `/input` route returns **409** for two purely TRANSIENT states (a previous
  turn awaiting tool results / another turn mid-flight). A reconnect auto-flush
  landing while a turn was still running dropped the queued message forever
  (the "N pending" badge fell 1→0, indistinguishable from delivered). 409 (and
  408/423/425) are now KEPT and retried on the next flush; only a terminal 4xx
  (400/401/403/404/422) drops the item.
- **A stale ambiguous replay can no longer double-run a turn.** Replays reuse
  the original `turnId`, safe only while the server still remembers it. The
  server's dedup window was 10 min / last 8 turns — far shorter than a persisted
  outbox can sit — so a replay after a longer gap re-ran the SAME turn (double
  model bill + duplicate reply + duplicate tool side effects, incl. an approved
  `pay_*`). Now: (a) a queued send records whether it PROVABLY never reached the
  server (`QueuedSend.unsent` — `navigator.onLine` was false, the request was
  never made); those replay at any age (they run exactly once). (b) An AMBIGUOUS
  item (a dispatch was attempted and failed at the network level / a stream
  dropped mid-flight — the turn MAY have run) is auto-replayed only while
  younger than the new exported `OUTBOX_AMBIGUOUS_MAX_AGE_MS` (30 min); past
  that it is dropped rather than risk a double-run. (c) The server dedup window
  is widened to 60 min / last 32 turns so any item the client will replay is
  still deduplicated.

Additive surface: `OUTBOX_AMBIGUOUS_MAX_AGE_MS` export and the optional
`QueuedSend.unsent` field (minor bump). No wire/protocol change
(`PROTOCOL_VERSION` stays `1`).

## [0.38.1] - 2026-07-24

Fix (voice, OpenAI provider): the SDP-answer **body** read is now bounded by
the same 8s exchange timer as the headers, and every failure leg cleans up the
peer connection + mic tracks before throwing. Previously the timer was cleared
as soon as headers arrived, so a network stall mid-body hung `connectCall()`
forever **with the microphone already live**, and a body-read or
`setRemoteDescription` failure escaped as a raw exception (breaking the
`CompanionError` contract) while leaking the pc + hot mic. All such failures
now surface as `CompanionError` code `call_connect_failed`. No surface, wire,
or protocol change (`PROTOCOL_VERSION` stays `1`).

## [0.38.0] - 2026-07-19

Additive: new `grantsScope(granted, required)` helper. Like `hasScope`, but it
honours the server's scope subsumption — a key granted only `wallet.spend` now
`grantsScope(g, 'wallet.read')`, because the wallet API (`GET
/api/companion/wallet`, `getWallet()`) serves the balance to a spender. Gate a
read-only affordance on `grantsScope`, not `hasScope`, or the UI hides a feature
the server would allow. `hasScope` is unchanged (exact-match), for
"was-this-scope-explicitly-granted" checks. No wire/protocol change
(`PROTOCOL_VERSION` stays `1`); no scope vocabulary change.

## [0.37.1] - 2026-07-18

Documentation-only patch — no code, no surface, no protocol change
(`PROTOCOL_VERSION` stays `1`). Published so the corrected jsdoc actually
ships in the `.d.ts`.

### Fixed

- **Stale `HelloAckPayload` jsdoc**: the event stream's greeting `open` frame
  was described as carrying "just `{ resumeCursor }`" — since 0.37.0 it also
  carries `pendingToolCalls` when reconnecting mid-pause (that release updated
  the greeting but not this comment). Same correction in the Unity
  `Models.cs` doc comment.

## [0.37.0] - 2026-07-17

No breaking changes — additive surface. No protocol change (`PROTOCOL_VERSION`
stays `1`; `pendingToolCalls` / `replayed` are optional additive fields on
existing payloads).

### Added

- **`hello.ack` / `connect()` → `pendingToolCalls`** — the discovery half of
  client-restart recovery for a turn paused on YOUR declared tools. When a
  session is resumed mid-pause (tab reload / crash while the companion waits
  for tool results), the `/session` handshake response — and the stream's
  greeting `open` frame — now list the outstanding calls as
  `PendingToolCall[]` (`{ id, name, args, turnId?, pausedAt? }`, new exported
  type). `HelloAck.pendingToolCalls` is always present on `connect()`'s return
  (empty array when nothing is pending). A reloaded embed can now **complete**
  the paused turn — perform each call and post the results with
  `sendToolResult` (the server's apply is idempotent per call id) — instead of
  abandoning it via `endSession()` (`/end`, the 0.36-era only unblock path;
  that abandon path still works and still clears the pause).
- **Automatic replay: `replayPendingToolCalls` option** (default `true`) —
  after a `connect()` that resumed mid-pause, each outstanding call is
  re-emitted to your `onToolCall` handler with the new `ToolCallPayload`
  flag **`replayed: true`** (deferred one tick so handlers registered right
  after `connect()` still catch it; at most once per client instance). Your
  existing tool loop completes the turn with zero new code — but register
  `onToolCall` BEFORE `connect()`/`start()` to be safe. Side-effecting tools
  should treat `call.id` as an idempotency key when `replayed` is set: the app
  may have already performed the call and died before posting the result.
  Pass `replayPendingToolCalls: false` to opt out and drive recovery yourself
  from `HelloAck.pendingToolCalls` (judge staleness via `pausedAt` first).

## [0.36.0] - 2026-07-16

No breaking changes — additive surface. No protocol change (`PROTOCOL_VERSION`
stays `1`; `interrupt()` is client↔provider-local and never touches the Pouchy
wire protocol).

### Added

- **`call.interrupt()` — silent cut** (integrator request: fast-paced game
  commentary going stale mid-utterance). Stops the utterance currently being
  spoken WITHOUT generating new speech; idempotent (no-op when idle, after
  `close()`, and mid-connect). Provider tiers, discriminated by
  `call.provider`:
  - `openai-realtime` — guaranteed: `response.cancel` +
    `output_audio_buffer.clear` over the data channel; playback stops within
    ~an audio frame and the session continues normally (the `.cleared` event
    re-arms the half-duplex mic like a natural end of turn).
  - `elevenlabs-convai` — best-effort: the EL client has no public
    stop-playback API, so this signals `user_activity` (halts the agent's
    stream when the agent's interruption handling allows, without speaking a
    reply) and attempts the client's internal output-buffer cut when the
    installed build exposes it; degrades to a no-op. For guaranteed
    preemption on EL, `injectEvent(text, true)` (which speaks new content)
    remains the reliable lever.

## [0.35.0] - 2026-07-16

No breaking changes — reliability hardening + one additive option. No protocol
change (`PROTOCOL_VERSION` stays `1`).

### Added

- **`requestTimeoutMs` option** (default `30000`, `0` disables) — a deadline
  for every HTTP call to produce RESPONSE HEADERS, so a server that accepts
  the connection but never answers can no longer hang `connect()`, plain
  `sendText()`, `recall()`, etc. forever (previously only
  `sendText({ awaitReply })` had any deadline). The deadline never bounds
  reading a response BODY — streaming replies and the SSE event stream are
  unaffected. On expiry helpers reject with the new SDK-synthesized
  `code: 'request_timeout'` (status 0). The timeout is deliberately NOT
  treated as a network-level failure by the offline queue: the server may
  have received the request, and unlike a pre-flight rejection the SDK can't
  know — flush-replay of an explicit network failure stays the only
  auto-queue path.

### Fixed

- **A mid-stream network drop now rejects with `CompanionError`** (status 0)
  instead of leaking the runtime's raw `TypeError` — restoring the "every
  helper rejects with `CompanionError`" contract on the streaming `sendText`
  path (the default whenever an `onDelta` subscriber exists). With
  `queueOffline: true` the interrupted send is now correctly queued and
  replayed with its original `turnId` (server-side turn dedup makes the
  replay safe even when the turn had actually completed).
- **SSE liveness watchdog** — a silently half-open event-stream connection
  (NAT/LB idle-kill, laptop sleep) used to leave `streamState` at
  `'connected'` while replies were lost until the OS TCP timeout. The client
  now expects the server's ~1.5s heartbeat and force-reconnects after 15s of
  byte silence (normal backoff + cursor resume; replays dedupe by envelope
  id). SSE only — the WebSocket plane has no heartbeat contract.
- **Listener leak on already-aborted `awaitReply` calls** — passing an
  AbortSignal that had already fired leaked one `companion.message` handler
  per call (hosts reusing one `AbortController` across turns accumulated
  them without bound). The fast-fail now runs before the subscription.
- **Outbox replay honors `Retry-After`** — a 429 during `flushOutbox()`
  arms a backoff window (from the server's own `retryAfterSec` / header), so
  the auto-flush on every stream reconnect no longer re-hammers a server
  that just throttled the replay.
- **WebSocket reconnect backoff measured from `onopen`** — connection
  lifetime previously included DNS/TCP/handshake time, so a gateway that
  took ~2s to accept and instantly closed was misread as long-lived and
  reset the backoff into a near-hot reconnect loop.

### Docs

- `openCompanionCall`'s README description now matches its real signature —
  it consumes the `CallCredentials` that `startCall()` mints (it never took
  a client instance).
- `/sdk` options table now lists `outboxStore`, `requestTimeoutMs`, `fetch`
  and `webSocketImpl`.
- `onError` documents the literal `'error'` fallback code a malformed
  `control.error` frame is delivered with (give your `switch` a `default:`).

## [0.34.0] - 2026-07-14

No breaking changes — additive, opt-in surface. No protocol change
(`PROTOCOL_VERSION` stays `1`).

### Added

- **Offline send queue** — opt in with `createCompanion({ queueOffline: true })`.
  A `sendText` that fails at the NETWORK level (fetch rejected before any HTTP
  response, or `navigator.onLine === false`) is queued instead of lost and
  resolves `{ seq: null, queued: true, turnId }`; with `awaitReply: true` it
  rejects with the new SDK-synthesized `code: 'queued_offline'` (the item is
  still queued — there is just no reply to await this session). HTTP error
  responses (4xx/5xx incl. 429) are NEVER queued — the server saw those; they
  keep the existing throw semantics.
  - **Flush** is FIFO + sequential and runs automatically after a successful
    `connect()` and whenever the event stream reconnects (`streamState` →
    `'connected'`), or manually via `flushOutbox()` → `{ sent, remaining }`
    (one flush at a time — concurrent calls share the in-flight run). Each
    replay is a buffered input POST reusing the item's ORIGINAL `turnId`: the
    server dedupes completed turnIds (10 min TTL, last 8 per session, answered
    `duplicate: true`), so a retry after an ambiguous failure can never
    double-run a turn. Per item: success or 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 — dropped
    (`console.warn` + outbox-change notification), flush continues.
  - **Surface:** `pendingOutbox()` (defensive copy; new exported `QueuedSend`
    type — `{ turnId, text, images?, at }`) and `onOutboxChange(fn)` for a
    "N pending" badge. Persistence defaults to localStorage (key
    `pouchy-outbox:<surface>`, in-memory page-lifetime fallback) or bring your
    own via the `outboxStore` option (new exported `OutboxStore` type); a
    reloaded tab resumes its persisted queue.
  - **Adapters:** the view snapshot gains `outbox: number` (feature-detected,
    0 for clients from older bundles), and the optimistic user line is KEPT
    for a queued send — both the resolved `queued: true` shape and the
    `queued_offline` rejection — while the 0.32.1 rollback still applies to
    real rejections.

## [0.33.0] - 2026-07-14

**Breaking** (adapter view controller only; pre-1.0 minor per this package's
semver caveat). No protocol change (`PROTOCOL_VERSION` stays `1`).

### Changed

- **`CompanionTranscriptEntry.role` is now `'user' | 'assistant'`** (was
  `'user' | 'companion'`). `'assistant'` is the industry role vocabulary and
  what BOTH sibling surfaces already used — this package's own voice-call
  transcript (`onTranscript` events) and `@pouchy_ai/react`'s `ChatMessage` —
  so the adapters were the one odd dialect out. Migration: replace
  `role === 'companion'` checks with `role === 'assistant'` in hosts using
  `createCompanionView` / `/svelte` / `/vue` (0.31.0–0.32.1).

## [0.32.1] - 2026-07-14

Backwards-compatible fixes to the adapter view controller (`createCompanionView`,
`/svelte`, `/vue`). No protocol change (`PROTOCOL_VERSION` stays `1`).

### Fixed

- **Rejected sends roll back the optimistic user line.** `view.sendText`
  appends the user turn optimistically; if the underlying send rejects
  (network drop, `409 turn_pending`), the line is now removed from the
  transcript instead of surviving as a message the server never saw. The
  rejection still propagates — retry semantics are the host's.
- **Stale confirm cards are dropped when the confirm was resolved elsewhere.**
  A pending confirm can be resolved out from under an SDK card — on the hosted
  confirm page, or by an IM keyword reply through the channel confirm relay.
  `view.confirmAction` rejecting with `confirm_resolved` / `confirm_not_found`
  now removes the dead card from `pendingConfirms` (other rejections keep the
  card for retry). The error still propagates.

## [0.32.0] - 2026-07-14

No breaking changes — additive surface + docs infrastructure. No protocol
change (`PROTOCOL_VERSION` stays `1`).

### Added

- **`debug` instrumentation option.** `createCompanion({ debug: true })` logs
  structured events via `console.debug('[pouchy-sdk]', …)`; passing a function
  receives every `CompanionDebugEvent` (new exported type) for your own
  logger / devtools / test recorder:
  - `request` / `response` — every authed HTTP call with method, path,
    status, and wall-clock `ms` (including the post-401-refresh retry leg);
  - `envelope` — each DELIVERED stream envelope (type + id), post-dedup, so
    replay collapses are visible by omission;
  - `stream` — receive-stream lifecycle transitions (state + prev);
  - `error` — synthesized transport failures (message, code, status).
  Events never carry the token, headers, or bodies — safe to pipe anywhere.
  A throwing sink is isolated (it can never break the request it observes).
  Zero cost when unset.

### Docs

- **README example code is now compiled in CI.** Every ```` ```ts ```` block
  in both READMEs typechecks against the real SDK source
  (docs-snippets.test.ts) — a renamed method or changed option shape in
  example prose now fails the build instead of shipping as broken copy-paste.
- **Generated full API reference.** TypeDoc renders the complete per-symbol
  reference from source JSDoc to `pouchy.ai/sdk-api/` at build time (never
  committed, can't drift); the /sdk page header links to it.

## [0.31.0] - 2026-07-14

No breaking changes — additive surface + packaging/distribution engineering.
No protocol change (`PROTOCOL_VERSION` stays `1`).

### Added

- **Framework adapters** as subpath exports — thin bindings over one shared,
  fully-tested view controller (`createCompanionView`, also exported from the
  root for hosts on other frameworks). React already has its own richer
  package (`@pouchy_ai/react` — Provider-owned lifecycle + voice-call hook);
  these bring Svelte and Vue to parity with it:
  - `@pouchy_ai/companion-sdk/svelte` — `companionStore(client)`, a
    hand-rolled Svelte store contract (no `svelte` import — works Svelte 3→5).
  - `@pouchy_ai/companion-sdk/vue` — `useCompanion(client)` returning a
    `shallowRef` snapshot; auto-disposes with the effect scope.
  The view tracks `{ streamState, transcript, draft, typing, pendingConfirms }`
  with immutable snapshots, wraps `sendText`/`confirmAction` (optimistic user
  turn; confirm-card bookkeeping incl. the retryable exec_failed re-approve
  semantic), and can restore recent history on creation (`restore: N`, the
  `useMessages` semantic). The HOST owns the client lifecycle — adapters only
  observe. New peer dep `vue` is optional; core stays dependency-free.
- **Version-pinned CDN with SRI.** `npm run build:cdn` now also emits
  `cdn/companion-sdk.min.js` (~10KB gzip vs ~22KB), a version archive under
  `cdn/versions/<version>/` served immutable at
  `https://pouchy.ai/sdk/v<version>/companion-sdk{,.min}.js`, and
  `cdn/integrity.json` (sha384 SRI hashes, published on the /sdk page) — so
  an integrator can pin an exact release + integrity hash instead of riding
  silent upgrades of the floating URL.
- **Go-to-definition into real source.** The published package now ships
  `src/` plus `declarationMap`/`sourceMap` (rewritten to resolve in-package),
  so editors jump from `useCompanion(...)` to the commented TypeScript, not a
  bare `.d.ts`.

### Changed

- **CDN bundle size is now a CI budget** (gzip: min ≤ 14KB, full ≤ 29KB, ~30%
  headroom over current). A legitimate feature that exceeds it must raise the
  budget consciously in the same PR and say so here.

## [0.30.0] - 2026-07-12

No breaking changes despite the minor bump — everything here is additive
surface (which this repo's house policy ships as `0.x.0`).

### Added

- **Typed `control.error` code vocabulary.** New runtime export
  `CONTROL_ERROR_CODES` — the server-emitted stream-plane codes
  (`agent_error`, `call_mint_failed`) — plus the types `ControlErrorCode`,
  `ControlErrorPayload`, and the open union `ControlErrorCodeValue` (server
  codes + the SDK-synthesized `stream_unauthorized` + a forward-compat string
  arm, same doctrine as `CompanionErrorCodeValue`). `onError`'s handler now
  sees `code: ControlErrorCodeValue` instead of a bare `string`, so a `switch`
  on it autocompletes. Drift-tested two ways: the SDK list must equal the
  server list, AND every `control.error` emit site's code literal (server and
  SDK) must be declared — a new site inventing a code fails CI.
- **`on()` narrows the envelope payload per event name.** New
  `OutboundPayloadMap` + a generic overload:
  `on('companion.typing', (env) => env.payload.active)` type-checks without a
  cast; `on('*')` keeps the untyped envelope. Type-only — zero runtime change.
  New wire-payload types exported along the way: `HelloAckPayload`,
  `CallReadyPayload`, `ControlErrorPayload`.
- **Stream-state observability.** `client.streamState` +
  `onStreamStateChange((state, prev) => …)` with
  `'idle' | 'connecting' | 'connected' | 'reconnecting' | 'degraded_sse' |
  'stopped'` (`CompanionStreamState`) — including the previously invisible
  WebSocket→SSE degrade (`degraded_sse`) and the permanent-401/403 stop. Hosts
  no longer hand-roll a connection enum around `onMessage` traffic.

### Fixed

- **Reconnect backoff now carries jitter (additive, 0–25%)** in both the SSE
  and WebSocket receive loops, so a fleet of embeds doesn't reconnect in
  lockstep after a server deploy/restart (thundering herd).
- **`Retry-After` HTTP-date form is parsed.** RFC 9110 allows a date form
  (proxies/CDNs emit it); it used to read as `undefined`. Delta-seconds still
  win; a date converts to a non-negative seconds delta.
- **The streaming `sendText` leg keeps the 429 body `hint`.** The non-SSE
  error fallback dropped the actionable hint (e.g. "provision a project …")
  that the buffered path has surfaced since 0.27.0.

## [0.29.1] - 2026-07-12

### Fixed

- **`CompanionError.retryAfter` no longer reports a spurious `0`.** A missing
  `Retry-After` header was parsed as `Number(null) === 0`, so EVERY error
  thrown from a POST without the header (a `400 invalid_request`, a
  `404 session_not_found`, …) carried `retryAfter: 0` — telling backoff code
  "retry immediately" on non-retryable errors. It is now `undefined` unless
  the body's `retryAfterSec` or a real header names a value, as documented.
- **`sendText({ awaitReply: true, stream: false })` now cancels its POST.**
  The buffered leg never passed the operation's `AbortSignal` to the network
  call, so the promise rejected on abort/deadline but the billed turn kept
  running server-side. It now aborts with the same linked controller the
  streaming leg uses.
- **Abort-listener leak on a reused `AbortSignal` in `awaitReply`.** The
  deadline's reject listener was never removed, so a host reusing one
  long-lived signal across many `sendText({ awaitReply, signal })` calls
  accumulated one orphan listener per call (memory growth + Node's
  MaxListenersExceeded warning). Removed in the same `finally` as the rest.

### Added

- **`signal` on `sendToolResult` and `startCall`** — the two request-performing
  methods 0.29.0's "AbortSignal on every network method" pass missed
  (backwards-compatible addition, per this package's patch policy).
- **`require()` of the package now works on Node ≥ 20.17** (require-ESM): the
  `exports` map gained a `default` condition — previously `require()` failed
  with `ERR_PACKAGE_PATH_NOT_EXPORTED` before the feature could apply, despite
  the README documenting it.

## [0.29.0] - 2026-07-12

### Added

- **`AbortSignal` on every network method.** All request-performing public
  methods now accept an optional `signal` — cancel an in-flight `sendText`
  (including its `awaitReply` deadline + event-stream teardown), `recall`,
  `history`, `sendWorldState`, `ingestKnowledge`, `ingestFile`, `remember`,
  `getAvatar`, `getWallet`, `confirmAction`, `pendingConfirms`,
  `setModalities`, `ping`, `pairVisitor`, or `connect`. Methods with an
  existing options object gained a `signal?` field; the rest gained a trailing
  `opts?: { signal?: AbortSignal }`. Fully additive — no existing call
  changes. A fired signal rejects with `CompanionError` `code: 'aborted'`
  (new synthesized code), converted at the single fetch choke point so the
  "every helper rejects with CompanionError" contract holds; the signal also
  rides the 401-refresh retry automatically.

## [0.28.1] - 2026-07-12

### Fixed

- **WebSocket reconnect storm on short-lived connections.** A gateway that
  accepted the socket and immediately closed it cleanly (auth-expressed-as-
  close, LB idle policy, misconfigured proxy) made `stream: 'websocket'`
  reconnect in a zero-delay hot loop. A connection that lived under ~2s now
  sits out the same 500ms→8s doubling backoff the SSE loop uses (a long-lived
  connection still reconnects immediately).
- **A throwing event handler no longer poisons the stream loop.** Handler
  exceptions used to propagate into the SSE/WS loop's catch — read as a
  CONNECTION failure (backoff + reconnect + cursor replay) — and, on the
  `sendText` local-emit path, rejected a `sendText` whose turn had actually
  succeeded. Handlers are now isolated per-call (logged, remaining handlers
  still run).
- **Custom voice-tool bridges may throw.** With `openCompanionCall` and a
  bring-your-own bridge whose `invoke` rejected, the model never received a
  `function_call_output` — the voice turn hung until the provider timeout,
  plus an unhandled rejection. A rejecting bridge now answers the model with
  `{ ok: false, error }`.
- Timer hygiene: the 20s voice-tool timeout and the 10s microphone-acquire
  timeout are now cleared on normal completion instead of lingering.

### Changed

- **`CompanionErrorCodeValue` is now DERIVED** from the drift-tested server
  vocabulary plus the SDK-synthesized list, instead of hand-retyped — the old
  copy had silently drifted 5 codes behind (`not_representative`,
  `call_unsupported`, `call_connect_failed`, `call_dependency_missing` now
  autocomplete). A new drift test pins every `new CompanionError(...)` call
  site to a declared code. Types-only; no runtime behavior change.
- `package.json` now declares `"engines": { "node": ">=18" }` and the README
  documents that the package ships **ESM-only** (modern bundlers and Node ≥ 18
  consume it as-is; there is no CJS build).

## [0.28.0] - 2026-07-12

### Added

- **Turn correlation (`turnId` → `replyTo`).** Every `sendText` now mints a
  `turnId` and sends it with the turn; servers on API ≥ 1.1 echo it back as
  `replyTo` on the reply's `companion.message` — including a reply that lands
  after an app-tool pause. `awaitReply`'s buffered fallback now resolves ONLY
  on the correlated reply on those servers, so a proactive world-state
  reaction or a confirm outcome can no longer steal the resolution (the known
  0.25–0.27 caveat). Against older servers the legacy resolve-on-first
  behavior is kept automatically (capability-detected via
  `X-Pouchy-Api-Version`). New `MessagePayload` type (`{ text, replyTo? }`).
- **`needs_event_stream` error.** With `awaitReply` and no `start()`, a turn
  that pauses on app tool calls used to wait out the full `replyTimeoutMs`
  and then time out — the post-resume reply can only arrive on the event
  stream. It now rejects immediately with `code: "needs_event_stream"`.
- **Typed confirm errors.** The confirm/step-up endpoints now carry codes:
  `confirm_not_found`, `confirm_resolved`, `step_up_required`,
  `step_up_failed` (vocabulary is append-only). The client no longer burns an
  `onAuthError` token refresh on a step-up 401.
- **`sendWorldState` returns `injected` and `reacted`** — whether the batch
  triggered a live-call inject or a proactive text reaction (previously
  discarded).
- **429 `hint` surfaced.** The demo-budget hint ("provision a project…") is
  appended to the thrown `CompanionError` message instead of being dropped.
- **`CompanionErrorCodeValue`** — `CompanionError.code` is now typed as the
  known vocabulary union (plus open `string`), so `switch` statements get
  autocomplete and exhaustiveness help.

### Fixed

- **`replyTimeoutMs` now bounds the WHOLE awaitReply operation.** Previously
  only the buffered-fallback wait was bounded — a stalled streaming response
  could hang the promise indefinitely. The deadline now starts before the
  POST and aborts the in-flight stream on expiry.
- **`stop()` immediately followed by `start()` no longer leaves two live
  receive loops.** Loops carry a generation token; a stale loop exits instead
  of resurrecting alongside the new one (it double-polled the stream forever).
- **`connectCall` cleans up on provider-initiated teardown.** A dropped call
  (pc failure / provider disconnect) now runs the same unsubscribe +
  `endSession({ transcript })` consolidation that `close()` runs — previously
  the voice_inject listener leaked and the call was never consolidated into
  memory. The buffered transcript is also capped (drop-oldest 200).
- **A throwing `onDelta` handler no longer leaks the streaming reader** — the
  stream is cancelled before the error propagates.
- **`remember`'s `category` is now typed `'relationship' |
  'shared_experience'`** — the only two values the server keeps; anything
  else was silently discarded.
- **`/input` image constraints now error instead of silently dropping.** A
  non-`data:` URL or a 5th image is a 400 `invalid_request` — previously the
  image was filtered out and the model just never saw it.
- **OpenAPI drift**: `/input` documents `stream` + `turnId` + image rules;
  confirm 200 documents `exec_failed`/`outcome`/`retryable`; hello.ack lists
  `modalities`/`representative`/`visitorPaired`; `/pair` documents its body.

## [0.27.0] - 2026-07-12

### Added

- **`rate_limited` error code + `CompanionError.retryAfter`.** The runtime's
  two cost gates (per-user turn burst ceiling, demo daily budget) answer 429;
  those responses now carry `code: "rate_limited"`, so the typed-error contract
  finally covers the most common failure a busy integration hits. On any 429
  the thrown `CompanionError` also exposes `retryAfter` (seconds, from the
  body's `retryAfterSec` or the standard `Retry-After` header) so consumers can
  back off precisely:
  `if (e.code === 'rate_limited') await sleep(e.retryAfter * 1000)`.
- **OpenAPI now documents the 429s** (shared `RateLimited` response on every
  model-running path) and the `input` text cap below.

### Changed

- **`input` text is capped at 32,000 chars** (parity with `tool-result`); an
  over-limit turn returns 413 `payload_too_large` instead of riding a multi-MB
  string into the model context.
- Malformed streaming frames no longer throw raw `SyntaxError` out of
  `sendText`: a bad `delta` frame is skipped (the durable reply still lands),
  and a bad `done` frame fails the turn with a clean `CompanionError` (502).

### Fixed

- OpenAPI: `/api/companion/pair` no longer claims to deliver paired-friend
  messages (pairing only; sends happen inside companion turns).

## [0.26.1] - 2026-07-12

### Changed

- **Docs: `/api/stt` now requires auth.** The audio-transcription relay the
  `ingestKnowledge` guidance points at is no longer an open endpoint — send
  your `pchy_…` access token as the bearer (any valid token works; the call
  is metered like other companion ops). No SDK code paths changed; this
  release updates the inline JSDoc guidance only.
- Server-side hardening (no client change needed): concurrent `input` posts
  on one session now lose cleanly with the existing 409 `turn_pending` code
  instead of double-running a turn — retry after the in-flight reply lands.

## [0.26.0] - 2026-07-11

### Fixed

- **Voice-call connect failures now honor the typed-error contract.** The
  call helpers (`connectCall` / `startCall` / `openCompanionCall`) used to
  reject with plain `Error`s for the connect-step failures, so they carried
  no `.code` and couldn't be `switch`ed like every other SDK rejection.
  They now reject with `CompanionError` and three new client-synthesized
  codes: `call_unsupported` (non-browser environment — no WebRTC/mic),
  `call_connect_failed` (mic-permission timeout, SDP exchange failed or
  timed out; `status` carries the HTTP status when the exchange answered
  non-2xx), and `call_dependency_missing` (the optional
  `@elevenlabs/client` peer dependency isn't installed). Like
  `reply_timeout`/`stream_unauthorized` these are SDK-synthesized and
  deliberately NOT part of the server-mirrored `COMPANION_ERROR_CODES`
  HTTP vocabulary. Browser-native `getUserMedia` rejections (e.g.
  `NotAllowedError` on permission denial) still propagate untouched.
  `CompanionError` itself is unchanged and keeps its import path.

## [0.25.0] - 2026-07-11

### Added

- **`sendText(text, { awaitReply: true })` — await the reply as the return
  value.** Resolves with `{ seq, text, envelope }` (a new exported
  `SendTextReply` type) once the turn completes: `text` is the authoritative
  final assistant text (the same value `onMessage` receives), `envelope` the
  full `companion.message` envelope. Request/response hosts — a CLI REPL, an
  HTTP handler, a test — no longer hand-roll the resolve-on-message +
  timeout gate every integration was writing. It rides the same streaming
  request (the terminal `done` frame carries the envelope), so it works
  without `start()`; `onMessage`/`onDelta` subscribers still fire as usual.
  With `stream: false` (or an old server that answers plain JSON) it falls
  back to the event stream — which then does need `start()` — resolving with
  the next `companion.message` or rejecting with a client-side
  `CompanionError` `code: 'reply_timeout'` after `replyTimeoutMs`
  (default 60 000 ms).

### Docs

- OpenAPI spec corrections (served at `/api/companion/openapi.json`): the
  memory-write body field is `content` (was wrongly documented as `text`),
  knowledge ingest takes `name`/`kind`/`locale` (was `title`), the
  modalities enum is `text|voice|call|files` (`avatar` never existed),
  `GET …/confirm` (pending confirmations) is now documented, the session
  handshake body documents `handles`/`contextKinds`/`visitor`, and memory
  scopes use the real namespaced strings (`memory.read:app` …).
- The `/sdk` reference described the call handle as having `.end()` — the
  method is `close()`.

## [0.24.0] - 2026-07-11

### Added

- **`CompanionError.code` is now populated on EVERY helper, and the vocabulary
  ships in the package.** `COMPANION_ERROR_CODES` (+ the `CompanionErrorCode`
  type) is exported — the append-only HTTP `code` list the server emits
  (`missing_scope`, `session_not_found`, `turn_pending`, …), drift-tested
  against the server source. Previously only `postJson`-backed calls carried
  `code`; the GET/stream helpers (`recall`, `history`, `setModalities`,
  `ping`, `getAvatar`, `getWallet`, `pendingConfirms`, and `sendText`'s
  streaming fallback) constructed `CompanionError` without it, so a
  `missing_scope` 403 from `getWallet()` had `code === undefined`. All of them
  now thread the server's `code` through — switch on it instead of matching
  error prose.

### Changed (server-side, shipped with the platform)

- `GET …/history` now enforces the `chat` scope the API reference always
  documented (a token without `chat` gets a 403 `missing_scope` instead of the
  transcript).
- The `control.error` stream code `call_mint_failed` (voice-credential mint
  failure) is now documented everywhere the other stream codes are.

### Docs

- `/sdk` REST quickstart now shows the REAL session → input flow (the previous
  snippet showed a `POST /api/companion/message` endpoint that never existed)
  and the 0.23.0 `argsJson` tool-handler form.
- OpenAPI: `history` response shape corrected to `{ ok, count, history }`;
  `confirm` documents instance-token auth + the full
  `{ status, outcome?, retryable? }` response; `end` summary reflects memory
  consolidation.
- `wallet.read` scope comment no longer claims there is no standalone REST
  endpoint (`GET /api/companion/wallet` shipped in 0.20.0).

## [0.23.0] - 2026-07-11

### Added

- **`onToolCall` hands you pre-parsed arguments.** The callback now receives a
  `ToolCallEvent` — the `companion.tool_call` wire payload plus `argsJson`:
  `args` pre-parsed as JSON (`undefined` when empty or malformed). Every tool
  handler was re-implementing the same `try { JSON.parse(args) } catch {}`
  boilerplate; now `const { item } = call.argsJson as {...}` just works, and the
  raw `args` string is still there when you need the exact model output.
  `ToolCallEvent` is exported. Backwards-compatible (the type extends
  `ToolCallPayload`).

### Docs

- The `control.error` stream-plane code vocabulary (`agent_error`,
  `stream_unauthorized`) is now documented in the protocol/API references, the
  README event tables, and pouchy.ai/sdk — previously the codes were emitted
  but listed nowhere.
- The pouchy.ai/sdk CDN snippet now uses the canonical self-hosted bundle URL
  (`https://pouchy.ai/sdk/companion-sdk.js`) and includes the
  `@elevenlabs/client` import-map entry voice needs.
- `openCompanionCall` / `VoiceToolBridge` (the advanced voice primitive
  `connectCall`/`startCall` wrap) get a pointer in the package README.

No wire-protocol change (`PROTOCOL_VERSION` stays `1`).

## [0.22.0] - 2026-07-11

### Added

- **Runtime protocol constants are now exported.** `PROTOCOL_VERSION`,
  `INBOUND_TYPES`, and `OUTBOUND_TYPES` are re-exported from the package entry —
  previously only their *types* were reachable. A host can now assert the
  protocol version or enumerate/validate the event vocabulary at runtime without
  hard-coding the strings.
- **`ToolCallPayload` type.** The `companion.tool_call` payload
  (`{ id, name, args }`) now has a named, exported interface, so a consumer using
  the generic `on('companion.tool_call', …)` gets a typed payload — parity with
  `RenderInterfacePayload`, `ConfirmRequestPayload`, and the other payload types.
  `onToolCall`'s callback is typed against it.

No wire-protocol change (`PROTOCOL_VERSION` stays `1`); this is a purely additive
type/export surface, hence the minor bump.

## [0.21.0] - 2026-07-11

### Added

- **Token refresh:** `client.setToken(token)` swaps the bearer used by every
  subsequent request and stream reconnect, and the new `onAuthError` client
  option refreshes on demand — a 401 (REST **or** event stream) calls it; return
  a fresh token (e.g. re-minted via `POST /v1/sessions`) and the client retries
  transparently, return `null` to surface the failure exactly as before.
  Concurrent 401s share one refresh; the stream loop caps consecutive
  refresh-and-reject cycles at 3 so a bad hook can't hot-loop. Previously a
  1-hour session token expiring mid-embed silently stopped reply delivery
  (`stream_unauthorized`) with no recovery short of rebuilding the client.
- **Semantic recall:** `recall({ query })` — the server has always supported
  `?q=` semantic search over the token-visible facts; the client now exposes it.
  Without `query` the ranking is importance/recency, as before.
- **Machine-readable error codes:** companion API error responses now carry a
  stable `code` (`missing_token` / `invalid_token` / `missing_scope` /
  `invalid_request` / `session_not_found` / `turn_pending` / `no_pending_tools` /
  `unknown_call` / `payload_too_large` / `forbidden` / `unavailable`), so
  `CompanionError.code` is finally switchable for HTTP failures — the type
  always promised it; the server now delivers it. The vocabulary is append-only.

### Fixed

- README method table showed pre-0.18 signatures for `recall` / `remember` /
  `ingestKnowledge` / `history` (positional args instead of the actual object
  args). Corrected to match the shipped API.

## [0.20.0] - 2026-07-11

### Added

- `client.getWallet(): Promise<CompanionWallet>` and the `GET /api/companion/wallet`
  endpoint — read the companion instance's OWN wallet (nonzero stablecoin
  balances + total USD). Read-only and receive-only; requires the `wallet.read`
  scope (or `wallet.spend`, which subsumes it). Shares its read path with the
  conversational `get_wallet_balance` tool so the endpoint and the in-chat answer
  can never diverge. New exported types `CompanionWallet` / `WalletBalance`.

## [0.19.1] - 2026-07-10

### Documentation

- README gains a client-method reference table (memory ingest, history,
  setModalities, ping, pendingConfirms/confirmAction incl. the
  exec_failed/retryable retry contract, endSession skipped reasons, close(),
  startCall/connectCall, getAvatar/brandIconUrl) plus a note on
  HOST_CONTROL_TOOLS and AVATAR_VISUAL_TOOLS (declare-or-silent-no-op).
  No code change.

## [0.19.0] - 2026-07-10

### Added

- **Typed scope vocabulary exported from the package.** `COMPANION_SCOPES` /
  `CompanionScope`, `COMPANION_MODALITIES` / `CompanionModality`,
  `SENSITIVE_SCOPES`, `REPRESENT_SCOPES`, `DEFAULT_SCOPES`,
  `DEFAULT_MODALITIES`, and the helpers `isSensitiveScope()`,
  `isRepresentScope()`, `hasScope(granted, required)`. Configure keys, gate
  features on `hello.ack.grantedScopes`, and explain 403s with compile-checked
  scope strings instead of prose-copied literals. Mirrors the server vocabulary
  and is guarded by the same drift test as the wire protocol. Additive — no
  wire-protocol change.

## [0.18.1] - 2026-07-08

### Changed

- `confirmAction()` return type widened: `status` may now be `'exec_failed'` and
  the response carries an optional `retryable` flag. When an approved **idempotent**
  action (a wallet payment) fails on a transient error, the server no longer burns
  the confirmation — it returns `{ status: 'exec_failed', retryable: true }`, and
  you MAY re-call `confirmAction(sameConfirmId, true)` to re-run it (the server
  dedupes a partial first attempt on the confirmId). Non-idempotent actions (a
  friend message, a skill POST) stay strictly single-use and are never retryable.
  Additive + backwards-compatible: existing `'approved' | 'denied'` handling is
  unaffected. No wire-protocol change.

## [0.18.0] - 2026-07-08

### Added

- **`close()`** — one-call teardown for a non-voice (text) session: stops the
  event stream and calls `endSession()` once (idempotent). The text-path mirror
  of what a `connectCall()` handle's `close()` already does for voice.
- **`EndSessionResult`** type — exported. `endSession()` now RETURNS the server's
  consolidation diagnostic `{ ok, facts?, skipped? }` (or `null`) instead of
  discarding it, so an integrator whose memory write silently does nothing (wrong
  id → `skipped: 'no_session'`, empty transcript → `'no_content'`) gets the
  signal the server already provides. Backwards-compatible: callers ignoring the
  return value are unaffected (`connectCall`'s internal `void endSession()` too).
- **`CompanionError.code`** — an optional stable, machine-readable tag on the
  thrown error. Local precondition mistakes now throw a `CompanionError`
  (`status: 0`) instead of a bare `Error`, with codes `not_connected` (a method
  called before `connect()`), `missing_option` (no `baseUrl`/`token`), and
  `not_representative` (`pairVisitor` off a non-representative session); HTTP
  failures carry the server's error `code` when present. `catch (e) { if (e
  instanceof CompanionError) … }` — the natural one-error-type pattern — now
  catches the common first-run mistakes too.

### Changed

- `endSession(opts?)` return type: `Promise<void>` → `Promise<EndSessionResult | null>`.

## [0.17.0] - 2026-07-08

### Added

Surface completion — the last three wire types that lacked a typed
client accessor now have one. All additive; wire protocol unchanged
(`PROTOCOL_VERSION 1`).

- **`onVoiceInject(fn)`** + `VoiceInjectPayload { text, speak }` — a typed
  subscription for `companion.voice_inject` (a `voiceRelevant` world-state
  line the companion should say aloud during a live call). The event was
  already emitted; this is the public handler + payload type for it.
- **`client.setModalities(modalities)`** — change a live session's I/O
  modalities mid-session (e.g. toggle `voice`). Intersected with the
  token's granted modalities server-side; returns the effective set.
  Backed by `POST /api/companion/session/{id}/modalities`.
- **`client.ping()`** — keepalive that bumps the session's last-seen time
  so a long-idle embed stays "live" within the TTL (cross-app A2A friend
  messages keep reaching it). Backed by
  `POST /api/companion/session/{id}/ping`.

### Added

- **Conversation history** — `client.history({ limit })` fetches this session's
  recent turns (`{ user, assistant, ts }`, oldest→newest) so a reconnecting
  embed can restore its transcript. Distinct from `recall` (durable memory /
  facts): this is the raw exchange log. Reads the token's OWN session only
  (tenant-safe by construction — the turn log is keyed by the token's uid);
  internal per-turn trace (`meta`) is stripped server-side. `limit` defaults to
  20, capped at 50. New type `CompanionTurn`. Backed by
  `GET /api/companion/session/{sessionId}/history`. Wire protocol unchanged
  (`PROTOCOL_VERSION 1`) — this is a REST method, not a stream event.

### Added

- **Typing / activity indicator** — a new `companion.typing` event and a
  `client.onTyping(({ active }) => …)` convenience. The server emits
  `active:true` when a turn starts working and `active:false` when it emits
  its reply or pauses for an app tool result, so it spans the whole turn —
  including the tool-loop / thinking phase BEFORE the first text delta, which
  `onDelta` can't observe. Drive a "typing…" affordance uniformly across
  streaming and non-streaming replies. Additive and non-breaking (receivers
  ignore unknown types); the terminal `companion.message` stays authoritative.
  Wire protocol stays `PROTOCOL_VERSION 1` (an added outbound type is
  backwards-compatible). New payload type: `TypingPayload { active: boolean }`.

### Added

- **Opt-in voice barge-in** — `connectCall({ bargeIn: true })` keeps the
  microphone OPEN while the companion speaks, so the user can interrupt
  mid-utterance and both providers' native VAD interruption takes over
  (OpenAI Realtime server VAD; ElevenLabs Convai interruption handling).
  Default remains half-duplex (mic muted during agent speech): on phone
  speakers browser echo cancellation does not fully remove the companion's
  own voice, and it would barge in on itself. Enable only where AEC holds —
  headphones, desktop, or devices you have verified.
- The OpenAI Realtime microphone request now asks for explicit
  `echoCancellation` / `noiseSuppression` / `autoGainControl` processing
  constraints (harmless in half-duplex; required for barge-in to be viable).

No wire-protocol change (`PROTOCOL_VERSION` stays `1`).

## [0.13.0] - 2026-07-06

### Added

- **Token streaming** — the reply can now render as it is generated instead
  of arriving as one block. Register `onDelta((chunk, meta) => …)` and
  `sendText` automatically streams: the POST response itself carries SSE
  `delta` frames (raw text chunks), an optional `reset` (a hop that streamed
  text turned out to be tool-call deliberation — clear the partial render),
  and a final `done` frame. `onMessage` still fires exactly ONCE with the
  authoritative final text (the done frame's envelope is emitted locally and
  the event-stream replay of the same envelope id is deduplicated), so
  existing render code keeps working unchanged — deltas are a pure add-on.
  Pass `sendText(text, { stream: false })` to force the buffered path, or
  `{ stream: true }` to stream without a subscriber. Requires a server with
  streaming input support; on older servers the SDK transparently falls back
  to the buffered response. No wire-protocol (envelope) change.

## [0.12.1] - 2026-07-05

### Added

- **`confirmAction` now returns the action's `outcome` text** alongside
  `status`. The surface that clicked Approve renders the result immediately
  instead of racing the stream copy (which still arrives as a normal
  `companion.message`) — a field round caught an approved skill run whose
  stream frame missed a 30s window while the server-side audit proved the
  call executed. Additive; no wire-protocol change.

## [0.12.0] - 2026-07-05

### Added

- **`confirmAction(confirmId, approve)`** — resolve a pending sensitive-op
  confirmation from the embed. Platform **session tokens** only (end-user
  instances minted via `/v1/sessions`): the embedding app's end user is that
  account's only human, so the session may approve. First-party user tokens
  remain observe-only (401) — approval of a real Pouchy user's ops stays on
  their first-party surface with its biometric step-up. On approve the
  recorded action runs server-side and its outcome arrives as a normal
  `companion.message`. This is the run path for the platform's new
  **confirm-gated custom skills** (POST / credentialed tools, keys served
  from the project credential vault).
- **`pendingConfirms()`** — the session's still-pending confirmations
  (display-safe: `confirmId`, `scope`, `summary`, `createdAt`), for rebuilding
  a confirm card after a reload. Same session-token-only auth.
- **`PendingConfirm`** type export; `ConfirmRequestPayload` /
  `onConfirmRequest` docs updated for the two-token model. No wire-protocol
  change (`PROTOCOL_VERSION` stays `1`); additive surface.

## [0.11.0] - 2026-07-02

### Added

- **`AVATAR_VISUAL_TOOLS` / `AVATAR_VISUAL_TOOL_NAMES` are now exported from the
  package entry point.** `docs/companion-host-control.md` has documented these
  since the avatar-emote work, but the public `index.ts` never re-exported them —
  hosts that wanted to filter or inspect the avatar visual tool set had to
  reach into internal module paths. Additive; no behavior change.

### Docs

- The protocol document now reflects the shipped transport reality: REST + SSE
  is the live control/data plane, `stream: 'websocket'` is a client-side option
  that automatically falls back to SSE (the server WebSocket endpoint is not
  yet available), and the server agent loop is live. The voice and poker
  integration guides now install the SDK from npm instead of a local build /
  bundled tarball.

## [0.10.1] - 2026-06-29

### Fixed

- **Voice calls no longer drop on a transient network blip.** A WebRTC
  `connectionState` of `disconnected` is transient per spec — a brief Wi-Fi
  hiccup or cell handoff routinely hits it and recovers to `connected` on its
  own. `connectCall()` previously tore the call down the instant `disconnected`
  appeared, killing calls that would have healed. It now holds a short grace
  window (6s) and only tears down if the connection is still `disconnected` when
  it elapses; recovery to `connected` cancels the teardown. `failed` / `closed`
  remain immediately fatal, and a genuine silent drop still tears down (so the
  mic can't keep recording). No surface change — `connectCall` signature,
  events, payloads, and scopes are unchanged.

## [0.10.0] - 2026-06-28

Completes the SDK surface for all six companion capabilities, with Instant UI and
cross-app A2A added. All changes are **additive** (new event types are ignored by
older receivers — `PROTOCOL_VERSION` stays `1`).

### Added

- **Instant UI.** The companion can render an interactive panel on the host:
  `companion.ui_action` (`onRender`) carries a platform-neutral genui interface
  (`{ title?, nodes, state?, computed?, reportChanges? }`), and
  `companion.ui_update` (`onInterfaceUpdate`) applies live `{key,value}` writes to
  an already-rendered panel. Gated by the new `ui.render` scope. The same payload
  renders on web, native iOS/Android, or a CLI — reference renderers in
  `examples/web-instant-ui`, `examples/native-instant-ui` (SwiftUI + Jetpack
  Compose). New types `RenderInterfacePayload` / `InterfaceUpdatePayload`. See
  `docs/companion-instant-ui.md`.
- **Cross-app A2A.** `companion.social_message` (`onSocialMessage`) delivers an
  inbound A2A friend message to any embed whose token holds `social.message`, not
  just the first-party app. New type `SocialMessagePayload`.
- **Confirmation observation + biometric cue.** `onConfirmRequest`
  (`companion.confirm_request`) lets an embed surface a pending sensitive-op
  approval (observe-only; approval stays first-party). The payload now carries an
  advisory `stepUp` flag (true for money ops) so a first-party surface can gate
  approval behind a passkey / Face ID. New type `ConfirmRequestPayload`.
- **Typed event helpers** for the remaining outbound events: `onAudio`
  (`companion.audio`), `onExpression` (`companion.expression`), `onUsage`
  (`control.usage`). New types `AudioClipPayload` / `ExpressionPayload` /
  `UsagePayload`.
- **Capabilities map** doc (`docs/companion-capabilities.md`) — the six
  capabilities, their SDK surface, and the beyond-the-sandbox model.

### Protocol

- Outbound types added: `companion.ui_action`* / `companion.ui_update` /
  `companion.social_message`. (*`ui_action` was reserved previously; now emitted.)
  Non-breaking — receivers ignore unknown types; `PROTOCOL_VERSION` unchanged (`1`).

## [0.9.0] - 2026-06-23

First publicly published release (public npm, `publishConfig.access: "public"`,
proprietary license — see `LICENSE`). No code changes versus the previously
self-hosted/CDN build; this entry captures the surface that ships publicly.

### Added

- **Public distribution.** Installable from the public npm registry as
  `@pouchy_ai/companion-sdk`; also resolvable via `esm.sh` / `jsDelivr` for
  no-bundler / import-map consumers, and self-hosted at
  `https://pouchy.ai/sdk/companion-sdk.js`.
- **Chat.** Text and multimodal (text + images) messaging.
- **Voice.** Live WebRTC call (`connectCall`), provider-agnostic across OpenAI
  Realtime (zero extra deps) and ElevenLabs Convai (optional
  `@elevenlabs/client` peer dependency).
- **Tools.** Bidirectional tool calling — the companion invokes app-declared
  actions and the app reports results back.
- **Memory.** Scope-gated read/write into the user's recall namespace.
- **Live world-state.** CloudEvents-shaped "play-along" channel
  (`sendWorldState`) for streaming contextual data to the companion.
- **Representative mode (代聊).** Visitor-facing sessions answered on the owner's
  behalf using only screened context (`represent` scope + optional
  `expose:*` / `represent:*` scopes).
- **Avatar.** VRM (3D) and 2D portrait retrieval.
- **Transports.** REST + Server-Sent Events by default; optional WebSocket
  (`stream: 'websocket'`) with automatic SSE fallback.
- **Auth.** Personal Access Tokens (`pchy_…`) and "Login with Pouchy" OAuth 2.1
  (Authorization Code + PKCE, rotating refresh tokens).
- **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
  server by `protocol.drift.test.ts` (fails CI on divergence).

[Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.37.1...HEAD
[0.37.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.37.0...companion-sdk-v0.37.1
[0.37.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.36.0...companion-sdk-v0.37.0
[0.36.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.35.0...companion-sdk-v0.36.0
[0.35.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.34.0...companion-sdk-v0.35.0
[0.34.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.33.0...companion-sdk-v0.34.0
[0.33.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.32.1...companion-sdk-v0.33.0
[0.32.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.32.0...companion-sdk-v0.32.1
[0.32.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.31.0...companion-sdk-v0.32.0
[0.31.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.30.0...companion-sdk-v0.31.0
[0.30.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.1...companion-sdk-v0.30.0
[0.29.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.29.0...companion-sdk-v0.29.1
[0.29.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.1...companion-sdk-v0.29.0
[0.28.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.28.0...companion-sdk-v0.28.1
[0.28.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.27.0...companion-sdk-v0.28.0
[0.27.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.26.1...companion-sdk-v0.27.0
[0.26.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.26.0...companion-sdk-v0.26.1
[0.26.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.25.0...companion-sdk-v0.26.0
[0.25.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.24.0...companion-sdk-v0.25.0
[0.24.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.23.0...companion-sdk-v0.24.0
[0.23.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.22.0...companion-sdk-v0.23.0
[0.22.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.21.0...companion-sdk-v0.22.0
[0.21.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.20.0...companion-sdk-v0.21.0
[0.20.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.19.1...companion-sdk-v0.20.0
[0.19.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.19.0...companion-sdk-v0.19.1
[0.19.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.18.1...companion-sdk-v0.19.0
[0.18.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.18.0...companion-sdk-v0.18.1
[0.18.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.17.0...companion-sdk-v0.18.0
[0.17.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.13.0...companion-sdk-v0.17.0
[0.13.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.1...companion-sdk-v0.13.0
[0.12.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...companion-sdk-v0.12.1
[0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
[0.11.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.1...companion-sdk-v0.11.0
[0.10.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.0...companion-sdk-v0.10.1
[0.10.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.9.0...companion-sdk-v0.10.0
[0.9.0]: https://github.com/oviswang/Pouchy/releases/tag/companion-sdk-v0.9.0
