@pouchy_ai/companion-sdk 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,35 @@ a protocol bump is always called out explicitly here.
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.13.0] - 2026-07-06
16
+
17
+ ### Added
18
+
19
+ - **Token streaming** — the reply can now render as it is generated instead
20
+ of arriving as one block. Register `onDelta((chunk, meta) => …)` and
21
+ `sendText` automatically streams: the POST response itself carries SSE
22
+ `delta` frames (raw text chunks), an optional `reset` (a hop that streamed
23
+ text turned out to be tool-call deliberation — clear the partial render),
24
+ and a final `done` frame. `onMessage` still fires exactly ONCE with the
25
+ authoritative final text (the done frame's envelope is emitted locally and
26
+ the event-stream replay of the same envelope id is deduplicated), so
27
+ existing render code keeps working unchanged — deltas are a pure add-on.
28
+ Pass `sendText(text, { stream: false })` to force the buffered path, or
29
+ `{ stream: true }` to stream without a subscriber. Requires a server with
30
+ streaming input support; on older servers the SDK transparently falls back
31
+ to the buffered response. No wire-protocol (envelope) change.
32
+
33
+ ## [0.12.1] - 2026-07-05
34
+
35
+ ### Added
36
+
37
+ - **`confirmAction` now returns the action's `outcome` text** alongside
38
+ `status`. The surface that clicked Approve renders the result immediately
39
+ instead of racing the stream copy (which still arrives as a normal
40
+ `companion.message`) — a field round caught an approved skill run whose
41
+ stream frame missed a 30s window while the server-side audit proved the
42
+ call executed. Additive; no wire-protocol change.
43
+
15
44
  ## [0.12.0] - 2026-07-05
16
45
 
17
46
  ### Added
@@ -116,7 +145,7 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
116
145
  - **Public distribution.** Installable from the public npm registry as
117
146
  `@pouchy_ai/companion-sdk`; also resolvable via `esm.sh` / `jsDelivr` for
118
147
  no-bundler / import-map consumers, and self-hosted at
119
- `https://www.pouchy.ai/sdk/companion-sdk.js`.
148
+ `https://pouchy.ai/sdk/companion-sdk.js`.
120
149
  - **Chat.** Text and multimodal (text + images) messaging.
121
150
  - **Voice.** Live WebRTC call (`connectCall`), provider-agnostic across OpenAI
122
151
  Realtime (zero extra deps) and ElevenLabs Convai (optional
@@ -137,7 +166,8 @@ self-hosted/CDN build; this entry captures the surface that ships publicly.
137
166
  - **Versioned wire protocol.** `PROTOCOL_VERSION = 1`, kept in lockstep with the
138
167
  server by `protocol.drift.test.ts` (fails CI on divergence).
139
168
 
140
- [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...HEAD
169
+ [Unreleased]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.1...HEAD
170
+ [0.12.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.12.0...companion-sdk-v0.12.1
141
171
  [0.12.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.11.0...companion-sdk-v0.12.0
142
172
  [0.11.0]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.1...companion-sdk-v0.11.0
143
173
  [0.10.1]: https://github.com/oviswang/Pouchy/compare/companion-sdk-v0.10.0...companion-sdk-v0.10.1
package/README.md CHANGED
@@ -18,10 +18,26 @@ No bundler? Pull it from a CDN via an import map instead — see
18
18
  [`docs/companion-integration-faq.md`](../../docs/companion-integration-faq.md).
19
19
  Changes per release are tracked in [`CHANGELOG.md`](./CHANGELOG.md).
20
20
 
21
+ ## Quickstart
22
+
23
+ Two steps. **1)** Your backend exchanges the project **Secret Key** (create one
24
+ in the [dashboard](https://pouchy.ai/dashboard) → Keys) for a per-user
25
+ session token — first-seen `external_user_id`s are provisioned automatically:
26
+
27
+ ```http
28
+ POST https://pouchy.ai/v1/sessions
29
+ Authorization: Bearer pchy_sk_…
30
+ { "agent": "<agentId>", "external_user_id": "user_4211" }
31
+
32
+ → { "session_token": "pchy_…", "expires_in": 3600, "instance": { … } }
33
+ ```
34
+
35
+ **2)** The client connects with the session token (never ship the Secret Key):
36
+
21
37
  ```ts
22
38
  import { createCompanion } from '@pouchy_ai/companion-sdk';
23
39
 
24
- const companion = createCompanion({ baseUrl: 'https://www.pouchy.ai', token: PAT });
40
+ const companion = createCompanion({ baseUrl: 'https://pouchy.ai', token: sessionToken });
25
41
  await companion.connect();
26
42
  companion.onMessage((t) => console.log(t));
27
43
  companion.start();
@@ -30,6 +46,9 @@ await companion.connectCall(); // live voice (browser)
30
46
  companion.sendWorldState({ type: 'game.player.hp', data: { hp: 12 }, retained: true });
31
47
  ```
32
48
 
49
+ (Personal/single-user integrations can pass a server-minted Personal Access
50
+ Token as `token` instead — same API. See the docs' authentication section.)
51
+
33
52
  - **Guide:** [`docs/companion-quickstart.md`](../../docs/companion-quickstart.md)
34
53
  - **Spec:** [`docs/companion-sdk-protocol.md`](../../docs/companion-sdk-protocol.md)
35
54
  - **API reference:** [`docs/companion-api-reference.md`](../../docs/companion-api-reference.md)
@@ -42,7 +61,8 @@ Subscribe with `on(type, fn)` (or `'*'`), or these typed convenience helpers:
42
61
 
43
62
  | Helper | Event | Use |
44
63
  | --- | --- | --- |
45
- | `onMessage(fn)` | `companion.message` | streamed assistant text |
64
+ | `onMessage(fn)` | `companion.message` | the assistant reply (fires exactly once per turn, streaming or not) |
65
+ | `onDelta(fn)` | *(POST-response SSE)* | **token streaming** — registering makes `sendText` stream the reply as it is generated: `fn(chunk, {reset?})` fires per text chunk (`reset` = clear the partial — that text was tool-call deliberation); `onMessage` then fires once with the authoritative final text (replay-deduplicated). Force per call with `sendText(text, { stream: true \| false })` |
46
66
  | `onToolCall(fn)` | `companion.tool_call` | the companion asks your app to run a declared tool → `sendToolResult` |
47
67
  | `onRender(fn)` | `companion.ui_action` | **Instant UI** — draw `payload.interface` (platform-neutral genui schema) with your own renderer (web / iOS / Android / CLI). Needs `ui.render` |
48
68
  | `onInterfaceUpdate(fn)` | `companion.ui_update` | live `{key,value}` update to an already-rendered panel (no rebuild) |
@@ -75,7 +95,7 @@ memory, system prompt, or PII. Works over text and voice.
75
95
 
76
96
  ```ts
77
97
  const c = createCompanion({
78
- baseUrl: 'https://www.pouchy.ai',
98
+ baseUrl: 'https://pouchy.ai',
79
99
  token: OWNER_PAT, // must hold the `represent` scope
80
100
  surface: 'support-widget',
81
101
  appContext: { name: 'AcmeShop', description: 'Order support' },
package/dist/client.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { AudioClipPayload, CompanionEnvelope, ConfirmRequestPayload, Expres
4
4
  export type WorldStateInput<D = unknown> = Omit<WorldStateEvent<D>, 'specversion' | 'id' | 'source'> & Partial<Pick<WorldStateEvent<D>, 'specversion' | 'id' | 'source'>>;
5
5
  import { type CompanionCall, type CompanionCallOptions } from './call.js';
6
6
  export interface CompanionClientOptions {
7
- /** Origin of the Pouchy deployment, e.g. "https://www.pouchy.ai". */
7
+ /** Origin of the Pouchy deployment, e.g. "https://pouchy.ai". */
8
8
  baseUrl: string;
9
9
  /** A Pouchy access token (PAT). */
10
10
  token: string;
@@ -126,6 +126,15 @@ export declare class CompanionError extends Error {
126
126
  constructor(message: string, status: number);
127
127
  }
128
128
  type Handler = (envelope: CompanionEnvelope) => void;
129
+ /** Token-streaming subscriber (P1-2). `chunk` is a raw text delta of the reply
130
+ * being generated; `meta.reset` (with an empty chunk) tells the renderer to
131
+ * CLEAR the partial text (a hop that streamed text turned out to be tool-call
132
+ * deliberation, not the reply). Deltas are advisory — the final
133
+ * companion.message (fired through onMessage as usual) is authoritative and
134
+ * should replace whatever the deltas rendered. */
135
+ type DeltaHandler = (chunk: string, meta: {
136
+ reset?: boolean;
137
+ }) => void;
129
138
  export declare class CompanionClient {
130
139
  private readonly opts;
131
140
  private readonly doFetch;
@@ -135,6 +144,7 @@ export declare class CompanionClient {
135
144
  private abort;
136
145
  private readonly handlers;
137
146
  private readonly seen;
147
+ private readonly deltaHandlers;
138
148
  private readonly pendingVoiceTools;
139
149
  constructor(opts: CompanionClientOptions);
140
150
  /** The active session id, or null before connect(). */
@@ -158,12 +168,31 @@ export declare class CompanionClient {
158
168
  }>;
159
169
  /** Send a user text turn, optionally with images (data URLs) for a multimodal
160
170
  * turn — images need the `files` scope. The reply arrives on the event stream
161
- * as a `companion.message` — subscribe via onMessage()/start() to receive it. */
171
+ * as a `companion.message` — subscribe via onMessage()/start() to receive it.
172
+ *
173
+ * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
174
+ * `opts.stream` is true), the reply streams token-by-token on this very
175
+ * request — delta subscribers fire as chunks arrive, and onMessage still
176
+ * fires exactly ONCE with the authoritative final text (the streamed copy
177
+ * is emitted locally; the event-stream replay of the same envelope is
178
+ * deduplicated by id). Pass `stream: false` to force the buffered path. */
162
179
  sendText(text: string, opts?: {
163
180
  images?: string[];
181
+ stream?: boolean;
164
182
  }): Promise<{
165
183
  seq: number | null;
166
184
  }>;
185
+ /** The streaming leg of sendText: parse the POST response's SSE frames —
186
+ * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
187
+ * final envelope locally so onMessage fires immediately and the log replay
188
+ * dedups by id). Falls back to buffered semantics if the server ever
189
+ * responds with plain JSON (e.g. an old deployment). */
190
+ private sendTextStreaming;
191
+ /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
192
+ * subscriber makes sendText stream automatically. Returns an unsubscribe
193
+ * function. Render chunks as they arrive; on `meta.reset` clear the partial;
194
+ * when onMessage fires, replace the partial with the final text. */
195
+ onDelta(handler: DeltaHandler): () => void;
167
196
  /** Push live world-state — a single event or a batch. Retained events
168
197
  * (retained:true) coalesce per kind; transient ones carry a salience.
169
198
  *
@@ -349,12 +378,15 @@ export declare class CompanionClient {
349
378
  /** Resolve a pending confirmation (platform session tokens only — see
350
379
  * `onConfirmRequest`). Show the user what they're approving (the event's
351
380
  * `summary`), collect an explicit tap, then pass their decision here. On
352
- * approval the recorded action runs server-side and its outcome arrives as
353
- * a normal companion.message; a denial emits a brief decline. One-time:
381
+ * approval the recorded action runs server-side; its result comes back as
382
+ * `outcome` in this response (render it where the user tapped) AND as a
383
+ * normal companion.message on the stream (so the conversation shows it).
384
+ * A denial returns/emits a brief decline the same way. One-time:
354
385
  * re-resolving a settled confirmId fails with 409. */
355
386
  confirmAction(confirmId: string, approve: boolean): Promise<{
356
387
  ok: boolean;
357
388
  status: 'approved' | 'denied';
389
+ outcome?: string;
358
390
  }>;
359
391
  /** The session's still-pending confirmations (display-safe: id, summary,
360
392
  * scope, timestamps — never raw args). Use it to rebuild your confirm card
package/dist/client.js CHANGED
@@ -37,6 +37,9 @@ export class CompanionClient {
37
37
  abort = null;
38
38
  handlers = new Map();
39
39
  seen = new Set();
40
+ // Token-streaming delta subscribers (P1-2). When any are registered,
41
+ // sendText automatically streams the reply.
42
+ deltaHandlers = new Set();
40
43
  // Voice tool-calls awaiting the app's sendToolResult, keyed by a synthetic id.
41
44
  pendingVoiceTools = new Map();
42
45
  constructor(opts) {
@@ -115,11 +118,98 @@ export class CompanionClient {
115
118
  }
116
119
  /** Send a user text turn, optionally with images (data URLs) for a multimodal
117
120
  * turn — images need the `files` scope. The reply arrives on the event stream
118
- * as a `companion.message` — subscribe via onMessage()/start() to receive it. */
121
+ * as a `companion.message` — subscribe via onMessage()/start() to receive it.
122
+ *
123
+ * TOKEN STREAMING (P1-2): when any onDelta() subscriber is registered (or
124
+ * `opts.stream` is true), the reply streams token-by-token on this very
125
+ * request — delta subscribers fire as chunks arrive, and onMessage still
126
+ * fires exactly ONCE with the authoritative final text (the streamed copy
127
+ * is emitted locally; the event-stream replay of the same envelope is
128
+ * deduplicated by id). Pass `stream: false` to force the buffered path. */
119
129
  async sendText(text, opts) {
120
130
  const id = this.requireSession();
121
- const json = await this.postJson(`/api/companion/session/${id}/input`, { text, ...(opts?.images?.length ? { images: opts.images } : {}) });
122
- return { seq: json.seq ?? null };
131
+ const wantStream = opts?.stream ?? this.deltaHandlers.size > 0;
132
+ const body = { text, ...(opts?.images?.length ? { images: opts.images } : {}) };
133
+ if (!wantStream) {
134
+ const json = await this.postJson(`/api/companion/session/${id}/input`, body);
135
+ return { seq: json.seq ?? null };
136
+ }
137
+ return this.sendTextStreaming(id, body);
138
+ }
139
+ /** The streaming leg of sendText: parse the POST response's SSE frames —
140
+ * `delta` / `reset` → delta subscribers, `done` → resolve (emitting the
141
+ * final envelope locally so onMessage fires immediately and the log replay
142
+ * dedups by id). Falls back to buffered semantics if the server ever
143
+ * responds with plain JSON (e.g. an old deployment). */
144
+ async sendTextStreaming(sessionId, body) {
145
+ const res = await this.doFetch(this.url(`/api/companion/session/${sessionId}/input`), {
146
+ method: 'POST',
147
+ headers: this.jsonHeaders(),
148
+ body: JSON.stringify({ ...body, stream: true })
149
+ });
150
+ const ctype = res.headers.get('content-type') ?? '';
151
+ if (!ctype.includes('text/event-stream')) {
152
+ // Old server / error before the stream opened — behave like postJson.
153
+ const json = (await res.json().catch(() => null));
154
+ if (!res.ok || !json || json.ok === false) {
155
+ throw new CompanionError(json?.error || `request failed (${res.status})`, res.status);
156
+ }
157
+ return { seq: json.seq ?? null };
158
+ }
159
+ if (!res.body)
160
+ throw new CompanionError('streaming response has no body', 502);
161
+ const reader = res.body.getReader();
162
+ const decoder = new TextDecoder();
163
+ let buf = '';
164
+ const fireDelta = (chunk, meta) => {
165
+ for (const h of this.deltaHandlers)
166
+ h(chunk, meta);
167
+ };
168
+ for (;;) {
169
+ const { done, value } = await reader.read();
170
+ if (value)
171
+ buf += decoder.decode(value, { stream: true });
172
+ const { events, rest } = parseSse(buf);
173
+ buf = rest;
174
+ for (const ev of events) {
175
+ if (ev.event === 'delta') {
176
+ const d = JSON.parse(ev.data);
177
+ if (typeof d.text === 'string' && d.text)
178
+ fireDelta(d.text, {});
179
+ }
180
+ else if (ev.event === 'reset') {
181
+ fireDelta('', { reset: true });
182
+ }
183
+ else if (ev.event === 'done') {
184
+ const d = JSON.parse(ev.data);
185
+ try {
186
+ await reader.cancel();
187
+ }
188
+ catch {
189
+ /* stream already finished */
190
+ }
191
+ if (d.ok === false) {
192
+ throw new CompanionError(d.error || 'turn failed', d.status ?? 500);
193
+ }
194
+ // Emit the final envelope NOW so onMessage fires with zero poll
195
+ // latency; the event-stream replay of the same id is deduped.
196
+ if (d.envelope && typeof d.envelope === 'object')
197
+ this.emit(d.envelope);
198
+ return { seq: d.seq ?? null };
199
+ }
200
+ }
201
+ if (done)
202
+ break;
203
+ }
204
+ throw new CompanionError('stream ended without a done frame', 502);
205
+ }
206
+ /** Subscribe to token-streaming deltas of the reply (P1-2). Registering a
207
+ * subscriber makes sendText stream automatically. Returns an unsubscribe
208
+ * function. Render chunks as they arrive; on `meta.reset` clear the partial;
209
+ * when onMessage fires, replace the partial with the final text. */
210
+ onDelta(handler) {
211
+ this.deltaHandlers.add(handler);
212
+ return () => this.deltaHandlers.delete(handler);
123
213
  }
124
214
  /** Push live world-state — a single event or a batch. Retained events
125
215
  * (retained:true) coalesce per kind; transient ones carry a salience.
@@ -511,8 +601,10 @@ export class CompanionClient {
511
601
  /** Resolve a pending confirmation (platform session tokens only — see
512
602
  * `onConfirmRequest`). Show the user what they're approving (the event's
513
603
  * `summary`), collect an explicit tap, then pass their decision here. On
514
- * approval the recorded action runs server-side and its outcome arrives as
515
- * a normal companion.message; a denial emits a brief decline. One-time:
604
+ * approval the recorded action runs server-side; its result comes back as
605
+ * `outcome` in this response (render it where the user tapped) AND as a
606
+ * normal companion.message on the stream (so the conversation shows it).
607
+ * A denial returns/emits a brief decline the same way. One-time:
516
608
  * re-resolving a settled confirmId fails with 409. */
517
609
  async confirmAction(confirmId, approve) {
518
610
  const sessionId = this.requireSession();
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // surface (web / app / game / hardware) over the REST/SSE plane.
3
3
  //
4
4
  // import { createCompanion } from '$lib/companion-sdk';
5
- // const c = createCompanion({ baseUrl: 'https://www.pouchy.ai', token: PAT });
5
+ // const c = createCompanion({ baseUrl: 'https://pouchy.ai', token: PAT });
6
6
  // await c.connect();
7
7
  // c.onMessage((text) => console.log(text));
8
8
  // c.start();
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@pouchy_ai/companion-sdk",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Embed the Pouchy companion — chat, voice, tools, memory, live world-state, instant UI, and agent-to-agent messaging — in any app, game, or site.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
7
- "homepage": "https://www.pouchy.ai",
7
+ "homepage": "https://pouchy.ai",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "https://github.com/oviswang/Pouchy.git",