@zoowork-ai/sdk 0.4.2 → 0.5.1

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
@@ -3,6 +3,72 @@
3
3
  All notable changes to `@zoowork-ai/sdk` (formerly `@zooclaw-agents/sdk`). Dates are the
4
4
  day the behaviour was verified, not the day it was written.
5
5
 
6
+ ## 0.5.1 — 2026-08-31
7
+
8
+ ### Added
9
+
10
+ - **`ZooworkError` keeps the evidence: `contentType`, `bodySnippet`, `cfRay`, `requestId`,
11
+ `retryable`.** A production `postEvents` failure surfaced as `HTTP 502, type: undefined`
12
+ and cost the reporter a day of black-box contrast experiments (2026-08-30,
13
+ `notes/probes/system-message-cold-session-probe.mts`): the edge replaces an origin 502/504
14
+ body wholesale with a branded `text/html` page, so no JSON envelope ever reaches the SDK —
15
+ and 0.5.0 then dropped the only three facts that survived. Now every transport error keeps
16
+ the response `Content-Type`, the first 600 characters of the raw body, and the `cf-ray`
17
+ header (present on JSON errors too, verified 2026-08-31) — the id to quote when reporting
18
+ a gateway failure. `requestId` reads `request_id` from either error envelope once the
19
+ server starts sending one; today it is usually absent. `retryable` is a transport-class
20
+ hint (`408/429/502/503/504`): it says the failure class tends to pass, not that a replay
21
+ is safe — pair it with `idempotency_key` before looping on it.
22
+ SDK-synthesized wait timeouts keep `retryable: false`: they report that the caller's own
23
+ polling budget expired, not that an HTTP 408 came back from the service.
24
+
25
+ ### Changed
26
+
27
+ - **The fallback error message names what actually came back.** A non-JSON error body used
28
+ to read a bare `HTTP 502`; it now reads
29
+ `HTTP 502 (text/html; charset=UTF-8) [cf-ray a338c539…]`. Messages parsed from a server
30
+ envelope are unchanged — keep matching on `type`/`status`, never on message text.
31
+ - **`streamEvents` raises the same enriched envelope** on a non-ok response instead of the
32
+ bare `events stream HTTP <status>` string, so SSE failures are diagnosable the same way.
33
+ - **A structured `detail` object no longer stringifies into the message.** The agents-family
34
+ envelope may carry `detail` as an object; it previously became the literal message
35
+ `[object Object]`, now it falls through to the status line and stays readable in
36
+ `bodySnippet`.
37
+
38
+ ## 0.5.0 — 2026-08-28
39
+
40
+ ### Added
41
+
42
+ - **The QR flow now covers WeCom and WeChat, not just Feishu** — gateway PR #3512 shipped
43
+ `/channels/{wecom,weixin}/{setup,poll,setup-cancel}`, and 0.4.x had no way to call them.
44
+ Four platform-taking methods replace the four Feishu-only ones:
45
+ `startChannelSetup(agentId, platform, input?)`, `pollChannelSetup`, `cancelChannelSetup`,
46
+ `waitForChannelSetup`. New types `ChannelSetupInput`, `ChannelSetupSession`,
47
+ `ChannelPollResult`, `GuidedSetupPlatform` (`'feishu' | 'wecom' | 'weixin'`) and
48
+ `AddChannelPlatform`.
49
+ - **`startFeishuSetup` / `pollFeishuSetup` / `cancelFeishuSetup` / `waitForFeishuSetup` still
50
+ work** — they now delegate to the platform-taking versions. `FeishuSetupInput` and
51
+ `FeishuPollResult` are aliases of the new names; `FeishuSetupSession` narrows
52
+ `ChannelSetupSession` to the one platform that always answers `verification_uri_complete`.
53
+ Only the message text of a thrown timeout/abort changed (it names `waitForChannelSetup` and
54
+ the platform); `status` and `type` are unchanged.
55
+
56
+ ### Documentation
57
+
58
+ - **`ChannelPlatform` gains `'weixin'`, and WeChat is no longer described as unbindable.**
59
+ 0.3.2–0.4.2 said WeChat "answers `400 channel.weixin_setup_required`, naming a QR flow this
60
+ API does not expose". The flow exists now; that error is a signpost to it, not a dead end.
61
+ `addChannel` still refuses WeChat, so `AddChannelPlatform` is the type that lists what it
62
+ takes.
63
+ - **Per-platform shapes, staging-verified 2026-08-28** (`notes/probes/channels-guided-probe.mts`):
64
+ Feishu answers `verification_uri_complete` + `poll_interval: 5`, `expires_in: 600`; WeCom and
65
+ WeChat answer `qrcode_url` with no `poll_interval` and `expires_in: 300`; WeChat's
66
+ `qrcode_url` may be an inline `data:image/…` payload rather than a URL. WeChat reads only
67
+ `dm_policy` and only `'open'`/`'disabled'` — `'allowlist'` is
68
+ `400 channel.allowlist_unsupported` — pins the account to `'default'`, and ignores anything
69
+ else in the body. Cancelled sessions 404 per platform:
70
+ `channel.{feishu,wecom,weixin}_session_not_found`.
71
+
6
72
  ## 0.4.2 — 2026-08-25
7
73
 
8
74
  ### Documentation
package/dist/client.d.ts CHANGED
@@ -55,7 +55,39 @@ export declare class ZooworkError extends Error {
55
55
  * you only need the class of failure.
56
56
  */
57
57
  type?: string;
58
- constructor(status: number, message: string, type?: string);
58
+ /**
59
+ * `Content-Type` of the error response. The field that tells an edge error page apart from an
60
+ * API answer: both envelopes above are `application/json`, while a gateway 502/504 arrives as
61
+ * `text/html` — the edge replaces the origin's body wholesale, so no JSON survives to parse
62
+ * (verified 2026-08-30 against both deployments;
63
+ * `notes/probes/system-message-cold-session-probe.mts`).
64
+ */
65
+ contentType?: string;
66
+ /** First {@link BODY_SNIPPET_LIMIT} characters of the raw error body, whatever it was.
67
+ * Without it a non-JSON failure cannot be reconstructed from `status` alone. */
68
+ bodySnippet?: string;
69
+ /**
70
+ * Cloudflare ray id (`cf-ray` response header), when the response crossed Cloudflare. It
71
+ * survives even the replaced-body case above — on an HTML 502 it is the only correlation id
72
+ * left, and the value to quote when reporting a gateway failure.
73
+ */
74
+ cfRay?: string;
75
+ /** Correlation id from the error envelope (`request_id` on either vocabulary), when the
76
+ * server includes one. Usually absent today. */
77
+ requestId?: string;
78
+ /**
79
+ * Transport-class transient hint: `true` for 408, 429, 502, 503 and 504. It says the failure
80
+ * CLASS tends to pass, not that a replay is safe — retrying a `postEvents` without an
81
+ * `idempotency_key` can still deliver twice. Pair it with idempotency keys before looping.
82
+ */
83
+ retryable: boolean;
84
+ constructor(status: number, message: string, type?: string, extra?: {
85
+ contentType?: string;
86
+ bodySnippet?: string;
87
+ cfRay?: string;
88
+ requestId?: string;
89
+ retryable?: boolean;
90
+ });
59
91
  }
60
92
  export interface Ownership {
61
93
  owner_uid: string;
@@ -259,22 +291,47 @@ export interface AgentChannel {
259
291
  [k: string]: unknown;
260
292
  }
261
293
  /**
262
- * The chat platforms you can bind, staging-verified 2026-08-25.
294
+ * The chat platforms you can bind, staging-verified 2026-08-28.
295
+ *
296
+ * Three of them have a server-driven QR flow ({@link GuidedSetupPlatform}); Slack does not,
297
+ * and structurally cannot — a Slack app is created by a person and its tokens only ever exist
298
+ * in that person's browser, so {@link AddChannelInput} with `botToken` + `appToken` is its
299
+ * permanent path.
300
+ *
301
+ * WeChat is the one platform that goes the other way: `'weixin'`/`'wechat'` on
302
+ * {@link ZooworkClient.addChannel} answers `400 channel.weixin_setup_required`, so the QR flow
303
+ * is its ONLY path. See {@link AddChannelPlatform}. Any name outside this type answers
304
+ * `400 channel.invalid_request`.
305
+ */
306
+ export type ChannelPlatform = 'feishu' | 'slack' | 'wecom' | 'weixin';
307
+ /**
308
+ * The platforms {@link ZooworkClient.addChannel} accepts — every {@link ChannelPlatform}
309
+ * except WeChat, which refuses explicit config and takes the QR flow only.
310
+ */
311
+ export type AddChannelPlatform = 'feishu' | 'slack' | 'wecom';
312
+ /**
313
+ * The platforms with a server-driven QR flow: {@link ZooworkClient.startChannelSetup} →
314
+ * render the URI → poll. Slack is absent by design, not by omission.
263
315
  *
264
- * Only `'feishu'` has a server-driven QR flow here, and the two reasons the others lack one
265
- * are different. Slack structurally cannot have one — a Slack app is created by a person, and
266
- * its tokens only ever exist in that person's browser — so `addChannel` with `botToken` +
267
- * `appToken` is its permanent path. WeCom's flow exists in the product but is not exposed on
268
- * this API yet, so today it also binds through `addChannel`.
316
+ * The three differ in what the setup answer carries and in what the body may say
317
+ * (staging-verified 2026-08-28):
269
318
  *
270
- * WeChat (`'weixin'`/`'wechat'`) is absent because it cannot be bound here at all: it answers
271
- * `400 channel.weixin_setup_required`, naming a QR flow this API does not expose. Any other
272
- * name answers `400 channel.invalid_request`.
319
+ * - `feishu` answers `verification_uri_complete` and a `poll_interval`; takes `brand`,
320
+ * `account`, `dm_policy`, `group_policy`; `expires_in: 600`.
321
+ * - `wecom` — answers `qrcode_url` and NO `poll_interval` (you pick the cadence); takes
322
+ * `account`, `dm_policy`, `group_policy`; `expires_in: 300`.
323
+ * - `weixin` — answers `qrcode_url`, which may be a URL *or* an inline `data:image/…` payload;
324
+ * takes `dm_policy` only, `'open'` or `'disabled'` (the account is pinned to `'default'` and
325
+ * the group policy to `'disabled'` server-side); `expires_in: 300`.
273
326
  */
274
- export type ChannelPlatform = 'feishu' | 'slack' | 'wecom';
327
+ export type GuidedSetupPlatform = 'feishu' | 'wecom' | 'weixin';
275
328
  export interface AddChannelInput {
276
- /** See {@link ChannelPlatform}. Typed loosely so a newly supported platform needs no SDK release. */
277
- platform: ChannelPlatform | (string & {});
329
+ /**
330
+ * See {@link AddChannelPlatform}. Typed loosely so a newly supported platform needs no SDK
331
+ * release. `'weixin'`/`'wechat'` is refused here with `400 channel.weixin_setup_required` —
332
+ * use {@link ZooworkClient.startChannelSetup} instead.
333
+ */
334
+ platform: AddChannelPlatform | (string & {});
278
335
  /**
279
336
  * Names this binding. It is part of the record's identity, not a setting: `updateChannel`
280
337
  * and `removeChannel` find a binding by `platform` + `account`, and nothing renames one
@@ -324,8 +381,14 @@ export interface UpdateChannelInput {
324
381
  group_policy?: string;
325
382
  enabled?: boolean;
326
383
  }
327
- export interface FeishuSetupInput {
328
- /** `'feishu'` (default) or `'lark'` the international brand of the same platform. */
384
+ /**
385
+ * Body for {@link ZooworkClient.startChannelSetup}. Every field is optional, and each platform
386
+ * reads a different subset — see {@link GuidedSetupPlatform}. A field a platform does not read
387
+ * is ignored rather than rejected: `weixin` accepts an `account` in the body and still binds
388
+ * `'default'` (staging-verified 2026-08-28).
389
+ */
390
+ export interface ChannelSetupInput {
391
+ /** Feishu only: `'feishu'` (default) or `'lark'` — the international brand of the same platform. */
329
392
  brand?: 'feishu' | 'lark';
330
393
  /**
331
394
  * Names this binding. It is part of the record's identity, not a setting: `updateChannel`
@@ -351,37 +414,60 @@ export interface FeishuSetupInput {
351
414
  * works as-is: it matches the pattern and is unique per agent by construction.
352
415
  */
353
416
  account?: string;
354
- /** Server default: `'open'`. */
417
+ /**
418
+ * Server default: `'open'`. WeChat takes only `'open'` or `'disabled'` — `'allowlist'` is
419
+ * `400 channel.allowlist_unsupported` there, and `'pairing'` is
420
+ * `400 channel.pairing_unsupported` on every platform.
421
+ */
355
422
  dm_policy?: string;
356
- /** Server default: `'open'`. */
423
+ /** Server default: `'open'`. Ignored by WeChat, which forces `'disabled'`. */
357
424
  group_policy?: string;
358
425
  }
426
+ /** @deprecated Use {@link ChannelSetupInput}; this is the same shape under the old name. */
427
+ export type FeishuSetupInput = ChannelSetupInput;
359
428
  /**
360
- * A running Feishu QR registration. Render `verification_uri_complete` to the person
361
- * doing the binding (typically as a QR code), then poll with `pollFeishuSetup` /
362
- * `waitForFeishuSetup` until it leaves `pending`. The session expires after
363
- * `expires_in` seconds.
429
+ * A running QR registration. Show the person doing the binding whichever URI the platform
430
+ * answered `verification_uri_complete` for Feishu, `qrcode_url` for WeCom and WeChat, so
431
+ * `session.verification_uri_complete ?? session.qrcode_url` is the value to render then poll
432
+ * with {@link ZooworkClient.pollChannelSetup} / {@link ZooworkClient.waitForChannelSetup} until
433
+ * it leaves `pending`. The session expires after `expires_in` seconds.
434
+ *
435
+ * Two of the three are QR-only: WeCom's `qrcode_url` is a URL you encode yourself, and WeChat's
436
+ * may be a URL *or* an inline `data:image/…` payload you render directly, so check the prefix
437
+ * before you feed it to a QR encoder.
364
438
  */
365
- export interface FeishuSetupSession {
439
+ export interface ChannelSetupSession {
366
440
  session_id: string;
367
- verification_uri_complete: string;
441
+ /** Feishu only — the URI to encode into a QR code. */
442
+ verification_uri_complete?: string;
443
+ /** WeCom and WeChat — a URL to encode, or (WeChat) an inline `data:image/…` image. */
444
+ qrcode_url?: string;
368
445
  expires_in: number;
369
- /** Suggested seconds between polls; the server may omit it. */
446
+ /** Suggested seconds between polls. Feishu sends it; WeCom and WeChat never do. */
370
447
  poll_interval?: number | null;
371
448
  [k: string]: unknown;
372
449
  }
373
450
  /**
374
- * One poll of a Feishu setup session. The gateway's own vocabulary for `status` is
375
- * `pending | success | expired | denied | error`; treat anything unknown as
376
- * still-in-flight rather than throwing.
451
+ * A running Feishu QR registration {@link ChannelSetupSession} narrowed to the one platform
452
+ * that always answers `verification_uri_complete`.
377
453
  */
378
- export interface FeishuPollResult {
454
+ export interface FeishuSetupSession extends ChannelSetupSession {
455
+ verification_uri_complete: string;
456
+ }
457
+ /**
458
+ * One poll of a setup session. The gateway's own vocabulary for `status` is
459
+ * `pending | success | expired | denied | error` (`denied` is Feishu-only); treat anything
460
+ * unknown as still-in-flight rather than throwing.
461
+ */
462
+ export interface ChannelPollResult {
379
463
  status: string;
380
464
  channel_configured?: boolean;
381
465
  message?: string | null;
382
466
  poll_interval?: number | null;
383
467
  [k: string]: unknown;
384
468
  }
469
+ /** @deprecated Use {@link ChannelPollResult}; this is the same shape under the old name. */
470
+ export type FeishuPollResult = ChannelPollResult;
385
471
  export interface AgentRecord {
386
472
  agent_id: string;
387
473
  computer_id?: string;
@@ -1053,8 +1139,8 @@ export interface ZooworkClient {
1053
1139
  /**
1054
1140
  * Bind a channel from explicit platform config (the non-QR path) — `config` carries the
1055
1141
  * platform's own credential keys. Answers the created channel (HTTP 201). This is the ONLY
1056
- * path for Slack and WeCom; Feishu also has the QR flow. See {@link ChannelPlatform} for what
1057
- * binds and what does not.
1142
+ * path for Slack, an alternative to the QR flow for Feishu and WeCom, and refused outright
1143
+ * for WeChat (`400 channel.weixin_setup_required`). See {@link AddChannelPlatform}.
1058
1144
  *
1059
1145
  * **It is idempotent, not an upsert.** Re-posting an identical body for the same
1060
1146
  * `platform` + `account` answers `201` again and replays the binding you already have — it
@@ -1090,52 +1176,70 @@ export interface ZooworkClient {
1090
1176
  account?: string;
1091
1177
  }): Promise<void>;
1092
1178
  /**
1093
- * Start the Feishu/Lark QR registration. YOU own the UI: render
1094
- * `verification_uri_complete` (usually as a QR code) and drive the poll loop —
1095
- * `waitForFeishuSetup` does the loop part for you.
1179
+ * Start a QR registration on one of the three guided platforms. YOU own the UI: render the
1180
+ * URI the session answers (`verification_uri_complete` for Feishu, `qrcode_url` for WeCom and
1181
+ * WeChat) and drive the poll loop — {@link waitForChannelSetup} does the loop part for you.
1096
1182
  *
1097
- * Observed defaults: `expires_in: 600`, `poll_interval: 5`. `brand` picks the real host —
1098
- * `'feishu'` answers an `open.feishu.cn` URI, `'lark'` an `open.larksuite.com` one, so the
1099
- * brand has to match the workspace the person will approve it in.
1183
+ * What each platform reads from `input`, and what it answers, is in
1184
+ * {@link GuidedSetupPlatform}; the short version is that only Feishu takes `brand`, only
1185
+ * Feishu and WeCom take `account`, and only Feishu sends back a `poll_interval`.
1100
1186
  *
1101
- * Pick `account` before you show the QR. Approving the scan registers a NEW app in that
1102
- * Feishu workspace, and only then does the binding get written so a name clash surfaces as
1187
+ * For Feishu, `brand` picks the real host `'feishu'` answers an `open.feishu.cn` URI,
1188
+ * `'lark'` an `open.larksuite.com` one so it has to match the workspace the person will
1189
+ * approve it in.
1190
+ *
1191
+ * Pick `account` before you show the QR. On Feishu, approving the scan registers a NEW app in
1192
+ * that workspace, and only then does the binding get written — so a name clash surfaces as
1103
1193
  * `409 channel.conflict` AFTER someone has scanned, with the freshly registered app left
1104
1194
  * behind in their workspace. Retrying under the same name repeats both. See
1105
1195
  * {@link AddChannelInput.account} for how names are scoped.
1106
1196
  */
1107
- startFeishuSetup(agentId: string, input?: FeishuSetupInput): Promise<FeishuSetupSession>;
1197
+ startChannelSetup(agentId: string, platform: GuidedSetupPlatform, input?: ChannelSetupInput): Promise<ChannelSetupSession>;
1108
1198
  /**
1109
- * One poll of a setup session. `status: 'pending'` means keep going; a cancelled or expired
1110
- * session answers `404 channel.feishu_session_not_found` rather than a terminal status, so
1111
- * a hand-rolled loop must treat that 404 as an end condition, not as a transport error.
1199
+ * One poll of a setup session. `status: 'pending'` means keep going; a cancelled session
1200
+ * answers `404 channel.{platform}_session_not_found` rather than a terminal status, so a
1201
+ * hand-rolled loop must treat that 404 as an end condition, not as a transport error.
1112
1202
  */
1113
- pollFeishuSetup(agentId: string, sessionId: string): Promise<FeishuPollResult>;
1114
- /** Abandon a setup session. Afterwards polling it answers `404 channel.feishu_session_not_found`. */
1115
- cancelFeishuSetup(agentId: string, sessionId: string): Promise<void>;
1203
+ pollChannelSetup(agentId: string, platform: GuidedSetupPlatform, sessionId: string): Promise<ChannelPollResult>;
1204
+ /** Abandon a setup session. Afterwards polling it answers `404 channel.{platform}_session_not_found`. */
1205
+ cancelChannelSetup(agentId: string, platform: GuidedSetupPlatform, sessionId: string): Promise<void>;
1116
1206
  /**
1117
- * Poll a Feishu setup session until it leaves `pending`, then hand back that terminal poll.
1207
+ * Poll a setup session until it leaves `pending`, then hand back that terminal poll.
1118
1208
  * A status the server reports in the body — `success` / `expired` / `denied` / `error` — is
1119
1209
  * RETURNED, not thrown: "the person rejected it" is an outcome, not an exception.
1120
1210
  *
1121
1211
  * But a session can also stop existing, and then polling answers
1122
- * `404 channel.feishu_session_not_found`, which surfaces here as a thrown
1123
- * {@link ZooworkError} carrying that `type`. Confirmed for a cancelled session
1124
- * (staging 2026-08-25); whether a session that simply runs past `expires_in` reports
1125
- * `status: 'expired'` in a 200 or disappears into this 404 was NOT observed — handle both.
1212
+ * `404 channel.{platform}_session_not_found`, which surfaces here as a thrown
1213
+ * {@link ZooworkError} carrying that `type`. Confirmed for a cancelled session on all three
1214
+ * platforms (staging 2026-08-28); whether a session that simply runs past `expires_in`
1215
+ * reports `status: 'expired'` in a 200 or disappears into this 404 was NOT observed — handle
1216
+ * both.
1126
1217
  *
1127
- * Pacing follows the server's `poll_interval` when present (observed default 5s; the local
1128
- * fallback matches). The default budget is 600s, which is also the observed `expires_in` —
1129
- * pass the session's own value when you have it. On timeout it throws `status: 408` /
1130
- * `type: 'timeout'`; on abort, `status: 0` / `type: 'aborted'` both synthesized locally,
1131
- * and every in-flight poll is bounded the way {@link waitUntilRunning} bounds its polls.
1132
- * `onPoll` fires after every poll, terminal one included, for progress UI.
1218
+ * Pacing follows the server's `poll_interval` when present (Feishu sends 5s; WeCom and WeChat
1219
+ * send none and fall back to the same 5s). The default budget is 600s, which matches Feishu's
1220
+ * `expires_in` but is twice WeCom's and WeChat's 300s — pass the session's own value when you
1221
+ * have it. On timeout it throws `status: 408` / `type: 'timeout'`; on abort, `status: 0` /
1222
+ * `type: 'aborted'` — both synthesized locally, and every in-flight poll is bounded the way
1223
+ * {@link waitUntilRunning} bounds its polls. `onPoll` fires after every poll, terminal one
1224
+ * included, for progress UI.
1133
1225
  */
1226
+ waitForChannelSetup(agentId: string, platform: GuidedSetupPlatform, sessionId: string, opts?: {
1227
+ timeoutMs?: number;
1228
+ signal?: AbortSignal;
1229
+ onPoll?: (poll: ChannelPollResult) => void;
1230
+ }): Promise<ChannelPollResult>;
1231
+ /** Feishu-only spelling of {@link startChannelSetup}, kept for callers written against 0.3.x–0.4.x. */
1232
+ startFeishuSetup(agentId: string, input?: ChannelSetupInput): Promise<FeishuSetupSession>;
1233
+ /** Feishu-only spelling of {@link pollChannelSetup}. */
1234
+ pollFeishuSetup(agentId: string, sessionId: string): Promise<ChannelPollResult>;
1235
+ /** Feishu-only spelling of {@link cancelChannelSetup}. */
1236
+ cancelFeishuSetup(agentId: string, sessionId: string): Promise<void>;
1237
+ /** Feishu-only spelling of {@link waitForChannelSetup}. */
1134
1238
  waitForFeishuSetup(agentId: string, sessionId: string, opts?: {
1135
1239
  timeoutMs?: number;
1136
1240
  signal?: AbortSignal;
1137
- onPoll?: (poll: FeishuPollResult) => void;
1138
- }): Promise<FeishuPollResult>;
1241
+ onPoll?: (poll: ChannelPollResult) => void;
1242
+ }): Promise<ChannelPollResult>;
1139
1243
  /**
1140
1244
  * The agent's system-prompt pin and the rendered template in effect. Staging-verified
1141
1245
  * 2026-08-14 — a fresh agent answers a real `declaration` (`{source:'platform',version:1}`),
package/dist/client.js CHANGED
@@ -39,6 +39,11 @@ function stripTrailingSlashes(url) {
39
39
  end--;
40
40
  return end === url.length ? url : url.slice(0, end);
41
41
  }
42
+ /** Statuses whose failure class tends to pass on its own: timeout, throttle, gateway. */
43
+ const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
44
+ /** Bound on `ZooworkError.bodySnippet` — enough to keep a whole error envelope or the
45
+ * opening of an HTML error page, small enough to log unconditionally. */
46
+ const BODY_SNIPPET_LIMIT = 600;
42
47
  export class ZooworkError extends Error {
43
48
  status;
44
49
  /**
@@ -52,13 +57,87 @@ export class ZooworkError extends Error {
52
57
  * you only need the class of failure.
53
58
  */
54
59
  type;
55
- constructor(status, message, type) {
60
+ /**
61
+ * `Content-Type` of the error response. The field that tells an edge error page apart from an
62
+ * API answer: both envelopes above are `application/json`, while a gateway 502/504 arrives as
63
+ * `text/html` — the edge replaces the origin's body wholesale, so no JSON survives to parse
64
+ * (verified 2026-08-30 against both deployments;
65
+ * `notes/probes/system-message-cold-session-probe.mts`).
66
+ */
67
+ contentType;
68
+ /** First {@link BODY_SNIPPET_LIMIT} characters of the raw error body, whatever it was.
69
+ * Without it a non-JSON failure cannot be reconstructed from `status` alone. */
70
+ bodySnippet;
71
+ /**
72
+ * Cloudflare ray id (`cf-ray` response header), when the response crossed Cloudflare. It
73
+ * survives even the replaced-body case above — on an HTML 502 it is the only correlation id
74
+ * left, and the value to quote when reporting a gateway failure.
75
+ */
76
+ cfRay;
77
+ /** Correlation id from the error envelope (`request_id` on either vocabulary), when the
78
+ * server includes one. Usually absent today. */
79
+ requestId;
80
+ /**
81
+ * Transport-class transient hint: `true` for 408, 429, 502, 503 and 504. It says the failure
82
+ * CLASS tends to pass, not that a replay is safe — retrying a `postEvents` without an
83
+ * `idempotency_key` can still deliver twice. Pair it with idempotency keys before looping.
84
+ */
85
+ retryable;
86
+ constructor(status, message, type, extra) {
56
87
  super(message);
57
88
  this.name = 'ZooworkError';
58
89
  this.status = status;
59
90
  if (type)
60
91
  this.type = type;
92
+ if (extra?.contentType)
93
+ this.contentType = extra.contentType;
94
+ if (extra?.bodySnippet)
95
+ this.bodySnippet = extra.bodySnippet;
96
+ if (extra?.cfRay)
97
+ this.cfRay = extra.cfRay;
98
+ if (extra?.requestId)
99
+ this.requestId = extra.requestId;
100
+ this.retryable = extra?.retryable ?? RETRYABLE_STATUSES.has(status);
101
+ }
102
+ }
103
+ /** The response facts worth keeping on every transport-level ZooworkError, whatever the body. */
104
+ function responseForensics(res, text) {
105
+ return {
106
+ contentType: res.headers.get('content-type') ?? undefined,
107
+ cfRay: res.headers.get('cf-ray') ?? undefined,
108
+ bodySnippet: text ? text.slice(0, BODY_SNIPPET_LIMIT) : undefined,
109
+ };
110
+ }
111
+ /**
112
+ * Build the ZooworkError for a non-2xx response. Unpacks BOTH envelope vocabularies (see
113
+ * {@link ZooworkError.type}); when neither matches — typically an edge error page whose body
114
+ * replaced the origin's JSON — the message names status, content-type and ray id instead of a
115
+ * bare `HTTP 502`. Those three are what turn an "HTTP 502, type: undefined" report into an
116
+ * answerable one (2026-08-30). A structured `detail` object is deliberately NOT stringified
117
+ * into the message (`[object Object]`); it stays readable in `bodySnippet`.
118
+ */
119
+ function httpError(res, text) {
120
+ const forensics = responseForensics(res, text);
121
+ let msg;
122
+ let type;
123
+ let requestId;
124
+ try {
125
+ const j = JSON.parse(text);
126
+ const detail = typeof j?.detail === 'string' ? j.detail : undefined;
127
+ msg = j?.error?.message || j?.message || detail || undefined;
128
+ type = j?.error?.type ?? j?.code;
129
+ requestId = j?.error?.request_id ?? j?.request_id;
61
130
  }
131
+ catch {
132
+ /* non-JSON error body — usually the edge speaking, not the API */
133
+ }
134
+ if (!msg) {
135
+ msg =
136
+ `HTTP ${res.status}` +
137
+ (forensics.contentType ? ` (${forensics.contentType})` : '') +
138
+ (forensics.cfRay ? ` [cf-ray ${forensics.cfRay}]` : '');
139
+ }
140
+ return new ZooworkError(res.status, msg, type, { ...forensics, requestId });
62
141
  }
63
142
  /**
64
143
  * Create a client.
@@ -90,36 +169,22 @@ export function createZooworkClient(cfg = {}) {
90
169
  }
91
170
  const bearer = 'serviceToken' in auth ? auth.serviceToken : auth.apiKey;
92
171
  /**
93
- * TWO error envelopes, one ZooworkError shape, for every helper below.
94
- *
95
- * The API does not answer failures the same way everywhere — staging-verified 2026-08-07. Most
96
- * families send `{ error: { type, message } }`; the agents family sends `{ code, detail }`.
97
- * Reading only the first left every agent 404 with `type: undefined` and the message `HTTP 404`,
98
- * so both are unpacked here. The codes stay verbatim (`not_found` vs `service_api.not_found`) —
99
- * inventing a shared vocabulary would be this SDK guessing, which is what it exists not to do.
172
+ * TWO error envelopes, one ZooworkError shape, for every helper below — the unpacking (and
173
+ * why both vocabularies exist) lives in module-level {@link httpError}. The codes stay
174
+ * verbatim (`not_found` vs `service_api.not_found`) inventing a shared vocabulary would be
175
+ * this SDK guessing, which is what it exists not to do.
100
176
  */
101
177
  const readResponse = async (res, path) => {
102
178
  const text = await res.text();
103
- if (!res.ok) {
104
- let msg = `HTTP ${res.status}`;
105
- let type;
106
- try {
107
- const j = JSON.parse(text);
108
- msg = j?.error?.message || j?.message || j?.detail || msg;
109
- type = j?.error?.type ?? j?.code;
110
- }
111
- catch {
112
- /* non-JSON error body → keep clean status */
113
- }
114
- throw new ZooworkError(res.status, msg, type);
115
- }
179
+ if (!res.ok)
180
+ throw httpError(res, text);
116
181
  if (!text)
117
182
  return {};
118
183
  try {
119
184
  return JSON.parse(text);
120
185
  }
121
186
  catch {
122
- throw new ZooworkError(res.status, `non-JSON response: ${path}`);
187
+ throw new ZooworkError(res.status, `non-JSON response: ${path}`, undefined, responseForensics(res, text));
123
188
  }
124
189
  };
125
190
  /**
@@ -270,7 +335,7 @@ export function createZooworkClient(cfg = {}) {
270
335
  let lastSeen = 'unknown';
271
336
  const abortedError = () => new ZooworkError(0, `waitUntilRunning(${agentId}) aborted`, 'aborted');
272
337
  const timeoutError = () => new ZooworkError(408, `agent ${agentId} did not reach status.desired_state=running within ${timeoutMs}ms ` +
273
- `(last seen: ${lastSeen})`, 'timeout');
338
+ `(last seen: ${lastSeen})`, 'timeout', { retryable: false });
274
339
  for (;;) {
275
340
  if (opts.signal?.aborted)
276
341
  throw abortedError();
@@ -336,20 +401,21 @@ export function createZooworkClient(cfg = {}) {
336
401
  body: JSON.stringify({ account: opts.account ?? 'default' }),
337
402
  });
338
403
  },
339
- startFeishuSetup: (agentId, input = {}) => json(`${agents(agentId)}/channels/feishu/setup`, { method: 'POST', body: JSON.stringify(input) }),
340
- pollFeishuSetup: (agentId, sessionId) => json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`),
341
- cancelFeishuSetup: async (agentId, sessionId) => {
342
- await json(`${agents(agentId)}/channels/feishu/setup/cancel${query({ session_id: sessionId })}`, {
343
- method: 'POST',
344
- });
404
+ startChannelSetup: (agentId, platform, input = {}) => json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/setup`, {
405
+ method: 'POST',
406
+ body: JSON.stringify(input),
407
+ }),
408
+ pollChannelSetup: (agentId, platform, sessionId) => json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/poll${query({ session_id: sessionId })}`),
409
+ cancelChannelSetup: async (agentId, platform, sessionId) => {
410
+ await json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/setup/cancel${query({ session_id: sessionId })}`, { method: 'POST' });
345
411
  },
346
- waitForFeishuSetup: async (agentId, sessionId, opts = {}) => {
412
+ waitForChannelSetup: async (agentId, platform, sessionId, opts = {}) => {
347
413
  const timeoutMs = opts.timeoutMs ?? 600_000;
348
414
  const deadline = Date.now() + timeoutMs;
349
415
  let lastStatus = 'unknown';
350
- const abortedError = () => new ZooworkError(0, `waitForFeishuSetup(${agentId}, ${sessionId}) aborted`, 'aborted');
351
- const timeoutError = () => new ZooworkError(408, `Feishu setup session ${sessionId} still '${lastStatus}' after ${timeoutMs}ms — ` +
352
- 'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout');
416
+ const abortedError = () => new ZooworkError(0, `waitForChannelSetup(${agentId}, ${platform}, ${sessionId}) aborted`, 'aborted');
417
+ const timeoutError = () => new ZooworkError(408, `${platform} setup session ${sessionId} still '${lastStatus}' after ${timeoutMs}ms — ` +
418
+ 'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout', { retryable: false });
353
419
  for (;;) {
354
420
  if (opts.signal?.aborted)
355
421
  throw abortedError();
@@ -364,7 +430,7 @@ export function createZooworkClient(cfg = {}) {
364
430
  const budget = setTimeout(cancelPoll, remaining);
365
431
  let result;
366
432
  try {
367
- result = await json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`, { signal: poll.signal });
433
+ result = await json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/poll${query({ session_id: sessionId })}`, { signal: poll.signal });
368
434
  }
369
435
  catch (e) {
370
436
  if (poll.signal.aborted)
@@ -378,7 +444,7 @@ export function createZooworkClient(cfg = {}) {
378
444
  opts.onPoll?.(result);
379
445
  lastStatus = result.status ?? 'unknown';
380
446
  // Only a literal 'pending' keeps the loop alive… except that an UNKNOWN status is
381
- // treated as still-in-flight too (see FeishuPollResult): a new intermediate state on
447
+ // treated as still-in-flight too (see ChannelPollResult): a new intermediate state on
382
448
  // the server should stretch the wait, not end it with a fake terminal result.
383
449
  const terminal = ['success', 'expired', 'denied', 'error'].includes(lastStatus);
384
450
  if (terminal)
@@ -391,6 +457,12 @@ export function createZooworkClient(cfg = {}) {
391
457
  await sleep(intervalMs, opts.signal);
392
458
  }
393
459
  },
460
+ // The Feishu-only spellings, kept for callers written against 0.3.x–0.4.x. `startFeishuSetup`
461
+ // narrows the return type only — Feishu always answers `verification_uri_complete`.
462
+ startFeishuSetup: (agentId, input = {}) => client.startChannelSetup(agentId, 'feishu', input),
463
+ pollFeishuSetup: (agentId, sessionId) => client.pollChannelSetup(agentId, 'feishu', sessionId),
464
+ cancelFeishuSetup: (agentId, sessionId) => client.cancelChannelSetup(agentId, 'feishu', sessionId),
465
+ waitForFeishuSetup: (agentId, sessionId, opts = {}) => client.waitForChannelSetup(agentId, 'feishu', sessionId, opts),
394
466
  uploadSkill: (zip, opts) => {
395
467
  const form = skillForm(zip, opts);
396
468
  form.append('scope', opts.scope);
@@ -510,7 +582,7 @@ export function createZooworkClient(cfg = {}) {
510
582
  ...(opts.signal ? { signal: opts.signal } : {}),
511
583
  });
512
584
  if (!res.ok)
513
- throw new ZooworkError(res.status, `events stream HTTP ${res.status}`);
585
+ throw httpError(res, await res.text().catch(() => ''));
514
586
  if (!res.body)
515
587
  return;
516
588
  for await (const msg of parseSSE(res.body)) {
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { createZooworkClient, DEFAULT_BASE_URL, ZooworkError, type ZooworkClient, type ZooworkConfig, type ZooworkAuth, type Ownership, type ModelInfo, type AgentResource, type AgentRecord, type AgentStatus, type AgentSkill, type AgentChannel, type ChannelPlatform, type AddChannelInput, type UpdateChannelInput, type FeishuSetupInput, type FeishuSetupSession, type FeishuPollResult, type McpServerDeclaration, type SkillRecord, type SessionRecord, type SessionHistoryEntry, type SessionEvent, type SessionEventPage, type OutboundEvent, type PostEventReceipt, type ApprovalDecision, type ApprovalRecord, type ArtifactPage, type ArtifactRecord, type ArtifactStatus, type OutcomeConfig, type OutcomeEvaluator, type SystemPromptDeclaration, type SystemPromptInfo, type SystemPromptPreview, type SystemPromptPreviewInput, type SystemPromptUpgrade, type ScheduleSpec, type SchedulePayload, type ScheduleInput, type ScheduleUpdate, type ScheduleRecord, type ScheduleRun, type WakeResult, type ExecResult, type EnvironmentConfig, type EnvironmentResource, type EnvironmentRecord, type EnvironmentVersionRecord, } from './client.js';
1
+ export { createZooworkClient, DEFAULT_BASE_URL, ZooworkError, type ZooworkClient, type ZooworkConfig, type ZooworkAuth, type Ownership, type ModelInfo, type AgentResource, type AgentRecord, type AgentStatus, type AgentSkill, type AgentChannel, type ChannelPlatform, type AddChannelPlatform, type GuidedSetupPlatform, type AddChannelInput, type UpdateChannelInput, type ChannelSetupInput, type ChannelSetupSession, type ChannelPollResult, type FeishuSetupInput, type FeishuSetupSession, type FeishuPollResult, type McpServerDeclaration, type SkillRecord, type SessionRecord, type SessionHistoryEntry, type SessionEvent, type SessionEventPage, type OutboundEvent, type PostEventReceipt, type ApprovalDecision, type ApprovalRecord, type ArtifactPage, type ArtifactRecord, type ArtifactStatus, type OutcomeConfig, type OutcomeEvaluator, type SystemPromptDeclaration, type SystemPromptInfo, type SystemPromptPreview, type SystemPromptPreviewInput, type SystemPromptUpgrade, type ScheduleSpec, type SchedulePayload, type ScheduleInput, type ScheduleUpdate, type ScheduleRecord, type ScheduleRun, type WakeResult, type ExecResult, type EnvironmentConfig, type EnvironmentResource, type EnvironmentRecord, type EnvironmentVersionRecord, } from './client.js';
2
2
  export { SESSION_EVENT_TYPES, type SessionEventType, PUBLIC_INPUT_EVENT_TYPES, type PublicInputEventType, normalizeEvent, isRunFinished, runOutcome, messageText, assistantText, thinkingText, toolCall, type ToolCall, } from './events.js';
3
3
  export { parseSSE, type SSEMessage } from './sse.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoowork-ai/sdk",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "TypeScript SDK for the ZooWork Managed Agents API (Developer Preview)",
5
5
  "keywords": [
6
6
  "zoowork",